From a1b33555b95a3a01fe7e7f24ffb946567ae8d734 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 13:28:54 +0200 Subject: [PATCH] Support larger operator training samples --- CHANGELOG.md | 1 + ...print127_operator_sample_quality_matrix.py | 3 + ...est_sprint131_operator_sample_expansion.py | 21 ++++++ docs/CODEX_EXECUTION_LOG.md | 8 ++- docs/TODO.md | 3 +- scripts/README.md | 28 ++++++-- scripts/prepare_operator_real_data_samples.py | 67 +++++++++++++++++-- 7 files changed, 120 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d0241d..a8abcae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Added `--min-label-visible-ratio` / `OPERATOR_YOLO_MIN_LABEL_VISIBLE_RATIO` to the operator YOLO tile dataset exporter. - The exporter can now drop clipped building labels where only a small share of the original object bbox is visible in an overlapping tile. - Tile dataset summaries and audit reports now retain/report `min_label_visible_ratio`. +- Added operator-only `--width`, `--height` and `--half-size-scale` options to `prepare_operator_real_data_samples.py` so larger training AOIs can be prepared explicitly. - Updated operator documentation for the recommended next dataset pass. - No model was activated, no detections were faked, no provider fetching was introduced and no migration changed. diff --git a/backend/tests/test_sprint127_operator_sample_quality_matrix.py b/backend/tests/test_sprint127_operator_sample_quality_matrix.py index ec22e4cd..eaa57fdc 100644 --- a/backend/tests/test_sprint127_operator_sample_quality_matrix.py +++ b/backend/tests/test_sprint127_operator_sample_quality_matrix.py @@ -43,6 +43,9 @@ def test_prepare_operator_real_data_samples_help_does_not_require_gis_dependenci assert result.returncode == 0 assert "Prepare real Digitaal Vlaanderen" in result.stdout assert "--samples" in result.stdout + assert "--width" in result.stdout + assert "--height" in result.stdout + assert "--half-size-scale" in result.stdout def test_multi_sample_detection_quality_matrix_runs_existing_matrix_for_each_sample() -> None: diff --git a/backend/tests/test_sprint131_operator_sample_expansion.py b/backend/tests/test_sprint131_operator_sample_expansion.py index 9295a5c9..cb38d4f4 100644 --- a/backend/tests/test_sprint131_operator_sample_expansion.py +++ b/backend/tests/test_sprint131_operator_sample_expansion.py @@ -63,6 +63,27 @@ def test_operator_background_candidates_are_unique_enough_for_hard_negative_trai assert half_sizes == {260.0} +def test_operator_sample_can_be_scaled_for_larger_training_aoi(tmp_path: Path) -> None: + module = load_sample_preparer() + + sample = module.OperatorSample( + slug="geel", + display_name="Geel", + center_lon=5.0, + center_lat=51.0, + half_size_m=250.0, + ) + + configured = module.apply_sample_overrides(sample, width=1024, height=1024, half_size_scale=2.0) + ortho_path, reference_path = module.sample_artifact_paths(configured, tmp_path) + + assert configured.width == 1024 + assert configured.height == 1024 + assert configured.half_size_m == 500.0 + assert ortho_path.name == "geel_orthophoto_wms_1024.tif" + assert reference_path.name == "geel_grb_gbg_buildings.geojson" + + def test_background_candidate_can_write_empty_reference_geojson(tmp_path: Path, monkeypatch) -> None: module = load_sample_preparer() diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 51ffe56d..a9f051e4 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -6,6 +6,7 @@ Changed: - Default remains `0` for legacy behavior; use `0.25` for the next overlap-heavy operator dataset experiment. - Tile dataset summaries include `min_label_visible_ratio`. - `scripts/audit_operator_yolo_dataset_quality.py` now reports `min_label_visible_ratio` in JSON and Markdown. +- Added operator-only `--width`, `--height` and `--half-size-scale` options to `scripts/prepare_operator_real_data_samples.py`; generated raster names now include the requested width. - Updated operator script documentation. Why: @@ -18,9 +19,14 @@ Tested: - `python -m pytest backend\tests\test_sprint130_operator_yolo_tile_dataset.py -q` (`6 passed`) - Red step: `python -m pytest backend\tests\test_sprint146_operator_yolo_dataset_quality_audit.py -q` failed because the audit report did not expose `min_label_visible_ratio`. - `python -m pytest backend\tests\test_sprint130_operator_yolo_tile_dataset.py backend\tests\test_sprint146_operator_yolo_dataset_quality_audit.py -q` (`7 passed`) +- Red step: `python -m pytest backend\tests\test_sprint127_operator_sample_quality_matrix.py backend\tests\test_sprint131_operator_sample_expansion.py -q` failed because sample prep lacked larger-AOI options. +- `python -m pytest backend\tests\test_sprint127_operator_sample_quality_matrix.py backend\tests\test_sprint131_operator_sample_expansion.py -q` (`8 passed`) +- Full readiness: `bash scripts/run_readiness_check.sh` (`427 passed`, frontend typecheck/build passed). +- Tower deploy: first AI rebuild failed with Docker storage full; after Docker build cache cleanup `/var/lib/docker` had 98G free and redeploy passed live migration smoke and browser runtime verification on `http://192.168.10.150:1202`. +- Tower dataset audit: exported `/app/storage/operator-data/yolo-building-aoi512-visible025` with `min_label_visible_ratio=0.25`; audit returned `needs_attention` because the current 512x512 source rasters still produce only 16 tiles and median normalized box area remains below gate. Next: -- Run full readiness, deploy Tower, export a new visible-ratio-gated operator tile dataset, audit it, then decide whether it is good enough for another CPU training candidate. +- Prepare a larger explicit operator sample manifest, for example `/app/storage/operator-data/operator-samples-1024` with `--width 1024 --height 1024 --half-size-scale 2`, then export/audit `yolo-building-aoi1024-visible025` before another CPU training candidate. ## Sprint 149 YOLO duplicate suppression evidence (2026-07-09) diff --git a/docs/TODO.md b/docs/TODO.md index c214da08..ab3a14e0 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -115,9 +115,10 @@ This file now starts with the current implementation status. Older preparation/b - [x] Add configured-YOLO cross-tile duplicate suppression and raw/suppressed calibration evidence fields. - [x] Rerun live dense-AOI calibration after redeploy with `YOLO_DUPLICATE_IOU_THRESHOLD=0.5`; Westerlo 0.25 improved to F1 `0.2537313432835821` and Turnhout 0.25 improved to F1 `0.14114114114114112`, but the candidate remains rejected. - [x] Add `OPERATOR_YOLO_MIN_LABEL_VISIBLE_RATIO` so the next overlapping-tile dataset can drop tiny clipped edge-fragment labels. +- [x] Add operator-only larger-AOI sample prep flags so the next training dataset is not limited to one 512x512 tile per documented sample. - [ ] Find or train a materially stronger aerial/Kempen building model candidate; `geointel-building-yolov8n-expanded160e50-pt` is the best current dense-AOI candidate but still too weak and too noisy for a V1 default. - [ ] Train a higher-capacity local aerial-building detector with stronger positive recall while preserving the hard-negative false-positive gate. -- [ ] Export and audit a visible-ratio-gated tile dataset on Tower before the next default-model training attempt. +- [ ] Prepare `/app/storage/operator-data/operator-samples-1024` on Tower, then export and audit `yolo-building-aoi1024-visible025` before the next default-model training attempt. - [ ] Build the next candidate gate around better positive AOI coverage, label strategy and hard-negative retention. ## Sprint 8 status diff --git a/scripts/README.md b/scripts/README.md index b145504a..9904ac97 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -191,6 +191,24 @@ negative-tile training. The helper fetches only the explicit documented AOIs, records Digitaal Vlaanderen attribution and reuses existing files by default. Use `--force` only when the local runtime artifacts should be regenerated. +For model-training candidates, prepare a larger operator-only sample manifest so +tile overlap can create meaningful context instead of one tile per source +raster: + +```bash +docker exec -it geointel python3 /app/scripts/prepare_operator_real_data_samples.py \ + --output-dir /app/storage/operator-data/operator-samples-1024 \ + --manifest-name operator_samples_manifest.json \ + --width 1024 \ + --height 1024 \ + --half-size-scale 2 \ + --force +``` + +This keeps the same documented AOI centers but requests larger WMS rasters and a +larger GRB reference bbox. Use the generated manifest path for the next YOLO +tile export. The default remains 512x512 for quick smoke runs. + The real-data smoke is intentionally mutating and refuses to run without operator-supplied files. Current V1 upload support expects a georeferenced `.tif`, `.tiff` or `.geotiff` raster and a `.geojson` or `.json` reference @@ -316,13 +334,13 @@ with overlapping raster windows: ```bash docker exec -it geointel python3 /app/scripts/export_operator_yolo_tile_dataset.py \ - --manifest-path /app/storage/operator-data/operator_samples_manifest.json \ - --output-dir /app/storage/operator-data/yolo-building-tile-expanded160 \ - --tile-size 160 \ - --stride 80 \ + --manifest-path /app/storage/operator-data/operator-samples-1024/operator_samples_manifest.json \ + --output-dir /app/storage/operator-data/yolo-building-aoi1024-visible025 \ + --tile-size 512 \ + --stride 256 \ --negative-keep-ratio 1.0 \ --min-label-visible-ratio 0.25 \ - --val-samples turnhout,retie,kasterlee_bos \ + --val-samples turnhout,retie,westerlo,arendonk_heide \ --force ``` diff --git a/scripts/prepare_operator_real_data_samples.py b/scripts/prepare_operator_real_data_samples.py index 277ce166..1a9d7ad3 100644 --- a/scripts/prepare_operator_real_data_samples.py +++ b/scripts/prepare_operator_real_data_samples.py @@ -12,7 +12,7 @@ import argparse import json import os import sys -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import Any @@ -196,6 +196,24 @@ def parse_args() -> argparse.Namespace: default="operator_samples_manifest.json", help="Manifest filename written inside output-dir.", ) + parser.add_argument( + "--width", + type=int, + default=int(os.environ.get("OPERATOR_SAMPLE_WIDTH", "512")), + help="Orthophoto WMS output width in pixels. Use larger values for operator training datasets.", + ) + parser.add_argument( + "--height", + type=int, + default=int(os.environ.get("OPERATOR_SAMPLE_HEIGHT", "512")), + help="Orthophoto WMS output height in pixels. Use larger values for operator training datasets.", + ) + parser.add_argument( + "--half-size-scale", + type=float, + default=float(os.environ.get("OPERATOR_SAMPLE_HALF_SIZE_SCALE", "1")), + help="Multiplier applied to each documented AOI half-size in meters.", + ) return parser.parse_args() @@ -229,6 +247,25 @@ def selected_samples(raw: str) -> list[OperatorSample]: return [SAMPLES[slug] for slug in slugs] +def apply_sample_overrides( + sample: OperatorSample, + *, + width: int, + height: int, + half_size_scale: float, +) -> OperatorSample: + if width <= 0 or height <= 0: + raise SystemExit("--width and --height must be positive integers") + if half_size_scale <= 0: + raise SystemExit("--half-size-scale must be greater than zero") + return replace( + sample, + width=width, + height=height, + half_size_m=sample.half_size_m * half_size_scale, + ) + + def sample_bounds(sample: OperatorSample) -> tuple[tuple[float, float, float, float], list[float]]: lambert = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True) @@ -273,6 +310,12 @@ def geojson_feature_count(path: Path) -> int: return len(payload.get("features") or []) +def sample_artifact_paths(sample: OperatorSample, output_dir: Path) -> tuple[Path, Path]: + ortho_path = output_dir / f"{sample.slug}_orthophoto_wms_{sample.width}.tif" + reference_path = output_dir / f"{sample.slug}_grb_gbg_buildings.geojson" + return ortho_path, reference_path + + def fetch_orthophoto(sample: OperatorSample, ortho_path: Path, lambert_bbox: tuple[float, float, float, float]) -> str: minx, miny, maxx, maxy = lambert_bbox wms_params = { @@ -352,8 +395,7 @@ def fetch_reference(sample: OperatorSample, reference_path: Path, geo_bbox: list def prepare_sample(sample: OperatorSample, output_dir: Path, force: bool) -> dict[str, Any]: - ortho_path = output_dir / f"{sample.slug}_orthophoto_wms_512.tif" - reference_path = output_dir / f"{sample.slug}_grb_gbg_buildings.geojson" + ortho_path, reference_path = sample_artifact_paths(sample, output_dir) lambert_bbox, geo_bbox = sample_bounds(sample) skip_existing = ortho_path.exists() and reference_path.exists() and not force @@ -370,6 +412,8 @@ def prepare_sample(sample: OperatorSample, output_dir: Path, force: bool) -> dic "center_lon": sample.center_lon, "center_lat": sample.center_lat, "half_size_m": sample.half_size_m, + "width": sample.width, + "height": sample.height, "sample_role": sample.sample_role, "allow_empty_reference": sample.allow_empty_reference, "raster_path": str(ortho_path), @@ -416,13 +460,28 @@ def main() -> int: ensure_gis_dependencies() output_dir: Path = args.output_dir output_dir.mkdir(parents=True, exist_ok=True) - samples = [prepare_sample(sample, output_dir, force=args.force) for sample in selected_samples(args.samples)] + samples = [ + prepare_sample( + apply_sample_overrides( + sample, + width=args.width, + height=args.height, + half_size_scale=args.half_size_scale, + ), + output_dir, + force=args.force, + ) + for sample in selected_samples(args.samples) + ] write_readme(output_dir, samples) manifest = { "schema_version": 1, "description": "GeoIntel operator real-data samples for configured-YOLO QA validation.", "output_dir": str(output_dir), + "sample_width": args.width, + "sample_height": args.height, + "half_size_scale": args.half_size_scale, "samples": samples, } manifest_path = output_dir / args.manifest_name