diff --git a/nodes/ratio_outpaint.py b/nodes/ratio_outpaint.py index 4abf9d5..adf1837 100644 --- a/nodes/ratio_outpaint.py +++ b/nodes/ratio_outpaint.py @@ -1,6 +1,8 @@ import math import torch +from .tensor_utils import resize_lanczos + RATIOS = { "1:1": (1, 1), @@ -37,6 +39,18 @@ class RatioOutpaintCalc: 8 / 16 / 32 / 64 - arrondi au multiple le plus proche (round) sur les deux dimensions. Recommande : 16 (Flux), 64 (SDXL). + + mask (optionnel, v1.7.1) : + Un masque dessine sur l'image source. Il est remis a la taille de + l'image si besoin, puis padde EXACTEMENT comme l'image (zeros dans les + bandes) -> sortie 'mask_padded', alignee sur image_padded. Sert a garder + les masques d'inpaint en place quand on outpainte dans le meme run. + Non branche : mask_padded = zeros. + + Pour un workflow ou les multiples sont geres plus loin (Aioli Mask BBox V2), + preferer multiple = none : il ne fait qu'agrandir, sans jamais rogner la + source. Les multiples 8..64 arrondissent au plus proche et peuvent rogner + quelques pixels. """ @classmethod @@ -46,22 +60,40 @@ class RatioOutpaintCalc: "image": ("IMAGE",), "ratio": (["none"] + list(RATIOS.keys()),), "multiple": (MULTIPLES, {"default": "16 (Flux)"}), - } + }, + "optional": { + "mask": ("MASK",), + }, } - RETURN_TYPES = ("IMAGE", "MASK") - RETURN_NAMES = ("image_padded", "mask") + RETURN_TYPES = ("IMAGE", "MASK", "MASK") + RETURN_NAMES = ("image_padded", "mask", "mask_padded") FUNCTION = "calculate" CATEGORY = "Aioli Nodes" - def calculate(self, image, ratio, multiple="16 (Flux)"): + @staticmethod + def _input_mask(mask, B, H, W): + """Masque d'entree aligne sur l'image (B, H, W) ; zeros si absent.""" + if mask is None: + return torch.zeros((B, H, W), dtype=torch.float32) + m = mask.float() + if m.dim() == 2: + m = m.unsqueeze(0) + if m.shape[1] != H or m.shape[2] != W: + print(f"[RatioOutpaintCalc] mask {m.shape[2]}x{m.shape[1]} != image {W}x{H} " + f"-> mask redimensionne sur l'image") + m = resize_lanczos(m, W, H, "mask") + return m + + def calculate(self, image, ratio, multiple="16 (Flux)", mask=None): B, H, W, C = image.shape + in_mask = self._input_mask(mask, B, H, W) # Mode ratio=none : pass-through, aucun padding if ratio == "none": print(f"[RatioOutpaintCalc] {W}x{H} | ratio=none, pass-through") mask = torch.zeros((B, H, W), dtype=torch.float32) - return (image, mask) + return (image, mask, in_mask) rw, rh = RATIOS[ratio] @@ -77,7 +109,7 @@ class RatioOutpaintCalc: else: print(f"[RatioOutpaintCalc] {W}x{H} deja au ratio {ratio}") mask = torch.zeros((B, H, W), dtype=torch.float32) - return (image, mask) + return (image, mask, in_mask) mult_label = "legacy x8-ceil" else: @@ -93,7 +125,7 @@ class RatioOutpaintCalc: else: print(f"[RatioOutpaintCalc] {W}x{H} deja au ratio {ratio}") mask = torch.zeros((B, H, W), dtype=torch.float32) - return (image, mask) + return (image, mask, in_mask) mult_label = f"x{mult}-round" pad_left = (new_W - W) // 2 @@ -112,7 +144,13 @@ class RatioOutpaintCalc: ) img = img.permute(0, 2, 3, 1) - mask = torch.ones((B, new_H, new_W), dtype=torch.float32) - mask[:, pad_top:pad_top + H, pad_left:pad_left + W] = 0.0 + # Masques construits avec EXACTEMENT le meme pad que l'image. Un pad + # negatif (l'arrondi au multiple descend sous la taille source) rogne + # l'image : le masque des bandes doit le suivre. Avant 1.7.1, il etait + # construit par indexation, qui donnait un masque faux dans ce cas. + pads = (pad_left, pad_right, pad_top, pad_bottom) + mask = torch.nn.functional.pad( + torch.zeros((B, H, W), dtype=torch.float32), pads, mode="constant", value=1.0) + mask_padded = torch.nn.functional.pad(in_mask, pads, mode="constant", value=0.0) - return (img, mask) + return (img, mask, mask_padded) diff --git a/nodes/region_mask_list.py b/nodes/region_mask_list.py index 30f35e5..20ca36c 100644 --- a/nodes/region_mask_list.py +++ b/nodes/region_mask_list.py @@ -14,7 +14,18 @@ class RegionMaskList: 'list_first_on_top' et RegionalMaskConditioning, "dernier = dessous" -> le fond reste sous les objets. C'est pour ca que add_background ajoute en queue. - Cette meme liste alimente : AioliMaskBBox (bbox + crop + coords par region), + v1.7.1, deux options (non branchees / par defaut : comportement d'avant) : + extra_mask : un masque ajoute TEL QUEL comme une region de plus, apres + les autres (avant le fond), s'il n'est pas vide. + Typiquement le masque des bandes + de Ratio Outpaint Calc : une seule region, toute l'image en + contexte, a cote des masques dessines ou SAM3. + empty : keep (defaut) -> les masques vides passent comme avant. + full_image -> les masques vides (rien >= threshold) sont + retires ; s'il ne reste aucune region, la liste devient UN + masque plein : pas de masque = toute l'image est editee. + + Cette meme liste alimente : AioliMaskBBox V2 (bbox + crop + coords par region), Gemma (caption par region), le KSampler (N passes) ET le conditionnement regional mode B. Le fond y est donc lui aussi captionne et enhance. @@ -33,6 +44,8 @@ class RegionMaskList: "add_background": ("BOOLEAN", {"default": True}), "threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), "min_bg_area": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "extra_mask": ("MASK",), + "empty": (["keep", "full_image"], {"default": "keep"}), }, } @@ -42,14 +55,13 @@ class RegionMaskList: FUNCTION = "build" CATEGORY = "Aioli Nodes" - def build(self, masks, add_background=None, threshold=None, min_bg_area=None): - add_background = bool((add_background or [True])[0]) - threshold = float((threshold or [0.5])[0]) - min_bg_area = float((min_bg_area or [0.0])[0]) - - # aplatit batch + liste -> liste de masques HxW + @staticmethod + def _flatten(masks): + """batch + liste -> liste de masques HxW.""" flat = [] - for entry in masks: + for entry in masks or []: + if entry is None: + continue t = entry if t.dim() == 3: # batch B,H,W for k in range(t.shape[0]): @@ -58,10 +70,34 @@ class RegionMaskList: flat.append(t.float()) else: flat.append(t.reshape(t.shape[-2], t.shape[-1]).float()) + return flat + + def build(self, masks, add_background=None, threshold=None, min_bg_area=None, + extra_mask=None, empty=None): + add_background = bool((add_background or [True])[0]) + threshold = float((threshold or [0.5])[0]) + min_bg_area = float((min_bg_area or [0.0])[0]) + empty = (empty or ["keep"])[0] + + flat = self._flatten(masks) + extras = self._flatten(extra_mask) + n_in, n_extra = len(flat), len(extras) + + ref = flat[0] if flat else (extras[0] if extras else None) + # un extra_mask vide (ratio d'outpaint = none) n'ajoute jamais de region + extras = [m for m in extras if (m >= threshold).any()] + if empty == "full_image": + flat = [m for m in flat if (m >= threshold).any()] + flat = flat + extras if not flat: - empty = torch.zeros((1, 1), dtype=torch.float32) - return ([empty], empty.unsqueeze(0), 0) + if empty == "full_image" and ref is not None: + full = torch.ones_like(ref) + print(f"[RegionMaskList] aucune region ({n_in} masques, {n_extra} extra, vides) " + f"-> 1 region = image entiere (empty=full_image)") + return ([full], torch.zeros_like(ref).unsqueeze(0), 1) + empty_m = torch.zeros((1, 1), dtype=torch.float32) if ref is None else torch.zeros_like(ref) + return ([empty_m], empty_m.unsqueeze(0), 0) H, W = flat[0].shape union = torch.zeros((H, W), dtype=torch.float32) @@ -78,6 +114,7 @@ class RegionMaskList: if add_background and bg.mean().item() > min_bg_area: out.append(bg) - print(f"[RegionMaskList] {len(flat)} masques SAM3" + extra_info = f" (dont {len(extras)} extra)" if extras else "" + print(f"[RegionMaskList] {len(flat)} masques{extra_info}" f"{' + fond (inverse union)' if len(out) > len(flat) else ''} -> {len(out)} calques") return (out, bg.unsqueeze(0), len(out))