from __future__ import annotations import importlib.util from pathlib import Path import subprocess import sys import numpy as np import pytest ROOT = Path(__file__).resolve().parents[2] def load_tile_exporter(): script_path = ROOT / "scripts" / "export_operator_yolo_tile_dataset.py" spec = importlib.util.spec_from_file_location("operator_tile_exporter", 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_operator_yolo_tile_dataset_export_script_contract() -> None: script_path = ROOT / "scripts" / "export_operator_yolo_tile_dataset.py" readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") assert script_path.exists() script = script_path.read_text(encoding="utf-8") assert "py_compile scripts/export_operator_yolo_tile_dataset.py" in readiness assert "operator_samples_manifest.json" in script assert "yolo-building-tile-dataset" in script assert "dataset.yaml" in script assert "images/train" in script assert "labels/train" in script assert "images/val" in script assert "labels/val" in script assert "tile_size" in script assert "stride" in script assert "negative_keep_ratio" in script assert "min_label_visible_ratio" in script assert "drop_low_variance_negatives" in script assert "skipped_low_variance_negative_tile_count" in script assert "positive_tile_count" in script assert "negative_tile_count" in script assert "skipped_negative_tile_count" in script assert "source_name" in script assert "reference_layer_name" in script assert "Window" in script assert "Transformer" in script assert "fixture_mode" not in script assert "will_download_models" not in script def test_operator_yolo_tile_dataset_export_help_does_not_require_gis_dependencies() -> None: script_path = ROOT / "scripts" / "export_operator_yolo_tile_dataset.py" result = subprocess.run( [sys.executable, str(script_path), "--help"], check=False, capture_output=True, text=True, ) assert result.returncode == 0 assert "Export operator real-data samples to a tile-level YOLO detection dataset" in result.stdout assert "--tile-size" in result.stdout assert "--stride" in result.stdout assert "--samples" in result.stdout assert "--negative-keep-ratio" in result.stdout assert "--min-label-visible-ratio" in result.stdout assert "--background-negative-repeat" in result.stdout assert "--drop-low-variance-negatives" in result.stdout assert "--blank-range-threshold" in result.stdout assert "--class-name" in result.stdout assert "--reference-source" in result.stdout assert "--reference-layer" in result.stdout def test_default_validation_split_is_explicit_and_rejects_holdout_leakage() -> None: module = load_tile_exporter() samples = [ {"sample_slug": "geel", "recommended_split": "train"}, {"sample_slug": "turnhout", "recommended_split": "val"}, {"sample_slug": "retie", "recommended_split": "val"}, {"sample_slug": "westerlo", "recommended_split": "val"}, {"sample_slug": "arendonk_heide", "recommended_split": "val"}, {"sample_slug": "vosselaar_center", "recommended_split": "val"}, {"sample_slug": "grobbendonk_center", "recommended_split": "val"}, ] assert module.DEFAULT_VALIDATION_SAMPLE_SLUGS == frozenset( { "turnhout", "retie", "westerlo", "arendonk_heide", "vosselaar_center", "grobbendonk_center", } ) assert module.validate_validation_split( samples, set(module.DEFAULT_VALIDATION_SAMPLE_SLUGS), ) == set(module.DEFAULT_VALIDATION_SAMPLE_SLUGS) with pytest.raises(SystemExit, match="recommended validation holdouts"): module.validate_validation_split(samples, {"turnhout"}) with pytest.raises(SystemExit, match="unknown samples"): module.validate_validation_split(samples, {"turnhout", "missing"}) def test_manifest_sample_selection_keeps_external_holdouts_out_of_targeted_dataset() -> None: module = load_tile_exporter() samples = [ {"sample_slug": "geel", "recommended_split": "train"}, {"sample_slug": "beerse_center", "recommended_split": "train"}, {"sample_slug": "vosselaar_center", "recommended_split": "val"}, {"sample_slug": "turnhout", "recommended_split": "val"}, {"sample_slug": "retie", "recommended_split": "val"}, {"sample_slug": "westerlo", "recommended_split": "val"}, ] selected, excluded = module.select_manifest_samples( samples, {"geel", "beerse_center", "vosselaar_center"}, ) assert [sample["sample_slug"] for sample in selected] == [ "geel", "beerse_center", "vosselaar_center", ] assert excluded == ["retie", "turnhout", "westerlo"] assert module.validate_validation_split(selected, {"vosselaar_center"}) == { "vosselaar_center" } with pytest.raises(SystemExit, match="unknown samples"): module.select_manifest_samples(samples, {"geel", "missing"}) def test_train_only_shard_requires_explicit_opt_in() -> None: module = load_tile_exporter() samples = [{"sample_slug": "new-train-aoi", "recommended_split": "train"}] with pytest.raises(SystemExit, match="must include at least one"): module.validate_validation_split(samples, set()) assert module.validate_validation_split(samples, set(), allow_empty=True) == set() def test_validation_coverage_reports_holdouts_without_retained_tiles() -> None: module = load_tile_exporter() coverage = module.validation_sample_coverage( [ {"sample_slug": "turnhout", "split": "val", "kept": True}, {"sample_slug": "retie", "split": "val", "kept": True}, {"sample_slug": "geel", "split": "train", "kept": True}, ], {"turnhout", "retie", "arendonk_heide"}, ) assert coverage == { "retained_validation_sample_slugs": ["retie", "turnhout"], "empty_validation_sample_slugs": ["arendonk_heide"], } def test_iter_tile_windows_covers_edges_without_duplicates() -> None: module = load_tile_exporter() windows = list(module.iter_tile_windows(width=512, height=512, tile_size=192, stride=96)) assert len(windows) == 25 assert windows[0].row_off == 0 assert windows[0].col_off == 0 assert windows[-1].row_off == 320 assert windows[-1].col_off == 320 assert len({(window.row_off, window.col_off) for window in windows}) == len(windows) assert all(window.width == 192 for window in windows) assert all(window.height == 192 for window in windows) def test_negative_tile_keep_is_deterministic_and_ratio_bound() -> None: module = load_tile_exporter() first = [module.keep_negative_tile("geel", index, 0.25) for index in range(50)] second = [module.keep_negative_tile("geel", index, 0.25) for index in range(50)] all_kept = [module.keep_negative_tile("geel", index, 1.0) for index in range(10)] none_kept = [module.keep_negative_tile("geel", index, 0.0) for index in range(10)] assert first == second assert 1 <= sum(first) <= 25 assert all(all_kept) assert not any(none_kept) def test_labels_for_tile_can_drop_tiny_visible_box_fragments() -> None: module = load_tile_exporter() tile = module.TileWindow(row_off=0, col_off=0, height=100, width=100) mostly_outside_box = module.PixelBox(min_col=90, min_row=10, max_col=190, max_row=90) labels_without_gate = module.labels_for_tile( tile, [mostly_outside_box], min_label_px=4, min_visible_ratio=0.0, ) labels_with_gate = module.labels_for_tile( tile, [mostly_outside_box], min_label_px=4, min_visible_ratio=0.25, ) assert labels_without_gate == ["0 0.95000000 0.50000000 0.10000000 0.80000000"] assert labels_with_gate == [] def test_background_negative_repeat_only_applies_to_training_background_tiles() -> None: module = load_tile_exporter() assert module.background_negative_repeat_count( is_negative=True, sample_role="background_candidate", split="train", background_negative_repeat=4, ) == 4 assert module.background_negative_repeat_count( is_negative=True, sample_role="background_candidate", split="val", background_negative_repeat=4, ) == 1 assert module.background_negative_repeat_count( is_negative=False, sample_role="background_candidate", split="train", background_negative_repeat=4, ) == 1 assert module.background_negative_repeat_count( is_negative=True, sample_role="reference", 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" def test_export_can_skip_low_variance_negative_tiles(tmp_path: Path, monkeypatch) -> None: module = load_tile_exporter() raster_path = tmp_path / "sample.tif" reference_path = tmp_path / "reference.geojson" raster_path.write_bytes(b"fake-raster") reference_path.write_text('{"type": "FeatureCollection", "features": []}', encoding="utf-8") class FakeDataset: width = 256 height = 128 crs = "EPSG:31370" def __enter__(self): return self def __exit__(self, exc_type, exc, traceback): return False class FakeRasterio: @staticmethod def open(path): assert Path(path) == raster_path return FakeDataset() class FakeImageObject: def __init__(self, array): self.array = array def save(self, path): Path(path).write_bytes(b"png") class FakeImage: @staticmethod def fromarray(array): return FakeImageObject(array) def fake_image_array_from_raster_window(dataset, tile_window): if tile_window.col_off == 0: return np.full((128, 128, 3), 255, dtype=np.uint8) image = np.zeros((128, 128, 3), dtype=np.uint8) image[:, 64:, :] = 80 return image monkeypatch.setattr(module, "rasterio", FakeRasterio) monkeypatch.setattr(module, "Image", FakeImage) monkeypatch.setattr( module, "load_reference_pixel_boxes", lambda reference_path, dataset, min_label_px, **kwargs: [], ) monkeypatch.setattr(module, "image_array_from_raster_window", fake_image_array_from_raster_window) records = module.export_sample_tiles( sample={ "sample_slug": "blank_negative", "sample_role": "background_candidate", "background_category": "pure_empty_negative", "raster_path": str(raster_path), "reference_path": str(reference_path), }, manifest_path=tmp_path / "operator_samples_manifest.json", output_dir=tmp_path / "dataset", val_slugs=set(), tile_size=128, stride=128, negative_keep_ratio=1.0, min_label_px=4, min_label_visible_ratio=0.0, background_negative_repeat=1, drop_low_variance_negatives=True, blank_range_threshold=3, reference_source="grb", reference_layer="buildings", ) skipped = [record for record in records if not record["kept"]] kept = [record for record in records if record["kept"]] assert len(skipped) == 1 assert skipped[0]["skip_reason"] == "low_visual_variance_negative" assert skipped[0]["low_visual_variance"] is True assert skipped[0]["is_negative"] is True assert skipped[0]["tile_index"] == 0 assert len(kept) == 1 assert kept[0]["tile_index"] == 1 assert kept[0]["low_visual_variance"] is False assert Path(kept[0]["image_path"]).exists()