Add fail-closed regional detector routing
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-30 02:27:17 +02:00
parent e987f9224c
commit 7b222e6210
2 changed files with 70 additions and 3 deletions
+47 -3
View File
@@ -10,6 +10,21 @@ from pathlib import Path
from typing import Any from typing import Any
def parse_regional_models(values: list[str]) -> dict[str, Path]:
"""Parse explicit REGION=MODEL routing without silently accepting ambiguity."""
models: dict[str, Path] = {}
for value in values:
region, separator, model = value.partition("=")
region = region.strip().lower()
model = model.strip()
if not separator or not region or not model:
raise ValueError(f"Invalid regional model {value!r}; expected REGION=/path/to/model.pt")
if region in models:
raise ValueError(f"Duplicate regional model for {region!r}")
models[region] = Path(model)
return models
def iou(left: tuple[float, float, float, float], right: tuple[float, float, float, float]) -> float: def iou(left: tuple[float, float, float, float], right: tuple[float, float, float, float]) -> float:
x1, y1 = max(left[0], right[0]), max(left[1], right[1]) x1, y1 = max(left[0], right[0]), max(left[1], right[1])
x2, y2 = min(left[2], right[2]), min(left[3], right[3]) x2, y2 = min(left[2], right[2]), min(left[3], right[3])
@@ -173,6 +188,13 @@ def main() -> int:
parser.add_argument("--box-offset-x", type=float, default=0.0) parser.add_argument("--box-offset-x", type=float, default=0.0)
parser.add_argument("--box-offset-y", type=float, default=0.0) parser.add_argument("--box-offset-y", type=float, default=0.0)
parser.add_argument("--additional-model", type=Path) parser.add_argument("--additional-model", type=Path)
parser.add_argument(
"--regional-model",
action="append",
default=[],
metavar="REGION=MODEL",
help="Route tiles from one manifest region to an explicit expert model; repeat per region.",
)
parser.add_argument("--ensemble-mode", choices=("consensus", "union"), default="consensus") parser.add_argument("--ensemble-mode", choices=("consensus", "union"), default="consensus")
parser.add_argument("--ensemble-match-iou", type=float, default=0.3) parser.add_argument("--ensemble-match-iou", type=float, default=0.3)
parser.add_argument("--proposal-classifier", type=Path) parser.add_argument("--proposal-classifier", type=Path)
@@ -188,6 +210,10 @@ def main() -> int:
parser.error("--nms-iou must be between zero and one") parser.error("--nms-iou must be between zero and one")
if not 0.0 < args.containment_nms <= 1.0: if not 0.0 < args.containment_nms <= 1.0:
parser.error("--containment-nms must be above zero and at most one") parser.error("--containment-nms must be above zero and at most one")
try:
regional_models = parse_regional_models(args.regional_model)
except ValueError as exc:
parser.error(str(exc))
from ultralytics import YOLO from ultralytics import YOLO
proposal_classifier = None proposal_classifier = None
@@ -213,12 +239,17 @@ def main() -> int:
} }
tiles = [item for item in summary["tiles"] if item.get("kept", True) and item["split"] == args.split] tiles = [item for item in summary["tiles"] if item.get("kept", True) and item["split"] == args.split]
image_paths = [item["image_path"] for item in tiles] image_paths = [item["image_path"] for item in tiles]
def predict_bounded(model: YOLO) -> list[Any]: unknown_regions = sorted(set(regional_models) - set(regions.values()))
if unknown_regions:
parser.error(f"Regional model keys absent from corpus manifest: {', '.join(unknown_regions)}")
def predict_bounded(model: YOLO, paths: list[str] | None = None) -> list[Any]:
selected_paths = image_paths if paths is None else paths
bounded_results: list[Any] = [] bounded_results: list[Any] = []
for start in range(0, len(image_paths), args.batch): for start in range(0, len(selected_paths), args.batch):
bounded_results.extend( bounded_results.extend(
model.predict( model.predict(
image_paths[start : start + args.batch], selected_paths[start : start + args.batch],
conf=min(args.thresholds), conf=min(args.thresholds),
device=args.device, device=args.device,
augment=args.augment, augment=args.augment,
@@ -232,6 +263,17 @@ def main() -> int:
return bounded_results return bounded_results
results = predict_bounded(YOLO(str(args.model))) results = predict_bounded(YOLO(str(args.model)))
routed_tile_counts: dict[str, int] = {}
for region, model_path in regional_models.items():
indices = [index for index, tile in enumerate(tiles) if regions[tile["sample_slug"]] == region]
if not indices:
parser.error(f"Regional model {region!r} has no tiles in selected split")
routed_results = predict_bounded(YOLO(str(model_path)), [image_paths[index] for index in indices])
if len(routed_results) != len(indices):
raise RuntimeError(f"Regional model {region!r} returned an incomplete result set")
for index, result in zip(indices, routed_results, strict=True):
results[index] = result
routed_tile_counts[region] = len(indices)
additional_results = None additional_results = None
if args.additional_model: if args.additional_model:
additional_results = predict_bounded(YOLO(str(args.additional_model))) additional_results = predict_bounded(YOLO(str(args.additional_model)))
@@ -337,6 +379,8 @@ def main() -> int:
"box_offset_x": args.box_offset_x, "box_offset_x": args.box_offset_x,
"box_offset_y": args.box_offset_y, "box_offset_y": args.box_offset_y,
"additional_model": str(args.additional_model) if args.additional_model else None, "additional_model": str(args.additional_model) if args.additional_model else None,
"regional_models": {region: str(path) for region, path in sorted(regional_models.items())},
"regional_model_tile_counts": dict(sorted(routed_tile_counts.items())),
"ensemble_mode": args.ensemble_mode if args.additional_model else None, "ensemble_mode": args.ensemble_mode if args.additional_model else None,
"ensemble_match_iou": args.ensemble_match_iou if args.additional_model else None, "ensemble_match_iou": args.ensemble_match_iou if args.additional_model else None,
"proposal_classifier": str(args.proposal_classifier) if args.proposal_classifier else None, "proposal_classifier": str(args.proposal_classifier) if args.proposal_classifier else None,
@@ -0,0 +1,23 @@
from pathlib import Path
import pytest
from scripts.evaluate_belgium_building_candidate import parse_regional_models
def test_parse_regional_models_accepts_explicit_unique_routes() -> None:
assert parse_regional_models(["flanders=/models/flanders.pt", "wallonia=/models/wallonia.pt"]) == {
"flanders": Path("/models/flanders.pt"),
"wallonia": Path("/models/wallonia.pt"),
}
@pytest.mark.parametrize("value", ["flanders", "=/model.pt", "flanders="])
def test_parse_regional_models_rejects_incomplete_routes(value: str) -> None:
with pytest.raises(ValueError, match="expected REGION"):
parse_regional_models([value])
def test_parse_regional_models_rejects_duplicate_routes() -> None:
with pytest.raises(ValueError, match="Duplicate regional model"):
parse_regional_models(["flanders=/first.pt", "FLANDERS=/second.pt"])