Fix YOLO class normalization
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-07 01:50:24 +02:00
parent 8f75c89b1c
commit 71c2cd9411
8 changed files with 98 additions and 14 deletions
+4 -2
View File
@@ -367,8 +367,10 @@ dataset through the normal dataset service, generates raster tiles, selects a
local model asset, runs configured YOLO detection, compares persisted
detections against persisted `vector_features`, persists QA/QC rows and exports
the detection GeoJSON. It never seeds demo detections, enables fixture mode,
fetches live providers or downloads model weights. Current V1 upload support is
limited to GeoTIFF-style rasters and GeoJSON/JSON reference vectors.
fetches live providers or downloads model weights. Configured-YOLO model class
labels are normalized to lowercase for filtering and persisted detections while
the original model label is retained in detection provenance. Current V1 upload
support is limited to GeoTIFF-style rasters and GeoJSON/JSON reference vectors.
### Run backend
+11 -3
View File
@@ -541,13 +541,14 @@ class DetectionService:
model_path = Path(settings.yolo_model_path or "").expanduser()
adapter = yolo_adapter_class(settings)
model = adapter.load_model(model_path)
allowed_classes = set(class_filter)
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
persisted: list[Detection] = []
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())
for raw in adapter.predict_tile(model, tile_path, confidence_threshold):
class_name = str(raw.get("class_name") or "")
model_class_name = str(raw.get("class_name") or "").strip()
class_name = DetectionService._canonical_class_name(model_class_name)
confidence = float(raw.get("confidence", 0.0))
if allowed_classes and class_name not in allowed_classes:
continue
@@ -557,6 +558,9 @@ class DetectionService:
if not isinstance(bbox, list):
raise AppError(code="DETECTION_INVALID_BBOX", message="YOLO adapter returned a detection without bbox", status_code=422)
geometry = pixel_bbox_to_epsg4326_polygon(bbox=bbox, tile=tile, crs=tile.get("crs") or manifest_crs)
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,
@@ -575,7 +579,7 @@ class DetectionService:
"y_max": float(bbox[3]),
},
source_tile_path=str(tile_path),
properties_json={**dict(raw.get("properties") or {}), "tile_index": tile.get("index")},
properties_json={**properties, "tile_index": tile.get("index")},
)
db.add(detection)
persisted.append(detection)
@@ -584,6 +588,10 @@ class DetectionService:
db.refresh(detection)
return persisted
@staticmethod
def _canonical_class_name(value: Any) -> str:
return str(value or "").strip().casefold()
@staticmethod
def _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int) -> dict[str, Any]:
if not tile_manifest_path:
@@ -77,6 +77,18 @@ class MockYoloAdapter:
]
class MixedCaseYoloAdapter(MockYoloAdapter):
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict]:
return [
{
"class_name": "Building",
"confidence": 0.91,
"bbox": [10.0, 20.0, 30.0, 40.0],
"properties": {"adapter": "mock"},
}
]
class RecordingPredictModel:
def __init__(self) -> None:
self.seen_sources: list[dict] = []
@@ -343,6 +355,33 @@ def test_yolo_run_persists_mocked_georeferenced_detections(tmp_path: Path) -> No
assert jobs[0].status == "success"
def test_yolo_class_filter_is_case_insensitive_and_persists_canonical_class(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))
manifest_path = _manifest(tmp_path, tile_count=1)
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=MixedCaseYoloAdapter,
)
detections = [item for item in db.added if isinstance(item, Detection)]
assert result.status == "success"
assert result.detection_count == 1
assert detections[0].class_name == "building"
assert detections[0].properties_json["model_class_name"] == "Building"
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"