Add
Ajout clone, blur, classement des nodes
This commit is contained in:
+23
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user