Ajout clone, blur, classement des nodes
This commit is contained in:
Bouletto
2025-09-03 10:31:30 +02:00
parent c88d3fba2b
commit 00988810f4
10 changed files with 1531 additions and 4 deletions
+23
View File
@@ -33,6 +33,15 @@ from .svg_load import SVGLoad
from .folder_file_pro import FolderFilePro
from .path_to_image import PathToImage
from .load_image_pro import LoadImagePro
from .dao_clone_grid import DAOCloneGrid
from .dao_clone_circular import DAOCloneCircular
from .dao_clone_circular_path import DAOCloneCircularPath
from .dao_clone_grid_path import DAOCloneGridPath
from .mosaic_nodes import (
MosaicTileExport,
MosaicTileAssemble,
MosaicAssembleFromFolder,
)
# Dictionnaires de mapping
NODE_CLASS_MAPPINGS = {
@@ -66,6 +75,13 @@ NODE_CLASS_MAPPINGS = {
"Folder File Pro": FolderFilePro,
"Path To Image": PathToImage,
"Load Image Pro": LoadImagePro,
"DAO Clone Grid": DAOCloneGrid,
"DAO Clone Circular": DAOCloneCircular,
"DAO Clone Circular Path": DAOCloneCircularPath,
"DAO Clone Grid Path": DAOCloneGridPath,
"MosaicTileExport": MosaicTileExport,
"MosaicTileAssemble": MosaicTileAssemble,
"MosaicAssembleFromFolder": MosaicAssembleFromFolder,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -99,6 +115,13 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"Folder File Pro": "Folder File Pro (dir → file_path)",
"Path To Image": "Path → Image (+RGBA/Mask/Meta)",
"Load Image Pro": "Load Image Pro (Path/Image → RGB/RGBA/Mask/Upscale)",
"DAO Clone Grid": "Clone Grid (X/Y)",
"DAO Clone Circular": "Clone Circular",
"DAO Clone Circular Path": "Clone Circular (Path)",
"DAO Clone Grid Path": "Clone Grid (Path)",
"MosaicTileExport": "Mosaic: Tile & Export",
"MosaicTileAssemble": "Mosaic: Assemble (Batch)",
"MosaicAssembleFromFolder": "Mosaic: Assemble (Folder)",
}
WEB_DIRECTORY = "./web"
+1 -1
View File
@@ -95,7 +95,7 @@ def _parse_hex_color(s: str):
# ---------- NODE ----------
class DAOBlur:
CATEGORY = "DAO_master/Filter"
CATEGORY = "DAO_master/Images/Filter"
FUNCTION = "apply"
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
RETURN_NAMES = ("image", "mask", "drop_shadow")
+266
View File
@@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
# ComfyUI_DAO_master / dao_clone_circular.py
#
# DAO Clone Circular
# - Centre automatiquement l’anneau au milieu du canvas.
# - radius = distance du centre aux clones.
# - rotate = rotation globale (phase) de l’anneau, en degrés.
# - object_rotation = rotation de chaque sprite autour de lui-même.
# - use_background (BOOLEAN) + background_hex (#RGB, #RRGGBB, #RRGGBBAA, "white", "black", "transparent").
# - Entrée mask (optionnelle) pour découper le sprite source.
# - Sortie mask = union des clones.
#
# Sorties:
# IMAGE: [1,H,W,4] en 0..1
# MASK : [1,H,W] en 0..1
#
# Dépendances: Pillow, numpy, torch
import math
from typing import Optional
from PIL import Image, ImageChops
import numpy as np
import torch
# ---------- Utils robustes ----------
def _image_to_rgba_pil(t: torch.Tensor) -> Image.Image:
"""
Accepte: [B,H,W,C], [H,W,C], [C,H,W] (C=1/3/4), 0..1
Retourne PIL RGBA.
"""
if t is None:
raise ValueError("Image tensor is None")
if t.dim() == 4: # [B,H,W,C]
t = t[0]
if t.dim() != 3:
raise ValueError("Expected 3D or 4D tensor for image")
# [C,H,W] -> [H,W,C]
if t.shape[0] in (1, 3, 4) and (t.shape[-1] not in (1, 3, 4)):
t = t.permute(1, 2, 0)
if t.shape[-1] not in (1, 3, 4):
raise ValueError(f"Unsupported channel count: {t.shape[-1]}")
arr = t.detach().cpu().float().clamp(0, 1).numpy() # [H,W,C]
if arr.shape[-1] == 1: # gray -> RGBA
arr = np.repeat(arr, 3, axis=-1)
a = np.ones((*arr.shape[:2], 1), dtype=arr.dtype)
arr = np.concatenate([arr, a], axis=-1)
elif arr.shape[-1] == 3: # RGB -> RGBA
a = np.ones((*arr.shape[:2], 1), dtype=arr.dtype)
arr = np.concatenate([arr, a], axis=-1)
u8 = (arr * 255.0 + 0.5).astype(np.uint8)
return Image.fromarray(u8, mode="RGBA")
def _mask_to_L(mask_t: Optional[torch.Tensor], size) -> Optional[Image.Image]:
"""
MASK attendu: [H,W] ou [1,H,W] ou [B,H,W], valeurs 0..1
Retourne PIL 'L' 0..255 de la taille demandée (redimensionné si besoin).
"""
if mask_t is None:
return None
t = mask_t
if t.dim() == 3: # [B,H,W] ou [1,H,W]
t = t[0]
if t.dim() != 2:
raise ValueError("Mask must be 2D or 3D [1,H,W]")
arr = t.detach().cpu().float().clamp(0, 1).numpy() # [H,W]
u8 = (arr * 255.0 + 0.5).astype(np.uint8)
m = Image.fromarray(u8, mode="L")
if m.size != size:
m = m.resize(size, Image.LANCZOS)
return m
def _rgba_pil_to_tensor(img: Image.Image) -> torch.Tensor:
if img.mode != "RGBA":
img = img.convert("RGBA")
arr = np.array(img).astype(np.float32) / 255.0 # [H,W,4]
return torch.from_numpy(arr).unsqueeze(0) # [1,H,W,4]
def _maskL_to_tensor(maskL: Image.Image) -> torch.Tensor:
arr = np.array(maskL).astype(np.float32) / 255.0 # [H,W]
return torch.from_numpy(arr).unsqueeze(0) # [1,H,W]
def _parse_hex(color: str):
"""
Accepte #RGB, #RRGGBB, #RRGGBBAA, et noms: white, black, transparent, none
Retourne (r,g,b,a) en 0..255
"""
if not color:
return (0, 0, 0, 0)
s = color.strip().lower()
named = {
"white": "#ffffff",
"black": "#000000",
"transparent": "#00000000",
"none": "#00000000",
}
if s in named:
s = named[s]
if not s.startswith("#"):
s = "#" + s
# #RGB -> #RRGGBB
if len(s) == 4:
s = "#" + "".join(ch * 2 for ch in s[1:])
if len(s) == 7: # #RRGGBB
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = 255
elif len(s) == 9: # #RRGGBBAA
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = int(s[7:9], 16)
else:
r, g, b, a = 0, 0, 0, 0
return (r, g, b, a)
def _make_canvas(w: int, h: int, use_bg: bool, bg_hex: str) -> Image.Image:
return Image.new("RGBA", (w, h), _parse_hex(bg_hex) if use_bg else (0, 0, 0, 0))
def _transform_sprite(sprite_rgba: Image.Image, mask_L: Optional[Image.Image],
scale: float, object_rotation: float, opacity: float) -> Image.Image:
"""
Applique: mask (multiplie alpha), scale, rotation objet, opacity.
Retourne un RGBA prêt à coller.
"""
img = sprite_rgba
# 1) appliquer mask sur alpha si fourni
if mask_L is not None:
r, g, b, a = img.split()
# multiply alpha by mask (redimensionnée au sprite)
if mask_L.size != img.size:
m = mask_L.resize(img.size, Image.LANCZOS)
else:
m = mask_L
a = ImageChops.multiply(a, m)
img = Image.merge("RGBA", (r, g, b, a))
# 2) scale
if scale != 1.0:
sw, sh = img.size
img = img.resize((max(1, int(sw * scale)), max(1, int(sh * scale))), Image.LANCZOS)
# 3) rotation objet
if object_rotation != 0.0:
img = img.rotate(object_rotation, expand=True, resample=Image.BICUBIC)
# 4) opacity
if opacity < 1.0:
r, g, b, a = img.split()
a = a.point(lambda v: int(v * opacity))
img = Image.merge("RGBA", (r, g, b, a))
return img
# --------------- NODE: DAO Clone Circular ---------------
class DAOCloneCircular:
"""
Clone un sprite sur un cercle centré au canvas.
- Le centre est (canvas_width/2, canvas_height/2).
- `radius` est la distance du centre aux clones.
- `rotate` décale l'anneau (phase) en degrés.
- `object_rotation` fait tourner chaque sprite sur lui-même.
- Entrée optionnelle MASK pour découper le sprite source.
- Sorties: IMAGE (RGBA) + MASK (union des clones).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
},
"optional": {
"mask": ("MASK",),
"canvas_width": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"canvas_height": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"use_background": ("BOOLEAN", {"default": False}),
"background_hex": ("STRING", {"default": "#00000000"}),
"radius": ("FLOAT", {"default": 300.0, "min": 0.0, "max": 100000.0, "step": 1.0}),
"count": ("INT", {"default": 12, "min": 1, "max": 20000}),
"rotate": ("FLOAT", {"default": 0.0, "min": -1440.0, "max": 1440.0, "step": 0.1}),
"object_rotation": ("FLOAT", {"default": 0.0, "min": -1440.0, "max": 1440.0, "step": 0.1}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "run"
CATEGORY = "DAO_master/Images/Clone"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def run(
self,
image: torch.Tensor,
mask: Optional[torch.Tensor] = None,
canvas_width: int = 1024,
canvas_height: int = 1024,
use_background: bool = False,
background_hex: str = "#00000000",
radius: float = 300.0,
count: int = 12,
rotate: float = 0.0,
object_rotation: float = 0.0,
scale: float = 1.0,
opacity: float = 1.0,
):
sprite_rgba = _image_to_rgba_pil(image)
mask_L_src = _mask_to_L(mask, sprite_rgba.size)
base = _make_canvas(canvas_width, canvas_height, use_background, background_hex)
mask_canvas = Image.new("L", (canvas_width, canvas_height), 0)
if count > 50000:
raise ValueError("Trop de clones (limite 50k)")
# centre du canvas
cx = canvas_width / 2.0
cy = canvas_height / 2.0
# Pré-transformations invariantes pour tous les clones
base_sprite = _transform_sprite(
sprite_rgba, mask_L_src, scale=scale, object_rotation=object_rotation, opacity=opacity
)
sw, sh = base_sprite.size
# distribution angulaire uniforme 0..360 + phase 'rotate'
for i in range(count):
ang = (i / count) * 360.0 + rotate
rad = math.radians(ang)
x = cx + radius * math.cos(rad) - sw / 2.0
y = cy + radius * math.sin(rad) - sh / 2.0
# coller RGBA
base.alpha_composite(base_sprite, (int(x), int(y)))
# construire un alpha placé pour le mask de sortie
_, _, _, a = base_sprite.split()
placed = Image.new("L", (canvas_width, canvas_height), 0)
placed.paste(a, (int(x), int(y)), a)
mask_canvas = ImageChops.lighter(mask_canvas, placed) # union (max)
out_img = _rgba_pil_to_tensor(base)
out_mask = _maskL_to_tensor(mask_canvas)
return (out_img, out_mask)
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
# ComfyUI_DAO_master / dao_clone_circular_path.py
#
# DAO Clone Circular Path
# - Charge des PNG/JPG d'un dossier (tri alpha), 1 image par clone
# - Boucle si pas assez, tronque si trop
# - shuffle + seed pour ordre aléatoire reproductible
# - Anneau centré: radius, count, rotate (phase), object_rotation (par sprite), scale, opacity
# - use_background (BOOLEAN) + background_hex
#
# Sorties:
# IMAGE: [1,H,W,4] en 0..1
# MASK : [1,H,W] en 0..1
import os, math, random
from typing import List, Optional
from PIL import Image, ImageChops
import numpy as np
import torch
# ---------- Utils fichiers & images ----------
_EXTS = {".png", ".jpg", ".jpeg"}
def _list_images_sorted(folder: str) -> List[str]:
if not folder or not os.path.isdir(folder):
raise ValueError(f"Dossier introuvable: {folder}")
files = []
for name in os.listdir(folder):
p = os.path.join(folder, name)
if os.path.isfile(p):
ext = os.path.splitext(name)[1].lower()
if ext in _EXTS:
files.append(p)
if not files:
raise ValueError(f"Aucune image .png/.jpg/.jpeg trouvée dans: {folder}")
files.sort(key=lambda s: os.path.basename(s).lower())
return files
def _open_rgba(path: str) -> Image.Image:
img = Image.open(path)
return img.convert("RGBA")
def _rgba_pil_to_tensor(img: Image.Image) -> torch.Tensor:
if img.mode != "RGBA":
img = img.convert("RGBA")
arr = np.array(img).astype(np.float32) / 255.0 # [H,W,4]
return torch.from_numpy(arr).unsqueeze(0) # [1,H,W,4]
def _maskL_to_tensor(maskL: Image.Image) -> torch.Tensor:
arr = np.array(maskL).astype(np.float32) / 255.0 # [H,W]
return torch.from_numpy(arr).unsqueeze(0) # [1,H,W]
def _parse_hex(color: str):
if not color:
return (0, 0, 0, 0)
s = color.strip().lower()
named = {
"white": "#ffffff",
"black": "#000000",
"transparent": "#00000000",
"none": "#00000000",
}
if s in named:
s = named[s]
if not s.startswith("#"):
s = "#" + s
if len(s) == 4:
s = "#" + "".join(ch * 2 for ch in s[1:])
if len(s) == 7:
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = 255
elif len(s) == 9:
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = int(s[7:9], 16)
else:
r, g, b, a = 0, 0, 0, 0
return (r, g, b, a)
def _make_canvas(w: int, h: int, use_bg: bool, bg_hex: str) -> Image.Image:
return Image.new("RGBA", (w, h), _parse_hex(bg_hex) if use_bg else (0, 0, 0, 0))
def _transform_sprite(sprite_rgba: Image.Image, scale: float, object_rotation: float, opacity: float) -> Image.Image:
img = sprite_rgba
if scale != 1.0:
sw, sh = img.size
img = img.resize((max(1, int(sw * scale)), max(1, int(sh * scale))), Image.LANCZOS)
if object_rotation != 0.0:
img = img.rotate(object_rotation, expand=True, resample=Image.BICUBIC)
if opacity < 1.0:
r, g, b, a = img.split()
a = a.point(lambda v: int(v * opacity))
img = Image.merge("RGBA", (r, g, b, a))
return img
# --------------- NODE ---------------
class DAOCloneCircularPath:
"""
Clonage circulaire basé dossier.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": ""}),
"canvas_width": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"canvas_height": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"use_background": ("BOOLEAN", {"default": False}),
"background_hex": ("STRING", {"default": "#00000000"}),
"radius": ("FLOAT", {"default": 300.0, "min": 0.0, "max": 100000.0, "step": 1.0}),
"count": ("INT", {"default": 12, "min": 1, "max": 20000}),
"rotate": ("FLOAT", {"default": 0.0, "min": -1440.0, "max": 1440.0, "step": 0.1}),
"object_rotation": ("FLOAT", {"default": 0.0, "min": -1440.0, "max": 1440.0, "step": 0.1}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"shuffle": ("BOOLEAN", {"default": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**31-1}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "run"
CATEGORY = "DAO_master/Images/Clone"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def run(
self,
folder_path: str,
canvas_width: int = 1024,
canvas_height: int = 1024,
use_background: bool = False,
background_hex: str = "#00000000",
radius: float = 300.0,
count: int = 12,
rotate: float = 0.0,
object_rotation: float = 0.0,
scale: float = 1.0,
opacity: float = 1.0,
shuffle: bool = False,
seed: int = 0,
):
files = _list_images_sorted(folder_path)
if shuffle:
rnd = random.Random(seed)
rnd.shuffle(files)
# Tronquer au besoin (on bouclera de toute façon via modulo)
if len(files) > count:
files = files[:count]
base = _make_canvas(canvas_width, canvas_height, use_background, background_hex)
mask_canvas = Image.new("L", (canvas_width, canvas_height), 0)
if count > 50000:
raise ValueError("Trop de clones (limite 50k)")
cx = canvas_width / 2.0
cy = canvas_height / 2.0
for i in range(count):
path = files[i % len(files)]
sprite = _open_rgba(path)
sprite = _transform_sprite(sprite, scale=scale, object_rotation=object_rotation, opacity=opacity)
sw, sh = sprite.size
ang = (i / count) * 360.0 + rotate
rad = math.radians(ang)
x = cx + radius * math.cos(rad) - sw / 2.0
y = cy + radius * math.sin(rad) - sh / 2.0
base.alpha_composite(sprite, (int(x), int(y)))
# Union du mask
_, _, _, a = sprite.split()
placed = Image.new("L", (canvas_width, canvas_height), 0)
placed.paste(a, (int(x), int(y)), a)
mask_canvas = ImageChops.lighter(mask_canvas, placed)
out_img = _rgba_pil_to_tensor(base)
out_mask = _maskL_to_tensor(mask_canvas)
return (out_img, out_mask)
+296
View File
@@ -0,0 +1,296 @@
# -*- coding: utf-8 -*-
# ComfyUI_DAO_master / dao_clone_grid.py
#
# DAO Clone Grid (X/Y)
# - Canvas indépendant (par défaut: custom, via canvas_width/height)
# - count_x, count_y : nombre d’objets en X/Y
# - spacing_x, spacing_y : écart (gap) entre objets
# - offset_x, offset_y : position du 1er objet (en haut-gauche)
# - row_offset_x : décalage horizontal appliqué UNE LIGNE SUR DEUX (lignes impaires)
# - col_offset_y : décalage vertical appliqué UNE COLONNE SUR DEUX (colonnes impaires)
# - use_background (BOOLEAN) + background_hex (#RGB, #RRGGBB, #RRGGBBAA, "white", "black", "transparent")
# - mask en entrée (optionnel) ; mask de sortie = union des clones
# - scale, rotation (par objet), opacity
#
# Sorties:
# IMAGE: [1,H,W,4] en 0..1
# MASK : [1,H,W] en 0..1
from typing import Optional, Tuple
import numpy as np
from PIL import Image, ImageChops
import torch
# --------- Utils tensor <-> PIL (robustes) ----------
def _image_to_rgba_pil(t: torch.Tensor) -> Image.Image:
"""
Accepte: [B,H,W,C], [H,W,C], [C,H,W] (C=1/3/4), 0..1 -> PIL RGBA.
"""
if t is None:
raise ValueError("Image tensor is None")
if t.dim() == 4: # [B,H,W,C]
t = t[0]
if t.dim() != 3:
raise ValueError("Expected 3D or 4D tensor for image")
# [C,H,W] -> [H,W,C]
if t.shape[0] in (1, 3, 4) and (t.shape[-1] not in (1, 3, 4)):
t = t.permute(1, 2, 0)
if t.shape[-1] not in (1, 3, 4):
raise ValueError(f"Unsupported channel count: {t.shape[-1]}")
arr = t.detach().cpu().float().clamp(0, 1).numpy() # [H,W,C]
if arr.shape[-1] == 1: # Gray -> RGBA
arr = np.repeat(arr, 3, axis=-1)
a = np.ones((*arr.shape[:2], 1), dtype=arr.dtype)
arr = np.concatenate([arr, a], axis=-1)
elif arr.shape[-1] == 3: # RGB -> RGBA
a = np.ones((*arr.shape[:2], 1), dtype=arr.dtype)
arr = np.concatenate([arr, a], axis=-1)
u8 = (arr * 255.0 + 0.5).astype(np.uint8)
return Image.fromarray(u8, mode="RGBA")
def _mask_to_L(mask_t: Optional[torch.Tensor], size) -> Optional[Image.Image]:
"""
MASK attendu: [H,W] ou [1,H,W] ou [B,H,W], 0..1 -> PIL 'L' (0..255)
"""
if mask_t is None:
return None
t = mask_t
if t.dim() == 3: # [B,H,W] ou [1,H,W]
t = t[0]
if t.dim() != 2:
raise ValueError("Mask must be 2D or 3D [1,H,W]")
arr = t.detach().cpu().float().clamp(0, 1).numpy()
u8 = (arr * 255.0 + 0.5).astype(np.uint8)
m = Image.fromarray(u8, mode="L")
if m.size != size:
m = m.resize(size, Image.LANCZOS)
return m
def _rgba_pil_to_tensor(img: Image.Image) -> torch.Tensor:
if img.mode != "RGBA":
img = img.convert("RGBA")
arr = np.array(img).astype(np.float32) / 255.0 # [H,W,4]
return torch.from_numpy(arr).unsqueeze(0) # [1,H,W,4]
def _maskL_to_tensor(maskL: Image.Image) -> torch.Tensor:
arr = np.array(maskL).astype(np.float32) / 255.0 # [H,W]
return torch.from_numpy(arr).unsqueeze(0) # [1,H,W]
def _parse_hex(color: str):
"""
#RGB, #RRGGBB, #RRGGBBAA + noms: white, black, transparent, none
-> tuple (r,g,b,a) 0..255
"""
if not color:
return (0, 0, 0, 0)
s = color.strip().lower()
named = {
"white": "#ffffff",
"black": "#000000",
"transparent": "#00000000",
"none": "#00000000",
}
if s in named:
s = named[s]
if not s.startswith("#"):
s = "#" + s
if len(s) == 4: # #RGB -> #RRGGBB
s = "#" + "".join(ch * 2 for ch in s[1:])
if len(s) == 7: # #RRGGBB
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = 255
elif len(s) == 9: # #RRGGBBAA
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = int(s[7:9], 16)
else:
r, g, b, a = 0, 0, 0, 0
return (r, g, b, a)
def _make_canvas(w: int, h: int, use_bg: bool, bg_hex: str) -> Image.Image:
return Image.new("RGBA", (w, h), _parse_hex(bg_hex) if use_bg else (0, 0, 0, 0))
def _transform_sprite(sprite_rgba: Image.Image, mask_L: Optional[Image.Image],
scale: float, rotation_deg: float, opacity: float) -> Image.Image:
"""
Applique: mask (alpha*=mask), scale, rotation, opacity -> RGBA
"""
img = sprite_rgba
# mask sur alpha
if mask_L is not None:
r, g, b, a = img.split()
m = mask_L.resize(img.size, Image.LANCZOS) if mask_L.size != img.size else mask_L
a = ImageChops.multiply(a, m)
img = Image.merge("RGBA", (r, g, b, a))
# scale
if scale != 1.0:
sw, sh = img.size
img = img.resize((max(1, int(sw * scale)), max(1, int(sh * scale))), Image.LANCZOS)
# rotation par objet
if rotation_deg != 0.0:
img = img.rotate(rotation_deg, expand=True, resample=Image.BICUBIC)
# opacity
if opacity < 1.0:
r, g, b, a = img.split()
a = a.point(lambda v: int(v * opacity))
img = Image.merge("RGBA", (r, g, b, a))
return img
def _auto_canvas_size(sprite_size: Tuple[int, int], count_x: int, count_y: int,
spacing_x: int, spacing_y: int, offset_x: int, offset_y: int,
scale: float) -> Tuple[int, int]:
"""
Calcule (w,h) = offset + count*size + (count-1)*spacing
(approximation qui n’intègre pas les décalages alternés ; pratique pour pré-dimensionner)
"""
sw, sh = sprite_size
sw = max(1, int(sw * scale))
sh = max(1, int(sh * scale))
w = offset_x + count_x * sw + max(0, count_x - 1) * spacing_x
h = offset_y + count_y * sh + max(0, count_y - 1) * spacing_y
return max(1, w), max(1, h)
# -------------------- NODE GRID --------------------
class DAOCloneGrid:
"""
Grille simple & prévisible.
- Canvas indépendant (par défaut 'custom').
- Premier clone en haut-gauche (offset_x/offset_y).
- count_x/count_y + spacing_x/spacing_y.
- Décalages alternés : row_offset_x (lignes impaires), col_offset_y (colonnes impaires).
- Entrée mask optionnelle. Sortie mask = union des clones.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
},
"optional": {
"mask": ("MASK",),
# Canvas
"canvas_mode": (["custom", "auto_from_grid", "match_input"], {"default": "custom"}),
"canvas_width": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"canvas_height": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"use_background": ("BOOLEAN", {"default": False}),
"background_hex": ("STRING", {"default": "#00000000"}),
# Layout basique
"count_x": ("INT", {"default": 4, "min": 1, "max": 4096}),
"count_y": ("INT", {"default": 4, "min": 1, "max": 4096}),
"spacing_x": ("INT", {"default": 20, "min": -10000, "max": 10000}),
"spacing_y": ("INT", {"default": 20, "min": -10000, "max": 10000}),
"offset_x": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"offset_y": ("INT", {"default": 0, "min": -10000, "max": 10000}),
# Décalages alternés
"row_offset_x": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"col_offset_y": ("INT", {"default": 0, "min": -10000, "max": 10000}),
# Apparence
"rotation": ("FLOAT", {"default": 0.0, "min": -1440.0, "max": 1440.0, "step": 0.1}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "run"
CATEGORY = "DAO_master/Images/Clone"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def run(
self,
image: torch.Tensor,
mask: Optional[torch.Tensor] = None,
canvas_mode: str = "custom",
canvas_width: int = 1024,
canvas_height: int = 1024,
use_background: bool = False,
background_hex: str = "#00000000",
count_x: int = 4,
count_y: int = 4,
spacing_x: int = 20,
spacing_y: int = 20,
offset_x: int = 0,
offset_y: int = 0,
row_offset_x: int = 0,
col_offset_y: int = 0,
rotation: float = 0.0,
scale: float = 1.0,
opacity: float = 1.0,
):
sprite_rgba = _image_to_rgba_pil(image)
mask_L_src = _mask_to_L(mask, sprite_rgba.size)
# Sprite transformé (mask/scale/rotation/opacity)
sprite_t = _transform_sprite(sprite_rgba, mask_L_src, scale=scale,
rotation_deg=rotation, opacity=opacity)
sw, sh = sprite_t.size
# Canvas
if canvas_mode == "match_input":
cw, ch = sprite_rgba.size
elif canvas_mode == "auto_from_grid":
cw, ch = _auto_canvas_size((sw, sh), count_x, count_y, spacing_x, spacing_y,
offset_x, offset_y, 1.0)
else: # "custom"
cw, ch = canvas_width, canvas_height
base = _make_canvas(cw, ch, use_background, background_hex)
mask_canvas = Image.new("L", (cw, ch), 0)
total = count_x * count_y
if total > 50000:
raise ValueError("Trop de clones (limite 50k)")
step_x = sw + spacing_x
step_y = sh + spacing_y
for j in range(count_y):
for i in range(count_x):
x = offset_x + i * step_x
y = offset_y + j * step_y
# Décalages alternés
if row_offset_x != 0 and (j % 2 == 1):
x += row_offset_x
if col_offset_y != 0 and (i % 2 == 1):
y += col_offset_y
base.alpha_composite(sprite_t, (int(x), int(y)))
# union du mask
_, _, _, a = sprite_t.split()
placed = Image.new("L", (cw, ch), 0)
placed.paste(a, (int(x), int(y)), a)
mask_canvas = ImageChops.lighter(mask_canvas, placed)
out_img = _rgba_pil_to_tensor(base)
out_mask = _maskL_to_tensor(mask_canvas)
return (out_img, out_mask)
+217
View File
@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
# ComfyUI_DAO_master / dao_clone_grid_path.py
#
# DAO Clone Grid Path
# - Charge des PNG/JPG d'un dossier (tri alpha), 1 image par clone
# - Boucle si pas assez, tronque si trop
# - shuffle + seed pour ordre aléatoire reproductible
# - Grille simple: count_x/count_y, spacing_x/y, offset_x/y
# - Décalages alternés: row_offset_x (lignes impaires), col_offset_y (colonnes impaires)
# - Canvas indépendant par défaut (custom)
#
# Sorties:
# IMAGE: [1,H,W,4] en 0..1
# MASK : [1,H,W] en 0..1
import os, random
from typing import List
from PIL import Image, ImageChops
import numpy as np
import torch
_EXTS = {".png", ".jpg", ".jpeg"}
def _list_images_sorted(folder: str) -> List[str]:
if not folder or not os.path.isdir(folder):
raise ValueError(f"Dossier introuvable: {folder}")
files = []
for name in os.listdir(folder):
p = os.path.join(folder, name)
if os.path.isfile(p):
ext = os.path.splitext(name)[1].lower()
if ext in _EXTS:
files.append(p)
if not files:
raise ValueError(f"Aucune image .png/.jpg/.jpeg trouvée dans: {folder}")
files.sort(key=lambda s: os.path.basename(s).lower())
return files
def _open_rgba(path: str) -> Image.Image:
img = Image.open(path)
return img.convert("RGBA")
def _rgba_pil_to_tensor(img: Image.Image) -> torch.Tensor:
if img.mode != "RGBA":
img = img.convert("RGBA")
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _maskL_to_tensor(maskL: Image.Image) -> torch.Tensor:
arr = np.array(maskL).astype(np.float32) / 255.0
return torch.from_numpy(arr).unsqueeze(0)
def _parse_hex(color: str):
if not color:
return (0, 0, 0, 0)
s = color.strip().lower()
named = {
"white": "#ffffff",
"black": "#000000",
"transparent": "#00000000",
"none": "#00000000",
}
if s in named:
s = named[s]
if not s.startswith("#"):
s = "#" + s
if len(s) == 4:
s = "#" + "".join(ch * 2 for ch in s[1:])
if len(s) == 7:
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = 255
elif len(s) == 9:
r = int(s[1:3], 16); g = int(s[3:5], 16); b = int(s[5:7], 16); a = int(s[7:9], 16)
else:
r, g, b, a = 0, 0, 0, 0
return (r, g, b, a)
def _make_canvas(w: int, h: int, use_bg: bool, bg_hex: str) -> Image.Image:
return Image.new("RGBA", (w, h), _parse_hex(bg_hex) if use_bg else (0, 0, 0, 0))
def _transform_sprite(sprite_rgba: Image.Image, scale: float, rotation_deg: float, opacity: float) -> Image.Image:
img = sprite_rgba
if scale != 1.0:
sw, sh = img.size
img = img.resize((max(1, int(sw * scale)), max(1, int(sh * scale))), Image.LANCZOS)
if rotation_deg != 0.0:
img = img.rotate(rotation_deg, expand=True, resample=Image.BICUBIC)
if opacity < 1.0:
r, g, b, a = img.split()
a = a.point(lambda v: int(v * opacity))
img = Image.merge("RGBA", (r, g, b, a))
return img
class DAOCloneGridPath:
"""
Grille basée dossier, avec décalages alternés.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": ""}),
# Canvas
"canvas_mode": (["custom", "auto_from_grid"], {"default": "custom"}),
"canvas_width": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"canvas_height": ("INT", {"default": 1024, "min": 1, "max": 32768}),
"use_background": ("BOOLEAN", {"default": False}),
"background_hex": ("STRING", {"default": "#00000000"}),
# Layout
"count_x": ("INT", {"default": 4, "min": 1, "max": 4096}),
"count_y": ("INT", {"default": 4, "min": 1, "max": 4096}),
"spacing_x": ("INT", {"default": 20, "min": -10000, "max": 10000}),
"spacing_y": ("INT", {"default": 20, "min": -10000, "max": 10000}),
"offset_x": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"offset_y": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"row_offset_x": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"col_offset_y": ("INT", {"default": 0, "min": -10000, "max": 10000}),
# Apparence
"rotation": ("FLOAT", {"default": 0.0, "min": -1440.0, "max": 1440.0, "step": 0.1}),
"scale": ("FLOAT", {"default": 1.0, "min": 0.01, "max": 10.0, "step": 0.01}),
"opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
# Random
"shuffle": ("BOOLEAN", {"default": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**31-1}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "run"
CATEGORY = "DAO_master/Images/Clone"
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN")
def run(
self,
folder_path: str,
canvas_mode: str = "custom",
canvas_width: int = 1024,
canvas_height: int = 1024,
use_background: bool = False,
background_hex: str = "#00000000",
count_x: int = 4,
count_y: int = 4,
spacing_x: int = 20,
spacing_y: int = 20,
offset_x: int = 0,
offset_y: int = 0,
row_offset_x: int = 0,
col_offset_y: int = 0,
rotation: float = 0.0,
scale: float = 1.0,
opacity: float = 1.0,
shuffle: bool = False,
seed: int = 0,
):
files = _list_images_sorted(folder_path)
total_needed = count_x * count_y
if shuffle:
rnd = random.Random(seed)
rnd.shuffle(files)
if len(files) > total_needed:
files = files[:total_needed]
# Sprite de référence (pour step), basé sur la première image du set
ref_sprite = _open_rgba(files[0])
ref_sprite = _transform_sprite(ref_sprite, scale=scale, rotation_deg=rotation, opacity=opacity)
sw, sh = ref_sprite.size
# Canvas
if canvas_mode == "auto_from_grid":
cw = offset_x + count_x * sw + max(0, count_x - 1) * spacing_x + max(0, row_offset_x)
ch = offset_y + count_y * sh + max(0, count_y - 1) * spacing_y + max(0, col_offset_y)
else: # custom
cw, ch = canvas_width, canvas_height
base = _make_canvas(cw, ch, use_background, background_hex)
mask_canvas = Image.new("L", (cw, ch), 0)
step_x = sw + spacing_x
step_y = sh + spacing_y
idx = 0
N = len(files)
for j in range(count_y):
for i in range(count_x):
path = files[idx % N]
idx += 1
sprite = _open_rgba(path)
sprite = _transform_sprite(sprite, scale=scale, rotation_deg=rotation, opacity=opacity)
ssw, ssh = sprite.size
x = offset_x + i * step_x
y = offset_y + j * step_y
if row_offset_x != 0 and (j % 2 == 1):
x += row_offset_x
if col_offset_y != 0 and (i % 2 == 1):
y += col_offset_y
# centrer sur la "cell" (optionnel) : ici on cale en haut-gauche pour rester strict
# Si on voulait centrer : x += (sw - ssw)//2 ; y += (sh - ssh)//2
base.alpha_composite(sprite, (int(x), int(y)))
# union mask
_, _, _, a = sprite.split()
placed = Image.new("L", (cw, ch), 0)
placed.paste(a, (int(x), int(y)), a)
mask_canvas = ImageChops.lighter(mask_canvas, placed)
out_img = _rgba_pil_to_tensor(base)
out_mask = _maskL_to_tensor(mask_canvas)
return (out_img, out_mask)
+1 -1
View File
@@ -375,7 +375,7 @@ class FolderFilePro:
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("file_path", "filename", "dir_used", "files_json", "file_info")
FUNCTION = "pick"
CATEGORY = "DAO_master/IO"
CATEGORY = "DAO_master"
def pick(self, directory: str, extensions: str,
name_regex: str, regex_mode: str, regex_ignore_case: bool,
+1 -1
View File
@@ -153,7 +153,7 @@ class LoadImagePro:
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "image_rgba", "mask", "mask_image", "width", "height")
FUNCTION = "run"
CATEGORY = "DAO_master"
CATEGORY = "DAO_master/Images/IO"
def _load_from_path(self, path: str) -> Tuple[np.ndarray, Optional[np.ndarray]]:
if not path: raise ValueError("Path is empty.")
+540
View File
@@ -0,0 +1,540 @@
import os, math, glob, time, re
from typing import List, Tuple, Dict
import numpy as np
from PIL import Image
try:
import torch
except Exception:
torch = None
# ==== Utils communs ====
def _tensor_to_numpy_single(img: "torch.Tensor") -> np.ndarray:
if img is None:
raise ValueError("Image tensor is None.")
if img.ndim == 4:
img = img[0]
if img.ndim != 3:
raise ValueError(f"Unexpected image ndim={img.ndim}, expected 3 or 4.")
arr = img.detach().cpu().numpy()
arr = np.clip(arr, 0.0, 1.0)
arr = (arr * 255.0 + 0.5).astype(np.uint8)
return arr
def _tensor_batch_to_numpy(imgs: "torch.Tensor") -> np.ndarray:
if imgs is None or imgs.ndim != 4:
raise ValueError("Expected IMAGE batch with shape (N,H,W,C).")
arr = imgs.detach().cpu().numpy()
arr = np.clip(arr, 0.0, 1.0)
arr = (arr * 255.0 + 0.5).astype(np.uint8)
return arr
def _numpy_to_tensor(arr: np.ndarray) -> "torch.Tensor":
if arr.ndim == 3:
arr = arr[None, ...]
t = torch.from_numpy(arr.astype(np.float32) / 255.0)
return t
def _ensure_mode(arr: np.ndarray) -> Tuple[np.ndarray, str]:
C = arr.shape[2]
if C == 1:
arr = np.repeat(arr, 3, axis=2)
return arr, "RGB"
elif C == 3:
return arr, "RGB"
elif C == 4:
return arr, "RGBA"
else:
arr = arr[:, :, :3]
return arr, "RGB"
def _save_pil(img: Image.Image, path: str, filetype: str, quality: int = 95) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
ft = filetype.lower()
if ft == "png":
img.save(path, format="PNG", compress_level=4)
elif ft in ("jpg", "jpeg"):
if img.mode in ("RGBA", "LA"):
img = img.convert("RGB")
img.save(path, format="JPEG", quality=int(quality), subsampling=1, optimize=True)
else:
img.save(path)
def _parse_hex_color(hex_str: str) -> Tuple[int, int, int]:
s = hex_str.strip().lstrip("#")
if len(s) == 3:
s = "".join([ch*2 for ch in s])
if len(s) != 6:
raise ValueError(f"Invalid hex color: {hex_str}")
return int(s[0:2],16), int(s[2:4],16), int(s[4:6],16)
def _make_canvas(width: int, height: int, rgba: bool, bg_rgb=(0,0,0), bg_alpha: int = 0) -> np.ndarray:
if rgba:
canvas = np.zeros((height, width, 4), dtype=np.uint8)
canvas[...,0] = bg_rgb[0]; canvas[...,1] = bg_rgb[1]; canvas[...,2] = bg_rgb[2]; canvas[...,3] = np.clip(bg_alpha,0,255)
else:
canvas = np.zeros((height, width, 3), dtype=np.uint8)
canvas[...,0] = bg_rgb[0]; canvas[...,1] = bg_rgb[1]; canvas[...,2] = bg_rgb[2]
return canvas
def _index_to_rowcol(idx: int, rows: int, cols: int, mode: str) -> Tuple[int,int]:
if mode == "row_major":
return idx // cols, idx % cols
if mode == "column_major":
return idx % rows, idx // rows
if mode == "snake_row":
r, c = idx // cols, idx % cols
return (r, cols-1-c) if (r % 2) else (r, c)
if mode == "snake_col":
c, r = idx // rows, idx % rows
return (rows-1-r, c) if (c % 2) else (r, c)
# fallback
return idx // cols, idx % cols
# ---- Blend helpers ----
def _alpha_over(dst: np.ndarray, src: np.ndarray) -> np.ndarray:
# src over dst; handle RGB or RGBA
if dst.shape[2] == 4:
Cd = dst[...,:3].astype(np.float32); Ad = (dst[...,3:4].astype(np.float32))/255.0
else:
Cd = dst.astype(np.float32); Ad = np.ones(dst.shape[:2]+(1,), np.float32)
if src.shape[2] == 4:
Cs = src[...,:3].astype(np.float32); As = (src[...,3:4].astype(np.float32))/255.0
else:
Cs = src.astype(np.float32); As = np.ones(src.shape[:2]+(1,), np.float32)
Co = Cs + Cd * (1.0 - As)
Ao = As + Ad * (1.0 - As)
out_rgb = np.clip(Co, 0, 255)
if dst.shape[2] == 4:
out_a = np.clip(Ao*255.0, 0, 255)
out = np.concatenate([out_rgb, out_a], axis=2).astype(np.uint8)
else:
out = out_rgb.astype(np.uint8)
return out
def _apply_op(dst: np.ndarray, src: np.ndarray, op: str) -> np.ndarray:
# operates channel-wise (on all channels; alpha treated like color if present)
if op == "add":
return np.clip(dst.astype(np.int16) + src.astype(np.int16), 0, 255).astype(np.uint8)
if op == "multiply":
return ((dst.astype(np.float32) * src.astype(np.float32)) / 255.0).astype(np.uint8)
if op == "screen":
return (255 - ((255 - dst.astype(np.int16)) * (255 - src.astype(np.int16)) // 255)).astype(np.uint8)
if op == "lighten":
return np.maximum(dst, src)
if op == "darken":
return np.minimum(dst, src)
if op == "max":
return np.maximum(dst, src)
if op == "min":
return np.minimum(dst, src)
if op == "average":
return ((dst.astype(np.uint16) + src.astype(np.uint16)) // 2).astype(np.uint8)
# default 'last'
return src
def _feather_mask(h: int, w: int, fx: int, fy: int, mode: str = "linear") -> np.ndarray:
"""Return 2D weight mask (h,w,1) in [0,1] that ramps on the 4 edges with radii fx,fy."""
wx = np.ones((w,), np.float32)
wy = np.ones((h,), np.float32)
if fx > 0:
ramp = np.linspace(0.0, 1.0, fx, dtype=np.float32)
if mode == "cosine":
ramp = (1 - np.cos(np.linspace(0, np.pi, fx, dtype=np.float32))) * 0.5
wx[:fx] = ramp
wx[-fx:] = ramp[::-1]
if fy > 0:
ramp = np.linspace(0.0, 1.0, fy, dtype=np.float32)
if mode == "cosine":
ramp = (1 - np.cos(np.linspace(0, np.pi, fy, dtype=np.float32))) * 0.5
wy[:fy] = ramp
wy[-fy:] = ramp[::-1]
mask = wy[:,None] * wx[None,:]
return mask[...,None] # (h,w,1)
def _blend_place(dst: np.ndarray, src: np.ndarray, y: int, x: int, *,
mode: str = "last", weighted_w: float = 0.5,
feather_px: int = 0, feather_kind: str = "linear"):
Hs, Ws = src.shape[0], src.shape[1]
patch = dst[y:y+Hs, x:x+Ws, :]
if mode == "alpha_over":
patch[:] = _alpha_over(patch, src)
return
if mode in ("feather_linear","feather_cosine"):
fk = "cosine" if mode.endswith("cosine") else "linear"
fx = fy = max(0, int(feather_px))
mask = _feather_mask(Hs, Ws, fx, fy, fk).astype(np.float32)
src_f = src.astype(np.float32)
dst_f = patch.astype(np.float32)
out = dst_f * (1.0 - mask) + src_f * mask
patch[:] = np.clip(out, 0, 255).astype(np.uint8)
return
if mode == "weighted":
w = float(np.clip(weighted_w, 0.0, 1.0))
src_f = src.astype(np.float32)
dst_f = patch.astype(np.float32)
out = dst_f * (1.0 - w) + src_f * w
patch[:] = np.clip(out, 0, 255).astype(np.uint8)
return
# compositing ops
patch[:] = _apply_op(patch, src, op=mode)
# ==== Node 1: Tile & Export ====
class MosaicTileExport:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"rows": ("INT", {"default": 2, "min": 1, "max": 512}),
"cols": ("INT", {"default": 2, "min": 1, "max": 512}),
},
"optional": {
"fit_mode": (["crop", "pad"], {"default": "crop"}),
"filetype": (["png", "jpg"], {"default": "png"}),
"quality": ("INT", {"default": 95, "min": 1, "max": 100}),
"basename": ("STRING", {"default": "tiles"}),
"subfolder": ("STRING", {"default": ""}),
},
}
RETURN_TYPES = ("IMAGE", "STRING",)
RETURN_NAMES = ("tiles_batch", "output_dir",)
FUNCTION = "tile_and_export"
CATEGORY = "DAO_master/Images/Mosaic"
def _compute_tile_sizes(self, H, W, rows, cols, fit_mode):
if fit_mode == "crop":
th, tw = H // rows, W // cols
if th < 1 or tw < 1:
raise ValueError("Avec 'crop', rows/cols trop grands.")
return th, tw, th*rows, tw*cols
th, tw = math.ceil(H/rows), math.ceil(W/cols)
return th, tw, th*rows, tw*cols
def _pad_canvas(self, arr, used_H, used_W, mode):
if mode == "RGBA":
canvas = np.zeros((used_H, used_W, 4), dtype=np.uint8)
else:
canvas = np.zeros((used_H, used_W, 3), dtype=np.uint8)
H, W = arr.shape[0], arr.shape[1]
canvas[:H,:W,:arr.shape[2]] = arr
return canvas
def _slice_grid(self, base, rows, cols, th, tw):
tiles = []
for r in range(rows):
for c in range(cols):
y0, x0 = r*th, c*tw
tiles.append(base[y0:y0+th, x0:x0+tw, :])
return tiles
def tile_and_export(self, image, rows, cols, fit_mode="crop",
filetype="png", quality=95, basename="tiles", subfolder=""):
if torch is None:
raise RuntimeError("PyTorch requis par ComfyUI n'est pas disponible.")
np_img = _tensor_to_numpy_single(image)
np_img, mode = _ensure_mode(np_img)
H, W, _ = np_img.shape
th, tw, used_H, used_W = self._compute_tile_sizes(H, W, rows, cols, fit_mode)
base = np_img[:used_H,:used_W,:] if fit_mode=="crop" else self._pad_canvas(np_img, used_H, used_W, mode)
tiles = self._slice_grid(base, rows, cols, th, tw)
root_out = os.path.join("output","tiles"); ts = time.strftime("%Y%m%d-%H%M%S")
safe_sub = subfolder.strip().replace("\\","/")
out_dir = os.path.join(root_out, safe_sub, f"{basename}_{rows}x{cols}_{ts}") if safe_sub \
else os.path.join(root_out, f"{basename}_{rows}x{cols}_{ts}")
os.makedirs(out_dir, exist_ok=True)
saved = []
for i,tile in enumerate(tiles):
r, c = i//cols, i%cols
pil = Image.fromarray(tile, mode=mode)
fpath = os.path.join(out_dir, f"{basename}_r{r:02d}_c{c:02d}.{filetype}")
_save_pil(pil, fpath, filetype=filetype, quality=quality)
saved.append(fpath)
batch = np.stack(tiles, axis=0)
return (_numpy_to_tensor(batch), out_dir + "\n" + "\n".join(saved))
# ==== Node 2: Assemble (Batch) ====
class MosaicTileAssemble:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"tiles": ("IMAGE",),
"rows": ("INT", {"default": 2, "min": 1, "max": 512}),
"cols": ("INT", {"default": 2, "min": 1, "max": 512}),
},
"optional": {
"order_mode": (["row_major","column_major","snake_row","snake_col"], {"default":"row_major"}),
"offset_x": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"offset_y": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"gutter": ("INT", {"default": 0, "min": -4096, "max": 4096}),
"overlap_x": ("INT", {"default": 0, "min": 0, "max": 4096}),
"overlap_y": ("INT", {"default": 0, "min": 0, "max": 4096}),
"overlap_blend": ([
"last","average","alpha_over","add","multiply","screen","lighten","darken","max","min",
"weighted","feather_linear","feather_cosine"
], {"default": "last"}),
"blend_weight": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0}),
"feather_px": ("INT", {"default": 0, "min": 0, "max": 2048}),
"export": ("BOOLEAN", {"default": False}),
"filetype": (["png","jpg"], {"default":"png"}),
"quality": ("INT", {"default":95, "min":1, "max":100}),
"basename": ("STRING", {"default":"mosaic"}),
"subfolder": ("STRING", {"default":""}),
"bg_color": ("STRING", {"default":"#000000"}),
"bg_alpha": ("INT", {"default":0, "min":0, "max":255}),
},
}
RETURN_TYPES = ("IMAGE","STRING",)
RETURN_NAMES = ("image","save_path",)
FUNCTION = "assemble"
CATEGORY = "DAO_master/Images/Mosaic"
def assemble(self, tiles, rows, cols, order_mode="row_major",
offset_x=0, offset_y=0, gutter=0, overlap_x=0, overlap_y=0,
overlap_blend="last", blend_weight=0.5, feather_px=0,
export=False, filetype="png", quality=95, basename="mosaic", subfolder="",
bg_color="#000000", bg_alpha=0):
if torch is None:
raise RuntimeError("PyTorch requis par ComfyUI n'est pas disponible.")
arr = _tensor_batch_to_numpy(tiles) # (N,H,W,C)
N, Ht, Wt, C = arr.shape
expected = rows*cols
if N != expected:
raise ValueError(f"Batch={N} mais rows*cols={expected}")
if C not in (3,4):
arr = arr[...,:3]; C = 3
rgba = (C==4)
bg_rgb = _parse_hex_color(bg_color)
stride_x = max(1, Wt + gutter - overlap_x)
stride_y = max(1, Ht + gutter - overlap_y)
canvas_H = offset_y + rows*Ht + max(0, rows-1)*(gutter - overlap_y)
canvas_W = offset_x + cols*Wt + max(0, cols-1)*(gutter - overlap_x)
canvas_H = max(canvas_H, Ht + offset_y); canvas_W = max(canvas_W, Wt + offset_x)
canvas = _make_canvas(canvas_W, canvas_H, rgba=rgba, bg_rgb=bg_rgb, bg_alpha=bg_alpha)
for i in range(N):
r, c = _index_to_rowcol(i, rows, cols, order_mode)
y = offset_y + r*stride_y; x = offset_x + c*stride_x
tile = arr[i]
if tile.shape[2] != C:
if C==3 and tile.shape[2]==4: tile = tile[...,:3]
if C==4 and tile.shape[2]==3:
a = 255*np.ones((Ht,Wt,1), np.uint8); tile = np.concatenate([tile,a], axis=2)
# crop si placement partiellement en dehors
y0, x0 = max(0,y), max(0,x); dy, dx = y0-y, x0-x
y1, x1 = min(canvas.shape[0], y+Ht), min(canvas.shape[1], x+Wt)
if y1<=y0 or x1<=x0: continue
src = tile[dy:dy+(y1-y0), dx:dx+(x1-x0), :]
_blend_place(canvas[y0:y1, x0:x1, :], src, 0, 0,
mode=overlap_blend, weighted_w=blend_weight, feather_px=feather_px)
pil = Image.fromarray(canvas, mode=("RGBA" if rgba else "RGB"))
save_path = ""
if export:
root_out = os.path.join("output","tiles"); ts = time.strftime("%Y%m%d-%H%M%S")
folder = os.path.join(root_out, subfolder) if subfolder.strip() else root_out
os.makedirs(folder, exist_ok=True)
fname = f"{basename}_{rows}x{cols}_{canvas.shape[1]}x{canvas.shape[0]}_{ts}.{filetype}"
save_path = os.path.join(folder, fname)
_save_pil(pil, save_path, filetype=filetype, quality=quality)
return (_numpy_to_tensor(np.array(pil)), save_path or "")
# ==== Node 3: Assemble (Folder) avec regex_filename_order ====
class MosaicAssembleFromFolder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder": ("STRING", {"default":"output/tiles"}),
"glob_pattern": ("STRING", {"default":"*.png"}),
"rows": ("INT", {"default":2, "min":1, "max":1024}),
"cols": ("INT", {"default":2, "min":1, "max":1024}),
},
"optional": {
"sort_mode": (["name_asc","name_desc","mtime_asc","mtime_desc"], {"default":"name_asc"}),
"order_mode": ([
"row_major","column_major","snake_row","snake_col","regex_filename_order"
], {"default":"row_major"}),
# regex settings (used only when order_mode == 'regex_filename_order')
"regex_row": ("STRING", {"default": r"r(\d+)", "multiline": False}),
"regex_col": ("STRING", {"default": r"c(\d+)", "multiline": False}),
"base_index": ("INT", {"default": 0, "min": 0, "max": 10}),
"fallback_order": (["row_major","column_major","snake_row","snake_col"], {"default":"row_major"}),
"offset_x": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"offset_y": ("INT", {"default": 0, "min": -10000, "max": 10000}),
"gutter": ("INT", {"default": 0, "min": -4096, "max": 4096}),
"overlap_x": ("INT", {"default": 0, "min": 0, "max": 4096}),
"overlap_y": ("INT", {"default": 0, "min": 0, "max": 4096}),
"overlap_blend": ([
"last","average","alpha_over","add","multiply","screen","lighten","darken","max","min",
"weighted","feather_linear","feather_cosine"
], {"default": "last"}),
"blend_weight": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0}),
"feather_px": ("INT", {"default": 0, "min": 0, "max": 2048}),
"enforce_tile_size": ("BOOLEAN", {"default": True}),
"target_w": ("INT", {"default": 0, "min": 0, "max": 8192}),
"target_h": ("INT", {"default": 0, "min": 0, "max": 8192}),
"export": ("BOOLEAN", {"default": True}),
"filetype": (["png","jpg"], {"default":"png"}),
"quality": ("INT", {"default":95, "min":1, "max":100}),
"basename": ("STRING", {"default":"mosaic_from_folder"}),
"subfolder": ("STRING", {"default":""}),
"bg_color": ("STRING", {"default":"#000000"}),
"bg_alpha": ("INT", {"default":0, "min":0, "max":255}),
},
}
RETURN_TYPES = ("IMAGE","STRING",)
RETURN_NAMES = ("image","save_path",)
FUNCTION = "assemble_from_folder"
CATEGORY = "DAO_master/Images/Mosaic"
def _collect_files(self, folder: str, pattern: str, sort_mode: str) -> List[str]:
folder = folder.strip().strip('"'); search = os.path.join(folder, pattern.strip())
files = glob.glob(search)
if not files: return []
if sort_mode == "name_asc":
files.sort(key=lambda p: os.path.basename(p).lower())
elif sort_mode == "name_desc":
files.sort(key=lambda p: os.path.basename(p).lower(), reverse=True)
elif sort_mode == "mtime_asc":
files.sort(key=lambda p: os.path.getmtime(p))
elif sort_mode == "mtime_desc":
files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return files
def _regex_map(self, files: List[str], rows: int, cols: int, regex_row: str, regex_col: str, base_index: int) -> Dict[Tuple[int,int], str]:
rx_r = re.compile(regex_row); rx_c = re.compile(regex_col)
placed: Dict[Tuple[int,int], str] = {}
for p in files:
name = os.path.basename(p)
mr = rx_r.search(name); mc = rx_c.search(name)
if not mr or not mc: continue
try:
r = int(mr.group(1)) - base_index
c = int(mc.group(1)) - base_index
except Exception:
continue
if 0 <= r < rows and 0 <= c < cols and (r,c) not in placed:
placed[(r,c)] = p
return placed
def assemble_from_folder(self, folder, glob_pattern, rows, cols,
sort_mode="name_asc",
order_mode="row_major",
regex_row=r"r(\d+)", regex_col=r"c(\d+)", base_index=0, fallback_order="row_major",
offset_x=0, offset_y=0, gutter=0, overlap_x=0, overlap_y=0,
overlap_blend="last", blend_weight=0.5, feather_px=0,
enforce_tile_size=True, target_w=0, target_h=0,
export=True, filetype="png", quality=95, basename="mosaic_from_folder", subfolder="",
bg_color="#000000", bg_alpha=0):
if torch is None:
raise RuntimeError("PyTorch requis par ComfyUI n'est pas disponible.")
files = self._collect_files(folder, glob_pattern, sort_mode)
expected = rows*cols
if len(files) < expected:
raise ValueError(f"Pas assez d'images ({len(files)}) pour {rows}x{cols} ({expected}).")
# Détermine l'ordre/mapping
use_regex = (order_mode == "regex_filename_order")
mapping: Dict[Tuple[int,int], str] = {}
if use_regex:
mapping = self._regex_map(files, rows, cols, regex_row, regex_col, base_index)
# Compléter les cases manquantes avec l'ordre fallback
remaining = [f for f in files if f not in mapping.values()]
idx_rem = 0
for i in range(expected):
r, c = _index_to_rowcol(i, rows, cols, fallback_order)
if (r,c) not in mapping and idx_rem < len(remaining):
mapping[(r,c)] = remaining[idx_rem]; idx_rem += 1
else:
# ordre standard à partir de files[:expected]
mapping = {}
for i in range(expected):
r, c = _index_to_rowcol(i, rows, cols, order_mode)
mapping[(r,c)] = files[i]
# Charge les images selon mapping (row-major de placement)
imgs = []
for r in range(rows):
for c in range(cols):
p = mapping[(r,c)]
im = Image.open(p)
if im.mode not in ("RGB","RGBA"):
im = im.convert("RGBA" if im.mode=="LA" else "RGB")
imgs.append(im)
# Normalisation taille
if target_w <= 0 or target_h <= 0:
target_w = imgs[0].width if target_w <= 0 else target_w
target_h = imgs[0].height if target_h <= 0 else target_h
norm = []
for im in imgs:
if enforce_tile_size and (im.width != target_w or im.height != target_h):
im = im.resize((target_w, target_h), Image.Resampling.LANCZOS)
norm.append(im)
C = 4 if any(im.mode=="RGBA" for im in norm) else 3
rgba = (C==4)
bg_rgb = _parse_hex_color(bg_color)
Ht, Wt = target_h, target_w
stride_x = max(1, Wt + gutter - overlap_x)
stride_y = max(1, Ht + gutter - overlap_y)
canvas_H = offset_y + rows*Ht + max(0, rows-1)*(gutter - overlap_y)
canvas_W = offset_x + cols*Wt + max(0, cols-1)*(gutter - overlap_x)
canvas_H = max(canvas_H, Ht + offset_y); canvas_W = max(canvas_W, Wt + offset_x)
canvas = _make_canvas(canvas_W, canvas_H, rgba=rgba, bg_rgb=bg_rgb, bg_alpha=bg_alpha)
# Placement
for i, im in enumerate(norm):
r, c = i // cols, i % cols # on place selon row-major des images listées
y = offset_y + r*stride_y; x = offset_x + c*stride_x
src = np.array(im.convert("RGBA" if rgba else "RGB"), dtype=np.uint8)
# crop si hors-champ via offset
y0, x0 = max(0,y), max(0,x); dy, dx = y0-y, x0-x
y1, x1 = min(canvas.shape[0], y+Ht), min(canvas.shape[1], x+Wt)
if y1<=y0 or x1<=x0: continue
src = src[dy:dy+(y1-y0), dx:dx+(x1-x0), :]
_blend_place(canvas[y0:y1, x0:x1, :], src, 0, 0,
mode=overlap_blend, weighted_w=blend_weight, feather_px=feather_px)
pil = Image.fromarray(canvas, mode=("RGBA" if rgba else "RGB"))
save_path = ""
if export:
root_out = os.path.join("output","tiles"); ts = time.strftime("%Y%m%d-%H%M%S")
folder_out = os.path.join(root_out, subfolder) if subfolder.strip() else root_out
os.makedirs(folder_out, exist_ok=True)
fname = f"{basename}_{rows}x{cols}_{canvas.shape[1]}x{canvas.shape[0]}_{ts}.{filetype}"
save_path = os.path.join(folder_out, fname)
_save_pil(pil, save_path, filetype=filetype, quality=quality)
return (_numpy_to_tensor(np.array(pil)), save_path or "")
+1 -1
View File
@@ -64,7 +64,7 @@ class PathToImage:
Path → Image (RGB & RGBA / Mask / Meta)
Aucune preview intégrée (on évite les erreurs de dtype).
"""
CATEGORY = "DAO_master/IO"
CATEGORY = "DAO_master/Images/IO"
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK", "IMAGE", "STRING", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "image_rgba", "mask", "mask_image", "json", "metadata", "width", "height")
FUNCTION = "load"