Skip to content

Commit 894daca

Browse files
rohan-pandeyyrahulharpal1603
authored andcommitted
feat(backend): add lazy model registry and downloader
1 parent b7aa81a commit 894daca

8 files changed

Lines changed: 362 additions & 29 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pnpm-debug.log*
99
lerna-debug.log*
1010

1111
backend/app/models/image-generation/*
12+
backend/app/models/ONNX_Exports/
1213

1314
node_modules
1415

backend/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ MANIFEST
3131
# before PyInstaller builds the exe, so as to inject date/other infos into it.
3232
*.manifest
3333
*.spec
34+
!PictoPy.spec
3435

3536
# Installer logs
3637
pip-log.txt

backend/app/models/FaceNet.py

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,69 @@
1-
# app/facenet/FaceNet.py
2-
1+
import os
2+
import threading
33
import onnxruntime
4+
from app.models.model_registry import MODEL_REGISTRY
45
from app.utils.FaceNet import FaceNet_util_normalize_embedding
56
from app.utils.ONNX import ONNX_util_get_execution_providers
67
from app.logging.setup_logging import get_logger
78

8-
# Initialize logger
99
logger = get_logger(__name__)
1010

1111

1212
class FaceNet:
1313
def __init__(self, model_path):
14-
self.session = onnxruntime.InferenceSession(
15-
model_path, providers=ONNX_util_get_execution_providers()
16-
)
17-
self.input_tensor_name = self.session.get_inputs()[0].name
18-
self.output_tensor_name = self.session.get_outputs()[0].name
14+
self.model_path = model_path
15+
self._session: onnxruntime.InferenceSession | None = None
16+
self.input_tensor_name: str | None = None
17+
self.output_tensor_name: str | None = None
18+
self._lock = threading.Lock()
19+
20+
def get_session(self) -> onnxruntime.InferenceSession:
21+
# Fast path: capture reference before returning so close() on another
22+
# thread between the check and the return cannot cause a None return.
23+
session = self._session
24+
if session is not None:
25+
return session
26+
27+
with self._lock:
28+
if self._session is None:
29+
if not os.path.exists(self.model_path):
30+
model_key = None
31+
for key, spec in MODEL_REGISTRY.items():
32+
if spec["filename"] in self.model_path:
33+
model_key = key
34+
break
35+
model_name = (
36+
model_key if model_key else os.path.basename(self.model_path)
37+
)
38+
raise RuntimeError(
39+
f"Model '{model_name}' is not installed. "
40+
"Please install it from Settings → AI Models before using this feature."
41+
)
42+
43+
self._session = onnxruntime.InferenceSession(
44+
self.model_path, providers=ONNX_util_get_execution_providers()
45+
)
46+
self.input_tensor_name = self._session.get_inputs()[0].name
47+
self.output_tensor_name = self._session.get_outputs()[0].name
48+
49+
# Capture inside the lock before releasing — prevents close() on
50+
# another thread from nulling _session between lock release and return.
51+
session = self._session
52+
53+
return session
1954

2055
def get_embedding(self, preprocessed_image):
21-
result = self.session.run(
56+
session = self.get_session()
57+
result = session.run(
2258
[self.output_tensor_name], {self.input_tensor_name: preprocessed_image}
2359
)[0]
2460
embedding = result[0]
2561
return FaceNet_util_normalize_embedding(embedding)
2662

2763
def close(self):
28-
del self.session
29-
logger.info("FaceNet model session closed.")
64+
with self._lock:
65+
if self._session is not None:
66+
self._session = None
67+
self.input_tensor_name = None
68+
self.output_tensor_name = None
69+
logger.info("FaceNet model session closed.")

backend/app/models/YOLO.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,45 +19,77 @@ def __init__(self, path, conf_threshold=0.7, iou_threshold=0.5):
1919
self.model_path = path
2020
self.conf_threshold = conf_threshold
2121
self.iou_threshold = iou_threshold
22-
# Create ONNX session once and reuse it
23-
self.session = onnxruntime.InferenceSession(
24-
self.model_path, providers=ONNX_util_get_execution_providers()
25-
)
26-
27-
# Initialize model info
28-
self.get_input_details()
29-
self.get_output_details()
22+
self._session = None
23+
import threading
24+
self._lock = threading.Lock()
25+
26+
def get_session(self):
27+
session = self._session
28+
if session is not None:
29+
return session
30+
31+
with self._lock:
32+
if self._session is None:
33+
import os
34+
from app.models.model_registry import MODEL_REGISTRY
35+
36+
if not os.path.exists(self.model_path):
37+
model_key = None
38+
for key, spec in MODEL_REGISTRY.items():
39+
if spec["filename"] in self.model_path:
40+
model_key = key
41+
break
42+
model_name = model_key if model_key else os.path.basename(self.model_path)
43+
raise RuntimeError(
44+
f"Model '{model_name}' is not installed. "
45+
"Please install it from Settings → AI Models before using this feature."
46+
)
47+
48+
self._session = onnxruntime.InferenceSession(
49+
self.model_path, providers=ONNX_util_get_execution_providers()
50+
)
51+
# Initialize model info once session is created
52+
self.get_input_details()
53+
self.get_output_details()
54+
55+
return self._session
3056

3157
def __call__(self, image):
3258
return self.detect_objects(image)
3359

3460
def close(self):
35-
del self.session # Clean up the ONNX session
61+
with self._lock:
62+
if self._session is not None:
63+
self._session = None
3664
logger.info("YOLO model session closed.")
3765

3866
@log_memory_usage
3967
def detect_objects(self, image):
68+
session = self.get_session()
4069
input_tensor = self.prepare_input(image)
41-
outputs = self.inference(input_tensor)
70+
outputs = self.inference(input_tensor, session=session)
4271
self.boxes, self.scores, self.class_ids = self.process_output(outputs)
4372
return self.boxes, self.scores, self.class_ids
4473

45-
def inference(self, input_tensor):
46-
time.perf_counter()
47-
outputs = self.session.run(
74+
def inference(self, input_tensor, session=None):
75+
start = time.perf_counter()
76+
if session is None:
77+
session = self.get_session()
78+
outputs = session.run(
4879
self.output_names, {self.input_names[0]: input_tensor}
4980
)
81+
logger.debug("Inference completed in %.4fs", time.perf_counter() - start)
5082
return outputs
5183

5284
def get_input_details(self):
53-
model_inputs = self.session.get_inputs()
85+
model_inputs = self._session.get_inputs()
5486
self.input_names = [inp.name for inp in model_inputs]
5587
self.input_shape = model_inputs[0].shape
5688
self.input_height = self.input_shape[2]
5789
self.input_width = self.input_shape[3]
5890

5991
def get_output_details(self):
60-
model_outputs = self.session.get_outputs()
92+
model_outputs = self._session.get_outputs()
6193
self.output_names = [out.name for out in model_outputs]
6294

6395
def prepare_input(self, image):
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from typing import TypedDict, Literal
2+
import json
3+
import os
4+
import sys
5+
from platformdirs import user_data_dir
6+
7+
FeatureType = Literal["object_detection", "face_detection", "face_embedding"]
8+
TierType = Literal["nano", "small", "medium", "required"]
9+
10+
11+
class ModelSpec(TypedDict):
12+
filename: str
13+
url: str
14+
sha256: str
15+
size_mb: float
16+
feature: FeatureType
17+
tier: TierType
18+
19+
20+
MODEL_REGISTRY: dict[str, ModelSpec] = {
21+
"yolo_nano": ModelSpec(
22+
filename="YOLOv11_Nano.onnx",
23+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/YOLOv11_Nano.onnx",
24+
sha256="64e0a360a854cd1cebf697e38967adb73f24a2c41a86379f8a0bfae1b0c6af0b",
25+
size_mb=10.2,
26+
feature="object_detection",
27+
tier="nano",
28+
),
29+
"yolo_nano_face": ModelSpec(
30+
filename="YOLOv11_Nano_Face.onnx",
31+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/YOLOv11_Nano_Face.onnx",
32+
sha256="1d09cb0f31d46700a3e80838623aeafa125f2cd0d1c9a12f0b0853bf64b0a83d",
33+
size_mb=10.1,
34+
feature="face_detection",
35+
tier="nano",
36+
),
37+
"yolo_small": ModelSpec(
38+
filename="YOLOv11_Small.onnx",
39+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/YOLOv11_Small.onnx",
40+
sha256="8bfa953dbe93bef33b09be00da01cb4727f25416cb5d2fdb2a9f1083283b8aaa",
41+
size_mb=36.2,
42+
feature="object_detection",
43+
tier="small",
44+
),
45+
"yolo_small_face": ModelSpec(
46+
filename="YOLOv11_Small_Face.onnx",
47+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/YOLOv11_Small_Face.onnx",
48+
sha256="213333bbecd049c8e1d8bc63b4799df08d2ec52c8f8a6737b37b75ba839d7c03",
49+
size_mb=36.1,
50+
feature="face_detection",
51+
tier="small",
52+
),
53+
"yolo_medium": ModelSpec(
54+
filename="YOLOv11_Medium.onnx",
55+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/YOLOv11_Medium.onnx",
56+
sha256="f53a0bf6a141788f329ab92b438045f0ebac73ff10285b9ef0d06551cdbd01ea",
57+
size_mb=76.9,
58+
feature="object_detection",
59+
tier="medium",
60+
),
61+
"yolo_medium_face": ModelSpec(
62+
filename="YOLOv11_Medium_Face.onnx",
63+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/YOLOv11_Medium_Face.onnx",
64+
sha256="fd3ab085d776ee1ce59fd47def82c2abf49bbfde3a10a1b5b660b76cb41a6912",
65+
size_mb=76.6,
66+
feature="face_detection",
67+
tier="medium",
68+
),
69+
"facenet": ModelSpec(
70+
filename="FaceNet_128D.onnx",
71+
url="https://github.com/AOSSIE-Org/PictoPy/releases/download/models-v1.0/FaceNet_128D.onnx",
72+
sha256="c37946ea8cce94141777dcbcccb8a61786c9a7c4f0c9f40471bd27029fa664ed",
73+
size_mb=87.0,
74+
feature="face_embedding",
75+
tier="required",
76+
),
77+
}
78+
79+
TIER_MODELS: dict[str, list[str]] = {
80+
"nano": ["yolo_nano", "yolo_nano_face"],
81+
"small": ["yolo_small", "yolo_small_face"],
82+
"medium": ["yolo_medium", "yolo_medium_face"],
83+
"required": ["facenet"], # Required model; not user-selectable
84+
}
85+
86+
USER_DATA_MODELS = os.path.join(user_data_dir("PictoPy"), "models")
87+
LOCAL_ONNX_EXPORTS = os.path.join(os.path.dirname(__file__), "ONNX_Exports")
88+
89+
90+
def ensure_model_exports_directory() -> None:
91+
"""Create the active model exports directory if it does not exist."""
92+
if getattr(sys, 'frozen', False):
93+
os.makedirs(USER_DATA_MODELS, exist_ok=True)
94+
else:
95+
os.makedirs(LOCAL_ONNX_EXPORTS, exist_ok=True)
96+
97+
98+
def get_model_path(key: str) -> str:
99+
filename = MODEL_REGISTRY[key]["filename"]
100+
ensure_model_exports_directory()
101+
102+
# In production (compiled by PyInstaller), use the platform-appropriate user data directory.
103+
if getattr(sys, 'frozen', False):
104+
return os.path.normpath(os.path.join(USER_DATA_MODELS, filename))
105+
106+
# In development, strictly use the local repo folder
107+
return os.path.normpath(os.path.join(LOCAL_ONNX_EXPORTS, filename))
108+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import asyncio
2+
import logging
3+
4+
from app.database.metadata import db_get_metadata
5+
from app.models.model_registry import TIER_MODELS
6+
from app.utils.model_downloader import ensure_model
7+
8+
logger = logging.getLogger(__name__)
9+
10+
11+
def _get_preferred_yolo_tier() -> str:
12+
metadata = db_get_metadata() or {}
13+
user_preferences = metadata.get("user_preferences") or {}
14+
tier = user_preferences.get("YOLO_model_size", "small")
15+
16+
if tier not in TIER_MODELS:
17+
return "small"
18+
19+
return tier
20+
21+
22+
async def _ensure_ai_tagging_models_async() -> None:
23+
tier = _get_preferred_yolo_tier()
24+
model_keys = list(TIER_MODELS.get(tier, TIER_MODELS["small"]))
25+
if "facenet" not in model_keys:
26+
model_keys.append("facenet")
27+
28+
for model_key in model_keys:
29+
logger.info("Ensuring AI tagging model is available: %s", model_key)
30+
await ensure_model(model_key)
31+
32+
33+
def ensure_ai_tagging_models() -> None:
34+
asyncio.run(_ensure_ai_tagging_models_async())

0 commit comments

Comments
 (0)