Add Mol multi-zone operational validation

This commit is contained in:
Codex
2026-07-13 18:12:48 +02:00
parent 4539e92bcf
commit 2c16ff2bc0
16 changed files with 605 additions and 12 deletions
+38
View File
@@ -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:
@@ -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']}`."
)
+14 -1
View File
@@ -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/<timestamp>.
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,
+209
View File
@@ -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/<timestamp>.
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
@@ -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"),
@@ -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,
+1
View File
@@ -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
@@ -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}"