MogensR commited on
Commit
a074475
Β·
1 Parent(s): 94dcf70

Create hair_segmentation.py

Browse files
Files changed (1) hide show
  1. hair_segmentation.py +576 -0
hair_segmentation.py ADDED
@@ -0,0 +1,576 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Professional Hair Segmentation Module
3
+ =====================================
4
+
5
+ This module provides high-quality hair segmentation for video processing
6
+ using SAM2 + MatAnyone pipeline with comprehensive error handling and fallbacks.
7
+
8
+ Author: Your Project
9
+ License: MIT
10
+ """
11
+
12
+ import os
13
+ import torch
14
+ import cv2
15
+ import numpy as np
16
+ import logging
17
+ from typing import Dict, List, Tuple, Optional, Union
18
+ from pathlib import Path
19
+ import warnings
20
+ from dataclasses import dataclass
21
+ from abc import ABC, abstractmethod
22
+
23
+ # Fix threading issues immediately
24
+ os.environ['OMP_NUM_THREADS'] = '4'
25
+ os.environ['MKL_NUM_THREADS'] = '4'
26
+
27
+ # Configure logging
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
31
+ )
32
+ logger = logging.getLogger(__name__)
33
+
34
+ @dataclass
35
+ class SegmentationResult:
36
+ """Result container for hair segmentation"""
37
+ mask: np.ndarray
38
+ confidence: float
39
+ coverage_percent: float
40
+ asymmetry_score: float
41
+ processing_time: float
42
+ fallback_used: bool
43
+ quality_score: float
44
+ error_message: Optional[str] = None
45
+
46
+ class BaseSegmentationModel(ABC):
47
+ """Abstract base class for segmentation models"""
48
+
49
+ @abstractmethod
50
+ def initialize(self) -> bool:
51
+ """Initialize the model"""
52
+ pass
53
+
54
+ @abstractmethod
55
+ def segment(self, frame: np.ndarray) -> np.ndarray:
56
+ """Segment hair in frame"""
57
+ pass
58
+
59
+ @abstractmethod
60
+ def get_model_name(self) -> str:
61
+ """Get model name for logging"""
62
+ pass
63
+
64
+ class SAM2Model(BaseSegmentationModel):
65
+ """SAM2 segmentation model wrapper"""
66
+
67
+ def __init__(self, model_path: Optional[str] = None, device: str = 'auto'):
68
+ self.model_path = model_path
69
+ self.device = self._get_best_device(device)
70
+ self.predictor = None
71
+ self.initialized = False
72
+
73
+ def _get_best_device(self, device: str) -> str:
74
+ """Determine best available device"""
75
+ if device == 'auto':
76
+ return 'cuda' if torch.cuda.is_available() else 'cpu'
77
+ return device
78
+
79
+ def initialize(self) -> bool:
80
+ """Initialize SAM2 model"""
81
+ try:
82
+ logger.info("πŸ€– Initializing SAM2 model...")
83
+
84
+ # Import SAM2 (handle different installation methods)
85
+ try:
86
+ from sam2.build_sam import build_sam2_video_predictor
87
+ except ImportError:
88
+ logger.error("SAM2 not found. Please install SAM2.")
89
+ return False
90
+
91
+ # Build predictor
92
+ if self.model_path and Path(self.model_path).exists():
93
+ self.predictor = build_sam2_video_predictor(self.model_path, device=self.device)
94
+ else:
95
+ # Use default model
96
+ self.predictor = build_sam2_video_predictor("sam2_hiera_large.pt", device=self.device)
97
+
98
+ self.initialized = True
99
+ logger.info(f"βœ… SAM2 initialized on {self.device}")
100
+ return True
101
+
102
+ except Exception as e:
103
+ logger.error(f"❌ SAM2 initialization failed: {e}")
104
+ return False
105
+
106
+ def segment(self, frame: np.ndarray) -> np.ndarray:
107
+ """Segment using SAM2"""
108
+ if not self.initialized:
109
+ raise RuntimeError("SAM2 model not initialized")
110
+
111
+ try:
112
+ # Convert BGR to RGB
113
+ if len(frame.shape) == 3:
114
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
115
+ else:
116
+ frame_rgb = frame
117
+
118
+ # Set image for SAM2
119
+ self.predictor.set_image(frame_rgb)
120
+
121
+ # Auto-detect person in center (you can make this more sophisticated)
122
+ height, width = frame_rgb.shape[:2]
123
+ center_point = np.array([[width//2, height//2]])
124
+
125
+ # Predict mask
126
+ masks, scores, _ = self.predictor.predict(
127
+ point_coords=center_point,
128
+ point_labels=np.array([1])
129
+ )
130
+
131
+ # Return best mask
132
+ if len(masks) > 0:
133
+ best_mask_idx = np.argmax(scores)
134
+ return masks[best_mask_idx].astype(np.float32)
135
+ else:
136
+ return np.zeros((height, width), dtype=np.float32)
137
+
138
+ except Exception as e:
139
+ logger.error(f"SAM2 segmentation failed: {e}")
140
+ raise
141
+
142
+ def get_model_name(self) -> str:
143
+ return "SAM2"
144
+
145
+ class MatAnyoneModel(BaseSegmentationModel):
146
+ """MatAnyone model wrapper with quality checking"""
147
+
148
+ def __init__(self, use_hf_api: bool = True, hf_token: Optional[str] = None):
149
+ self.use_hf_api = use_hf_api
150
+ self.hf_token = hf_token
151
+ self.client = None
152
+ self.processor = None
153
+ self.initialized = False
154
+ self.quality_threshold = 0.3
155
+
156
+ def initialize(self) -> bool:
157
+ """Initialize MatAnyone model"""
158
+ try:
159
+ logger.info("🎭 Initializing MatAnyone model...")
160
+
161
+ if self.use_hf_api:
162
+ from gradio_client import Client
163
+ self.client = Client("PeiqingYang/MatAnyone", hf_token=self.hf_token)
164
+ logger.info("βœ… MatAnyone HF API initialized")
165
+ else:
166
+ # Local MatAnyone initialization would go here
167
+ logger.warning("Local MatAnyone not implemented yet")
168
+ return False
169
+
170
+ self.initialized = True
171
+ return True
172
+
173
+ except Exception as e:
174
+ logger.error(f"❌ MatAnyone initialization failed: {e}")
175
+ return False
176
+
177
+ def segment(self, frame: np.ndarray) -> np.ndarray:
178
+ """MatAnyone is primarily for matting, not segmentation"""
179
+ raise NotImplementedError("MatAnyone is used for matting, not direct segmentation")
180
+
181
+ def matte(self, image: np.ndarray, trimap: np.ndarray) -> np.ndarray:
182
+ """Apply matting using MatAnyone"""
183
+ if not self.initialized:
184
+ raise RuntimeError("MatAnyone model not initialized")
185
+
186
+ try:
187
+ # Save temporary files
188
+ import tempfile
189
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_file:
190
+ cv2.imwrite(img_file.name, image)
191
+ img_path = img_file.name
192
+
193
+ with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tri_file:
194
+ cv2.imwrite(tri_file.name, trimap)
195
+ tri_path = tri_file.name
196
+
197
+ # Process with MatAnyone
198
+ if self.use_hf_api:
199
+ result = self._process_hf_api(img_path, tri_path)
200
+ else:
201
+ result = self._process_local(img_path, tri_path)
202
+
203
+ # Cleanup temp files
204
+ os.unlink(img_path)
205
+ os.unlink(tri_path)
206
+
207
+ return result
208
+
209
+ except Exception as e:
210
+ logger.error(f"MatAnyone matting failed: {e}")
211
+ raise
212
+
213
+ def _process_hf_api(self, image_path: str, trimap_path: str) -> np.ndarray:
214
+ """Process using HuggingFace API"""
215
+ try:
216
+ result = self.client.predict(
217
+ image=image_path,
218
+ trimap=trimap_path,
219
+ api_name="/predict"
220
+ )
221
+
222
+ # Load result
223
+ if isinstance(result, str):
224
+ result_image = cv2.imread(result)
225
+ return result_image
226
+ else:
227
+ return result
228
+
229
+ except Exception as e:
230
+ logger.error(f"HF API processing failed: {e}")
231
+ raise
232
+
233
+ def get_model_name(self) -> str:
234
+ return "MatAnyone"
235
+
236
+ class TraditionalCVModel(BaseSegmentationModel):
237
+ """Traditional computer vision fallback"""
238
+
239
+ def __init__(self):
240
+ self.initialized = False
241
+
242
+ def initialize(self) -> bool:
243
+ """Initialize traditional CV methods"""
244
+ self.initialized = True
245
+ return True
246
+
247
+ def segment(self, frame: np.ndarray) -> np.ndarray:
248
+ """Traditional hair segmentation using color and texture"""
249
+ try:
250
+ # Convert to different color spaces
251
+ hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
252
+ lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
253
+
254
+ # Hair color detection
255
+ hair_mask_hsv = self._detect_hair_hsv(hsv)
256
+ hair_mask_lab = self._detect_hair_lab(lab)
257
+
258
+ # Combine masks
259
+ combined_mask = cv2.bitwise_or(hair_mask_hsv, hair_mask_lab)
260
+
261
+ # Morphological operations
262
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
263
+ combined_mask = cv2.morphologyEx(combined_mask, cv2.MORPH_CLOSE, kernel)
264
+ combined_mask = cv2.morphologyEx(combined_mask, cv2.MORPH_OPEN, kernel)
265
+
266
+ return combined_mask.astype(np.float32) / 255.0
267
+
268
+ except Exception as e:
269
+ logger.error(f"Traditional CV segmentation failed: {e}")
270
+ raise
271
+
272
+ def _detect_hair_hsv(self, hsv: np.ndarray) -> np.ndarray:
273
+ """Detect hair in HSV color space"""
274
+ # Multiple hair color ranges
275
+ ranges = [
276
+ # Dark hair
277
+ ([0, 0, 0], [180, 255, 80]),
278
+ # Brown hair
279
+ ([8, 50, 20], [25, 255, 200]),
280
+ # Blonde hair
281
+ ([15, 30, 100], [35, 255, 255])
282
+ ]
283
+
284
+ masks = []
285
+ for lower, upper in ranges:
286
+ mask = cv2.inRange(hsv, np.array(lower), np.array(upper))
287
+ masks.append(mask)
288
+
289
+ # Combine all color ranges
290
+ final_mask = masks[0]
291
+ for mask in masks[1:]:
292
+ final_mask = cv2.bitwise_or(final_mask, mask)
293
+
294
+ return final_mask
295
+
296
+ def _detect_hair_lab(self, lab: np.ndarray) -> np.ndarray:
297
+ """Detect hair in LAB color space"""
298
+ l_channel = lab[:, :, 0]
299
+ hair_mask = cv2.inRange(l_channel, 0, 120)
300
+ return hair_mask
301
+
302
+ def get_model_name(self) -> str:
303
+ return "TraditionalCV"
304
+
305
+ class TemporalSmoother:
306
+ """Temporal smoothing for video sequences"""
307
+
308
+ def __init__(self, smoothing_factor: float = 0.7, change_threshold: float = 0.05):
309
+ self.smoothing_factor = smoothing_factor
310
+ self.change_threshold = change_threshold
311
+ self.previous_mask = None
312
+ self.correction_count = 0
313
+ self.total_frames = 0
314
+
315
+ def smooth(self, current_mask: np.ndarray) -> Tuple[np.ndarray, bool]:
316
+ """Apply temporal smoothing"""
317
+ self.total_frames += 1
318
+ corrected = False
319
+
320
+ if self.previous_mask is not None:
321
+ # Calculate change
322
+ diff = np.mean(np.abs(current_mask - self.previous_mask))
323
+
324
+ if diff > self.change_threshold:
325
+ # Apply smoothing
326
+ smoothed_mask = (self.smoothing_factor * current_mask +
327
+ (1 - self.smoothing_factor) * self.previous_mask)
328
+ self.correction_count += 1
329
+ corrected = True
330
+ else:
331
+ smoothed_mask = current_mask
332
+ else:
333
+ smoothed_mask = current_mask
334
+
335
+ self.previous_mask = smoothed_mask.copy()
336
+ return smoothed_mask, corrected
337
+
338
+ def get_correction_ratio(self) -> float:
339
+ """Get ratio of frames that needed correction"""
340
+ return self.correction_count / max(self.total_frames, 1)
341
+
342
+ class HairSegmentationPipeline:
343
+ """Main hair segmentation pipeline with multiple models and fallbacks"""
344
+
345
+ def __init__(self, config: Optional[Dict] = None):
346
+ self.config = config or {}
347
+ self.models = {}
348
+ self.active_model = None
349
+ self.fallback_models = []
350
+ self.temporal_smoother = TemporalSmoother()
351
+ self.initialized = False
352
+
353
+ # Setup models
354
+ self._setup_models()
355
+
356
+ def _setup_models(self):
357
+ """Setup available models"""
358
+ try:
359
+ # Primary model: SAM2
360
+ sam2_model = SAM2Model(
361
+ model_path=self.config.get('sam2_model_path'),
362
+ device=self.config.get('device', 'auto')
363
+ )
364
+ self.models['sam2'] = sam2_model
365
+
366
+ # MatAnyone for matting
367
+ matanyone_model = MatAnyoneModel(
368
+ use_hf_api=self.config.get('use_hf_api', True),
369
+ hf_token=self.config.get('hf_token')
370
+ )
371
+ self.models['matanyone'] = matanyone_model
372
+
373
+ # Fallback: Traditional CV
374
+ traditional_model = TraditionalCVModel()
375
+ self.models['traditional'] = traditional_model
376
+
377
+ except Exception as e:
378
+ logger.error(f"Model setup failed: {e}")
379
+
380
+ def initialize(self, preferred_model: str = 'sam2') -> bool:
381
+ """Initialize the pipeline"""
382
+ logger.info("πŸš€ Initializing Hair Segmentation Pipeline...")
383
+
384
+ # Try to initialize preferred model
385
+ if preferred_model in self.models:
386
+ if self.models[preferred_model].initialize():
387
+ self.active_model = preferred_model
388
+ logger.info(f"βœ… Primary model {preferred_model} initialized")
389
+ else:
390
+ logger.warning(f"⚠️ Primary model {preferred_model} failed")
391
+
392
+ # Initialize fallback models
393
+ for model_name, model in self.models.items():
394
+ if model_name != self.active_model:
395
+ if model.initialize():
396
+ self.fallback_models.append(model_name)
397
+ logger.info(f"βœ… Fallback model {model_name} ready")
398
+
399
+ # Check if we have at least one working model
400
+ if self.active_model or self.fallback_models:
401
+ self.initialized = True
402
+ logger.info(f"🎯 Pipeline ready - Active: {self.active_model}, Fallbacks: {self.fallback_models}")
403
+ return True
404
+ else:
405
+ logger.error("❌ No working models available")
406
+ return False
407
+
408
+ def segment_frame(self, frame: np.ndarray,
409
+ apply_temporal_smoothing: bool = True) -> SegmentationResult:
410
+ """Segment hair in a single frame"""
411
+ if not self.initialized:
412
+ raise RuntimeError("Pipeline not initialized")
413
+
414
+ import time
415
+ start_time = time.time()
416
+
417
+ # Try active model first
418
+ mask, model_used, error_msg = self._try_segment_with_model(frame, self.active_model)
419
+
420
+ # If failed, try fallback models
421
+ if mask is None:
422
+ for fallback_model in self.fallback_models:
423
+ mask, model_used, error_msg = self._try_segment_with_model(frame, fallback_model)
424
+ if mask is not None:
425
+ break
426
+
427
+ if mask is None:
428
+ # Complete failure - return empty mask
429
+ h, w = frame.shape[:2]
430
+ mask = np.zeros((h, w), dtype=np.float32)
431
+ model_used = "none"
432
+ error_msg = "All models failed"
433
+
434
+ # Apply temporal smoothing
435
+ corrected = False
436
+ if apply_temporal_smoothing:
437
+ mask, corrected = self.temporal_smoother.smooth(mask)
438
+
439
+ # Calculate metrics
440
+ processing_time = time.time() - start_time
441
+ confidence = self._calculate_confidence(mask)
442
+ coverage = self._calculate_coverage(mask)
443
+ asymmetry = self._calculate_asymmetry(mask)
444
+ quality = self._calculate_quality(mask)
445
+
446
+ return SegmentationResult(
447
+ mask=mask,
448
+ confidence=confidence,
449
+ coverage_percent=coverage,
450
+ asymmetry_score=asymmetry,
451
+ processing_time=processing_time,
452
+ fallback_used=(model_used != self.active_model),
453
+ quality_score=quality,
454
+ error_message=error_msg
455
+ )
456
+
457
+ def _try_segment_with_model(self, frame: np.ndarray, model_name: str) -> Tuple[Optional[np.ndarray], str, Optional[str]]:
458
+ """Try to segment with a specific model"""
459
+ if model_name not in self.models:
460
+ return None, model_name, f"Model {model_name} not available"
461
+
462
+ try:
463
+ mask = self.models[model_name].segment(frame)
464
+ return mask, model_name, None
465
+ except Exception as e:
466
+ error_msg = f"Model {model_name} failed: {str(e)}"
467
+ logger.warning(error_msg)
468
+ return None, model_name, error_msg
469
+
470
+ def _calculate_confidence(self, mask: np.ndarray) -> float:
471
+ """Calculate mask confidence"""
472
+ # Edge sharpness
473
+ edges = cv2.Canny((mask * 255).astype(np.uint8), 50, 150)
474
+ edge_ratio = np.sum(edges > 0) / mask.size
475
+
476
+ # Mask smoothness
477
+ gradient = np.gradient(mask)
478
+ smoothness = 1.0 / (1.0 + np.std(gradient))
479
+
480
+ return min(edge_ratio * 0.3 + smoothness * 0.7, 1.0)
481
+
482
+ def _calculate_coverage(self, mask: np.ndarray) -> float:
483
+ """Calculate hair coverage percentage"""
484
+ return (np.sum(mask > 0.5) / mask.size) * 100
485
+
486
+ def _calculate_asymmetry(self, mask: np.ndarray) -> float:
487
+ """Calculate left-right asymmetry score"""
488
+ h, w = mask.shape[:2]
489
+ center_x = w // 2
490
+
491
+ left_half = mask[:, :center_x]
492
+ right_half = np.fliplr(mask[:, center_x:])
493
+
494
+ min_width = min(left_half.shape[1], right_half.shape[1])
495
+ left_half = left_half[:, :min_width]
496
+ right_half = right_half[:, :min_width]
497
+
498
+ return np.mean(np.abs(left_half - right_half))
499
+
500
+ def _calculate_quality(self, mask: np.ndarray) -> float:
501
+ """Calculate overall mask quality"""
502
+ # Combine multiple quality metrics
503
+ confidence = self._calculate_confidence(mask)
504
+ coverage = self._calculate_coverage(mask) / 100.0
505
+ asymmetry_penalty = 1.0 - min(self._calculate_asymmetry(mask), 1.0)
506
+
507
+ return (confidence * 0.5 + coverage * 0.3 + asymmetry_penalty * 0.2)
508
+
509
+ def get_pipeline_stats(self) -> Dict:
510
+ """Get pipeline performance statistics"""
511
+ return {
512
+ 'active_model': self.active_model,
513
+ 'fallback_models': self.fallback_models,
514
+ 'temporal_correction_ratio': self.temporal_smoother.get_correction_ratio(),
515
+ 'total_frames_processed': self.temporal_smoother.total_frames,
516
+ 'corrections_applied': self.temporal_smoother.correction_count
517
+ }
518
+
519
+ # Convenience functions
520
+ def create_pipeline(config: Optional[Dict] = None) -> HairSegmentationPipeline:
521
+ """Create and initialize hair segmentation pipeline"""
522
+ pipeline = HairSegmentationPipeline(config)
523
+ pipeline.initialize()
524
+ return pipeline
525
+
526
+ def segment_image(image_path: str, config: Optional[Dict] = None) -> SegmentationResult:
527
+ """Segment hair in a single image"""
528
+ pipeline = create_pipeline(config)
529
+ frame = cv2.imread(image_path)
530
+ return pipeline.segment_frame(frame)
531
+
532
+ def segment_video_frames(video_frames: List[np.ndarray],
533
+ config: Optional[Dict] = None) -> List[SegmentationResult]:
534
+ """Segment hair in multiple video frames"""
535
+ pipeline = create_pipeline(config)
536
+ results = []
537
+
538
+ for frame in video_frames:
539
+ result = pipeline.segment_frame(frame)
540
+ results.append(result)
541
+
542
+ return results
543
+
544
+ # Example usage
545
+ if __name__ == "__main__":
546
+ # Example configuration
547
+ config = {
548
+ 'sam2_model_path': None, # Use default
549
+ 'device': 'auto',
550
+ 'use_hf_api': True,
551
+ 'hf_token': None # Set your token if needed
552
+ }
553
+
554
+ # Create pipeline
555
+ pipeline = create_pipeline(config)
556
+
557
+ # Test with example frame (you would load your actual frame)
558
+ test_frame = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
559
+
560
+ # Segment frame
561
+ result = pipeline.segment_frame(test_frame)
562
+
563
+ # Print results
564
+ print(f"Segmentation Results:")
565
+ print(f" Coverage: {result.coverage_percent:.1f}%")
566
+ print(f" Confidence: {result.confidence:.3f}")
567
+ print(f" Quality: {result.quality_score:.3f}")
568
+ print(f" Processing time: {result.processing_time:.2f}s")
569
+ print(f" Fallback used: {result.fallback_used}")
570
+
571
+ # Get pipeline stats
572
+ stats = pipeline.get_pipeline_stats()
573
+ print(f"\nPipeline Stats:")
574
+ print(f" Active model: {stats['active_model']}")
575
+ print(f" Fallbacks: {stats['fallback_models']}")
576
+ print(f" Correction ratio: {stats['temporal_correction_ratio']:.3f}")