Skip to content

Commit 26d6604

Browse files
committed
InpaintSuite: expand-before-blur blend masks; auto-oval mask; fix paste-back feathering
1 parent 0d4b0cf commit 26d6604

1 file changed

Lines changed: 77 additions & 16 deletions

File tree

nodes/inpaint_suite.py

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,38 @@ def _erode_mask(mask: torch.Tensor, radius: int) -> torch.Tensor:
126126
return eroded.squeeze(1)
127127

128128

129+
def _expand_mask(mask: torch.Tensor, radius: int) -> torch.Tensor:
130+
"""Expand (dilate) a (B, H, W) mask outward by `radius` pixels via max-pool."""
131+
if radius <= 0:
132+
return mask
133+
k = 2 * int(radius) + 1
134+
m4 = mask.unsqueeze(1)
135+
expanded = F.max_pool2d(m4, kernel_size=k, stride=1, padding=k // 2)
136+
return expanded.squeeze(1).clamp(0.0, 1.0)
137+
138+
139+
def _oval_face_mask(B: int, H: int, W: int,
140+
device: torch.device, dtype: torch.dtype) -> torch.Tensor:
141+
"""Soft elliptical alpha mask inscribed in (H, W), shape (B, H, W).
142+
143+
The ellipse exactly fits the crop frame (major axes = H/2, W/2).
144+
Values are 1.0 well inside and fade smoothly to 0.0 at the boundary.
145+
Used to remove blocky corner artefacts when stitching back face crops.
146+
"""
147+
cy, cx = (H - 1) / 2.0, (W - 1) / 2.0
148+
ry = max(cy, 1e-6)
149+
rx = max(cx, 1e-6)
150+
y = torch.arange(H, device=device, dtype=dtype) - cy # (H,)
151+
x = torch.arange(W, device=device, dtype=dtype) - cx # (W,)
152+
# Normalised radial distance: 0 at centre, 1 at ellipse boundary, >1 outside
153+
r = ((y.unsqueeze(1) / ry) ** 2 + (x.unsqueeze(0) / rx) ** 2).sqrt() # (H, W)
154+
# Linear fade: 1.0 at centre, 0.0 at boundary, clamp at 0
155+
alpha = (1.0 - r).clamp(0.0, 1.0)
156+
# Lightly blur to anti-alias the oval boundary
157+
alpha_soft = _gaussian_blur_mask(alpha.unsqueeze(0), sigma=2.0).squeeze(0)
158+
return alpha_soft.unsqueeze(0).expand(B, -1, -1) # (B, H, W)
159+
160+
129161
def _opaque_core_with_feather(soft_mask: torch.Tensor,
130162
feathered: torch.Tensor,
131163
radius: int) -> torch.Tensor:
@@ -593,30 +625,53 @@ def _apply_inpaint_mask_mode(mask: torch.Tensor, mode: str) -> torch.Tensor:
593625
# ══════════════════════════════════════════════════════════════════════
594626

595627
def _generate_stitch_blend_mask(image: torch.Tensor, mask: torch.Tensor,
596-
mode: str, radius: int) -> torch.Tensor:
628+
mode: str, radius: int,
629+
shape: str = "auto") -> torch.Tensor:
597630
"""Generate the stitch blend mask based on the selected mode.
598631
599632
image: (B, H, W, C)
600633
mask: (B, H, W)
601634
mode: 'edge_aware' | 'gaussian' | 'laplacian_pyramid' | 'frequency_blend'
602635
radius: feather radius
636+
shape: 'auto' | 'oval' | 'rectangle'
637+
auto → oval for portrait crops (H ≥ W), rectangle for landscape
603638
Returns: (B, H, W) soft blend mask
604639
605-
All modes produce a *bidirectional* feather: the transition zone straddles
606-
the binary boundary so the seam disappears smoothly on both sides.
640+
Key improvement over the old implementation: the mask is *expanded* outward
641+
by ``radius`` pixels before blurring. This matches the pattern used by
642+
InpaintCropAndStitch and ensures the feather zone straddles the boundary
643+
(half inside, half outside) rather than being entirely interior — which was
644+
the root cause of the hard-edge seam visible at paste-back time.
607645
"""
608-
soft = mask.clamp(0.0, 1.0)
646+
B, H, W, C = image.shape
647+
device = _get_device(mask)
648+
649+
# Optional oval shaping: replace rectangular mask with elliptical one.
650+
# Eliminates the blocky corner artefacts on face/portrait crops.
651+
use_oval = (shape == "oval") or (shape == "auto" and H >= W)
652+
if use_oval:
653+
oval = _oval_face_mask(B, H, W, device, mask.dtype)
654+
soft = (mask.clamp(0.0, 1.0) * oval).clamp(0.0, 1.0)
655+
else:
656+
soft = mask.clamp(0.0, 1.0)
657+
658+
# Expand outward BEFORE blurring — the key step that makes the feather
659+
# bidirectional (extends into the surrounding background area).
660+
expanded = _expand_mask(soft, radius) if radius > 0 else soft
661+
609662
if mode == "edge_aware":
610-
return _edge_aware_blend_mask(image, mask, radius)
663+
return _edge_aware_blend_mask(image, expanded, radius)
611664
elif mode == "gaussian":
612-
feathered = _gaussian_blur_mask(soft, sigma=radius * 0.4)
665+
feathered = _gaussian_blur_mask(expanded, sigma=radius * 0.5)
613666
elif mode == "laplacian_pyramid":
614-
feathered = _gaussian_blur_mask(soft, sigma=radius * 0.5)
667+
feathered = _gaussian_blur_mask(expanded, sigma=radius * 0.55)
615668
elif mode == "frequency_blend":
616-
feathered = _gaussian_blur_mask(soft, sigma=radius * 0.6)
669+
feathered = _gaussian_blur_mask(expanded, sigma=radius * 0.6)
617670
else:
618-
feathered = _gaussian_blur_mask(soft, sigma=radius * 0.4)
619-
# Keep interior fully opaque (see _opaque_core_with_feather docstring).
671+
feathered = _gaussian_blur_mask(expanded, sigma=radius * 0.5)
672+
673+
# Keep the original (pre-expand) mask's interior fully opaque so the
674+
# inpaint composites at 1.0 inside and only feathers at the boundary.
620675
return _opaque_core_with_feather(soft, feathered, radius)
621676

622677

@@ -1823,9 +1878,14 @@ def paste_back(self, stitch_data: Dict[str, Any], inpainted_image: torch.Tensor,
18231878
offsets_iter = [(int(fx), int(fy)) for (fx, fy) in frame_offsets]
18241879

18251880
if feather_active:
1826-
paste_mask = torch.ones(1, 1, ctc_h, ctc_w, device=device, dtype=torch.float32)
1827-
paste_mask = _gaussian_blur_2d(paste_mask, sigma=feather_radius)
1828-
pm3 = paste_mask.squeeze(0).squeeze(0).unsqueeze(-1)
1881+
# Correct feather: erode inward to get a zero-border ring,
1882+
# then blur to create a smooth edge ramp (0 at boundary → 1 inside).
1883+
# The old _gaussian_blur_2d on a ones-tensor did nothing useful
1884+
# due to replicate padding making the convolution of a constant = constant.
1885+
binary_ones = torch.ones(1, ctc_h, ctc_w, device=device, dtype=torch.float32)
1886+
core = _erode_mask(binary_ones, feather_radius)
1887+
pm_bw = _gaussian_blur_mask(core, sigma=feather_radius * 0.5).clamp(0.0, 1.0) # (1, H, W)
1888+
pm3 = pm_bw.squeeze(0).unsqueeze(-1) # (H, W, 1)
18291889
for b, (fx, fy) in enumerate(offsets_iter):
18301890
crop = canvas[b, fy:fy + ctc_h, fx:fx + ctc_w, :]
18311891
canvas[b, fy:fy + ctc_h, fx:fx + ctc_w, :] = (
@@ -1866,9 +1926,10 @@ def paste_back(self, stitch_data: Dict[str, Any], inpainted_image: torch.Tensor,
18661926
canvas = original_image.clone()
18671927

18681928
if feather_active:
1869-
paste_mask = torch.ones(1, 1, crop_h, crop_w, device=device, dtype=torch.float32)
1870-
paste_mask = _gaussian_blur_2d(paste_mask, sigma=feather_radius)
1871-
paste_mask_3d = paste_mask.squeeze(0).squeeze(0).unsqueeze(-1)
1929+
binary_ones = torch.ones(1, crop_h, crop_w, device=device, dtype=torch.float32)
1930+
core = _erode_mask(binary_ones, feather_radius)
1931+
pm_bw = _gaussian_blur_mask(core, sigma=feather_radius * 0.5).clamp(0.0, 1.0)
1932+
paste_mask_3d = pm_bw.squeeze(0).unsqueeze(-1) # (crop_h, crop_w, 1)
18721933
for b in _PB.track(range(B), B, "InpaintPasteBackMEC: paste"):
18731934
orig_crop = canvas[b, crop_y:crop_y + crop_h, crop_x:crop_x + crop_w, :]
18741935
canvas[b, crop_y:crop_y + crop_h, crop_x:crop_x + crop_w, :] = (

0 commit comments

Comments
 (0)