Suppress duplicate YOLO tile detections
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-09 10:55:45 +02:00
parent 638534f011
commit 3f5ca4c85e
24 changed files with 266 additions and 39 deletions
+10 -1
View File
@@ -360,6 +360,7 @@ YOLO_DEVICE=cpu
YOLO_IMAGE_SIZE=640
YOLO_MAX_TILES=100
YOLO_MAX_DETECTIONS=1000
YOLO_DUPLICATE_IOU_THRESHOLD=0.5
YOLO_BATCH_SIZE=1
```
@@ -389,6 +390,13 @@ The preflight checks configuration, dependency availability, local model file ex
`1000` because dense building AOIs can exceed the upstream default cap of 300
detections before QA/QC can measure recall honestly.
`YOLO_DUPLICATE_IOU_THRESHOLD` controls GeoIntel-side cross-tile duplicate
suppression after YOLO pixel boxes are converted to EPSG:4326 polygons and
before `Detection` rows are persisted. Candidates are sorted by confidence per
class; lower-confidence same-class candidates with geometry IoU greater than or
equal to the threshold are suppressed. The default is `0.5`; set `0` to disable
this post-processing for debugging.
The same read-only status is available through the API and Detection Lab UI:
```bash
@@ -458,7 +466,8 @@ bash scripts/run_detection_calibration_sweep.sh http://192.168.10.150:1202
The sweep creates one real persisted workflow run per threshold, fetches the
persisted `QualityCheck`/`Metric` rows and writes a `calibration_summary.json`
with detection count, score, precision, recall, F1, mean IoU and false
with persisted detection count, raw candidate count, suppressed duplicate count,
duplicate IoU threshold, score, precision, recall, F1, mean IoU and false
positive/negative counts. It is intended to tune confidence/IoU/model choices,
not to add new inference behavior.
+1
View File
@@ -32,6 +32,7 @@ class Settings(BaseSettings):
yolo_image_size: int = Field(default=640, validation_alias="YOLO_IMAGE_SIZE")
yolo_max_tiles: int = Field(default=100, validation_alias="YOLO_MAX_TILES")
yolo_max_detections: int = Field(default=1000, validation_alias="YOLO_MAX_DETECTIONS")
yolo_duplicate_iou_threshold: float = Field(default=0.5, ge=0.0, le=1.0, validation_alias="YOLO_DUPLICATE_IOU_THRESHOLD")
yolo_batch_size: int = Field(default=1, validation_alias="YOLO_BATCH_SIZE")
cors_origins: list[str] | str = Field(
default=["http://localhost:5173", "http://127.0.0.1:5173"],
+79 -27
View File
@@ -138,7 +138,7 @@ class DetectionService:
if model.model_id == resolved_settings.yolo_model_id:
try:
detections = DetectionService._run_configured_yolo(
detections, postprocess_summary = DetectionService._run_configured_yolo(
db=db,
project_id=project_id,
dataset_id=dataset_id,
@@ -165,7 +165,7 @@ class DetectionService:
error_code=exc.code,
message=exc.message,
)
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections))
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections), extra_result=postprocess_summary)
return DetectionRunResponse(
analysis_run_id=analysis_run.id,
job_id=job.id,
@@ -454,8 +454,10 @@ class DetectionService:
db.refresh(job)
@staticmethod
def _mark_success(db, analysis_run: AnalysisRun, job: Job, detection_count: int) -> None:
def _mark_success(db, analysis_run: AnalysisRun, job: Job, detection_count: int, extra_result: dict[str, Any] | None = None) -> None:
result = {"detection_count": detection_count}
if extra_result:
result.update(extra_result)
analysis_run.status = "success"
analysis_run.finished_at = DetectionService._now()
analysis_run.result_json = result
@@ -536,13 +538,13 @@ class DetectionService:
class_filter: list[str],
settings: Settings,
yolo_adapter_class: Type[YoloDetectionAdapter],
) -> list[Detection]:
) -> tuple[list[Detection], dict[str, Any]]:
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
model_path = Path(settings.yolo_model_path or "").expanduser()
adapter = yolo_adapter_class(settings)
model = adapter.load_model(model_path)
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
persisted: list[Detection] = []
candidates: list[dict[str, Any]] = []
manifest_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326"
for tile in manifest["tiles"]:
tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser())
@@ -561,37 +563,87 @@ class DetectionService:
properties = dict(raw.get("properties") or {})
if model_class_name and model_class_name != class_name:
properties.setdefault("model_class_name", model_class_name)
detection = Detection(
id=uuid.uuid4(),
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run.id,
job_id=job.id,
model_name=model_name,
model_version=model_version,
class_name=class_name,
confidence=confidence,
geometry=from_shape(geometry, srid=4326),
bbox_json={
"x_min": float(bbox[0]),
"y_min": float(bbox[1]),
"x_max": float(bbox[2]),
"y_max": float(bbox[3]),
},
source_tile_path=str(tile_path),
properties_json={**properties, "tile_index": tile.get("index")},
candidates.append(
{
"class_name": class_name,
"confidence": confidence,
"geometry": geometry,
"bbox": bbox,
"source_tile_path": str(tile_path),
"properties": {**properties, "tile_index": tile.get("index")},
}
)
db.add(detection)
persisted.append(detection)
filtered_candidates = DetectionService._suppress_duplicate_candidates(
candidates,
iou_threshold=float(settings.yolo_duplicate_iou_threshold),
)
persisted: list[Detection] = []
for candidate in filtered_candidates:
bbox = candidate["bbox"]
detection = Detection(
id=uuid.uuid4(),
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run.id,
job_id=job.id,
model_name=model_name,
model_version=model_version,
class_name=candidate["class_name"],
confidence=candidate["confidence"],
geometry=from_shape(candidate["geometry"], srid=4326),
bbox_json={
"x_min": float(bbox[0]),
"y_min": float(bbox[1]),
"x_max": float(bbox[2]),
"y_max": float(bbox[3]),
},
source_tile_path=candidate["source_tile_path"],
properties_json=candidate["properties"],
)
db.add(detection)
persisted.append(detection)
db.commit()
for detection in persisted:
db.refresh(detection)
return persisted
return persisted, {
"raw_detection_count": len(candidates),
"suppressed_detection_count": len(candidates) - len(filtered_candidates),
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
}
@staticmethod
def _canonical_class_name(value: Any) -> str:
return str(value or "").strip().casefold()
@staticmethod
def _suppress_duplicate_candidates(candidates: list[dict[str, Any]], iou_threshold: float) -> list[dict[str, Any]]:
if iou_threshold <= 0 or len(candidates) < 2:
return candidates
kept: list[dict[str, Any]] = []
for candidate in sorted(candidates, key=lambda item: float(item["confidence"]), reverse=True):
duplicate = False
for kept_candidate in kept:
if candidate["class_name"] != kept_candidate["class_name"]:
continue
if DetectionService._geometry_iou(candidate["geometry"], kept_candidate["geometry"]) >= iou_threshold:
duplicate = True
break
if not duplicate:
kept.append(candidate)
return kept
@staticmethod
def _geometry_iou(left, right) -> float:
if left.is_empty or right.is_empty:
return 0.0
intersection_area = left.intersection(right).area
if intersection_area <= 0:
return 0.0
union_area = left.union(right).area
if union_area <= 0:
return 0.0
return intersection_area / union_area
@staticmethod
def _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int) -> dict[str, Any]:
if not tile_manifest_path:
@@ -94,6 +94,7 @@ def test_env_example_uses_runtime_env_names_read_by_backend_and_frontend() -> No
assert "YOLO_CONFIG_DIR=./storage/ultralytics" in env_example
assert "YOLO_MAX_TILES=100" in env_example
assert "YOLO_MAX_DETECTIONS=1000" in env_example
assert "YOLO_DUPLICATE_IOU_THRESHOLD=0.5" in env_example
assert "ENABLE_YOLO" not in env_example
assert "ENABLE_SAM" not in env_example
assert "VITE_API_BASE_URL=" in env_example
@@ -247,4 +248,5 @@ def test_unraid_deploy_passes_ai_build_arg_and_yolo_runtime_env() -> None:
assert '-e YOLO_MODEL_PATH="$YOLO_MODEL_PATH"' in run_script
assert '-e YOLO_MAX_TILES="$YOLO_MAX_TILES"' in run_script
assert '-e YOLO_MAX_DETECTIONS="$YOLO_MAX_DETECTIONS"' in run_script
assert '-e YOLO_DUPLICATE_IOU_THRESHOLD="$YOLO_DUPLICATE_IOU_THRESHOLD"' in run_script
assert "-v \"${GEOINTEL_MODELS_PATH}:/app/models\"" in run_script
@@ -18,7 +18,11 @@ def test_detection_calibration_sweep_reuses_real_data_workflow_and_reports_qa_me
assert "REAL_RASTER_PATH" in script
assert "REAL_REFERENCE_VECTOR_PATH" in script
assert "/api/v1/projects/${project_id}/quality-checks" in script
assert "/api/v1/detection/runs/${analysis_run_id}" in script
assert "quality_check_id" in script
assert "raw_detection_count" in script
assert "suppressed_detection_count" in script
assert "duplicate_iou_threshold" in script
assert "false_positives" in script
assert "false_negatives" in script
assert "quality_score" in script
@@ -24,7 +24,11 @@ def test_detection_quality_matrix_compares_models_tiles_and_thresholds() -> None
assert "REAL_RASTER_PATH" in script
assert "REAL_REFERENCE_VECTOR_PATH" in script
assert "/api/v1/projects/${project_id}/quality-checks" in script
assert "/api/v1/detection/runs/${analysis_run_id}" in script
assert "quality_matrix_summary.json" in script
assert "raw_detection_count" in script
assert "suppressed_detection_count" in script
assert "duplicate_iou_threshold" in script
assert "best_by_score" in script
assert "best_by_recall" in script
assert "best_by_precision" in script
@@ -66,6 +66,8 @@ def test_multi_sample_detection_quality_matrix_runs_existing_matrix_for_each_sam
assert "best_overall_by_score" in script
assert "best_by_sample" in script
assert "sample_slug" in script
assert "raw_detection_count" in script
assert "suppressed_detection_count" in script
assert "demo/workflow" not in script
assert "fixture_mode" not in script
assert "will_download_models" not in script
@@ -89,6 +89,25 @@ class MixedCaseYoloAdapter(MockYoloAdapter):
]
class OverlappingTileYoloAdapter(MockYoloAdapter):
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
tile_index = int(tile_path.stem.split("_")[-1])
if tile_index == 0:
bbox = [10.0, 20.0, 30.0, 40.0]
confidence = 0.82
else:
bbox = [11.0, 21.0, 31.0, 41.0]
confidence = 0.91
return [
{
"class_name": "building",
"confidence": confidence,
"bbox": bbox,
"properties": {"adapter": "overlap"},
}
]
class RecordingPredictModel:
def __init__(self) -> None:
self.seen_sources: list[dict] = []
@@ -383,6 +402,37 @@ def test_yolo_class_filter_is_case_insensitive_and_persists_canonical_class(tmp_
assert detections[0].properties_json["model_class_name"] == "Building"
def test_yolo_run_suppresses_cross_tile_duplicate_detections(tmp_path: Path) -> None:
db, project_id, dataset_id = _project_and_dataset()
model_path = tmp_path / "model.pt"
model_path.write_bytes(b"local weights")
settings = _settings(tmp_path, yolo_model_path=str(model_path), yolo_duplicate_iou_threshold=0.5)
manifest_path = _manifest(tmp_path, tile_count=2)
result = DetectionService.run_detection(
db=db,
project_id=project_id,
dataset_id=dataset_id,
model_id="yolo-configured",
confidence_threshold=0.5,
class_filter=["building"],
tile_manifest_path=str(manifest_path),
settings=settings,
yolo_adapter_class=OverlappingTileYoloAdapter,
)
detections = [item for item in db.added if isinstance(item, Detection)]
runs = [item for item in db.added if isinstance(item, AnalysisRun)]
assert result.status == "success"
assert result.detection_count == 1
assert detections[0].confidence == 0.91
assert detections[0].source_tile_path.endswith("tile_0001.tif")
assert runs[0].result_json["raw_detection_count"] == 2
assert runs[0].result_json["suppressed_detection_count"] == 1
assert runs[0].result_json["duplicate_iou_threshold"] == 0.5
def test_yolo_adapter_converts_single_band_tiles_to_rgb_before_prediction(tmp_path: Path) -> None:
Image = pytest.importorskip("PIL.Image")
tile_path = tmp_path / "single_band_tile.tif"