MogensR commited on
Commit
04ca462
·
1 Parent(s): ee9591a

Update utils/cv_processing.py

Browse files
Files changed (1) hide show
  1. utils/cv_processing.py +117 -147
utils/cv_processing.py CHANGED
@@ -8,14 +8,7 @@
8
  - refine_mask_hq(frame, mask, matanyone=None, fallback_enabled=True, **compat)
9
  - replace_background_hq(frame, mask, background, fallback_enabled=True)
10
  - create_professional_background(key_or_cfg, width, height)
11
- - create_gradient_background(spec, width, height)
12
  - validate_video_file(video_path) -> (bool, reason)
13
-
14
- Design:
15
- * NO imports from other utils.* modules → avoids circular imports.
16
- * Torch is imported lazily inside functions.
17
- * All masks are single-channel float32 in [0..1] at stage boundaries.
18
- * MatAnyOne gets (N,C,H,W) — no 5D tensors.
19
  """
20
 
21
  from __future__ import annotations
@@ -40,33 +33,30 @@
40
  "white": {"color": (255, 255, 255), "gradient": False},
41
  "black": {"color": (0, 0, 0), "gradient": False},
42
  }
43
- # Optional alias if callers import by this name
44
- PROFESSIONAL_BACKGROUNDS = PROFESSIONAL_BACKGROUNDS_LOCAL
45
 
46
  # ----------------------------------------------------------------------------
47
  # Helpers
48
  # ----------------------------------------------------------------------------
49
  def _ensure_rgb(img: np.ndarray) -> np.ndarray:
50
- """Convert BGR→RGB if it looks like BGR (OpenCV convention)."""
51
  if img is None:
52
  return img
53
  if img.ndim == 3 and img.shape[2] == 3:
 
54
  return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
55
  return img
56
 
57
  def _to_mask01(m: np.ndarray) -> np.ndarray:
58
- """Return single-channel float32 in [0..1]."""
59
  if m is None:
60
  return None
61
- if m.ndim == 3:
62
  m = m[..., 0]
63
- m = m.astype(np.float32, copy=False)
64
  if m.max() > 1.0:
65
  m = m / 255.0
66
  return np.clip(m, 0.0, 1.0)
67
 
68
  def _feather(mask01: np.ndarray, k: int = 2) -> np.ndarray:
69
- """Tiny Gaussian feather for smoother edges."""
70
  if mask01.ndim == 3:
71
  mask01 = mask01[..., 0]
72
  k = max(1, int(k) * 2 + 1)
@@ -83,13 +73,6 @@ def _vertical_gradient(top: Tuple[int,int,int], bottom: Tuple[int,int,int], widt
83
  bg[y, :] = (r, g, b)
84
  return bg
85
 
86
- def _rotate_image(img: np.ndarray, angle_deg: float) -> np.ndarray:
87
- if float(angle_deg) % 360 == 0:
88
- return img
89
- h, w = img.shape[:2]
90
- M = cv2.getRotationMatrix2D((w/2, h/2), float(angle_deg), 1.0)
91
- return cv2.warpAffine(img, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT_101)
92
-
93
  def _looks_like_mask(x: Any) -> bool:
94
  return (
95
  isinstance(x, np.ndarray)
@@ -102,12 +85,6 @@ def _looks_like_mask(x: Any) -> bool:
102
  # Background creation (RGB)
103
  # ----------------------------------------------------------------------------
104
  def create_professional_background(key_or_cfg: Any, width: int, height: int) -> np.ndarray:
105
- """
106
- Accepts:
107
- - key: str in preset dict
108
- - cfg: {"color": (r,g,b), "gradient": bool}
109
- Returns RGB uint8 image (H,W,3).
110
- """
111
  if isinstance(key_or_cfg, str):
112
  cfg = PROFESSIONAL_BACKGROUNDS_LOCAL.get(key_or_cfg, PROFESSIONAL_BACKGROUNDS_LOCAL["office"])
113
  elif isinstance(key_or_cfg, dict):
@@ -124,41 +101,10 @@ def create_professional_background(key_or_cfg: Any, width: int, height: int) ->
124
  dark = (int(color[0]*0.7), int(color[1]*0.7), int(color[2]*0.7))
125
  return _vertical_gradient(dark, color, width, height)
126
 
127
- def create_gradient_background(spec: Dict[str, Any], width: int, height: int) -> np.ndarray:
128
- """
129
- spec: {
130
- "type": "linear" | "radial",
131
- "start": (r,g,b),
132
- "end": (r,g,b),
133
- "angle_deg": float # for linear only
134
- }
135
- Returns RGB uint8 (H,W,3).
136
- """
137
- gtype = str(spec.get("type", "linear")).lower()
138
- start = tuple(int(c) for c in spec.get("start", (34,34,34)))
139
- end = tuple(int(c) for c in spec.get("end", (200,200,200)))
140
- if gtype == "radial":
141
- yy, xx = np.mgrid[0:height, 0:width]
142
- cx, cy = width / 2.0, height / 2.0
143
- dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
144
- dist = dist / (dist.max() + 1e-6)
145
- dist = np.clip(dist, 0.0, 1.0).astype(np.float32)
146
- bg = np.zeros((height, width, 3), dtype=np.uint8)
147
- for i, (s, e) in enumerate(zip(start, end)):
148
- channel = (s * (1.0 - dist) + e * dist).astype(np.float32)
149
- bg[..., i] = np.clip(channel, 0, 255).astype(np.uint8)
150
- return bg
151
- else:
152
- # linear: vertical interpolate then rotate to angle
153
- angle = float(spec.get("angle_deg", 0.0))
154
- bg = _vertical_gradient(start, end, width, height)
155
- return _rotate_image(bg, angle)
156
-
157
  # ----------------------------------------------------------------------------
158
  # Segmentation
159
  # ----------------------------------------------------------------------------
160
  def _simple_person_segmentation(frame_bgr: np.ndarray) -> np.ndarray:
161
- """Very simple fallback segmentation by suppressing green/white backgrounds."""
162
  hsv = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2HSV)
163
 
164
  lower_green = np.array([40, 40, 40], dtype=np.uint8)
@@ -186,10 +132,6 @@ def segment_person_hq(
186
  use_sam2: Optional[bool] = None,
187
  **_compat_kwargs,
188
  ) -> np.ndarray:
189
- """
190
- Try SAM2 predictor if available; return single-channel float32 mask in [0..1].
191
- Backward-compat: accepts use_sam2 (if False → force fallback).
192
- """
193
  try:
194
  if use_sam2 is False:
195
  return _simple_person_segmentation(frame)
@@ -200,26 +142,17 @@ def segment_person_hq(
200
  h, w = rgb.shape[:2]
201
  center = np.array([[w // 2, h // 2]])
202
  labels = np.array([1])
203
-
204
- res = predictor.predict(
205
  point_coords=center,
206
  point_labels=labels,
207
  multimask_output=True
208
  )
209
-
210
- # SAM2 predictors often return (masks, scores, logits)
211
- if isinstance(res, tuple) and len(res) >= 1:
212
- masks, scores = res[0], (res[1] if len(res) > 1 else None)
213
- else:
214
- masks, scores = res, None
215
-
216
  m = np.array(masks)
217
- if m.ndim == 3: # (N,H,W)
218
  idx = int(np.argmax(scores)) if scores is not None else 0
219
  m = m[idx]
220
- elif m.ndim != 2: # not (H,W)
221
  raise RuntimeError(f"Unexpected SAM2 mask shape: {m.shape}")
222
-
223
  return _to_mask01(m)
224
 
225
  except Exception as e:
@@ -227,11 +160,10 @@ def segment_person_hq(
227
 
228
  return _simple_person_segmentation(frame) if fallback_enabled else np.ones(frame.shape[:2], dtype=np.float32)
229
 
230
- # Back-compat alias
231
- segment_person_hq_original = segment_person_hq
232
 
233
  # ----------------------------------------------------------------------------
234
- # Refinement (MatAnyOne)
235
  # ----------------------------------------------------------------------------
236
  def _to_tensor_chw(img_uint8_bgr: np.ndarray) -> "torch.Tensor":
237
  import torch
@@ -250,12 +182,93 @@ def _tensor_to_mask01(t: "torch.Tensor") -> np.ndarray:
250
  t = t[0]
251
  return np.clip(t.detach().float().cpu().numpy(), 0.0, 1.0)
252
 
253
- def _simple_mask_refinement(mask01: np.ndarray) -> np.ndarray:
 
 
 
 
 
 
 
 
 
 
 
254
  m = (mask01 * 255.0).astype(np.uint8)
255
- m = cv2.GaussianBlur(m, (5, 5), 0)
256
- m = cv2.bilateralFilter(m, 9, 75, 75)
 
 
 
 
257
  return (m.astype(np.float32) / 255.0)
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  def refine_mask_hq(
260
  frame: np.ndarray,
261
  mask: np.ndarray,
@@ -270,63 +283,32 @@ def refine_mask_hq(
270
  Backward-compat:
271
  - accepts use_matanyone (False → skip model)
272
  - tolerates legacy arg order refine_mask_hq(mask, frame, ...)
273
- - accepts mat_core=<processor> in kwargs
274
  """
275
  # tolerate legacy order: refine_mask_hq(mask, frame, ...)
276
- if _looks_like_mask(frame) and isinstance(mask, np.ndarray) and mask.ndim == 3 and mask.shape[2] == 3:
277
- frame, mask = mask, frame
278
-
279
- # prefer explicitly passed matanyone, else legacy kw
280
- if matanyone is None and "mat_core" in _compat_kwargs:
281
- matanyone = _compat_kwargs.get("mat_core")
282
 
283
  mask01 = _to_mask01(mask)
284
 
285
- try:
286
- if use_matanyone is False:
287
- return _simple_mask_refinement(mask01)
288
-
289
- if matanyone is not None:
290
- import torch
291
-
292
- img_t = _to_tensor_chw(frame).unsqueeze(0) # (1,3,H,W)
293
- mask_t = _mask_to_tensor01(mask01) # (1,1,H,W)
294
-
295
- device = "cuda" if torch.cuda.is_available() else "cpu"
296
- img_t = img_t.to(device)
297
- mask_t = mask_t.to(device)
298
-
299
- # Preferred path
300
- if hasattr(matanyone, "step"):
301
- try:
302
- with torch.inference_mode():
303
- out = matanyone.step(
304
- image_tensor=img_t,
305
- mask_tensor=mask_t,
306
- objects=None,
307
- first_frame_pred=True
308
- )
309
- if hasattr(matanyone, "output_prob_to_mask"):
310
- out = matanyone.output_prob_to_mask(out)
311
- return _tensor_to_mask01(out)
312
- except Exception as e:
313
- logger.warning("MatAnyOne .step path failed: %s ; trying .process fallback if available", e)
314
-
315
- # Generic fallback
316
- if hasattr(matanyone, "process"):
317
- try:
318
- refined = matanyone.process(frame, mask01) # accepts numpy/PIL in many builds
319
- refined = np.asarray(refined).astype(np.float32)
320
- return _to_mask01(refined)
321
- except Exception as e:
322
- logger.warning("MatAnyOne .process path also failed: %s", e)
323
-
324
- logger.warning("MatAnyOne provided but neither 'step' nor 'process' usable.")
325
-
326
- except Exception as e:
327
- logger.warning("MatAnyOne refinement failed: %s", e)
328
-
329
- return _simple_mask_refinement(mask01) if fallback_enabled else mask01
330
 
331
  # ----------------------------------------------------------------------------
332
  # Compositing
@@ -338,21 +320,14 @@ def replace_background_hq(
338
  fallback_enabled: bool = True,
339
  **_compat,
340
  ) -> np.ndarray:
341
- """
342
- Composite frame over background using feathered mask.
343
- Inputs:
344
- - frame: (H,W,3) uint8 (BGR or RGB, linear blend anyway)
345
- - mask01: (H,W) or (H,W,1) float32 in [0..1]
346
- - background: (H,W,3) uint8
347
- Returns:
348
- - composited frame (H,W,3) uint8
349
- """
350
  try:
351
  H, W = frame.shape[:2]
352
  if background.shape[:2] != (H, W):
353
  background = cv2.resize(background, (W, H), interpolation=cv2.INTER_LANCZOS4)
354
 
355
- m = _feather(_to_mask01(mask01), k=2)
 
 
356
  m3 = np.repeat(m[:, :, None], 3, axis=2)
357
 
358
  comp = frame.astype(np.float32) * m3 + background.astype(np.float32) * (1.0 - m3)
@@ -367,10 +342,6 @@ def replace_background_hq(
367
  # Video validation
368
  # ----------------------------------------------------------------------------
369
  def validate_video_file(video_path: str) -> Tuple[bool, str]:
370
- """
371
- Quick sanity-check before passing a file to OpenCV / FFmpeg.
372
- Returns (ok, human_readable_reason)
373
- """
374
  if not video_path or not Path(video_path).exists():
375
  return False, "Video file not found"
376
 
@@ -417,7 +388,6 @@ def validate_video_file(video_path: str) -> Tuple[bool, str]:
417
  "refine_mask_hq",
418
  "replace_background_hq",
419
  "create_professional_background",
420
- "create_gradient_background",
421
  "validate_video_file",
422
  "PROFESSIONAL_BACKGROUNDS",
423
  ]
 
8
  - refine_mask_hq(frame, mask, matanyone=None, fallback_enabled=True, **compat)
9
  - replace_background_hq(frame, mask, background, fallback_enabled=True)
10
  - create_professional_background(key_or_cfg, width, height)
 
11
  - validate_video_file(video_path) -> (bool, reason)
 
 
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
 
33
  "white": {"color": (255, 255, 255), "gradient": False},
34
  "black": {"color": (0, 0, 0), "gradient": False},
35
  }
36
+ PROFESSIONAL_BACKGROUNDS = PROFESSIONAL_BACKGROUNDS_LOCAL # alias for callers
 
37
 
38
  # ----------------------------------------------------------------------------
39
  # Helpers
40
  # ----------------------------------------------------------------------------
41
  def _ensure_rgb(img: np.ndarray) -> np.ndarray:
 
42
  if img is None:
43
  return img
44
  if img.ndim == 3 and img.shape[2] == 3:
45
+ # Assume OpenCV BGR → convert to RGB
46
  return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
47
  return img
48
 
49
  def _to_mask01(m: np.ndarray) -> np.ndarray:
 
50
  if m is None:
51
  return None
52
+ if m.ndim == 3 and m.shape[2] in (1, 3):
53
  m = m[..., 0]
54
+ m = m.astype(np.float32)
55
  if m.max() > 1.0:
56
  m = m / 255.0
57
  return np.clip(m, 0.0, 1.0)
58
 
59
  def _feather(mask01: np.ndarray, k: int = 2) -> np.ndarray:
 
60
  if mask01.ndim == 3:
61
  mask01 = mask01[..., 0]
62
  k = max(1, int(k) * 2 + 1)
 
73
  bg[y, :] = (r, g, b)
74
  return bg
75
 
 
 
 
 
 
 
 
76
  def _looks_like_mask(x: Any) -> bool:
77
  return (
78
  isinstance(x, np.ndarray)
 
85
  # Background creation (RGB)
86
  # ----------------------------------------------------------------------------
87
  def create_professional_background(key_or_cfg: Any, width: int, height: int) -> np.ndarray:
 
 
 
 
 
 
88
  if isinstance(key_or_cfg, str):
89
  cfg = PROFESSIONAL_BACKGROUNDS_LOCAL.get(key_or_cfg, PROFESSIONAL_BACKGROUNDS_LOCAL["office"])
90
  elif isinstance(key_or_cfg, dict):
 
101
  dark = (int(color[0]*0.7), int(color[1]*0.7), int(color[2]*0.7))
102
  return _vertical_gradient(dark, color, width, height)
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  # ----------------------------------------------------------------------------
105
  # Segmentation
106
  # ----------------------------------------------------------------------------
107
  def _simple_person_segmentation(frame_bgr: np.ndarray) -> np.ndarray:
 
108
  hsv = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2HSV)
109
 
110
  lower_green = np.array([40, 40, 40], dtype=np.uint8)
 
132
  use_sam2: Optional[bool] = None,
133
  **_compat_kwargs,
134
  ) -> np.ndarray:
 
 
 
 
135
  try:
136
  if use_sam2 is False:
137
  return _simple_person_segmentation(frame)
 
142
  h, w = rgb.shape[:2]
143
  center = np.array([[w // 2, h // 2]])
144
  labels = np.array([1])
145
+ masks, scores, _ = predictor.predict(
 
146
  point_coords=center,
147
  point_labels=labels,
148
  multimask_output=True
149
  )
 
 
 
 
 
 
 
150
  m = np.array(masks)
151
+ if m.ndim == 3:
152
  idx = int(np.argmax(scores)) if scores is not None else 0
153
  m = m[idx]
154
+ elif m.ndim != 2:
155
  raise RuntimeError(f"Unexpected SAM2 mask shape: {m.shape}")
 
156
  return _to_mask01(m)
157
 
158
  except Exception as e:
 
160
 
161
  return _simple_person_segmentation(frame) if fallback_enabled else np.ones(frame.shape[:2], dtype=np.float32)
162
 
163
+ segment_person_hq_original = segment_person_hq # back-compat alias
 
164
 
165
  # ----------------------------------------------------------------------------
166
+ # MatAnyOne helpers
167
  # ----------------------------------------------------------------------------
168
  def _to_tensor_chw(img_uint8_bgr: np.ndarray) -> "torch.Tensor":
169
  import torch
 
182
  t = t[0]
183
  return np.clip(t.detach().float().cpu().numpy(), 0.0, 1.0)
184
 
185
+ def _remap_harden(mask01: np.ndarray, inside: float = 0.70, outside: float = 0.35) -> np.ndarray:
186
+ """
187
+ Pull the mask toward {0,1} to avoid 'ghost' translucency.
188
+ Values <= outside -> 0; >= inside -> 1; linear in between.
189
+ """
190
+ m = mask01.astype(np.float32)
191
+ if inside <= outside:
192
+ return m
193
+ m = (m - outside) / max(1e-6, (inside - outside))
194
+ return np.clip(m, 0.0, 1.0)
195
+
196
+ def _pad_and_smooth_edges(mask01: np.ndarray, dilate_px: int = 6, edge_blur_px: int = 2) -> np.ndarray:
197
  m = (mask01 * 255.0).astype(np.uint8)
198
+ if dilate_px > 0:
199
+ k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate_px, dilate_px))
200
+ m = cv2.dilate(m, k, iterations=1)
201
+ if edge_blur_px > 0:
202
+ ksize = edge_blur_px * 2 + 1
203
+ m = cv2.GaussianBlur(m, (ksize, ksize), 0)
204
  return (m.astype(np.float32) / 255.0)
205
 
206
+ def _try_matanyone_refine(
207
+ matanyone: Any,
208
+ frame_bgr: np.ndarray,
209
+ mask01: np.ndarray
210
+ ) -> Optional[np.ndarray]:
211
+ """
212
+ Try several MatAnyOne interfaces:
213
+ 1) InferenceCore.infer(PIL_image, PIL_mask)
214
+ 2) .step(image_tensor=NCHW, mask_tensor=NCHW)
215
+ 3) .process(image_np, mask_np)
216
+ 4) callable(image_tensor, mask_tensor) → tensor
217
+ Returns refined mask01 (np.ndarray) or None if not usable.
218
+ """
219
+ try:
220
+ # --- (1) PIL infer path ------------------------------------------------
221
+ if hasattr(matanyone, "infer"):
222
+ try:
223
+ from PIL import Image
224
+ img_pil = Image.fromarray(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
225
+ m_pil = Image.fromarray((mask01 * 255.0).astype(np.uint8))
226
+ out_pil = matanyone.infer(img_pil, m_pil)
227
+ out_np = np.asarray(out_pil).astype(np.float32)
228
+ return _to_mask01(out_np)
229
+ except Exception as e:
230
+ logger.debug("MatAnyOne.infer path failed: %s", e)
231
+
232
+ # --- (2) tensor .step path --------------------------------------------
233
+ if hasattr(matanyone, "step"):
234
+ import torch
235
+ device = "cuda" if torch.cuda.is_available() else "cpu"
236
+ img_t = _to_tensor_chw(frame_bgr).unsqueeze(0).to(device) # (1,3,H,W)
237
+ mask_t = _mask_to_tensor01(mask01).to(device) # (1,1,H,W)
238
+ with torch.inference_mode():
239
+ out = matanyone.step(
240
+ image_tensor=img_t,
241
+ mask_tensor=mask_t,
242
+ objects=None,
243
+ first_frame_pred=True
244
+ )
245
+ if hasattr(matanyone, "output_prob_to_mask"):
246
+ out = matanyone.output_prob_to_mask(out)
247
+ return _tensor_to_mask01(out)
248
+
249
+ # --- (3) numpy .process path ------------------------------------------
250
+ if hasattr(matanyone, "process"):
251
+ out = matanyone.process(frame_bgr, mask01)
252
+ return _to_mask01(np.asarray(out))
253
+
254
+ # --- (4) callable / nn.Module path ------------------------------------
255
+ if callable(matanyone):
256
+ import torch
257
+ device = "cuda" if torch.cuda.is_available() else "cpu"
258
+ img_t = _to_tensor_chw(frame_bgr).unsqueeze(0).to(device)
259
+ mask_t = _mask_to_tensor01(mask01).to(device)
260
+ with torch.inference_mode():
261
+ out = matanyone(img_t, mask_t)
262
+ return _tensor_to_mask01(out)
263
+
264
+ except Exception as e:
265
+ logger.warning("MatAnyOne refine error: %s", e)
266
+
267
+ return None
268
+
269
+ # ----------------------------------------------------------------------------
270
+ # Refinement (MatAnyOne)
271
+ # ----------------------------------------------------------------------------
272
  def refine_mask_hq(
273
  frame: np.ndarray,
274
  mask: np.ndarray,
 
283
  Backward-compat:
284
  - accepts use_matanyone (False → skip model)
285
  - tolerates legacy arg order refine_mask_hq(mask, frame, ...)
 
286
  """
287
  # tolerate legacy order: refine_mask_hq(mask, frame, ...)
288
+ if _looks_like_mask(frame) and _looks_like_mask(mask) and mask.ndim == 3 and mask.shape[2] == 3:
289
+ frame, mask = mask, frame # swap
 
 
 
 
290
 
291
  mask01 = _to_mask01(mask)
292
 
293
+ # Use MatAnyOne when possible
294
+ if use_matanyone is not False and matanyone is not None:
295
+ refined = _try_matanyone_refine(matanyone, frame, mask01)
296
+ if refined is not None:
297
+ # Hardening + edge handling to avoid translucent body/halo
298
+ refined = _remap_harden(refined, inside=0.70, outside=0.35)
299
+ refined = _pad_and_smooth_edges(refined, dilate_px=4, edge_blur_px=1)
300
+ return refined
301
+ else:
302
+ logger.warning("MatAnyOne provided but no usable interface found; falling back.")
303
+
304
+ # Simple refinement fallback
305
+ m = (mask01 * 255.0).astype(np.uint8)
306
+ m = cv2.GaussianBlur(m, (5, 5), 0)
307
+ m = cv2.bilateralFilter(m, 9, 75, 75)
308
+ m = (m.astype(np.float32) / 255.0)
309
+ m = _remap_harden(m, inside=0.68, outside=0.40)
310
+ m = _pad_and_smooth_edges(m, dilate_px=3, edge_blur_px=1)
311
+ return m if fallback_enabled else mask01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
 
313
  # ----------------------------------------------------------------------------
314
  # Compositing
 
320
  fallback_enabled: bool = True,
321
  **_compat,
322
  ) -> np.ndarray:
 
 
 
 
 
 
 
 
 
323
  try:
324
  H, W = frame.shape[:2]
325
  if background.shape[:2] != (H, W):
326
  background = cv2.resize(background, (W, H), interpolation=cv2.INTER_LANCZOS4)
327
 
328
+ m = _to_mask01(mask01)
329
+ # Very light feather to hide stair-steps; most shaping already done
330
+ m = _feather(m, k=1)
331
  m3 = np.repeat(m[:, :, None], 3, axis=2)
332
 
333
  comp = frame.astype(np.float32) * m3 + background.astype(np.float32) * (1.0 - m3)
 
342
  # Video validation
343
  # ----------------------------------------------------------------------------
344
  def validate_video_file(video_path: str) -> Tuple[bool, str]:
 
 
 
 
345
  if not video_path or not Path(video_path).exists():
346
  return False, "Video file not found"
347
 
 
388
  "refine_mask_hq",
389
  "replace_background_hq",
390
  "create_professional_background",
 
391
  "validate_video_file",
392
  "PROFESSIONAL_BACKGROUNDS",
393
  ]