Add Mol multi-zone operational validation
This commit is contained in:
@@ -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']}`."
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user