From b3bd34c384defefc1f17685abfab40ea19d6e3f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 15:52:45 +0200 Subject: [PATCH] Harden operator tile background metadata --- ...st_sprint130_operator_yolo_tile_dataset.py | 24 ++++++++++++++ ...int156_background_corpus_classification.py | 27 +++++++++++++++- scripts/README.md | 31 +++++++++++++++++++ scripts/export_operator_yolo_tile_dataset.py | 17 +++++++++- 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_sprint130_operator_yolo_tile_dataset.py b/backend/tests/test_sprint130_operator_yolo_tile_dataset.py index d94edcd9..d630f356 100644 --- a/backend/tests/test_sprint130_operator_yolo_tile_dataset.py +++ b/backend/tests/test_sprint130_operator_yolo_tile_dataset.py @@ -147,3 +147,27 @@ def test_background_negative_repeat_only_applies_to_training_background_tiles() split="train", background_negative_repeat=4, ) == 1 + + +def test_background_category_is_derived_for_legacy_operator_manifests() -> None: + module = load_tile_exporter() + + pure_empty_sample = { + "sample_slug": "postel_bos", + "sample_role": "background_candidate", + "reference_feature_count": 0, + } + sparse_context_sample = { + "sample_slug": "kasterlee_bos", + "sample_role": "background_candidate", + "reference_feature_count": 104, + } + reference_sample = { + "sample_slug": "geel", + "sample_role": "reference", + "reference_feature_count": 2500, + } + + assert module.background_category_for_sample(pure_empty_sample) == "pure_empty_negative" + assert module.background_category_for_sample(sparse_context_sample) == "sparse_building_context" + assert module.background_category_for_sample(reference_sample) == "reference_aoi" diff --git a/backend/tests/test_sprint156_background_corpus_classification.py b/backend/tests/test_sprint156_background_corpus_classification.py index b0afcf7c..c362fff0 100644 --- a/backend/tests/test_sprint156_background_corpus_classification.py +++ b/backend/tests/test_sprint156_background_corpus_classification.py @@ -20,6 +20,17 @@ def load_sample_preparer(): return module +def load_tile_exporter(): + script_path = ROOT / "scripts" / "export_operator_yolo_tile_dataset.py" + spec = importlib.util.spec_from_file_location("operator_tile_exporter_s156", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_background_samples_are_classified_by_actual_reference_density() -> None: module = load_sample_preparer() @@ -93,7 +104,21 @@ def test_hard_negative_matrix_can_filter_background_categories() -> None: def test_yolo_tile_export_preserves_background_category_provenance() -> None: + module = load_tile_exporter() script = (ROOT / "scripts" / "export_operator_yolo_tile_dataset.py").read_text(encoding="utf-8") - assert "background_category = str(sample.get(\"background_category\") or \"reference_aoi\")" in script + assert module.background_category_for_sample( + { + "sample_slug": "postel_bos", + "sample_role": "background_candidate", + "reference_feature_count": 0, + } + ) == "pure_empty_negative" + assert module.background_category_for_sample( + { + "sample_slug": "kasterlee_bos", + "sample_role": "background_candidate", + "reference_feature_count": 104, + } + ) == "sparse_building_context" assert "\"background_category\": background_category" in script diff --git a/scripts/README.md b/scripts/README.md index ad4cb97e..dd017299 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -367,9 +367,40 @@ negative tiles, and records `yolo_tile_dataset_summary.json` with `--min-label-visible-ratio` drops labels where only a small clipped fragment of the original building bbox is visible inside the tile; this reduces noisy tile-edge labels in overlapping-tile datasets. Use `0` for legacy behavior. +For legacy operator manifests that predate explicit `background_category`, the +exporter derives the same categories as the split-background evaluator: +background samples with `reference_feature_count == 0` become +`pure_empty_negative`, and background samples with one or more reference +features become `sparse_building_context`. It remains operator tooling only: no provider fetch, no API mutation and no automatic model training. +For the current AOI1024 baseline, prefer the stricter clean-label profile before +spending another training run: + +```bash +docker exec -it geointel python3 /app/scripts/export_operator_yolo_tile_dataset.py \ + --manifest-path /app/storage/operator-data/operator-samples-1024/operator_samples_manifest.json \ + --output-dir /app/storage/operator-data/yolo-building-aoi1024-cleanpx12vis035 \ + --tile-size 512 \ + --stride 256 \ + --negative-keep-ratio 1.0 \ + --min-label-px 12 \ + --min-label-visible-ratio 0.35 \ + --val-samples turnhout,retie,westerlo,arendonk_heide \ + --force +``` + +Then audit with stricter small-box gates: + +```bash +docker exec -it geointel python3 /app/scripts/audit_operator_yolo_dataset_quality.py \ + --summary-path /app/storage/operator-data/yolo-building-aoi1024-cleanpx12vis035/yolo_tile_dataset_summary.json \ + --output-dir /app/artifacts/operator-yolo-dataset-audit/aoi1024-cleanpx12vis035 \ + --max-small-box-share 0.25 \ + --min-median-box-area 0.001 +``` + Audit the generated tile dataset before spending another long training run: ```bash diff --git a/scripts/export_operator_yolo_tile_dataset.py b/scripts/export_operator_yolo_tile_dataset.py index 2c1c4c08..8f6d3a76 100644 --- a/scripts/export_operator_yolo_tile_dataset.py +++ b/scripts/export_operator_yolo_tile_dataset.py @@ -21,6 +21,9 @@ from typing import Any, Iterable DEFAULT_MANIFEST_PATH = Path("/app/storage/operator-data/operator_samples_manifest.json") DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data/yolo-building-tile-dataset") +REFERENCE_AOI_CATEGORY = "reference_aoi" +PURE_EMPTY_BACKGROUND_CATEGORY = "pure_empty_negative" +SPARSE_BACKGROUND_CATEGORY = "sparse_building_context" rasterio: Any = None Window: Any = None Transformer: Any = None @@ -165,6 +168,18 @@ def background_negative_repeat_count( return max(1, background_negative_repeat) +def background_category_for_sample(sample: dict[str, Any]) -> str: + explicit_category = str(sample.get("background_category") or "").strip() + if explicit_category: + return explicit_category + if str(sample.get("sample_role") or "reference") != "background_candidate": + return REFERENCE_AOI_CATEGORY + reference_feature_count = int(sample.get("reference_feature_count") or 0) + if reference_feature_count <= 0: + return PURE_EMPTY_BACKGROUND_CATEGORY + return SPARSE_BACKGROUND_CATEGORY + + def resolve_manifest_path(raw: str, manifest_path: Path) -> Path: path = Path(raw) if path.exists(): @@ -315,7 +330,7 @@ def export_sample_tiles( ) -> list[dict[str, Any]]: sample_slug = str(sample["sample_slug"]) sample_role = str(sample.get("sample_role") or "reference") - background_category = str(sample.get("background_category") or "reference_aoi") + background_category = background_category_for_sample(sample) split = "val" if sample_slug.lower() in val_slugs else "train" raster_path = resolve_manifest_path(str(sample["raster_path"]), manifest_path) reference_path = resolve_manifest_path(str(sample["reference_path"]), manifest_path)