diff --git a/CHANGELOG.md b/CHANGELOG.md index e393a258..8e3c547d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ # Changelog +## Sprint 178 Mol multi-zone operational validation (2026-07-13) + +- Added documented Mol center, Achterbos, Gompel, Donk and Postel operator zones with municipality and operational-zone provenance. +- Protected the four new positive zones as validation holdouts; they are not silently eligible for model training. +- Hardened real-data quality workflows to persist manifest-backed EPSG:4326 Areas and Mol project regions alongside datasets and analysis evidence. +- Added a single Mol operator runner that composes existing positive QA/QC and background detection-pressure workflows without fake metrics or model downloads. +- Added all-in-one image/readiness wiring and focused regression coverage; APIs, migrations, model activation and frontend behavior remain unchanged. + ## Sprint 177 Mol-first operating context (2026-07-13) - Made Mol the explicit primary operating focus while retaining the broader Kempen as the validation and interoperability region. diff --git a/backend/README.md b/backend/README.md index aa09e65f..aa780391 100644 --- a/backend/README.md +++ b/backend/README.md @@ -460,6 +460,13 @@ manifests generated for AI handoff include source CRS metadata so pixel-space model outputs can be transformed to WGS84 GeoJSON coordinates. Current V1 upload support is limited to GeoTIFF-style rasters and GeoJSON/JSON reference vectors. +When `REAL_AREA_BBOX=minx,miny,maxx,maxy` is supplied, the same workflow also +persists an EPSG:4326 project Area before uploading data. `REAL_AREA_NAME` and +`REAL_PROJECT_REGION` retain operator context. The multi-sample runner fills +these values from manifest `wgs84_bbox` and municipality metadata, so generated +projects are immediately usable in the map without an alternate persistence +path or API contract. + To prepare the documented operator sample corpus inside the all-in-one runtime container, run: @@ -477,6 +484,12 @@ empty GRB FeatureCollections for negative-tile training; normal reference AOIs still fail when GRB returns no buildings. These are runtime artifacts only and are not committed to Git. +Mol additionally has operational holdouts for Achterbos, Gompel, Donk and +Postel, with Mol center as the historical baseline and Postel-bos as a separate +background control. Prepare and execute that pack with the documented +`prepare_operator_real_data_samples.py` and +`run_mol_operational_validation.sh` commands in `scripts/README.md`. + The current recommended local building model is `geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt` with tile size `512`, overlap `64` and confidence threshold `0.15`. Its SHA256 is diff --git a/backend/tests/test_docker_runtime_config.py b/backend/tests/test_docker_runtime_config.py index 5dea715f..d2126a16 100644 --- a/backend/tests/test_docker_runtime_config.py +++ b/backend/tests/test_docker_runtime_config.py @@ -75,6 +75,7 @@ def test_all_in_one_dockerfile_copies_operator_scripts_for_runtime_use() -> None "verify_real_data_detection_qa_workflow.sh", "run_detection_quality_matrix.sh", "run_multi_sample_detection_quality_matrix.sh", + "run_mol_operational_validation.sh", "export_detection_calibration_evidence.sh", "assemble_detection_calibration_evidence_portfolio.sh", "build_fixed_threshold_evidence_portfolio_inputs.py", diff --git a/backend/tests/test_sprint178_mol_operational_pack.py b/backend/tests/test_sprint178_mol_operational_pack.py new file mode 100644 index 00000000..11b3b45b --- /dev/null +++ b/backend/tests/test_sprint178_mol_operational_pack.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import importlib.util +import math +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[2] + + +def load_sample_preparer(): + script_path = ROOT / "scripts" / "prepare_operator_real_data_samples.py" + spec = importlib.util.spec_from_file_location("mol_operational_sample_preparer", 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 distance_m(left, right) -> float: + radius_m = 6_371_008.8 + left_lat = math.radians(left.center_lat) + right_lat = math.radians(right.center_lat) + delta_lat = right_lat - left_lat + delta_lon = math.radians(right.center_lon - left.center_lon) + haversine = ( + math.sin(delta_lat / 2) ** 2 + + math.cos(left_lat) * math.cos(right_lat) * math.sin(delta_lon / 2) ** 2 + ) + return 2 * radius_m * math.asin(math.sqrt(haversine)) + + +def test_mol_operational_registry_has_distinct_real_world_zones_and_holdouts() -> None: + module = load_sample_preparer() + + expected = { + "mol": "center", + "mol_achterbos": "residential", + "mol_gompel": "mixed_settlement", + "mol_donk": "canal_industrial", + "mol_postel": "rural_village", + } + assert tuple(expected) == module.MOL_OPERATIONAL_SAMPLE_SLUGS + assert module.MOL_BACKGROUND_CONTROL_SAMPLE_SLUGS == ("postel_bos",) + assert module.MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS == frozenset(expected) - {"mol"} + + samples = [module.SAMPLES[slug] for slug in expected] + assert all(sample.municipality == "Mol" for sample in samples) + assert {sample.operational_zone for sample in samples} == set(expected.values()) + assert all(5.09 < sample.center_lon < 5.20 for sample in samples) + assert all(51.18 < sample.center_lat < 51.30 for sample in samples) + assert all( + module.recommended_split_for_sample(module.SAMPLES[slug]) == "val" + for slug in module.MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS + ) + + new_holdouts = [module.SAMPLES[slug] for slug in module.MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS] + assert min( + distance_m(left, right) + for index, left in enumerate(new_holdouts) + for right in new_holdouts[index + 1 :] + ) >= 1_500 + + +def test_mol_sample_metadata_is_persisted_in_operator_manifest_records(tmp_path: Path, monkeypatch) -> None: + module = load_sample_preparer() + sample = module.SAMPLES["mol_achterbos"] + monkeypatch.setattr(module, "sample_bounds", lambda _sample: ((1.0, 2.0, 3.0, 4.0), [5.0, 51.0, 5.1, 51.1])) + monkeypatch.setattr(module, "sample_artifact_paths", lambda _sample, _output: (tmp_path / "ortho.tif", tmp_path / "reference.geojson")) + monkeypatch.setattr(module, "fetch_orthophoto", lambda *_args: "https://example.test/ortho") + monkeypatch.setattr(module, "fetch_reference", lambda *_args, **_kwargs: ("https://example.test/grb", 42)) + monkeypatch.setattr(module, "raster_summary", lambda _path: {"crs": "EPSG:31370"}) + + prepared = module.prepare_sample(sample, tmp_path, force=True) + + assert prepared["municipality"] == "Mol" + assert prepared["operational_zone"] == "residential" + assert prepared["recommended_split"] == "val" + assert prepared["wgs84_bbox"] == [5.0, 51.0, 5.1, 51.1] + + +def test_real_data_matrix_propagates_project_region_and_persisted_area() -> None: + workflow = (ROOT / "scripts" / "verify_real_data_detection_qa_workflow.sh").read_text(encoding="utf-8") + matrix = (ROOT / "scripts" / "run_detection_quality_matrix.sh").read_text(encoding="utf-8") + multi = (ROOT / "scripts" / "run_multi_sample_detection_quality_matrix.sh").read_text(encoding="utf-8") + + assert 'REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}"' in workflow + assert 'REAL_AREA_BBOX="${REAL_AREA_BBOX:-}"' in workflow + assert '/api/v1/projects/${project_id}/areas' in workflow + assert 'echo "Area: ${area_id}"' in workflow + assert 'REAL_PROJECT_REGION="${REAL_PROJECT_REGION}"' in matrix + assert 'REAL_AREA_BBOX="${REAL_AREA_BBOX}"' in matrix + assert 'REAL_AREA_BBOX="${wgs84_bbox}"' in multi + assert 'REAL_AREA_NAME="${sample_slug} AOI"' in multi + assert 'project_region="Mol, Kempen"' in multi + assert 'REAL_PROJECT_REGION="${project_region}"' in multi + + +def test_mol_operational_runner_uses_real_positive_qa_and_background_paths() -> None: + runner = (ROOT / "scripts" / "run_mol_operational_validation.sh").read_text(encoding="utf-8") + readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8") + dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8") + + assert "mol_achterbos mol_gompel mol_donk mol_postel" in runner + assert "run_multi_sample_detection_quality_matrix.sh" in runner + assert "run_operator_hard_negative_detection_matrix.sh" in runner + assert "mol_operational_validation_summary.json" in runner + assert "fixture_mode" not in runner + assert "manual-fixture-detector" not in runner + assert "bash -n scripts/run_mol_operational_validation.sh" in readiness + assert "COPY scripts/run_mol_operational_validation.sh" in dockerfile diff --git a/deploy/unraid/Dockerfile.all-in-one b/deploy/unraid/Dockerfile.all-in-one index 05a5ab35..8a2545f3 100644 --- a/deploy/unraid/Dockerfile.all-in-one +++ b/deploy/unraid/Dockerfile.all-in-one @@ -79,6 +79,7 @@ COPY scripts/train_operator_yolo_detector.sh /app/scripts/train_operator_yolo_de COPY scripts/verify_real_data_detection_qa_workflow.sh /app/scripts/verify_real_data_detection_qa_workflow.sh COPY scripts/run_detection_quality_matrix.sh /app/scripts/run_detection_quality_matrix.sh COPY scripts/run_multi_sample_detection_quality_matrix.sh /app/scripts/run_multi_sample_detection_quality_matrix.sh +COPY scripts/run_mol_operational_validation.sh /app/scripts/run_mol_operational_validation.sh COPY scripts/export_detection_calibration_evidence.sh /app/scripts/export_detection_calibration_evidence.sh COPY scripts/assemble_detection_calibration_evidence_portfolio.sh /app/scripts/assemble_detection_calibration_evidence_portfolio.sh COPY scripts/build_fixed_threshold_evidence_portfolio_inputs.py /app/scripts/build_fixed_threshold_evidence_portfolio_inputs.py @@ -102,6 +103,7 @@ RUN chmod +x /usr/local/bin/geointel-all-in-one-start \ /app/scripts/verify_real_data_detection_qa_workflow.sh \ /app/scripts/run_detection_quality_matrix.sh \ /app/scripts/run_multi_sample_detection_quality_matrix.sh \ + /app/scripts/run_mol_operational_validation.sh \ /app/scripts/export_detection_calibration_evidence.sh \ /app/scripts/assemble_detection_calibration_evidence_portfolio.sh \ /app/scripts/run_operator_hard_negative_detection_matrix.sh \ diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index ac80993c..fe92bead 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -227,6 +227,21 @@ the first 1000-feature page. Generated reference GeoJSON records `reference_max_features` and `source_urls` for auditability. The application itself still does not perform live provider fetching. +### Mol operational validation pack + +The operator registry includes a Mol-first validation pack: Mol center, +Achterbos residential, Gompel mixed settlement, Donk canal/industrial and +Postel rural village. The four new zones are marked as validation holdouts so +future training exports cannot silently consume the operational benchmark. +Postel-bos is evaluated separately as a background control. + +`run_mol_operational_validation.sh` composes the existing positive multi-sample +quality matrix and background detection matrix. Positive runs use persisted GRB +`vector_features` and create real `QualityCheck`/`Metric` rows; background runs +only report persisted detection pressure and never synthesize QA metrics. AOI +bounds from the operator manifest are persisted as EPSG:4326 `Area` records so +every generated project opens as a complete map context. + For confidence-threshold calibration, use the sweep wrapper: ```bash diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 2707f42e..ce4cf69f 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -7267,3 +7267,23 @@ Open: - Live Mol context contains a ready EPSG:31370 1024x1024 orthophoto and `1,993` ready EPSG:4326 GRB reference buildings. - Persisted detection QA remains unchanged: precision `0.6011`, recall `0.5891`, F1 `0.5950`, mean IoU `0.4554`, `779` false positives and `819` false negatives. - In-app browser verification selected the Mol project, Mol AOI and Mol reference dataset automatically. MapLibre rendered the existing `1,953`-feature detection layer over the road basemap with one canvas, zero browser warnings/errors and no horizontal document overflow at `1265x720`. + +# Sprint 178 - Mol multi-zone operational validation + +## Implementation + +- Added Mol center, Achterbos, Gompel, Donk and Postel as explicit operator contexts with municipality and operational-zone provenance. +- Marked Achterbos, Gompel, Donk and Postel as validation holdouts to keep them outside future training exports unless the split policy is deliberately changed. +- Kept Postel-bos as a separate background control, so an empty/sparse context is never assigned fabricated precision, recall or F1. +- Extended the existing real-data workflow with optional project region and EPSG:4326 AOI bounds; manifest-backed positive and background projects now open map-ready with persisted Areas. +- Added `run_mol_operational_validation.sh` to compose the existing positive QA matrix and background detection-pressure matrix and emit one evidence summary. +- Included the new runner in the all-in-one runtime and release-readiness syntax gate. No API route, ORM model, migration, model activation or frontend behavior changed. + +## Initial validation + +- Focused Mol/operator/workflow/Docker regressions: `43 passed`. +- Changed Python operator preparer compiled successfully. +- All five affected shell workflows passed `bash -n`. +- `git diff --check`: clean apart from the existing Windows line-ending notice for the all-in-one Dockerfile. +- `bash scripts/run_readiness_check.sh`: passed with `485` backend tests, contract audit, single Alembic head, frontend typecheck and production build. +- Frontend bundle sizes are unchanged: app `217.69 kB`, React vendor `140.74 kB` and MapLibre `801.82 kB` before gzip. diff --git a/docs/TODO.md b/docs/TODO.md index add48187..206ee894 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -19,6 +19,7 @@ This file now starts with the current implementation status. Older preparation/b ## Current implementation status - [x] Make Mol the primary workbench, AOI and operator-sample context while preserving broader Kempen coverage. +- [x] Add a Mol multi-zone operational pack with independent positive holdouts, background control and persisted map-ready AOIs. - [x] Backend FastAPI foundation, health endpoint and service structure. - [x] React/TypeScript frontend foundation and MapLibre workbench. - [x] Map layer visibility, opacity and feature property inspection. diff --git a/scripts/README.md b/scripts/README.md index 04b3f2e3..16b5a9a1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -203,6 +203,41 @@ GeoJSON files record `reference_pages_fetched`, `reference_truncated`, `reference_page_limit`, `reference_max_features` and every fetched `source_urls` page for auditability. +Mol has a dedicated operational pack with five positive contexts: center, +Achterbos residential, Gompel mixed settlement, Donk canal/industrial and +Postel rural village. The four new contexts are validation holdouts and are not +silently added to training. Postel-bos remains a separate background control. +Prepare the 1 km / 1024 px pack explicitly: + +```bash +docker exec -it geointel python3 /app/scripts/prepare_operator_real_data_samples.py \ + --output-dir /app/storage/operator-data/mol-operational-1024 \ + --samples mol,mol_achterbos,mol_gompel,mol_donk,mol_postel,postel_bos \ + --width 1024 \ + --height 1024 \ + --half-size-scale 2 \ + --force +``` + +Then run the existing persisted positive QA and background-control paths as one +operator command: + +```bash +docker exec -it \ + -e OPERATOR_SAMPLE_MANIFEST_PATH=/app/storage/operator-data/mol-operational-1024/operator_samples_manifest.json \ + -e MOL_VALIDATION_OUTPUT_DIR=/app/artifacts/mol-operational-validation/current \ + geointel bash /app/scripts/run_mol_operational_validation.sh http://127.0.0.1 +``` + +The runner defaults to the active local model at tile `512`, overlap `64`, +confidence `0.15` and QA IoU `0.25`. Every positive run persists Project, Area, +Dataset, Job, AnalysisRun, Detection, QualityCheck, Metric and Export records. +The background run persists its project, AOI, raster, job, analysis and +detections but intentionally does not invent QA metrics for an empty or sparse +reference context. The combined JSON/Markdown summary reports +`evidence_ready`; this records completed evidence and is not an automatic model +promotion decision. + 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: @@ -301,6 +336,9 @@ plus a combined `multi_sample_quality_summary.json` with `best_overall_by_precision` and `best_by_sample` rankings. It resolves container-style `/app/storage/...` manifest paths to repo-relative `storage/...` paths when run from the Tower host checkout. +Manifest-backed runs also persist the declared EPSG:4326 AOI and municipality +region, and retain municipality/operational-zone metadata in the combined +summary. Export the same operator samples to a local YOLO detection dataset when the public model candidates are not strong enough for the target imagery: diff --git a/scripts/prepare_operator_real_data_samples.py b/scripts/prepare_operator_real_data_samples.py index 0ee10d57..f775810f 100644 --- a/scripts/prepare_operator_real_data_samples.py +++ b/scripts/prepare_operator_real_data_samples.py @@ -34,6 +34,15 @@ SMALL_BUILDING_TRAINING_SAMPLE_SLUGS = frozenset( SMALL_BUILDING_VALIDATION_SAMPLE_SLUGS = frozenset( {"vosselaar_center", "grobbendonk_center"} ) +MOL_OPERATIONAL_SAMPLE_SLUGS = ( + "mol", + "mol_achterbos", + "mol_gompel", + "mol_donk", + "mol_postel", +) +MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS = frozenset(MOL_OPERATIONAL_SAMPLE_SLUGS[1:]) +MOL_BACKGROUND_CONTROL_SAMPLE_SLUGS = ("postel_bos",) DEFAULT_VALIDATION_SAMPLE_SLUGS = frozenset( { "turnhout", @@ -41,6 +50,7 @@ DEFAULT_VALIDATION_SAMPLE_SLUGS = frozenset( "westerlo", "arendonk_heide", *SMALL_BUILDING_VALIDATION_SAMPLE_SLUGS, + *MOL_OPERATIONAL_VALIDATION_SAMPLE_SLUGS, } ) requests: Any = None @@ -61,6 +71,8 @@ class OperatorSample: height: int = 512 sample_role: str = "reference" allow_empty_reference: bool = False + municipality: str | None = None + operational_zone: str = "regional_reference" SAMPLES: dict[str, OperatorSample] = { @@ -69,6 +81,40 @@ SAMPLES: dict[str, OperatorSample] = { display_name="Mol center", center_lon=5.1167, center_lat=51.1919, + municipality="Mol", + operational_zone="center", + ), + "mol_achterbos": OperatorSample( + slug="mol_achterbos", + display_name="Mol Achterbos residential", + center_lon=5.0979785, + center_lat=51.2008032, + municipality="Mol", + operational_zone="residential", + ), + "mol_gompel": OperatorSample( + slug="mol_gompel", + display_name="Mol Gompel mixed settlement", + center_lon=5.1502009, + center_lat=51.1927937, + municipality="Mol", + operational_zone="mixed_settlement", + ), + "mol_donk": OperatorSample( + slug="mol_donk", + display_name="Mol Donk canal and industrial context", + center_lon=5.1126881, + center_lat=51.2179802, + municipality="Mol", + operational_zone="canal_industrial", + ), + "mol_postel": OperatorSample( + slug="mol_postel", + display_name="Mol Postel rural village", + center_lon=5.1897863, + center_lat=51.2874865, + municipality="Mol", + operational_zone="rural_village", ), "geel": OperatorSample( slug="geel", @@ -178,6 +224,8 @@ SAMPLES: dict[str, OperatorSample] = { half_size_m=260.0, sample_role="background_candidate", allow_empty_reference=True, + municipality="Mol", + operational_zone="forest_background", ), "lommel_heide": OperatorSample( slug="lommel_heide", @@ -561,6 +609,8 @@ def fetch_reference( reference["bbox"] = geo_bbox reference["sample_slug"] = sample.slug reference["sample_role"] = sample.sample_role + reference["municipality"] = sample.municipality + reference["operational_zone"] = sample.operational_zone reference["allow_empty_reference"] = sample.allow_empty_reference reference["background_category"] = background_category_for_sample(sample, len(features)) reference["recommended_split"] = recommended_split_for_sample(sample) @@ -577,6 +627,8 @@ def fetch_reference( props.setdefault("reference_layer_name", "buildings") props.setdefault("sample_slug", sample.slug) props.setdefault("sample_role", sample.sample_role) + props.setdefault("municipality", sample.municipality) + props.setdefault("operational_zone", sample.operational_zone) props.setdefault("background_category", background_category_for_sample(sample, len(features))) props.setdefault("recommended_split", recommended_split_for_sample(sample)) @@ -619,6 +671,8 @@ def prepare_sample( "width": sample.width, "height": sample.height, "sample_role": sample.sample_role, + "municipality": sample.municipality, + "operational_zone": sample.operational_zone, "allow_empty_reference": sample.allow_empty_reference, "background_category": background_category, "recommended_split": recommended_split_for_sample(sample), @@ -657,6 +711,7 @@ def write_readme(output_dir: Path, samples: list[dict[str, Any]]) -> None: f"- `{sample['sample_slug']}`: `{Path(sample['raster_path']).name}` and " f"`{Path(sample['reference_path']).name}`, " f"{sample['reference_feature_count']} reference features, role `{sample['sample_role']}`, " + f"municipality `{sample['municipality'] or 'regional'}`, zone `{sample['operational_zone']}`, " f"background category `{sample['background_category']}`, " f"recommended split `{sample['recommended_split']}`." ) diff --git a/scripts/run_detection_quality_matrix.sh b/scripts/run_detection_quality_matrix.sh index 227f9638..2855c497 100644 --- a/scripts/run_detection_quality_matrix.sh +++ b/scripts/run_detection_quality_matrix.sh @@ -27,6 +27,9 @@ Optional environment: QUALITY_THRESHOLDS Space/comma separated confidence thresholds, default: 0.50 0.25 0.15. QUALITY_OUTPUT_DIR Output directory, default: artifacts/detection-quality-matrix/. QUALITY_SAMPLE_SLUG Optional AOI slug included in persisted project names. + REAL_PROJECT_REGION Persisted project region forwarded to the workflow. + REAL_AREA_NAME Persisted AOI name when REAL_AREA_BBOX is set. + REAL_AREA_BBOX Optional EPSG:4326 minx,miny,maxx,maxy AOI bounds. REAL_IOU_THRESHOLD QA IoU threshold, default inherited by the underlying workflow. This script never downloads models and never uses fixture detections. It repeats @@ -45,6 +48,9 @@ QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-640}" QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS:-64}" QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS:-0.50 0.25 0.15}" QUALITY_SAMPLE_SLUG="${QUALITY_SAMPLE_SLUG:-}" +REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}" +REAL_AREA_NAME="${REAL_AREA_NAME:-${QUALITY_SAMPLE_SLUG:-Detection quality} AOI}" +REAL_AREA_BBOX="${REAL_AREA_BBOX:-}" QUALITY_OUTPUT_DIR="${QUALITY_OUTPUT_DIR:-artifacts/detection-quality-matrix/$(date -u +%Y%m%dT%H%M%SZ)}" if [ "${BASE_URL}" = "-h" ] || [ "${BASE_URL}" = "--help" ]; then @@ -184,6 +190,9 @@ while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label echo "-- Matrix run ${run_index}: model=${model_request} tile=${tile_size} overlap=${tile_overlap} threshold=${threshold} --" if ! REAL_PROJECT_NAME="GeoIntel Detection Quality Matrix${project_sample_label} ${model_request} tile ${tile_size} overlap ${tile_overlap} threshold ${threshold}" \ + REAL_PROJECT_REGION="${REAL_PROJECT_REGION}" \ + REAL_AREA_NAME="${REAL_AREA_NAME}" \ + REAL_AREA_BBOX="${REAL_AREA_BBOX}" \ REAL_MODEL_ASSET_ID="${model_env}" \ REAL_TILE_SIZE="${tile_size}" \ REAL_TILE_OVERLAP="${tile_overlap}" \ @@ -196,6 +205,7 @@ while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label fi project_id="$(sed -n 's/^Project: //p' "${run_log}" | tail -n 1)" + area_id="$(sed -n 's/^Area: //p' "${run_log}" | tail -n 1)" raster_dataset_id="$(sed -n 's/^Raster dataset: //p' "${run_log}" | tail -n 1)" reference_dataset_id="$(sed -n 's/^Reference dataset: //p' "${run_log}" | tail -n 1)" selected_model_asset_id="$(sed -n 's/^Model asset: //p' "${run_log}" | tail -n 1)" @@ -223,6 +233,7 @@ while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label "${tile_overlap}" \ "${threshold}" \ "${project_id}" \ + "${area_id}" \ "${raster_dataset_id}" \ "${reference_dataset_id}" \ "${manifest_path}" \ @@ -244,6 +255,7 @@ import sys tile_overlap, threshold, project_id, + area_id, raster_dataset_id, reference_dataset_id, manifest_path, @@ -252,7 +264,7 @@ import sys detection_count, export_id, run_log, -) = sys.argv[1:18] +) = sys.argv[1:19] with open(quality_path, "r", encoding="utf-8") as handle: payload = json.load(handle) @@ -281,6 +293,7 @@ summary = { "tile_overlap": int(tile_overlap), "threshold": float(threshold), "project_id": project_id, + "area_id": area_id or None, "raster_dataset_id": raster_dataset_id, "reference_dataset_id": reference_dataset_id, "manifest_path": manifest_path, diff --git a/scripts/run_mol_operational_validation.sh b/scripts/run_mol_operational_validation.sh new file mode 100644 index 00000000..8d319011 --- /dev/null +++ b/scripts/run_mol_operational_validation.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: + OPERATOR_SAMPLE_MANIFEST_PATH=storage/operator-data/mol-operational-1024/operator_samples_manifest.json \ + bash scripts/run_mol_operational_validation.sh [base_url] + +Optional environment: + MOL_POSITIVE_SAMPLE_SLUGS Positive Mol holdouts. Default: mol_achterbos mol_gompel mol_donk mol_postel. + MOL_BACKGROUND_SAMPLE_SLUGS Mol background controls. Default: postel_bos. + MOL_VALIDATION_OUTPUT_DIR Output directory, default: artifacts/mol-operational-validation/. + QUALITY_MODEL_ASSET_IDS Local model asset ID, default: active configured model. + QUALITY_TILE_SIZES Default: 512. + QUALITY_TILE_OVERLAPS Default: 64. + QUALITY_THRESHOLDS Default: 0.15. + REAL_IOU_THRESHOLD Default: 0.25. + +The runner never downloads weights, fetches product providers or uses fixture +outputs. Prepare the documented real orthophoto/GRB files explicitly first. +EOF +} + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT}" + +BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://localhost:1202}}" +OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH:-storage/operator-data/mol-operational-1024/operator_samples_manifest.json}" +MOL_POSITIVE_SAMPLE_SLUGS="${MOL_POSITIVE_SAMPLE_SLUGS:-mol_achterbos mol_gompel mol_donk mol_postel}" +MOL_BACKGROUND_SAMPLE_SLUGS="${MOL_BACKGROUND_SAMPLE_SLUGS:-postel_bos}" +MOL_VALIDATION_OUTPUT_DIR="${MOL_VALIDATION_OUTPUT_DIR:-artifacts/mol-operational-validation/$(date -u +%Y%m%dT%H%M%SZ)}" +QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS:-${REAL_MODEL_ASSET_ID:-__active__}}" +QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-512}" +QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS:-64}" +QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS:-0.15}" +REAL_IOU_THRESHOLD="${REAL_IOU_THRESHOLD:-0.25}" + +if [ "${BASE_URL}" = "-h" ] || [ "${BASE_URL}" = "--help" ]; then + usage + exit 0 +fi + +if [ ! -f "${OPERATOR_SAMPLE_MANIFEST_PATH}" ]; then + echo "Mol operator manifest is not readable: ${OPERATOR_SAMPLE_MANIFEST_PATH}" >&2 + exit 2 +fi + +if [ -n "${PYTHON_BIN:-}" ]; then + PYTHON_BIN="${PYTHON_BIN}" +else + PYTHON_BIN="" + for candidate in python3 python.exe python; do + if command -v "${candidate}" >/dev/null 2>&1 && "${candidate}" -c "import json, sys" >/dev/null 2>&1; then + PYTHON_BIN="${candidate}" + break + fi + done +fi +if [ -z "${PYTHON_BIN}" ]; then + echo "A Python interpreter is required for Mol operational validation" >&2 + exit 1 +fi + +mkdir -p "${MOL_VALIDATION_OUTPUT_DIR}" +positive_output_dir="${MOL_VALIDATION_OUTPUT_DIR}/positive-qa" +background_output_dir="${MOL_VALIDATION_OUTPUT_DIR}/background-control" + +"${PYTHON_BIN}" - \ + "${OPERATOR_SAMPLE_MANIFEST_PATH}" \ + "${MOL_POSITIVE_SAMPLE_SLUGS}" \ + "${MOL_BACKGROUND_SAMPLE_SLUGS}" <<'PY' +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +positive_slugs = {value.strip().lower() for value in sys.argv[2].replace(",", " ").split() if value.strip()} +background_slugs = {value.strip().lower() for value in sys.argv[3].replace(",", " ").split() if value.strip()} +payload = json.loads(manifest_path.read_text(encoding="utf-8-sig")) +samples = {str(sample.get("sample_slug") or "").lower(): sample for sample in payload.get("samples") or []} +required = positive_slugs | background_slugs +missing = sorted(required - samples.keys()) +if missing: + raise SystemExit(f"Mol manifest is missing required samples: {', '.join(missing)}") +for slug in sorted(positive_slugs): + sample = samples[slug] + if sample.get("municipality") != "Mol": + raise SystemExit(f"Positive sample is not attributed to Mol: {slug}") + if sample.get("recommended_split") != "val": + raise SystemExit(f"Positive Mol operational sample is not a validation holdout: {slug}") + if int(sample.get("reference_feature_count") or 0) < 1: + raise SystemExit(f"Positive Mol operational sample has no GRB references: {slug}") +for slug in sorted(background_slugs): + sample = samples[slug] + if sample.get("municipality") != "Mol": + raise SystemExit(f"Background sample is not attributed to Mol: {slug}") + if sample.get("sample_role") != "background_candidate" and not sample.get("allow_empty_reference"): + raise SystemExit(f"Mol background sample is not explicitly marked as background: {slug}") +PY + +echo "== GeoIntel Mol operational validation ==" +echo "Base URL: ${BASE_URL}" +echo "Manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}" +echo "Positive holdouts: ${MOL_POSITIVE_SAMPLE_SLUGS}" +echo "Background controls: ${MOL_BACKGROUND_SAMPLE_SLUGS}" +echo "Output: ${MOL_VALIDATION_OUTPUT_DIR}" + +OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH}" \ +OPERATOR_SAMPLE_SLUGS="${MOL_POSITIVE_SAMPLE_SLUGS}" \ +MULTI_SAMPLE_OUTPUT_DIR="${positive_output_dir}" \ +QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS}" \ +QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES}" \ +QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS}" \ +QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS}" \ +REAL_IOU_THRESHOLD="${REAL_IOU_THRESHOLD}" \ +bash scripts/run_multi_sample_detection_quality_matrix.sh "${BASE_URL}" + +OPERATOR_SAMPLE_MANIFEST_PATH="${OPERATOR_SAMPLE_MANIFEST_PATH}" \ +OPERATOR_BACKGROUND_SAMPLE_SLUGS="${MOL_BACKGROUND_SAMPLE_SLUGS}" \ +HARD_NEGATIVE_OUTPUT_DIR="${background_output_dir}" \ +QUALITY_MODEL_ASSET_IDS="${QUALITY_MODEL_ASSET_IDS}" \ +QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES}" \ +QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS}" \ +QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS}" \ +bash scripts/run_operator_hard_negative_detection_matrix.sh "${BASE_URL}" + +"${PYTHON_BIN}" - \ + "${positive_output_dir}/multi_sample_quality_summary.json" \ + "${background_output_dir}/hard_negative_matrix_summary.json" \ + "${MOL_VALIDATION_OUTPUT_DIR}" \ + "${BASE_URL}" \ + "${OPERATOR_SAMPLE_MANIFEST_PATH}" <<'PY' +import json +import statistics +import sys +from datetime import datetime, timezone +from pathlib import Path + +positive_path = Path(sys.argv[1]) +background_path = Path(sys.argv[2]) +output_dir = Path(sys.argv[3]) +base_url = sys.argv[4] +manifest_path = sys.argv[5] +positive = json.loads(positive_path.read_text(encoding="utf-8")) +background = json.loads(background_path.read_text(encoding="utf-8")) +positive_items = positive.get("items") or [] +background_items = background.get("items") or [] +if not positive_items or not background_items: + raise SystemExit("Mol operational validation did not produce both positive and background evidence") + +def metric_values(key: str) -> list[float]: + return [float(item[key]) for item in positive_items if item.get(key) is not None] + +f1_values = metric_values("f1_score") +precision_values = metric_values("precision") +recall_values = metric_values("recall") +summary = { + "schema_version": 1, + "status": "evidence_ready", + "generated_at": datetime.now(timezone.utc).isoformat(), + "base_url": base_url, + "operator_sample_manifest_path": manifest_path, + "positive_summary_path": str(positive_path), + "background_summary_path": str(background_path), + "positive_sample_count": int(positive.get("sample_count") or 0), + "positive_run_count": len(positive_items), + "background_sample_count": int(background.get("sample_count") or 0), + "background_run_count": len(background_items), + "mean_precision": statistics.fmean(precision_values) if precision_values else None, + "mean_recall": statistics.fmean(recall_values) if recall_values else None, + "mean_f1": statistics.fmean(f1_values) if f1_values else None, + "minimum_f1": min(f1_values) if f1_values else None, + "total_matches": sum(int(item.get("matches") or 0) for item in positive_items), + "total_false_positives": sum(int(item.get("false_positives") or 0) for item in positive_items), + "total_false_negatives": sum(int(item.get("false_negatives") or 0) for item in positive_items), + "total_background_detections": sum(int(item.get("detection_count") or 0) for item in background_items), + "zero_detection_background_runs": sum(1 for item in background_items if int(item.get("detection_count") or 0) == 0), + "project_ids": [item.get("project_id") for item in positive_items + background_items], + "area_ids": [item.get("area_id") for item in positive_items + background_items], + "analysis_run_ids": [item.get("analysis_run_id") for item in positive_items + background_items], + "quality_check_ids": [item.get("quality_check_id") for item in positive_items], + "positive_items": positive_items, + "background_items": background_items, +} +summary_path = output_dir / "mol_operational_validation_summary.json" +summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding="utf-8") + +def fmt(value): + return "n/a" if value is None else f"{value:.4f}" + +lines = [ + "# Mol operational validation", + "", + f"- Status: `{summary['status']}`", + f"- Positive samples/runs: `{summary['positive_sample_count']}` / `{summary['positive_run_count']}`", + f"- Background samples/runs: `{summary['background_sample_count']}` / `{summary['background_run_count']}`", + f"- Mean precision: `{fmt(summary['mean_precision'])}`", + f"- Mean recall: `{fmt(summary['mean_recall'])}`", + f"- Mean F1: `{fmt(summary['mean_f1'])}`", + f"- Minimum F1: `{fmt(summary['minimum_f1'])}`", + f"- Total matches / FP / FN: `{summary['total_matches']}` / `{summary['total_false_positives']}` / `{summary['total_false_negatives']}`", + f"- Background detections: `{summary['total_background_detections']}`", + "", + "`evidence_ready` records completed persisted workflows; it is not an automatic model-promotion decision.", +] +(output_dir / "mol_operational_validation_summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8") +print(json.dumps({"status": summary["status"], "summary_path": str(summary_path)}, indent=2)) +PY diff --git a/scripts/run_multi_sample_detection_quality_matrix.sh b/scripts/run_multi_sample_detection_quality_matrix.sh index d095e597..a3bdd714 100644 --- a/scripts/run_multi_sample_detection_quality_matrix.sh +++ b/scripts/run_multi_sample_detection_quality_matrix.sh @@ -118,7 +118,15 @@ with output_path.open("w", encoding="utf-8") as handle: reference_count = int(sample.get("reference_feature_count") or 0) if reference_count < 1: raise SystemExit(f"Operator sample has no reference features: {sample_slug}") - handle.write(f"{sample_slug}\t{raster_path}\t{reference_path}\t{reference_count}\n") + bbox = sample.get("wgs84_bbox") or [] + if len(bbox) != 4: + raise SystemExit(f"Operator sample has no valid wgs84_bbox: {sample_slug}") + bbox_csv = ",".join(str(float(value)) for value in bbox) + municipality = str(sample.get("municipality") or "") + handle.write( + f"{sample_slug}\t{raster_path}\t{reference_path}\t{reference_count}\t" + f"{bbox_csv}\t{municipality}\n" + ) selected += 1 if selected == 0: @@ -131,13 +139,20 @@ echo "Manifest: ${OPERATOR_SAMPLE_MANIFEST_PATH}" echo "Sample filter: ${OPERATOR_SAMPLE_SLUGS:-all}" echo "Output: ${MULTI_SAMPLE_OUTPUT_DIR}" -while IFS=$'\t' read -r sample_slug raster_path reference_path reference_feature_count; do +while IFS=$'\t' read -r sample_slug raster_path reference_path reference_feature_count wgs84_bbox municipality; do sample_output_dir="${MULTI_SAMPLE_OUTPUT_DIR}/${sample_slug}" mkdir -p "${sample_output_dir}" echo "-- Sample ${sample_slug}: reference_features=${reference_feature_count} --" + project_region="Kempen" + if [ "${municipality,,}" = "mol" ]; then + project_region="Mol, Kempen" + fi REAL_RASTER_PATH="${raster_path}" \ REAL_REFERENCE_VECTOR_PATH="${reference_path}" \ QUALITY_SAMPLE_SLUG="${sample_slug}" \ + REAL_PROJECT_REGION="${project_region}" \ + REAL_AREA_NAME="${sample_slug} AOI" \ + REAL_AREA_BBOX="${wgs84_bbox}" \ QUALITY_OUTPUT_DIR="${sample_output_dir}" \ bash scripts/run_detection_quality_matrix.sh "${BASE_URL}" done < "${sample_manifest_tsv}" @@ -153,11 +168,17 @@ from pathlib import Path output_dir = Path(sys.argv[1]) base_url = sys.argv[2] manifest_path = sys.argv[3] +manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8-sig")) +manifest_samples = { + str(sample.get("sample_slug") or "").lower(): sample + for sample in manifest_payload.get("samples") or [] +} sample_summaries = [] flat_items = [] for summary_path in sorted(glob.glob(str(output_dir / "*" / "quality_matrix_summary.json"))): sample_slug = Path(summary_path).parent.name + sample_metadata = manifest_samples.get(sample_slug, {}) summary = json.loads(Path(summary_path).read_text(encoding="utf-8")) items = summary.get("items") or [] for item in items: @@ -167,6 +188,11 @@ for summary_path in sorted(glob.glob(str(output_dir / "*" / "quality_matrix_summ sample_summaries.append( { "sample_slug": sample_slug, + "display_name": sample_metadata.get("display_name"), + "municipality": sample_metadata.get("municipality"), + "operational_zone": sample_metadata.get("operational_zone"), + "wgs84_bbox": sample_metadata.get("wgs84_bbox"), + "recommended_split": sample_metadata.get("recommended_split"), "summary_path": summary_path, "run_count": len(items), "best_by_score": summary.get("best_by_score"), diff --git a/scripts/run_operator_hard_negative_detection_matrix.sh b/scripts/run_operator_hard_negative_detection_matrix.sh index a376fb56..d1b08749 100644 --- a/scripts/run_operator_hard_negative_detection_matrix.sh +++ b/scripts/run_operator_hard_negative_detection_matrix.sh @@ -146,9 +146,14 @@ with output_path.open("w", encoding="utf-8") as handle: background_category = "reference_aoi" if requested_categories and background_category not in requested_categories: continue + bbox = sample.get("wgs84_bbox") or [] + if len(bbox) != 4: + raise SystemExit(f"Background sample has no valid wgs84_bbox: {sample_slug}") + bbox_csv = ",".join(str(float(value)) for value in bbox) + municipality = str(sample.get("municipality") or "") handle.write( f"{sample_slug}\t{raster_path}\t{sample_role}\t{allow_empty_reference}\t" - f"{reference_count}\t{background_category}\n" + f"{reference_count}\t{background_category}\t{bbox_csv}\t{municipality}\n" ) selected += 1 @@ -263,8 +268,12 @@ echo "Tile overlaps: ${tile_overlaps_normalized}" echo "Thresholds: ${thresholds_normalized}" echo "Output: ${HARD_NEGATIVE_OUTPUT_DIR}" -while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_reference reference_feature_count background_category; do +while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_reference reference_feature_count background_category wgs84_bbox municipality; do echo "-- Background sample ${sample_slug}: role=${sample_role} category=${background_category} allow_empty_reference=${allow_empty_reference} reference_features=${reference_feature_count} --" + project_region="Kempen" + if [ "${municipality,,}" = "mol" ]; then + project_region="Mol, Kempen" + fi while IFS=$'\t' read -r model_request tile_size tile_overlap threshold run_label; do sample_output_dir="${HARD_NEGATIVE_OUTPUT_DIR}/${sample_slug}" mkdir -p "${sample_output_dir}" @@ -278,17 +287,17 @@ while IFS=$'\t' read -r sample_slug raster_path sample_role allow_empty_referenc { echo "sample=${sample_slug} model=${model_request} tile=${tile_size} overlap=${tile_overlap} threshold=${threshold}" - "${PYTHON_BIN}" - "${tmp_dir}/project_request.json" "${sample_slug}" "${model_request}" "${threshold}" <<'PY' + "${PYTHON_BIN}" - "${tmp_dir}/project_request.json" "${sample_slug}" "${model_request}" "${threshold}" "${project_region}" <<'PY' import json import sys from datetime import datetime, timezone -path, sample_slug, model_request, threshold = sys.argv[1:5] +path, sample_slug, model_request, threshold, project_region = sys.argv[1:6] stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") payload = { "name": f"GeoIntel hard-negative {sample_slug} {model_request} {threshold} {stamp}", "description": "Operator hard-negative validation: raster upload, tiling, configured YOLO detection count, no QA reference.", - "region": "Kempen", + "region": project_region, } with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle) @@ -300,6 +309,29 @@ PY require_json_data "${tmp_dir}/project.json" project_id="$(json_field "${tmp_dir}/project.json" "data.id")" + "${PYTHON_BIN}" - "${tmp_dir}/area_request.json" "${sample_slug} background AOI" "${wgs84_bbox}" <<'PY' +import json +import sys + +path, area_name, bbox_raw = sys.argv[1:4] +minx, miny, maxx, maxy = [float(value.strip()) for value in bbox_raw.split(",")] +if minx >= maxx or miny >= maxy: + raise SystemExit("Background AOI bbox minimum values must be smaller than maximum values") +ring = [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]] +payload = { + "name": area_name, + "crs": "EPSG:4326", + "geometry": {"type": "MultiPolygon", "coordinates": [[ring]]}, +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle) +PY + curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/areas" \ + -H "Content-Type: application/json" \ + --data-binary "@${tmp_dir}/area_request.json" > "${tmp_dir}/area.json" + require_json_data "${tmp_dir}/area.json" + area_id="$(json_field "${tmp_dir}/area.json" "data.id")" + curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \ -F "file=@${raster_path}" \ -F "dataset_type=raster" \ @@ -407,6 +439,7 @@ PY "${tile_overlap}" \ "${threshold}" \ "${project_id}" \ + "${area_id}" \ "${raster_dataset_id}" \ "${manifest_path}" \ "${analysis_run_id}" \ @@ -430,6 +463,7 @@ import sys tile_overlap, threshold, project_id, + area_id, raster_dataset_id, manifest_path, analysis_run_id, @@ -437,7 +471,7 @@ import sys detections_list_count, tile_count, run_log, -) = sys.argv[1:20] +) = sys.argv[1:21] detections = int(detection_count) listed = int(detections_list_count) @@ -456,6 +490,7 @@ summary = { "tile_overlap": int(tile_overlap), "threshold": float(threshold), "project_id": project_id, + "area_id": area_id, "raster_dataset_id": raster_dataset_id, "manifest_path": manifest_path, "analysis_run_id": analysis_run_id, diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index 9d3a19f4..fec8060c 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -73,6 +73,7 @@ bash -n scripts/smoke_detection_calibration_evidence_bundle.sh bash -n scripts/assemble_detection_calibration_evidence_portfolio.sh bash -n scripts/run_detection_quality_matrix.sh bash -n scripts/run_multi_sample_detection_quality_matrix.sh +bash -n scripts/run_mol_operational_validation.sh bash -n scripts/run_operator_hard_negative_detection_matrix.sh bash -n scripts/run_background_corpus_split_matrix.sh bash -n scripts/run_split_background_promotion_workflow.sh diff --git a/scripts/verify_real_data_detection_qa_workflow.sh b/scripts/verify_real_data_detection_qa_workflow.sh index b226ce4d..8f7d1abd 100644 --- a/scripts/verify_real_data_detection_qa_workflow.sh +++ b/scripts/verify_real_data_detection_qa_workflow.sh @@ -17,6 +17,9 @@ Required inputs: Optional environment: REAL_PROJECT_NAME Project name for the validation run. + REAL_PROJECT_REGION Persisted project region, default: Kempen. + REAL_AREA_NAME Persisted AOI name when REAL_AREA_BBOX is set. + REAL_AREA_BBOX Optional EPSG:4326 minx,miny,maxx,maxy AOI bounds. REAL_MODEL_ASSET_ID Specific /api/v1/detection/model-assets id to use. REAL_TILE_SIZE Raster tile size, default 640. REAL_TILE_OVERLAP Raster tile overlap, default 64. @@ -29,6 +32,9 @@ BASE_URL="${1:-${GE_INTEL_BASE_URL:-http://localhost:1202}}" REAL_RASTER_PATH="${2:-${REAL_RASTER_PATH:-}}" REAL_REFERENCE_VECTOR_PATH="${3:-${REAL_REFERENCE_VECTOR_PATH:-}}" REAL_PROJECT_NAME="${REAL_PROJECT_NAME:-GeoIntel Real Data Validation}" +REAL_PROJECT_REGION="${REAL_PROJECT_REGION:-Kempen}" +REAL_AREA_NAME="${REAL_AREA_NAME:-${REAL_PROJECT_NAME} AOI}" +REAL_AREA_BBOX="${REAL_AREA_BBOX:-}" REAL_TILE_SIZE="${REAL_TILE_SIZE:-640}" REAL_TILE_OVERLAP="${REAL_TILE_OVERLAP:-64}" REAL_CONFIDENCE_THRESHOLD="${REAL_CONFIDENCE_THRESHOLD:-0.5}" @@ -130,17 +136,17 @@ echo "Base URL: ${BASE_URL}" echo "Raster: ${REAL_RASTER_PATH}" echo "Reference vector: ${REAL_REFERENCE_VECTOR_PATH}" -"${PYTHON_BIN}" - "${TMP_DIR}/project_request.json" "${REAL_PROJECT_NAME}" <<'PY' +"${PYTHON_BIN}" - "${TMP_DIR}/project_request.json" "${REAL_PROJECT_NAME}" "${REAL_PROJECT_REGION}" <<'PY' import json import sys from datetime import datetime, timezone -path, project_name = sys.argv[1], sys.argv[2] +path, project_name, project_region = sys.argv[1:4] stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") payload = { "name": f"{project_name} {stamp}", "description": "Operator-provided real data validation: raster upload, reference vector upload, configured YOLO detection, QA/QC and GeoJSON export.", - "region": "Kempen", + "region": project_region, } with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle) @@ -156,6 +162,41 @@ if [ -z "${project_id}" ] || [ "${project_id}" = "None" ] || [ "${project_id}" = exit 1 fi +area_id="" +if [ -n "${REAL_AREA_BBOX}" ]; then + "${PYTHON_BIN}" - "${TMP_DIR}/area_request.json" "${REAL_AREA_NAME}" "${REAL_AREA_BBOX}" <<'PY' +import json +import sys + +path, area_name, bbox_raw = sys.argv[1:4] +try: + minx, miny, maxx, maxy = [float(value.strip()) for value in bbox_raw.split(",")] +except (TypeError, ValueError) as exc: + raise SystemExit("REAL_AREA_BBOX must contain four numeric EPSG:4326 values: minx,miny,maxx,maxy") from exc +if minx >= maxx or miny >= maxy: + raise SystemExit("REAL_AREA_BBOX minimum values must be smaller than maximum values") +if not (-180 <= minx <= 180 and -180 <= maxx <= 180 and -90 <= miny <= 90 and -90 <= maxy <= 90): + raise SystemExit("REAL_AREA_BBOX is outside EPSG:4326 longitude/latitude bounds") +ring = [[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]] +payload = { + "name": area_name, + "crs": "EPSG:4326", + "geometry": {"type": "MultiPolygon", "coordinates": [[ring]]}, +} +with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle) +PY + curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/areas" \ + -H "Content-Type: application/json" \ + --data-binary "@${TMP_DIR}/area_request.json" > "${TMP_DIR}/area.json" + require_json_data "${TMP_DIR}/area.json" + area_id="$(json_field "${TMP_DIR}/area.json" "data.id")" + if [ -z "${area_id}" ] || [ "${area_id}" = "None" ] || [ "${area_id}" = "null" ]; then + echo "Area creation did not return an area id" >&2 + exit 1 + fi +fi + curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \ -F "file=@${REAL_RASTER_PATH}" \ -F "dataset_type=raster" \ @@ -494,6 +535,7 @@ PY echo "Real data detection + QA workflow verification passed" echo "Project: ${project_id}" +echo "Area: ${area_id}" echo "Raster dataset: ${raster_dataset_id}" echo "Reference dataset: ${reference_dataset_id}" echo "Model asset: ${model_asset_id}"