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