Skip to content

Commit 2be8ea5

Browse files
committed
feat: upgrade default backend to ONNX with YOLO11n/YOLO26n models
- Switch default backend from tflite (SSD MobileNet V1) to onnx (YOLO11n) - Add YOLO11n (10.2MB, 30% faster than YOLOv8n) and YOLO26n (9.5MB, 52% faster, NMS-free) ONNX models - Add postprocess_yolo26() for YOLO26 end-to-end output format (1, 300, 6) - Make tensorflow optional (INSTALL_TENSORFLOW=1 build arg) — saves ~1GB Docker image size - Make tflite backend import lazy so app starts without tensorflow installed - Fix /api/v1/backends endpoint: distinguish 'unavailable' (deps missing) vs 'error' (model missing) - Use backend-specific confidence threshold defaults in /detect endpoint - Unpin onnxruntime version for latest optimizations
1 parent fd04573 commit 2be8ea5

8 files changed

Lines changed: 119 additions & 26 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ ENV/
3434
# TFLite models and data
3535
backends/tflite/models/
3636

37+
# PyTorch weights (used only for export, not deployed)
38+
*.pt
39+
3740
# Logs
3841
*.log
3942

Dockerfile

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ RUN apt-get update \
1414
libglib2.0-0 \
1515
&& rm -rf /var/lib/apt/lists/*
1616

17-
# Install Python deps (keeps this image self-contained for Unraid)
17+
# Core Python deps (ONNX is now the default backend)
1818
COPY Pipfile Pipfile.lock ./
1919
RUN python -m pip install --upgrade pip \
2020
&& python -m pip install \
@@ -26,17 +26,21 @@ RUN python -m pip install --upgrade pip \
2626
pillow \
2727
exceptiongroup \
2828
numpy \
29-
tensorflow \
30-
onnxruntime==1.23.2 \
29+
onnxruntime \
3130
opencv-python \
3231
scipy \
3332
shapely
3433

34+
# Optional: install tensorflow for tflite backend support
35+
# Usage: docker build --build-arg INSTALL_TENSORFLOW=1 ...
36+
ARG INSTALL_TENSORFLOW=0
37+
RUN if [ "$INSTALL_TENSORFLOW" = "1" ]; then python -m pip install tensorflow; fi
38+
3539
COPY . .
3640

37-
# Bake the default TFLite model into the image (can be disabled at build time).
38-
# Usage: docker build --build-arg DOWNLOAD_DEFAULT_MODEL=0 ...
39-
ARG DOWNLOAD_DEFAULT_MODEL=1
41+
# Optionally bake the TFLite model (only useful if tensorflow is installed)
42+
# Usage: docker build --build-arg DOWNLOAD_DEFAULT_MODEL=1 ...
43+
ARG DOWNLOAD_DEFAULT_MODEL=0
4044
RUN if [ "$DOWNLOAD_DEFAULT_MODEL" = "1" ]; then python scripts/download_model.py; fi
4145

4246
EXPOSE 8000

api/v1/endpoints/detection.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from models.detection import DetectionResponse, DetectionResult, ImageResponse
1010
from models.zone import ZoneConfiguration
11-
from backends.factory import get_backend
11+
from backends.factory import get_backend, BACKEND_REGISTRY
1212
from utils.image import validate_image, preprocess_image, image_to_bytes
1313
from utils.zones import filter_detections_by_zones, apply_class_filter, apply_size_filter
1414
from config import settings
@@ -85,8 +85,14 @@ async def detect_objects(
8585
logger.error(f"Image validation/processing failed: {str(e)}")
8686
raise HTTPException(status_code=400, detail=f"Invalid image: {str(e)}")
8787

88-
# Set confidence threshold
89-
threshold = confidence_threshold or settings.TFLITE_CONFIDENCE_THRESHOLD
88+
# Set confidence threshold — use the backend-specific default
89+
if backend == "onnx":
90+
default_threshold = settings.ONNX_CONFIDENCE_THRESHOLD
91+
elif backend == "tflite":
92+
default_threshold = settings.TFLITE_CONFIDENCE_THRESHOLD
93+
else:
94+
default_threshold = 0.5
95+
threshold = confidence_threshold or default_threshold
9096
logger.debug(f"Using confidence threshold: {threshold}")
9197

9298
# Perform detection
@@ -184,18 +190,25 @@ async def list_backends():
184190
"""
185191
backends = {}
186192
for backend_name in settings.AVAILABLE_BACKENDS:
193+
if backend_name not in BACKEND_REGISTRY:
194+
backends[backend_name] = {
195+
"status": "unavailable",
196+
"error": f"Backend '{backend_name}' dependencies not installed"
197+
}
198+
continue
187199
try:
188200
detector = get_backend(backend_name)
189201
backends[backend_name] = {
190202
"status": "available",
191203
"model_info": detector.get_model_info()
192204
}
193205
except Exception as e:
206+
logger.warning(f"Backend {backend_name} failed to initialize: {e}")
194207
backends[backend_name] = {
195208
"status": "error",
196209
"error": str(e)
197210
}
198-
211+
199212
return {
200213
"default_backend": settings.DEFAULT_BACKEND,
201214
"backends": backends

backends/factory.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from typing import Dict, Type
22

33
from backends.base import DetectionBackend
4-
from backends.tflite.backend import TFLiteBackend
54
from config import settings
65

7-
# Lazy imports for optional backends
6+
# Lazy imports for all optional backends
7+
try:
8+
from backends.tflite.backend import TFLiteBackend
9+
TFLITE_AVAILABLE = True
10+
except ImportError:
11+
TFLITE_AVAILABLE = False
12+
813
try:
914
from backends.onnx.backend import ONNXBackend
1015
ONNX_AVAILABLE = True
@@ -25,11 +30,11 @@
2530

2631

2732
# Registry of available backends
28-
BACKEND_REGISTRY: Dict[str, Type[DetectionBackend]] = {
29-
"tflite": TFLiteBackend,
30-
}
33+
BACKEND_REGISTRY: Dict[str, Type[DetectionBackend]] = {}
34+
35+
if TFLITE_AVAILABLE:
36+
BACKEND_REGISTRY["tflite"] = TFLiteBackend
3137

32-
# Add optional backends if available
3338
if ONNX_AVAILABLE:
3439
BACKEND_REGISTRY["onnx"] = ONNXBackend
3540

backends/onnx/backend.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ def __init__(self, model_path: str, labels_path: str, confidence_threshold: floa
1515
iou_threshold: float = 0.45, model_type: str = "yolov8"):
1616
"""
1717
Initialize the ONNX detection backend.
18-
18+
1919
Args:
2020
model_path: Path to the ONNX model file
2121
labels_path: Path to the labels file
2222
confidence_threshold: Default confidence threshold
2323
iou_threshold: IoU threshold for NMS
24-
model_type: Type of model (yolov8, yolov5, yolox, etc.)
24+
model_type: Type of model (yolov8, yolo11, yolo26, yolov5, yolox, etc.)
2525
"""
2626
self.model_path = model_path
2727
self.labels_path = labels_path
@@ -136,7 +136,10 @@ def postprocess_yolov8(self, outputs: List[np.ndarray], scale: float,
136136
pad: Tuple[int, int], confidence_threshold: float,
137137
orig_width: int, orig_height: int) -> List[DetectionResult]:
138138
"""
139-
Postprocess YOLOv8 outputs.
139+
Postprocess YOLOv8/YOLO11 outputs.
140+
141+
Both YOLOv8 and YOLO11 share the same output format:
142+
(1, 84, 8400) = (batch, 4_bbox + 80_classes, num_predictions)
140143
141144
Args:
142145
outputs: Model outputs
@@ -149,7 +152,7 @@ def postprocess_yolov8(self, outputs: List[np.ndarray], scale: float,
149152
Returns:
150153
List of DetectionResult objects
151154
"""
152-
# YOLOv8 output shape: (1, 84, 8400) or (1, num_classes+4, num_predictions)
155+
# YOLOv8/YOLO11 output shape: (1, 84, 8400) or (1, num_classes+4, num_predictions)
153156
# Format: [x_center, y_center, width, height, class_scores...]
154157
output = outputs[0]
155158

@@ -223,6 +226,69 @@ def postprocess_yolov8(self, outputs: List[np.ndarray], scale: float,
223226

224227
return results
225228

229+
def postprocess_yolo26(self, outputs: List[np.ndarray], scale: float,
230+
pad: Tuple[int, int], confidence_threshold: float,
231+
orig_width: int, orig_height: int) -> List[DetectionResult]:
232+
"""
233+
Postprocess YOLO26 (NMS-free, end-to-end) outputs.
234+
235+
YOLO26 output shape: (1, 300, 6) = (batch, max_detections, [x1, y1, x2, y2, conf, class_id])
236+
Coordinates are in input-image pixel space (640×640 with letterbox padding).
237+
NMS is already applied inside the model — no manual NMS needed.
238+
239+
Args:
240+
outputs: Model outputs
241+
scale: Scale factor used in preprocessing
242+
pad: Padding used in preprocessing (pad_w, pad_h)
243+
confidence_threshold: Confidence threshold
244+
orig_width: Original image width
245+
orig_height: Original image height
246+
247+
Returns:
248+
List of DetectionResult objects
249+
"""
250+
output = outputs[0] # Shape: (1, 300, 6)
251+
if len(output.shape) == 3:
252+
output = output[0] # Shape: (300, 6)
253+
254+
pad_w, pad_h = pad
255+
results = []
256+
257+
for detection in output:
258+
x1, y1, x2, y2, conf, class_id = detection
259+
260+
if conf < confidence_threshold:
261+
continue
262+
263+
# Remove padding and scale back to original image coordinates
264+
x_min = (x1 - pad_w) / scale
265+
y_min = (y1 - pad_h) / scale
266+
x_max = (x2 - pad_w) / scale
267+
y_max = (y2 - pad_h) / scale
268+
269+
# Normalize to [0, 1] using original image dimensions
270+
x_min_norm = max(0.0, min(1.0, x_min / orig_width))
271+
y_min_norm = max(0.0, min(1.0, y_min / orig_height))
272+
x_max_norm = max(0.0, min(1.0, x_max / orig_width))
273+
y_max_norm = max(0.0, min(1.0, y_max / orig_height))
274+
275+
cid = int(class_id)
276+
label = self.labels[cid] if cid < len(self.labels) else f"class_{cid}"
277+
278+
detection_result = DetectionResult(
279+
label=label,
280+
confidence=float(conf),
281+
bounding_box=BoundingBox(
282+
x_min=x_min_norm,
283+
y_min=y_min_norm,
284+
x_max=x_max_norm,
285+
y_max=y_max_norm
286+
)
287+
)
288+
results.append(detection_result)
289+
290+
return results
291+
226292
def _nms(self, boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> List[int]:
227293
"""
228294
Non-Maximum Suppression.
@@ -299,10 +365,12 @@ def detect(self, image: Image.Image, confidence_threshold: float = None) -> List
299365
outputs = self.session.run(self.output_names, {self.input_name: input_data})
300366

301367
# Postprocess based on model type
302-
if self.model_type in ["yolov8", "yolov5", "yolox"]:
368+
if self.model_type == "yolo26":
369+
results = self.postprocess_yolo26(outputs, scale, pad, confidence_threshold, orig_width, orig_height)
370+
elif self.model_type in ["yolov8", "yolo11", "yolov5", "yolox"]:
303371
results = self.postprocess_yolov8(outputs, scale, pad, confidence_threshold, orig_width, orig_height)
304372
else:
305-
# Default to YOLOv8 postprocessing
373+
# Default to YOLOv8/YOLO11 postprocessing (same format)
306374
results = self.postprocess_yolov8(outputs, scale, pad, confidence_threshold, orig_width, orig_height)
307375

308376
return results

backends/onnx/models/yolo11n.onnx

10.2 MB
Binary file not shown.

backends/onnx/models/yolo26n.onnx

9.48 MB
Binary file not shown.

config.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,20 @@ class Settings(BaseSettings):
1010
PROJECT_NAME: str = "Light Object Detection API"
1111

1212
# Backend settings
13-
DEFAULT_BACKEND: str = "tflite"
14-
AVAILABLE_BACKENDS: List[str] = ["tflite", "onnx", "opencv", "edgetpu"]
13+
DEFAULT_BACKEND: str = "onnx"
14+
AVAILABLE_BACKENDS: List[str] = ["onnx", "tflite", "opencv", "edgetpu"]
1515

1616
# TFLite settings
1717
TFLITE_MODEL_PATH: str = "backends/tflite/models/ssd_mobilenet_v1.tflite"
1818
TFLITE_LABELS_PATH: str = "backends/tflite/models/labelmap.txt"
1919
TFLITE_CONFIDENCE_THRESHOLD: float = 0.5
2020

2121
# ONNX settings
22-
ONNX_MODEL_PATH: str = "backends/onnx/models/yolov8n.onnx"
22+
ONNX_MODEL_PATH: str = "backends/onnx/models/yolo11n.onnx"
2323
ONNX_LABELS_PATH: str = "backends/onnx/models/coco.txt"
2424
ONNX_CONFIDENCE_THRESHOLD: float = 0.5
2525
ONNX_IOU_THRESHOLD: float = 0.45
26-
ONNX_MODEL_TYPE: str = "yolov8"
26+
ONNX_MODEL_TYPE: str = "yolo11"
2727

2828
# OpenCV DNN settings
2929
OPENCV_MODEL_PATH: str = "backends/opencv/models/yolov4-tiny.weights"

0 commit comments

Comments
 (0)