Initial release

This commit is contained in:
AtomAI-GR
2026-05-02 18:20:30 +03:00
commit 4c68e12443
19 changed files with 719 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
<div align="center">
# Atom_AI — Custom ComfyUI Nodes
**A clean, practical node pack for LoRA management and image enhancement.**
![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-brightgreen)
![Python](https://img.shields.io/badge/Python-3.10+-blue)
![License](https://img.shields.io/badge/License-MIT-yellow)
</div>
---
## Nodes
### 🔁 LoRA Loader
The simplest way to load multiple LoRAs in one node.
Add or remove slots with **+** / **−** buttons. Each slot has a strength slider and an enable/disable toggle.
### ⚙️ LoRA Loader Pro
Same as LoRA Loader but with separate **model strength** and **clip strength** per LoRA.
For advanced users who need fine control.
### 🔗 LoRA Stack
A chainable single LoRA slot. Connect multiple entries in sequence to build a stack of any size.
### 📦 LoRA Stack Apply
Takes the final stack and applies all LoRAs to model and clip in one pass.
### 🔍 LoRA Stack Info
Displays the contents of a LoRA stack as text — useful for debugging.
### ✨ Detail Enhance
Post-processing node that sharpens and enhances detail in generated images.
Controls: **Sharpen**, **Detail**, **Contrast**, **Clarity**
---
## Installation
**Option A — ComfyUI Manager** *(recommended)*
Search for `Atom_AI` in the Manager and install directly.
**Option B — Manual**
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/AtomAI-GR/Atom_AI
```
Restart ComfyUI.
---
## Usage
### Basic LoRA workflow
```
Load Checkpoint
├── MODEL ──→ LoRA Loader ──→ model ──→ KSampler
└── CLIP ──→ LoRA Loader ──→ clip ──→ CLIP Text Encode
```
### With Detail Enhance
```
VAE Decode ──→ IMAGE ──→ Detail Enhance ──→ Preview Image
```
---
## Compatibility
Works with all major model architectures: **SD 1.5 · SDXL · Flux · HiDream**
---
## License
MIT — free to use, modify, and share.
---
<div align="center">
Made with ❤️ by <a href="https://github.com/AtomAI-GR">Atom_AI</a>
</div>
+15
View File
@@ -0,0 +1,15 @@
from .nodes.detail_enhance import NODE_CLASS_MAPPINGS as ENHANCE_NODES
from .nodes.detail_enhance import NODE_DISPLAY_NAME_MAPPINGS as ENHANCE_NAMES
from .nodes.lora_stack import NODE_CLASS_MAPPINGS as LORA_NODES
from .nodes.lora_stack import NODE_DISPLAY_NAME_MAPPINGS as LORA_NAMES
from .nodes.lora_loader_pro import NODE_CLASS_MAPPINGS as DYN_NODES
from .nodes.lora_loader_pro import NODE_DISPLAY_NAME_MAPPINGS as DYN_NAMES
from .nodes.lora_loader import NODE_CLASS_MAPPINGS as SIMPLE_NODES
from .nodes.lora_loader import NODE_DISPLAY_NAME_MAPPINGS as SIMPLE_NAMES
NODE_CLASS_MAPPINGS = {**ENHANCE_NODES, **LORA_NODES, **DYN_NODES, **SIMPLE_NODES}
NODE_DISPLAY_NAME_MAPPINGS = {**ENHANCE_NAMES, **LORA_NAMES, **DYN_NAMES, **SIMPLE_NAMES}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
import torch
import numpy as np
from PIL import Image, ImageFilter, ImageEnhance
class DetailEnhanceNode:
CATEGORY = "Atom_AI/Image"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("enhanced_image",)
FUNCTION = "enhance"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"sharpen_strength": ("FLOAT", {
"default": 1.5, "min": 0.0, "max": 5.0,
"step": 0.1, "display": "slider"
}),
"detail_radius": ("FLOAT", {
"default": 1.0, "min": 0.5, "max": 5.0,
"step": 0.5, "display": "slider"
}),
"detail_strength": ("FLOAT", {
"default": 0.4, "min": 0.0, "max": 2.0,
"step": 0.1, "display": "slider"
}),
"contrast": ("FLOAT", {
"default": 1.1, "min": 0.5, "max": 2.0,
"step": 0.05, "display": "slider"
}),
"clarity": ("FLOAT", {
"default": 0.3, "min": 0.0, "max": 1.0,
"step": 0.05, "display": "slider"
}),
}
}
@staticmethod
def _to_pil(t):
arr = (t[0].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr)
@staticmethod
def _to_tensor(img):
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def enhance(self, image, sharpen_strength, detail_radius,
detail_strength, contrast, clarity):
pil = self._to_pil(image)
if contrast != 1.0:
pil = ImageEnhance.Contrast(pil).enhance(contrast)
if sharpen_strength != 1.0:
pil = ImageEnhance.Sharpness(pil).enhance(sharpen_strength)
if detail_strength > 0:
blurred = pil.filter(ImageFilter.GaussianBlur(radius=detail_radius))
img_arr = np.array(pil, dtype=np.float32)
blur_arr = np.array(blurred, dtype=np.float32)
detail = img_arr - blur_arr
result = img_arr + detail * detail_strength
pil = Image.fromarray(result.clip(0, 255).astype(np.uint8))
if clarity > 0:
arr = np.array(pil, dtype=np.float32) / 255.0
mid = 0.5
arr = arr + clarity * 0.15 * np.sin((arr - mid) * np.pi) * (1 - np.abs(arr - mid) * 2).clip(0)
pil = Image.fromarray((arr.clip(0, 1) * 255).astype(np.uint8))
return (self._to_tensor(pil),)
NODE_CLASS_MAPPINGS = {"DetailEnhanceNode": DetailEnhanceNode}
NODE_DISPLAY_NAME_MAPPINGS = {"DetailEnhanceNode": "Detail Enhance"}
+74
View File
@@ -0,0 +1,74 @@
import folder_paths
import comfy.utils
import comfy.sd
import json
def get_lora_list():
return ["None"] + folder_paths.get_filename_list("loras")
class AtomSimpleLoRANode:
"""
Simple LoRA Loader — clean UI, no clip str., strength 0-2.
"""
CATEGORY = "Atom_AI/LoRA"
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("model", "clip", "stack_info")
FUNCTION = "apply_loras"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"loras_json": ("STRING", {
"default": "[]",
"multiline": False,
}),
}
}
def apply_loras(self, model, clip, loras_json):
try:
entries = json.loads(loras_json)
except Exception:
return (model, clip, "Error: invalid loras_json")
info_lines = []
loaded = 0
for entry in entries:
name = entry.get("name", "None")
s_mod = float(entry.get("strength_model", 1.0))
enabled = bool(entry.get("enabled", True))
if not enabled or name == "None":
continue
lora_path = folder_paths.get_full_path("loras", name)
if lora_path is None:
info_lines.append(f" ✗ {name} (not found)")
continue
lora_data = comfy.utils.load_torch_file(lora_path, safe_load=True)
# clip strength fixed at 1.0
model, clip = comfy.sd.load_lora_for_models(
model, clip, lora_data, s_mod, 1.0
)
info_lines.append(f" ✓ {name} (strength={s_mod:.2f})")
loaded += 1
total = len(entries)
header = f"Loaded {loaded} of {total} LoRA(s):"
info = header + "\n" + "\n".join(info_lines) if info_lines else header
return (model, clip, info)
NODE_CLASS_MAPPINGS = {"AtomSimpleLoRANode": AtomSimpleLoRANode}
NODE_DISPLAY_NAME_MAPPINGS = {"AtomSimpleLoRANode": "LoRA Loader"}
+81
View File
@@ -0,0 +1,81 @@
import folder_paths
import comfy.utils
import comfy.sd
def get_lora_list():
return ["None"] + folder_paths.get_filename_list("loras")
class AtomDynamicLoRANode:
"""
Dynamic LoRA Loader — slots managed entirely by JS.
Python receives lora_count + flat arrays of name/strength_model/strength_clip/enabled.
"""
CATEGORY = "Atom_AI/LoRA"
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("model", "clip", "stack_info")
FUNCTION = "apply_loras"
# Tell ComfyUI this node has dynamic widgets — skip validation
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("nan")
@classmethod
def INPUT_TYPES(cls):
loras = get_lora_list()
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
# JS writes all LoRA data as a JSON string — one input only
"loras_json": ("STRING", {
"default": "[]",
"multiline": False,
}),
}
}
def apply_loras(self, model, clip, loras_json):
import json
try:
entries = json.loads(loras_json)
except Exception:
return (model, clip, "Error: invalid loras_json")
info_lines = []
loaded = 0
for entry in entries:
name = entry.get("name", "None")
s_mod = float(entry.get("strength_model", 1.0))
s_clip = float(entry.get("strength_clip", 1.0))
enabled = bool(entry.get("enabled", True))
if not enabled or name == "None":
continue
lora_path = folder_paths.get_full_path("loras", name)
if lora_path is None:
info_lines.append(f" ✗ {name} (not found)")
continue
lora_data = comfy.utils.load_torch_file(lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(
model, clip, lora_data, s_mod, s_clip
)
info_lines.append(
f" ✓ {name}\n model={s_mod:.2f} clip={s_clip:.2f}"
)
loaded += 1
total = len(entries)
header = f"Loaded {loaded} of {total} LoRA(s):"
info = header + "\n" + "\n".join(info_lines) if info_lines else header
return (model, clip, info)
NODE_CLASS_MAPPINGS = {"AtomDynamicLoRANode": AtomDynamicLoRANode}
NODE_DISPLAY_NAME_MAPPINGS = {"AtomDynamicLoRANode": "LoRA Loader Pro"}
+165
View File
@@ -0,0 +1,165 @@
import os
import folder_paths
# ──────────────────────────────────────────────
# Helper: get all available LoRA filenames
# ──────────────────────────────────────────────
def get_lora_list():
loras = folder_paths.get_filename_list("loras")
return ["None"] + loras
# ══════════════════════════════════════════════
# NODE 1 — LoRA Stack Entry
# One slot: pick a LoRA + strength + toggle.
# Chains into a growing stack list.
# ══════════════════════════════════════════════
class AtomLoRAStackEntry:
"""
A single chainable LoRA slot.
Connect multiple entries in sequence to build a stack of any size.
"""
CATEGORY = "Atom_AI/LoRA"
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("lora_stack",)
FUNCTION = "add_lora"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_name": (get_lora_list(),),
"strength_model": ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0,
"step": 0.01, "display": "slider"
}),
"strength_clip": ("FLOAT", {
"default": 1.0, "min": -10.0, "max": 10.0,
"step": 0.01, "display": "slider"
}),
"enabled": ("BOOLEAN", {"default": True}),
},
"optional": {
"lora_stack": ("LORA_STACK",), # chain input from previous entry
}
}
def add_lora(self, lora_name, strength_model, strength_clip, enabled,
lora_stack=None):
# Start from existing stack or fresh list
stack = list(lora_stack) if lora_stack else []
if enabled and lora_name != "None":
stack.append({
"lora_name": lora_name,
"strength_model": strength_model,
"strength_clip": strength_clip,
})
return (stack,)
# ══════════════════════════════════════════════
# NODE 2 — LoRA Stack Loader
# Takes the final stack list, applies every
# LoRA in sequence to model + clip.
# ══════════════════════════════════════════════
class AtomLoRAStackLoader:
"""
Applies a LORA_STACK (built from AtomLoRAStackEntry nodes) to
a model and clip in one pass.
"""
CATEGORY = "Atom_AI/LoRA"
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("model", "clip", "stack_info")
FUNCTION = "load_stack"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"lora_stack": ("LORA_STACK",),
}
}
def load_stack(self, model, clip, lora_stack):
import comfy.utils
if not lora_stack:
return (model, clip, "No LoRAs loaded.")
info_lines = [f"Loaded {len(lora_stack)} LoRA(s):"]
for entry in lora_stack:
name = entry["lora_name"]
s_mod = entry["strength_model"]
s_clip = entry["strength_clip"]
lora_path = folder_paths.get_full_path("loras", name)
lora_data = comfy.utils.load_torch_file(lora_path, safe_load=True)
model, clip = comfy.sd.load_lora_for_models(
model, clip, lora_data, s_mod, s_clip
)
info_lines.append(f" ✓ {name} (model={s_mod:.2f}, clip={s_clip:.2f})")
return (model, clip, "\n".join(info_lines))
# ══════════════════════════════════════════════
# NODE 3 — LoRA Stack Inspector (bonus)
# Shows what's in the stack without applying.
# ══════════════════════════════════════════════
class AtomLoRAStackInspector:
"""
Displays the contents of a LORA_STACK as a human-readable string.
Useful for debugging complex chains.
"""
CATEGORY = "Atom_AI/LoRA"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("stack_info",)
FUNCTION = "inspect"
OUTPUT_NODE = True # allows display in UI
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"lora_stack": ("LORA_STACK",),
}
}
def inspect(self, lora_stack):
if not lora_stack:
return ("Stack is empty.",)
lines = [f"Stack contains {len(lora_stack)} LoRA(s):\n"]
for i, entry in enumerate(lora_stack, 1):
lines.append(
f"{i}. {entry['lora_name']}\n"
f" model strength : {entry['strength_model']:.3f}\n"
f" clip strength : {entry['strength_clip']:.3f}"
)
return ("\n".join(lines),)
# ──────────────────────────────────────────────
# ComfyUI registration
# ──────────────────────────────────────────────
NODE_CLASS_MAPPINGS = {
"AtomLoRAStackEntry": AtomLoRAStackEntry,
"AtomLoRAStackLoader": AtomLoRAStackLoader,
"AtomLoRAStackInspector": AtomLoRAStackInspector,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AtomLoRAStackEntry": "LoRA Stack",
"AtomLoRAStackLoader": "LoRA Stack Apply",
"AtomLoRAStackInspector": "LoRA Stack Info",
}
+138
View File
@@ -0,0 +1,138 @@
import { app } from "/scripts/app.js";
const DEFAULT_SLOTS = 1;
function serializeSlots(node) {
const entries = (node._loraSlots ?? []).map(s => ({
name: s.nameW.value,
strength_model: s.smW.value,
strength_clip: s.scW.value,
enabled: s.enW.value,
}));
const jw = node.widgets?.find(w => w.name === "loras_json");
if (jw) jw.value = JSON.stringify(entries);
}
function addSlot(node, def = {}) {
const loras = node._loraNames ?? ["None"];
const nameW = node.addWidget("combo", "lora", def.name ?? "None",
() => serializeSlots(node), { values: loras });
const smW = node.addWidget("number", "model str.", def.strength_model ?? 1.0,
() => serializeSlots(node), { min: -10, max: 10, step: 0.1, precision: 2 });
const scW = node.addWidget("number", "clip str.", def.strength_clip ?? 1.0,
() => serializeSlots(node), { min: -10, max: 10, step: 0.1, precision: 2 });
const enW = node.addWidget("toggle", "enabled", def.enabled ?? true,
() => serializeSlots(node));
node._loraSlots = node._loraSlots ?? [];
node._loraSlots.push({ nameW, smW, scW, enW });
serializeSlots(node);
node.setSize([node.size[0], node.computeSize()[1]]);
node.graph?.setDirtyCanvas(true, true);
}
function removeSlot(node) {
if (!node._loraSlots?.length) return;
const s = node._loraSlots.pop();
for (const w of [s.nameW, s.smW, s.scW, s.enW]) {
const i = node.widgets.indexOf(w);
if (i !== -1) node.widgets.splice(i, 1);
}
serializeSlots(node);
node.setSize([node.size[0], node.computeSize()[1]]);
node.graph?.setDirtyCanvas(true, true);
}
function init(node) {
if (node._atomDynInit) return;
node._atomDynInit = true;
node._loraSlots = [];
// Hide loras_json
const jw = node.widgets?.find(w => w.name === "loras_json");
if (jw) {
jw.hidden = true;
jw.computeSize = () => [0, -4];
}
node.addWidget("button", "+ Add LoRA", null, () => addSlot(node));
node.addWidget("button", "− Remove LoRA", null, () => removeSlot(node));
// Restore from saved JSON or add defaults
let restored = false;
if (jw?.value && jw.value !== "[]") {
try {
const entries = JSON.parse(jw.value);
if (entries.length > 0) {
entries.forEach(e => addSlot(node, e));
restored = true;
}
} catch {}
}
if (!restored) {
for (let i = 0; i < DEFAULT_SLOTS; i++) addSlot(node);
}
}
// Fetch lora list directly from ComfyUI's /object_info endpoint
async function fetchLoraNames() {
try {
const r = await fetch("/object_info");
const all = await r.json();
// Try every known lora loader node name
const candidates = [
"LoraLoader",
"LoRALoader",
"Load LoRA",
"LoraLoaderModelOnly",
];
for (const name of candidates) {
const def = all[name];
if (!def) continue;
// Search all required inputs for a list that looks like lora filenames
for (const [, val] of Object.entries(def.input?.required ?? {})) {
if (Array.isArray(val[0]) && val[0].some(v =>
typeof v === "string" && (v.endsWith(".safetensors") || v.endsWith(".pt") || v.endsWith(".ckpt"))
)) {
return ["None", ...val[0]];
}
}
}
// Last resort: scan ALL node defs for a safetensors list
for (const def of Object.values(all)) {
for (const [, val] of Object.entries(def.input?.required ?? {})) {
if (Array.isArray(val[0]) && val[0].length > 0 &&
val[0].some(v => typeof v === "string" && v.includes("lora"))) {
return ["None", ...val[0]];
}
}
}
} catch (e) {
console.warn("[Atom_AI] Could not fetch lora list:", e);
}
return ["None"];
}
app.registerExtension({
name: "Atom_AI.DynamicLoRANode",
async setup() {
// Fetch and cache lora list at startup
app._atomLoraNames = await fetchLoraNames();
console.log("[Atom_AI] LoRA list loaded:", app._atomLoraNames.length, "entries");
},
nodeCreated(node) {
if (node.comfyClass !== "AtomDynamicLoRANode") return;
node._loraNames = app._atomLoraNames ?? ["None"];
init(node);
},
});
+84
View File
@@ -0,0 +1,84 @@
import { app } from "/scripts/app.js";
const DEFAULT_SLOTS = 2;
function serializeSlots(node) {
const entries = (node._loraSlots ?? []).map(s => ({
name: s.nameW.value,
strength_model: s.smW.value,
enabled: s.enW.value,
}));
const jw = node.widgets?.find(w => w.name === "loras_json");
if (jw) jw.value = JSON.stringify(entries);
}
function addSlot(node, def = {}) {
const loras = node._loraNames ?? ["None"];
const nameW = node.addWidget("combo", "lora", def.name ?? "None",
() => serializeSlots(node), { values: loras });
const smW = node.addWidget("number", "strength", def.strength_model ?? 1.0,
() => serializeSlots(node), { min: 0.0, max: 2.0, step: 0.1, precision: 2 });
const enW = node.addWidget("toggle", "enabled", def.enabled ?? true,
() => serializeSlots(node));
node._loraSlots = node._loraSlots ?? [];
node._loraSlots.push({ nameW, smW, enW });
serializeSlots(node);
node.setSize([node.size[0], node.computeSize()[1]]);
node.graph?.setDirtyCanvas(true, true);
}
function removeSlot(node) {
if (!node._loraSlots?.length) return;
const s = node._loraSlots.pop();
for (const w of [s.nameW, s.smW, s.enW]) {
const i = node.widgets.indexOf(w);
if (i !== -1) node.widgets.splice(i, 1);
}
serializeSlots(node);
node.setSize([node.size[0], node.computeSize()[1]]);
node.graph?.setDirtyCanvas(true, true);
}
function init(node) {
if (node._atomSimpleInit) return;
node._atomSimpleInit = true;
node._loraSlots = [];
const jw = node.widgets?.find(w => w.name === "loras_json");
if (jw) {
jw.hidden = true;
jw.computeSize = () => [0, -4];
}
node.addWidget("button", "+ Add LoRA", null, () => addSlot(node));
node.addWidget("button", "− Remove LoRA", null, () => removeSlot(node));
let restored = false;
if (jw?.value && jw.value !== "[]") {
try {
const entries = JSON.parse(jw.value);
if (entries.length > 0) {
entries.forEach(e => addSlot(node, e));
restored = true;
}
} catch {}
}
if (!restored) {
for (let i = 0; i < DEFAULT_SLOTS; i++) addSlot(node);
}
}
app.registerExtension({
name: "Atom_AI.SimpleLoRANode",
nodeCreated(node) {
if (node.comfyClass !== "AtomSimpleLoRANode") return;
node._loraNames = app._atomLoraNames ?? ["None"];
init(node);
},
});