Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/backend-metadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ jobs:
python-version: '3.11'
- run: pip install 'numpy<2' tflite-runtime==2.14.0 fastapi pillow
- run: python -m unittest discover -s tests -p test_backend_model_info.py -v
- name: Install API startup test dependencies
run: pip install pydantic-settings uvicorn httpx python-multipart shapely sentry-sdk
- name: Verify default readiness and lazy optional engines
run: python -m unittest discover -s tests -p test_startup.py -v
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

A lightweight Python API for object detection with pluggable backends. This API allows you to detect objects in images using different detection backends, starting with TensorFlow Lite.

The API initializes `DEFAULT_BACKEND` before accepting requests and fails startup
if its model or runtime is unavailable. Other backends initialize on demand;
listing them in `AVAILABLE_BACKENDS` does not require installing every optional
model, runtime, or accelerator. Requests for an optional backend still require its
corresponding model and dependencies. For optional TFLite use, provision the model
with `python scripts/download_model.py` or configure an existing model path.

## Features

- FastAPI-based REST API for object detection
Expand Down Expand Up @@ -33,7 +40,7 @@ A lightweight Python API for object detection with pluggable backends. This API
pipenv run python scripts/download_model.py
```

If you skip this step, the API will try to download the default model on startup when the `tflite` backend is enabled (requires internet access). Docker builds download the default model by default.
If you skip this step, the API will try to download the default model on startup when `DEFAULT_BACKEND=tflite` (requires internet access). Docker builds download the default model by default.

## Usage

Expand Down
46 changes: 19 additions & 27 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from api.router import router as api_router
from config import settings
from backends.factory import get_backend
from utils.model_download import ensure_tflite_ssd_mobilenet_v1, ModelDownloadError
from utils.model_download import ensure_tflite_ssd_mobilenet_v1

from observability import init_sentry

Expand Down Expand Up @@ -35,33 +35,25 @@ async def startup_event():
logger.info(f"Available backends: {settings.AVAILABLE_BACKENDS}")
logger.info(f"Default backend: {settings.DEFAULT_BACKEND}")

# Ensure the default TFLite model is present if TFLite is enabled.
# This keeps the out-of-the-box experience working, while still allowing
# users to override paths via .env.
if "tflite" in settings.AVAILABLE_BACKENDS:
try:
ensure_result = ensure_tflite_ssd_mobilenet_v1(
model_path=settings.TFLITE_MODEL_PATH,
labels_path=settings.TFLITE_LABELS_PATH,
force=False,
)
if ensure_result.did_download:
logger.info(f"✓ Downloaded default TFLite model: {ensure_result.message}")
except ModelDownloadError as e:
logger.error(
"✗ TFLite model is missing and could not be downloaded. "
f"Reason: {e}. "
"Fix: run 'python scripts/download_model.py' or set TFLITE_MODEL_PATH to an existing model."
)
# Optional engines stay lazy: their models, runtimes or hardware may not
# exist on this host. The configured default must actually initialize before
# the application can start accepting requests (including /health).
backend_name = settings.DEFAULT_BACKEND
if backend_name not in settings.AVAILABLE_BACKENDS:
raise ValueError("DEFAULT_BACKEND must be included in AVAILABLE_BACKENDS")
if backend_name == "tflite":
ensure_result = ensure_tflite_ssd_mobilenet_v1(
model_path=settings.TFLITE_MODEL_PATH,
labels_path=settings.TFLITE_LABELS_PATH,
force=False,
)
if ensure_result.did_download:
logger.info("Downloaded default TFLite model: %s", ensure_result.message)

# Test each backend
for backend_name in settings.AVAILABLE_BACKENDS:
try:
detector = get_backend(backend_name)
model_info = detector.get_model_info()
logger.info(f"✓ {backend_name.upper()} backend: {model_info.get('model_path', 'N/A')}")
except Exception as e:
logger.error(f"✗ {backend_name.upper()} backend failed: {str(e)}")
detector = get_backend(backend_name)
model_info = detector.get_model_info()
logger.info("Default %s backend ready: %s", backend_name.upper(),
model_info.get("model_path", "N/A"))

logger.info("=" * 60)

Expand Down
57 changes: 57 additions & 0 deletions tests/test_startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Startup requires the default engine, without probing unrelated hardware."""
import unittest
from unittest.mock import Mock, patch

from fastapi.testclient import TestClient
import main


class StartupTests(unittest.TestCase):
def setup_settings(self, default='onnx', available=None):
return patch.multiple(main.settings, DEFAULT_BACKEND=default,
AVAILABLE_BACKENDS=available or ['onnx','tflite','opencv','edgetpu','moondream'])

def test_optional_backends_are_not_loaded_or_downloaded_during_startup(self):
detector = Mock()
detector.get_model_info.return_value = {'model_path':'fixture.onnx'}
with self.setup_settings(), patch.object(main, 'get_backend', return_value=detector) as get, \
patch.object(main, 'ensure_tflite_ssd_mobilenet_v1') as download:
with TestClient(main.app) as client:
self.assertEqual(client.get('/health').status_code, 200)
get.assert_called_once_with('onnx')
download.assert_not_called()

def test_missing_default_model_or_driver_prevents_healthy_startup(self):
for error in (FileNotFoundError('fixture model'), OSError('fixture driver')):
with self.subTest(error=type(error).__name__), self.setup_settings('edgetpu'), \
patch.object(main, 'get_backend', side_effect=error):
with self.assertRaises(type(error)):
with TestClient(main.app):pass

def test_disabled_default_prevents_startup(self):
with self.setup_settings('edgetpu', ['onnx']), patch.object(main, 'get_backend') as get:
with self.assertRaises(ValueError):
with TestClient(main.app):pass
get.assert_not_called()

def test_tflite_default_is_provisioned_before_initialization(self):
calls = []
def download(**kwargs):
calls.append('download');return Mock(did_download=False)
def get(name):
calls.append(name);return Mock(get_model_info=lambda:{})
with self.setup_settings('tflite'), patch.object(main, 'get_backend', side_effect=get), \
patch.object(main, 'ensure_tflite_ssd_mobilenet_v1', side_effect=download):
with TestClient(main.app) as client:
self.assertEqual(client.get('/health').status_code, 200)
self.assertEqual(calls, ['download','tflite'])

def test_default_download_failure_prevents_startup(self):
with self.setup_settings('tflite'), patch.object(main, 'get_backend') as get, \
patch.object(main, 'ensure_tflite_ssd_mobilenet_v1', side_effect=OSError('fixture download')):
with self.assertRaises(OSError):
with TestClient(main.app):pass
get.assert_not_called()


if __name__ == '__main__':unittest.main()
Loading