From 00988810f40941c0a40c0122d4a94dfbee7449fe Mon Sep 17 00:00:00 2001 From: Bouletto <118677264+orion4d@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:31:30 +0200 Subject: [PATCH] Add Ajout clone, blur, classement des nodes --- __init__.py | 23 ++ dao_blur.py | 2 +- dao_clone_circular.py | 266 ++++++++++++++++++ dao_clone_circular_path.py | 185 +++++++++++++ dao_clone_grid.py | 296 ++++++++++++++++++++ dao_clone_grid_path.py | 217 +++++++++++++++ folder_file_pro.py | 2 +- load_image_pro.py | 2 +- mosaic_nodes.py | 540 +++++++++++++++++++++++++++++++++++++ path_to_image.py | 2 +- 10 files changed, 1531 insertions(+), 4 deletions(-) create mode 100644 dao_clone_circular.py create mode 100644 dao_clone_circular_path.py create mode 100644 dao_clone_grid.py create mode 100644 dao_clone_grid_path.py create mode 100644 mosaic_nodes.py diff --git a/__init__.py b/__init__.py index 372dd01..ffc330b 100644 --- a/__init__.py +++ b/__init__.py @@ -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" diff --git a/dao_blur.py b/dao_blur.py index 13ee862..be1dacc 100644 --- a/dao_blur.py +++ b/dao_blur.py @@ -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") diff --git a/dao_clone_circular.py b/dao_clone_circular.py new file mode 100644 index 0000000..e2c73f7 --- /dev/null +++ b/dao_clone_circular.py @@ -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) diff --git a/dao_clone_circular_path.py b/dao_clone_circular_path.py new file mode 100644 index 0000000..fc619b8 --- /dev/null +++ b/dao_clone_circular_path.py @@ -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) diff --git a/dao_clone_grid.py b/dao_clone_grid.py new file mode 100644 index 0000000..281a10c --- /dev/null +++ b/dao_clone_grid.py @@ -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) diff --git a/dao_clone_grid_path.py b/dao_clone_grid_path.py new file mode 100644 index 0000000..4319402 --- /dev/null +++ b/dao_clone_grid_path.py @@ -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) diff --git a/folder_file_pro.py b/folder_file_pro.py index 9440422..377e183 100644 --- a/folder_file_pro.py +++ b/folder_file_pro.py @@ -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, diff --git a/load_image_pro.py b/load_image_pro.py index f147e1a..6af9092 100644 --- a/load_image_pro.py +++ b/load_image_pro.py @@ -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.") diff --git a/mosaic_nodes.py b/mosaic_nodes.py new file mode 100644 index 0000000..dcb0c55 --- /dev/null +++ b/mosaic_nodes.py @@ -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 "") diff --git a/path_to_image.py b/path_to_image.py index 4de80fb..9c2f6a5 100644 --- a/path_to_image.py +++ b/path_to_image.py @@ -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"