Skip to content

Commit 0990fa1

Browse files
committed
Strengthen backend quality gates
1 parent 99be045 commit 0990fa1

File tree

14 files changed

+168
-38
lines changed

14 files changed

+168
-38
lines changed

.github/workflows/template-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979
python -m pip install --upgrade pip
8080
python -m pip install -e ./backend[dev]
8181
82+
- name: Lint backend
83+
run: python -m ruff check .
84+
working-directory: backend
85+
8286
- name: Run backend tests
8387
run: python -m pytest
8488
working-directory: backend

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ npm run check
6161
What they cover:
6262

6363
- `check:contract`: regenerates frontend API types and fails if generated files drift from `docs/openapi.yaml`
64-
- `check`: frontend lint, frontend typecheck, frontend production build, backend tests, backend bytecode compile
64+
- `check`: frontend lint, frontend typecheck, frontend production build, backend Ruff lint, backend tests, backend bytecode compile
6565

6666
## Changing the API Contract
6767

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ The root check runs:
103103
- frontend lint
104104
- frontend typecheck
105105
- frontend production build
106+
- backend Ruff lint
106107
- backend `pytest`
107108
- backend `compileall`
108109

backend/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
2626
## Test
2727

2828
```bash
29+
python -m ruff check .
2930
python -m pytest
3031
```

backend/app/vision/service.py

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
Detection,
1212
ImageMetadata,
1313
Metric,
14-
PolygonPoint,
1514
PipelineSummary,
15+
PolygonPoint,
1616
SegmentationRegion,
1717
)
1818
from app.vision.pipelines import PipelineDefinition
@@ -45,6 +45,40 @@ def _contour_to_polygon(contour: np.ndarray) -> list[PolygonPoint]:
4545
]
4646

4747

48+
def _partition_significant_contours(
49+
contours: list[np.ndarray],
50+
image_area: int,
51+
) -> tuple[list[np.ndarray], list[np.ndarray]]:
52+
significant: list[np.ndarray] = []
53+
near_full_frame: list[np.ndarray] = []
54+
55+
for contour in contours:
56+
area_ratio = cv2.contourArea(contour) / image_area
57+
if area_ratio < 0.02:
58+
continue
59+
60+
x, y, width, height = cv2.boundingRect(contour)
61+
box_area_ratio = (width * height) / image_area
62+
if area_ratio >= 0.9 or box_area_ratio >= 0.94:
63+
near_full_frame.append(contour)
64+
continue
65+
66+
significant.append(contour)
67+
68+
return significant, near_full_frame
69+
70+
71+
def _select_significant_contours(
72+
contours: list[np.ndarray],
73+
image_area: int,
74+
) -> list[np.ndarray]:
75+
significant, near_full_frame = _partition_significant_contours(
76+
contours,
77+
image_area,
78+
)
79+
return significant or near_full_frame[:1]
80+
81+
4882
def _starter_detection(image: np.ndarray) -> tuple[list[dict], list[dict], list[dict]]:
4983
grayscale = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
5084
blurred = cv2.GaussianBlur(grayscale, (5, 5), 0)
@@ -151,33 +185,43 @@ def _foreground_segmentation(
151185

152186
best_mask = thresholded
153187
best_score = -1.0
188+
best_has_non_full_contours = False
154189

155190
for candidate in candidates:
156191
cleaned = cv2.morphologyEx(
157192
candidate,
158193
cv2.MORPH_OPEN,
159194
np.ones((5, 5), dtype=np.uint8),
160195
)
161-
contours, _ = cv2.findContours(
196+
raw_contours, _ = cv2.findContours(
162197
cleaned,
163198
cv2.RETR_EXTERNAL,
164199
cv2.CHAIN_APPROX_SIMPLE,
165200
)
166-
significant = [
167-
contour
168-
for contour in contours
169-
if cv2.contourArea(contour) / image_area >= 0.02
170-
]
171-
score = sum(cv2.contourArea(contour) for contour in significant) / image_area
172-
if score > best_score:
201+
significant, near_full_frame = _partition_significant_contours(
202+
raw_contours,
203+
image_area,
204+
)
205+
candidate_contours = significant or near_full_frame[:1]
206+
has_non_full_contours = bool(significant)
207+
score = (
208+
sum(cv2.contourArea(contour) for contour in candidate_contours) / image_area
209+
)
210+
if (
211+
has_non_full_contours and not best_has_non_full_contours
212+
) or (
213+
has_non_full_contours == best_has_non_full_contours and score > best_score
214+
):
173215
best_score = score
174216
best_mask = cleaned
217+
best_has_non_full_contours = has_non_full_contours
175218

176-
contours, _ = cv2.findContours(
219+
raw_contours, _ = cv2.findContours(
177220
best_mask,
178221
cv2.RETR_EXTERNAL,
179222
cv2.CHAIN_APPROX_SIMPLE,
180223
)
224+
contours = _select_significant_contours(raw_contours, image_area)
181225

182226
detections = []
183227
segmentations = []
@@ -242,7 +286,10 @@ def _dominant_color(image: np.ndarray) -> tuple[list[dict], list[dict], list[dic
242286
"starter-detection": PipelineDefinition(
243287
id="starter-detection",
244288
name="Starter Detection",
245-
summary="Detection-first sample pipeline that returns object-style boxes and confidence scores.",
289+
summary=(
290+
"Detection-first sample pipeline that returns object-style boxes "
291+
"and confidence scores."
292+
),
246293
tags=["detection", "default", "cpu"],
247294
runtime="opencv-cpu",
248295
sample_outputs=["object boxes", "confidence scores", "coverage metrics"],
@@ -251,7 +298,10 @@ def _dominant_color(image: np.ndarray) -> tuple[list[dict], list[dict], list[dic
251298
"document-layout": PipelineDefinition(
252299
id="document-layout",
253300
name="Document Layout",
254-
summary="Document-oriented box extraction for capture, scanning, and kiosk workflows.",
301+
summary=(
302+
"Document-oriented box extraction for capture, scanning, and kiosk "
303+
"workflows."
304+
),
255305
tags=["detection", "document", "cpu"],
256306
runtime="opencv-cpu",
257307
sample_outputs=["quadrilateral candidates", "layout blocks"],
@@ -260,7 +310,10 @@ def _dominant_color(image: np.ndarray) -> tuple[list[dict], list[dict], list[dic
260310
"foreground-segmentation": PipelineDefinition(
261311
id="foreground-segmentation",
262312
name="Foreground Segmentation",
263-
summary="Segmentation extension pipeline that returns region polygons plus detection-style boxes.",
313+
summary=(
314+
"Segmentation extension pipeline that returns region polygons plus "
315+
"detection-style boxes."
316+
),
264317
tags=["segmentation", "extension", "cpu"],
265318
runtime="opencv-cpu",
266319
sample_outputs=["region polygons", "mask coverage", "derived boxes"],
@@ -269,7 +322,10 @@ def _dominant_color(image: np.ndarray) -> tuple[list[dict], list[dict], list[dic
269322
"dominant-color": PipelineDefinition(
270323
id="dominant-color",
271324
name="Dominant Color",
272-
summary="Metrics-only extension pipeline for quality and image analytics workflows.",
325+
summary=(
326+
"Metrics-only extension pipeline for quality and image analytics "
327+
"workflows."
328+
),
273329
tags=["analytics", "extension", "cpu"],
274330
runtime="opencv-cpu",
275331
sample_outputs=["channel metrics", "brightness"],

backend/pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,18 @@ dependencies = [
2121
dev = [
2222
"httpx>=0.28,<1.0",
2323
"pytest>=8.3,<9.0",
24+
"ruff>=0.11,<1.0",
2425
]
2526

2627
[tool.hatch.build.targets.wheel]
2728
packages = ["app"]
2829

2930
[tool.pytest.ini_options]
3031
testpaths = ["tests"]
32+
33+
[tool.ruff]
34+
line-length = 88
35+
target-version = "py312"
36+
37+
[tool.ruff.lint]
38+
select = ["E", "F", "I"]
2.08 KB
Loading
4.05 KB
Loading
1.96 KB
Loading

backend/tests/helpers.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from pathlib import Path
2+
3+
FIXTURE_DIR = Path(__file__).parent / "fixtures"
4+
5+
6+
def fixture_path(name: str) -> Path:
7+
return FIXTURE_DIR / name
8+
9+
10+
def fixture_bytes(name: str) -> bytes:
11+
return fixture_path(name).read_bytes()

0 commit comments

Comments
 (0)