Skip to content

Commit 9db37ce

Browse files
committed
Add docker and snapshot checks
1 parent 0990fa1 commit 9db37ce

File tree

15 files changed

+389
-6
lines changed

15 files changed

+389
-6
lines changed

.github/workflows/template-ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,16 @@ jobs:
131131

132132
- name: Run root verification
133133
run: npm run check
134+
135+
docker-images:
136+
name: Docker Images
137+
runs-on: ubuntu-latest
138+
steps:
139+
- name: Checkout
140+
uses: actions/checkout@v4
141+
142+
- name: Build backend image
143+
run: docker build --target runner --tag cv-kit-backend:ci ./backend
144+
145+
- name: Build frontend image
146+
run: docker build --target runner --tag cv-kit-frontend:ci ./frontend

CONTRIBUTING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ What they cover:
6363
- `check:contract`: regenerates frontend API types and fails if generated files drift from `docs/openapi.yaml`
6464
- `check`: frontend lint, frontend typecheck, frontend production build, backend Ruff lint, backend tests, backend bytecode compile
6565

66+
If Docker is available locally, you can also verify the production-style images:
67+
68+
```bash
69+
npm run check:images
70+
```
71+
6672
## Changing the API Contract
6773

6874
If you modify request or response shapes:

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ npm run dev
9393
npm run dev:down
9494
npm run api:types
9595
npm run check:contract
96+
npm run check:images
9697
npm run check
9798
```
9899

@@ -107,6 +108,8 @@ The root check runs:
107108
- backend `pytest`
108109
- backend `compileall`
109110

111+
`check:images` is separate and intended for environments where a Docker daemon is available.
112+
110113
## Contract Notes
111114

112115
- `docs/openapi.yaml` is the source of truth for the HTTP contract.

backend/.dockerignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.venv/
2+
.pytest_cache/
3+
__pycache__/
4+
tests/
5+
*.pyc
6+
*.pyo

backend/Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM python:3.12-slim AS base
2+
3+
ENV PYTHONDONTWRITEBYTECODE=1 \
4+
PYTHONUNBUFFERED=1 \
5+
PIP_NO_CACHE_DIR=1
6+
7+
WORKDIR /app
8+
9+
RUN apt-get update \
10+
&& apt-get install --yes --no-install-recommends libglib2.0-0 libgl1 \
11+
&& rm -rf /var/lib/apt/lists/*
12+
13+
FROM base AS dev
14+
15+
COPY pyproject.toml README.md ./
16+
COPY app ./app
17+
18+
RUN python -m pip install --upgrade pip \
19+
&& python -m pip install -e ".[dev]"
20+
21+
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
22+
23+
FROM base AS runner
24+
25+
COPY pyproject.toml README.md ./
26+
COPY app ./app
27+
28+
RUN python -m pip install --upgrade pip \
29+
&& python -m pip install .
30+
31+
EXPOSE 8000
32+
33+
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

backend/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
2929
python -m ruff check .
3030
python -m pytest
3131
```
32+
33+
The backend test suite now includes committed image fixtures and snapshot-backed API
34+
regression checks for the starter inference routes.

backend/tests/helpers.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import json
12
from pathlib import Path
23

34
FIXTURE_DIR = Path(__file__).parent / "fixtures"
5+
SNAPSHOT_DIR = Path(__file__).parent / "snapshots"
46

57

68
def fixture_path(name: str) -> Path:
@@ -9,3 +11,14 @@ def fixture_path(name: str) -> Path:
911

1012
def fixture_bytes(name: str) -> bytes:
1113
return fixture_path(name).read_bytes()
14+
15+
16+
def snapshot_json(name: str) -> dict:
17+
return json.loads((SNAPSHOT_DIR / name).read_text(encoding="utf-8"))
18+
19+
20+
def normalize_analysis_payload(payload: dict) -> dict:
21+
normalized = json.loads(json.dumps(payload))
22+
normalized["analysis_id"] = "<analysis-id>"
23+
normalized["generated_at"] = "<generated-at>"
24+
return normalized
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
{
2+
"analysis_id": "<analysis-id>",
3+
"pipeline": {
4+
"id": "foreground-segmentation",
5+
"name": "Foreground Segmentation",
6+
"summary": "Segmentation extension pipeline that returns region polygons plus detection-style boxes.",
7+
"tags": [
8+
"segmentation",
9+
"extension",
10+
"cpu"
11+
],
12+
"runtime": "opencv-cpu",
13+
"sample_outputs": [
14+
"region polygons",
15+
"mask coverage",
16+
"derived boxes"
17+
]
18+
},
19+
"image": {
20+
"filename": "segmentation-scene.png",
21+
"content_type": "image/png",
22+
"width": 320,
23+
"height": 240
24+
},
25+
"detections": [
26+
{
27+
"label": "segment-region",
28+
"confidence": 0.734,
29+
"box": {
30+
"x": 51,
31+
"y": 71,
32+
"width": 107,
33+
"height": 107
34+
},
35+
"area_ratio": 0.117
36+
},
37+
{
38+
"label": "segment-region",
39+
"confidence": 0.651,
40+
"box": {
41+
"x": 197,
42+
"y": 74,
43+
"width": 87,
44+
"height": 130
45+
},
46+
"area_ratio": 0.0753
47+
}
48+
],
49+
"segmentations": [
50+
{
51+
"label": "segment-region",
52+
"confidence": 0.734,
53+
"polygon": [
54+
{
55+
"x": 94,
56+
"y": 71
57+
},
58+
{
59+
"x": 60,
60+
"y": 93
61+
},
62+
{
63+
"x": 51,
64+
"y": 134
65+
},
66+
{
67+
"x": 73,
68+
"y": 168
69+
},
70+
{
71+
"x": 114,
72+
"y": 177
73+
},
74+
{
75+
"x": 148,
76+
"y": 155
77+
},
78+
{
79+
"x": 157,
80+
"y": 114
81+
},
82+
{
83+
"x": 135,
84+
"y": 80
85+
}
86+
],
87+
"box": {
88+
"x": 51,
89+
"y": 71,
90+
"width": 107,
91+
"height": 107
92+
},
93+
"area_ratio": 0.117
94+
},
95+
{
96+
"label": "segment-region",
97+
"confidence": 0.651,
98+
"polygon": [
99+
{
100+
"x": 212,
101+
"y": 74
102+
},
103+
{
104+
"x": 197,
105+
"y": 203
106+
},
107+
{
108+
"x": 283,
109+
"y": 156
110+
}
111+
],
112+
"box": {
113+
"x": 197,
114+
"y": 74,
115+
"width": 87,
116+
"height": 130
117+
},
118+
"area_ratio": 0.0753
119+
}
120+
],
121+
"metrics": [
122+
{
123+
"name": "segmented_regions",
124+
"value": 2,
125+
"unit": null
126+
},
127+
{
128+
"name": "segmented_coverage",
129+
"value": 0.1923,
130+
"unit": null
131+
}
132+
],
133+
"generated_at": "<generated-at>"
134+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"analysis_id": "<analysis-id>",
3+
"pipeline": {
4+
"id": "starter-detection",
5+
"name": "Starter Detection",
6+
"summary": "Detection-first sample pipeline that returns object-style boxes and confidence scores.",
7+
"tags": [
8+
"detection",
9+
"default",
10+
"cpu"
11+
],
12+
"runtime": "opencv-cpu",
13+
"sample_outputs": [
14+
"object boxes",
15+
"confidence scores",
16+
"coverage metrics"
17+
]
18+
},
19+
"image": {
20+
"filename": "detection-scene.png",
21+
"content_type": "image/png",
22+
"width": 320,
23+
"height": 240
24+
},
25+
"detections": [
26+
{
27+
"label": "primary-object",
28+
"confidence": 0.98,
29+
"box": {
30+
"x": 27,
31+
"y": 33,
32+
"width": 188,
33+
"height": 150
34+
},
35+
"area_ratio": 0.3672
36+
},
37+
{
38+
"label": "object-candidate",
39+
"confidence": 0.614,
40+
"box": {
41+
"x": 222,
42+
"y": 134,
43+
"width": 73,
44+
"height": 73
45+
},
46+
"area_ratio": 0.0694
47+
}
48+
],
49+
"segmentations": [],
50+
"metrics": [
51+
{
52+
"name": "edge_density",
53+
"value": 0.012,
54+
"unit": null
55+
},
56+
{
57+
"name": "object_candidates",
58+
"value": 2,
59+
"unit": null
60+
},
61+
{
62+
"name": "largest_detection_ratio",
63+
"value": 0.3672,
64+
"unit": null
65+
}
66+
],
67+
"generated_at": "<generated-at>"
68+
}

backend/tests/test_inference_route.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from fastapi.testclient import TestClient
22

33
from app.main import app
4-
from tests.helpers import fixture_bytes
4+
from tests.helpers import fixture_bytes, normalize_analysis_payload, snapshot_json
55

66
client = TestClient(app)
77

88

9-
def test_analyze_route_accepts_fixture_upload() -> None:
9+
def test_analyze_route_matches_starter_detection_snapshot() -> None:
1010
response = client.post(
1111
"/api/v1/analyze",
1212
data={"pipeline_id": "starter-detection"},
@@ -20,10 +20,28 @@ def test_analyze_route_accepts_fixture_upload() -> None:
2020
)
2121

2222
assert response.status_code == 200
23-
payload = response.json()
24-
assert payload["pipeline"]["id"] == "starter-detection"
25-
assert payload["image"]["filename"] == "detection-scene.png"
26-
assert len(payload["detections"]) >= 2
23+
assert normalize_analysis_payload(response.json()) == snapshot_json(
24+
"starter-detection-response.json"
25+
)
26+
27+
28+
def test_analyze_route_matches_segmentation_snapshot() -> None:
29+
response = client.post(
30+
"/api/v1/analyze",
31+
data={"pipeline_id": "foreground-segmentation"},
32+
files={
33+
"file": (
34+
"segmentation-scene.png",
35+
fixture_bytes("segmentation-scene.png"),
36+
"image/png",
37+
)
38+
},
39+
)
40+
41+
assert response.status_code == 200
42+
assert normalize_analysis_payload(response.json()) == snapshot_json(
43+
"foreground-segmentation-response.json"
44+
)
2745

2846

2947
def test_analyze_route_rejects_non_image_upload() -> None:

0 commit comments

Comments
 (0)