Support larger operator training samples
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-09 13:28:54 +02:00
parent a20d9b70c7
commit a1b33555b9
7 changed files with 120 additions and 11 deletions
+1
View File
@@ -12,6 +12,7 @@
- Added `--min-label-visible-ratio` / `OPERATOR_YOLO_MIN_LABEL_VISIBLE_RATIO` to the operator YOLO tile dataset exporter. - 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. - 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`. - 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. - 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. - No model was activated, no detections were faked, no provider fetching was introduced and no migration changed.
@@ -43,6 +43,9 @@ def test_prepare_operator_real_data_samples_help_does_not_require_gis_dependenci
assert result.returncode == 0 assert result.returncode == 0
assert "Prepare real Digitaal Vlaanderen" in result.stdout assert "Prepare real Digitaal Vlaanderen" in result.stdout
assert "--samples" 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: def test_multi_sample_detection_quality_matrix_runs_existing_matrix_for_each_sample() -> None:
@@ -63,6 +63,27 @@ def test_operator_background_candidates_are_unique_enough_for_hard_negative_trai
assert half_sizes == {260.0} 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: def test_background_candidate_can_write_empty_reference_geojson(tmp_path: Path, monkeypatch) -> None:
module = load_sample_preparer() module = load_sample_preparer()
+7 -1
View File
@@ -6,6 +6,7 @@ Changed:
- Default remains `0` for legacy behavior; use `0.25` for the next overlap-heavy operator dataset experiment. - 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`. - 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. - `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. - Updated operator script documentation.
Why: Why:
@@ -18,9 +19,14 @@ Tested:
- `python -m pytest backend\tests\test_sprint130_operator_yolo_tile_dataset.py -q` (`6 passed`) - `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`. - 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`) - `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: 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) ## Sprint 149 YOLO duplicate suppression evidence (2026-07-09)
+2 -1
View File
@@ -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] 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] 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_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. - [ ] 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. - [ ] 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. - [ ] Build the next candidate gate around better positive AOI coverage, label strategy and hard-negative retention.
## Sprint 8 status ## Sprint 8 status
+23 -5
View File
@@ -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. records Digitaal Vlaanderen attribution and reuses existing files by default.
Use `--force` only when the local runtime artifacts should be regenerated. 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 The real-data smoke is intentionally mutating and refuses to run without
operator-supplied files. Current V1 upload support expects a georeferenced operator-supplied files. Current V1 upload support expects a georeferenced
`.tif`, `.tiff` or `.geotiff` raster and a `.geojson` or `.json` reference `.tif`, `.tiff` or `.geotiff` raster and a `.geojson` or `.json` reference
@@ -316,13 +334,13 @@ with overlapping raster windows:
```bash ```bash
docker exec -it geointel python3 /app/scripts/export_operator_yolo_tile_dataset.py \ docker exec -it geointel python3 /app/scripts/export_operator_yolo_tile_dataset.py \
--manifest-path /app/storage/operator-data/operator_samples_manifest.json \ --manifest-path /app/storage/operator-data/operator-samples-1024/operator_samples_manifest.json \
--output-dir /app/storage/operator-data/yolo-building-tile-expanded160 \ --output-dir /app/storage/operator-data/yolo-building-aoi1024-visible025 \
--tile-size 160 \ --tile-size 512 \
--stride 80 \ --stride 256 \
--negative-keep-ratio 1.0 \ --negative-keep-ratio 1.0 \
--min-label-visible-ratio 0.25 \ --min-label-visible-ratio 0.25 \
--val-samples turnhout,retie,kasterlee_bos \ --val-samples turnhout,retie,westerlo,arendonk_heide \
--force --force
``` ```
+63 -4
View File
@@ -12,7 +12,7 @@ import argparse
import json import json
import os import os
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -196,6 +196,24 @@ def parse_args() -> argparse.Namespace:
default="operator_samples_manifest.json", default="operator_samples_manifest.json",
help="Manifest filename written inside output-dir.", 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() return parser.parse_args()
@@ -229,6 +247,25 @@ def selected_samples(raw: str) -> list[OperatorSample]:
return [SAMPLES[slug] for slug in slugs] 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]]: def sample_bounds(sample: OperatorSample) -> tuple[tuple[float, float, float, float], list[float]]:
lambert = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True) lambert = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", 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 []) 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: def fetch_orthophoto(sample: OperatorSample, ortho_path: Path, lambert_bbox: tuple[float, float, float, float]) -> str:
minx, miny, maxx, maxy = lambert_bbox minx, miny, maxx, maxy = lambert_bbox
wms_params = { 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]: def prepare_sample(sample: OperatorSample, output_dir: Path, force: bool) -> dict[str, Any]:
ortho_path = output_dir / f"{sample.slug}_orthophoto_wms_512.tif" ortho_path, reference_path = sample_artifact_paths(sample, output_dir)
reference_path = output_dir / f"{sample.slug}_grb_gbg_buildings.geojson"
lambert_bbox, geo_bbox = sample_bounds(sample) lambert_bbox, geo_bbox = sample_bounds(sample)
skip_existing = ortho_path.exists() and reference_path.exists() and not force 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_lon": sample.center_lon,
"center_lat": sample.center_lat, "center_lat": sample.center_lat,
"half_size_m": sample.half_size_m, "half_size_m": sample.half_size_m,
"width": sample.width,
"height": sample.height,
"sample_role": sample.sample_role, "sample_role": sample.sample_role,
"allow_empty_reference": sample.allow_empty_reference, "allow_empty_reference": sample.allow_empty_reference,
"raster_path": str(ortho_path), "raster_path": str(ortho_path),
@@ -416,13 +460,28 @@ def main() -> int:
ensure_gis_dependencies() ensure_gis_dependencies()
output_dir: Path = args.output_dir output_dir: Path = args.output_dir
output_dir.mkdir(parents=True, exist_ok=True) 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) write_readme(output_dir, samples)
manifest = { manifest = {
"schema_version": 1, "schema_version": 1,
"description": "GeoIntel operator real-data samples for configured-YOLO QA validation.", "description": "GeoIntel operator real-data samples for configured-YOLO QA validation.",
"output_dir": str(output_dir), "output_dir": str(output_dir),
"sample_width": args.width,
"sample_height": args.height,
"half_size_scale": args.half_size_scale,
"samples": samples, "samples": samples,
} }
manifest_path = output_dir / args.manifest_name manifest_path = output_dir / args.manifest_name