MogensR commited on
Commit
565a301
·
verified ·
1 Parent(s): b592ffc

Update processing/two_stage/two_stage_processor.py

Browse files
processing/two_stage/two_stage_processor.py CHANGED
@@ -1,10 +1,10 @@
1
  #!/usr/bin/env python3
2
  """
3
- Two-Stage Green-Screen Processing System with Comprehensive Debug Outputs ✅ 2025-09-09
4
- FIXED: Now uses enhanced SAM2Handler with full-body detection strategies
5
- Stage 1: Original → keyed background (auto-selected colour)
6
- Stage 2: Keyed video → final composite (hybrid chroma + segmentation rescue)
7
- DEBUG: Added extensive debug outputs to isolate quality degradation at every step
8
  """
9
  from __future__ import annotations
10
  import cv2, numpy as np, os, gc, pickle, logging, tempfile, traceback, threading
@@ -55,17 +55,17 @@ def create_video_writer(output_path: str, fps: float, width: int, height: int, p
55
  raise RuntimeError(f"Could not create video writer for {output_path}")
56
 
57
  # ---------------------------------------------------------------------------
58
- # TwoStageProcessor: Main processing pipeline with debug outputs
59
  # ---------------------------------------------------------------------------
60
  class TwoStageProcessor:
61
  """
62
- Two-stage video background replacement with comprehensive debugging:
63
- Stage 1: Person segmentation → Green screen video
64
- Stage 2: Green screen video → Final composite
65
  """
66
 
67
  def __init__(self, sam2_handler, matanyone_processor=None):
68
- """Initialize with enhanced SAM2Handler and optional MatAnyone."""
69
  # CRITICAL: Ensure we receive the full SAM2Handler, not just predictor
70
  if hasattr(sam2_handler, 'create_mask'):
71
  self.sam2_handler = sam2_handler
@@ -100,90 +100,6 @@ def __init__(self, sam2_handler, matanyone_processor=None):
100
  logger.warning(f"Could not access quality_manager profile: {e}, using default")
101
  self.quality_profile = {"name": "default", "settings": {}}
102
 
103
- def _get_mask(self, frame_rgb, frame_index):
104
- """Get person mask with extensive debug outputs at every step."""
105
- logger.info(f"=== DEBUG: TwoStageProcessor._get_mask called for frame {frame_index} ===")
106
- logger.info(f"DEBUG: Input frame shape: {frame_rgb.shape}, dtype: {frame_rgb.dtype}")
107
-
108
- try:
109
- # Check what type of SAM2 handler we have
110
- if hasattr(self.sam2_handler, 'create_mask'):
111
- logger.info("DEBUG: Using ENHANCED SAM2Handler.create_mask() with full-body detection")
112
-
113
- # Call SAM2Handler's create_mask method (which has its own debug outputs)
114
- mask = self.sam2_handler.create_mask(frame_rgb)
115
-
116
- else:
117
- logger.info("DEBUG: Using basic SAM2 segmentation fallback")
118
- mask = self._basic_sam2_fallback(frame_rgb)
119
-
120
- # DEBUG: Check if TwoStageProcessor is getting the mask correctly
121
- if mask is not None:
122
- logger.info(f"DEBUG: TwoStageProcessor received mask shape: {mask.shape}, dtype: {mask.dtype}")
123
- logger.info(f"DEBUG: TwoStageProcessor received mask range: {mask.min():.3f} to {mask.max():.3f}")
124
- logger.info(f"DEBUG: TwoStageProcessor received mask non-zero pixels: {np.count_nonzero(mask)}")
125
-
126
- # Convert to uint8 for saving if needed
127
- if mask.dtype != np.uint8:
128
- debug_mask = (mask * 255).astype(np.uint8)
129
- else:
130
- debug_mask = mask.copy()
131
-
132
- cv2.imwrite(f"/tmp/debug_twostage_received_mask_frame_{frame_index}.png", debug_mask)
133
- logger.info(f"DEBUG: Saved TwoStageProcessor received mask to /tmp/debug_twostage_received_mask_frame_{frame_index}.png")
134
-
135
- # DISABLED: No additional cleaning - let SAM2Handler's cleaning stand
136
- # mask = self._maximum_quality_mask_cleaning(mask) # DISABLED FOR TESTING
137
- logger.info("DEBUG: Double cleaning DISABLED - using mask as received from SAM2Handler")
138
-
139
- return mask
140
- else:
141
- logger.warning(f"DEBUG: No mask received from SAM2Handler for frame {frame_index}")
142
- return None
143
-
144
- except Exception as e:
145
- logger.error(f"DEBUG: Exception in TwoStageProcessor._get_mask for frame {frame_index}: {e}")
146
- logger.error(f"DEBUG: Traceback: {traceback.format_exc()}")
147
- return None
148
-
149
- def _basic_sam2_fallback(self, frame_rgb):
150
- """Fallback for basic SAM2 predictor without enhanced features."""
151
- try:
152
- logger.info("DEBUG: Running basic SAM2 fallback")
153
- self.sam2_handler.set_image(frame_rgb)
154
- # Simple center point prompt
155
- h, w = frame_rgb.shape[:2]
156
- center_point = np.array([[w//2, h//2]])
157
- center_label = np.array([1])
158
-
159
- masks, scores, _ = self.sam2_handler.predict(
160
- point_coords=center_point,
161
- point_labels=center_label,
162
- multimask_output=True
163
- )
164
-
165
- if len(masks) > 0:
166
- # Select best mask by score
167
- best_idx = np.argmax(scores)
168
- mask = masks[best_idx].astype(np.float32)
169
- logger.info(f"DEBUG: Basic SAM2 fallback - selected mask {best_idx} with score {scores[best_idx]:.3f}")
170
- return mask
171
- else:
172
- logger.warning("DEBUG: Basic SAM2 fallback produced no masks")
173
- return None
174
- except Exception as e:
175
- logger.error(f"DEBUG: Basic SAM2 fallback failed: {e}")
176
- return None
177
-
178
- def _maximum_quality_mask_cleaning(self, mask):
179
- """
180
- DISABLED FOR TESTING - Maximum quality mask post-processing.
181
- Returns mask unchanged to prevent double cleaning artifacts.
182
- """
183
- logger.info("DEBUG: _maximum_quality_mask_cleaning called but DISABLED")
184
- logger.info("DEBUG: Returning mask unchanged to prevent double cleaning artifacts")
185
- return mask
186
-
187
  def process_video(self,
188
  input_path: str,
189
  background_image_path: Optional[str] = None,
@@ -191,46 +107,48 @@ def process_video(self,
191
  callback: Optional[Callable] = None,
192
  trim_seconds: float = 5.0) -> Tuple[Optional[str], Optional[str], str]:
193
  """
194
- Process video with two-stage background replacement.
195
- Returns: (final_output_path, green_screen_path, status_message)
196
  """
197
  try:
198
- self.temp_dir = tempfile.mkdtemp(prefix="twostage_debug_")
199
- logger.info(f"DEBUG: Processing video: {input_path}")
200
- logger.info(f"DEBUG: Temp directory: {self.temp_dir}")
201
 
202
- # Stage 1: Create green screen video
203
- green_screen_path = self._stage1_create_greenscreen(
204
- input_path, trim_seconds, callback
205
  )
206
 
207
- if not green_screen_path:
208
- return None, None, "❌ Stage 1 failed: Could not create green screen video"
209
 
210
  if callback:
211
- callback(50, "Stage 1 complete, starting Stage 2...")
212
 
213
- # Stage 2: Apply background to green screen video
214
- final_output_path = self._stage2_apply_background(
215
- green_screen_path, background_image_path, background_preset, callback
 
216
  )
217
 
218
  if not final_output_path:
219
- return None, green_screen_path, "❌ Stage 2 failed: Could not apply background"
220
 
221
- logger.info(f"✅ Two-stage processing complete: {final_output_path}")
222
- return final_output_path, green_screen_path, "✅ Video processed successfully!"
223
 
224
  except Exception as e:
225
- logger.error(f"Two-stage processing failed: {e}")
226
  logger.error(traceback.format_exc())
227
  return None, None, f"❌ Processing failed: {str(e)}"
228
 
229
  finally:
230
- self._cleanup()
 
231
 
232
- def _stage1_create_greenscreen(self, input_path: str, trim_seconds: float, callback: Optional[Callable]) -> Optional[str]:
233
- """Stage 1: Create green screen video using person segmentation with debug."""
234
  try:
235
  cap = cv2.VideoCapture(input_path)
236
  if not cap.isOpened():
@@ -243,230 +161,362 @@ def _stage1_create_greenscreen(self, input_path: str, trim_seconds: float, callb
243
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
244
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
245
 
246
- if trim_seconds > 0:
247
- max_frames = int(fps * trim_seconds)
248
- total_frames = min(total_frames, max_frames)
249
-
250
- logger.info(f"DEBUG: Stage 1 - {total_frames} frames, {fps:.2f} FPS, {width}x{height}")
251
 
252
- # Output path
253
- green_screen_path = os.path.join(self.temp_dir, "green_screen_debug.mp4")
254
- writer = create_video_writer(green_screen_path, fps, width, height)
255
-
256
- # Define green screen color (force consistent green)
257
- green_color = (0, 255, 0) # Pure green in BGR
258
-
259
- frame_count = 0
260
- successful_masks = 0
261
- failed_masks = 0
262
 
263
- while frame_count < total_frames:
264
  ret, frame = cap.read()
265
  if not ret:
266
  break
267
 
 
 
268
  # Convert to RGB for SAM2
269
  frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
270
 
271
- # Get person mask with debug outputs
272
- mask = self._get_mask(frame_rgb, frame_count)
273
 
274
  if mask is not None:
275
- successful_masks += 1
276
-
277
  # Ensure mask is proper format
278
  if mask.dtype != np.uint8:
279
  mask = (mask * 255).astype(np.uint8)
280
-
281
- # Create green screen frame
282
- green_frame = np.full_like(frame, green_color, dtype=np.uint8)
283
-
284
- # Apply mask to original frame
285
- if len(mask.shape) == 2:
286
- mask_3ch = cv2.merge([mask, mask, mask])
287
- else:
288
- mask_3ch = mask
289
-
290
- # Normalize mask to 0-1 range
291
- mask_norm = mask_3ch.astype(np.float32) / 255.0
292
-
293
- # Composite: person on green background
294
- result_frame = (frame.astype(np.float32) * mask_norm +
295
- green_frame.astype(np.float32) * (1 - mask_norm)).astype(np.uint8)
296
-
297
- writer.write(result_frame)
298
-
299
- # Save debug frame occasionally
300
- if frame_count % 30 == 0: # Every 30th frame
301
- debug_frame_path = f"/tmp/debug_greenscreen_frame_{frame_count}.png"
302
- cv2.imwrite(debug_frame_path, result_frame)
303
- logger.info(f"DEBUG: Saved green screen frame to {debug_frame_path}")
304
-
305
  else:
306
- failed_masks += 1
307
- # No mask detected - use pure green frame
308
- logger.warning(f"DEBUG: No mask detected for frame {frame_count}, using pure green")
309
- green_frame = np.full_like(frame, green_color, dtype=np.uint8)
310
- writer.write(green_frame)
311
 
312
- frame_count += 1
313
-
314
- # Progress callback
315
- if callback and frame_count % 10 == 0:
316
- progress = int(25 * frame_count / total_frames) # Stage 1 is 0-25%
317
- callback(progress, f"Creating green screen... {frame_count}/{total_frames}")
318
 
319
  cap.release()
320
- writer.release()
321
 
322
- logger.info(f"DEBUG: Stage 1 complete - Successful masks: {successful_masks}, Failed masks: {failed_masks}")
 
 
 
 
 
 
 
 
 
 
323
 
324
- if os.path.exists(green_screen_path) and os.path.getsize(green_screen_path) > 0:
325
- logger.info(f"✅ Stage 1 complete: {green_screen_path}")
326
- self._cleanup_list.append(green_screen_path)
327
- return green_screen_path
 
 
328
  else:
329
- logger.error("Stage 1 failed: Empty output file")
330
  return None
331
 
332
  except Exception as e:
333
- logger.error(f"Stage 1 failed: {e}")
334
  logger.error(traceback.format_exc())
335
  return None
336
 
337
- def _stage2_apply_background(self, green_screen_path: str, background_image_path: Optional[str],
338
- background_preset: str, callback: Optional[Callable]) -> Optional[str]:
339
- """Stage 2: Apply background to green screen video using professional chroma key."""
340
  try:
341
- # Load background
342
- background = self._prepare_background(background_image_path, background_preset)
343
- if background is None:
344
- logger.error("Failed to prepare background")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  return None
346
 
347
- cap = cv2.VideoCapture(green_screen_path)
348
- if not cap.isOpened():
349
- logger.error(f"Cannot open green screen video: {green_screen_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  return None
351
 
352
- # Video properties
353
- fps = cap.get(cv2.CAP_PROP_FPS)
354
- width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
355
- height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
356
- total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
357
 
358
- logger.info(f"DEBUG: Stage 2 - {total_frames} frames, {fps:.2f} FPS, {width}x{height}")
 
 
 
 
359
 
360
- # Resize background to match video
361
- background = cv2.resize(background, (width, height))
362
- logger.info(f"DEBUG: Background resized to {width}x{height}")
363
 
364
- # Output path
365
- final_output_path = os.path.join(self.temp_dir, "final_output_debug.mp4")
366
- writer = create_video_writer(final_output_path, fps, width, height)
 
 
 
 
 
 
367
 
368
- frame_count = 0
369
- while frame_count < total_frames:
370
- ret, frame = cap.read()
371
- if not ret:
372
- break
373
-
374
- # Professional chroma key for green screen
375
- result_frame = self._professional_chroma_key(frame, background)
376
- writer.write(result_frame)
377
-
378
- # Save debug frame occasionally
379
- if frame_count % 30 == 0: # Every 30th frame
380
- debug_final_path = f"/tmp/debug_final_frame_{frame_count}.png"
381
- cv2.imwrite(debug_final_path, result_frame)
382
- logger.info(f"DEBUG: Saved final frame to {debug_final_path}")
383
-
384
- frame_count += 1
 
 
 
 
 
 
 
 
 
 
 
385
 
386
- # Progress callback
387
- if callback and frame_count % 10 == 0:
388
- progress = int(25 + 75 * frame_count / total_frames) # Stage 2 is 25-100%
389
- callback(progress, f"Applying background... {frame_count}/{total_frames}")
 
 
 
 
 
 
390
 
391
- cap.release()
392
- writer.release()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
 
394
  if os.path.exists(final_output_path) and os.path.getsize(final_output_path) > 0:
395
- logger.info(f"✅ Stage 2 complete: {final_output_path}")
396
  return final_output_path
397
  else:
398
- logger.error("Stage 2 failed: Empty output file")
399
  return None
400
 
401
  except Exception as e:
402
- logger.error(f"Stage 2 failed: {e}")
403
  logger.error(traceback.format_exc())
404
  return None
405
 
406
- def _professional_chroma_key(self, frame, background):
407
- """Professional chroma key for green screen removal."""
408
- # Convert to HSV for better green detection
409
- hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
410
-
411
- # Define green color range in HSV
412
- # Green hue is around 60 in OpenCV HSV (0-179 range)
413
- lower_green = np.array([40, 40, 40]) # Lower bound
414
- upper_green = np.array([80, 255, 255]) # Upper bound
415
-
416
- # Create mask for green pixels
417
- green_mask = cv2.inRange(hsv, lower_green, upper_green)
418
-
419
- # Smooth the mask
420
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
421
- green_mask = cv2.morphologyEx(green_mask, cv2.MORPH_OPEN, kernel)
422
- green_mask = cv2.morphologyEx(green_mask, cv2.MORPH_CLOSE, kernel)
423
-
424
- # Blur for soft edges
425
- green_mask = cv2.GaussianBlur(green_mask, (5, 5), 0)
426
-
427
- # Convert mask to 3-channel and normalize
428
- green_mask_3ch = cv2.merge([green_mask, green_mask, green_mask])
429
- alpha = green_mask_3ch.astype(np.float32) / 255.0
430
-
431
- # Composite: background where green, original where not green
432
- result = (background.astype(np.float32) * alpha +
433
- frame.astype(np.float32) * (1 - alpha)).astype(np.uint8)
434
-
435
- return result
436
-
437
- def _prepare_background(self, background_image_path: Optional[str], background_preset: str) -> Optional[np.ndarray]:
438
  """Prepare background image from file or preset."""
439
  try:
440
  if background_image_path and os.path.exists(background_image_path):
441
- logger.info(f"Loading custom background: {background_image_path}")
442
  background = cv2.imread(background_image_path)
443
  if background is not None:
 
 
 
444
  return background
445
  logger.warning(f"Failed to load custom background, falling back to preset")
446
 
447
  # Generate preset background
448
- logger.info(f"Using preset background: {background_preset}")
449
- return self._generate_preset_background(background_preset)
450
 
451
  except Exception as e:
452
  logger.error(f"Background preparation failed: {e}")
453
  return None
454
 
455
- def _generate_preset_background(self, preset: str) -> np.ndarray:
456
- """Generate preset background (fallback implementation)."""
457
- # Simple solid color backgrounds
458
  color_map = {
459
- "office": (240, 240, 240), # Light gray
460
- "blue": (255, 200, 100), # Light blue
461
  "studio": (200, 200, 200), # Medium gray
462
  "white": (255, 255, 255), # White
463
  "black": (0, 0, 0) # Black
464
  }
465
 
466
- color = color_map.get(preset, (240, 240, 240)) # Default to office
467
- background = np.full((720, 1280, 3), color, dtype=np.uint8) # Default size
468
 
469
- logger.info(f"Generated {preset} background with color {color}")
470
  return background
471
 
472
  def _cleanup(self):
@@ -486,18 +536,15 @@ def _cleanup(self):
486
  logger.warning(f"Cleanup failed: {e}")
487
 
488
  class BasicSAM2Wrapper:
489
- """Wrapper for basic SAM2 predictor to provide create_mask interface with debug."""
490
 
491
  def __init__(self, predictor):
492
  self.predictor = predictor
493
- logger.info("DEBUG: Created BasicSAM2Wrapper for compatibility")
494
 
495
  def create_mask(self, frame_rgb):
496
- """Basic mask creation using center point prompt with debug outputs."""
497
  try:
498
- logger.info("DEBUG: BasicSAM2Wrapper.create_mask() called")
499
- logger.info(f"DEBUG: BasicSAM2Wrapper input frame shape: {frame_rgb.shape}")
500
-
501
  self.predictor.set_image(frame_rgb)
502
 
503
  # Simple center point prompt
@@ -515,22 +562,12 @@ def create_mask(self, frame_rgb):
515
  # Select best mask by score
516
  best_idx = np.argmax(scores)
517
  mask = masks[best_idx].astype(np.float32)
518
-
519
- logger.info(f"DEBUG: BasicSAM2Wrapper generated {len(masks)} masks")
520
- logger.info(f"DEBUG: BasicSAM2Wrapper selected mask {best_idx} with score {scores[best_idx]:.3f}")
521
- logger.info(f"DEBUG: BasicSAM2Wrapper mask has {np.count_nonzero(mask)} non-zero pixels")
522
-
523
- # Save debug mask
524
- debug_mask = (mask * 255).astype(np.uint8)
525
- cv2.imwrite("/tmp/debug_basic_sam2_wrapper_mask.png", debug_mask)
526
- logger.info("DEBUG: Saved BasicSAM2Wrapper mask to /tmp/debug_basic_sam2_wrapper_mask.png")
527
-
528
  return mask
529
 
530
- logger.warning("DEBUG: BasicSAM2Wrapper - No masks generated")
531
  return None
532
 
533
  except Exception as e:
534
- logger.error(f"DEBUG: BasicSAM2Wrapper failed: {e}")
535
- logger.error(f"DEBUG: BasicSAM2Wrapper traceback: {traceback.format_exc()}")
536
  return None
 
1
  #!/usr/bin/env python3
2
  """
3
+ EFFICIENT Two-Stage Green-Screen Processing System ✅ 2025-09-09
4
+ CORRECTED APPROACH: SAM2 creates reference mask from first 3 frames, MatAnyone handles entire video
5
+ Stage 1: SAM2 processes first 3 frames → Creates reference mask
6
+ Stage 2: MatAnyone processes entire video using reference mask → Final composite
7
+ This is the intended efficient approach, not frame-by-frame SAM2 processing!
8
  """
9
  from __future__ import annotations
10
  import cv2, numpy as np, os, gc, pickle, logging, tempfile, traceback, threading
 
55
  raise RuntimeError(f"Could not create video writer for {output_path}")
56
 
57
  # ---------------------------------------------------------------------------
58
+ # TwoStageProcessor: EFFICIENT processing pipeline
59
  # ---------------------------------------------------------------------------
60
  class TwoStageProcessor:
61
  """
62
+ EFFICIENT Two-stage video background replacement:
63
+ Stage 1: SAM2 processes first 3 frames → Creates reference mask
64
+ Stage 2: MatAnyone processes entire video using reference mask → Final output
65
  """
66
 
67
  def __init__(self, sam2_handler, matanyone_processor=None):
68
+ """Initialize with enhanced SAM2Handler and MatAnyone."""
69
  # CRITICAL: Ensure we receive the full SAM2Handler, not just predictor
70
  if hasattr(sam2_handler, 'create_mask'):
71
  self.sam2_handler = sam2_handler
 
100
  logger.warning(f"Could not access quality_manager profile: {e}, using default")
101
  self.quality_profile = {"name": "default", "settings": {}}
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def process_video(self,
104
  input_path: str,
105
  background_image_path: Optional[str] = None,
 
107
  callback: Optional[Callable] = None,
108
  trim_seconds: float = 5.0) -> Tuple[Optional[str], Optional[str], str]:
109
  """
110
+ EFFICIENT video processing with SAM2 reference mask + MatAnyone.
111
+ Returns: (final_output_path, reference_mask_path, status_message)
112
  """
113
  try:
114
+ self.temp_dir = tempfile.mkdtemp(prefix="twostage_efficient_")
115
+ logger.info(f"🎬 EFFICIENT Processing: {input_path}")
116
+ logger.info(f"📁 Temp directory: {self.temp_dir}")
117
 
118
+ # Stage 1: Create reference mask from first 3 frames using SAM2
119
+ reference_mask_path = self._stage1_create_reference_mask(
120
+ input_path, callback
121
  )
122
 
123
+ if not reference_mask_path:
124
+ return None, None, "❌ Stage 1 failed: Could not create reference mask"
125
 
126
  if callback:
127
+ callback(25, "Reference mask created, starting MatAnyone processing...")
128
 
129
+ # Stage 2: Process entire video with MatAnyone using reference mask
130
+ final_output_path = self._stage2_matanyone_processing(
131
+ input_path, reference_mask_path, background_image_path,
132
+ background_preset, callback, trim_seconds
133
  )
134
 
135
  if not final_output_path:
136
+ return None, reference_mask_path, "❌ Stage 2 failed: MatAnyone processing failed"
137
 
138
+ logger.info(f"✅ EFFICIENT processing complete: {final_output_path}")
139
+ return final_output_path, reference_mask_path, "✅ Video processed successfully with efficient SAM2 + MatAnyone pipeline!"
140
 
141
  except Exception as e:
142
+ logger.error(f"EFFICIENT processing failed: {e}")
143
  logger.error(traceback.format_exc())
144
  return None, None, f"❌ Processing failed: {str(e)}"
145
 
146
  finally:
147
+ # Don't auto-cleanup - let caller decide
148
+ pass
149
 
150
+ def _stage1_create_reference_mask(self, input_path: str, callback: Optional[Callable]) -> Optional[str]:
151
+ """Stage 1: Create reference mask from first 3 frames using SAM2."""
152
  try:
153
  cap = cv2.VideoCapture(input_path)
154
  if not cap.isOpened():
 
161
  height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
162
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
163
 
164
+ logger.info(f"📊 Video: {total_frames} frames, {fps:.2f} FPS, {width}x{height}")
 
 
 
 
165
 
166
+ # Process first 3 frames to create robust reference mask
167
+ reference_frames = []
168
+ masks = []
 
 
 
 
 
 
 
169
 
170
+ for frame_idx in range(min(3, total_frames)):
171
  ret, frame = cap.read()
172
  if not ret:
173
  break
174
 
175
+ reference_frames.append(frame)
176
+
177
  # Convert to RGB for SAM2
178
  frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
179
 
180
+ logger.info(f"🎯 Processing reference frame {frame_idx + 1}/3 with SAM2...")
181
+ mask = self.sam2_handler.create_mask(frame_rgb)
182
 
183
  if mask is not None:
 
 
184
  # Ensure mask is proper format
185
  if mask.dtype != np.uint8:
186
  mask = (mask * 255).astype(np.uint8)
187
+ masks.append(mask)
188
+ logger.info(f"✅ Reference frame {frame_idx + 1} mask created successfully")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  else:
190
+ logger.warning(f"⚠️ Reference frame {frame_idx + 1} failed to generate mask")
 
 
 
 
191
 
192
+ if callback and frame_idx < 2:
193
+ callback(5 + frame_idx * 7, f"Processing reference frame {frame_idx + 1}/3...")
 
 
 
 
194
 
195
  cap.release()
 
196
 
197
+ if not masks:
198
+ logger.error("❌ No valid masks generated from reference frames")
199
+ return None
200
+
201
+ # Create composite reference mask from all successful masks
202
+ logger.info(f"🔄 Creating composite reference mask from {len(masks)} frames...")
203
+ reference_mask = self._create_composite_mask(masks, height, width)
204
+
205
+ # Save reference mask
206
+ reference_mask_path = os.path.join(self.temp_dir, "reference_mask.png")
207
+ cv2.imwrite(reference_mask_path, reference_mask)
208
 
209
+ # Verify mask was saved
210
+ if os.path.exists(reference_mask_path) and os.path.getsize(reference_mask_path) > 0:
211
+ logger.info(f"✅ Reference mask saved: {reference_mask_path}")
212
+ logger.info(f"📐 Mask coverage: {np.mean(reference_mask / 255.0):.3f}")
213
+ self._cleanup_list.append(reference_mask_path)
214
+ return reference_mask_path
215
  else:
216
+ logger.error("❌ Failed to save reference mask")
217
  return None
218
 
219
  except Exception as e:
220
+ logger.error(f"❌ Stage 1 failed: {e}")
221
  logger.error(traceback.format_exc())
222
  return None
223
 
224
+ def _create_composite_mask(self, masks: List[np.ndarray], height: int, width: int) -> np.ndarray:
225
+ """Create a robust composite mask from multiple reference frames."""
 
226
  try:
227
+ if len(masks) == 1:
228
+ return masks[0]
229
+
230
+ # Method 1: Union of all masks (conservative approach)
231
+ composite = np.zeros((height, width), dtype=np.float32)
232
+
233
+ for mask in masks:
234
+ # Normalize mask to 0-1
235
+ if mask.dtype != np.float32:
236
+ mask_norm = mask.astype(np.float32) / 255.0
237
+ else:
238
+ mask_norm = mask
239
+
240
+ # Resize if needed
241
+ if mask_norm.shape != (height, width):
242
+ mask_norm = cv2.resize(mask_norm, (width, height))
243
+
244
+ # Union operation
245
+ composite = np.maximum(composite, mask_norm)
246
+
247
+ # Method 2: Voting-based approach for stability
248
+ vote_threshold = len(masks) * 0.6 # 60% of frames must agree
249
+ vote_composite = np.zeros((height, width), dtype=np.float32)
250
+
251
+ for mask in masks:
252
+ mask_norm = mask.astype(np.float32) / 255.0
253
+ if mask_norm.shape != (height, width):
254
+ mask_norm = cv2.resize(mask_norm, (width, height))
255
+ vote_composite += (mask_norm > 0.5).astype(np.float32)
256
+
257
+ voting_mask = (vote_composite >= vote_threshold).astype(np.float32)
258
+
259
+ # Combine both approaches - use union but refine with voting
260
+ final_mask = composite * 0.7 + voting_mask * 0.3
261
+
262
+ # Post-process for smoothness
263
+ final_mask = cv2.GaussianBlur(final_mask, (5, 5), 1.0)
264
+ final_mask = (final_mask > 0.4).astype(np.float32)
265
+
266
+ # Convert back to uint8
267
+ return (final_mask * 255).astype(np.uint8)
268
+
269
+ except Exception as e:
270
+ logger.error(f"Composite mask creation failed: {e}")
271
+ # Fallback to first mask
272
+ return masks[0] if masks else np.zeros((height, width), dtype=np.uint8)
273
+
274
+ def _stage2_matanyone_processing(self, input_path: str, reference_mask_path: str,
275
+ background_image_path: Optional[str], background_preset: str,
276
+ callback: Optional[Callable], trim_seconds: float) -> Optional[str]:
277
+ """Stage 2: Process entire video with MatAnyone using reference mask."""
278
+ try:
279
+ if not self.matanyone:
280
+ logger.error("❌ MatAnyone processor not available")
281
  return None
282
 
283
+ # Trim video if needed
284
+ if trim_seconds > 0:
285
+ trimmed_video_path = self._trim_video(input_path, trim_seconds, callback)
286
+ if not trimmed_video_path:
287
+ return None
288
+ video_to_process = trimmed_video_path
289
+ else:
290
+ video_to_process = input_path
291
+
292
+ if callback:
293
+ callback(50, "Starting MatAnyone processing...")
294
+
295
+ # Create output directory for MatAnyone
296
+ matanyone_output_dir = os.path.join(self.temp_dir, "matanyone_output")
297
+ os.makedirs(matanyone_output_dir, exist_ok=True)
298
+
299
+ logger.info(f"🚀 Processing entire video with MatAnyone...")
300
+ logger.info(f"📹 Video: {video_to_process}")
301
+ logger.info(f"🎭 Reference mask: {reference_mask_path}")
302
+ logger.info(f"📁 Output dir: {matanyone_output_dir}")
303
+
304
+ # Process with MatAnyone
305
+ matanyone_result = self.matanyone.process_video(
306
+ input_path=video_to_process,
307
+ mask_path=reference_mask_path,
308
+ output_path=matanyone_output_dir,
309
+ max_size=1024 # Reasonable resolution for processing
310
+ )
311
+
312
+ if callback:
313
+ callback(80, "MatAnyone processing complete, applying background...")
314
+
315
+ # Handle MatAnyone result (it might return tuple or string)
316
+ alpha_video_path = None
317
+ if matanyone_result:
318
+ if isinstance(matanyone_result, (list, tuple)) and len(matanyone_result) > 1:
319
+ alpha_video_path = matanyone_result[1] # Second element is usually alpha video
320
+ elif isinstance(matanyone_result, str):
321
+ alpha_video_path = matanyone_result
322
+ else:
323
+ # Search for alpha files in output directory
324
+ for pattern in ["alpha.mp4", "alpha.mkv", "alpha.mov", "alpha.webm"]:
325
+ candidate = os.path.join(matanyone_output_dir, pattern)
326
+ if os.path.exists(candidate):
327
+ alpha_video_path = candidate
328
+ break
329
+
330
+ if not alpha_video_path or not os.path.exists(alpha_video_path):
331
+ logger.error("❌ MatAnyone failed to produce alpha video")
332
  return None
333
 
334
+ logger.info(f"✅ MatAnyone alpha video created: {alpha_video_path}")
 
 
 
 
335
 
336
+ # Stage 3: Composite with background
337
+ final_output_path = self._stage3_composite_background(
338
+ video_to_process, alpha_video_path, background_image_path,
339
+ background_preset, callback
340
+ )
341
 
342
+ return final_output_path
 
 
343
 
344
+ except Exception as e:
345
+ logger.error(f"❌ Stage 2 failed: {e}")
346
+ logger.error(traceback.format_exc())
347
+ return None
348
+
349
+ def _trim_video(self, input_path: str, trim_seconds: float, callback: Optional[Callable]) -> Optional[str]:
350
+ """Trim video to specified duration."""
351
+ try:
352
+ from moviepy.editor import VideoFileClip
353
 
354
+ trimmed_path = os.path.join(self.temp_dir, "trimmed_video.mp4")
355
+
356
+ logger.info(f"✂️ Trimming video to {trim_seconds}s...")
357
+
358
+ clip = VideoFileClip(input_path)
359
+ duration = min(trim_seconds, float(clip.duration or trim_seconds))
360
+ trimmed_clip = clip.subclip(0, duration)
361
+
362
+ # Write with optimized settings
363
+ trimmed_clip.write_videofile(
364
+ trimmed_path,
365
+ audio=False,
366
+ fps=clip.fps,
367
+ codec="libx264",
368
+ logger=None,
369
+ ffmpeg_params=["-preset", "fast", "-pix_fmt", "yuv420p"]
370
+ )
371
+
372
+ clip.close()
373
+ trimmed_clip.close()
374
+
375
+ if os.path.exists(trimmed_path):
376
+ logger.info(f"✅ Video trimmed: {trimmed_path}")
377
+ self._cleanup_list.append(trimmed_path)
378
+ return trimmed_path
379
+ else:
380
+ logger.error("❌ Video trimming failed")
381
+ return None
382
 
383
+ except Exception as e:
384
+ logger.error(f"❌ Video trimming failed: {e}")
385
+ return None
386
+
387
+ def _stage3_composite_background(self, original_video_path: str, alpha_video_path: str,
388
+ background_image_path: Optional[str], background_preset: str,
389
+ callback: Optional[Callable]) -> Optional[str]:
390
+ """Stage 3: Composite alpha video with background."""
391
+ try:
392
+ from moviepy.editor import VideoFileClip
393
 
394
+ # Load videos
395
+ original_clip = VideoFileClip(original_video_path)
396
+ alpha_clip = VideoFileClip(alpha_video_path)
397
+
398
+ # Get video properties
399
+ width = int(original_clip.w)
400
+ height = int(original_clip.h)
401
+
402
+ # Prepare background
403
+ background = self._prepare_background(background_image_path, background_preset, width, height)
404
+ if background is None:
405
+ logger.error("❌ Failed to prepare background")
406
+ return None
407
+
408
+ logger.info(f"🎨 Compositing final video...")
409
+
410
+ def composite_frame(get_frame, t):
411
+ """Composite frame with alpha matte."""
412
+ try:
413
+ original_frame = get_frame(t)
414
+
415
+ # Get alpha frame (handle duration mismatch)
416
+ alpha_duration = float(alpha_clip.duration or 0)
417
+ alpha_t = min(t, max(0, alpha_duration - 0.01)) if alpha_duration > 0 else 0
418
+ alpha_frame = alpha_clip.get_frame(alpha_t)
419
+
420
+ # Process alpha
421
+ if alpha_frame.ndim == 3:
422
+ alpha_frame = alpha_frame[:, :, 0] # Take first channel
423
+
424
+ # Normalize alpha to 0-1 range
425
+ if alpha_frame.max() > 1.1:
426
+ alpha_frame = alpha_frame / 255.0
427
+
428
+ alpha_frame = np.clip(alpha_frame, 0, 1)
429
+
430
+ # Resize alpha if needed
431
+ if alpha_frame.shape != (height, width):
432
+ alpha_frame = cv2.resize(alpha_frame, (width, height))
433
+
434
+ # Add channel dimension
435
+ alpha_frame = alpha_frame[:, :, np.newaxis]
436
+
437
+ # Prepare background
438
+ bg_normalized = background.astype(np.float32) / 255.0
439
+
440
+ # Composite
441
+ result = alpha_frame * original_frame + (1 - alpha_frame) * bg_normalized
442
+
443
+ return np.clip(result, 0, 1).astype(np.float32)
444
+
445
+ except Exception as e:
446
+ logger.error(f"Frame compositing error: {e}")
447
+ return original_frame if 'original_frame' in locals() else np.zeros((height, width, 3), dtype=np.float32)
448
+
449
+ # Create final composite
450
+ final_clip = original_clip.fl(composite_frame)
451
+
452
+ # Output path
453
+ final_output_path = os.path.join(self.temp_dir, "final_output.mp4")
454
+
455
+ # Write final video
456
+ final_clip.write_videofile(
457
+ final_output_path,
458
+ audio=False,
459
+ fps=original_clip.fps,
460
+ codec="libx264",
461
+ logger=None,
462
+ ffmpeg_params=["-preset", "fast", "-pix_fmt", "yuv420p"]
463
+ )
464
+
465
+ original_clip.close()
466
+ alpha_clip.close()
467
+ final_clip.close()
468
+
469
+ if callback:
470
+ callback(100, "Final compositing completed!")
471
 
472
  if os.path.exists(final_output_path) and os.path.getsize(final_output_path) > 0:
473
+ logger.info(f"✅ Final video created: {final_output_path}")
474
  return final_output_path
475
  else:
476
+ logger.error("❌ Final video creation failed")
477
  return None
478
 
479
  except Exception as e:
480
+ logger.error(f"❌ Stage 3 failed: {e}")
481
  logger.error(traceback.format_exc())
482
  return None
483
 
484
+ def _prepare_background(self, background_image_path: Optional[str], background_preset: str,
485
+ width: int, height: int) -> Optional[np.ndarray]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  """Prepare background image from file or preset."""
487
  try:
488
  if background_image_path and os.path.exists(background_image_path):
489
+ logger.info(f"📷 Loading custom background: {background_image_path}")
490
  background = cv2.imread(background_image_path)
491
  if background is not None:
492
+ # Convert BGR to RGB and resize
493
+ background = cv2.cvtColor(background, cv2.COLOR_BGR2RGB)
494
+ background = cv2.resize(background, (width, height))
495
  return background
496
  logger.warning(f"Failed to load custom background, falling back to preset")
497
 
498
  # Generate preset background
499
+ logger.info(f"🎨 Using preset background: {background_preset}")
500
+ return self._generate_preset_background(background_preset, width, height)
501
 
502
  except Exception as e:
503
  logger.error(f"Background preparation failed: {e}")
504
  return None
505
 
506
+ def _generate_preset_background(self, preset: str, width: int, height: int) -> np.ndarray:
507
+ """Generate preset background."""
 
508
  color_map = {
509
+ "office": (245, 246, 248), # Light gray
510
+ "blue": (100, 200, 255), # Light blue
511
  "studio": (200, 200, 200), # Medium gray
512
  "white": (255, 255, 255), # White
513
  "black": (0, 0, 0) # Black
514
  }
515
 
516
+ color = color_map.get(preset, (245, 246, 248)) # Default to office
517
+ background = np.full((height, width, 3), color, dtype=np.uint8)
518
 
519
+ logger.info(f"Generated {preset} background ({width}x{height}) with color {color}")
520
  return background
521
 
522
  def _cleanup(self):
 
536
  logger.warning(f"Cleanup failed: {e}")
537
 
538
  class BasicSAM2Wrapper:
539
+ """Wrapper for basic SAM2 predictor to provide create_mask interface."""
540
 
541
  def __init__(self, predictor):
542
  self.predictor = predictor
543
+ logger.info("Created BasicSAM2Wrapper for compatibility")
544
 
545
  def create_mask(self, frame_rgb):
546
+ """Basic mask creation using center point prompt."""
547
  try:
 
 
 
548
  self.predictor.set_image(frame_rgb)
549
 
550
  # Simple center point prompt
 
562
  # Select best mask by score
563
  best_idx = np.argmax(scores)
564
  mask = masks[best_idx].astype(np.float32)
565
+ logger.info(f"BasicSAM2Wrapper: Generated mask with {np.count_nonzero(mask)} pixels")
 
 
 
 
 
 
 
 
 
566
  return mask
567
 
568
+ logger.warning("BasicSAM2Wrapper: No masks generated")
569
  return None
570
 
571
  except Exception as e:
572
+ logger.error(f"BasicSAM2Wrapper failed: {e}")
 
573
  return None