MogensR commited on
Commit
0e72f3b
Β·
1 Parent(s): 6d91182

Update processing/two_stage/two_stage_processor.py

Browse files
processing/two_stage/two_stage_processor.py CHANGED
@@ -4,22 +4,7 @@
4
  Stage 1: Original β†’ keyed background (auto-selected colour)
5
  Stage 2: Keyed video β†’ final composite (hybrid chroma + segmentation rescue)
6
 
7
- Aligned with current project layout:
8
- * uses helpers from utils.cv_processing (segment_person_hq, refine_mask_hq)
9
- * safe local create_video_writer (no core.app dependency)
10
- * cancel support via stop_event
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.
18
-
19
- Quality profiles (set via env BFX_QUALITY = speed | balanced | max):
20
- * refine cadence, spill suppression, edge softness
21
- * hybrid matte mix (segmentation vs chroma), small dilate/blur on mask
22
- * optional tiny background blur to hide seams on very flat backgrounds
23
  """
24
  from __future__ import annotations
25
 
@@ -37,9 +22,10 @@
37
  logger = logging.getLogger(__name__)
38
 
39
  # ---------------------------------------------------------------------------
40
- # Local video-writer helper
41
  # ---------------------------------------------------------------------------
42
  def create_video_writer(output_path: str, fps: float, width: int, height: int, prefer_mp4: bool = True):
 
43
  try:
44
  ext = ".mp4" if prefer_mp4 else ".avi"
45
  if not output_path:
@@ -64,6 +50,64 @@ def create_video_writer(output_path: str, fps: float, width: int, height: int, p
64
  logger.error(f"create_video_writer failed: {e}")
65
  return None, output_path
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # ---------------------------------------------------------------------------
68
  # Key-colour helpers (fast, no external deps)
69
  # ---------------------------------------------------------------------------
@@ -72,13 +116,11 @@ def _bgr_to_hsv_hue_deg(bgr: np.ndarray) -> np.ndarray:
72
  # OpenCV H is 0-180; scale to degrees 0-360
73
  return hsv[..., 0].astype(np.float32) * 2.0
74
 
75
-
76
  def _hue_distance(a_deg: float, b_deg: float) -> float:
77
  """Circular distance on the hue wheel (degrees)."""
78
  d = abs(a_deg - b_deg) % 360.0
79
  return min(d, 360.0 - d)
80
 
81
-
82
  def _key_candidates_bgr() -> dict:
83
  return {
84
  "green": {"bgr": np.array([ 0,255, 0], dtype=np.uint8), "hue": 120.0},
@@ -87,7 +129,6 @@ def _key_candidates_bgr() -> dict:
87
  "magenta": {"bgr": np.array([255, 0,255], dtype=np.uint8), "hue": 300.0},
88
  }
89
 
90
-
91
  def _choose_best_key_color(frame_bgr: np.ndarray, mask_uint8: np.ndarray) -> dict:
92
  """Pick the candidate colour farthest from the actor's dominant hues."""
93
  try:
@@ -110,7 +151,6 @@ def _choose_best_key_color(frame_bgr: np.ndarray, mask_uint8: np.ndarray) -> dic
110
  except Exception:
111
  return _key_candidates_bgr()["green"]
112
 
113
-
114
  # ---------------------------------------------------------------------------
115
  # Chroma presets
116
  # ---------------------------------------------------------------------------
@@ -121,12 +161,36 @@ def _choose_best_key_color(frame_bgr: np.ndarray, mask_uint8: np.ndarray) -> dic
121
  }
122
 
123
  # ---------------------------------------------------------------------------
124
- # Quality profiles (env: BFX_QUALITY = speed | balanced | max)
125
  # ---------------------------------------------------------------------------
126
  QUALITY_PROFILES: Dict[str, Dict[str, Any]] = {
127
- "speed": dict(refine_stride=4, spill=0.30, edge_softness=2, mix=0.60, dilate=0, blur=0, bg_sigma=0.0),
128
- "balanced": dict(refine_stride=2, spill=0.40, edge_softness=2, mix=0.75, dilate=1, blur=1, bg_sigma=0.6),
129
- "max": dict(refine_stride=1, spill=0.45, edge_softness=3, mix=0.85, dilate=2, blur=2, bg_sigma=1.0),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  }
131
 
132
  # ---------------------------------------------------------------------------
@@ -134,7 +198,7 @@ def _choose_best_key_color(frame_bgr: np.ndarray, mask_uint8: np.ndarray) -> dic
134
  # ---------------------------------------------------------------------------
135
  class TwoStageProcessor:
136
  def __init__(self, sam2_predictor=None, matanyone_model=None):
137
- self.sam2 = self._unwrap_sam2(sam2_predictor)
138
  self.matanyone = matanyone_model
139
  self.mask_cache_dir = Path("/tmp/mask_cache")
140
  self.mask_cache_dir.mkdir(parents=True, exist_ok=True)
@@ -142,6 +206,10 @@ def __init__(self, sam2_predictor=None, matanyone_model=None):
142
  # Internal flags/state
143
  self._mat_bootstrapped = False
144
  self._alpha_prev: Optional[np.ndarray] = None # temporal smoothing
 
 
 
 
145
 
146
  # Quality selection at construction
147
  qname = os.getenv("BFX_QUALITY", "balanced").strip().lower()
@@ -149,8 +217,10 @@ def __init__(self, sam2_predictor=None, matanyone_model=None):
149
  qname = "balanced"
150
  self.quality = qname
151
  self.q = QUALITY_PROFILES[qname]
152
- logger.info(f"TwoStageProcessor quality='{self.quality}' β‡’ {self.q}")
153
-
 
 
154
  logger.info(f"TwoStageProcessor init – SAM2: {self.sam2 is not None} | MatAnyOne: {self.matanyone is not None}")
155
 
156
  # --------------------------- internal utils ---------------------------
@@ -169,9 +239,12 @@ def _refresh_quality_from_env(self):
169
  if qname not in QUALITY_PROFILES:
170
  qname = "balanced"
171
  if qname != getattr(self, "quality", None) or not hasattr(self, "q"):
 
172
  self.quality = qname
173
  self.q = QUALITY_PROFILES[qname]
174
- logger.info(f"Quality switched to '{self.quality}' β‡’ {self.q}")
 
 
175
 
176
  def _get_mask(self, frame: np.ndarray) -> np.ndarray:
177
  """Get segmentation mask using SAM2 (delegates to project helper)."""
@@ -276,7 +349,7 @@ def _soft_key_mask(self, frame_bgr: np.ndarray, key_bgr: np.ndarray, tol: int =
276
  alpha = 255.0 * np.clip((d - tol) / (tol*1.7), 0.0, 1.0) # far from key = keep (255)
277
  return alpha.astype(np.uint8)
278
 
279
- # --------------------- NEW: MatAnyone bootstrap ----------------------
280
  def _bootstrap_matanyone_if_needed(self, frame_bgr: np.ndarray, coarse_mask: np.ndarray):
281
  """
282
  Call the MatAnyone session ONCE with the first coarse mask to initialize
@@ -294,6 +367,18 @@ def _bootstrap_matanyone_if_needed(self, frame_bgr: np.ndarray, coarse_mask: np.
294
  except Exception as e:
295
  logger.warning(f"MatAnyone bootstrap failed (continuing without): {e}")
296
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  # ---------------------------------------------------------------------
298
  # Stage 1 – Original β†’ keyed (green/blue/…) -- chooses colour on 1st frame
299
  # ---------------------------------------------------------------------
@@ -328,16 +413,20 @@ def _prog(p, d):
328
  w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
329
  h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
330
 
331
- writer, out_path = create_video_writer(output_path, fps, w, h)
332
- if writer is None:
333
  cap.release()
334
  return None, "Could not create output writer"
 
 
 
335
 
336
  key_info: dict | None = None
337
  chosen_bgr = np.array([0, 255, 0], np.uint8) # default
338
  probe_done = False
339
  masks: List[np.ndarray] = []
340
  frame_idx = 0
 
341
 
342
  solid_bg = np.zeros((h, w, 3), np.uint8) # overwritten per-frame
343
 
@@ -373,12 +462,15 @@ def _prog(p, d):
373
  logger.info(f"[TwoStage] Using key colour: {key_color_mode} β†’ {chosen_bgr.tolist()}")
374
 
375
  # --- Optional refinement via MatAnyone (profile cadence) ---
376
- stride = int(self.q.get("refine_stride", 3))
377
- if self.matanyone and (frame_idx % max(1, stride) == 0):
378
  try:
379
  mask = refine_mask_hq(frame, mask, self.matanyone, fallback_enabled=True)
 
 
380
  except Exception as e:
381
  logger.warning(f"MatAnyOne refine fail f={frame_idx}: {e}")
 
 
382
 
383
  # --- Composite onto solid key colour ---
384
  solid_bg[:] = chosen_bgr
@@ -389,23 +481,30 @@ def _prog(p, d):
389
 
390
  frame_idx += 1
391
  pct = 0.05 + 0.9 * (frame_idx / total) if total else min(0.95, 0.05 + frame_idx * 0.002)
392
- _prog(pct, f"Stage 1: {frame_idx}/{total or '?'}")
393
 
394
  cap.release()
395
  writer.release()
 
396
 
397
  # save mask cache
398
  try:
399
  cache_file = self.mask_cache_dir / (Path(out_path).stem + "_masks.pkl")
400
  with open(cache_file, "wb") as f:
401
  pickle.dump(masks, f)
 
402
  except Exception as e:
403
  logger.warning(f"mask cache save fail: {e}")
404
 
405
  _prog(1.0, "Stage 1: complete")
 
 
 
 
 
406
  return (
407
  {"path": out_path, "frames": frame_idx, "key_bgr": chosen_bgr.tolist()},
408
- f"Green-screen video created ({frame_idx} frames)"
409
  )
410
 
411
  except Exception as e:
@@ -462,11 +561,15 @@ def _prog(p, d):
462
  sigma = float(self.q.get("bg_sigma", 0.0))
463
  if sigma > 0:
464
  bg = cv2.GaussianBlur(bg, (0, 0), sigmaX=sigma, sigmaY=sigma)
 
465
 
466
- writer, out_path = create_video_writer(output_path, fps, w, h)
467
- if writer is None:
468
  cap.release()
469
  return None, "Could not create output writer"
 
 
 
470
 
471
  # Load cached masks if available
472
  masks = None
@@ -530,6 +633,11 @@ def _prog(p, d):
530
  writer.release()
531
 
532
  _prog(1.0, "Stage 2: complete")
 
 
 
 
 
533
  return out_path, f"Final composite created ({frame_idx} frames)"
534
 
535
  except Exception as e:
@@ -637,6 +745,9 @@ def _combined_progress(pct, desc):
637
  # Reset per-video state
638
  self._mat_bootstrapped = False
639
  self._alpha_prev = None
 
 
 
640
  if self.matanyone is not None and hasattr(self.matanyone, "reset"):
641
  try:
642
  self.matanyone.reset()
@@ -670,8 +781,13 @@ def _combined_progress(pct, desc):
670
  except Exception:
671
  pass
672
 
 
 
 
 
 
673
  return final_path, stage2_msg
674
 
675
  except Exception as e:
676
  logger.error(f"Full pipeline error: {e}\n{traceback.format_exc()}")
677
- return None, f"Pipeline failed: {e}"
 
4
  Stage 1: Original β†’ keyed background (auto-selected colour)
5
  Stage 2: Keyed video β†’ final composite (hybrid chroma + segmentation rescue)
6
 
7
+ UPDATED: Enhanced quality profiles, improved frame handling, better status reporting
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
  from __future__ import annotations
10
 
 
22
  logger = logging.getLogger(__name__)
23
 
24
  # ---------------------------------------------------------------------------
25
+ # Local video-writer helper with frame count guarantee
26
  # ---------------------------------------------------------------------------
27
  def create_video_writer(output_path: str, fps: float, width: int, height: int, prefer_mp4: bool = True):
28
+ """Creates video writer with fallback options"""
29
  try:
30
  ext = ".mp4" if prefer_mp4 else ".avi"
31
  if not output_path:
 
50
  logger.error(f"create_video_writer failed: {e}")
51
  return None, output_path
52
 
53
+ # ---------------------------------------------------------------------------
54
+ # Robust video writer wrapper to prevent frame loss
55
+ # ---------------------------------------------------------------------------
56
+ class RobustVideoWriter:
57
+ """Wrapper that ensures all frames are written"""
58
+
59
+ def __init__(self, writer, output_path: str):
60
+ self.writer = writer
61
+ self.output_path = output_path
62
+ self.frame_buffer = []
63
+ self.frames_written = 0
64
+ self.frames_attempted = 0
65
+
66
+ def write(self, frame):
67
+ """Buffer and write frame"""
68
+ if frame is None:
69
+ return False
70
+
71
+ self.frames_attempted += 1
72
+ self.frame_buffer.append(frame.copy())
73
+
74
+ # Try to write buffered frames
75
+ while self.frame_buffer and self.writer:
76
+ try:
77
+ self.writer.write(self.frame_buffer[0])
78
+ self.frame_buffer.pop(0)
79
+ self.frames_written += 1
80
+ except Exception as e:
81
+ logger.warning(f"Frame write failed: {e}")
82
+ return False
83
+ return True
84
+
85
+ def release(self):
86
+ """Flush remaining frames and close"""
87
+ # Write any remaining buffered frames
88
+ while self.frame_buffer and self.writer:
89
+ try:
90
+ self.writer.write(self.frame_buffer[0])
91
+ self.frame_buffer.pop(0)
92
+ self.frames_written += 1
93
+ except Exception:
94
+ break
95
+
96
+ # Close writer
97
+ if self.writer:
98
+ self.writer.release()
99
+
100
+ # Log statistics
101
+ logger.info(f"Video writer closed: {self.frames_written}/{self.frames_attempted} frames written")
102
+
103
+ # Verify output exists
104
+ if os.path.exists(self.output_path):
105
+ size = os.path.getsize(self.output_path)
106
+ if size == 0:
107
+ logger.error("WARNING: Output file is empty!")
108
+ else:
109
+ logger.info(f"Output file size: {size:,} bytes")
110
+
111
  # ---------------------------------------------------------------------------
112
  # Key-colour helpers (fast, no external deps)
113
  # ---------------------------------------------------------------------------
 
116
  # OpenCV H is 0-180; scale to degrees 0-360
117
  return hsv[..., 0].astype(np.float32) * 2.0
118
 
 
119
  def _hue_distance(a_deg: float, b_deg: float) -> float:
120
  """Circular distance on the hue wheel (degrees)."""
121
  d = abs(a_deg - b_deg) % 360.0
122
  return min(d, 360.0 - d)
123
 
 
124
  def _key_candidates_bgr() -> dict:
125
  return {
126
  "green": {"bgr": np.array([ 0,255, 0], dtype=np.uint8), "hue": 120.0},
 
129
  "magenta": {"bgr": np.array([255, 0,255], dtype=np.uint8), "hue": 300.0},
130
  }
131
 
 
132
  def _choose_best_key_color(frame_bgr: np.ndarray, mask_uint8: np.ndarray) -> dict:
133
  """Pick the candidate colour farthest from the actor's dominant hues."""
134
  try:
 
151
  except Exception:
152
  return _key_candidates_bgr()["green"]
153
 
 
154
  # ---------------------------------------------------------------------------
155
  # Chroma presets
156
  # ---------------------------------------------------------------------------
 
161
  }
162
 
163
  # ---------------------------------------------------------------------------
164
+ # ENHANCED Quality profiles with clear differentiation
165
  # ---------------------------------------------------------------------------
166
  QUALITY_PROFILES: Dict[str, Dict[str, Any]] = {
167
+ "speed": dict(
168
+ refine_stride=10, # Refine every 10th frame only
169
+ spill=0.15, # Minimal spill work
170
+ edge_softness=1, # Basic edges
171
+ mix=0.50, # 50/50 chroma/segmentation
172
+ dilate=1, # Minimal morphology
173
+ blur=0, # No blur
174
+ bg_sigma=0.0 # No background blur
175
+ ),
176
+ "balanced": dict(
177
+ refine_stride=3, # Refine every 3rd frame
178
+ spill=0.35, # Moderate spill removal
179
+ edge_softness=2, # Smooth edges
180
+ mix=0.70, # Favor segmentation (70%)
181
+ dilate=2, # Some hole filling
182
+ blur=1, # Light feathering
183
+ bg_sigma=0.8 # Subtle background blur
184
+ ),
185
+ "max": dict(
186
+ refine_stride=1, # Refine EVERY frame
187
+ spill=0.50, # Strong spill removal
188
+ edge_softness=3, # Very smooth edges
189
+ mix=0.85, # Heavy segmentation bias (85%)
190
+ dilate=3, # Strong hole filling
191
+ blur=2, # More feathering
192
+ bg_sigma=1.5 # Visible background blur
193
+ ),
194
  }
195
 
196
  # ---------------------------------------------------------------------------
 
198
  # ---------------------------------------------------------------------------
199
  class TwoStageProcessor:
200
  def __init__(self, sam2_predictor=None, matanyone_model=None):
201
+ self.sam2 = self._unwrap_sam2(sam2_predictor)
202
  self.matanyone = matanyone_model
203
  self.mask_cache_dir = Path("/tmp/mask_cache")
204
  self.mask_cache_dir.mkdir(parents=True, exist_ok=True)
 
206
  # Internal flags/state
207
  self._mat_bootstrapped = False
208
  self._alpha_prev: Optional[np.ndarray] = None # temporal smoothing
209
+
210
+ # Frame tracking
211
+ self.total_frames_processed = 0
212
+ self.frames_refined = 0
213
 
214
  # Quality selection at construction
215
  qname = os.getenv("BFX_QUALITY", "balanced").strip().lower()
 
217
  qname = "balanced"
218
  self.quality = qname
219
  self.q = QUALITY_PROFILES[qname]
220
+
221
+ # Log quality details
222
+ logger.info(f"TwoStageProcessor quality='{self.quality}' β‡’ refine_every={self.q['refine_stride']}, "
223
+ f"spill={self.q['spill']:.2f}, mix={self.q['mix']:.2f}, bg_blur={self.q['bg_sigma']:.1f}")
224
  logger.info(f"TwoStageProcessor init – SAM2: {self.sam2 is not None} | MatAnyOne: {self.matanyone is not None}")
225
 
226
  # --------------------------- internal utils ---------------------------
 
239
  if qname not in QUALITY_PROFILES:
240
  qname = "balanced"
241
  if qname != getattr(self, "quality", None) or not hasattr(self, "q"):
242
+ old_quality = self.quality
243
  self.quality = qname
244
  self.q = QUALITY_PROFILES[qname]
245
+ logger.info(f"Quality switched from '{old_quality}' to '{self.quality}' β‡’ "
246
+ f"refine_every={self.q['refine_stride']}, spill={self.q['spill']:.2f}, "
247
+ f"mix={self.q['mix']:.2f}, bg_blur={self.q['bg_sigma']:.1f}")
248
 
249
  def _get_mask(self, frame: np.ndarray) -> np.ndarray:
250
  """Get segmentation mask using SAM2 (delegates to project helper)."""
 
349
  alpha = 255.0 * np.clip((d - tol) / (tol*1.7), 0.0, 1.0) # far from key = keep (255)
350
  return alpha.astype(np.uint8)
351
 
352
+ # --------------------- MatAnyone bootstrap ----------------------
353
  def _bootstrap_matanyone_if_needed(self, frame_bgr: np.ndarray, coarse_mask: np.ndarray):
354
  """
355
  Call the MatAnyone session ONCE with the first coarse mask to initialize
 
367
  except Exception as e:
368
  logger.warning(f"MatAnyone bootstrap failed (continuing without): {e}")
369
 
370
+ def _should_refine_frame(self, frame_idx: int) -> bool:
371
+ """Check if current frame should be refined based on quality profile"""
372
+ if not self.matanyone:
373
+ return False
374
+
375
+ # Always refine first frame for bootstrap
376
+ if frame_idx == 0:
377
+ return True
378
+
379
+ stride = max(1, int(self.q.get("refine_stride", 3)))
380
+ return (frame_idx % stride) == 0
381
+
382
  # ---------------------------------------------------------------------
383
  # Stage 1 – Original β†’ keyed (green/blue/…) -- chooses colour on 1st frame
384
  # ---------------------------------------------------------------------
 
413
  w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
414
  h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
415
 
416
+ base_writer, out_path = create_video_writer(output_path, fps, w, h)
417
+ if base_writer is None:
418
  cap.release()
419
  return None, "Could not create output writer"
420
+
421
+ # Use robust wrapper
422
+ writer = RobustVideoWriter(base_writer, out_path)
423
 
424
  key_info: dict | None = None
425
  chosen_bgr = np.array([0, 255, 0], np.uint8) # default
426
  probe_done = False
427
  masks: List[np.ndarray] = []
428
  frame_idx = 0
429
+ self.frames_refined = 0
430
 
431
  solid_bg = np.zeros((h, w, 3), np.uint8) # overwritten per-frame
432
 
 
462
  logger.info(f"[TwoStage] Using key colour: {key_color_mode} β†’ {chosen_bgr.tolist()}")
463
 
464
  # --- Optional refinement via MatAnyone (profile cadence) ---
465
+ if self._should_refine_frame(frame_idx):
 
466
  try:
467
  mask = refine_mask_hq(frame, mask, self.matanyone, fallback_enabled=True)
468
+ self.frames_refined += 1
469
+ logger.debug(f"Frame {frame_idx}: Refined (quality={self.quality})")
470
  except Exception as e:
471
  logger.warning(f"MatAnyOne refine fail f={frame_idx}: {e}")
472
+ else:
473
+ logger.debug(f"Frame {frame_idx}: Skipped refinement (cadence={self.q['refine_stride']})")
474
 
475
  # --- Composite onto solid key colour ---
476
  solid_bg[:] = chosen_bgr
 
481
 
482
  frame_idx += 1
483
  pct = 0.05 + 0.9 * (frame_idx / total) if total else min(0.95, 0.05 + frame_idx * 0.002)
484
+ _prog(pct, f"Stage 1: {frame_idx}/{total or '?'} (refined: {self.frames_refined})")
485
 
486
  cap.release()
487
  writer.release()
488
+ self.total_frames_processed = frame_idx
489
 
490
  # save mask cache
491
  try:
492
  cache_file = self.mask_cache_dir / (Path(out_path).stem + "_masks.pkl")
493
  with open(cache_file, "wb") as f:
494
  pickle.dump(masks, f)
495
+ logger.info(f"Cached {len(masks)} masks to {cache_file}")
496
  except Exception as e:
497
  logger.warning(f"mask cache save fail: {e}")
498
 
499
  _prog(1.0, "Stage 1: complete")
500
+
501
+ # Log quality impact
502
+ logger.info(f"Stage 1 complete: {frame_idx} frames, {self.frames_refined} refined "
503
+ f"({100*self.frames_refined/max(1,frame_idx):.1f}%)")
504
+
505
  return (
506
  {"path": out_path, "frames": frame_idx, "key_bgr": chosen_bgr.tolist()},
507
+ f"Green-screen video created ({frame_idx} frames, {self.frames_refined} refined)"
508
  )
509
 
510
  except Exception as e:
 
561
  sigma = float(self.q.get("bg_sigma", 0.0))
562
  if sigma > 0:
563
  bg = cv2.GaussianBlur(bg, (0, 0), sigmaX=sigma, sigmaY=sigma)
564
+ logger.debug(f"Applied background blur: sigma={sigma:.1f}")
565
 
566
+ base_writer, out_path = create_video_writer(output_path, fps, w, h)
567
+ if base_writer is None:
568
  cap.release()
569
  return None, "Could not create output writer"
570
+
571
+ # Use robust wrapper
572
+ writer = RobustVideoWriter(base_writer, out_path)
573
 
574
  # Load cached masks if available
575
  masks = None
 
633
  writer.release()
634
 
635
  _prog(1.0, "Stage 2: complete")
636
+
637
+ # Verify frame counts match
638
+ if total > 0 and frame_idx != total:
639
+ logger.warning(f"Frame count mismatch: processed {frame_idx}, expected {total}")
640
+
641
  return out_path, f"Final composite created ({frame_idx} frames)"
642
 
643
  except Exception as e:
 
745
  # Reset per-video state
746
  self._mat_bootstrapped = False
747
  self._alpha_prev = None
748
+ self.total_frames_processed = 0
749
+ self.frames_refined = 0
750
+
751
  if self.matanyone is not None and hasattr(self.matanyone, "reset"):
752
  try:
753
  self.matanyone.reset()
 
781
  except Exception:
782
  pass
783
 
784
+ # Report quality impact
785
+ logger.info(f"Pipeline complete with quality='{self.quality}': "
786
+ f"{self.total_frames_processed} frames, "
787
+ f"{self.frames_refined} refined ({100*self.frames_refined/max(1,self.total_frames_processed):.1f}%)")
788
+
789
  return final_path, stage2_msg
790
 
791
  except Exception as e:
792
  logger.error(f"Full pipeline error: {e}\n{traceback.format_exc()}")
793
+ return None, f"Pipeline failed: {e}"