andevs commited on
Commit
2b19476
·
verified ·
1 Parent(s): 73b95b9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +514 -342
app.py CHANGED
@@ -1,22 +1,56 @@
1
  #!/usr/bin/env python3
2
- # app.py - Background Remover v32.3.0
3
  #
4
- # Changes vs v32.2.6:
5
- # • Router rewritten. Priority order is now:
6
- # flat image → human → small → complex non-human → fallback
7
- # Fixes the "Japanese flag took 25s" bug (was misclassified as
8
- # product_white_bg → vehicle_like → birefnet).
9
- # • _skin_score vectorized. Router samples 50x50 instead of 10x10,
10
- # so partially-occluded faces register correctly.
11
- # • /api/process/frames-batch cap raised from 4 to 12.
12
- # • _process_frames_sync honors `transparent` — returns RGBA PNG for
13
- # client-side compositing instead of black-composited JPEG.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
 
 
 
15
  import io
16
  import time
17
  import logging
18
  import os
19
- import tempfile
20
  import asyncio
21
  import functools
22
  import sys
@@ -28,6 +62,8 @@ import json
28
  import uuid
29
  import base64
30
  import urllib.parse
 
 
31
  from collections import defaultdict, Counter, deque, OrderedDict
32
  from datetime import datetime
33
  from pathlib import Path
@@ -82,54 +118,92 @@ logger = logging.getLogger(__name__)
82
 
83
 
84
  # ============================================================
85
- # MEMORY
86
  # ============================================================
87
- def get_container_memory() -> Dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  try:
89
  if os.path.exists('/sys/fs/cgroup/memory.max'):
90
  with open('/sys/fs/cgroup/memory.max') as f:
91
  limit_str = f.read().strip()
92
- with open('/sys/fs/cgroup/memory.current') as f:
93
- current = int(f.read().strip())
94
  if limit_str != 'max':
95
- limit = int(limit_str)
96
- if limit > 0:
97
- return {
98
- 'total_mb': limit // 1024 // 1024,
99
- 'used_mb': current // 1024 // 1024,
100
- 'available_mb': (limit - current) // 1024 // 1024,
101
- 'percent': (current / limit) * 100,
102
- 'source': 'cgroup-v2',
103
- }
104
- except Exception:
105
- pass
106
- try:
107
  if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
108
  with open('/sys/fs/cgroup/memory/memory.limit_in_bytes') as f:
109
  limit = int(f.read().strip())
110
- with open('/sys/fs/cgroup/memory/memory.usage_in_bytes') as f:
111
- current = int(f.read().strip())
112
  if 0 < limit < (1 << 60):
113
- return {
114
- 'total_mb': limit // 1024 // 1024,
115
- 'used_mb': current // 1024 // 1024,
116
- 'available_mb': (limit - current) // 1024 // 1024,
117
- 'percent': (current / limit) * 100,
118
- 'source': 'cgroup-v1',
119
- }
120
  except Exception:
121
  pass
122
  try:
123
- m = psutil.virtual_memory()
124
- return {'total_mb': m.total // 1024 // 1024,
125
- 'used_mb': m.used // 1024 // 1024,
126
- 'available_mb': m.available // 1024 // 1024,
127
- 'percent': m.percent, 'source': 'psutil-host'}
128
  except Exception:
129
- return {'total_mb': 0, 'used_mb': 0, 'available_mb': 0,
130
- 'percent': 0, 'source': 'unknown'}
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  class ResolutionManager:
134
  RECOMMENDED = {
135
  'simple_graphic': 600, 'human_hair': 1024, 'human': 1024,
@@ -144,62 +218,73 @@ class ResolutionManager:
144
 
145
 
146
  class ModelManager:
147
- def __init__(self, max_models: int = 3, memory_limit_mb: int = 8000):
148
  self.models: Dict[str, Any] = {}
149
  self.load_times: Dict[str, float] = {}
 
150
  self.use_counts: Dict[str, int] = {}
151
  self.max_models = max_models
152
- self.memory_limit_mb = memory_limit_mb
153
 
154
  def get_model(self, name, loader_func):
155
  if name in self.models:
156
  self.use_counts[name] = self.use_counts.get(name, 0) + 1
157
  return self.models[name]
158
- try:
159
- mem = get_container_memory()
160
- if 'cgroup' in mem.get('source', '') and mem['percent'] > 82:
161
- logger.warning(f"Container RAM {mem['percent']:.1f}%. Evicting.")
162
- self._evict_models()
163
- gc.collect()
164
- except Exception:
165
- pass
166
  start = time.time()
167
  try:
168
  session = loader_func(name)
169
  if session is None:
170
  return None
171
  self.models[name] = session
 
172
  self.load_times[name] = time.time() - start
173
  self.use_counts[name] = 1
174
  gc.collect()
175
- try:
176
- mem = get_container_memory()
177
- logger.info(f"After {name}: {mem['used_mb']}MB "
178
- f"({mem['percent']:.1f}% of {mem['total_mb']}MB)")
179
- except Exception:
180
- pass
181
- if len(self.models) > self.max_models:
182
- self._evict_models()
183
  return session
184
  except Exception as e:
185
  logger.warning(f"Loader failed for {name}: {e}")
186
  gc.collect()
187
  return None
188
 
189
- def _evict_models(self):
190
- if len(self.models) <= 1:
 
 
 
 
 
 
191
  return
192
- to_remove = min(self.models.keys(), key=lambda x: self.use_counts.get(x, 0))
193
- logger.info(f"Evicting LRU: {to_remove}")
194
  del self.models[to_remove]
195
  self.use_counts.pop(to_remove, None)
 
196
  gc.collect()
 
 
 
 
197
 
198
  def get_stats(self):
199
- return {'loaded_models': list(self.models.keys()),
200
- 'model_count': len(self.models),
201
- 'use_counts': self.use_counts,
202
- 'load_times': self.load_times}
 
 
 
 
203
 
204
 
205
  class MaskQualityChecker:
@@ -424,8 +509,9 @@ class ContentClassifier:
424
  return 'complex'
425
 
426
 
427
- logger.info("RLIMIT_AS not set — relying on cgroup memory.max")
428
-
 
429
  os.environ["U2NET_HOME"] = "/tmp/u2net_models"
430
  os.environ["OMP_NUM_THREADS"] = "2"
431
  os.environ["CUDA_VISIBLE_DEVICES"] = ""
@@ -433,26 +519,27 @@ os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
433
  os.environ["PYTHONUNBUFFERED"] = "1"
434
  socket.setdefaulttimeout(60)
435
 
436
- for d in ["/tmp/u2net_models", "/tmp/uploads", "/tmp/outputs", "/tmp/frames"]:
437
  os.makedirs(d, exist_ok=True)
438
 
439
  cpu_executor = ThreadPoolExecutor(max_workers=2)
440
 
441
  content_classifier = ContentClassifier()
442
  resolution_manager = ResolutionManager()
443
- model_manager = ModelManager(max_models=3, memory_limit_mb=8000)
444
  mask_checker = MaskQualityChecker()
445
  hair_refiner = HairRefiner()
446
  graphic_processor = GraphicProcessor()
447
 
448
 
449
  # ============================================================
450
- # TEMPORAL ALPHA SMOOTHING
451
  # ============================================================
452
  ALPHA_SMOOTH_WEIGHT = 0.35
453
  ALPHA_CACHE_MAX_SESSIONS = 8
454
- ALPHA_CACHE_TTL_SECONDS = 300
455
  ALPHA_CACHE_MAX_DIM = 1280
 
456
 
457
  _alpha_cache: "OrderedDict[str, tuple]" = OrderedDict()
458
  _alpha_cache_lock = threading.Lock()
@@ -509,34 +596,73 @@ def _alpha_cache_stats() -> Dict:
509
  }
510
 
511
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
  # ============================================================
513
- # VIDEO FRAME SERIALIZATION
514
  # ============================================================
515
  _video_semaphore = threading.Semaphore(1)
516
- VIDEO_RAM_REJECT_PERCENT = 75.0
517
  VIDEO_MAX_BATCH = 12
518
-
519
  VIDEO_FORCE_MODEL = os.environ.get('VIDEO_FORCE_MODEL', 'silueta')
520
  BIREFNET_MAX_DIM = 512
521
 
522
 
523
  # ============================================================
524
- # MODEL CONFIG
525
  # ============================================================
526
  PRELOAD_MODELS = [
527
- ("u2netp", "~4.7 MB disk / ~180 MB RAM"),
528
- ("silueta", "~43 MB disk / ~200 MB RAM"),
529
- ("birefnet-general-lite", "~12 MB disk / ~250 MB RAM"),
 
530
  ]
531
 
 
 
 
 
 
 
 
 
 
532
  LAZY_MODELS = {
533
- "isnet-general-use": "~176 MB disk / ~800 MB RAM",
534
- "u2net": "~176 MB disk / ~800 MB RAM",
535
- "u2net_human_seg": "~176 MB disk / ~800 MB RAM",
536
- "isnet-anime": "~176 MB disk / ~800 MB RAM",
537
- "birefnet-general": "~900 MB disk / ~2.5 GB RAM",
538
- "u2net_cloth_seg": "~176 MB disk / ~800 MB RAM",
539
- "sam": "~375 MB disk / ~1 GB RAM",
540
  }
541
 
542
  VALID_REMBG_MODELS = {
@@ -591,7 +717,7 @@ VEHICLE_RES_CAP = BIREFNET_MAX_DIM
591
  FRAME_MAX_DIM = 640
592
 
593
  CLASSICAL_PROCESSORS = {'flood_fill', 'graphic_classical'}
594
- DEFAULT_MODEL_NAME = 'isnet-general-use'
595
  DEFAULT_VIDEO_MODEL = VIDEO_FORCE_MODEL
596
 
597
  DISPLAY_NAMES = {
@@ -614,10 +740,8 @@ def _load_session(name: str):
614
  from rembg import new_session
615
  resolved = MODEL_ALIASES.get(name, name)
616
  if resolved not in VALID_REMBG_MODELS:
617
- logger.warning(f"'{resolved}' not valid. Using isnet-general-use.")
618
- resolved = 'isnet-general-use'
619
- if resolved != name:
620
- logger.info(f"Alias: {name} -> {resolved}")
621
  logger.info(f"Loading {resolved}...")
622
  return new_session(resolved)
623
 
@@ -815,17 +939,24 @@ except ImportError as e:
815
  logger.warning(f"pymatting not available: {e}")
816
 
817
 
818
- def pre_download_models():
819
  logger.info(f"Preloading {len(PRELOAD_MODELS)} models...")
820
- for name, size in PRELOAD_MODELS:
821
  try:
822
- logger.info(f" Downloading {name} ({size})...")
 
823
  session = model_manager.get_model(name, _load_session)
824
  if session is not None:
825
- logger.info(f" {name} ready")
 
 
826
  except Exception as e:
827
  logger.warning(f" {name}: {e}")
828
- logger.info("Preload complete")
 
 
 
 
829
 
830
 
831
  def initialize_models():
@@ -835,70 +966,97 @@ def initialize_models():
835
  from rembg import remove
836
  remove_func = remove
837
  loaded = model_manager.get_stats().get("loaded_models", [])
838
- if DEFAULT_MODEL_NAME in loaded:
839
- default_model_name = DEFAULT_MODEL_NAME
840
- else:
841
- for m in ('isnet-general-use', 'silueta', 'birefnet-general-lite', 'u2netp'):
842
- if m in loaded:
843
- default_model_name = m
844
- break
845
- else:
846
- default_model_name = loaded[0] if loaded else 'u2netp'
847
  AI_AVAILABLE = len(loaded) > 0
848
  if AI_AVAILABLE:
849
- logger.info(f"AI ready with {len(loaded)} models")
850
  except Exception as e:
851
  logger.error(f"AI setup failed: {e}")
852
  AI_AVAILABLE = False
853
 
854
 
855
  def _prewarm_models():
 
 
 
856
  global remove_func
857
  if remove_func is None:
 
858
  return
859
- logger.info("Pre-warming sessions...")
860
- try:
861
- test_img = Image.new('RGB', (64, 64), (255, 255, 255))
862
- ImageDraw.Draw(test_img).rectangle([20, 20, 44, 44], fill=(220, 40, 40))
863
- WARMUP_SKIP = {'birefnet-general-lite'}
864
- for name, _ in PRELOAD_MODELS:
865
- if name in WARMUP_SKIP:
866
- logger.info(f" {name} skipped (heavy warmup)")
867
- continue
868
- session = model_manager.models.get(name)
869
- if session is None:
 
 
 
 
 
 
 
 
 
 
 
 
870
  continue
871
- try:
872
- t0 = time.time()
873
- result = remove_func(test_img, session=session, alpha_matting=False)
874
- dt = time.time() - t0
875
- if result.mode != 'RGBA':
876
- result = result.convert('RGBA')
877
- arr = np.array(result)
878
- cy, cx = arr.shape[0] // 2, arr.shape[1] // 2
879
- ca = int(arr[cy, cx, 3]); corner = int(arr[2, 2, 3])
880
- if ca > 200 and corner < 50:
881
- logger.info(f" {name} OK ({dt:.2f}s)")
882
- else:
883
- logger.warning(f" {name} WRONG (center={ca} corner={corner})")
884
- except Exception as e:
885
- logger.warning(f" {name}: {type(e).__name__}: {e}")
 
 
886
  gc.collect()
887
- except Exception as e:
888
- logger.warning(f"Pre-warm skipped: {e}")
 
 
 
 
 
 
889
 
890
 
891
  logger.info("=" * 60)
892
- logger.info("Background Remover v32.3.0")
 
893
  logger.info("=" * 60)
894
- pre_download_models()
 
895
  initialize_models()
896
  _prewarm_models()
 
 
897
  mem = get_container_memory()
898
  logger.info(f"AI: {AI_AVAILABLE} | Default: {default_model_name}")
899
- logger.info(f"RAM: {mem['used_mb']}MB / {mem['total_mb']}MB ({mem['percent']:.1f}%)")
900
- logger.info(f"Video: FORCE_MODEL={VIDEO_FORCE_MODEL}, MAX_BATCH={VIDEO_MAX_BATCH}")
901
- logger.info(f"Video: serialized (1 concurrent), RAM reject at {VIDEO_RAM_REJECT_PERCENT}%")
 
 
 
 
902
  logger.info("=" * 60)
903
 
904
 
@@ -912,10 +1070,11 @@ def get_session_for_model(model_name):
912
  if session is None:
913
  return None
914
  model_manager.models[resolved] = session
 
915
  model_manager.use_counts[resolved] = 1
916
  model_manager.load_times[resolved] = 0.0
917
  if len(model_manager.models) > model_manager.max_models:
918
- model_manager._evict_models()
919
  return session
920
  except Exception as e:
921
  logger.error(f"Lazy-load failed for {resolved}: {e}")
@@ -923,16 +1082,9 @@ def get_session_for_model(model_name):
923
 
924
 
925
  # ============================================================
926
- # ROUTER — CLEAN PRIORITY ORDER
 
927
  # ============================================================
928
- def _preferred_quality_model():
929
- loaded = model_manager.get_stats().get('loaded_models', [])
930
- for m in ('isnet-general-use', 'silueta', 'birefnet-general-lite', 'u2netp'):
931
- if m in loaded:
932
- return m
933
- return 'u2netp'
934
-
935
-
936
  def detect_hair_complexity(image):
937
  try:
938
  gray = image.convert('L').resize((100, 100), Image.Resampling.LANCZOS)
@@ -953,7 +1105,6 @@ def detect_hair_complexity(image):
953
 
954
 
955
  def _skin_score(rgb):
956
- """Fraction of pixels that look like skin. Vectorized."""
957
  if rgb is None or rgb.size == 0:
958
  return 0.0
959
  r = rgb[:, :, 0].astype(np.int32)
@@ -969,110 +1120,83 @@ def _skin_score(rgb):
969
 
970
 
971
  def analyze_image_complexity(image):
972
- """Route an image to the best processor for its content type.
973
-
974
- Priority order is strict and unambiguous:
975
- 1. FLAT IMAGE (flag, logo, product on white, screenshot)
976
- → flood_fill (classical, ~50ms, no model needed)
977
- 2. HUMAN → quality model (isnet for hair detail)
978
- 3. SMALL IMAGE (<400px) → u2netp (quality gains nothing)
979
- 4. COMPLEX NON-HUMAN → birefnet-general-lite (detail preservation)
980
- 5. FALLBACK → u2netp
981
-
982
- The bug this fixes: a Japanese flag was being classified as
983
- product_white_bg, then matched the 'vehicle_like' tuple, then
984
- routed to birefnet (25s). It should be flood_fill (50ms).
985
- The fix is: check for flat images FIRST, before anything else.
986
  """
987
  try:
988
  cc = content_classifier.classify(image)
989
- cc_cat = cc.get('category', 'general_photo')
990
  sig = cc.get('signals', {}) or {}
 
991
  cc_color = float(sig.get('color_complexity', 0.5))
992
  cc_border = float(sig.get('border_uniformity', 0))
993
  cc_edge = float(sig.get('edge_density', 0))
994
  cc_skin = float(sig.get('skin_score', 0))
995
  cc_texture = float(sig.get('texture', 0))
996
 
997
- loaded = model_manager.get_stats().get('loaded_models', [])
998
- has_birefnet = 'birefnet-general-lite' in loaded
999
- has_silueta = 'silueta' in loaded
1000
- has_isnet = 'isnet-general-use' in loaded
1001
-
1002
- quality = ('isnet-general-use' if has_isnet else
1003
- 'silueta' if has_silueta else
1004
- 'birefnet-general-lite' if has_birefnet else
1005
- 'u2netp')
1006
-
1007
  def _route(proc, cat, strat, reason, extra=None):
1008
  r = {
1009
- 'processor': proc,
1010
- 'model': proc,
1011
- 'model_id': proc,
1012
- 'category': cat,
1013
- 'strategy': strat,
1014
- 'reason': reason,
1015
- 'score': 0,
1016
- 'is_human': False,
1017
- 'is_graphic': cat in ('simple_graphic', 'logo_icon', 'product_white_bg'),
1018
  'hair_complexity': {'route': 'simple_hair'},
1019
- 'cc_category': cc_cat,
1020
- 'cc_confidence': 0.7,
1021
  }
1022
  if extra:
1023
  r.update(extra)
1024
  return r
1025
 
1026
- # ── TIER 0: FLAT IMAGE → flood_fill ──
1027
- # The tell is a uniform border + low color complexity + no skin.
1028
- # A flag has all three. A product on white has all three. A logo
1029
- # has all three. None of them need a neural network.
1030
  is_flat = (
1031
  cc_skin < 0.05
1032
- and cc_color < 0.4
1033
  and (
1034
- cc_border > 0.7
1035
  or cc_cat in ('simple_graphic', 'logo_icon')
1036
- or (cc_color < 0.15 and cc_edge < 0.25)
1037
  )
1038
  )
1039
  if is_flat:
1040
- cat = cc_cat if cc_cat in ('simple_graphic', 'logo_icon', 'product_white_bg') else 'simple_graphic'
1041
- return _route('flood_fill', cat, 'classical',
1042
- f'flat (color={cc_color:.2f}, border={cc_border:.2f}, '
1043
- f'edge={cc_edge:.2f}, cat={cc_cat})')
1044
-
1045
- # ── TIER 1: HUMAN → quality model ──
1046
- is_human = cc_skin > 0.05 or cc_cat in ('human', 'human_hair')
1047
- if is_human:
1048
  hc = detect_hair_complexity(image)
1049
- if hc['route'] in ('wispy_hair', 'complex_hair'):
1050
- return _route(quality, 'human_hair', 'neural',
1051
- f'human + {hc["route"]}',
1052
- {'is_human': True, 'hair_complexity': hc})
1053
- return _route(quality, 'human', 'neural', 'human',
1054
- {'is_human': True, 'hair_complexity': hc})
1055
-
1056
- # ── TIER 2: SMALL IMAGE → u2netp ──
 
1057
  if max(image.size) < 400:
1058
- return _route('u2netp', 'small', 'neural',
1059
- f'small image ({max(image.size)}px)')
 
 
1060
 
1061
- # ── TIER 3: COMPLEX NON-HUMAN → birefnet ──
1062
- is_complex = cc_edge > 0.05 or cc_color > 0.35 or cc_texture > 0.5
1063
- if is_complex and has_birefnet:
1064
- return _route('birefnet-general-lite', 'complex', 'neural',
1065
- f'complex non-human (edge={cc_edge:.2f}, '
1066
- f'color={cc_color:.2f}, texture={cc_texture:.2f})')
1067
 
1068
- # ── TIER 4: FALLBACK → u2netp ──
1069
  return _route('u2netp', 'general', 'neural', 'fallback to u2netp')
1070
 
1071
  except Exception as e:
1072
  logger.warning(f"Router failed: {e}")
1073
- m = _preferred_quality_model()
1074
  return {
1075
- 'processor': m, 'model': m, 'model_id': m,
1076
  'category': 'error', 'strategy': 'neural',
1077
  'reason': f'router-error: {e}', 'score': 0,
1078
  'is_human': False, 'is_graphic': False,
@@ -1083,7 +1207,7 @@ def analyze_image_complexity(image):
1083
 
1084
  def get_default_model():
1085
  loaded = model_manager.get_stats().get('loaded_models', [])
1086
- for m in ('isnet-general-use', 'silueta', 'birefnet-general-lite', 'u2netp'):
1087
  if m in loaded:
1088
  return m
1089
  return loaded[0] if loaded else 'u2netp'
@@ -1183,7 +1307,7 @@ def process_image_sync(image_data, model_name=None, transparent=True,
1183
 
1184
  if result is None:
1185
  tried = set()
1186
- for candidate in [selected, 'birefnet-general-lite', 'silueta', 'isnet-general-use', 'u2netp']:
1187
  if candidate in tried or candidate in CLASSICAL_PROCESSORS:
1188
  continue
1189
  tried.add(candidate)
@@ -1266,13 +1390,13 @@ def process_image_sync(image_data, model_name=None, transparent=True,
1266
 
1267
 
1268
  # ============================================================
1269
- # VIDEO FRAME PIPELINE — honors `transparent`
1270
  # ============================================================
1271
  def _process_frames_sync(raw_frames, resolved_model, transparent,
1272
  session_id: Optional[str] = None,
1273
  smooth: bool = True):
1274
  fallback_chain = [resolved_model]
1275
- for candidate in ('silueta', 'isnet-general-use', 'u2netp'):
1276
  if candidate not in fallback_chain:
1277
  fallback_chain.append(candidate)
1278
 
@@ -1312,12 +1436,10 @@ def _process_frames_sync(raw_frames, resolved_model, transparent,
1312
  max_a = int(alpha.max())
1313
 
1314
  if max_a < 10:
1315
- logger.warning(f"[frames-batch] {candidate} blank "
1316
- f"(max_a={max_a}); frame {idx} trying next")
1317
  continue
1318
  if min_a > 245:
1319
- logger.warning(f"[frames-batch] {candidate} no-op "
1320
- f"(min_a={min_a}); frame {idx} trying next")
1321
  continue
1322
 
1323
  result = res
@@ -1351,11 +1473,9 @@ def _process_frames_sync(raw_frames, resolved_model, transparent,
1351
 
1352
  buf = io.BytesIO()
1353
  if transparent:
1354
- # RGBA PNG. Client composites alpha onto high-res original.
1355
  result.save(buf, format="PNG", compress_level=3)
1356
  fmt = "png"
1357
  else:
1358
- # Legacy flat black composite for old clients.
1359
  if result.mode == "RGBA":
1360
  bg = Image.new("RGBA", result.size, (0, 0, 0, 255))
1361
  result = Image.alpha_composite(bg, result).convert("RGB")
@@ -1383,7 +1503,7 @@ def _process_frames_sync(raw_frames, resolved_model, transparent,
1383
  # ============================================================
1384
  # FASTAPI
1385
  # ============================================================
1386
- app = FastAPI(title="Background Remover", version="32.3.0")
1387
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
1388
  allow_methods=["*"], allow_headers=["*"])
1389
 
@@ -1402,7 +1522,8 @@ def _get_client_ip(request):
1402
  @app.middleware("http")
1403
  async def rate_limit_middleware(request, call_next):
1404
  if request.url.path in ("/api/health", "/", "/docs", "/redoc", "/openapi.json",
1405
- "/api/warmup", "/api/debug/env",
 
1406
  "/api/process/frames-batch",
1407
  "/api/frames/session/reset",
1408
  "/api/frames/session/stats"):
@@ -1519,55 +1640,47 @@ async def process_bulk_images(
1519
  if resolved in VALID_REMBG_MODELS or resolved in CLASSICAL_PROCESSORS:
1520
  user_model = resolved
1521
 
1522
- temp_dir = tempfile.mkdtemp(prefix='bulk_')
1523
- processed = []
1524
- scores = []
1525
  used_names = set()
 
 
1526
 
1527
- for f in files:
1528
- try:
1529
- c = await f.read()
1530
- if not c: continue
1531
- d = process_image_sync(
1532
- c, model_name=user_model, transparent=transparent,
1533
- max_size=max_size, quality=95,
1534
- preserve_hair=preserve_hair,
1535
- use_alpha_matting=use_alpha_matting,
1536
- border_pixels=border_pixels, sharpen=sharpen,
1537
- )
1538
- scores.append(d['quality_score'])
1539
- stem = Path(f.filename).stem or "image"
1540
- out_stem = f"{stem}_remova"
1541
- if out_stem in used_names:
1542
- n = 2
1543
- while f"{out_stem}_{n}" in used_names: n += 1
1544
- out_stem = f"{out_stem}_{n}"
1545
- used_names.add(out_stem)
1546
- out_name = f"{out_stem}.{d['extension']}"
1547
- p = os.path.join(temp_dir, out_name)
1548
- with open(p, 'wb') as fh:
1549
- fh.write(d['data'])
1550
- processed.append(p)
1551
- except Exception as e:
1552
- logger.error(f"Failed {f.filename}: {e}")
1553
-
1554
- if not processed: raise HTTPException(500, "No images processed")
1555
-
1556
- zb = io.BytesIO()
1557
  with zipfile.ZipFile(zb, 'w', zipfile.ZIP_DEFLATED) as zf:
1558
- for p in processed:
1559
- zf.write(p, os.path.basename(p))
1560
- zb.seek(0)
1561
-
1562
- try: shutil.rmtree(temp_dir)
1563
- except Exception: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1564
 
 
1565
  pt = time.time() - t0
1566
  avg = sum(scores) / len(scores) if scores else 0
1567
  headers = {
1568
  'X-Processing-Time': f'{pt:.3f}',
1569
  'X-Model-Used': 'smart_routing',
1570
- 'X-Image-Count': str(len(processed)),
1571
  'X-Avg-Quality-Score': f'{avg:.1f}',
1572
  'Content-Disposition': f'attachment; filename=processed_images_{int(time.time())}.zip',
1573
  }
@@ -1579,9 +1692,6 @@ async def process_bulk_images(
1579
  raise HTTPException(500, str(e)[:200])
1580
 
1581
 
1582
- # ============================================================
1583
- # FRAMES BATCH — cap raised to 12, transparent honored
1584
- # ============================================================
1585
  @app.post("/api/process/frames-batch")
1586
  async def process_frames_batch(
1587
  files: List[UploadFile] = File(...),
@@ -1593,21 +1703,20 @@ async def process_frames_batch(
1593
  if len(files) > VIDEO_MAX_BATCH:
1594
  raise HTTPException(400, f"Max {VIDEO_MAX_BATCH} frames per batch")
1595
 
1596
- resolved = VIDEO_FORCE_MODEL
1597
-
1598
  mem = get_container_memory()
1599
- if 'cgroup' in mem.get('source', '') and mem['percent'] > VIDEO_RAM_REJECT_PERCENT:
1600
  logger.warning(
1601
- f"[frames-batch] RAM {mem['percent']:.1f}% > "
1602
- f"{VIDEO_RAM_REJECT_PERCENT}%, rejecting batch"
1603
  )
1604
  return JSONResponse(
1605
  status_code=503,
1606
  content={
1607
  'error': 'Server memory high. Retry shortly.',
1608
- 'ram_percent': round(mem['percent'], 1),
1609
- 'ram_used_mb': mem['used_mb'],
1610
- 'ram_total_mb': mem['total_mb'],
1611
  'retry_in': 10,
1612
  'cold': True,
1613
  },
@@ -1627,7 +1736,7 @@ async def process_frames_batch(
1627
  def _worker():
1628
  with _video_semaphore:
1629
  return _process_frames_sync(
1630
- raw_frames, resolved, transparent, session_id, smooth,
1631
  )
1632
 
1633
  try:
@@ -1637,8 +1746,9 @@ async def process_frames_batch(
1637
  raise HTTPException(500, f"Frame processing failed: {str(e)[:200]}")
1638
  finally:
1639
  gc.collect()
 
1640
 
1641
- used = next((r.get("model") for r in results if r.get("model")), resolved)
1642
  smoothed_count = sum(1 for r in results if r.get("smoothed"))
1643
  return {
1644
  "frames": results,
@@ -1733,38 +1843,85 @@ async def warmup():
1733
  return {"status": "warm", "ts": datetime.now().isoformat()}
1734
 
1735
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1736
  @app.get("/api/debug/env")
1737
  async def debug_env():
1738
- m = get_container_memory()
1739
- return {'total_mb': m['total_mb'], 'used_mb': m['used_mb'],
1740
- 'available_mb': m['available_mb'],
1741
- 'percent_used': round(m['percent'], 1),
1742
- 'memory_source': m['source'],
1743
- 'models_loaded': list(model_manager.models.keys()),
1744
- 'model_count': len(model_manager.models),
1745
- 'remove_func_ready': remove_func is not None,
1746
- 'ai_available': AI_AVAILABLE,
1747
- 'default_model': default_model_name,
1748
- 'default_video_model': DEFAULT_VIDEO_MODEL,
1749
- 'video_force_model': VIDEO_FORCE_MODEL,
1750
- 'video_ram_reject_percent': VIDEO_RAM_REJECT_PERCENT,
1751
- 'video_max_batch': VIDEO_MAX_BATCH,
1752
- 'preload_models': [n for n, _ in PRELOAD_MODELS],
1753
- 'pad_models': sorted(PAD_MULTIPLE_32_MODELS),
1754
- 'matting_models': sorted(MATTING_MODELS),
1755
- 'vehicle_res_cap': VEHICLE_RES_CAP,
1756
- 'birefnet_max_dim': BIREFNET_MAX_DIM,
1757
- 'frame_max_dim': FRAME_MAX_DIM,
1758
- 'alpha_smoothing': _alpha_cache_stats()}
1759
 
1760
 
1761
  @app.get("/")
1762
  async def home():
1763
  stats = model_manager.get_stats()
1764
  mem = get_container_memory()
 
1765
  return HTMLResponse(f"""
1766
  <!DOCTYPE html><html><head><meta charset="UTF-8">
1767
- <title>Background Remover v32.3.0</title>
1768
  <style>body{{font-family:system-ui;background:#0A0A0A;
1769
  color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;
1770
  margin:0;padding:20px}}.c{{max-width:900px;background:#111;
@@ -1772,12 +1929,14 @@ async def home():
1772
  border:1px solid #222}}.s{{padding:15px;border-radius:12px;
1773
  margin:20px 0;background:#1a1a1a}}a{{color:#7ED957}}</style>
1774
  </head><body><div class="c">
1775
- <h1>Background Remover v32.3.0</h1>
1776
- <div class="s">AI online. {len(stats['loaded_models'])} models.
1777
- RAM {mem['percent']:.0f}% ({mem['used_mb']}/{mem['total_mb']}MB)</div>
1778
- <p>Router: flat→flood_fill · human→isnet · complex→birefnet</p>
1779
- <p>Video: <strong>{VIDEO_FORCE_MODEL}</strong> · max {VIDEO_MAX_BATCH} frames/batch</p>
1780
- <p><a href="/docs">API Docs</a></p>
 
 
1781
  </div></body></html>
1782
  """)
1783
 
@@ -1786,32 +1945,46 @@ async def home():
1786
  async def health_check():
1787
  stats = model_manager.get_stats()
1788
  mem = get_container_memory()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1789
  return {
1790
- 'status': 'healthy' if AI_AVAILABLE else 'degraded',
1791
  'ai_available': AI_AVAILABLE,
1792
  'models_loaded': stats['loaded_models'],
1793
  'models_count': stats['model_count'],
1794
- 'memory_percent': round(mem['percent'], 1),
1795
- 'memory_used_mb': mem['used_mb'],
1796
- 'memory_available_mb': mem['available_mb'],
 
 
 
 
1797
  'memory_total_mb': mem['total_mb'],
1798
  'memory_source': mem['source'],
1799
- 'pymatting_available': PYMATTING_AVAILABLE,
1800
- 'version': '32.3.0',
1801
- 'default_model': get_default_model(),
1802
- 'default_video_model': DEFAULT_VIDEO_MODEL,
1803
- 'video_force_model': VIDEO_FORCE_MODEL,
1804
  'video_ram_reject_percent': VIDEO_RAM_REJECT_PERCENT,
1805
  'video_max_batch': VIDEO_MAX_BATCH,
1806
- 'video_serialized': True,
1807
- 'preload_models': [n for n, _ in PRELOAD_MODELS],
 
1808
  'lazy_models': sorted(LAZY_MODELS.keys()),
1809
- 'matting_models': sorted(MATTING_MODELS),
1810
- 'pad_models': sorted(PAD_MULTIPLE_32_MODELS),
1811
- 'frame_max_dim': FRAME_MAX_DIM,
1812
- 'birefnet_max_dim': BIREFNET_MAX_DIM,
1813
- 'onnx_arena': False,
1814
- 'rlimit_as': None,
1815
  'alpha_smoothing': _alpha_cache_stats(),
1816
  }
1817
 
@@ -1832,23 +2005,22 @@ async def gen_exc(request, exc):
1832
  @app.on_event("startup")
1833
  async def startup():
1834
  logger.info("=" * 60)
1835
- logger.info("v32.3.0 ready")
1836
- logger.info("Router: flat→flood_fill · human→isnet · complex→birefnet")
1837
- logger.info(f"Video: FORCE_MODEL={VIDEO_FORCE_MODEL}, MAX_BATCH={VIDEO_MAX_BATCH}")
 
 
1838
  logger.info("=" * 60)
1839
 
1840
 
1841
  @app.on_event("shutdown")
1842
  async def shutdown():
1843
  cpu_executor.shutdown(wait=True)
1844
- for d in ['/tmp/uploads', '/tmp/outputs', '/tmp/frames']:
1845
- try:
1846
- if os.path.exists(d): shutil.rmtree(d)
1847
- except Exception: pass
1848
 
1849
 
1850
  if __name__ == '__main__':
1851
  import uvicorn
1852
  uvicorn.run('app:app', host='0.0.0.0',
1853
  port=int(os.environ.get('PORT', 7860)),
1854
- timeout_keep_alive=300, log_level='info', reload=False)
 
 
1
  #!/usr/bin/env python3
2
+ # app.py - Background Remover v32.5.0
3
  #
4
+ # Changes in this version:
5
+ # • All 4 models preloaded and pre-warmed at inference resolution.
6
+ # No cold-start for birefnet; first request is as fast as the 100th.
7
+ # • Memory rejection uses process RSS, not cgroup. Cgroup includes
8
+ # page cache from model downloads (~2-3 GB) which triggered false
9
+ # 503s. RSS is the actual process RAM.
10
+ # • All 4 preloaded models are protected from eviction. No LRU churn.
11
+ # • Bulk endpoint builds zip entirely in memory. No /tmp/bulk_* tempdir.
12
+ # • Alpha cache TTL reduced to 60s with a background purge thread.
13
+ # Orphaned sessions are cleaned every 15s even if the client never
14
+ # calls /api/frames/session/reset.
15
+ # • SpooledTemporaryFile monkey-patched to keep uploads up to 200 MB
16
+ # in RAM. No >1 MB uploads touching /tmp during parsing.
17
+ # • Router: flat → u2netp, human → isnet, small → u2netp,
18
+ # complex → birefnet. Priority order correct.
19
+ # • /api/debug/tmp endpoint lists any files in /tmp for privacy audit.
20
+ #
21
+ # Privacy contract:
22
+ # Image uploads: bytes → PIL → result → zip buffer → HTTP response.
23
+ # Never touches disk.
24
+ # Video frames: same. base64-encoded results streamed back.
25
+ # Bulk images: same. In-memory zip, no tempdir.
26
+ # Alpha cache: numpy arrays of alpha values, not the image itself.
27
+ # TTL 60s, purged every 15s by background thread.
28
+ # Model weights: /tmp/u2net_models/*.onnx — AI models, not user data.
29
+
30
+ # ============================================================
31
+ # FORCE IN-MEMORY UPLOAD PARSING (must run before fastapi import)
32
+ # ============================================================
33
+ # Starlette's default UploadFile uses SpooledTemporaryFile with a 1 MB
34
+ # threshold. Uploads over 1 MB spool to /tmp, then are deleted when the
35
+ # request closes. To eliminate that brief disk write, we raise the
36
+ # in-memory ceiling to 200 MB. Uploads are capped at 30 MB (image) and
37
+ # 100 MB (video), so 200 MB is a safe upper bound.
38
+ import starlette.formparsers as _sfp
39
+ _orig_spooled = _sfp.SpooledTemporaryFile
40
+
41
+ def _in_memory_spool(*args, **kwargs):
42
+ kwargs['max_size'] = 200 * 1024 * 1024
43
+ return _orig_spooled(*args, **kwargs)
44
+
45
+ _sfp.SpooledTemporaryFile = _in_memory_spool
46
 
47
+ # ============================================================
48
+ # IMPORTS
49
+ # ============================================================
50
  import io
51
  import time
52
  import logging
53
  import os
 
54
  import asyncio
55
  import functools
56
  import sys
 
62
  import uuid
63
  import base64
64
  import urllib.parse
65
+ import ctypes
66
+ import resource
67
  from collections import defaultdict, Counter, deque, OrderedDict
68
  from datetime import datetime
69
  from pathlib import Path
 
118
 
119
 
120
  # ============================================================
121
+ # MEMORY UTILITIES
122
  # ============================================================
123
+ def trim_memory():
124
+ """Force glibc + PyTorch to release memory back to the OS."""
125
+ try:
126
+ import torch
127
+ if hasattr(torch, 'cuda') and torch.cuda.is_available():
128
+ torch.cuda.empty_cache()
129
+ except Exception:
130
+ pass
131
+ try:
132
+ libc = ctypes.CDLL("libc.so.6")
133
+ libc.malloc_trim(0)
134
+ except Exception:
135
+ pass
136
+
137
+
138
+ def get_process_rss_mb() -> float:
139
+ """Real physical RAM used by this Python process (excludes page cache)."""
140
+ try:
141
+ return round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024, 1)
142
+ except Exception:
143
+ return 0.0
144
+
145
+
146
+ def get_container_limit_mb() -> int:
147
+ """Container memory limit in MB. 0 if unknown."""
148
  try:
149
  if os.path.exists('/sys/fs/cgroup/memory.max'):
150
  with open('/sys/fs/cgroup/memory.max') as f:
151
  limit_str = f.read().strip()
 
 
152
  if limit_str != 'max':
153
+ return int(limit_str) // 1024 // 1024
 
 
 
 
 
 
 
 
 
 
 
154
  if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'):
155
  with open('/sys/fs/cgroup/memory/memory.limit_in_bytes') as f:
156
  limit = int(f.read().strip())
 
 
157
  if 0 < limit < (1 << 60):
158
+ return limit // 1024 // 1024
 
 
 
 
 
 
159
  except Exception:
160
  pass
161
  try:
162
+ return psutil.virtual_memory().total // 1024 // 1024
 
 
 
 
163
  except Exception:
164
+ return 0
 
165
 
166
 
167
+ def get_container_memory() -> Dict:
168
+ """Full memory report: cgroup + RSS + limit."""
169
+ limit_mb = get_container_limit_mb()
170
+ rss_mb = get_process_rss_mb()
171
+
172
+ cgroup_used = 0
173
+ cgroup_source = 'unknown'
174
+ try:
175
+ if os.path.exists('/sys/fs/cgroup/memory.current'):
176
+ with open('/sys/fs/cgroup/memory.current') as f:
177
+ cgroup_used = int(f.read().strip()) // 1024 // 1024
178
+ cgroup_source = 'cgroup-v2'
179
+ elif os.path.exists('/sys/fs/cgroup/memory/memory.usage_in_bytes'):
180
+ with open('/sys/fs/cgroup/memory/memory.usage_in_bytes') as f:
181
+ cgroup_used = int(f.read().strip()) // 1024 // 1024
182
+ cgroup_source = 'cgroup-v1'
183
+ else:
184
+ m = psutil.virtual_memory()
185
+ cgroup_used = m.used // 1024 // 1024
186
+ cgroup_source = 'psutil-host'
187
+ except Exception:
188
+ pass
189
+
190
+ rss_percent = (rss_mb / limit_mb * 100) if limit_mb > 0 else 0
191
+ cgroup_percent = (cgroup_used / limit_mb * 100) if limit_mb > 0 else 0
192
+
193
+ return {
194
+ 'total_mb': limit_mb,
195
+ 'used_mb': cgroup_used,
196
+ 'available_mb': max(0, limit_mb - cgroup_used),
197
+ 'percent': cgroup_percent,
198
+ 'process_rss_mb': rss_mb,
199
+ 'rss_percent': rss_percent,
200
+ 'source': cgroup_source,
201
+ }
202
+
203
+
204
+ # ============================================================
205
+ # CORE CLASSES
206
+ # ============================================================
207
  class ResolutionManager:
208
  RECOMMENDED = {
209
  'simple_graphic': 600, 'human_hair': 1024, 'human': 1024,
 
218
 
219
 
220
  class ModelManager:
221
+ def __init__(self, max_models: int = 4):
222
  self.models: Dict[str, Any] = {}
223
  self.load_times: Dict[str, float] = {}
224
+ self.warmed: Dict[str, bool] = {}
225
  self.use_counts: Dict[str, int] = {}
226
  self.max_models = max_models
227
+ self.protected = {'silueta', 'u2netp', 'isnet-general-use', 'birefnet-general-lite'}
228
 
229
  def get_model(self, name, loader_func):
230
  if name in self.models:
231
  self.use_counts[name] = self.use_counts.get(name, 0) + 1
232
  return self.models[name]
233
+
234
+ if len(self.models) >= self.max_models:
235
+ self._evict_one()
236
+
 
 
 
 
237
  start = time.time()
238
  try:
239
  session = loader_func(name)
240
  if session is None:
241
  return None
242
  self.models[name] = session
243
+ self.warmed[name] = False
244
  self.load_times[name] = time.time() - start
245
  self.use_counts[name] = 1
246
  gc.collect()
247
+ trim_memory()
248
+ mem = get_container_memory()
249
+ logger.info(
250
+ f"Loaded {name} in {self.load_times[name]:.1f}s. "
251
+ f"RSS {mem['process_rss_mb']}MB ({mem['rss_percent']:.1f}%)"
252
+ )
 
 
253
  return session
254
  except Exception as e:
255
  logger.warning(f"Loader failed for {name}: {e}")
256
  gc.collect()
257
  return None
258
 
259
+ def _evict_one(self):
260
+ """Evict LRU non-protected model. Falls back to protected if needed."""
261
+ candidates = [k for k in self.models.keys() if k not in self.protected]
262
+ if not candidates:
263
+ candidates = [k for k in self.models.keys() if k != 'silueta']
264
+ logger.warning(f"No non-protected models. Evicting from protected: {candidates}")
265
+ if not candidates:
266
+ logger.warning("Cannot evict — silueta is the only model left")
267
  return
268
+ to_remove = min(candidates, key=lambda x: self.use_counts.get(x, 0))
269
+ logger.info(f"Evicting {to_remove} (use_count={self.use_counts.get(to_remove, 0)})")
270
  del self.models[to_remove]
271
  self.use_counts.pop(to_remove, None)
272
+ self.warmed.pop(to_remove, None)
273
  gc.collect()
274
+ trim_memory()
275
+
276
+ def mark_warmed(self, name):
277
+ self.warmed[name] = True
278
 
279
  def get_stats(self):
280
+ return {
281
+ 'loaded_models': list(self.models.keys()),
282
+ 'model_count': len(self.models),
283
+ 'use_counts': self.use_counts,
284
+ 'load_times': self.load_times,
285
+ 'warmed': dict(self.warmed),
286
+ 'protected': list(self.protected),
287
+ }
288
 
289
 
290
  class MaskQualityChecker:
 
509
  return 'complex'
510
 
511
 
512
+ # ============================================================
513
+ # ENVIRONMENT
514
+ # ============================================================
515
  os.environ["U2NET_HOME"] = "/tmp/u2net_models"
516
  os.environ["OMP_NUM_THREADS"] = "2"
517
  os.environ["CUDA_VISIBLE_DEVICES"] = ""
 
519
  os.environ["PYTHONUNBUFFERED"] = "1"
520
  socket.setdefaulttimeout(60)
521
 
522
+ for d in ["/tmp/u2net_models"]:
523
  os.makedirs(d, exist_ok=True)
524
 
525
  cpu_executor = ThreadPoolExecutor(max_workers=2)
526
 
527
  content_classifier = ContentClassifier()
528
  resolution_manager = ResolutionManager()
529
+ model_manager = ModelManager(max_models=4)
530
  mask_checker = MaskQualityChecker()
531
  hair_refiner = HairRefiner()
532
  graphic_processor = GraphicProcessor()
533
 
534
 
535
  # ============================================================
536
+ # ALPHA CACHE — video temporal smoothing
537
  # ============================================================
538
  ALPHA_SMOOTH_WEIGHT = 0.35
539
  ALPHA_CACHE_MAX_SESSIONS = 8
540
+ ALPHA_CACHE_TTL_SECONDS = 60 # was 300. 60s covers batch gaps.
541
  ALPHA_CACHE_MAX_DIM = 1280
542
+ ALPHA_PURGE_INTERVAL = 15
543
 
544
  _alpha_cache: "OrderedDict[str, tuple]" = OrderedDict()
545
  _alpha_cache_lock = threading.Lock()
 
596
  }
597
 
598
 
599
+ def _purge_expired_sessions() -> int:
600
+ """Remove cache entries past their TTL. Called by background thread."""
601
+ now = time.time()
602
+ with _alpha_cache_lock:
603
+ expired = [
604
+ sid for sid, (_, ts) in _alpha_cache.items()
605
+ if now - ts > ALPHA_CACHE_TTL_SECONDS
606
+ ]
607
+ for sid in expired:
608
+ _alpha_cache.pop(sid, None)
609
+ return len(expired)
610
+
611
+
612
+ def _start_cache_purge_thread():
613
+ """Background thread: even if the client crashes without calling
614
+ /api/frames/session/reset, expired entries get cleaned."""
615
+ def _loop():
616
+ while True:
617
+ time.sleep(ALPHA_PURGE_INTERVAL)
618
+ try:
619
+ purged = _purge_expired_sessions()
620
+ if purged:
621
+ logger.info(f"[alpha-cache] purged {purged} expired sessions")
622
+ except Exception as e:
623
+ logger.warning(f"[alpha-cache] purge failed: {e}")
624
+
625
+ t = threading.Thread(target=_loop, daemon=True, name="alpha-cache-purge")
626
+ t.start()
627
+ return t
628
+
629
+
630
  # ============================================================
631
+ # VIDEO CONFIG
632
  # ============================================================
633
  _video_semaphore = threading.Semaphore(1)
634
+ VIDEO_RAM_REJECT_PERCENT = 90.0 # uses process RSS, not cgroup
635
  VIDEO_MAX_BATCH = 12
 
636
  VIDEO_FORCE_MODEL = os.environ.get('VIDEO_FORCE_MODEL', 'silueta')
637
  BIREFNET_MAX_DIM = 512
638
 
639
 
640
  # ============================================================
641
+ # MODEL CONFIG — 4 models, all preloaded
642
  # ============================================================
643
  PRELOAD_MODELS = [
644
+ ("silueta", "~400 MB — video only"),
645
+ ("u2netp", "~300 MB — easy/flat"),
646
+ ("isnet-general-use", "~800 MB — human subjects"),
647
+ ("birefnet-general-lite", "~2 GB — complex non-human"),
648
  ]
649
 
650
+ # Warmup resolution per model. Matches inference res so buffers are
651
+ # correctly sized on first real request.
652
+ WARMUP_RESOLUTIONS = {
653
+ 'silueta': (640, 640),
654
+ 'u2netp': (512, 512),
655
+ 'isnet-general-use': (640, 640),
656
+ 'birefnet-general-lite': (512, 512),
657
+ }
658
+
659
  LAZY_MODELS = {
660
+ "u2net": "~176 MB disk / ~800 MB RAM",
661
+ "u2net_human_seg": "~176 MB disk / ~800 MB RAM",
662
+ "isnet-anime": "~176 MB disk / ~800 MB RAM",
663
+ "birefnet-general": "~900 MB disk / ~2.5 GB RAM",
664
+ "u2net_cloth_seg": "~176 MB disk / ~800 MB RAM",
665
+ "sam": "~375 MB disk / ~1 GB RAM",
 
666
  }
667
 
668
  VALID_REMBG_MODELS = {
 
717
  FRAME_MAX_DIM = 640
718
 
719
  CLASSICAL_PROCESSORS = {'flood_fill', 'graphic_classical'}
720
+ DEFAULT_MODEL_NAME = 'u2netp'
721
  DEFAULT_VIDEO_MODEL = VIDEO_FORCE_MODEL
722
 
723
  DISPLAY_NAMES = {
 
740
  from rembg import new_session
741
  resolved = MODEL_ALIASES.get(name, name)
742
  if resolved not in VALID_REMBG_MODELS:
743
+ logger.warning(f"'{resolved}' not valid. Using u2netp.")
744
+ resolved = 'u2netp'
 
 
745
  logger.info(f"Loading {resolved}...")
746
  return new_session(resolved)
747
 
 
939
  logger.warning(f"pymatting not available: {e}")
940
 
941
 
942
+ def preload_all_models():
943
  logger.info(f"Preloading {len(PRELOAD_MODELS)} models...")
944
+ for name, desc in PRELOAD_MODELS:
945
  try:
946
+ logger.info(f" {name} ({desc})")
947
+ t0 = time.time()
948
  session = model_manager.get_model(name, _load_session)
949
  if session is not None:
950
+ logger.info(f" {name} ready in {time.time() - t0:.1f}s")
951
+ else:
952
+ logger.warning(f" {name} failed")
953
  except Exception as e:
954
  logger.warning(f" {name}: {e}")
955
+ mem = get_container_memory()
956
+ logger.info(
957
+ f"Preload done. {len(model_manager.models)} models. "
958
+ f"RSS {mem['process_rss_mb']}MB ({mem['rss_percent']:.1f}%)"
959
+ )
960
 
961
 
962
  def initialize_models():
 
966
  from rembg import remove
967
  remove_func = remove
968
  loaded = model_manager.get_stats().get("loaded_models", [])
969
+ if 'u2netp' in loaded:
970
+ default_model_name = 'u2netp'
971
+ elif loaded:
972
+ default_model_name = loaded[0]
 
 
 
 
 
973
  AI_AVAILABLE = len(loaded) > 0
974
  if AI_AVAILABLE:
975
+ logger.info(f"AI ready with {len(loaded)} models: {loaded}")
976
  except Exception as e:
977
  logger.error(f"AI setup failed: {e}")
978
  AI_AVAILABLE = False
979
 
980
 
981
  def _prewarm_models():
982
+ """Warm each loaded model with a dummy inference at inference res.
983
+ This sizes ONNX Runtime's internal buffers so the first real request
984
+ doesn't pay cold-start penalty. Especially important for birefnet."""
985
  global remove_func
986
  if remove_func is None:
987
+ logger.warning("Cannot pre-warm: remove_func not available")
988
  return
989
+
990
+ logger.info("Pre-warming all models...")
991
+
992
+ for name, _desc in PRELOAD_MODELS:
993
+ session = model_manager.models.get(name)
994
+ if session is None:
995
+ logger.warning(f" {name}: not loaded, skip warmup")
996
+ continue
997
+
998
+ size = WARMUP_RESOLUTIONS.get(name, (512, 512))
999
+
1000
+ try:
1001
+ img = Image.new('RGB', size, (245, 245, 245))
1002
+ draw = ImageDraw.Draw(img)
1003
+ mx, my = size[0] // 4, size[1] // 4
1004
+ draw.ellipse([mx, my, size[0] - mx, size[1] - my], fill=(180, 60, 60))
1005
+
1006
+ t0 = time.time()
1007
+ result = remove_func(img, session=session, alpha_matting=False)
1008
+ elapsed = time.time() - t0
1009
+
1010
+ if result is None:
1011
+ logger.warning(f" {name}: warmup returned None")
1012
  continue
1013
+ if result.mode != 'RGBA':
1014
+ result = result.convert('RGBA')
1015
+
1016
+ arr = np.array(result)
1017
+ alpha = arr[:, :, 3]
1018
+ min_a, max_a = int(alpha.min()), int(alpha.max())
1019
+
1020
+ if min_a < 100 and max_a > 150:
1021
+ model_manager.mark_warmed(name)
1022
+ logger.info(f" {name}: warm ({elapsed:.2f}s @ {size[0]}×{size[1]})")
1023
+ else:
1024
+ logger.warning(f" {name}: warm but odd (alpha {min_a}-{max_a})")
1025
+
1026
+ del result, arr, alpha
1027
+ except Exception as e:
1028
+ logger.warning(f" {name}: warmup failed — {type(e).__name__}: {e}")
1029
+
1030
  gc.collect()
1031
+ trim_memory()
1032
+
1033
+ mem = get_container_memory()
1034
+ warmed = sum(1 for w in model_manager.warmed.values() if w)
1035
+ logger.info(
1036
+ f"Warmup done: {warmed}/{len(PRELOAD_MODELS)} warm. "
1037
+ f"RSS {mem['process_rss_mb']}MB ({mem['rss_percent']:.1f}%)"
1038
+ )
1039
 
1040
 
1041
  logger.info("=" * 60)
1042
+ logger.info("Background Remover v32.5.0")
1043
+ logger.info("4 models preloaded and pre-warmed")
1044
  logger.info("=" * 60)
1045
+
1046
+ preload_all_models()
1047
  initialize_models()
1048
  _prewarm_models()
1049
+ _start_cache_purge_thread()
1050
+
1051
  mem = get_container_memory()
1052
  logger.info(f"AI: {AI_AVAILABLE} | Default: {default_model_name}")
1053
+ logger.info(
1054
+ f"RSS: {mem['process_rss_mb']}MB / {mem['total_mb']}MB "
1055
+ f"({mem['rss_percent']:.1f}%)"
1056
+ )
1057
+ logger.info(f"Video: FORCE={VIDEO_FORCE_MODEL}, MAX_BATCH={VIDEO_MAX_BATCH}")
1058
+ logger.info(f"Video RAM reject: {VIDEO_RAM_REJECT_PERCENT}% (process RSS)")
1059
+ logger.info(f"Alpha cache: TTL {ALPHA_CACHE_TTL_SECONDS}s, purge every {ALPHA_PURGE_INTERVAL}s")
1060
  logger.info("=" * 60)
1061
 
1062
 
 
1070
  if session is None:
1071
  return None
1072
  model_manager.models[resolved] = session
1073
+ model_manager.warmed[resolved] = False
1074
  model_manager.use_counts[resolved] = 1
1075
  model_manager.load_times[resolved] = 0.0
1076
  if len(model_manager.models) > model_manager.max_models:
1077
+ model_manager._evict_one()
1078
  return session
1079
  except Exception as e:
1080
  logger.error(f"Lazy-load failed for {resolved}: {e}")
 
1082
 
1083
 
1084
  # ============================================================
1085
+ # ROUTER — flat → u2netp, human → isnet, small → u2netp,
1086
+ # complex → birefnet
1087
  # ============================================================
 
 
 
 
 
 
 
 
1088
  def detect_hair_complexity(image):
1089
  try:
1090
  gray = image.convert('L').resize((100, 100), Image.Resampling.LANCZOS)
 
1105
 
1106
 
1107
  def _skin_score(rgb):
 
1108
  if rgb is None or rgb.size == 0:
1109
  return 0.0
1110
  r = rgb[:, :, 0].astype(np.int32)
 
1120
 
1121
 
1122
  def analyze_image_complexity(image):
1123
+ """Priority router.
1124
+ TIER 0 flat → u2netp
1125
+ TIER 1 human → isnet-general-use
1126
+ TIER 2 small → u2netp
1127
+ TIER 3 complex → birefnet-general-lite
1128
+ TIER 4 fallback → u2netp
 
 
 
 
 
 
 
 
1129
  """
1130
  try:
1131
  cc = content_classifier.classify(image)
 
1132
  sig = cc.get('signals', {}) or {}
1133
+ cc_cat = cc.get('category', 'general_photo')
1134
  cc_color = float(sig.get('color_complexity', 0.5))
1135
  cc_border = float(sig.get('border_uniformity', 0))
1136
  cc_edge = float(sig.get('edge_density', 0))
1137
  cc_skin = float(sig.get('skin_score', 0))
1138
  cc_texture = float(sig.get('texture', 0))
1139
 
 
 
 
 
 
 
 
 
 
 
1140
  def _route(proc, cat, strat, reason, extra=None):
1141
  r = {
1142
+ 'processor': proc, 'model': proc, 'model_id': proc,
1143
+ 'category': cat, 'strategy': strat, 'reason': reason,
1144
+ 'score': 0, 'is_human': False,
1145
+ 'is_graphic': cat in ('flat', 'simple_graphic', 'logo_icon'),
 
 
 
 
 
1146
  'hair_complexity': {'route': 'simple_hair'},
1147
+ 'cc_category': cc_cat, 'cc_confidence': 0.7,
 
1148
  }
1149
  if extra:
1150
  r.update(extra)
1151
  return r
1152
 
1153
+ # TIER 0: flat → u2netp
 
 
 
1154
  is_flat = (
1155
  cc_skin < 0.05
 
1156
  and (
1157
+ cc_color < 0.15
1158
  or cc_cat in ('simple_graphic', 'logo_icon')
1159
+ or (cc_color < 0.3 and cc_border > 0.7)
1160
  )
1161
  )
1162
  if is_flat:
1163
+ return _route(
1164
+ 'u2netp', 'flat', 'neural',
1165
+ f'flat (color={cc_color:.2f}, border={cc_border:.2f}, cat={cc_cat})'
1166
+ )
1167
+
1168
+ # TIER 1: human → isnet
1169
+ if cc_skin > 0.05 or cc_cat in ('human', 'human_hair'):
 
1170
  hc = detect_hair_complexity(image)
1171
+ return _route(
1172
+ 'isnet-general-use',
1173
+ 'human_hair' if hc['route'] != 'simple_hair' else 'human',
1174
+ 'neural',
1175
+ f'human (skin={cc_skin:.2f}, hair={hc["route"]})',
1176
+ {'is_human': True, 'hair_complexity': hc}
1177
+ )
1178
+
1179
+ # TIER 2: small → u2netp
1180
  if max(image.size) < 400:
1181
+ return _route(
1182
+ 'u2netp', 'small', 'neural',
1183
+ f'small ({max(image.size)}px)'
1184
+ )
1185
 
1186
+ # TIER 3: complex non-human → birefnet
1187
+ if cc_edge > 0.05 or cc_color > 0.35 or cc_texture > 0.5:
1188
+ return _route(
1189
+ 'birefnet-general-lite', 'complex', 'neural',
1190
+ f'complex (edge={cc_edge:.2f}, color={cc_color:.2f}, texture={cc_texture:.2f})'
1191
+ )
1192
 
1193
+ # TIER 4: fallback
1194
  return _route('u2netp', 'general', 'neural', 'fallback to u2netp')
1195
 
1196
  except Exception as e:
1197
  logger.warning(f"Router failed: {e}")
 
1198
  return {
1199
+ 'processor': 'u2netp', 'model': 'u2netp', 'model_id': 'u2netp',
1200
  'category': 'error', 'strategy': 'neural',
1201
  'reason': f'router-error: {e}', 'score': 0,
1202
  'is_human': False, 'is_graphic': False,
 
1207
 
1208
  def get_default_model():
1209
  loaded = model_manager.get_stats().get('loaded_models', [])
1210
+ for m in ('u2netp', 'isnet-general-use', 'silueta'):
1211
  if m in loaded:
1212
  return m
1213
  return loaded[0] if loaded else 'u2netp'
 
1307
 
1308
  if result is None:
1309
  tried = set()
1310
+ for candidate in [selected, 'u2netp', 'isnet-general-use', 'birefnet-general-lite', 'silueta']:
1311
  if candidate in tried or candidate in CLASSICAL_PROCESSORS:
1312
  continue
1313
  tried.add(candidate)
 
1390
 
1391
 
1392
  # ============================================================
1393
+ # VIDEO FRAMES
1394
  # ============================================================
1395
  def _process_frames_sync(raw_frames, resolved_model, transparent,
1396
  session_id: Optional[str] = None,
1397
  smooth: bool = True):
1398
  fallback_chain = [resolved_model]
1399
+ for candidate in ('silueta', 'u2netp', 'isnet-general-use'):
1400
  if candidate not in fallback_chain:
1401
  fallback_chain.append(candidate)
1402
 
 
1436
  max_a = int(alpha.max())
1437
 
1438
  if max_a < 10:
1439
+ logger.warning(f"[frames-batch] {candidate} blank (frame {idx})")
 
1440
  continue
1441
  if min_a > 245:
1442
+ logger.warning(f"[frames-batch] {candidate} no-op (frame {idx})")
 
1443
  continue
1444
 
1445
  result = res
 
1473
 
1474
  buf = io.BytesIO()
1475
  if transparent:
 
1476
  result.save(buf, format="PNG", compress_level=3)
1477
  fmt = "png"
1478
  else:
 
1479
  if result.mode == "RGBA":
1480
  bg = Image.new("RGBA", result.size, (0, 0, 0, 255))
1481
  result = Image.alpha_composite(bg, result).convert("RGB")
 
1503
  # ============================================================
1504
  # FASTAPI
1505
  # ============================================================
1506
+ app = FastAPI(title="Background Remover", version="32.5.0")
1507
  app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
1508
  allow_methods=["*"], allow_headers=["*"])
1509
 
 
1522
  @app.middleware("http")
1523
  async def rate_limit_middleware(request, call_next):
1524
  if request.url.path in ("/api/health", "/", "/docs", "/redoc", "/openapi.json",
1525
+ "/api/warmup", "/api/debug/env", "/api/debug/memory",
1526
+ "/api/debug/tmp",
1527
  "/api/process/frames-batch",
1528
  "/api/frames/session/reset",
1529
  "/api/frames/session/stats"):
 
1640
  if resolved in VALID_REMBG_MODELS or resolved in CLASSICAL_PROCESSORS:
1641
  user_model = resolved
1642
 
1643
+ # Build zip entirely in memory. No tempdir. No disk write.
1644
+ zb = io.BytesIO()
 
1645
  used_names = set()
1646
+ scores = []
1647
+ count = 0
1648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1649
  with zipfile.ZipFile(zb, 'w', zipfile.ZIP_DEFLATED) as zf:
1650
+ for f in files:
1651
+ try:
1652
+ c = await f.read()
1653
+ if not c: continue
1654
+ d = process_image_sync(
1655
+ c, model_name=user_model, transparent=transparent,
1656
+ max_size=max_size, quality=95,
1657
+ preserve_hair=preserve_hair,
1658
+ use_alpha_matting=use_alpha_matting,
1659
+ border_pixels=border_pixels, sharpen=sharpen,
1660
+ )
1661
+ scores.append(d['quality_score'])
1662
+ stem = Path(f.filename).stem or "image"
1663
+ out_stem = f"{stem}_remova"
1664
+ if out_stem in used_names:
1665
+ n = 2
1666
+ while f"{out_stem}_{n}" in used_names: n += 1
1667
+ out_stem = f"{out_stem}_{n}"
1668
+ used_names.add(out_stem)
1669
+ out_name = f"{out_stem}.{d['extension']}"
1670
+ zf.writestr(out_name, d['data'])
1671
+ count += 1
1672
+ except Exception as e:
1673
+ logger.error(f"Failed {f.filename}: {e}")
1674
+
1675
+ if count == 0: raise HTTPException(500, "No images processed")
1676
 
1677
+ zb.seek(0)
1678
  pt = time.time() - t0
1679
  avg = sum(scores) / len(scores) if scores else 0
1680
  headers = {
1681
  'X-Processing-Time': f'{pt:.3f}',
1682
  'X-Model-Used': 'smart_routing',
1683
+ 'X-Image-Count': str(count),
1684
  'X-Avg-Quality-Score': f'{avg:.1f}',
1685
  'Content-Disposition': f'attachment; filename=processed_images_{int(time.time())}.zip',
1686
  }
 
1692
  raise HTTPException(500, str(e)[:200])
1693
 
1694
 
 
 
 
1695
  @app.post("/api/process/frames-batch")
1696
  async def process_frames_batch(
1697
  files: List[UploadFile] = File(...),
 
1703
  if len(files) > VIDEO_MAX_BATCH:
1704
  raise HTTPException(400, f"Max {VIDEO_MAX_BATCH} frames per batch")
1705
 
1706
+ # RSS-based check — cgroup includes page cache
 
1707
  mem = get_container_memory()
1708
+ if mem['rss_percent'] > VIDEO_RAM_REJECT_PERCENT:
1709
  logger.warning(
1710
+ f"[frames-batch] RSS {mem['rss_percent']:.1f}% > "
1711
+ f"{VIDEO_RAM_REJECT_PERCENT}%"
1712
  )
1713
  return JSONResponse(
1714
  status_code=503,
1715
  content={
1716
  'error': 'Server memory high. Retry shortly.',
1717
+ 'rss_percent': round(mem['rss_percent'], 1),
1718
+ 'rss_mb': mem['process_rss_mb'],
1719
+ 'container_percent': round(mem['percent'], 1),
1720
  'retry_in': 10,
1721
  'cold': True,
1722
  },
 
1736
  def _worker():
1737
  with _video_semaphore:
1738
  return _process_frames_sync(
1739
+ raw_frames, VIDEO_FORCE_MODEL, transparent, session_id, smooth,
1740
  )
1741
 
1742
  try:
 
1746
  raise HTTPException(500, f"Frame processing failed: {str(e)[:200]}")
1747
  finally:
1748
  gc.collect()
1749
+ trim_memory()
1750
 
1751
+ used = next((r.get("model") for r in results if r.get("model")), VIDEO_FORCE_MODEL)
1752
  smoothed_count = sum(1 for r in results if r.get("smoothed"))
1753
  return {
1754
  "frames": results,
 
1843
  return {"status": "warm", "ts": datetime.now().isoformat()}
1844
 
1845
 
1846
+ @app.get("/api/debug/memory")
1847
+ async def debug_memory():
1848
+ mem = get_container_memory()
1849
+ stats = model_manager.get_stats()
1850
+ return {
1851
+ 'container': mem,
1852
+ 'process_rss_mb': mem['process_rss_mb'],
1853
+ 'rss_percent': round(mem['rss_percent'], 1),
1854
+ 'models_loaded': stats['loaded_models'],
1855
+ 'models_count': stats['model_count'],
1856
+ 'models_warmed': stats['warmed'],
1857
+ 'use_counts': stats['use_counts'],
1858
+ 'protected': stats['protected'],
1859
+ 'alpha_cache': _alpha_cache_stats(),
1860
+ 'video_reject_pct': VIDEO_RAM_REJECT_PERCENT,
1861
+ 'torch_imported': 'torch' in sys.modules,
1862
+ }
1863
+
1864
+
1865
+ @app.get("/api/debug/tmp")
1866
+ async def debug_tmp():
1867
+ """List files in /tmp for privacy audit. Shows what's on disk.
1868
+ Should show only /tmp/u2net_models/*.onnx after normal use.
1869
+ Remove in production."""
1870
+ items = []
1871
+ try:
1872
+ for root, dirs, files in os.walk('/tmp'):
1873
+ for f in files:
1874
+ p = os.path.join(root, f)
1875
+ try:
1876
+ items.append({
1877
+ 'path': p,
1878
+ 'size_mb': round(os.path.getsize(p) / 1048576, 2),
1879
+ 'mtime': os.path.getmtime(p),
1880
+ })
1881
+ except Exception:
1882
+ pass
1883
+ except Exception:
1884
+ pass
1885
+ return {
1886
+ 'count': len(items),
1887
+ 'total_mb': round(sum(i['size_mb'] for i in items), 2),
1888
+ 'items': sorted(items, key=lambda x: -x['size_mb'])[:50],
1889
+ }
1890
+
1891
+
1892
  @app.get("/api/debug/env")
1893
  async def debug_env():
1894
+ mem = get_container_memory()
1895
+ return {
1896
+ 'total_mb': mem['total_mb'],
1897
+ 'used_mb': mem['used_mb'],
1898
+ 'available_mb': mem['available_mb'],
1899
+ 'container_percent': round(mem['percent'], 1),
1900
+ 'process_rss_mb': mem['process_rss_mb'],
1901
+ 'rss_percent': round(mem['rss_percent'], 1),
1902
+ 'memory_source': mem['source'],
1903
+ 'models_loaded': list(model_manager.models.keys()),
1904
+ 'models_warmed': dict(model_manager.warmed),
1905
+ 'remove_func_ready': remove_func is not None,
1906
+ 'ai_available': AI_AVAILABLE,
1907
+ 'default_model': default_model_name,
1908
+ 'video_force_model': VIDEO_FORCE_MODEL,
1909
+ 'video_ram_reject_percent': VIDEO_RAM_REJECT_PERCENT,
1910
+ 'video_max_batch': VIDEO_MAX_BATCH,
1911
+ 'preload_models': [n for n, _ in PRELOAD_MODELS],
1912
+ 'lazy_models': sorted(LAZY_MODELS.keys()),
1913
+ 'upload_parser': 'in-memory (SpooledTemporaryFile patched to 200MB)',
1914
+ }
1915
 
1916
 
1917
  @app.get("/")
1918
  async def home():
1919
  stats = model_manager.get_stats()
1920
  mem = get_container_memory()
1921
+ warmed = sum(1 for w in stats['warmed'].values() if w)
1922
  return HTMLResponse(f"""
1923
  <!DOCTYPE html><html><head><meta charset="UTF-8">
1924
+ <title>Background Remover v32.5.0</title>
1925
  <style>body{{font-family:system-ui;background:#0A0A0A;
1926
  color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;
1927
  margin:0;padding:20px}}.c{{max-width:900px;background:#111;
 
1929
  border:1px solid #222}}.s{{padding:15px;border-radius:12px;
1930
  margin:20px 0;background:#1a1a1a}}a{{color:#7ED957}}</style>
1931
  </head><body><div class="c">
1932
+ <h1>Background Remover v32.5.0</h1>
1933
+ <div class="s">AI online. {len(stats['loaded_models'])} models ({warmed} warmed).
1934
+ RSS {mem['rss_percent']:.0f}% ({mem['process_rss_mb']}/{mem['total_mb']}MB)</div>
1935
+ <p>Router: flat→u2netp · human→isnet · small→u2netp · complex→birefnet</p>
1936
+ <p>Video: silueta · max {VIDEO_MAX_BATCH} frames/batch · reject at {VIDEO_RAM_REJECT_PERCENT}%</p>
1937
+ <p>Cache: alpha TTL {ALPHA_CACHE_TTL_SECONDS}s · purge every {ALPHA_PURGE_INTERVAL}s</p>
1938
+ <p>Privacy: no user data on disk. Bulk in-memory zip. Uploads never touch /tmp.</p>
1939
+ <p><a href="/docs">API Docs</a> · <a href="/api/debug/memory">Memory</a> · <a href="/api/debug/tmp">Disk Audit</a></p>
1940
  </div></body></html>
1941
  """)
1942
 
 
1945
  async def health_check():
1946
  stats = model_manager.get_stats()
1947
  mem = get_container_memory()
1948
+
1949
+ preload_names = [n for n, _ in PRELOAD_MODELS]
1950
+ all_loaded = all(n in stats['loaded_models'] for n in preload_names)
1951
+ all_warmed = all(stats['warmed'].get(n, False) for n in preload_names)
1952
+
1953
+ ram_ok = mem['rss_percent'] < (VIDEO_RAM_REJECT_PERCENT - 5)
1954
+
1955
+ if not AI_AVAILABLE:
1956
+ status = 'error'
1957
+ elif not all_loaded:
1958
+ status = 'degraded'
1959
+ elif not all_warmed:
1960
+ status = 'warming'
1961
+ elif not ram_ok:
1962
+ status = 'degraded'
1963
+ else:
1964
+ status = 'healthy'
1965
+
1966
  return {
1967
+ 'status': status,
1968
  'ai_available': AI_AVAILABLE,
1969
  'models_loaded': stats['loaded_models'],
1970
  'models_count': stats['model_count'],
1971
+ 'models_warmed': stats['warmed'],
1972
+ 'all_loaded': all_loaded,
1973
+ 'all_warmed': all_warmed,
1974
+ 'protected_models': stats['protected'],
1975
+ 'rss_percent': round(mem['rss_percent'], 1),
1976
+ 'rss_mb': mem['process_rss_mb'],
1977
+ 'container_percent': round(mem['percent'], 1),
1978
  'memory_total_mb': mem['total_mb'],
1979
  'memory_source': mem['source'],
 
 
 
 
 
1980
  'video_ram_reject_percent': VIDEO_RAM_REJECT_PERCENT,
1981
  'video_max_batch': VIDEO_MAX_BATCH,
1982
+ 'video_force_model': VIDEO_FORCE_MODEL,
1983
+ 'version': '32.5.0',
1984
+ 'preload_models': preload_names,
1985
  'lazy_models': sorted(LAZY_MODELS.keys()),
1986
+ 'alpha_cache_ttl': ALPHA_CACHE_TTL_SECONDS,
1987
+ 'alpha_purge_interval': ALPHA_PURGE_INTERVAL,
 
 
 
 
1988
  'alpha_smoothing': _alpha_cache_stats(),
1989
  }
1990
 
 
2005
  @app.on_event("startup")
2006
  async def startup():
2007
  logger.info("=" * 60)
2008
+ logger.info("v32.5.0 ready")
2009
+ logger.info("Preload: silueta + u2netp + isnet + birefnet (all 4, all warm)")
2010
+ logger.info(f"Video: RSS reject at {VIDEO_RAM_REJECT_PERCENT}%")
2011
+ logger.info(f"Alpha cache: {ALPHA_CACHE_TTL_SECONDS}s TTL, {ALPHA_PURGE_INTERVAL}s purge")
2012
+ logger.info("Privacy: bulk in-memory, uploads in-memory, no disk writes")
2013
  logger.info("=" * 60)
2014
 
2015
 
2016
  @app.on_event("shutdown")
2017
  async def shutdown():
2018
  cpu_executor.shutdown(wait=True)
 
 
 
 
2019
 
2020
 
2021
  if __name__ == '__main__':
2022
  import uvicorn
2023
  uvicorn.run('app:app', host='0.0.0.0',
2024
  port=int(os.environ.get('PORT', 7860)),
2025
+ timeout_keep_alive=300, log_level='info', reload=False)
2026
+