let segmentation honour its post-processing configuration

Segmentation reuses the detection suppressor but passed only the IoU threshold,
so it silently fell back to the hardcoded containment constant while detection
had just been given a configured one. Tuning containment for a promoted model
would have changed detection behaviour and left segmentation on the old value —
the same drift, one commit later.

Masks and boxes overlap differently, so segmentation carries its own setting
rather than borrowing the detector's, and records both thresholds on the run as
detection does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 21:45:33 +02:00
co-authored by Claude Opus 5
parent c8d32a4801
commit a2a8775df1
4 changed files with 75 additions and 0 deletions
@@ -0,0 +1,61 @@
"""Segmentation must honour the same post-processing configuration as detection.
Segmentation reuses the detection suppressor but passed only the IoU threshold,
so it silently fell back to the hardcoded containment constant while detection
read a configured one. A deployment tuning containment for a promoted model
changed detection behaviour and left segmentation on the old value.
"""
from __future__ import annotations
import pytest
from shapely.geometry import box
from app.core.config import Settings
def test_segmentation_has_its_own_containment_setting() -> None:
settings = Settings(_env_file=None)
assert settings.segmentation_containment_nms_threshold == pytest.approx(0.85)
def test_the_setting_is_independent_of_the_detection_one() -> None:
"""Masks and boxes overlap differently; one value need not fit both."""
settings = Settings(
_env_file=None,
yolo_containment_nms_threshold=0.7,
segmentation_containment_nms_threshold=0.95,
)
assert settings.yolo_containment_nms_threshold == pytest.approx(0.7)
assert settings.segmentation_containment_nms_threshold == pytest.approx(0.95)
def test_the_configured_value_reaches_the_suppressor() -> None:
from app.services.detection_service import DetectionService
def candidate(name: str, geometry, confidence: float):
return {
"class_name": "building",
"confidence": confidence,
"geometry": geometry,
"bbox": [0.0, 0.0, 1.0, 1.0],
"source_tile_path": f"/tiles/{name}.tif",
"properties": {"name": name},
}
outer = candidate("outer", box(0, 0, 10, 10), 0.9)
# Containment 0.9, IoU 0.09: only the containment rule can act on this pair.
mostly_nested = candidate("mostly", box(8.2, 1, 10.2, 6), 0.5)
strict = DetectionService._suppress_duplicate_candidates(
[outer, mostly_nested], iou_threshold=0.5, containment_threshold=0.95
)
loose = DetectionService._suppress_duplicate_candidates(
[outer, mostly_nested], iou_threshold=0.5, containment_threshold=0.7
)
assert len(strict) == 2
assert len(loose) == 1