3636
3737import io
3838import os
39+ import gc
3940import glob
4041import json
4142import time
@@ -177,6 +178,12 @@ def _pkg_version() -> str:
177178MAX_UPLOAD_MB = int (os .environ .get ("MAX_UPLOAD_MB" , "30" ))
178179MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
179180
181+ # How long an idle (unused) model session stays in RAM before being evicted.
182+ # Set to 0 to disable the background evictor entirely.
183+ MODEL_IDLE_TTL = int (os .environ .get ("RBL_MODEL_IDLE_TTL" , "600" ))
184+ # How often the evictor wakes up to check for idle models.
185+ MODEL_EVICTOR_INTERVAL = int (os .environ .get ("RBL_MODEL_EVICTOR_INTERVAL" , "30" ))
186+
180187# Execution providers for onnxruntime. CPU is the reliable default (CoreML hangs
181188# on some models on Apple Silicon). Override with REMBG_PROVIDERS if you want to
182189# experiment, e.g. "CoreMLExecutionProvider,CPUExecutionProvider".
@@ -225,13 +232,68 @@ def download_progress(name: str):
225232_SESSIONS : dict [str , object ] = {}
226233_MODEL_STATE : dict [str , str ] = {} # name -> "loading" | "ready" | "error"
227234_MODEL_ERROR : dict [str , str ] = {}
235+ _LAST_USED : dict [str , float ] = {} # name -> monotonic timestamp of last touch
228236_LOAD_LOCKS : dict [str , asyncio .Lock ] = {}
237+ # The "pinned" model is the one the UI currently shows as the active default.
238+ # It is exempt from idle-TTL eviction. /set_default_model rotates this; an
239+ # explicit per-image override via /remove?transient=true does NOT.
240+ _PINNED_MODEL : str = DEFAULT_MODEL
229241# Serializes inference (processing queue). Created lazily inside the running
230242# event loop: on Python 3.9 an asyncio.Lock() built at import time binds to the
231243# wrong loop and raises "got Future attached to a different loop" under uvicorn.
232244_INFER_LOCK : asyncio .Lock | None = None
233245
234246
247+ def _touch (model_name : str ) -> None :
248+ _LAST_USED [model_name ] = time .monotonic ()
249+
250+
251+ def _evict (model_name : str ) -> bool :
252+ """Drop a loaded session from RAM. Does NOT touch the on-disk .onnx file."""
253+ if model_name not in _SESSIONS :
254+ return False
255+ _SESSIONS .pop (model_name , None )
256+ _LAST_USED .pop (model_name , None )
257+ _MODEL_STATE .pop (model_name , None )
258+ _MODEL_ERROR .pop (model_name , None )
259+ gc .collect ()
260+ log .info ("Unloaded model %s from RAM" , model_name )
261+ return True
262+
263+
264+ def _evictor_sweep () -> list [str ]:
265+ """One pass of the idle-TTL eviction logic. Returns the names evicted.
266+
267+ Pure (no awaits) so it can be unit-tested without driving the event loop.
268+ """
269+ if MODEL_IDLE_TTL <= 0 :
270+ return []
271+ now = time .monotonic ()
272+ evicted : list [str ] = []
273+ for name in [n for n in list (_SESSIONS ) if n != _PINNED_MODEL ]:
274+ last = _LAST_USED .get (name , now )
275+ if now - last > MODEL_IDLE_TTL and _evict (name ):
276+ evicted .append (name )
277+ return evicted
278+
279+
280+ async def _evictor_loop () -> None :
281+ """Background task: drops loaded models that have been idle past the TTL.
282+
283+ The pinned model is never evicted by the timer — it's the one the user is
284+ actively working with. Everything else (overrides, leftovers from a model
285+ switch) is fair game.
286+ """
287+ while True :
288+ try :
289+ await asyncio .sleep (MODEL_EVICTOR_INTERVAL )
290+ _evictor_sweep ()
291+ except asyncio .CancelledError :
292+ raise
293+ except Exception : # noqa: BLE001
294+ log .exception ("Model evictor loop error (continuing)" )
295+
296+
235297def get_infer_lock () -> asyncio .Lock :
236298 global _INFER_LOCK
237299 if _INFER_LOCK is None :
@@ -262,11 +324,13 @@ async def ensure_session(model_name: str):
262324 """
263325 _check_model (model_name )
264326 if model_name in _SESSIONS :
327+ _touch (model_name )
265328 return _SESSIONS [model_name ]
266329
267330 lock = _LOAD_LOCKS .setdefault (model_name , asyncio .Lock ())
268331 async with lock :
269332 if model_name in _SESSIONS :
333+ _touch (model_name )
270334 return _SESSIONS [model_name ]
271335 _MODEL_STATE [model_name ] = "loading"
272336 _MODEL_ERROR .pop (model_name , None )
@@ -282,6 +346,7 @@ async def ensure_session(model_name: str):
282346 raise
283347 _SESSIONS [model_name ] = session
284348 _MODEL_STATE [model_name ] = "ready"
349+ _touch (model_name )
285350 log .info ("Model %s ready in %.1fs" , model_name , time .time () - t0 )
286351 return session
287352
@@ -292,6 +357,32 @@ async def ensure_session(model_name: str):
292357
293358app = FastAPI (title = "remove-background-local" , version = APP_VERSION )
294359
360+ _EVICTOR_TASK : asyncio .Task | None = None
361+
362+
363+ @app .on_event ("startup" )
364+ async def _start_evictor () -> None :
365+ global _EVICTOR_TASK
366+ if MODEL_IDLE_TTL > 0 and _EVICTOR_TASK is None :
367+ _EVICTOR_TASK = asyncio .create_task (_evictor_loop ())
368+ log .info (
369+ "Model evictor running: idle TTL %ds, check every %ds (pinned=%s)" ,
370+ MODEL_IDLE_TTL , MODEL_EVICTOR_INTERVAL , _PINNED_MODEL ,
371+ )
372+
373+
374+ @app .on_event ("shutdown" )
375+ async def _stop_evictor () -> None :
376+ global _EVICTOR_TASK
377+ if _EVICTOR_TASK is not None :
378+ _EVICTOR_TASK .cancel ()
379+ try :
380+ await _EVICTOR_TASK
381+ except (asyncio .CancelledError , Exception ): # noqa: BLE001
382+ pass
383+ _EVICTOR_TASK = None
384+
385+
295386if STATIC_DIR .exists ():
296387 app .mount ("/static" , StaticFiles (directory = str (STATIC_DIR )), name = "static" )
297388
@@ -311,7 +402,9 @@ async def health():
311402 "ok" : True ,
312403 "version" : APP_VERSION ,
313404 "default_model" : DEFAULT_MODEL ,
405+ "pinned_model" : _PINNED_MODEL ,
314406 "loaded_models" : list (_SESSIONS .keys ()),
407+ "idle_ttl_seconds" : MODEL_IDLE_TTL ,
315408 }
316409
317410
@@ -364,11 +457,55 @@ async def _bg():
364457 return {"model" : model , "state" : state_of (model ), "size_mb" : MODEL_SIZES_MB .get (model )}
365458
366459
460+ @app .post ("/unload_model" )
461+ async def unload_model (model : str = Form (...)):
462+ """Drop a loaded model from RAM without touching its on-disk .onnx cache.
463+
464+ Useful for freeing memory when a model is no longer needed. The next
465+ request that uses it will reload it from the local cache (no re-download).
466+ """
467+ _check_model (model )
468+ unloaded = _evict (model )
469+ return {"model" : model , "unloaded" : unloaded , "state" : state_of (model )}
470+
471+
472+ @app .post ("/set_default_model" )
473+ async def set_default_model (model : str = Form (...), warmup : bool = Form (False )):
474+ """Pin a new active default model and evict every other loaded model.
475+
476+ The UI calls this when the user changes the model in the main dropdown so
477+ that switching from BiRefNet to ISNet (or vice-versa) doesn't leave both
478+ sitting in RAM. Per-image overrides (/remove with transient=true) do NOT
479+ use this — they go through the idle TTL instead.
480+ """
481+ global _PINNED_MODEL
482+ _check_model (model )
483+ _PINNED_MODEL = model
484+ evicted = []
485+ for name in list (_SESSIONS ):
486+ if name != model and _evict (name ):
487+ evicted .append (name )
488+ if warmup and model not in _SESSIONS and state_of (model ) != "loading" :
489+ async def _bg ():
490+ try :
491+ await ensure_session (model )
492+ except Exception : # noqa: BLE001
493+ pass
494+ asyncio .create_task (_bg ())
495+ return {
496+ "pinned" : _PINNED_MODEL ,
497+ "evicted" : evicted ,
498+ "loaded" : list (_SESSIONS .keys ()),
499+ "state" : state_of (model ),
500+ }
501+
502+
367503@app .post ("/delete_model" )
368504async def delete_model (model : str = Form (...)):
369505 """Delete a model's cached .onnx file from disk and unload it from memory."""
370506 _check_model (model )
371507 _SESSIONS .pop (model , None )
508+ _LAST_USED .pop (model , None )
372509 _MODEL_STATE .pop (model , None )
373510 _MODEL_ERROR .pop (model , None )
374511 path = model_file (model )
@@ -397,8 +534,15 @@ async def remove_background(
397534 alpha_matting_foreground_threshold : int = Form (240 ),
398535 alpha_matting_background_threshold : int = Form (10 ),
399536 alpha_matting_erode_size : int = Form (10 ),
537+ transient : bool = Form (False ),
400538):
401- """Process an image and return a PNG with a transparent background."""
539+ """Process an image and return a PNG with a transparent background.
540+
541+ When ``transient`` is true the request is treated as a one-off override:
542+ the model is loaded if needed but the pinned default is left alone, so the
543+ idle TTL will reclaim this model later. Used for per-image reprocessing
544+ with a different model from the UI.
545+ """
402546 # Validate size
403547 raw = await image .read ()
404548 if len (raw ) == 0 :
@@ -418,8 +562,9 @@ async def remove_background(
418562 raise HTTPException (status_code = 400 , detail = f"Invalid image: { exc } " )
419563
420564 log .info (
421- "Processing %s (%dx%d, %.1f KB) with model=%s" ,
565+ "Processing %s (%dx%d, %.1f KB) with model=%s%s " ,
422566 image .filename , img .width , img .height , len (raw ) / 1024 , model ,
567+ " (transient)" if transient else "" ,
423568 )
424569
425570 # Loads/downloads the model if needed (in a worker thread).
@@ -444,6 +589,7 @@ async def remove_background(
444589 elapsed = time .time () - t0
445590
446591 png_bytes = await run_in_threadpool (_encode_png , out )
592+ _touch (model )
447593 log .info ("Done in %.2fs" , elapsed )
448594
449595 headers = {
0 commit comments