MogensR commited on
Commit
753ffed
·
1 Parent(s): 8d5b7b3

Update processing/two_stage/two_stage_processor.py

Browse files
processing/two_stage/two_stage_processor.py CHANGED
@@ -1,6 +1,6 @@
1
  #!/usr/bin/env python3
2
  """
3
- Two-Stage Green-Screen Processing System ✅ 2025-08-26
4
  Stage 1: Original → keyed background (auto-selected colour)
5
  Stage 2: Keyed video → final composite (hybrid chroma + segmentation rescue)
6
 
@@ -11,7 +11,7 @@
11
  * progress_callback(pct, desc)
12
  * fully self-contained – just drop in and import TwoStageProcessor
13
 
14
- Additional safety (2025-08-29):
15
  * Ensures MatAnyone receives a valid first-frame mask (bootstraps the session
16
  with the first SAM2 mask). This prevents "First frame arrived without a mask"
17
  warnings and shape mismatches inside the stateful refiner.
@@ -31,6 +31,9 @@
31
  except Exception:
32
  logger = logging.getLogger(__name__)
33
 
 
 
 
34
  def create_video_writer(output_path: str, fps: float, width: int, height: int, prefer_mp4: bool = True):
35
  try:
36
  ext = ".mp4" if prefer_mp4 else ".avi"
@@ -56,6 +59,9 @@ def create_video_writer(output_path: str, fps: float, width: int, height: int, p
56
  logger.error(f"create_video_writer failed: {e}")
57
  return None, output_path
58
 
 
 
 
59
  def _bgr_to_hsv_hue_deg(bgr: np.ndarray) -> np.ndarray:
60
  hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
61
  # OpenCV H is 0-180; scale to degrees 0-360
@@ -100,12 +106,18 @@ def _choose_best_key_color(frame_bgr: np.ndarray, mask_uint8: np.ndarray) -> dic
100
  return _key_candidates_bgr()["green"]
101
 
102
 
 
 
 
103
  CHROMA_PRESETS: Dict[str, Dict[str, Any]] = {
104
  'standard': {'key_color': [0,255,0], 'tolerance': 38, 'edge_softness': 2, 'spill_suppression': 0.35},
105
  'studio': {'key_color': [0,255,0], 'tolerance': 30, 'edge_softness': 1, 'spill_suppression': 0.45},
106
  'outdoor': {'key_color': [0,255,0], 'tolerance': 50, 'edge_softness': 3, 'spill_suppression': 0.25},
107
  }
108
 
 
 
 
109
  class TwoStageProcessor:
110
  def __init__(self, sam2_predictor=None, matanyone_model=None):
111
  self.sam2 = self._unwrap_sam2(sam2_predictor)
@@ -113,8 +125,9 @@ def __init__(self, sam2_predictor=None, matanyone_model=None):
113
  self.mask_cache_dir = Path("/tmp/mask_cache")
114
  self.mask_cache_dir.mkdir(parents=True, exist_ok=True)
115
 
116
- # Internal flag: we "boot" MatAnyone exactly once per video
117
  self._mat_bootstrapped = False
 
118
 
119
  logger.info(f"TwoStageProcessor init – SAM2: {self.sam2 is not None} | MatAnyOne: {self.matanyone is not None}")
120
 
@@ -147,7 +160,7 @@ def _get_mask(self, frame: np.ndarray) -> np.ndarray:
147
  return mask
148
 
149
  @staticmethod
150
- def _to_binary_mask(mask: np.ndarray) -> np.ndarray:
151
  """Convert mask to uint8(0..255)."""
152
  if mask is None:
153
  return None
@@ -159,7 +172,7 @@ def _to_binary_mask(mask: np.ndarray) -> np.ndarray:
159
  return mask
160
 
161
  @staticmethod
162
- def _to_float01(mask: np.ndarray, h: int = None, w: int = None) -> np.ndarray:
163
  """Float [0,1] mask, optionally resized to (h,w)."""
164
  if mask is None:
165
  return None
@@ -177,37 +190,79 @@ def _apply_greenscreen_hard(self, frame: np.ndarray, mask: np.ndarray, bg: np.nd
177
  result = frame * mask_norm + bg * (1 - mask_norm)
178
  return result.astype(np.uint8)
179
 
180
- def _suppress_green_spill(self, frame, amount=0.35):
181
- """Suppress green spill in the frame."""
182
- b, g, r = cv2.split(frame)
183
- green_dominant = (g > b) & (g > r)
184
- avg_br = (b.astype(np.float32) + r.astype(np.float32)) / 2
185
- g_float = g.astype(np.float32)
186
- g_float[green_dominant] = g_float[green_dominant] * (1 - amount) + avg_br[green_dominant] * amount
187
- g = np.clip(g_float, 0, 255).astype(np.uint8)
188
- return cv2.merge([b, g, r])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
- # --------------------- NEW: MatAnyone bootstrap ----------------------
 
 
 
 
 
191
 
 
192
  def _bootstrap_matanyone_if_needed(self, frame_bgr: np.ndarray, coarse_mask: np.ndarray):
193
  """
194
  Call the MatAnyone session ONCE with the first coarse mask to initialize
195
- its memory. This guarantees downstream calls (e.g., refine_mask_hq) never
196
- hit "first frame without a mask".
197
  """
198
  if self.matanyone is None or self._mat_bootstrapped:
199
  return
200
-
201
  try:
202
  h, w = frame_bgr.shape[:2]
203
  mask_f = self._to_float01(coarse_mask, h, w)
204
- # MatAnyone loaders expect RGB inputs; convert BGR→RGB
205
  rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
206
- # Call the stateful session directly; it returns alpha [H,W] but we ignore it here.
207
- _ = self.matanyone(rgb, mask_f)
208
  self._mat_bootstrapped = True
209
  logger.info("MatAnyone session bootstrapped with first-frame mask.")
210
- }except Exception as e:
211
  logger.warning(f"MatAnyone bootstrap failed (continuing without): {e}")
212
 
213
  # ---------------------------------------------------------------------
@@ -252,7 +307,7 @@ def _prog(p, d):
252
  masks: List[np.ndarray] = []
253
  frame_idx = 0
254
 
255
- green_bg_template = np.zeros((h, w, 3), np.uint8) # overwritten per-frame
256
 
257
  while True:
258
  if stop_event and stop_event.is_set():
@@ -263,7 +318,7 @@ def _prog(p, d):
263
  if not ok:
264
  break
265
 
266
- # --- SAM2 segmentation (project helper) ---
267
  mask = self._get_mask(frame)
268
 
269
  # --- MatAnyone bootstrap exactly once (first frame) ---
@@ -285,18 +340,17 @@ def _prog(p, d):
285
  probe_done = True
286
  logger.info(f"[TwoStage] Using key colour: {key_color_mode} → {chosen_bgr.tolist()}")
287
 
288
- # --- Optional refinement via MatAnyone every few frames (keep your cadence) ---
289
  if self.matanyone and (frame_idx % 3 == 0):
290
  try:
291
- # refine_mask_hq(frame_bgr, mask_uint8_or_float01, mat_session, fallback_enabled=True)
292
  mask = refine_mask_hq(frame, mask, self.matanyone, fallback_enabled=True)
293
  except Exception as e:
294
  logger.warning(f"MatAnyOne refine fail f={frame_idx}: {e}")
295
 
296
  # --- Composite onto solid key colour ---
297
- green_bg_template[:] = chosen_bgr
298
  mask_u8 = self._to_binary_mask(mask)
299
- gs = self._apply_greenscreen_hard(frame, mask_u8, green_bg_template)
300
  writer.write(gs)
301
  masks.append(mask_u8)
302
 
@@ -307,7 +361,7 @@ def _prog(p, d):
307
  cap.release()
308
  writer.release()
309
 
310
- # save mask cache (unchanged)
311
  try:
312
  cache_file = self.mask_cache_dir / (Path(out_path).stem + "_masks.pkl")
313
  with open(cache_file, "wb") as f:
@@ -337,6 +391,7 @@ def stage2_greenscreen_to_final(
337
  chroma_settings: Optional[Dict[str, Any]] = None,
338
  progress_callback: Optional[Callable[[float, str], None]] = None,
339
  stop_event: Optional["threading.Event"] = None,
 
340
  ) -> Tuple[Optional[str], str]:
341
 
342
  def _prog(p, d):
@@ -385,9 +440,15 @@ def _prog(p, d):
385
 
386
  # Get chroma settings
387
  settings = chroma_settings or CHROMA_PRESETS.get('standard', {})
388
- tolerance = settings.get('tolerance', 38)
389
- edge_softness = settings.get('edge_softness', 2)
390
- spill_suppression = settings.get('spill_suppression', 0.35)
 
 
 
 
 
 
391
 
392
  frame_idx = 0
393
  while True:
@@ -406,7 +467,8 @@ def _prog(p, d):
406
  frame, bg, mask,
407
  tolerance=tolerance,
408
  edge_softness=edge_softness,
409
- spill_suppression=spill_suppression
 
410
  )
411
  else:
412
  # Pure chroma key
@@ -414,7 +476,8 @@ def _prog(p, d):
414
  frame, bg,
415
  tolerance=tolerance,
416
  edge_softness=edge_softness,
417
- spill_suppression=spill_suppression
 
418
  )
419
 
420
  writer.write(final_frame)
@@ -432,39 +495,54 @@ def _prog(p, d):
432
  logger.error(f"Stage 2 error: {e}\n{traceback.format_exc()}")
433
  return None, f"Stage 2 failed: {e}"
434
 
435
- def _chroma_key_composite(self, frame, bg, tolerance=38, edge_softness=2, spill_suppression=0.35):
436
- """Apply chroma key compositing."""
437
- hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
438
-
439
- # Basic green range (kept as-is)
440
- lower_green = np.array([40, 40, 40])
441
- upper_green = np.array([80, 255, 255])
442
 
443
- mask = cv2.inRange(hsv, lower_green, upper_green)
444
- mask = cv2.bitwise_not(mask)
 
 
 
 
 
 
 
445
 
 
446
  if edge_softness > 0:
447
  k = edge_softness * 2 + 1
448
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
449
- mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
450
- mask = cv2.GaussianBlur(mask, (k, k), 0)
451
 
452
- if spill_suppression > 0:
453
- frame = self._suppress_green_spill(frame, spill_suppression)
 
 
454
 
455
- mask_3ch = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
456
- mask_norm = mask_3ch.astype(np.float32) / 255.0
457
- result = frame * mask_norm + bg * (1 - mask_norm)
458
- return result.astype(np.uint8)
459
 
460
- def _hybrid_composite(self, frame, bg, mask, tolerance=38, edge_softness=2, spill_suppression=0.35):
461
  """Apply hybrid compositing using both chroma key and cached mask."""
462
- chroma_result = self._chroma_key_composite(frame, bg, tolerance, edge_softness, spill_suppression)
 
 
 
 
 
 
463
  if mask is not None:
464
  mask_3ch = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) if mask.ndim == 2 else mask
465
  mask_norm = mask_3ch.astype(np.float32) / 255.0
466
- result = chroma_result * 0.7 + (frame * mask_norm + bg * (1 - mask_norm)) * 0.3
467
- return result.astype(np.uint8)
 
468
  return chroma_result
469
 
470
  # ---------------------------------------------------------------------
@@ -497,8 +575,9 @@ def _combined_progress(pct, desc):
497
  pass
498
 
499
  try:
500
- # Reset MatAnyone bootstrap flag between videos
501
  self._mat_bootstrapped = False
 
502
  if self.matanyone is not None and hasattr(self.matanyone, "reset"):
503
  try:
504
  self.matanyone.reset()
@@ -513,16 +592,17 @@ def _combined_progress(pct, desc):
513
  progress_callback=_combined_progress,
514
  stop_event=stop_event
515
  )
516
-
517
  if stage1_result is None:
518
  return None, stage1_msg
519
 
520
- # Stage 2
 
521
  final_path, stage2_msg = self.stage2_greenscreen_to_final(
522
  stage1_result["path"], background, output_path,
523
  chroma_settings=chroma_settings,
524
  progress_callback=_combined_progress,
525
- stop_event=stop_event
 
526
  )
527
 
528
  # Clean up temp file
 
1
  #!/usr/bin/env python3
2
  """
3
+ Two-Stage Green-Screen Processing System ✅ 2025-08-29
4
  Stage 1: Original → keyed background (auto-selected colour)
5
  Stage 2: Keyed video → final composite (hybrid chroma + segmentation rescue)
6
 
 
11
  * progress_callback(pct, desc)
12
  * fully self-contained – just drop in and import TwoStageProcessor
13
 
14
+ Additional safety:
15
  * Ensures MatAnyone receives a valid first-frame mask (bootstraps the session
16
  with the first SAM2 mask). This prevents "First frame arrived without a mask"
17
  warnings and shape mismatches inside the stateful refiner.
 
31
  except Exception:
32
  logger = logging.getLogger(__name__)
33
 
34
+ # ---------------------------------------------------------------------------
35
+ # Local video-writer helper
36
+ # ---------------------------------------------------------------------------
37
  def create_video_writer(output_path: str, fps: float, width: int, height: int, prefer_mp4: bool = True):
38
  try:
39
  ext = ".mp4" if prefer_mp4 else ".avi"
 
59
  logger.error(f"create_video_writer failed: {e}")
60
  return None, output_path
61
 
62
+ # ---------------------------------------------------------------------------
63
+ # Key-colour helpers (fast, no external deps)
64
+ # ---------------------------------------------------------------------------
65
  def _bgr_to_hsv_hue_deg(bgr: np.ndarray) -> np.ndarray:
66
  hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
67
  # OpenCV H is 0-180; scale to degrees 0-360
 
106
  return _key_candidates_bgr()["green"]
107
 
108
 
109
+ # ---------------------------------------------------------------------------
110
+ # Chroma presets
111
+ # ---------------------------------------------------------------------------
112
  CHROMA_PRESETS: Dict[str, Dict[str, Any]] = {
113
  'standard': {'key_color': [0,255,0], 'tolerance': 38, 'edge_softness': 2, 'spill_suppression': 0.35},
114
  'studio': {'key_color': [0,255,0], 'tolerance': 30, 'edge_softness': 1, 'spill_suppression': 0.45},
115
  'outdoor': {'key_color': [0,255,0], 'tolerance': 50, 'edge_softness': 3, 'spill_suppression': 0.25},
116
  }
117
 
118
+ # ---------------------------------------------------------------------------
119
+ # Two-Stage Processor
120
+ # ---------------------------------------------------------------------------
121
  class TwoStageProcessor:
122
  def __init__(self, sam2_predictor=None, matanyone_model=None):
123
  self.sam2 = self._unwrap_sam2(sam2_predictor)
 
125
  self.mask_cache_dir = Path("/tmp/mask_cache")
126
  self.mask_cache_dir.mkdir(parents=True, exist_ok=True)
127
 
128
+ # Internal flags/state
129
  self._mat_bootstrapped = False
130
+ self._alpha_prev: Optional[np.ndarray] = None # temporal smoothing
131
 
132
  logger.info(f"TwoStageProcessor init – SAM2: {self.sam2 is not None} | MatAnyOne: {self.matanyone is not None}")
133
 
 
160
  return mask
161
 
162
  @staticmethod
163
+ def _to_binary_mask(mask: np.ndarray) -> Optional[np.ndarray]:
164
  """Convert mask to uint8(0..255)."""
165
  if mask is None:
166
  return None
 
172
  return mask
173
 
174
  @staticmethod
175
+ def _to_float01(mask: np.ndarray, h: int = None, w: int = None) -> Optional[np.ndarray]:
176
  """Float [0,1] mask, optionally resized to (h,w)."""
177
  if mask is None:
178
  return None
 
190
  result = frame * mask_norm + bg * (1 - mask_norm)
191
  return result.astype(np.uint8)
192
 
193
+ # -------- improved spill suppression (preserves luminance & skin) --------
194
+ def _suppress_green_spill(self, frame: np.ndarray, amount: float = 0.35) -> np.ndarray:
195
+ """
196
+ Desaturate green dominance while preserving luminance and red skin hues.
197
+ amount: 0..1
198
+ """
199
+ b, g, r = cv2.split(frame.astype(np.float32))
200
+ y = 0.299*r + 0.587*g + 0.114*b # luminance (unused directly but good for future tuning)
201
+ green_dom = (g > r) & (g > b)
202
+ avg_rb = (r + b) * 0.5
203
+ g2 = np.where(green_dom, g*(1.0-amount) + avg_rb*amount, g)
204
+ # protect skin tones (red significantly above green)
205
+ skin = (r > g + 12)
206
+ g2 = np.where(skin, g, g2)
207
+ out = cv2.merge([np.clip(b,0,255), np.clip(g2,0,255), np.clip(r,0,255)]).astype(np.uint8)
208
+ return out
209
+
210
+ # -------- edge-aware alpha refinement (guided-like) --------
211
+ def _refine_alpha_edges(self, frame_bgr: np.ndarray, alpha_u8: np.ndarray, radius: int = 3, iters: int = 1) -> np.ndarray:
212
+ """
213
+ Fast, dependency-free, guided-like refinement on the alpha border.
214
+ Returns: uint8 alpha
215
+ """
216
+ a = alpha_u8.astype(np.uint8)
217
+ if radius <= 0:
218
+ return a
219
+
220
+ band = cv2.Canny(a, 32, 64)
221
+ if band.max() == 0:
222
+ return a
223
+
224
+ for _ in range(max(1, iters)):
225
+ a_blur = cv2.GaussianBlur(a, (radius*2+1, radius*2+1), 0)
226
+ b,g,r = cv2.split(frame_bgr.astype(np.float32))
227
+ green_dom = (g > r) & (g > b)
228
+ spill_mask = (green_dom & (a > 96) & (a < 224)).astype(np.uint8)*255
229
+ u = cv2.bitwise_or(band, spill_mask)
230
+ a = np.where(u>0, a_blur, a).astype(np.uint8)
231
+
232
+ return a
233
+
234
+ # -------- soft key based on chosen color (robust to blue/cyan/magenta) --------
235
+ def _soft_key_mask(self, frame_bgr: np.ndarray, key_bgr: np.ndarray, tol: int = 40) -> np.ndarray:
236
+ """
237
+ Soft chroma mask (uint8 0..255, 255=keep subject) using CbCr distance.
238
+ """
239
+ if key_bgr is None:
240
+ # fallback to keep-all (no key)
241
+ return np.full(frame_bgr.shape[:2], 255, np.uint8)
242
 
243
+ ycbcr = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2YCrCb).astype(np.float32)
244
+ kycbcr = cv2.cvtColor(key_bgr.reshape(1,1,3).astype(np.uint8), cv2.COLOR_BGR2YCrCb).astype(np.float32)[0,0]
245
+ d = np.linalg.norm((ycbcr[...,1:] - kycbcr[1:]), axis=-1)
246
+ d = cv2.GaussianBlur(d, (5,5), 0)
247
+ alpha = 255.0 * np.clip((d - tol) / (tol*1.7), 0.0, 1.0) # far from key = keep (255)
248
+ return alpha.astype(np.uint8)
249
 
250
+ # --------------------- NEW: MatAnyone bootstrap ----------------------
251
  def _bootstrap_matanyone_if_needed(self, frame_bgr: np.ndarray, coarse_mask: np.ndarray):
252
  """
253
  Call the MatAnyone session ONCE with the first coarse mask to initialize
254
+ its memory. This guarantees downstream calls never hit "first frame without a mask".
 
255
  """
256
  if self.matanyone is None or self._mat_bootstrapped:
257
  return
 
258
  try:
259
  h, w = frame_bgr.shape[:2]
260
  mask_f = self._to_float01(coarse_mask, h, w)
 
261
  rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
262
+ _ = self.matanyone(rgb, mask_f) # boot only; ignore returned alpha
 
263
  self._mat_bootstrapped = True
264
  logger.info("MatAnyone session bootstrapped with first-frame mask.")
265
+ except Exception as e:
266
  logger.warning(f"MatAnyone bootstrap failed (continuing without): {e}")
267
 
268
  # ---------------------------------------------------------------------
 
307
  masks: List[np.ndarray] = []
308
  frame_idx = 0
309
 
310
+ solid_bg = np.zeros((h, w, 3), np.uint8) # overwritten per-frame
311
 
312
  while True:
313
  if stop_event and stop_event.is_set():
 
318
  if not ok:
319
  break
320
 
321
+ # --- SAM2 segmentation ---
322
  mask = self._get_mask(frame)
323
 
324
  # --- MatAnyone bootstrap exactly once (first frame) ---
 
340
  probe_done = True
341
  logger.info(f"[TwoStage] Using key colour: {key_color_mode} → {chosen_bgr.tolist()}")
342
 
343
+ # --- Optional refinement via MatAnyone every few frames ---
344
  if self.matanyone and (frame_idx % 3 == 0):
345
  try:
 
346
  mask = refine_mask_hq(frame, mask, self.matanyone, fallback_enabled=True)
347
  except Exception as e:
348
  logger.warning(f"MatAnyOne refine fail f={frame_idx}: {e}")
349
 
350
  # --- Composite onto solid key colour ---
351
+ solid_bg[:] = chosen_bgr
352
  mask_u8 = self._to_binary_mask(mask)
353
+ gs = self._apply_greenscreen_hard(frame, mask_u8, solid_bg)
354
  writer.write(gs)
355
  masks.append(mask_u8)
356
 
 
361
  cap.release()
362
  writer.release()
363
 
364
+ # save mask cache
365
  try:
366
  cache_file = self.mask_cache_dir / (Path(out_path).stem + "_masks.pkl")
367
  with open(cache_file, "wb") as f:
 
391
  chroma_settings: Optional[Dict[str, Any]] = None,
392
  progress_callback: Optional[Callable[[float, str], None]] = None,
393
  stop_event: Optional["threading.Event"] = None,
394
+ key_bgr: Optional[np.ndarray] = None, # <-- NEW: pass chosen key color
395
  ) -> Tuple[Optional[str], str]:
396
 
397
  def _prog(p, d):
 
440
 
441
  # Get chroma settings
442
  settings = chroma_settings or CHROMA_PRESETS.get('standard', {})
443
+ tolerance = int(settings.get('tolerance', 38))
444
+ edge_softness = int(settings.get('edge_softness', 2))
445
+ spill_suppression = float(settings.get('spill_suppression', 0.35))
446
+
447
+ # If caller didn't pass key_bgr, try preset or default green
448
+ if key_bgr is None:
449
+ key_bgr = np.array(settings.get('key_color', [0,255,0]), dtype=np.uint8)
450
+
451
+ self._alpha_prev = None # reset temporal smoothing per render
452
 
453
  frame_idx = 0
454
  while True:
 
467
  frame, bg, mask,
468
  tolerance=tolerance,
469
  edge_softness=edge_softness,
470
+ spill_suppression=spill_suppression,
471
+ key_bgr=key_bgr
472
  )
473
  else:
474
  # Pure chroma key
 
476
  frame, bg,
477
  tolerance=tolerance,
478
  edge_softness=edge_softness,
479
+ spill_suppression=spill_suppression,
480
+ key_bgr=key_bgr
481
  )
482
 
483
  writer.write(final_frame)
 
495
  logger.error(f"Stage 2 error: {e}\n{traceback.format_exc()}")
496
  return None, f"Stage 2 failed: {e}"
497
 
498
+ # ---------------- chroma + hybrid compositors (polished) ----------------
499
+ def _chroma_key_composite(self, frame, bg, *, tolerance=38, edge_softness=2, spill_suppression=0.35, key_bgr: Optional[np.ndarray] = None):
500
+ """Apply chroma key compositing with soft color distance + edge refinement."""
501
+ # 1) spill first
502
+ if spill_suppression > 0:
503
+ frame = self._suppress_green_spill(frame, spill_suppression)
 
504
 
505
+ # 2) build alpha
506
+ if key_bgr is not None:
507
+ alpha = self._soft_key_mask(frame, key_bgr, tol=int(tolerance))
508
+ else:
509
+ # Fallback: HSV green range
510
+ hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
511
+ lower_green = np.array([40, 40, 40])
512
+ upper_green = np.array([80, 255, 255])
513
+ alpha = cv2.bitwise_not(cv2.inRange(hsv, lower_green, upper_green))
514
 
515
+ # 3) soft edges + refinement
516
  if edge_softness > 0:
517
  k = edge_softness * 2 + 1
518
+ alpha = cv2.GaussianBlur(alpha, (k, k), 0)
519
+ alpha = self._refine_alpha_edges(frame, alpha, radius=max(1, edge_softness), iters=1)
 
520
 
521
+ # 4) temporal smoothing
522
+ if self._alpha_prev is not None and self._alpha_prev.shape == alpha.shape:
523
+ alpha = cv2.addWeighted(alpha, 0.75, self._alpha_prev, 0.25, 0)
524
+ self._alpha_prev = alpha
525
 
526
+ # 5) composite
527
+ mask_3ch = cv2.cvtColor(alpha, cv2.COLOR_GRAY2BGR).astype(np.float32) / 255.0
528
+ out = frame.astype(np.float32) * mask_3ch + bg.astype(np.float32) * (1.0 - mask_3ch)
529
+ return np.clip(out, 0, 255).astype(np.uint8)
530
 
531
+ def _hybrid_composite(self, frame, bg, mask, *, tolerance=38, edge_softness=2, spill_suppression=0.35, key_bgr: Optional[np.ndarray] = None):
532
  """Apply hybrid compositing using both chroma key and cached mask."""
533
+ chroma_result = self._chroma_key_composite(
534
+ frame, bg,
535
+ tolerance=tolerance,
536
+ edge_softness=edge_softness,
537
+ spill_suppression=spill_suppression,
538
+ key_bgr=key_bgr
539
+ )
540
  if mask is not None:
541
  mask_3ch = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR) if mask.ndim == 2 else mask
542
  mask_norm = mask_3ch.astype(np.float32) / 255.0
543
+ guided = frame.astype(np.float32) * mask_norm + bg.astype(np.float32) * (1.0 - mask_norm)
544
+ result = chroma_result.astype(np.float32) * 0.7 + guided * 0.3
545
+ return np.clip(result, 0, 255).astype(np.uint8)
546
  return chroma_result
547
 
548
  # ---------------------------------------------------------------------
 
575
  pass
576
 
577
  try:
578
+ # Reset per-video state
579
  self._mat_bootstrapped = False
580
+ self._alpha_prev = None
581
  if self.matanyone is not None and hasattr(self.matanyone, "reset"):
582
  try:
583
  self.matanyone.reset()
 
592
  progress_callback=_combined_progress,
593
  stop_event=stop_event
594
  )
 
595
  if stage1_result is None:
596
  return None, stage1_msg
597
 
598
+ # Stage 2 (pass through chosen key color)
599
+ key_bgr = np.array(stage1_result.get("key_bgr", [0,255,0]), dtype=np.uint8)
600
  final_path, stage2_msg = self.stage2_greenscreen_to_final(
601
  stage1_result["path"], background, output_path,
602
  chroma_settings=chroma_settings,
603
  progress_callback=_combined_progress,
604
+ stop_event=stop_event,
605
+ key_bgr=key_bgr,
606
  )
607
 
608
  # Clean up temp file