Ratio Outpaint Calc : mask_padded + masque des bandes exact ; Region Mask List : extra_mask / empty

- Ratio Outpaint Calc : entree optionnelle mask -> sortie mask_padded
  (paddee exactement comme l'image). Masque des bandes construit avec le
  meme pad que l'image : il etait faux quand l'arrondi au multiple rognait
  la source (pad negatif).
- Region Mask List : extra_mask (une region de plus, non decoupee, ex. les
  bandes d'outpaint) et empty = keep / full_image (aucune region -> image
  entiere). Par defaut : comportement inchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScF8qKYdotua6aKovsgdxV
This commit is contained in:
/ai.oli
2026-10-12 19:46:44 +02:00
co-authored by Claude Opus 5.5
parent 62da29a66e
commit ea2ef591a3
2 changed files with 96 additions and 21 deletions
+48 -10
View File
@@ -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)
+48 -11
View File
@@ -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))