andevs commited on
Commit
d1521ff
·
verified ·
1 Parent(s): 927f601

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +635 -341
app.py CHANGED
@@ -1,8 +1,18 @@
1
  #!/usr/bin/env python3
2
- # app.py - Background Remover Pro v30.2.0
3
- # FIX: pre-warm runs AFTER remove_func is set
4
- # FIX: no-op on ALL models → proper HTTP error (not silent pass-through)
5
- # FIX: /api/debug/model-report endpoint
 
 
 
 
 
 
 
 
 
 
6
 
7
  import io
8
  import time
@@ -39,9 +49,11 @@ import cv2
39
  import threading
40
  from io import BytesIO
41
 
 
42
  if sys.version_info < (3, 8):
43
  raise RuntimeError("Python 3.8 or higher required")
44
 
 
45
  logging.basicConfig(
46
  level=logging.INFO,
47
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
@@ -50,38 +62,49 @@ logger = logging.getLogger(__name__)
50
 
51
 
52
  # ============================================================
53
- # INLINE MANAGERS
54
  # ============================================================
55
 
56
  class ResolutionManager:
57
  RECOMMENDED = {
58
- 'simple_graphic': 600, 'human_hair': 1024, 'human': 1024,
59
- 'anime': 800, 'logo_icon': 600, 'product_white_bg': 700,
60
- 'general_photo': 1024, 'complex': 1024,
 
 
 
 
 
61
  }
 
62
  @classmethod
63
- def get_resolution(cls, category, original_size):
64
  base = cls.RECOMMENDED.get(category, 1024)
65
  return min(base, max(original_size))
66
 
67
 
68
  class ModelManager:
69
- def __init__(self, max_models=5, memory_limit_mb=12000):
70
- self.models = {}
71
- self.load_times = {}
72
- self.use_counts = {}
73
  self.max_models = max_models
74
  self.memory_limit_mb = memory_limit_mb
75
 
76
- def get_model(self, name, loader_func):
77
  if name in self.models:
78
  self.use_counts[name] = self.use_counts.get(name, 0) + 1
79
  return self.models[name]
 
80
  try:
81
- if psutil.virtual_memory().percent > 85:
 
 
82
  self._evict_models()
 
83
  except Exception:
84
  pass
 
85
  start = time.time()
86
  try:
87
  session = loader_func(name)
@@ -90,23 +113,35 @@ class ModelManager:
90
  self.models[name] = session
91
  self.load_times[name] = time.time() - start
92
  self.use_counts[name] = 1
 
 
 
 
 
 
 
 
 
 
 
93
  if len(self.models) > self.max_models:
94
  self._evict_models()
95
  return session
96
  except Exception as e:
97
  logger.warning(f"Loader failed for {name}: {e}")
 
98
  return None
99
 
100
  def _evict_models(self):
101
  if len(self.models) <= 1:
102
  return
103
  to_remove = min(self.models.keys(), key=lambda x: self.use_counts.get(x, 0))
104
- logger.info(f"♻️ Evicting LRU: {to_remove}")
105
  del self.models[to_remove]
106
  self.use_counts.pop(to_remove, None)
107
  gc.collect()
108
 
109
- def get_stats(self):
110
  return {
111
  'loaded_models': list(self.models.keys()),
112
  'model_count': len(self.models),
@@ -123,11 +158,12 @@ class MaskQualityChecker:
123
  self.max_components = 100
124
  self.min_confidence = 0.40
125
 
126
- def check(self, mask):
127
  if mask.dtype != np.uint8:
128
  mask = (mask * 255).astype(np.uint8)
129
  h, w = mask.shape
130
  fg = float(np.sum(mask > 128) / (h * w))
 
131
  border_pixels = 0
132
  border_total = 0
133
  for x in range(w):
@@ -139,6 +175,7 @@ class MaskQualityChecker:
139
  if mask[y, w - 1] > 128: border_pixels += 1
140
  border_total += 2
141
  border_ratio = border_pixels / border_total if border_total > 0 else 0
 
142
  try:
143
  from scipy.ndimage import label
144
  labeled, num_components = label(mask > 128)
@@ -148,6 +185,7 @@ class MaskQualityChecker:
148
  except Exception:
149
  num_components = 1
150
  fragmentation = 0
 
151
  confidence = 1.0
152
  issues = []
153
  if fg < self.min_foreground:
@@ -158,6 +196,7 @@ class MaskQualityChecker:
158
  confidence -= 0.2; issues.append('border_contamination')
159
  if num_components > self.max_components:
160
  confidence -= 0.1; issues.append('too_many_components')
 
161
  confidence = max(0.0, min(1.0, confidence))
162
  return {
163
  'passed': confidence >= self.min_confidence,
@@ -171,7 +210,7 @@ class MaskQualityChecker:
171
 
172
 
173
  class HairRefiner:
174
- def refine(self, mask):
175
  if mask.dtype != np.uint8:
176
  mask = (mask * 255).astype(np.uint8)
177
  mask_float = mask.astype(np.float32) / 255.0
@@ -216,7 +255,7 @@ class GraphicProcessor:
216
  def __init__(self):
217
  self.tolerance = 25
218
 
219
- def remove_background(self, image):
220
  if image.mode != 'RGBA':
221
  image = image.convert('RGBA')
222
  np_img = np.array(image)
@@ -269,7 +308,7 @@ class GraphicProcessor:
269
 
270
 
271
  class ContentClassifier:
272
- def classify(self, image):
273
  if image.mode != 'RGB':
274
  image = image.convert('RGB')
275
  small = image.copy()
@@ -283,7 +322,8 @@ class ContentClassifier:
283
  'texture': self._tex(np_img),
284
  }
285
  category = self._cls(signals)
286
- return {'category': category, 'confidence': 0.7, 'signals': signals}
 
287
 
288
  def _border(self, np_img):
289
  h, w = np_img.shape[:2]
@@ -343,9 +383,19 @@ class ContentClassifier:
343
  return 'general_photo'
344
  return 'complex'
345
 
 
 
 
 
 
 
 
 
 
 
346
 
347
  # ============================================================
348
- # ENV / PATHS
349
  # ============================================================
350
  def limit_memory():
351
  try:
@@ -376,29 +426,33 @@ os.makedirs(CHUNK_DIR, exist_ok=True)
376
  cpu_executor = ThreadPoolExecutor(max_workers=2)
377
 
378
  # ============================================================
379
- # SINGLETONS
380
  # ============================================================
381
  content_classifier = ContentClassifier()
382
  resolution_manager = ResolutionManager()
383
- model_manager = ModelManager(max_models=5, memory_limit_mb=12000)
384
  mask_checker = MaskQualityChecker()
385
  hair_refiner = HairRefiner()
386
  graphic_processor = GraphicProcessor()
387
 
 
 
 
 
388
  PRELOAD_MODELS = [
389
- ("birefnet-general-lite", "~12 MB"),
390
- ("u2netp", "~4.7 MB"),
391
- ("silueta", "~43 MB"),
392
- ("isnet-general-use", "~176 MB"),
393
- ("u2net_human_seg", "~176 MB"),
394
  ]
395
 
396
  LAZY_MODELS = {
397
- "u2net": "~176 MB",
398
- "isnet-anime": "~176 MB",
399
- "birefnet-general": "~900 MB",
400
- "u2net_cloth_seg": "~176 MB",
401
- "sam": "~375 MB",
 
 
 
402
  }
403
 
404
  VALID_REMBG_MODELS = {
@@ -423,7 +477,18 @@ MODEL_ALIASES = {
423
  'u2net_cloth': 'u2net_cloth_seg',
424
  }
425
 
426
- MATTING_MODELS = {'isnet-general-use', 'u2net_human_seg'}
 
 
 
 
 
 
 
 
 
 
 
427
 
428
  NEURAL_CONFIGS = {
429
  'birefnet-general-lite': {'fg': 240, 'bg': 10, 'erode': 10},
@@ -438,11 +503,18 @@ NEURAL_CONFIGS = {
438
  'sam': {'fg': 240, 'bg': 10, 'erode': 10},
439
  }
440
 
 
441
  MODEL_RES_CAPS = {
442
- 'u2netp': 512, 'silueta': 512, 'u2net': 768,
443
- 'u2net_human_seg': 1024, 'birefnet-general-lite': 1024,
444
- 'birefnet-general': 1024, 'isnet-general-use': 1024,
445
- 'isnet-anime': 1024, 'u2net_cloth_seg': 768, 'sam': 1024,
 
 
 
 
 
 
446
  }
447
 
448
  CLASSICAL_PROCESSORS = {'flood_fill', 'graphic_classical'}
@@ -451,80 +523,86 @@ DEFAULT_MODEL_NAME = 'birefnet-general-lite'
451
  DISPLAY_NAMES = {
452
  'flood_fill': 'Flood Fill (Classical)',
453
  'graphic_classical': 'Flood Fill (Classical)',
454
- 'u2netp': 'U2-Net P', 'u2net': 'U2-Net',
455
- 'u2net_human_seg': 'U2-Net Human', 'u2net_cloth_seg': 'U2-Net Cloth',
456
- 'silueta': 'Silueta', 'isnet-general-use': 'InSPyReNet (ISNet)',
457
- 'isnet-anime': 'ISNet Anime', 'birefnet-general': 'BiRefNet',
458
- 'birefnet-general-lite': 'BiRefNet Lite', 'sam': 'SAM',
 
 
 
 
 
459
  }
460
 
 
461
  # ============================================================
462
- # MODEL LOADING
463
  # ============================================================
464
- AI_AVAILABLE = False
465
- remove_func = None
466
- _model_loading_lock = threading.Lock()
467
- default_model_name = DEFAULT_MODEL_NAME
468
-
469
- PYMATTING_AVAILABLE = False
470
- try:
471
- from pymatting import cutout, estimate_foreground_ml, estimate_alpha_cf
472
- from pymatting.util.util import stack_images # noqa
473
- PYMATTING_AVAILABLE = True
474
- logger.info("✅ pymatting available")
475
- except ImportError as e:
476
- logger.warning(f"⚠️ pymatting not available: {e}")
477
-
478
-
479
  def _load_session(name: str):
480
  from rembg import new_session
 
481
  resolved = MODEL_ALIASES.get(name, name)
482
  if resolved not in VALID_REMBG_MODELS:
483
  logger.warning(f"⚠️ '{resolved}' not valid → birefnet-general-lite")
484
  resolved = 'birefnet-general-lite'
 
485
  if resolved != name:
486
  logger.info(f"🔁 Alias: {name} → {resolved}")
 
487
  try:
488
  import onnxruntime as ort
489
  so = ort.SessionOptions()
490
  so.intra_op_num_threads = 2
491
  so.inter_op_num_threads = 1
492
- so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
493
- so.enable_cpu_mem_arena = True
 
 
 
 
 
 
 
 
 
494
  return new_session(resolved, sess_opts=so)
495
  except TypeError:
 
496
  return new_session(resolved)
497
  except Exception as e:
498
- logger.warning(f"ORT tuning failed for {resolved}: {e}")
499
- return new_session(resolved)
 
 
 
 
 
 
 
500
 
501
 
502
  # ============================================================
503
- # NO-OP DETECTION + ALPHA STATS
504
  # ============================================================
505
- def _alpha_stats(image):
506
- """Return (min, max, mean, pct_transparent) of the alpha channel."""
507
  try:
508
  arr = np.array(image.convert('RGBA'))
509
  a = arr[:, :, 3]
510
  return {
511
  'min': int(a.min()),
512
  'max': int(a.max()),
513
- 'mean': float(a.mean()),
514
- 'pct_transparent': float((a < 128).sum() / a.size * 100),
515
- 'pct_opaque': float((a > 250).sum() / a.size * 100),
516
  }
517
  except Exception:
518
  return {'min': 0, 'max': 255, 'mean': 255,
519
  'pct_transparent': 0, 'pct_opaque': 100}
520
 
521
 
522
- def _is_noop(original, result):
523
- """
524
- A result is a no-op if the alpha channel is fully opaque AND
525
- the RGB channels are essentially identical to the input.
526
- Returns (bool, reason_str).
527
- """
528
  try:
529
  orig = np.array(original.convert('RGBA'))
530
  res = np.array(result.convert('RGBA'))
@@ -532,7 +610,6 @@ def _is_noop(original, result):
532
  return False, 'shape-differs'
533
 
534
  res_alpha = res[:, :, 3]
535
- # Fully opaque output → check if RGB changed
536
  if int(res_alpha.min()) > 250:
537
  rgb_diff = np.abs(
538
  orig[:, :, :3].astype(np.int16) - res[:, :, :3].astype(np.int16)
@@ -545,137 +622,49 @@ def _is_noop(original, result):
545
  return False, f'check-failed:{e}'
546
 
547
 
548
- # ============================================================
549
- # PRE-WARM — MUST run after initialize_models()
550
- # ============================================================
551
- def _prewarm_models():
552
- global remove_func
553
- if remove_func is None:
554
- logger.error("❌ Pre-warm skipped: remove_func is None")
555
- return
556
-
557
- logger.info("🔥 Pre-warming sessions with a real test image...")
558
- test_img = Image.new('RGB', (128, 128), (255, 255, 255))
559
- ImageDraw.Draw(test_img).rectangle([40, 40, 88, 88], fill=(220, 40, 40))
560
-
561
- for name, _ in PRELOAD_MODELS:
562
- session = model_manager.models.get(name)
563
- if session is None:
564
- logger.warning(f" ⚠️ {name}: no session")
565
- continue
566
- try:
567
- t0 = time.time()
568
- result = remove_func(test_img, session=session, alpha_matting=False)
569
- dt = time.time() - t0
570
- if result.mode != 'RGBA':
571
- result = result.convert('RGBA')
572
- stats = _alpha_stats(result)
573
- noop, reason = _is_noop(test_img, result)
574
- if noop:
575
- logger.warning(
576
- f" ❌ {name} BROKEN — {reason} | stats={stats} | {dt:.2f}s"
577
- )
578
- else:
579
- logger.info(
580
- f" ✅ {name} OK — {reason} | "
581
- f"center_a={int(np.array(result)[64,64,3])} "
582
- f"corner_a={int(np.array(result)[5,5,3])} | {dt:.2f}s"
583
- )
584
- except Exception as e:
585
- logger.warning(f" ❌ {name} EXCEPTION: {type(e).__name__}: {e}")
586
-
587
-
588
- # ============================================================
589
- # BOOT
590
- # ============================================================
591
- logger.info("=" * 60)
592
- logger.info("🚀 Background Remover Pro v30.2.0")
593
- logger.info("=" * 60)
594
-
595
- # Phase 1: download weights + create sessions (remove_func not needed)
596
- logger.info(f"📥 Preloading {len(PRELOAD_MODELS)} hot models...")
597
- for name, size in PRELOAD_MODELS:
598
- try:
599
- session = model_manager.get_model(name, _load_session)
600
- logger.info(f" ✅ {name} weights ready ({size})" if session else f" ⚠️ {name} failed")
601
- except Exception as e:
602
- logger.warning(f" ⚠️ {name}: {e}")
603
-
604
-
605
- def initialize_models():
606
- global AI_AVAILABLE, remove_func, default_model_name
607
- with _model_loading_lock:
608
- try:
609
- from rembg import remove
610
- remove_func = remove
611
- loaded = model_manager.get_stats().get('loaded_models', [])
612
-
613
- if DEFAULT_MODEL_NAME in loaded:
614
- default_model_name = DEFAULT_MODEL_NAME
615
- else:
616
- for m in ('birefnet-general-lite', 'isnet-general-use', 'silueta', 'u2netp'):
617
- if m in loaded:
618
- default_model_name = m
619
- break
620
- else:
621
- default_model_name = loaded[0] if loaded else 'u2netp'
622
-
623
- AI_AVAILABLE = len(loaded) > 0
624
- logger.info(f"✅ AI ready — {len(loaded)} models loaded")
625
- except ImportError as e:
626
- logger.error(f"Failed to import rembg: {e}")
627
- AI_AVAILABLE = False
628
- except Exception as e:
629
- logger.error(f"AI setup failed: {e}")
630
- AI_AVAILABLE = False
631
-
632
-
633
- # Phase 2: wire up remove_func
634
- initialize_models()
635
-
636
- # Phase 3: NOW the pre-warm actually works
637
- _prewarm_models()
638
-
639
- logger.info(f"📊 AI Available: {AI_AVAILABLE}")
640
- logger.info(f"🔧 Default: {default_model_name}")
641
- logger.info("=" * 60)
642
-
643
-
644
- # ============================================================
645
- # SESSION LOOKUP
646
- # ============================================================
647
- def get_session_for_model(model_name):
648
- resolved = MODEL_ALIASES.get(model_name, model_name)
649
- if resolved in model_manager.models:
650
- model_manager.use_counts[resolved] = model_manager.use_counts.get(resolved, 0) + 1
651
- return model_manager.models[resolved]
652
- logger.info(f"🔄 Lazy-loading: {resolved}")
653
  try:
654
- session = _load_session(resolved)
655
- if session is None:
656
- return None
657
- model_manager.models[resolved] = session
658
- model_manager.use_counts[resolved] = 1
659
- model_manager.load_times[resolved] = 0.0
660
- if len(model_manager.models) > model_manager.max_models:
661
- model_manager._evict_models()
662
- return session
663
- except Exception as e:
664
- logger.error(f"Lazy-load failed for {resolved}: {e}")
665
- return None
 
 
 
666
 
667
 
668
  # ============================================================
669
- # NEURAL PROCESSOR — clean and explicit
670
  # ============================================================
671
- def process_with_neural(image, session, model_name, force_no_matting=False):
 
 
 
 
 
672
  """
673
- Returns (result_image, diagnostics).
674
- Diagnostics = list of {attempt, result, elapsed, alpha_stats, reason}
675
  """
676
  cfg = NEURAL_CONFIGS.get(model_name, {'fg': 240, 'bg': 10, 'erode': 10})
677
  use_matting = (model_name in MATTING_MODELS) and not force_no_matting
678
 
 
 
 
 
 
 
 
 
 
679
  attempts = []
680
  if use_matting:
681
  attempts.append(('with-matting', {
@@ -692,26 +681,51 @@ def process_with_neural(image, session, model_name, force_no_matting=False):
692
  t0 = time.time()
693
  try:
694
  logger.info(f" 🧪 {model_name} [{label}] starting")
695
- result = remove_func(image, session=session, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
696
  dt = time.time() - t0
697
 
698
  if result is None:
699
- diagnostics.append({'attempt': label, 'result': 'None', 'elapsed': dt})
700
- logger.warning(f" ⚠️ {model_name} [{label}] returned None")
 
701
  continue
702
 
703
  if result.mode != 'RGBA':
704
  result = result.convert('RGBA')
705
 
 
 
 
 
 
 
706
  stats = _alpha_stats(result)
707
  noop, reason = _is_noop(image, result)
708
  diagnostics.append({
709
- 'attempt': label, 'result': 'noop' if noop else 'ok',
710
- 'elapsed': round(dt, 3), 'stats': stats, 'reason': reason,
 
 
 
711
  })
712
 
713
  if noop:
714
- logger.warning(f" ⚠️ {model_name} [{label}] NOOP — {reason} | {stats}")
715
  continue
716
 
717
  logger.info(f" ✅ {model_name} [{label}] OK in {dt:.2f}s | {stats}")
@@ -719,11 +733,14 @@ def process_with_neural(image, session, model_name, force_no_matting=False):
719
 
720
  except Exception as e:
721
  dt = time.time() - t0
 
722
  diagnostics.append({
723
- 'attempt': label, 'result': 'exception',
724
- 'elapsed': round(dt, 3), 'error': f"{type(e).__name__}: {e}",
 
 
725
  })
726
- logger.warning(f" ⚠️ {model_name} [{label}] raised: {type(e).__name__}: {e}")
727
 
728
  logger.error(f" ❌ {model_name}: all attempts failed")
729
  return None, diagnostics
@@ -737,18 +754,19 @@ def _make_generic_processor(model_name):
737
 
738
 
739
  # ============================================================
740
- # CLASSICAL FALLBACK
741
  # ============================================================
742
- def process_graphic_classical(image):
 
743
  try:
744
  if image.mode != 'RGBA':
745
  image = image.convert('RGBA')
746
  np_img = np.array(image)
747
  h, w = np_img.shape[:2]
748
- bg = _find_bg(np_img)
749
  mask = np.ones((h, w), dtype=np.uint8) * 255
750
- for c in bg:
751
- _flood(np_img, mask, c, 30)
752
  gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY)
753
  edges = cv2.dilate(cv2.Canny(gray, 50, 150), np.ones((2, 2), np.uint8))
754
  mask[edges > 0] = 255
@@ -822,9 +840,163 @@ def get_model_specific_processor(model_name):
822
 
823
 
824
  # ============================================================
825
- # ROUTING
826
  # ============================================================
827
- def detect_hair_complexity(image):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
828
  try:
829
  gray = image.convert('L').resize((100, 100), Image.Resampling.LANCZOS)
830
  np_img = np.array(gray, dtype=np.float32)
@@ -853,18 +1025,20 @@ def _skin_score(rgb):
853
  return skin / len(sample) if len(sample) else 0
854
 
855
 
856
- def analyze_image_complexity(image):
857
  try:
858
  small = image.copy()
859
  small.thumbnail((10, 10), Image.Resampling.LANCZOS)
860
  gray = np.array(small.convert('L'))
861
  rgb = np.array(small.convert('RGB'))
 
862
  var = float(np.var(gray))
863
  gx = np.gradient(gray.astype(np.float32), axis=1)
864
  gy = np.gradient(gray.astype(np.float32), axis=0)
865
  em = np.sqrt(gx ** 2 + gy ** 2)
866
  edge = float(np.sum(em > 10) / 100)
867
  colors = len(np.unique(rgb.reshape(-1, 3), axis=0))
 
868
  is_graphic = colors < 15 and edge > 0.08
869
  is_human = _skin_score(rgb) > 0.1
870
  hc = detect_hair_complexity(image)
@@ -882,41 +1056,45 @@ def analyze_image_complexity(image):
882
  avail = model_manager.get_stats().get('loaded_models', [])
883
 
884
  def _route(proc, cat, strat, reason):
885
- return {'processor': proc, 'model': proc, 'model_id': proc,
886
- 'category': cat, 'strategy': strat, 'reason': reason,
887
- 'score': complexity, 'is_human': is_human,
888
- 'is_graphic': is_graphic, 'hair_complexity': hc,
889
- 'cc_category': cc_cat, 'cc_confidence': 0.7}
 
 
890
 
891
  if is_graphic:
892
  return _route('flood_fill', 'simple_graphic', 'classical', 'simple graphic')
893
  if is_human and hc['route'] in ('wispy_hair', 'complex_hair'):
894
- m = 'birefnet-general-lite' if 'birefnet-general-lite' in avail else 'u2net_human_seg'
895
  return _route(m, 'human_hair', 'neural', f'human+{hc["route"]}')
896
  if is_human:
897
- m = 'u2net_human_seg' if 'u2net_human_seg' in avail else 'isnet-general-use'
898
  return _route(m, 'human', 'neural', 'human')
899
  if 25 <= complexity <= 55:
900
- m = 'isnet-general-use' if 'isnet-general-use' in avail else 'u2netp'
901
  return _route(m, 'medium', 'neural', 'medium')
902
  if complexity > 55:
903
- m = 'birefnet-general-lite' if 'birefnet-general-lite' in avail else 'isnet-general-use'
904
  return _route(m, 'complex', 'neural', 'complex')
905
- return _route('birefnet-general-lite', 'fallback', 'neural', 'fallback')
906
  except Exception as e:
907
  logger.warning(f"Router failed: {e}")
908
  avail = model_manager.get_stats().get('loaded_models', [])
909
  m = avail[0] if avail else 'u2netp'
910
- return {'processor': m, 'model': m, 'model_id': m, 'category': 'error',
911
- 'strategy': 'neural', 'reason': f'router-error: {e}',
912
- 'score': 0, 'is_human': False, 'is_graphic': False,
913
- 'hair_complexity': {'route': 'simple_hair'}, 'cc_category': 'general_photo',
914
- 'cc_confidence': 0.5}
 
 
915
 
916
 
917
  def get_default_model():
918
  loaded = model_manager.get_stats().get('loaded_models', [])
919
- for m in ('birefnet-general-lite', 'isnet-general-use', 'silueta', 'u2netp'):
920
  if m in loaded:
921
  return m
922
  return loaded[0] if loaded else 'u2netp'
@@ -926,15 +1104,10 @@ def get_default_video_model():
926
  loaded = model_manager.get_stats().get('loaded_models', [])
927
  if 'u2netp' in loaded:
928
  return 'u2netp'
929
- if 'silueta' in loaded:
930
- return 'silueta'
931
  return loaded[0] if loaded else 'u2netp'
932
 
933
 
934
- # ============================================================
935
- # FRAGMENT CHECK + ALPHA PASSTHROUGH
936
- # ============================================================
937
- def detect_fragments(image):
938
  if image.mode != 'RGBA':
939
  return {'fragment_ratio': 0, 'quality_score': 100,
940
  'has_fragments': False, 'issues': []}
@@ -961,33 +1134,20 @@ def detect_fragments(image):
961
  'has_fragments': fr > 0.01, 'issues': issues}
962
 
963
 
964
- def _has_meaningful_alpha(image):
965
- try:
966
- if image.mode not in ('RGBA', 'LA', 'P'):
967
- return False
968
- if image.mode == 'P':
969
- if 'transparency' not in image.info:
970
- return False
971
- image = image.convert('RGBA')
972
- if image.mode == 'LA':
973
- image = image.convert('RGBA')
974
- arr = np.array(image)
975
- if len(arr.shape) < 3 or arr.shape[2] < 4:
976
- return False
977
- a = arr[:, :, 3]
978
- return (a < 200).sum() / a.size > 0.08
979
- except Exception:
980
- return False
981
-
982
-
983
  # ============================================================
984
- # IMAGE PIPELINE
985
  # ============================================================
986
  def process_image_sync(
987
- image_data, model_name=None, transparent=True, max_size=2048,
988
- quality=95, preserve_hair=True, use_alpha_matting=True,
989
- border_pixels=20, sharpen=True,
990
- ):
 
 
 
 
 
 
991
  req = str(uuid.uuid4())[:8]
992
  logger.info(f"[REQ {req}] === START ===")
993
 
@@ -996,7 +1156,7 @@ def process_image_sync(
996
  original_size = image.size
997
  logger.info(f"[REQ {req}] Input: {image.size} mode={image.mode} bytes={len(image_data)}")
998
 
999
- # Fast path: image already transparent
1000
  if _has_meaningful_alpha(image) and model_name is None:
1001
  logger.info(f"[REQ {req}] Already has alpha — passthrough")
1002
  if image.mode != 'RGBA':
@@ -1051,7 +1211,7 @@ def process_image_sync(
1051
  image.thumbnail((target_res, target_res), Image.Resampling.LANCZOS)
1052
  logger.info(f"[REQ {req}] Resized → {image.size}")
1053
 
1054
- # Process
1055
  diagnostics = []
1056
  result = None
1057
  session = None
@@ -1060,31 +1220,42 @@ def process_image_sync(
1060
  result = process_graphic_classical(image)
1061
  if result is not None:
1062
  logger.info(f"[REQ {req}] Classical: {selected}")
1063
- else:
1064
- session = get_session_for_model(selected)
1065
- if session is None:
1066
- logger.warning(f"[REQ {req}] No session for {selected}, trying u2netp")
1067
- session = get_session_for_model('u2netp')
1068
- selected = 'u2netp'
1069
 
1070
- if session is not None:
1071
- result, diagnostics = process_with_neural(image, session, selected)
1072
-
1073
- # If no-op, try u2netp as last resort
1074
- if result is None:
1075
- logger.warning(f"[REQ {req}] {selected} failed, u2netp fallback")
1076
- u2_sess = get_session_for_model('u2netp')
1077
- if u2_sess is not None:
1078
- result, u2_diag = process_with_neural(image, u2_sess, 'u2netp')
1079
- diagnostics.extend([{'fallback': 'u2netp', **d} for d in u2_diag])
1080
- if result is not None:
1081
- selected = 'u2netp'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1082
 
1083
  if result is None:
1084
  logger.error(f"[REQ {req}] ALL MODELS FAILED")
 
 
1085
  raise HTTPException(
1086
  500,
1087
- f"All models failed for this image. Diagnostics: {json.dumps(diagnostics)[:300]}"
1088
  )
1089
 
1090
  # Hair refine
@@ -1096,6 +1267,7 @@ def process_image_sync(
1096
  q = mask_checker.check(alpha)
1097
  mask_quality = q
1098
  if not q['passed']:
 
1099
  refined = hair_refiner.refine(alpha)
1100
  arr = np.array(result.convert('RGBA'))
1101
  arr[:, :, 3] = refined
@@ -1161,29 +1333,51 @@ def process_image_sync(
1161
  raise
1162
 
1163
 
 
 
 
 
 
 
 
 
 
 
 
 
1164
  # ============================================================
1165
- # FASTAPI
1166
  # ============================================================
1167
- app = FastAPI(title="Background Remover Pro", version="30.2.0")
1168
- app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
1169
- allow_methods=["*"], allow_headers=["*"])
 
 
 
 
 
 
1170
 
1171
  rate_limits = defaultdict(list)
1172
  RATE_LIMIT = 30
1173
 
1174
 
1175
- def _get_client_ip(request):
1176
- xff = request.headers.get('x-forwarded-for')
1177
- if xff: return xff.split(',')[0].strip()
1178
- xri = request.headers.get('x-real-ip')
1179
- if xri: return xri.strip()
1180
- return request.client.host if request.client else 'unknown'
 
 
1181
 
1182
 
1183
  @app.middleware("http")
1184
- async def rate_limit_middleware(request, call_next):
1185
- if request.url.path in ('/api/health', '/', '/docs', '/redoc', '/openapi.json',
1186
- '/api/debug/model-report'):
 
 
1187
  return await call_next(request)
1188
  cid = _get_client_ip(request)
1189
  now = datetime.now()
@@ -1194,7 +1388,7 @@ async def rate_limit_middleware(request, call_next):
1194
  return await call_next(request)
1195
 
1196
 
1197
- def timeout_handler(seconds=90):
1198
  def deco(func):
1199
  @functools.wraps(func)
1200
  async def wrap(*a, **kw):
@@ -1207,7 +1401,7 @@ def timeout_handler(seconds=90):
1207
 
1208
 
1209
  # ============================================================
1210
- # ENDPOINTS
1211
  # ============================================================
1212
  @app.post("/api/process/image")
1213
  @timeout_handler(seconds=90)
@@ -1242,10 +1436,16 @@ async def process_image_api(
1242
 
1243
  loop = asyncio.get_event_loop()
1244
  pf = functools.partial(
1245
- process_image_sync, image_data=contents, model_name=user_model,
1246
- transparent=transparent, max_size=max_size, quality=quality,
1247
- preserve_hair=preserve_hair, use_alpha_matting=use_alpha_matting,
1248
- border_pixels=border_pixels, sharpen=sharpen,
 
 
 
 
 
 
1249
  )
1250
  result_data = await loop.run_in_executor(cpu_executor, pf)
1251
 
@@ -1270,7 +1470,8 @@ async def process_image_api(
1270
  }
1271
  return StreamingResponse(
1272
  io.BytesIO(result_data['data']),
1273
- media_type=result_data['media_type'], headers=headers,
 
1274
  )
1275
  except HTTPException:
1276
  raise
@@ -1309,9 +1510,15 @@ async def process_image_png(
1309
 
1310
  loop = asyncio.get_event_loop()
1311
  pf = functools.partial(
1312
- process_image_sync, image_data=contents, model_name=user_model,
1313
- transparent=True, max_size=2048, quality=95, preserve_hair=preserve_hair,
1314
- use_alpha_matting=use_alpha_matting, border_pixels=border_pixels,
 
 
 
 
 
 
1315
  sharpen=sharpen,
1316
  )
1317
  result_data = await loop.run_in_executor(cpu_executor, pf)
@@ -1327,12 +1534,12 @@ async def process_image_png(
1327
  'X-Quality-Score': f"{result_data['quality_score']:.1f}",
1328
  'X-Quality-Label': result_data.get('quality_label', ''),
1329
  'X-Quality-Emoji': emoji,
1330
- 'X-Fragment-Ratio': f"{result_data.get('fragment_ratio', 0):.3f}",
1331
  'Content-Disposition': f"attachment; filename=processed_{fn}.png",
1332
  }
1333
  return StreamingResponse(
1334
  io.BytesIO(result_data['data']),
1335
- media_type='image/png', headers=headers,
 
1336
  )
1337
  except HTTPException:
1338
  raise
@@ -1442,7 +1649,7 @@ async def process_bulk_png(
1442
 
1443
 
1444
  # ============================================================
1445
- # VIDEO
1446
  # ============================================================
1447
  MAX_VIDEO_DURATION = 30
1448
  MAX_VIDEO_FRAMES = 240
@@ -1482,6 +1689,11 @@ async def process_video_api(
1482
  session = get_session_for_model(model_name) or get_session_for_model('u2netp')
1483
  if session is None:
1484
  raise HTTPException(503, "No model")
 
 
 
 
 
1485
 
1486
  inp = Path(file.filename).suffix or '.mp4'
1487
  input_path = tempfile.mktemp(suffix=inp, dir='/tmp/uploads')
@@ -1493,7 +1705,19 @@ async def process_video_api(
1493
 
1494
  cap = cv2.VideoCapture(input_path)
1495
  if not cap.isOpened():
1496
- raise HTTPException(400, "Cannot open video")
 
 
 
 
 
 
 
 
 
 
 
 
1497
 
1498
  w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
1499
  h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
@@ -1522,10 +1746,33 @@ async def process_video_api(
1522
  if not frames:
1523
  raise HTTPException(400, "No frames")
1524
 
1525
- logger.info(f"🎬 Video: {len(frames)} frames at {nw}x{nh} with {model_name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1526
 
1527
- fourcc = cv2.VideoWriter_fourcc(*'mp4v')
1528
- out = cv2.VideoWriter(output_path, fourcc, tfps, (nw, nh))
1529
 
1530
  for i, frame in enumerate(frames):
1531
  try:
@@ -1540,7 +1787,9 @@ async def process_video_api(
1540
  out.write(cv2.cvtColor(np.array(res), cv2.COLOR_RGBA2BGRA))
1541
  except Exception as e:
1542
  logger.error(f"Frame {i}: {e}")
1543
- out.write(cv2.cvtColor(np.array(Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).convert('RGBA')), cv2.COLOR_RGBA2BGRA))
 
 
1544
 
1545
  if (i + 1) % 10 == 0:
1546
  logger.info(f" 📸 {i+1}/{len(frames)}")
@@ -1560,7 +1809,7 @@ async def process_video_api(
1560
  'X-Video-Frames': str(len(frames)),
1561
  'Content-Disposition': f'attachment; filename=processed_{fn}.{out_ext}',
1562
  }
1563
- logger.info(f"✅ Video done: {len(frames)} frames in {pt:.1f}s")
1564
  return FileResponse(
1565
  output_path, media_type=mt,
1566
  filename=f'processed_{fn}.{out_ext}', headers=headers,
@@ -1589,8 +1838,10 @@ async def upload_video_chunk(
1589
  os.makedirs(cd, exist_ok=True)
1590
  with open(os.path.join(cd, f"chunk_{chunkIndex:04d}"), 'wb') as f:
1591
  f.write(await chunk.read())
1592
- return JSONResponse({'status': 'ok', 'chunkId': chunkIndex,
1593
- 'sessionId': sid, 'totalChunks': totalChunks})
 
 
1594
  except Exception as e:
1595
  raise HTTPException(500, str(e))
1596
 
@@ -1623,9 +1874,11 @@ async def assemble_video(
1623
  with open(cp, 'rb') as cf:
1624
  f.write(cf.read())
1625
  background_tasks.add_task(cleanup_temp_files, cd)
1626
- return FileResponse(ap, media_type='video/mp4',
1627
- filename=f'processed_{filename}',
1628
- background=background_tasks.add_task(cleanup_temp_files, ap))
 
 
1629
  except HTTPException:
1630
  raise
1631
  except Exception as e:
@@ -1633,11 +1886,34 @@ async def assemble_video(
1633
 
1634
 
1635
  # ============================================================
1636
- # DEBUG — model report
1637
  # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1638
  @app.get("/api/debug/model-report")
1639
  async def model_report():
1640
- """Runs every model on a synthetic test image and reports alpha stats."""
 
 
 
1641
  test_img = Image.new('RGB', (128, 128), (255, 255, 255))
1642
  ImageDraw.Draw(test_img).rectangle([40, 40, 88, 88], fill=(220, 40, 40))
1643
 
@@ -1666,12 +1942,13 @@ async def model_report():
1666
  'corner_alpha': int(arr[5, 5, 3]),
1667
  }
1668
  noop, reason = _is_noop(test_img, result)
 
1669
  entry.update({
1670
  'elapsed_s': round(dt, 3),
1671
  'stats': stats,
1672
  'is_noop': noop,
1673
  'reason': reason,
1674
- 'verdict': 'OK' if (stats['center_alpha'] > 200 and stats['corner_alpha'] < 50) else 'BROKEN',
1675
  })
1676
  except Exception as e:
1677
  entry['error'] = f'{type(e).__name__}: {str(e)[:200]}'
@@ -1681,7 +1958,7 @@ async def model_report():
1681
 
1682
  return JSONResponse({
1683
  'generated_at': datetime.now().isoformat(),
1684
- 'test_image': '128x128 white with red square at 40,40-88,88',
1685
  'expected': 'center_alpha > 200, corner_alpha < 50',
1686
  'models': report,
1687
  'summary': {
@@ -1694,14 +1971,15 @@ async def model_report():
1694
 
1695
 
1696
  # ============================================================
1697
- # HEALTH
1698
  # ============================================================
1699
  @app.get("/")
1700
  async def home():
1701
  stats = model_manager.get_stats()
 
1702
  return HTMLResponse(f"""
1703
  <!DOCTYPE html><html><head><meta charset="UTF-8">
1704
- <title>Background Remover Pro v30.2.0</title>
1705
  <style>body{{font-family:system-ui;background:linear-gradient(135deg,#667eea,#764ba2);
1706
  color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;
1707
  margin:0;padding:20px}}.c{{max-width:900px;background:rgba(255,255,255,.1);
@@ -1709,9 +1987,9 @@ async def home():
1709
  box-shadow:0 20px 60px rgba(0,0,0,.3)}}.s{{padding:15px;border-radius:12px;
1710
  margin:20px 0;background:rgba(126,217,87,.2)}}a{{color:#a78bfa}}</style>
1711
  </head><body><div class="c">
1712
- <h1>Background Remover Pro v30.2.0</h1>
1713
- <div class="s">AI Online · {len(stats['loaded_models'])} models</div>
1714
- <p><a href="/docs">API Docs</a> · <a href="/api/debug/model-report">Model Report</a></p>
1715
  </div></body></html>
1716
  """)
1717
 
@@ -1719,45 +1997,56 @@ async def home():
1719
  @app.get("/api/health")
1720
  async def health_check():
1721
  stats = model_manager.get_stats()
 
1722
  return {
1723
  'status': 'healthy' if AI_AVAILABLE else 'degraded',
1724
  'ai_available': AI_AVAILABLE,
1725
  'models_loaded': stats['loaded_models'],
1726
  'models_count': stats['model_count'],
 
 
1727
  'pymatting_available': PYMATTING_AVAILABLE,
1728
  'video_formats': ['mp4', 'avi', 'webm', 'mov'],
1729
  'image_formats': ['png', 'jpg', 'jpeg', 'webp', 'bmp'],
1730
  'bulk_limit': 20,
1731
- 'version': '30.2.0',
1732
  'default_model': get_default_model(),
1733
  'default_video_model': get_default_video_model(),
1734
  'preload_models': [n for n, _ in PRELOAD_MODELS],
1735
  'lazy_models': sorted(LAZY_MODELS.keys()),
1736
- 'valid_models': sorted(VALID_REMBG_MODELS),
1737
  'matting_models': sorted(MATTING_MODELS),
1738
- 'aliases': MODEL_ALIASES,
1739
  }
1740
 
1741
 
1742
  @app.exception_handler(HTTPException)
1743
  async def http_exc(request, exc):
1744
- return JSONResponse(status_code=exc.status_code,
1745
- content={'error': exc.detail, 'status_code': exc.status_code})
 
 
1746
 
1747
 
1748
  @app.exception_handler(Exception)
1749
  async def gen_exc(request, exc):
1750
  logger.error(f"Unhandled: {exc}")
1751
- return JSONResponse(status_code=500,
1752
- content={'error': 'Internal error', 'detail': str(exc)[:200]})
 
 
1753
 
1754
 
1755
  @app.on_event("startup")
1756
  async def startup():
 
 
1757
  logger.info("=" * 60)
1758
- logger.info("🚀 v30.2.0 ready")
1759
  logger.info(f"📊 AI: {AI_AVAILABLE}")
1760
- logger.info(f"🤖 Models: {model_manager.get_stats()['loaded_models']}")
 
 
 
1761
  logger.info("=" * 60)
1762
 
1763
 
@@ -1774,6 +2063,11 @@ async def shutdown():
1774
 
1775
  if __name__ == '__main__':
1776
  import uvicorn
1777
- uvicorn.run('app:app', host='0.0.0.0',
1778
- port=int(os.environ.get('PORT', 7860)),
1779
- timeout_keep_alive=300, log_level='info', reload=False)
 
 
 
 
 
 
1
  #!/usr/bin/env python3
2
+ # app.py - Background Remover Pro v31.0.0
3
+ # COMPLETE - All fixes applied:
4
+ # • 2 preload models only (memory safe)
5
+ # • LRU max_models=3
6
+ # • ORT graph opt disabled for BirefNet
7
+ # • BirefNet input padded to multiples of 32
8
+ # • Full fallback chain (tries every model)
9
+ # • No-op detection
10
+ # • Alpha passthrough
11
+ # • Pre-warm AFTER remove_func is set
12
+ # • URL-encoded emoji headers
13
+ # • CPU memory arena OFF
14
+ # • Video: matting off, u2netp default
15
+ # • Debug endpoints
16
 
17
  import io
18
  import time
 
49
  import threading
50
  from io import BytesIO
51
 
52
+ # ========== VERSION CHECK ==========
53
  if sys.version_info < (3, 8):
54
  raise RuntimeError("Python 3.8 or higher required")
55
 
56
+ # ========== LOGGING ==========
57
  logging.basicConfig(
58
  level=logging.INFO,
59
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
 
62
 
63
 
64
  # ============================================================
65
+ # ===== INLINE MANAGER CLASSES =====
66
  # ============================================================
67
 
68
  class ResolutionManager:
69
  RECOMMENDED = {
70
+ 'simple_graphic': 600,
71
+ 'human_hair': 1024,
72
+ 'human': 1024,
73
+ 'anime': 800,
74
+ 'logo_icon': 600,
75
+ 'product_white_bg': 700,
76
+ 'general_photo': 1024,
77
+ 'complex': 1024,
78
  }
79
+
80
  @classmethod
81
+ def get_resolution(cls, category: str, original_size: tuple) -> int:
82
  base = cls.RECOMMENDED.get(category, 1024)
83
  return min(base, max(original_size))
84
 
85
 
86
  class ModelManager:
87
+ def __init__(self, max_models: int = 3, memory_limit_mb: int = 10000):
88
+ self.models: Dict[str, Any] = {}
89
+ self.load_times: Dict[str, float] = {}
90
+ self.use_counts: Dict[str, int] = {}
91
  self.max_models = max_models
92
  self.memory_limit_mb = memory_limit_mb
93
 
94
+ def get_model(self, name: str, loader_func) -> Optional[Any]:
95
  if name in self.models:
96
  self.use_counts[name] = self.use_counts.get(name, 0) + 1
97
  return self.models[name]
98
+
99
  try:
100
+ mem = psutil.virtual_memory()
101
+ if mem.percent > 82:
102
+ logger.warning(f"⚠️ Memory at {mem.percent}% — evicting before load")
103
  self._evict_models()
104
+ gc.collect()
105
  except Exception:
106
  pass
107
+
108
  start = time.time()
109
  try:
110
  session = loader_func(name)
 
113
  self.models[name] = session
114
  self.load_times[name] = time.time() - start
115
  self.use_counts[name] = 1
116
+
117
+ gc.collect()
118
+ try:
119
+ mem = psutil.virtual_memory()
120
+ logger.info(
121
+ f" 📊 After {name}: {mem.used/1024/1024:.0f}MB used "
122
+ f"({mem.percent:.0f}%)"
123
+ )
124
+ except Exception:
125
+ pass
126
+
127
  if len(self.models) > self.max_models:
128
  self._evict_models()
129
  return session
130
  except Exception as e:
131
  logger.warning(f"Loader failed for {name}: {e}")
132
+ gc.collect()
133
  return None
134
 
135
  def _evict_models(self):
136
  if len(self.models) <= 1:
137
  return
138
  to_remove = min(self.models.keys(), key=lambda x: self.use_counts.get(x, 0))
139
+ logger.info(f"♻️ Evicting LRU model: {to_remove}")
140
  del self.models[to_remove]
141
  self.use_counts.pop(to_remove, None)
142
  gc.collect()
143
 
144
+ def get_stats(self) -> Dict:
145
  return {
146
  'loaded_models': list(self.models.keys()),
147
  'model_count': len(self.models),
 
158
  self.max_components = 100
159
  self.min_confidence = 0.40
160
 
161
+ def check(self, mask: np.ndarray) -> dict:
162
  if mask.dtype != np.uint8:
163
  mask = (mask * 255).astype(np.uint8)
164
  h, w = mask.shape
165
  fg = float(np.sum(mask > 128) / (h * w))
166
+
167
  border_pixels = 0
168
  border_total = 0
169
  for x in range(w):
 
175
  if mask[y, w - 1] > 128: border_pixels += 1
176
  border_total += 2
177
  border_ratio = border_pixels / border_total if border_total > 0 else 0
178
+
179
  try:
180
  from scipy.ndimage import label
181
  labeled, num_components = label(mask > 128)
 
185
  except Exception:
186
  num_components = 1
187
  fragmentation = 0
188
+
189
  confidence = 1.0
190
  issues = []
191
  if fg < self.min_foreground:
 
196
  confidence -= 0.2; issues.append('border_contamination')
197
  if num_components > self.max_components:
198
  confidence -= 0.1; issues.append('too_many_components')
199
+
200
  confidence = max(0.0, min(1.0, confidence))
201
  return {
202
  'passed': confidence >= self.min_confidence,
 
210
 
211
 
212
  class HairRefiner:
213
+ def refine(self, mask: np.ndarray) -> np.ndarray:
214
  if mask.dtype != np.uint8:
215
  mask = (mask * 255).astype(np.uint8)
216
  mask_float = mask.astype(np.float32) / 255.0
 
255
  def __init__(self):
256
  self.tolerance = 25
257
 
258
+ def remove_background(self, image: Image.Image) -> Image.Image:
259
  if image.mode != 'RGBA':
260
  image = image.convert('RGBA')
261
  np_img = np.array(image)
 
308
 
309
 
310
  class ContentClassifier:
311
+ def classify(self, image: Image.Image) -> dict:
312
  if image.mode != 'RGB':
313
  image = image.convert('RGB')
314
  small = image.copy()
 
322
  'texture': self._tex(np_img),
323
  }
324
  category = self._cls(signals)
325
+ confidence = self._conf(category, signals)
326
+ return {'category': category, 'confidence': confidence, 'signals': signals}
327
 
328
  def _border(self, np_img):
329
  h, w = np_img.shape[:2]
 
383
  return 'general_photo'
384
  return 'complex'
385
 
386
+ def _conf(self, category, s):
387
+ base = 0.7
388
+ if category == 'simple_graphic' and s['border_uniformity'] > 0.8:
389
+ base += 0.2
390
+ elif category == 'human_hair' and s['skin_score'] > 0.2 and s['texture'] > 0.4:
391
+ base += 0.2
392
+ elif category == 'human' and s['skin_score'] > 0.15:
393
+ base += 0.2
394
+ return min(1.0, base)
395
+
396
 
397
  # ============================================================
398
+ # ===== MEMORY + ENV =====
399
  # ============================================================
400
  def limit_memory():
401
  try:
 
426
  cpu_executor = ThreadPoolExecutor(max_workers=2)
427
 
428
  # ============================================================
429
+ # ===== SINGLETONS =====
430
  # ============================================================
431
  content_classifier = ContentClassifier()
432
  resolution_manager = ResolutionManager()
433
+ model_manager = ModelManager(max_models=3, memory_limit_mb=10000)
434
  mask_checker = MaskQualityChecker()
435
  hair_refiner = HairRefiner()
436
  graphic_processor = GraphicProcessor()
437
 
438
+ # ============================================================
439
+ # ===== MODEL CONFIG =====
440
+ # ============================================================
441
+ # Only 2 preload — memory safe on HF free tier
442
  PRELOAD_MODELS = [
443
+ ("birefnet-general-lite", "~12 MB disk / ~250 MB RAM"),
444
+ ("u2netp", "~4.7 MB disk / ~180 MB RAM"),
 
 
 
445
  ]
446
 
447
  LAZY_MODELS = {
448
+ "silueta": "~43 MB disk / ~200 MB RAM",
449
+ "isnet-general-use": "~176 MB disk / ~800 MB RAM",
450
+ "u2net": "~176 MB disk / ~800 MB RAM",
451
+ "u2net_human_seg": "~176 MB disk / ~800 MB RAM",
452
+ "isnet-anime": "~176 MB disk / ~800 MB RAM",
453
+ "birefnet-general": "~900 MB disk / ~2.5 GB RAM",
454
+ "u2net_cloth_seg": "~176 MB disk / ~800 MB RAM",
455
+ "sam": "~375 MB disk / ~1 GB RAM",
456
  }
457
 
458
  VALID_REMBG_MODELS = {
 
477
  'u2net_cloth': 'u2net_cloth_seg',
478
  }
479
 
480
+ # Models with known ORT graph optimization bugs (atrous_conv Sum crash)
481
+ ORT_OPT_DISABLED_MODELS = {
482
+ 'birefnet-general-lite',
483
+ 'birefnet-general',
484
+ 'u2net_cloth_seg',
485
+ }
486
+
487
+ # Alpha matting only for these (slower but better edges)
488
+ MATTING_MODELS = {
489
+ 'isnet-general-use',
490
+ 'u2net_human_seg',
491
+ }
492
 
493
  NEURAL_CONFIGS = {
494
  'birefnet-general-lite': {'fg': 240, 'bg': 10, 'erode': 10},
 
503
  'sam': {'fg': 240, 'bg': 10, 'erode': 10},
504
  }
505
 
506
+ # Lower res caps to keep peak RAM low
507
  MODEL_RES_CAPS = {
508
+ 'u2netp': 512,
509
+ 'silueta': 512,
510
+ 'u2net': 640,
511
+ 'u2net_human_seg': 640,
512
+ 'birefnet-general-lite': 640,
513
+ 'birefnet-general': 640,
514
+ 'isnet-general-use': 640,
515
+ 'isnet-anime': 640,
516
+ 'u2net_cloth_seg': 512,
517
+ 'sam': 512,
518
  }
519
 
520
  CLASSICAL_PROCESSORS = {'flood_fill', 'graphic_classical'}
 
523
  DISPLAY_NAMES = {
524
  'flood_fill': 'Flood Fill (Classical)',
525
  'graphic_classical': 'Flood Fill (Classical)',
526
+ 'u2netp': 'U2-Net P',
527
+ 'u2net': 'U2-Net',
528
+ 'u2net_human_seg': 'U2-Net Human',
529
+ 'u2net_cloth_seg': 'U2-Net Cloth',
530
+ 'silueta': 'Silueta',
531
+ 'isnet-general-use': 'InSPyReNet (ISNet)',
532
+ 'isnet-anime': 'ISNet Anime',
533
+ 'birefnet-general': 'BiRefNet',
534
+ 'birefnet-general-lite': 'BiRefNet Lite',
535
+ 'sam': 'SAM',
536
  }
537
 
538
+
539
  # ============================================================
540
+ # ===== SESSION LOADER (memory + ORT tuned) =====
541
  # ============================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  def _load_session(name: str):
543
  from rembg import new_session
544
+
545
  resolved = MODEL_ALIASES.get(name, name)
546
  if resolved not in VALID_REMBG_MODELS:
547
  logger.warning(f"⚠️ '{resolved}' not valid → birefnet-general-lite")
548
  resolved = 'birefnet-general-lite'
549
+
550
  if resolved != name:
551
  logger.info(f"🔁 Alias: {name} → {resolved}")
552
+
553
  try:
554
  import onnxruntime as ort
555
  so = ort.SessionOptions()
556
  so.intra_op_num_threads = 2
557
  so.inter_op_num_threads = 1
558
+ # ⚠️ Memory: disable arena so RAM is released after inference
559
+ so.enable_cpu_mem_arena = False
560
+ so.enable_mem_pattern = False
561
+
562
+ # ⚠️ BirefNet atrous_conv crash under default optimization
563
+ if resolved in ORT_OPT_DISABLED_MODELS:
564
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
565
+ logger.info(f"⚠️ {resolved}: ORT graph opt DISABLED (known bug)")
566
+ else:
567
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
568
+
569
  return new_session(resolved, sess_opts=so)
570
  except TypeError:
571
+ logger.info(f"⚠️ {resolved}: rembg doesn't accept sess_opts kwarg")
572
  return new_session(resolved)
573
  except Exception as e:
574
+ logger.warning(f"⚠️ ORT tuning failed for {resolved}: {e}")
575
+ try:
576
+ import onnxruntime as ort
577
+ so = ort.SessionOptions()
578
+ so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
579
+ so.enable_cpu_mem_arena = False
580
+ return new_session(resolved, sess_opts=so)
581
+ except Exception:
582
+ return new_session(resolved)
583
 
584
 
585
  # ============================================================
586
+ # ===== ALPHA STATS + NO-OP DETECTION =====
587
  # ============================================================
588
+ def _alpha_stats(image: Image.Image) -> Dict:
 
589
  try:
590
  arr = np.array(image.convert('RGBA'))
591
  a = arr[:, :, 3]
592
  return {
593
  'min': int(a.min()),
594
  'max': int(a.max()),
595
+ 'mean': round(float(a.mean()), 2),
596
+ 'pct_transparent': round(float((a < 128).sum() / a.size * 100), 2),
597
+ 'pct_opaque': round(float((a > 250).sum() / a.size * 100), 2),
598
  }
599
  except Exception:
600
  return {'min': 0, 'max': 255, 'mean': 255,
601
  'pct_transparent': 0, 'pct_opaque': 100}
602
 
603
 
604
+ def _is_noop(original: Image.Image, result: Image.Image) -> Tuple[bool, str]:
605
+ """Returns (is_noop, reason)."""
 
 
 
 
606
  try:
607
  orig = np.array(original.convert('RGBA'))
608
  res = np.array(result.convert('RGBA'))
 
610
  return False, 'shape-differs'
611
 
612
  res_alpha = res[:, :, 3]
 
613
  if int(res_alpha.min()) > 250:
614
  rgb_diff = np.abs(
615
  orig[:, :, :3].astype(np.int16) - res[:, :, :3].astype(np.int16)
 
622
  return False, f'check-failed:{e}'
623
 
624
 
625
+ def _has_meaningful_alpha(image: Image.Image) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  try:
627
+ if image.mode not in ('RGBA', 'LA', 'P'):
628
+ return False
629
+ if image.mode == 'P':
630
+ if 'transparency' not in image.info:
631
+ return False
632
+ image = image.convert('RGBA')
633
+ if image.mode == 'LA':
634
+ image = image.convert('RGBA')
635
+ arr = np.array(image)
636
+ if len(arr.shape) < 3 or arr.shape[2] < 4:
637
+ return False
638
+ a = arr[:, :, 3]
639
+ return (a < 200).sum() / a.size > 0.08
640
+ except Exception:
641
+ return False
642
 
643
 
644
  # ============================================================
645
+ # ===== NEURAL PROCESSOR (with padding for BirefNet) =====
646
  # ============================================================
647
+ def process_with_neural(
648
+ image: Image.Image,
649
+ session,
650
+ model_name: str,
651
+ force_no_matting: bool = False,
652
+ ) -> Tuple[Optional[Image.Image], List[Dict]]:
653
  """
654
+ Returns (result_image_or_None, diagnostics_list).
 
655
  """
656
  cfg = NEURAL_CONFIGS.get(model_name, {'fg': 240, 'bg': 10, 'erode': 10})
657
  use_matting = (model_name in MATTING_MODELS) and not force_no_matting
658
 
659
+ # Memory check
660
+ try:
661
+ mem = psutil.virtual_memory()
662
+ if mem.percent > 82:
663
+ logger.warning(f"⚠️ Memory {mem.percent}% — GC before {model_name}")
664
+ gc.collect()
665
+ except Exception:
666
+ pass
667
+
668
  attempts = []
669
  if use_matting:
670
  attempts.append(('with-matting', {
 
681
  t0 = time.time()
682
  try:
683
  logger.info(f" 🧪 {model_name} [{label}] starting")
684
+
685
+ # ⚠️ BirefNet: pad to multiples of 32 to avoid atrous_conv crash
686
+ img_for_inference = image
687
+ pad_info = None
688
+ if model_name in ORT_OPT_DISABLED_MODELS:
689
+ w, h = image.size
690
+ tw = ((w + 31) // 32) * 32
691
+ th = ((h + 31) // 32) * 32
692
+ if (tw, th) != (w, h):
693
+ padded = Image.new('RGB', (tw, th), (255, 255, 255))
694
+ padded.paste(image.convert('RGB'), (0, 0))
695
+ img_for_inference = padded
696
+ pad_info = (w, h)
697
+ logger.info(f" 📐 {model_name} padded {w}x{h} → {tw}x{th}")
698
+
699
+ result = remove_func(img_for_inference, session=session, **kwargs)
700
  dt = time.time() - t0
701
 
702
  if result is None:
703
+ diagnostics.append({
704
+ 'attempt': label, 'result': 'None', 'elapsed': round(dt, 3),
705
+ })
706
  continue
707
 
708
  if result.mode != 'RGBA':
709
  result = result.convert('RGBA')
710
 
711
+ # Crop back if we padded
712
+ if pad_info:
713
+ ow, oh = pad_info
714
+ if result.size != (ow, oh):
715
+ result = result.crop((0, 0, ow, oh))
716
+
717
  stats = _alpha_stats(result)
718
  noop, reason = _is_noop(image, result)
719
  diagnostics.append({
720
+ 'attempt': label,
721
+ 'result': 'noop' if noop else 'ok',
722
+ 'elapsed': round(dt, 3),
723
+ 'stats': stats,
724
+ 'reason': reason,
725
  })
726
 
727
  if noop:
728
+ logger.warning(f" ⚠️ {model_name} [{label}] NOOP — {reason}")
729
  continue
730
 
731
  logger.info(f" ✅ {model_name} [{label}] OK in {dt:.2f}s | {stats}")
 
733
 
734
  except Exception as e:
735
  dt = time.time() - t0
736
+ err_str = f"{type(e).__name__}: {str(e)[:200]}"
737
  diagnostics.append({
738
+ 'attempt': label,
739
+ 'result': 'exception',
740
+ 'elapsed': round(dt, 3),
741
+ 'error': err_str,
742
  })
743
+ logger.warning(f" ⚠️ {model_name} [{label}] raised: {err_str}")
744
 
745
  logger.error(f" ❌ {model_name}: all attempts failed")
746
  return None, diagnostics
 
754
 
755
 
756
  # ============================================================
757
+ # ===== CLASSICAL FALLBACK =====
758
  # ============================================================
759
+ def process_graphic_classical(image: Image.Image) -> Optional[Image.Image]:
760
+ logger.info("🎨 [PROCESS] Classical flood-fill")
761
  try:
762
  if image.mode != 'RGBA':
763
  image = image.convert('RGBA')
764
  np_img = np.array(image)
765
  h, w = np_img.shape[:2]
766
+ bg_colors = _find_bg(np_img)
767
  mask = np.ones((h, w), dtype=np.uint8) * 255
768
+ for c in bg_colors:
769
+ _flood(np_img, mask, c, tolerance=30)
770
  gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY)
771
  edges = cv2.dilate(cv2.Canny(gray, 50, 150), np.ones((2, 2), np.uint8))
772
  mask[edges > 0] = 255
 
840
 
841
 
842
  # ============================================================
843
+ # ===== AI MODEL BOOT =====
844
  # ============================================================
845
+ AI_AVAILABLE = False
846
+ remove_func = None
847
+ _model_loading_lock = threading.Lock()
848
+ default_model_name = DEFAULT_MODEL_NAME
849
+
850
+ PYMATTING_AVAILABLE = False
851
+ try:
852
+ from pymatting import cutout, estimate_foreground_ml, estimate_alpha_cf
853
+ from pymatting.util.util import stack_images # noqa
854
+ PYMATTING_AVAILABLE = True
855
+ logger.info("✅ pymatting available")
856
+ except ImportError as e:
857
+ logger.warning(f"⚠️ pymatting not available: {e}")
858
+
859
+
860
+ def pre_download_models():
861
+ logger.info(f"📥 Preloading {len(PRELOAD_MODELS)} hot models...")
862
+ for name, size in PRELOAD_MODELS:
863
+ try:
864
+ logger.info(f" Downloading {name} ({size})...")
865
+ session = model_manager.get_model(name, _load_session)
866
+ if session is not None:
867
+ logger.info(f" ✅ {name} ready")
868
+ else:
869
+ logger.warning(f" ⚠️ {name} returned None")
870
+ except Exception as e:
871
+ logger.warning(f" ⚠️ Could not preload {name}: {e}")
872
+
873
+ lazy_list = ", ".join(LAZY_MODELS.keys())
874
+ logger.info(f"✅ Preload complete. Lazy models: {lazy_list}")
875
+
876
+
877
+ def initialize_models():
878
+ global AI_AVAILABLE, remove_func, default_model_name
879
+ with _model_loading_lock:
880
+ try:
881
+ from rembg import remove
882
+ remove_func = remove
883
+
884
+ loaded_names = model_manager.get_stats().get("loaded_models", [])
885
+ if DEFAULT_MODEL_NAME in loaded_names:
886
+ default_model_name = DEFAULT_MODEL_NAME
887
+ else:
888
+ for m in ('birefnet-general-lite', 'u2netp'):
889
+ if m in loaded_names:
890
+ default_model_name = m
891
+ break
892
+ else:
893
+ default_model_name = loaded_names[0] if loaded_names else 'u2netp'
894
+
895
+ AI_AVAILABLE = len(loaded_names) > 0
896
+ if AI_AVAILABLE:
897
+ logger.info(f"✅ AI ready with {len(loaded_names)} models")
898
+ else:
899
+ logger.error("❌ No models loaded")
900
+ except ImportError as e:
901
+ logger.error(f"Failed to import rembg: {e}")
902
+ AI_AVAILABLE = False
903
+ except Exception as e:
904
+ logger.error(f"AI setup failed: {e}")
905
+ AI_AVAILABLE = False
906
+
907
+
908
+ def _prewarm_models():
909
+ """Runs AFTER initialize_models() so remove_func is set."""
910
+ global remove_func
911
+ if remove_func is None:
912
+ logger.error("❌ Pre-warm skipped: remove_func is None")
913
+ return
914
+
915
+ logger.info("🔥 Pre-warming sessions with test image...")
916
+ try:
917
+ test_img = Image.new('RGB', (64, 64), (255, 255, 255))
918
+ ImageDraw.Draw(test_img).rectangle([20, 20, 44, 44], fill=(220, 40, 40))
919
+
920
+ for name, _ in PRELOAD_MODELS:
921
+ session = model_manager.models.get(name)
922
+ if session is None:
923
+ logger.warning(f" ⚠️ {name}: no session")
924
+ continue
925
+ try:
926
+ t0 = time.time()
927
+ result = remove_func(test_img, session=session, alpha_matting=False)
928
+ dt = time.time() - t0
929
+ if result.mode != 'RGBA':
930
+ result = result.convert('RGBA')
931
+ arr = np.array(result)
932
+ cy, cx = arr.shape[0] // 2, arr.shape[1] // 2
933
+ center_a = int(arr[cy, cx, 3])
934
+ corner_a = int(arr[2, 2, 3])
935
+ if center_a > 200 and corner_a < 50:
936
+ logger.info(
937
+ f" ✅ {name} OK — center={center_a} corner={corner_a} | {dt:.2f}s"
938
+ )
939
+ else:
940
+ logger.warning(
941
+ f" ⚠️ {name} produced WRONG output — "
942
+ f"center={center_a} corner={corner_a}"
943
+ )
944
+ except Exception as e:
945
+ logger.warning(f" ❌ {name} warm-up failed: {type(e).__name__}: {e}")
946
+ except Exception as e:
947
+ logger.warning(f"Pre-warm skipped: {e}")
948
+
949
+
950
+ # ========== BOOT SEQUENCE ==========
951
+ logger.info("=" * 60)
952
+ logger.info("🚀 Background Remover Pro v31.0.0")
953
+ logger.info("=" * 60)
954
+
955
+ # Phase 1: download weights (remove_func not needed yet)
956
+ pre_download_models()
957
+
958
+ # Phase 2: wire up remove_func
959
+ initialize_models()
960
+
961
+ # Phase 3: now pre-warm actually works
962
+ _prewarm_models()
963
+
964
+ logger.info(f"📊 AI Available: {AI_AVAILABLE}")
965
+ logger.info(f"🔧 Default Model: {default_model_name}")
966
+ logger.info("=" * 60)
967
+
968
+
969
+ def get_session_for_model(model_name: str):
970
+ resolved = MODEL_ALIASES.get(model_name, model_name)
971
+
972
+ if resolved in model_manager.models:
973
+ model_manager.use_counts[resolved] = model_manager.use_counts.get(resolved, 0) + 1
974
+ return model_manager.models[resolved]
975
+
976
+ logger.info(f"🔄 Lazy-loading model: {resolved}")
977
+
978
+ try:
979
+ session = _load_session(resolved)
980
+ if session is None:
981
+ return None
982
+
983
+ model_manager.models[resolved] = session
984
+ model_manager.use_counts[resolved] = 1
985
+ model_manager.load_times[resolved] = 0.0
986
+
987
+ if len(model_manager.models) > model_manager.max_models:
988
+ model_manager._evict_models()
989
+
990
+ return session
991
+ except Exception as e:
992
+ logger.error(f"Lazy-load failed for {resolved}: {e}")
993
+ return None
994
+
995
+
996
+ # ============================================================
997
+ # ===== ROUTER =====
998
+ # ============================================================
999
+ def detect_hair_complexity(image: Image.Image) -> Dict:
1000
  try:
1001
  gray = image.convert('L').resize((100, 100), Image.Resampling.LANCZOS)
1002
  np_img = np.array(gray, dtype=np.float32)
 
1025
  return skin / len(sample) if len(sample) else 0
1026
 
1027
 
1028
+ def analyze_image_complexity(image: Image.Image) -> Dict:
1029
  try:
1030
  small = image.copy()
1031
  small.thumbnail((10, 10), Image.Resampling.LANCZOS)
1032
  gray = np.array(small.convert('L'))
1033
  rgb = np.array(small.convert('RGB'))
1034
+
1035
  var = float(np.var(gray))
1036
  gx = np.gradient(gray.astype(np.float32), axis=1)
1037
  gy = np.gradient(gray.astype(np.float32), axis=0)
1038
  em = np.sqrt(gx ** 2 + gy ** 2)
1039
  edge = float(np.sum(em > 10) / 100)
1040
  colors = len(np.unique(rgb.reshape(-1, 3), axis=0))
1041
+
1042
  is_graphic = colors < 15 and edge > 0.08
1043
  is_human = _skin_score(rgb) > 0.1
1044
  hc = detect_hair_complexity(image)
 
1056
  avail = model_manager.get_stats().get('loaded_models', [])
1057
 
1058
  def _route(proc, cat, strat, reason):
1059
+ return {
1060
+ 'processor': proc, 'model': proc, 'model_id': proc,
1061
+ 'category': cat, 'strategy': strat, 'reason': reason,
1062
+ 'score': complexity, 'is_human': is_human,
1063
+ 'is_graphic': is_graphic, 'hair_complexity': hc,
1064
+ 'cc_category': cc_cat, 'cc_confidence': 0.7,
1065
+ }
1066
 
1067
  if is_graphic:
1068
  return _route('flood_fill', 'simple_graphic', 'classical', 'simple graphic')
1069
  if is_human and hc['route'] in ('wispy_hair', 'complex_hair'):
1070
+ m = 'birefnet-general-lite' if 'birefnet-general-lite' in avail else 'u2netp'
1071
  return _route(m, 'human_hair', 'neural', f'human+{hc["route"]}')
1072
  if is_human:
1073
+ m = 'birefnet-general-lite' if 'birefnet-general-lite' in avail else 'u2netp'
1074
  return _route(m, 'human', 'neural', 'human')
1075
  if 25 <= complexity <= 55:
1076
+ m = 'birefnet-general-lite' if 'birefnet-general-lite' in avail else 'u2netp'
1077
  return _route(m, 'medium', 'neural', 'medium')
1078
  if complexity > 55:
1079
+ m = 'birefnet-general-lite' if 'birefnet-general-lite' in avail else 'u2netp'
1080
  return _route(m, 'complex', 'neural', 'complex')
1081
+ return _route('u2netp', 'simple', 'neural', 'simple')
1082
  except Exception as e:
1083
  logger.warning(f"Router failed: {e}")
1084
  avail = model_manager.get_stats().get('loaded_models', [])
1085
  m = avail[0] if avail else 'u2netp'
1086
+ return {
1087
+ 'processor': m, 'model': m, 'model_id': m, 'category': 'error',
1088
+ 'strategy': 'neural', 'reason': f'router-error: {e}',
1089
+ 'score': 0, 'is_human': False, 'is_graphic': False,
1090
+ 'hair_complexity': {'route': 'simple_hair'},
1091
+ 'cc_category': 'general_photo', 'cc_confidence': 0.5,
1092
+ }
1093
 
1094
 
1095
  def get_default_model():
1096
  loaded = model_manager.get_stats().get('loaded_models', [])
1097
+ for m in ('birefnet-general-lite', 'u2netp'):
1098
  if m in loaded:
1099
  return m
1100
  return loaded[0] if loaded else 'u2netp'
 
1104
  loaded = model_manager.get_stats().get('loaded_models', [])
1105
  if 'u2netp' in loaded:
1106
  return 'u2netp'
 
 
1107
  return loaded[0] if loaded else 'u2netp'
1108
 
1109
 
1110
+ def detect_fragments(image: Image.Image) -> Dict:
 
 
 
1111
  if image.mode != 'RGBA':
1112
  return {'fragment_ratio': 0, 'quality_score': 100,
1113
  'has_fragments': False, 'issues': []}
 
1134
  'has_fragments': fr > 0.01, 'issues': issues}
1135
 
1136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1137
  # ============================================================
1138
+ # ===== IMAGE PIPELINE =====
1139
  # ============================================================
1140
  def process_image_sync(
1141
+ image_data: bytes,
1142
+ model_name: str = None,
1143
+ transparent: bool = True,
1144
+ max_size: int = 2048,
1145
+ quality: int = 95,
1146
+ preserve_hair: bool = True,
1147
+ use_alpha_matting: bool = True,
1148
+ border_pixels: int = 20,
1149
+ sharpen: bool = True,
1150
+ ) -> Dict:
1151
  req = str(uuid.uuid4())[:8]
1152
  logger.info(f"[REQ {req}] === START ===")
1153
 
 
1156
  original_size = image.size
1157
  logger.info(f"[REQ {req}] Input: {image.size} mode={image.mode} bytes={len(image_data)}")
1158
 
1159
+ # Fast path: alpha passthrough
1160
  if _has_meaningful_alpha(image) and model_name is None:
1161
  logger.info(f"[REQ {req}] Already has alpha — passthrough")
1162
  if image.mode != 'RGBA':
 
1211
  image.thumbnail((target_res, target_res), Image.Resampling.LANCZOS)
1212
  logger.info(f"[REQ {req}] Resized → {image.size}")
1213
 
1214
+ # Full fallback chain — try every model until one works
1215
  diagnostics = []
1216
  result = None
1217
  session = None
 
1220
  result = process_graphic_classical(image)
1221
  if result is not None:
1222
  logger.info(f"[REQ {req}] Classical: {selected}")
 
 
 
 
 
 
1223
 
1224
+ if result is None:
1225
+ # Try the selected model first, then fallbacks
1226
+ tried = set()
1227
+ fallback_order = [selected, 'birefnet-general-lite', 'u2netp', 'silueta']
1228
+
1229
+ for candidate in fallback_order:
1230
+ if candidate in tried or candidate in CLASSICAL_PROCESSORS:
1231
+ continue
1232
+ tried.add(candidate)
1233
+
1234
+ cand_session = get_session_for_model(candidate)
1235
+ if cand_session is None:
1236
+ logger.warning(f"[REQ {req}] No session for {candidate}")
1237
+ continue
1238
+
1239
+ logger.info(f"[REQ {req}] Trying {candidate}...")
1240
+ cand_result, cand_diag = process_with_neural(image, cand_session, candidate)
1241
+ diagnostics.extend([{'model_tried': candidate, **d} for d in cand_diag])
1242
+
1243
+ if cand_result is not None:
1244
+ result = cand_result
1245
+ session = cand_session
1246
+ selected = candidate
1247
+ logger.info(f"[REQ {req}] ✅ {candidate} succeeded")
1248
+ break
1249
+ else:
1250
+ logger.warning(f"[REQ {req}] {candidate} failed, trying next")
1251
 
1252
  if result is None:
1253
  logger.error(f"[REQ {req}] ALL MODELS FAILED")
1254
+ for d in diagnostics:
1255
+ logger.error(f" {d}")
1256
  raise HTTPException(
1257
  500,
1258
+ f"All models failed. First error: {json.dumps(diagnostics[:2])[:400]}"
1259
  )
1260
 
1261
  # Hair refine
 
1267
  q = mask_checker.check(alpha)
1268
  mask_quality = q
1269
  if not q['passed']:
1270
+ logger.info(f"[REQ {req}] Poor mask — HairRefiner")
1271
  refined = hair_refiner.refine(alpha)
1272
  arr = np.array(result.convert('RGBA'))
1273
  arr[:, :, 3] = refined
 
1333
  raise
1334
 
1335
 
1336
+ def cleanup_temp_files(*file_paths: str) -> None:
1337
+ for path in file_paths:
1338
+ if path and os.path.exists(path):
1339
+ try:
1340
+ if os.path.isdir(path):
1341
+ shutil.rmtree(path)
1342
+ else:
1343
+ os.unlink(path)
1344
+ except Exception as e:
1345
+ logger.warning(f"Failed to cleanup {path}: {e}")
1346
+
1347
+
1348
  # ============================================================
1349
+ # ===== FASTAPI =====
1350
  # ============================================================
1351
+ app = FastAPI(title="Background Remover Pro", version="31.0.0")
1352
+
1353
+ app.add_middleware(
1354
+ CORSMiddleware,
1355
+ allow_origins=["*"],
1356
+ allow_credentials=True,
1357
+ allow_methods=["*"],
1358
+ allow_headers=["*"],
1359
+ )
1360
 
1361
  rate_limits = defaultdict(list)
1362
  RATE_LIMIT = 30
1363
 
1364
 
1365
+ def _get_client_ip(request: Request) -> str:
1366
+ xff = request.headers.get("x-forwarded-for")
1367
+ if xff:
1368
+ return xff.split(",")[0].strip()
1369
+ xri = request.headers.get("x-real-ip")
1370
+ if xri:
1371
+ return xri.strip()
1372
+ return request.client.host if request.client else "unknown"
1373
 
1374
 
1375
  @app.middleware("http")
1376
+ async def rate_limit_middleware(request: Request, call_next):
1377
+ if request.url.path in (
1378
+ "/api/health", "/", "/docs", "/redoc", "/openapi.json",
1379
+ "/api/debug/env", "/api/debug/model-report",
1380
+ ):
1381
  return await call_next(request)
1382
  cid = _get_client_ip(request)
1383
  now = datetime.now()
 
1388
  return await call_next(request)
1389
 
1390
 
1391
+ def timeout_handler(seconds: int = 90):
1392
  def deco(func):
1393
  @functools.wraps(func)
1394
  async def wrap(*a, **kw):
 
1401
 
1402
 
1403
  # ============================================================
1404
+ # ===== ENDPOINTS =====
1405
  # ============================================================
1406
  @app.post("/api/process/image")
1407
  @timeout_handler(seconds=90)
 
1436
 
1437
  loop = asyncio.get_event_loop()
1438
  pf = functools.partial(
1439
+ process_image_sync,
1440
+ image_data=contents,
1441
+ model_name=user_model,
1442
+ transparent=transparent,
1443
+ max_size=max_size,
1444
+ quality=quality,
1445
+ preserve_hair=preserve_hair,
1446
+ use_alpha_matting=use_alpha_matting,
1447
+ border_pixels=border_pixels,
1448
+ sharpen=sharpen,
1449
  )
1450
  result_data = await loop.run_in_executor(cpu_executor, pf)
1451
 
 
1470
  }
1471
  return StreamingResponse(
1472
  io.BytesIO(result_data['data']),
1473
+ media_type=result_data['media_type'],
1474
+ headers=headers,
1475
  )
1476
  except HTTPException:
1477
  raise
 
1510
 
1511
  loop = asyncio.get_event_loop()
1512
  pf = functools.partial(
1513
+ process_image_sync,
1514
+ image_data=contents,
1515
+ model_name=user_model,
1516
+ transparent=True,
1517
+ max_size=2048,
1518
+ quality=95,
1519
+ preserve_hair=preserve_hair,
1520
+ use_alpha_matting=use_alpha_matting,
1521
+ border_pixels=border_pixels,
1522
  sharpen=sharpen,
1523
  )
1524
  result_data = await loop.run_in_executor(cpu_executor, pf)
 
1534
  'X-Quality-Score': f"{result_data['quality_score']:.1f}",
1535
  'X-Quality-Label': result_data.get('quality_label', ''),
1536
  'X-Quality-Emoji': emoji,
 
1537
  'Content-Disposition': f"attachment; filename=processed_{fn}.png",
1538
  }
1539
  return StreamingResponse(
1540
  io.BytesIO(result_data['data']),
1541
+ media_type='image/png',
1542
+ headers=headers,
1543
  )
1544
  except HTTPException:
1545
  raise
 
1649
 
1650
 
1651
  # ============================================================
1652
+ # ===== VIDEO =====
1653
  # ============================================================
1654
  MAX_VIDEO_DURATION = 30
1655
  MAX_VIDEO_FRAMES = 240
 
1689
  session = get_session_for_model(model_name) or get_session_for_model('u2netp')
1690
  if session is None:
1691
  raise HTTPException(503, "No model")
1692
+ if model_name not in model_manager.models:
1693
+ model_name = 'u2netp'
1694
+ session = get_session_for_model('u2netp')
1695
+
1696
+ logger.info(f"🎬 Video: model={model_name} max_size={max_size} skip={frame_skip}")
1697
 
1698
  inp = Path(file.filename).suffix or '.mp4'
1699
  input_path = tempfile.mktemp(suffix=inp, dir='/tmp/uploads')
 
1705
 
1706
  cap = cv2.VideoCapture(input_path)
1707
  if not cap.isOpened():
1708
+ file_size = os.path.getsize(input_path)
1709
+ logger.error(f"❌ Cannot open video: size={file_size}B mime={file.content_type}")
1710
+ raise HTTPException(
1711
+ 400,
1712
+ f"Cannot decode video (size={file_size}B). Try H.264 MP4."
1713
+ )
1714
+
1715
+ # Verify readable
1716
+ ok, test_frame = cap.read()
1717
+ if not ok or test_frame is None:
1718
+ cap.release()
1719
+ raise HTTPException(400, "Video opened but no frames readable")
1720
+ cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
1721
 
1722
  w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
1723
  h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
 
1746
  if not frames:
1747
  raise HTTPException(400, "No frames")
1748
 
1749
+ logger.info(f"🎬 Processing {len(frames)} frames at {nw}x{nh} with {model_name}")
1750
+
1751
+ # Try codecs
1752
+ if out_ext == 'mp4':
1753
+ codecs = [('mp4v', 'MP4V'), ('avc1', 'H264')]
1754
+ elif out_ext == 'webm':
1755
+ codecs = [('VP80', 'VP8'), ('VP90', 'VP9')]
1756
+ elif out_ext == 'avi':
1757
+ codecs = [('XVID', 'XVID'), ('MJPG', 'MJPG')]
1758
+ else:
1759
+ codecs = [('mp4v', 'MP4V')]
1760
+
1761
+ out = None
1762
+ for cc, cname in codecs:
1763
+ try:
1764
+ fourcc = cv2.VideoWriter_fourcc(*cc)
1765
+ trial = cv2.VideoWriter(output_path, fourcc, tfps, (nw, nh))
1766
+ if trial.isOpened():
1767
+ out = trial
1768
+ logger.info(f"✅ VideoWriter: {cname}")
1769
+ break
1770
+ trial.release()
1771
+ except Exception as e:
1772
+ logger.warning(f"Codec {cname} failed: {e}")
1773
 
1774
+ if out is None or not out.isOpened():
1775
+ raise HTTPException(500, "No codec available")
1776
 
1777
  for i, frame in enumerate(frames):
1778
  try:
 
1787
  out.write(cv2.cvtColor(np.array(res), cv2.COLOR_RGBA2BGRA))
1788
  except Exception as e:
1789
  logger.error(f"Frame {i}: {e}")
1790
+ fallback = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
1791
+ fallback = cv2.cvtColor(fallback, cv2.COLOR_RGB2RGBA)
1792
+ out.write(cv2.cvtColor(np.array(Image.fromarray(fallback)), cv2.COLOR_RGBA2BGRA))
1793
 
1794
  if (i + 1) % 10 == 0:
1795
  logger.info(f" 📸 {i+1}/{len(frames)}")
 
1809
  'X-Video-Frames': str(len(frames)),
1810
  'Content-Disposition': f'attachment; filename=processed_{fn}.{out_ext}',
1811
  }
1812
+ logger.info(f"✅ Video: {len(frames)} frames in {pt:.1f}s")
1813
  return FileResponse(
1814
  output_path, media_type=mt,
1815
  filename=f'processed_{fn}.{out_ext}', headers=headers,
 
1838
  os.makedirs(cd, exist_ok=True)
1839
  with open(os.path.join(cd, f"chunk_{chunkIndex:04d}"), 'wb') as f:
1840
  f.write(await chunk.read())
1841
+ return JSONResponse({
1842
+ 'status': 'ok', 'chunkId': chunkIndex,
1843
+ 'sessionId': sid, 'totalChunks': totalChunks,
1844
+ })
1845
  except Exception as e:
1846
  raise HTTPException(500, str(e))
1847
 
 
1874
  with open(cp, 'rb') as cf:
1875
  f.write(cf.read())
1876
  background_tasks.add_task(cleanup_temp_files, cd)
1877
+ return FileResponse(
1878
+ ap, media_type='video/mp4',
1879
+ filename=f'processed_{filename}',
1880
+ background=background_tasks.add_task(cleanup_temp_files, ap),
1881
+ )
1882
  except HTTPException:
1883
  raise
1884
  except Exception as e:
 
1886
 
1887
 
1888
  # ============================================================
1889
+ # ===== DEBUG ENDPOINTS =====
1890
  # ============================================================
1891
+ @app.get("/api/debug/env")
1892
+ async def debug_env():
1893
+ """Memory + loaded models snapshot."""
1894
+ m = psutil.virtual_memory()
1895
+ return {
1896
+ 'total_mb': m.total // 1024 // 1024,
1897
+ 'available_mb': m.available // 1024 // 1024,
1898
+ 'used_mb': m.used // 1024 // 1024,
1899
+ 'percent_used': m.percent,
1900
+ 'models_loaded': list(model_manager.models.keys()),
1901
+ 'model_count': len(model_manager.models),
1902
+ 'remove_func_ready': remove_func is not None,
1903
+ 'ai_available': AI_AVAILABLE,
1904
+ 'default_model': default_model_name,
1905
+ 'preload_models': [n for n, _ in PRELOAD_MODELS],
1906
+ 'ort_opt_disabled': sorted(ORT_OPT_DISABLED_MODELS),
1907
+ 'matting_models': sorted(MATTING_MODELS),
1908
+ }
1909
+
1910
+
1911
  @app.get("/api/debug/model-report")
1912
  async def model_report():
1913
+ """Test every model on a synthetic image."""
1914
+ if remove_func is None:
1915
+ return JSONResponse({'error': 'remove_func not ready'}, status_code=503)
1916
+
1917
  test_img = Image.new('RGB', (128, 128), (255, 255, 255))
1918
  ImageDraw.Draw(test_img).rectangle([40, 40, 88, 88], fill=(220, 40, 40))
1919
 
 
1942
  'corner_alpha': int(arr[5, 5, 3]),
1943
  }
1944
  noop, reason = _is_noop(test_img, result)
1945
+ verdict = 'OK' if (stats['center_alpha'] > 200 and stats['corner_alpha'] < 50) else 'BROKEN'
1946
  entry.update({
1947
  'elapsed_s': round(dt, 3),
1948
  'stats': stats,
1949
  'is_noop': noop,
1950
  'reason': reason,
1951
+ 'verdict': verdict,
1952
  })
1953
  except Exception as e:
1954
  entry['error'] = f'{type(e).__name__}: {str(e)[:200]}'
 
1958
 
1959
  return JSONResponse({
1960
  'generated_at': datetime.now().isoformat(),
1961
+ 'test_image': '128x128 white with red square',
1962
  'expected': 'center_alpha > 200, corner_alpha < 50',
1963
  'models': report,
1964
  'summary': {
 
1971
 
1972
 
1973
  # ============================================================
1974
+ # ===== HEALTH =====
1975
  # ============================================================
1976
  @app.get("/")
1977
  async def home():
1978
  stats = model_manager.get_stats()
1979
+ mem = psutil.virtual_memory()
1980
  return HTMLResponse(f"""
1981
  <!DOCTYPE html><html><head><meta charset="UTF-8">
1982
+ <title>Background Remover Pro v31.0.0</title>
1983
  <style>body{{font-family:system-ui;background:linear-gradient(135deg,#667eea,#764ba2);
1984
  color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;
1985
  margin:0;padding:20px}}.c{{max-width:900px;background:rgba(255,255,255,.1);
 
1987
  box-shadow:0 20px 60px rgba(0,0,0,.3)}}.s{{padding:15px;border-radius:12px;
1988
  margin:20px 0;background:rgba(126,217,87,.2)}}a{{color:#a78bfa}}</style>
1989
  </head><body><div class="c">
1990
+ <h1>Background Remover Pro v31.0.0</h1>
1991
+ <div class="s">AI Online · {len(stats['loaded_models'])} models · RAM {mem.percent:.0f}%</div>
1992
+ <p><a href="/docs">API Docs</a> · <a href="/api/debug/model-report">Model Report</a> · <a href="/api/debug/env">Env</a></p>
1993
  </div></body></html>
1994
  """)
1995
 
 
1997
  @app.get("/api/health")
1998
  async def health_check():
1999
  stats = model_manager.get_stats()
2000
+ mem = psutil.virtual_memory()
2001
  return {
2002
  'status': 'healthy' if AI_AVAILABLE else 'degraded',
2003
  'ai_available': AI_AVAILABLE,
2004
  'models_loaded': stats['loaded_models'],
2005
  'models_count': stats['model_count'],
2006
+ 'memory_percent': mem.percent,
2007
+ 'memory_available_mb': mem.available // 1024 // 1024,
2008
  'pymatting_available': PYMATTING_AVAILABLE,
2009
  'video_formats': ['mp4', 'avi', 'webm', 'mov'],
2010
  'image_formats': ['png', 'jpg', 'jpeg', 'webp', 'bmp'],
2011
  'bulk_limit': 20,
2012
+ 'version': '31.0.0',
2013
  'default_model': get_default_model(),
2014
  'default_video_model': get_default_video_model(),
2015
  'preload_models': [n for n, _ in PRELOAD_MODELS],
2016
  'lazy_models': sorted(LAZY_MODELS.keys()),
 
2017
  'matting_models': sorted(MATTING_MODELS),
2018
+ 'ort_opt_disabled': sorted(ORT_OPT_DISABLED_MODELS),
2019
  }
2020
 
2021
 
2022
  @app.exception_handler(HTTPException)
2023
  async def http_exc(request, exc):
2024
+ return JSONResponse(
2025
+ status_code=exc.status_code,
2026
+ content={'error': exc.detail, 'status_code': exc.status_code}
2027
+ )
2028
 
2029
 
2030
  @app.exception_handler(Exception)
2031
  async def gen_exc(request, exc):
2032
  logger.error(f"Unhandled: {exc}")
2033
+ return JSONResponse(
2034
+ status_code=500,
2035
+ content={'error': 'Internal error', 'detail': str(exc)[:200]}
2036
+ )
2037
 
2038
 
2039
  @app.on_event("startup")
2040
  async def startup():
2041
+ stats = model_manager.get_stats()
2042
+ mem = psutil.virtual_memory()
2043
  logger.info("=" * 60)
2044
+ logger.info("🚀 v31.0.0 ready")
2045
  logger.info(f"📊 AI: {AI_AVAILABLE}")
2046
+ logger.info(f"🤖 Models: {stats['loaded_models']}")
2047
+ logger.info(f"💾 RAM: {mem.used//1024//1024}MB / {mem.total//1024//1024}MB ({mem.percent}%)")
2048
+ logger.info(f"🎨 Matting: {sorted(MATTING_MODELS)}")
2049
+ logger.info(f"⚠️ ORT opt disabled: {sorted(ORT_OPT_DISABLED_MODELS)}")
2050
  logger.info("=" * 60)
2051
 
2052
 
 
2063
 
2064
  if __name__ == '__main__':
2065
  import uvicorn
2066
+ uvicorn.run(
2067
+ 'app:app',
2068
+ host='0.0.0.0',
2069
+ port=int(os.environ.get('PORT', 7860)),
2070
+ timeout_keep_alive=300,
2071
+ log_level='info',
2072
+ reload=False,
2073
+ )