@@ -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+
129161def _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
595627def _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