diff --git a/backend/tests/test_sprint129_operator_yolo_training_dataset.py b/backend/tests/test_sprint129_operator_yolo_training_dataset.py index 5ddd633f..f29bc607 100644 --- a/backend/tests/test_sprint129_operator_yolo_training_dataset.py +++ b/backend/tests/test_sprint129_operator_yolo_training_dataset.py @@ -55,6 +55,7 @@ def test_operator_yolo_train_smoke_script_contract() -> None: script = script_path.read_text(encoding="utf-8") assert "bash -n scripts/train_operator_yolo_detector.sh" in readiness + assert "TRAIN_REQUIRE_CUDA" in script_path.read_text(encoding="utf-8") assert "OPERATOR_YOLO_DATASET_DIR" in script assert "YOLO_BASE_MODEL_PATH" in script assert "TRAIN_MODEL_OUTPUT_PATH" in script diff --git a/backend/tests/test_sprint130_operator_yolo_tile_dataset.py b/backend/tests/test_sprint130_operator_yolo_tile_dataset.py index eeb8c572..a813c6a6 100644 --- a/backend/tests/test_sprint130_operator_yolo_tile_dataset.py +++ b/backend/tests/test_sprint130_operator_yolo_tile_dataset.py @@ -75,6 +75,9 @@ def test_operator_yolo_tile_dataset_export_help_does_not_require_gis_dependencie 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: @@ -305,7 +308,11 @@ def test_export_can_skip_low_variance_negative_tiles(tmp_path: Path, monkeypatch monkeypatch.setattr(module, "rasterio", FakeRasterio) monkeypatch.setattr(module, "Image", FakeImage) - monkeypatch.setattr(module, "load_reference_pixel_boxes", lambda reference_path, dataset, min_label_px: []) + 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( @@ -327,6 +334,8 @@ def test_export_can_skip_low_variance_negative_tiles(tmp_path: Path, monkeypatch 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"]] diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index 5ab7eaf9..8a4dac3a 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -1,5 +1,14 @@ # AI Pipelines +The cross-task PyTorch scope, capability matrix and national promotion waves are +defined in `docs/PYTORCH_MODEL_PROGRAM.md`. PyTorch is used only for trainable +imagery tasks; authoritative GIS measurements remain source-derived. The +single-class tile exporter accepts explicit `--class-name`, +`--reference-source` and `--reference-layer` bindings and persists them in its +evidence summary. Production Tower training uses `TRAIN_DEVICE=cuda:0` with +`TRAIN_REQUIRE_CUDA=true`, which fails closed without CUDA and records the +PyTorch/CUDA runtime in `training_summary.json`. + ## 1. Object Detection Pipeline ```text diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 91e2eea9..3ad32b3e 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -1,3 +1,13 @@ +## 2026-07-26 - PyTorch programme clarification and training hardening + +- Clarified that PyTorch governs trainable imagery models and does not replace authoritative terrain, flood, land-use, road, water or change analyses. +- Audited Tower: CUDA PyTorch runs on the RTX 4080 SUPER; only the building corpus currently has promotion evidence. The active building candidate measures roughly 0.61 mean F1 on the expanded independent portfolio and zero detections on three pure-empty background samples. +- Generalized the tile exporter with explicit canonical class, reference source and reference layer evidence so regional authorities cannot be silently mixed. +- Added fail-closed `TRAIN_REQUIRE_CUDA` behavior and PyTorch/CUDA runtime evidence to training summaries. +- Added `docs/PYTORCH_MODEL_PROGRAM.md` with the task matrix and national training waves. +- Verification: 14 focused exporter/training tests passed. +- Open: nationwide building training cannot honestly start until spatially disjoint, temporally compatible GRB/PICC/URBIS plus orthophoto samples have been materialized and reviewed. + ## 2026-07-19 - GeoIntel 1.0.0 final release closeout - Promoted the current clean `main` revision to semantic version `1.0.0` in diff --git a/docs/PYTORCH_MODEL_PROGRAM.md b/docs/PYTORCH_MODEL_PROGRAM.md new file mode 100644 index 00000000..7b7f6d56 --- /dev/null +++ b/docs/PYTORCH_MODEL_PROGRAM.md @@ -0,0 +1,52 @@ +# PyTorch model programme + +## Principle + +PyTorch is the governed CUDA runtime for trainable image models. It is not a +replacement for authoritative GIS processing. Terrain, flood depth, land-use +classes, road length, water area and vector/raster change remain deterministic +source-derived analyses. + +## Capability matrix + +| Capability | Method | Current state | Promotion requirement | +|---|---|---|---| +| Building localisation | YOLO detection on orthophoto | Operational for `building`; validated only in Mol/Kempen | geographically independent Belgian train/validation/test portfolio, including Flanders, Wallonia and Brussels | +| Building footprint | official GRB/PICC/URBIS geometry | Operational where the governed source covers the AOI | no neural model; source coverage and freshness gates | +| Roads and water | official vectors and thematic rasters | Operational by provider zone | no detector unless a separately justified imagery use case and reviewed labels exist | +| Land use / vegetation | official classified rasters | Operational where source coverage exists | no neural model; preserve official class semantics | +| Terrain / flood / bathymetry | numeric raster analysis | Operational or explicit `not_configured` per source | no neural model; source and CRS validation | +| Solar panels or other imagery objects | dedicated detector | Unvalidated local asset only | task-specific labels, negative corpus, independent hold-outs and promotion report | +| Segmentation | dedicated YOLO-seg/SAM model | `not_configured` | reviewed polygon/mask corpus and segmentation QA; detection boxes are not valid mask labels | + +## Training rules + +1. One model contract per task and canonical class set. +2. Training, validation and test samples are separated geographically; an AOI + or overlapping image tile may occur in only one split. +3. Reference labels identify authority, source, layer, observation/publication + date, CRS and checksum. +4. Production training requires NVIDIA CUDA and fails closed when unavailable. +5. Every challenger is evaluated on positive AOIs, pure-empty negatives and + difficult contextual negatives. Promotion is never based on training loss. +6. A model remains `not_configured` outside its independently proven geography + and classes. + +## Executable waves + +1. **Building Belgium corpus:** prepare spatially disjoint samples from GRB + (Flanders), PICC (Wallonia) and URBIS (Brussels) against temporally compatible + orthophotos. Keep complete municipalities outside training as test areas. +2. **Building challenger:** train on Tower using PyTorch CUDA, compare against + the active model at fixed thresholds and retain the current model unless all + promotion gates pass. +3. **Additional imagery task intake:** admit solar panels or another class only + after an explicit use case and reviewed labels exist. Never infer new classes + from the building corpus. +4. **Segmentation intake:** build a polygon/mask corpus and independent QA before + enabling YOLO-seg or SAM. + +The tile exporter accepts explicit `--class-name`, `--reference-source` and +`--reference-layer` values and records them in dataset evidence. The production +training wrapper supports `TRAIN_REQUIRE_CUDA=true` and records CUDA and PyTorch +runtime evidence in every training summary. diff --git a/docs/TODO.md b/docs/TODO.md index 68c16dd5..d0276f7e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,14 @@ # GeoIntel TODO +## PyTorch-modelprogramma + +- [x] Scheid trainbare beeldtaken van deterministische GIS-analyses. +- [x] Generaliseer de single-class tile-export op klasse, referentiebron en referentielaag. +- [x] Laat productietraining fail-closed stoppen wanneer vereiste CUDA ontbreekt. +- [ ] Materialiseer geografisch gescheiden GRB/PICC/URBIS-gebouwcorpora met temporeel passende orthofoto's. +- [ ] Train en evalueer een Belgische gebouwchallenger onafhankelijk; promoveer alleen zonder achtergrondregressie. +- [ ] Houd zonnepanelen en segmentatie `not_configured` tot gereviewde taaklabels en hold-outs bestaan. + ## Actieve post-RC datadekkingsfase - [x] Voeg een interactieve, data-gedreven projectatlas toe aan de statuswerkruimte met toegankelijke navigatie, echte readiness-toestanden en reduced-motion ondersteuning. diff --git a/scripts/export_operator_yolo_tile_dataset.py b/scripts/export_operator_yolo_tile_dataset.py index 29da78d3..3025f09b 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") +DEFAULT_CLASS_NAME = "building" +DEFAULT_REFERENCE_SOURCE = "grb" +DEFAULT_REFERENCE_LAYER = "buildings" REFERENCE_AOI_CATEGORY = "reference_aoi" PURE_EMPTY_BACKGROUND_CATEGORY = "pure_empty_negative" SPARSE_BACKGROUND_CATEGORY = "sparse_building_context" @@ -74,6 +77,21 @@ def parse_args() -> argparse.Namespace: default=Path(os.environ.get("OPERATOR_YOLO_TILE_DATASET_DIR", DEFAULT_OUTPUT_DIR)), help="Output directory for tile images, labels, dataset.yaml and summary JSON.", ) + parser.add_argument( + "--class-name", + default=os.environ.get("OPERATOR_YOLO_CLASS_NAME", DEFAULT_CLASS_NAME), + help="Canonical single detection class written to dataset.yaml.", + ) + parser.add_argument( + "--reference-source", + default=os.environ.get("OPERATOR_YOLO_REFERENCE_SOURCE", DEFAULT_REFERENCE_SOURCE), + help="Required source_name in reference GeoJSON features.", + ) + parser.add_argument( + "--reference-layer", + default=os.environ.get("OPERATOR_YOLO_REFERENCE_LAYER", DEFAULT_REFERENCE_LAYER), + help="Required reference_layer_name in reference GeoJSON features.", + ) parser.add_argument("--tile-size", type=int, default=int(os.environ.get("OPERATOR_YOLO_TILE_SIZE", "256"))) parser.add_argument("--stride", type=int, default=int(os.environ.get("OPERATOR_YOLO_TILE_STRIDE", "128"))) parser.add_argument( @@ -326,7 +344,14 @@ def iter_geometry_coords(geometry: dict[str, Any]) -> Iterable[tuple[float, floa yield float(point[0]), float(point[1]) -def load_reference_pixel_boxes(reference_path: Path, dataset: Any, min_label_px: float) -> list[PixelBox]: +def load_reference_pixel_boxes( + reference_path: Path, + dataset: Any, + min_label_px: float, + *, + reference_source: str, + reference_layer: str, +) -> list[PixelBox]: reference = json.loads(reference_path.read_text(encoding="utf-8-sig")) features = reference.get("features") or [] transformer = Transformer.from_crs("EPSG:4326", dataset.crs, always_xy=True) @@ -334,9 +359,9 @@ def load_reference_pixel_boxes(reference_path: Path, dataset: Any, min_label_px: for feature in features: properties = feature.get("properties") or {} - if properties.get("source_name") != "grb": + if str(properties.get("source_name") or "").strip().lower() != reference_source: continue - if properties.get("reference_layer_name") != "buildings": + if str(properties.get("reference_layer_name") or "").strip().lower() != reference_layer: continue coords = list(iter_geometry_coords(feature.get("geometry") or {})) if not coords: @@ -423,7 +448,7 @@ def ensure_yolo_directories(output_dir: Path) -> None: (output_dir / relative_path).mkdir(parents=True, exist_ok=True) -def write_dataset_yaml(output_dir: Path) -> Path: +def write_dataset_yaml(output_dir: Path, class_name: str) -> Path: yaml_path = output_dir / "dataset.yaml" yaml_path.write_text( "\n".join( @@ -432,7 +457,7 @@ def write_dataset_yaml(output_dir: Path) -> Path: "train: images/train", "val: images/val", "names:", - " 0: building", + f" 0: {class_name}", "", ] ), @@ -454,6 +479,8 @@ def export_sample_tiles( background_negative_repeat: int, drop_low_variance_negatives: bool, blank_range_threshold: int, + reference_source: str, + reference_layer: str, ) -> list[dict[str, Any]]: sample_slug = str(sample["sample_slug"]) sample_role = str(sample.get("sample_role") or "reference") @@ -469,7 +496,13 @@ def export_sample_tiles( exported: list[dict[str, Any]] = [] with rasterio.open(raster_path) as dataset: - boxes = load_reference_pixel_boxes(reference_path, dataset, min_label_px=min_label_px) + boxes = load_reference_pixel_boxes( + reference_path, + dataset, + min_label_px=min_label_px, + reference_source=reference_source, + reference_layer=reference_layer, + ) for tile_index, tile_window in enumerate(iter_tile_windows(dataset.width, dataset.height, tile_size, stride)): labels = labels_for_tile( tile_window, @@ -566,6 +599,16 @@ def export_sample_tiles( def main() -> int: args = parse_args() + class_name = args.class_name.strip().lower() + reference_source = args.reference_source.strip().lower() + reference_layer = args.reference_layer.strip().lower() + for label, value in ( + ("class-name", class_name), + ("reference-source", reference_source), + ("reference-layer", reference_layer), + ): + if not value or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789_-" for character in value): + raise SystemExit(f"YOLO {label} must be a non-empty canonical slug") ensure_dependencies() if args.force and args.output_dir.exists(): shutil.rmtree(args.output_dir) @@ -597,6 +640,8 @@ def main() -> int: background_negative_repeat=args.background_negative_repeat, drop_low_variance_negatives=args.drop_low_variance_negatives, blank_range_threshold=args.blank_range_threshold, + reference_source=reference_source, + reference_layer=reference_layer, ) ) @@ -605,7 +650,7 @@ def main() -> int: raise SystemExit("YOLO tile dataset export produced no training tiles") if not any(tile["split"] == "val" for tile in kept_tiles): raise SystemExit("YOLO tile dataset export produced no validation tiles") - dataset_yaml = write_dataset_yaml(args.output_dir) + dataset_yaml = write_dataset_yaml(args.output_dir, class_name) positive_tiles = [tile for tile in kept_tiles if not tile["is_negative"]] negative_tiles = [tile for tile in kept_tiles if tile["is_negative"]] skipped_negative_tiles = [tile for tile in exported_tiles if not tile["kept"] and tile["is_negative"]] @@ -619,7 +664,9 @@ def main() -> int: "status": "ok", "dataset_yaml": str(dataset_yaml), "output_dir": str(args.output_dir), - "class_names": ["building"], + "class_names": [class_name], + "reference_source": reference_source, + "reference_layer": reference_layer, "tile_size": args.tile_size, "stride": args.stride, "negative_keep_ratio": args.negative_keep_ratio, diff --git a/scripts/train_operator_yolo_detector.sh b/scripts/train_operator_yolo_detector.sh index 21f1697d..36cfcceb 100644 --- a/scripts/train_operator_yolo_detector.sh +++ b/scripts/train_operator_yolo_detector.sh @@ -3,7 +3,7 @@ set -euo pipefail usage() { cat <<'EOF' -Train a local operator YOLO building detector from an exported GeoIntel dataset. +Train a local operator YOLO detector from an exported, governed GeoIntel dataset. Environment variables: OPERATOR_YOLO_DATASET_DIR Directory containing dataset.yaml. @@ -44,6 +44,7 @@ TRAIN_IMGSZ="${TRAIN_IMGSZ:-512}" TRAIN_BATCH="${TRAIN_BATCH:-2}" TRAIN_WORKERS="${TRAIN_WORKERS:-0}" TRAIN_DEVICE="${TRAIN_DEVICE:-cpu}" +TRAIN_REQUIRE_CUDA="${TRAIN_REQUIRE_CUDA:-false}" if [[ -z "${PYTHON_BIN:-}" ]]; then if [[ -x "/opt/geointel/venv/bin/python" ]]; then PYTHON_BIN="/opt/geointel/venv/bin/python" @@ -64,6 +65,7 @@ export TRAIN_IMGSZ export TRAIN_BATCH export TRAIN_WORKERS export TRAIN_DEVICE +export TRAIN_REQUIRE_CUDA export PYTHON_BIN export SUMMARY_PATH @@ -116,6 +118,7 @@ def seed_ultralytics_font() -> None: seed_ultralytics_font() from ultralytics import YOLO +import torch dataset_yaml = Path(os.environ["DATASET_YAML"]) base_model_path = Path(os.environ["YOLO_BASE_MODEL_PATH"]) @@ -128,6 +131,9 @@ image_size = int(os.environ["TRAIN_IMGSZ"]) batch_size = int(os.environ["TRAIN_BATCH"]) workers = int(os.environ["TRAIN_WORKERS"]) device = os.environ["TRAIN_DEVICE"] +require_cuda = os.environ["TRAIN_REQUIRE_CUDA"].strip().lower() in {"1", "true", "yes", "on"} +if require_cuda and (not device.lower().startswith("cuda") or not torch.cuda.is_available()): + raise SystemExit("TRAIN_REQUIRE_CUDA is enabled but the requested CUDA device is unavailable") model = YOLO(str(base_model_path)) model.train( @@ -179,6 +185,9 @@ summary = { "batch_size": batch_size, "workers": workers, "device": device, + "cuda_required": require_cuda, + "cuda_available": torch.cuda.is_available(), + "torch_version": torch.__version__, } summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8")