Chris commited on
Commit
00340de
·
unverified ·
0 Parent(s):

feat: add YOLOv8 object detection API

Browse files

- FastAPI application with /health and /detect endpoints
- YOLO model loaded at startup via lifespan
- Docker support for Hugging Face Spaces deployment
- Dev tooling: ruff, mypy, pytest with configurations
- CI workflow for lint, typecheck, tests and lockfile check
- HF sync workflow with commitizen for releases
- Test suite for API endpoints

Files changed (12) hide show
  1. .github/workflows/ci.yml +121 -0
  2. .github/workflows/hf-sync.yml +46 -0
  3. .gitignore +85 -0
  4. Dockerfile +31 -0
  5. LICENSE +21 -0
  6. README.md +198 -0
  7. app.py +54 -0
  8. pyproject.toml +55 -0
  9. tests/__init__.py +0 -0
  10. tests/conftest.py +10 -0
  11. tests/test_api.py +137 -0
  12. uv.lock +0 -0
.github/workflows/ci.yml ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - develop
8
+ pull_request:
9
+ branches:
10
+ - main
11
+ - develop
12
+
13
+ env:
14
+ UV_CACHE_DIR: /tmp/.uv-cache
15
+
16
+ jobs:
17
+ lockfile:
18
+ if: "!startsWith(github.event.head_commit.message, 'chore: release a new version') && !startsWith(github.event.head_commit.message, 'bump:')"
19
+ runs-on: ubuntu-latest
20
+ name: Check lockfile
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v4
26
+
27
+ - name: Check uv.lock is up to date
28
+ run: uv lock --check
29
+
30
+ lint:
31
+ if: "!startsWith(github.event.head_commit.message, 'chore: release a new version') && !startsWith(github.event.head_commit.message, 'bump:')"
32
+ runs-on: ubuntu-latest
33
+ name: Lint (ruff)
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+
37
+ - name: Install uv
38
+ uses: astral-sh/setup-uv@v4
39
+
40
+ - name: Restore uv cache
41
+ uses: actions/cache@v4
42
+ with:
43
+ path: /tmp/.uv-cache
44
+ key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
45
+ restore-keys: |
46
+ uv-${{ runner.os }}-
47
+
48
+ - name: Set up Python
49
+ run: uv python install 3.12
50
+
51
+ - name: Install dependencies
52
+ run: uv sync --extra dev
53
+
54
+ - name: Run ruff check
55
+ run: uv run ruff check .
56
+
57
+ - name: Run ruff format check
58
+ run: uv run ruff format --check .
59
+
60
+ - name: Minimize uv cache
61
+ run: uv cache prune --ci
62
+
63
+ typecheck:
64
+ if: "!startsWith(github.event.head_commit.message, 'chore: release a new version') && !startsWith(github.event.head_commit.message, 'bump:')"
65
+ runs-on: ubuntu-latest
66
+ name: Type check (mypy)
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+
70
+ - name: Install uv
71
+ uses: astral-sh/setup-uv@v4
72
+
73
+ - name: Restore uv cache
74
+ uses: actions/cache@v4
75
+ with:
76
+ path: /tmp/.uv-cache
77
+ key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
78
+ restore-keys: |
79
+ uv-${{ runner.os }}-
80
+
81
+ - name: Set up Python
82
+ run: uv python install 3.12
83
+
84
+ - name: Install dependencies
85
+ run: uv sync --extra dev
86
+
87
+ - name: Run mypy
88
+ run: uv run mypy app.py
89
+
90
+ - name: Minimize uv cache
91
+ run: uv cache prune --ci
92
+
93
+ test:
94
+ if: "!startsWith(github.event.head_commit.message, 'chore: release a new version') && !startsWith(github.event.head_commit.message, 'bump:')"
95
+ runs-on: ubuntu-latest
96
+ name: Test (pytest)
97
+ steps:
98
+ - uses: actions/checkout@v4
99
+
100
+ - name: Install uv
101
+ uses: astral-sh/setup-uv@v4
102
+
103
+ - name: Restore uv cache
104
+ uses: actions/cache@v4
105
+ with:
106
+ path: /tmp/.uv-cache
107
+ key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
108
+ restore-keys: |
109
+ uv-${{ runner.os }}-
110
+
111
+ - name: Set up Python
112
+ run: uv python install 3.12
113
+
114
+ - name: Install dependencies
115
+ run: uv sync --extra dev
116
+
117
+ - name: Run tests with coverage
118
+ run: uv run pytest -v --cov=app --cov-report=term-missing
119
+
120
+ - name: Minimize uv cache
121
+ run: uv cache prune --ci
.github/workflows/hf-sync.yml ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face Hub
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ bump_version:
13
+ if: "startsWith(github.event.head_commit.message, 'chore: release a new version') && !startsWith(github.event.head_commit.message, 'bump:')"
14
+ runs-on: ubuntu-latest
15
+ environment: release
16
+ name: Bump version, create changelog and sync to HF
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0
21
+ token: ${{ secrets.GITHUB_TOKEN }}
22
+
23
+ - uses: commitizen-tools/commitizen-action@master
24
+ with:
25
+ github_token: ${{ secrets.GITHUB_TOKEN }}
26
+ changelog_increment_filename: changes.md
27
+ changelog: true
28
+ debug: false
29
+
30
+ - name: Create GitHub Release
31
+ uses: softprops/action-gh-release@v2
32
+ with:
33
+ body_path: changes.md
34
+ tag_name: v${{ env.REVISION }}
35
+ env:
36
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37
+
38
+ - name: Sync to Hugging Face Hub
39
+ env:
40
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
41
+ HF_REPO_ID: ${{ vars.HF_REPO_ID }}
42
+ HF_USER: ${{ vars.HF_USER }}
43
+ run: |
44
+ git remote add hf "https://${HF_USER}:${HF_TOKEN}@huggingface.co/spaces/${HF_REPO_ID}"
45
+ git push hf main --force
46
+ git push hf --tags --force
.gitignore ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+
27
+ # PyInstaller
28
+ *.manifest
29
+ *.spec
30
+
31
+ # Installer logs
32
+ pip-log.txt
33
+ pip-delete-this-directory.txt
34
+
35
+ # Unit test / coverage reports
36
+ htmlcov/
37
+ .tox/
38
+ .nox/
39
+ .coverage
40
+ .coverage.*
41
+ .cache
42
+ nosetests.xml
43
+ coverage.xml
44
+ *.cover
45
+ *.py,cover
46
+ .hypothesis/
47
+ .pytest_cache/
48
+
49
+ # Translations
50
+ *.mo
51
+ *.pot
52
+
53
+ # Environments
54
+ .env
55
+ .venv
56
+ env/
57
+ venv/
58
+ ENV/
59
+ env.bak/
60
+ venv.bak/
61
+ .python-version
62
+
63
+ # IDEs
64
+ .idea/
65
+ .vscode/
66
+ *.swp
67
+ *.swo
68
+ *~
69
+
70
+ # Jupyter Notebook
71
+ .ipynb_checkpoints
72
+
73
+ # pytype static type analyzer
74
+ .pytype/
75
+
76
+ # Cython debug symbols
77
+ cython_debug/
78
+
79
+ # YOLO model weights
80
+ *.pt
81
+ *.onnx
82
+ *.engine
83
+
84
+ # UV
85
+ .uv/
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PIP_NO_CACHE_DIR=1 \
5
+ UV_SYSTEM_PYTHON=1
6
+
7
+ WORKDIR /app
8
+
9
+ # Dépendances système nécessaires à YOLO / OpenCV
10
+ RUN apt-get update && apt-get install -y \
11
+ curl \
12
+ libgl1 \
13
+ libglib2.0-0 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Installer uv
17
+ RUN curl -LsSf https://astral.sh/uv/install.sh | sh
18
+ ENV PATH="/root/.local/bin:$PATH"
19
+
20
+ # Copier les fichiers de dépendances
21
+ COPY pyproject.toml uv.lock ./
22
+
23
+ # Installer les deps (prod only)
24
+ RUN uv sync --no-dev
25
+
26
+ # Copier le code
27
+ COPY app.py .
28
+
29
+ EXPOSE 7860
30
+
31
+ CMD ["uv", "run", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Reachy Vision API
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Reachy Vision API
11
+
12
+ **FastAPI-based YOLOv8 vision API for Reachy robots.**
13
+
14
+ Reachy Vision API is a lightweight object detection service built with **FastAPI** and **YOLOv8**, designed to run on **Hugging Face Spaces (Docker)** or locally, and to be easily integrated with **Reachy robots** or any backend.
15
+
16
+ ---
17
+
18
+ ## ✨ Features
19
+
20
+ - 🔍 Object detection powered by **YOLOv8**
21
+ - ⚡ FastAPI HTTP API (simple & stateless)
22
+ - 🐳 Hugging Face **Docker Space** compatible
23
+ - 🧠 CPU-friendly (`yolov8n` by default)
24
+ - 🤖 Ready to integrate with **Reachy Mini**
25
+ - 📦 Dependency management with **uv**
26
+
27
+ ---
28
+
29
+ ## 📡 API Endpoints
30
+
31
+ ### Health check
32
+ ```
33
+ GET /health
34
+ ```
35
+
36
+ Response:
37
+ ```json
38
+ { "status": "ok" }
39
+ ```
40
+
41
+ ---
42
+
43
+ ### Object detection
44
+ ```
45
+ POST /detect
46
+ ```
47
+
48
+ **Request**
49
+ - `multipart/form-data`
50
+ - Field: `file` (image)
51
+
52
+ **Example**
53
+ ```bash
54
+ curl -X POST \
55
+ -F "file=@image.jpg" \
56
+ http://localhost:7860/detect
57
+ ```
58
+
59
+ **Response**
60
+ ```json
61
+ {
62
+ "num_detections": 2,
63
+ "detections": [
64
+ {
65
+ "class_id": 0,
66
+ "class_name": "person",
67
+ "confidence": 0.92,
68
+ "bbox_xyxy": [120.3, 45.1, 380.7, 620.9]
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ ---
75
+
76
+ ## 🚀 Deployment (Hugging Face Space)
77
+
78
+ Recommended setup:
79
+
80
+ - **Space type**: `Docker`
81
+ - **Hardware**: CPU (default) or GPU
82
+ - **Exposed port**: `7860`
83
+
84
+ ### Repository structure
85
+
86
+ ```
87
+ reachy-vision-api/
88
+ ├── app.py # FastAPI application
89
+ ├── Dockerfile # Docker image definition
90
+ ├── pyproject.toml # Project configuration and dependencies
91
+ ├── uv.lock # Lockfile for reproducible builds
92
+ ├── .gitignore # Git ignore rules
93
+ ├── tests/ # Test suite
94
+ │ ├── __init__.py
95
+ │ ├── conftest.py # Pytest fixtures
96
+ │ └── test_api.py # API tests
97
+ └── README.md
98
+ ```
99
+
100
+ Once pushed, the Space will automatically build and expose:
101
+ ```
102
+ https://<username>-<space-name>.hf.space
103
+ ```
104
+
105
+ ---
106
+
107
+ ## 🐳 Docker (local run)
108
+
109
+ ```bash
110
+ docker build -t reachy-vision-api .
111
+ docker run -p 7860:7860 reachy-vision-api
112
+ ```
113
+
114
+ ---
115
+
116
+ ## 📦 Dependencies
117
+
118
+ Dependencies are managed using **uv**.
119
+
120
+ Main dependencies:
121
+ - `fastapi`
122
+ - `uvicorn`
123
+ - `ultralytics`
124
+ - `pillow`
125
+ - `python-multipart`
126
+
127
+ The lockfile (`uv.lock`) ensures reproducible builds.
128
+
129
+ ---
130
+
131
+ ## 🛠️ Development
132
+
133
+ Install dev dependencies:
134
+ ```bash
135
+ uv sync --extra dev
136
+ ```
137
+
138
+ ### Tools
139
+
140
+ - **ruff** - Linter and formatter
141
+ - **mypy** - Static type checker
142
+ - **pytest** - Testing framework
143
+ - **pytest-cov** - Code coverage
144
+
145
+ ### Run tests
146
+ ```bash
147
+ uv run pytest
148
+ ```
149
+
150
+ Coverage report is generated in `htmlcov/` and displayed in terminal.
151
+
152
+ ### Lint and format
153
+ ```bash
154
+ uv run ruff check .
155
+ uv run ruff format .
156
+ ```
157
+
158
+ ### Type checking
159
+ ```bash
160
+ uv run mypy app.py
161
+ ```
162
+
163
+ ### Release workflow
164
+
165
+ This project uses [commitizen](https://commitizen-tools.github.io/commitizen/) for versioning and changelog generation.
166
+
167
+ To trigger a new release, push a commit to `main` with the message `chore: release a new version`:
168
+
169
+ ```bash
170
+ git commit --allow-empty -m "chore: release a new version"
171
+ git push origin main
172
+ ```
173
+
174
+ This will:
175
+ 1. Bump the version based on conventional commits
176
+ 2. Generate/update the CHANGELOG
177
+ 3. Create a GitHub Release
178
+ 4. Sync to Hugging Face Space
179
+
180
+ ---
181
+
182
+ ## 🤖 Usage with Reachy
183
+
184
+ This API is designed to be called from:
185
+ - Reachy Mini
186
+ - A central VPS backend
187
+ - Another Hugging Face Space
188
+
189
+ Typical flow:
190
+ 1. Capture image from Reachy camera
191
+ 2. Send image to `/detect`
192
+ 3. Use detections for interaction, navigation, or reasoning
193
+
194
+ ---
195
+
196
+ ## 📄 License
197
+
198
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
app.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import FastAPI, File, UploadFile
6
+ from PIL import Image
7
+ from ultralytics import YOLO
8
+
9
+ logger = logging.getLogger("uvicorn.error")
10
+
11
+ model: YOLO | None = None
12
+
13
+
14
+ @asynccontextmanager
15
+ async def lifespan(app: FastAPI):
16
+ global model
17
+ logger.info("Loading YOLO model...")
18
+ model = YOLO("yolov8n.pt")
19
+ logger.info("Model loaded successfully!")
20
+ yield
21
+ logger.info("Shutting down...")
22
+
23
+
24
+ app = FastAPI(title="Reachy Vision API", lifespan=lifespan)
25
+
26
+
27
+ @app.get("/health", include_in_schema=False)
28
+ def health():
29
+ return {"status": "ok"}
30
+
31
+
32
+ @app.post("/detect")
33
+ async def detect(file: UploadFile = File(...)):
34
+ if model is None:
35
+ raise RuntimeError("Model not loaded")
36
+
37
+ image_bytes = await file.read()
38
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
39
+
40
+ results = model(image)
41
+
42
+ detections = []
43
+ for r in results:
44
+ for box in r.boxes:
45
+ detections.append(
46
+ {
47
+ "class_id": int(box.cls),
48
+ "class_name": model.names[int(box.cls)],
49
+ "confidence": float(box.conf),
50
+ "bbox_xyxy": [float(x) for x in box.xyxy[0]],
51
+ }
52
+ )
53
+
54
+ return {"num_detections": len(detections), "detections": detections}
pyproject.toml ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "reachy-vision-api"
3
+ version = "0.0.0"
4
+ description = " Lightweight YOLOv8 vision API for real-time object detection on Reachy robots."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "fastapi",
9
+ "uvicorn",
10
+ "ultralytics",
11
+ "pillow",
12
+ "python-multipart"
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "ruff",
18
+ "mypy",
19
+ "pytest",
20
+ "pytest-cov",
21
+ "httpx",
22
+ ]
23
+
24
+ [tool.ruff]
25
+ line-length = 88
26
+ target-version = "py312"
27
+
28
+ [tool.ruff.lint]
29
+ select = ["E", "F", "I", "W"]
30
+
31
+ [tool.mypy]
32
+ python_version = "3.12"
33
+ warn_return_any = true
34
+ warn_unused_configs = true
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ addopts = "--cov=app --cov-report=term-missing --cov-report=html"
39
+
40
+ [tool.coverage.run]
41
+ source = ["app"]
42
+ omit = ["tests/*"]
43
+
44
+ [tool.coverage.report]
45
+ exclude_lines = [
46
+ "pragma: no cover",
47
+ "if __name__ == .__main__.:",
48
+ ]
49
+
50
+ [tool.commitizen]
51
+ name = "cz_conventional_commits"
52
+ version = "0.0.0"
53
+ version_files = ["pyproject.toml:^version"]
54
+ tag_format = "v$version"
55
+ update_changelog_on_bump = true
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi.testclient import TestClient
3
+
4
+ from app import app
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def client():
9
+ with TestClient(app) as c:
10
+ yield c
tests/test_api.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ from unittest.mock import MagicMock
3
+
4
+ import pytest
5
+ import torch
6
+ from PIL import Image
7
+
8
+ import app as app_module
9
+
10
+
11
+ def test_health(client):
12
+ response = client.get("/health")
13
+ assert response.status_code == 200
14
+ assert response.json() == {"status": "ok"}
15
+
16
+
17
+ def test_detect_returns_detections(client):
18
+ img = Image.new("RGB", (640, 480), color="red")
19
+ buffer = io.BytesIO()
20
+ img.save(buffer, format="PNG")
21
+ buffer.seek(0)
22
+
23
+ response = client.post("/detect", files={"file": ("test.png", buffer, "image/png")})
24
+
25
+ assert response.status_code == 200
26
+ data = response.json()
27
+ assert "num_detections" in data
28
+ assert "detections" in data
29
+ assert isinstance(data["detections"], list)
30
+
31
+
32
+ def test_detect_with_jpeg(client):
33
+ img = Image.new("RGB", (640, 480), color="blue")
34
+ buffer = io.BytesIO()
35
+ img.save(buffer, format="JPEG")
36
+ buffer.seek(0)
37
+
38
+ response = client.post(
39
+ "/detect", files={"file": ("test.jpg", buffer, "image/jpeg")}
40
+ )
41
+
42
+ assert response.status_code == 200
43
+ data = response.json()
44
+ assert "num_detections" in data
45
+ assert "detections" in data
46
+
47
+
48
+ def test_detect_missing_file(client):
49
+ response = client.post("/detect")
50
+ assert response.status_code == 422
51
+
52
+
53
+ def test_detect_response_structure(client):
54
+ img = Image.new("RGB", (100, 100), color="green")
55
+ buffer = io.BytesIO()
56
+ img.save(buffer, format="PNG")
57
+ buffer.seek(0)
58
+
59
+ response = client.post("/detect", files={"file": ("test.png", buffer, "image/png")})
60
+
61
+ assert response.status_code == 200
62
+ data = response.json()
63
+ assert isinstance(data["num_detections"], int)
64
+ assert data["num_detections"] >= 0
65
+ assert data["num_detections"] == len(data["detections"])
66
+
67
+ for detection in data["detections"]:
68
+ assert "class_id" in detection
69
+ assert "class_name" in detection
70
+ assert "confidence" in detection
71
+ assert "bbox_xyxy" in detection
72
+ assert isinstance(detection["class_id"], int)
73
+ assert isinstance(detection["class_name"], str)
74
+ assert 0 <= detection["confidence"] <= 1
75
+ assert len(detection["bbox_xyxy"]) == 4
76
+
77
+
78
+ def test_detect_model_not_loaded(client):
79
+ """Test that detect raises error when model is not loaded."""
80
+ original_model = app_module.model
81
+ app_module.model = None
82
+
83
+ try:
84
+ img = Image.new("RGB", (100, 100), color="red")
85
+ buffer = io.BytesIO()
86
+ img.save(buffer, format="PNG")
87
+ buffer.seek(0)
88
+
89
+ with pytest.raises(RuntimeError, match="Model not loaded"):
90
+ client.post("/detect", files={"file": ("test.png", buffer, "image/png")})
91
+ finally:
92
+ app_module.model = original_model
93
+
94
+
95
+ def test_detect_with_detections(client):
96
+ """Test detection with mocked YOLO results containing detections."""
97
+ original_model = app_module.model
98
+
99
+ # Create mock model
100
+ mock_model = MagicMock()
101
+ mock_model.names = {0: "person", 1: "car"}
102
+
103
+ # Create mock box
104
+ mock_box = MagicMock()
105
+ mock_box.cls = torch.tensor([0])
106
+ mock_box.conf = torch.tensor([0.95])
107
+ mock_box.xyxy = torch.tensor([[100.0, 150.0, 300.0, 400.0]])
108
+
109
+ # Create mock result
110
+ mock_result = MagicMock()
111
+ mock_result.boxes = [mock_box]
112
+
113
+ mock_model.return_value = [mock_result]
114
+ app_module.model = mock_model
115
+
116
+ try:
117
+ img = Image.new("RGB", (640, 480), color="red")
118
+ buffer = io.BytesIO()
119
+ img.save(buffer, format="PNG")
120
+ buffer.seek(0)
121
+
122
+ response = client.post(
123
+ "/detect", files={"file": ("test.png", buffer, "image/png")}
124
+ )
125
+
126
+ assert response.status_code == 200
127
+ data = response.json()
128
+ assert data["num_detections"] == 1
129
+ assert len(data["detections"]) == 1
130
+ assert data["detections"][0]["class_id"] == 0
131
+ assert data["detections"][0]["class_name"] == "person"
132
+ assert data["detections"][0]["confidence"] == pytest.approx(0.95)
133
+ assert data["detections"][0]["bbox_xyxy"] == pytest.approx(
134
+ [100.0, 150.0, 300.0, 400.0]
135
+ )
136
+ finally:
137
+ app_module.model = original_model
uv.lock ADDED
The diff for this file is too large to render. See raw diff