From a2a8775df1073a803d5142f90cca24e1fe41d239 Mon Sep 17 00:00:00 2001 From: Jens Date: Sat, 22 Aug 2026 21:45:33 +0200 Subject: [PATCH] let segmentation honour its post-processing configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 3 + backend/app/core/config.py | 8 +++ backend/app/services/segmentation_service.py | 3 + ...test_segmentation_postprocessing_config.py | 61 +++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 backend/tests/test_segmentation_postprocessing_config.py diff --git a/.env.example b/.env.example index cd30adc7..7f3e31af 100644 --- a/.env.example +++ b/.env.example @@ -143,6 +143,9 @@ YOLO_SUPPRESS_TILE_EDGE_DETECTIONS=true # Serving a promoted model at a different value means the runtime suppresses # detections its gate counted, so set this to the value the candidate was gated at. YOLO_CONTAINMENT_NMS_THRESHOLD=0.85 +# Segmentation carries its own value: masks and boxes overlap differently, +# so one threshold need not fit both. +SEGMENTATION_CONTAINMENT_NMS_THRESHOLD=0.85 # Local segmentation models. GeoIntel never downloads model weights # automatically; point these to existing local files to enable inference. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 4b94ab55..b1829ac6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -415,6 +415,14 @@ class Settings(BaseSettings): ) sam_model_version: str | None = Field(default=None, validation_alias="SAM_MODEL_VERSION") segmentation_max_masks_per_tile: int = Field(default=300, ge=1, validation_alias="SEGMENTATION_MAX_MASKS_PER_TILE") + # Masks and boxes overlap differently, so segmentation carries its own + # containment value rather than borrowing the detector's. + segmentation_containment_nms_threshold: float = Field( + default=0.85, + ge=0.0, + le=1.0, + validation_alias="SEGMENTATION_CONTAINMENT_NMS_THRESHOLD", + ) segmentation_duplicate_iou_threshold: float = Field( default=0.5, ge=0.0, diff --git a/backend/app/services/segmentation_service.py b/backend/app/services/segmentation_service.py index 999a7c4d..22008b18 100644 --- a/backend/app/services/segmentation_service.py +++ b/backend/app/services/segmentation_service.py @@ -784,6 +784,7 @@ class SegmentationService: filtered_candidates = DetectionService._suppress_duplicate_candidates( candidates, iou_threshold=float(settings.segmentation_duplicate_iou_threshold), + containment_threshold=float(settings.segmentation_containment_nms_threshold), ) persisted: list[Segmentation] = [] for candidate in filtered_candidates: @@ -834,6 +835,8 @@ class SegmentationService: "raw_segmentation_count": len(candidates), "suppressed_segmentation_count": len(candidates) - len(filtered_candidates), "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), + "containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold), + "duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold), "tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()), "runtime_model_provenance": runtime_model_provenance.as_dict(), } diff --git a/backend/tests/test_segmentation_postprocessing_config.py b/backend/tests/test_segmentation_postprocessing_config.py new file mode 100644 index 00000000..561efef0 --- /dev/null +++ b/backend/tests/test_segmentation_postprocessing_config.py @@ -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