#!/usr/bin/env bash set -euo pipefail usage() { cat >&2 <<'EOF' Usage: REAL_RASTER_PATH=/path/to/orthophoto.tif \ REAL_REFERENCE_VECTOR_PATH=/path/to/reference-buildings.geojson \ bash scripts/verify_real_data_detection_qa_workflow.sh [base_url] or: bash scripts/verify_real_data_detection_qa_workflow.sh [base_url] /path/to/orthophoto.tif /path/to/reference-buildings.geojson Required inputs: REAL_RASTER_PATH Georeferenced .tif/.tiff/.geotiff raster. REAL_REFERENCE_VECTOR_PATH EPSG-aware .geojson/.json reference vector with building polygons. Optional environment: REAL_PROJECT_NAME Project name for the validation run. REAL_PROJECT_REGION Persisted project region, default: Kempen. REAL_PROJECT_ID Existing project id to reuse instead of creating a validation project. 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_DATASET_NAME_PREFIX Safe filename prefix for raster/reference uploads in a reused project. 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. REAL_CONFIDENCE_THRESHOLD Detection confidence threshold, default 0.5. REAL_IOU_THRESHOLD QA IoU threshold, default 0.5. EOF } 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_PROJECT_ID="${REAL_PROJECT_ID:-}" REAL_AREA_NAME="${REAL_AREA_NAME:-${REAL_PROJECT_NAME} AOI}" REAL_AREA_BBOX="${REAL_AREA_BBOX:-}" REAL_DATASET_NAME_PREFIX="${REAL_DATASET_NAME_PREFIX:-}" REAL_TILE_SIZE="${REAL_TILE_SIZE:-640}" REAL_TILE_OVERLAP="${REAL_TILE_OVERLAP:-64}" REAL_CONFIDENCE_THRESHOLD="${REAL_CONFIDENCE_THRESHOLD:-0.5}" REAL_IOU_THRESHOLD="${REAL_IOU_THRESHOLD:-0.5}" REAL_MODEL_ASSET_ID="${REAL_MODEL_ASSET_ID:-}" TMP_DIR="$(mktemp -d)" trap 'rm -rf "${TMP_DIR}"' EXIT if [ "${BASE_URL}" = "-h" ] || [ "${BASE_URL}" = "--help" ]; then usage exit 0 fi if [ -z "${REAL_RASTER_PATH}" ] || [ -z "${REAL_REFERENCE_VECTOR_PATH}" ]; then usage exit 2 fi if [ ! -f "${REAL_RASTER_PATH}" ]; then echo "REAL_RASTER_PATH does not point to a readable file: ${REAL_RASTER_PATH}" >&2 exit 2 fi if [ ! -f "${REAL_REFERENCE_VECTOR_PATH}" ]; then echo "REAL_REFERENCE_VECTOR_PATH does not point to a readable file: ${REAL_REFERENCE_VECTOR_PATH}" >&2 exit 2 fi case "${REAL_DATASET_NAME_PREFIX}" in *[!a-zA-Z0-9._-]*) echo "REAL_DATASET_NAME_PREFIX may contain letters, numbers, dot, underscore and dash only" >&2 exit 2 ;; esac case "${REAL_RASTER_PATH,,}" in *.tif|*.tiff|*.geotiff) ;; *) echo "REAL_RASTER_PATH must be a .tif, .tiff or .geotiff file for the current V1 raster upload flow." >&2 exit 2 ;; esac case "${REAL_REFERENCE_VECTOR_PATH,,}" in *.geojson|*.json) ;; *) echo "REAL_REFERENCE_VECTOR_PATH must be .geojson or .json for the current V1 vector upload flow." >&2 exit 2 ;; esac if ! command -v curl >/dev/null 2>&1; then echo "curl is required for real data detection/QA workflow verification" >&2 exit 1 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 JSON parsing" >&2 exit 1 fi json_field() { local file_path="$1" local expression="$2" "${PYTHON_BIN}" - "$file_path" "$expression" <<'PY' import json import sys path, expression = sys.argv[1], sys.argv[2] with open(path, "r", encoding="utf-8") as handle: payload = json.load(handle) value = payload for part in expression.split("."): if part: value = value[part] print(value) PY } require_json_data() { local file_path="$1" "${PYTHON_BIN}" - "$file_path" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as handle: payload = json.load(handle) if "data" not in payload: raise SystemExit("Response is not a canonical GeoIntel data envelope") PY } echo "== GeoIntel real data detection + QA workflow verification ==" echo "Base URL: ${BASE_URL}" echo "Raster: ${REAL_RASTER_PATH}" echo "Reference vector: ${REAL_REFERENCE_VECTOR_PATH}" if [ -n "${REAL_PROJECT_ID}" ]; then curl -fsS "${BASE_URL%/}/api/v1/projects/${REAL_PROJECT_ID}" > "${TMP_DIR}/project.json" require_json_data "${TMP_DIR}/project.json" project_id="$(json_field "${TMP_DIR}/project.json" "data.id")" if [ "${project_id}" != "${REAL_PROJECT_ID}" ]; then echo "Existing project lookup returned the wrong project id" >&2 exit 1 fi else "${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, 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": project_region, } with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle) PY curl -fsS -X POST "${BASE_URL%/}/api/v1/projects" \ -H "Content-Type: application/json" \ --data-binary "@${TMP_DIR}/project_request.json" > "${TMP_DIR}/project.json" require_json_data "${TMP_DIR}/project.json" project_id="$(json_field "${TMP_DIR}/project.json" "data.id")" if [ -z "${project_id}" ] || [ "${project_id}" = "None" ] || [ "${project_id}" = "null" ]; then echo "Project creation did not return a project id" >&2 exit 1 fi 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 area_upload_args=() if [ -n "${area_id}" ]; then area_upload_args=(-F "area_id=${area_id}") fi raster_form_file="file=@${REAL_RASTER_PATH}" reference_form_file="file=@${REAL_REFERENCE_VECTOR_PATH}" if [ -n "${REAL_DATASET_NAME_PREFIX}" ]; then raster_extension="${REAL_RASTER_PATH##*.}" reference_extension="${REAL_REFERENCE_VECTOR_PATH##*.}" raster_form_file="${raster_form_file};filename=${REAL_DATASET_NAME_PREFIX}_orthophoto.${raster_extension}" reference_form_file="${reference_form_file};filename=${REAL_DATASET_NAME_PREFIX}_grb_buildings.${reference_extension}" fi curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \ -F "${raster_form_file}" \ -F "dataset_type=raster" \ -F "source=user_upload" \ -F "dataset_role=source" \ -F "source_name=manual" \ -F 'source_metadata_json={"validation_workflow":"real_data_detection_qa","input_kind":"orthophoto"}' \ -F 'provenance_metadata_json={"operator_supplied":true,"no_external_fetch":true}' \ "${area_upload_args[@]}" \ > "${TMP_DIR}/raster_upload.json" require_json_data "${TMP_DIR}/raster_upload.json" raster_dataset_id="$(json_field "${TMP_DIR}/raster_upload.json" "data.id")" "${PYTHON_BIN}" - "${TMP_DIR}/raster_upload.json" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("dataset_type") != "raster": raise SystemExit(f"Uploaded raster returned wrong dataset_type: {data.get('dataset_type')}") if data.get("status") != "ready": raise SystemExit(f"Uploaded raster is not ready: {data.get('status')}") if not data.get("crs"): raise SystemExit("Uploaded raster does not expose CRS metadata; refusing geospatial AI validation") if not data.get("bounds_json"): raise SystemExit("Uploaded raster does not expose bounds metadata; refusing geospatial AI validation") PY curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/upload" \ -F "${reference_form_file}" \ -F "dataset_type=vector" \ -F "source=user_upload" \ -F "dataset_role=reference" \ -F "source_name=manual" \ -F "reference_layer_name=buildings" \ -F 'source_metadata_json={"validation_workflow":"real_data_detection_qa","input_kind":"reference_buildings"}' \ -F 'provenance_metadata_json={"operator_supplied":true,"no_external_fetch":true}' \ "${area_upload_args[@]}" \ > "${TMP_DIR}/reference_upload.json" require_json_data "${TMP_DIR}/reference_upload.json" reference_dataset_id="$(json_field "${TMP_DIR}/reference_upload.json" "data.id")" "${PYTHON_BIN}" - "${TMP_DIR}/reference_upload.json" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("dataset_type") != "vector": raise SystemExit(f"Uploaded reference returned wrong dataset_type: {data.get('dataset_type')}") if data.get("dataset_role") != "reference": raise SystemExit("Uploaded reference did not persist dataset_role=reference") if data.get("reference_layer_name") != "buildings": raise SystemExit("Uploaded reference did not persist reference_layer_name=buildings") if data.get("status") != "ready": raise SystemExit(f"Uploaded reference is not ready: {data.get('status')}") if int(data.get("feature_count") or 0) < 1: raise SystemExit("Uploaded reference has no persisted features; QA would be meaningless") PY curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/inspect" > "${TMP_DIR}/raster_inspect.json" require_json_data "${TMP_DIR}/raster_inspect.json" "${PYTHON_BIN}" - "${TMP_DIR}/raster_inspect.json" "${raster_dataset_id}" <<'PY' import json import sys path, raster_id = sys.argv[1], sys.argv[2] with open(path, "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("dataset_id") != raster_id: raise SystemExit("Raster inspect returned the wrong dataset id") if data.get("ready") is not True: raise SystemExit("Raster inspect did not report ready=true") metadata = data.get("metadata") or {} if not metadata.get("crs"): raise SystemExit("Raster inspect metadata has no CRS") if int(metadata.get("width") or 0) < 1 or int(metadata.get("height") or 0) < 1: raise SystemExit("Raster inspect metadata has invalid dimensions") PY curl -fsS "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${reference_dataset_id}/vector/summary" > "${TMP_DIR}/reference_summary.json" require_json_data "${TMP_DIR}/reference_summary.json" "${PYTHON_BIN}" - "${TMP_DIR}/reference_summary.json" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if int(data.get("feature_count") or 0) < 1: raise SystemExit("Reference vector summary reports no features") geometry_types = set(data.get("geometry_types") or []) if not geometry_types.intersection({"Polygon", "MultiPolygon"}): raise SystemExit(f"Reference vector does not expose polygonal building geometries: {sorted(geometry_types)}") PY curl -fsS -X POST "${BASE_URL%/}/api/v1/projects/${project_id}/datasets/${raster_dataset_id}/raster/tile" \ -H "Content-Type: application/json" \ -d "{\"tile_size\":${REAL_TILE_SIZE},\"overlap\":${REAL_TILE_OVERLAP},\"output_name\":\"real_data_detection_tiles\"}" > "${TMP_DIR}/tile.json" require_json_data "${TMP_DIR}/tile.json" manifest_path="$(json_field "${TMP_DIR}/tile.json" "data.result_json.manifest_path")" if [ -z "${manifest_path}" ] || [ "${manifest_path}" = "None" ] || [ "${manifest_path}" = "null" ]; then echo "Raster tile response did not include a manifest_path" >&2 exit 1 fi "${PYTHON_BIN}" - "${TMP_DIR}/tile.json" "${raster_dataset_id}" <<'PY' import json import sys path, raster_id = sys.argv[1], sys.argv[2] with open(path, "r", encoding="utf-8") as handle: job = json.load(handle)["data"] if job.get("job_type") != "raster.tile": raise SystemExit(f"Unexpected tile job type: {job.get('job_type')}") if job.get("status") not in {"success", "completed"}: raise SystemExit(f"Raster tile job did not complete: {job.get('status')}") result = job.get("result_json") or {} if result.get("dataset_id") != raster_id: raise SystemExit("Raster tile result returned the wrong dataset id") if result.get("ready") is not True: raise SystemExit("Raster tile result did not report ready=true") manifest = result.get("manifest") or {} tiles = manifest.get("tiles") or [] if not tiles: raise SystemExit("Raster tile manifest has no tiles") if manifest.get("source_dataset_id") != raster_id: raise SystemExit("Raster tile manifest source_dataset_id drifted") PY curl -fsS "${BASE_URL%/}/api/v1/detection/model-assets" > "${TMP_DIR}/model_assets.json" require_json_data "${TMP_DIR}/model_assets.json" model_asset_id="$("${PYTHON_BIN}" - "${TMP_DIR}/model_assets.json" "${REAL_MODEL_ASSET_ID}" <<'PY' import json import sys path, requested = sys.argv[1], sys.argv[2] with open(path, "r", encoding="utf-8") as handle: data = json.load(handle)["data"] items = data.get("items") or [] if not items: raise SystemExit("No local model assets are available. Mount a local .pt/.onnx/.engine file before running this smoke.") for item in items: if item.get("will_download_models") is not False: raise SystemExit("Model asset catalog must never report automatic model downloads") if requested: matches = [item for item in items if item.get("model_asset_id") == requested] if not matches: raise SystemExit(f"Requested REAL_MODEL_ASSET_ID was not found: {requested}") selected = matches[0] else: selected = next((item for item in items if item.get("active")), items[0]) print(selected["model_asset_id"]) PY )" curl -fsS -G "${BASE_URL%/}/api/v1/detection/yolo/preflight" \ --data-urlencode "tile_manifest_path=${manifest_path}" \ --data-urlencode "model_asset_id=${model_asset_id}" > "${TMP_DIR}/preflight.json" require_json_data "${TMP_DIR}/preflight.json" "${PYTHON_BIN}" - "${TMP_DIR}/preflight.json" "${model_asset_id}" <<'PY' import json import sys path, expected_model_asset_id = sys.argv[1], sys.argv[2] with open(path, "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("model_asset_id") != expected_model_asset_id: raise SystemExit("YOLO preflight did not use the selected model_asset_id") if data.get("will_download_models") is not False: raise SystemExit("YOLO preflight must never download model weights") if data.get("will_run_inference") is not False: raise SystemExit("YOLO preflight must remain read-only") if data.get("status") != "ready": raise SystemExit(f"YOLO preflight is not ready: {data.get('status')} {data.get('message')}") checks = data.get("checks") or {} if checks.get("manifest_valid") is not True: raise SystemExit("YOLO preflight did not validate the raster tile manifest") if checks.get("model_file_exists") is not True: raise SystemExit("YOLO preflight did not confirm the local model file") PY "${PYTHON_BIN}" - "${TMP_DIR}/run_request.json" "${project_id}" "${raster_dataset_id}" "${model_asset_id}" "${manifest_path}" "${REAL_CONFIDENCE_THRESHOLD}" <<'PY' import json import sys path, project_id, dataset_id, model_asset_id, tile_manifest_path, confidence = sys.argv[1:7] payload = { "project_id": project_id, "dataset_id": dataset_id, "model_id": "yolo-configured", "model_asset_id": model_asset_id, "confidence_threshold": float(confidence), "class_filter": ["building"], "tile_manifest_path": tile_manifest_path, "parameters_json": { "workflow": "real_data_detection_qa", "operator_supplied_inputs": True, }, } with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle) PY curl -fsS -X POST "${BASE_URL%/}/api/v1/detection/run" \ -H "Content-Type: application/json" \ --data-binary "@${TMP_DIR}/run_request.json" > "${TMP_DIR}/detection_run.json" require_json_data "${TMP_DIR}/detection_run.json" "${PYTHON_BIN}" - "${TMP_DIR}/detection_run.json" <<'PY' import json import sys with open(sys.argv[1], "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("model_id") != "yolo-configured": raise SystemExit("Detection run did not use yolo-configured") if not data.get("analysis_run_id") or not data.get("job_id"): raise SystemExit("Detection run did not return persisted run/job ids") if data.get("status") != "success": raise SystemExit(f"Detection run failed: {data.get('error_code')} {data.get('message')}") if int(data.get("detection_count") or 0) < 0: raise SystemExit("Detection count cannot be negative") PY analysis_run_id="$(json_field "${TMP_DIR}/detection_run.json" "data.analysis_run_id")" detection_count="$(json_field "${TMP_DIR}/detection_run.json" "data.detection_count")" curl -fsS "${BASE_URL%/}/api/v1/detection/runs/${analysis_run_id}/detections" > "${TMP_DIR}/detections.json" require_json_data "${TMP_DIR}/detections.json" "${PYTHON_BIN}" - "${TMP_DIR}/detections.json" "${detection_count}" <<'PY' import json import sys path, expected_count = sys.argv[1], int(sys.argv[2]) with open(path, "r", encoding="utf-8") as handle: data = json.load(handle)["data"] items = data.get("items") or [] if int(data.get("total") or 0) != expected_count: raise SystemExit("Detection list total does not match run detection_count") if len(items) != expected_count: raise SystemExit("Detection list item count does not match run detection_count") for item in items: if not item.get("source_tile_path"): raise SystemExit("Persisted detection is missing source_tile_path provenance") PY curl -fsS "${BASE_URL%/}/api/v1/detection/runs/${analysis_run_id}/geojson" > "${TMP_DIR}/detection_geojson.json" require_json_data "${TMP_DIR}/detection_geojson.json" "${PYTHON_BIN}" - "${TMP_DIR}/detection_geojson.json" "${detection_count}" <<'PY' import json import sys path, expected_count = sys.argv[1], int(sys.argv[2]) with open(path, "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("type") != "FeatureCollection": raise SystemExit("Detection GeoJSON response is not a FeatureCollection") if len(data.get("features") or []) != expected_count: raise SystemExit("Detection GeoJSON feature count does not match detection_count") PY "${PYTHON_BIN}" - "${TMP_DIR}/qa_request.json" "${reference_dataset_id}" "${REAL_IOU_THRESHOLD}" "${REAL_CONFIDENCE_THRESHOLD}" <<'PY' import json import sys path, reference_dataset_id, iou_threshold, confidence = sys.argv[1:5] payload = { "reference_dataset_id": reference_dataset_id, "iou_threshold": float(iou_threshold), "class_name": "building", "min_confidence": float(confidence), } with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle) PY curl -fsS -X POST "${BASE_URL%/}/api/v1/detection/runs/${analysis_run_id}/qa/reference" \ -H "Content-Type: application/json" \ --data-binary "@${TMP_DIR}/qa_request.json" > "${TMP_DIR}/qa.json" require_json_data "${TMP_DIR}/qa.json" "${PYTHON_BIN}" - "${TMP_DIR}/qa.json" "${analysis_run_id}" "${reference_dataset_id}" <<'PY' import json import sys path, analysis_run_id, reference_dataset_id = sys.argv[1:4] with open(path, "r", encoding="utf-8") as handle: data = json.load(handle)["data"] if data.get("analysis_run_id") != analysis_run_id: raise SystemExit("Detection QA returned the wrong analysis_run_id") if data.get("reference_dataset_id") != reference_dataset_id: raise SystemExit("Detection QA returned the wrong reference_dataset_id") if not data.get("quality_check_id"): raise SystemExit("Detection QA did not persist a quality_check_id") if int(data.get("reference_feature_count") or 0) < 1: raise SystemExit("Detection QA reference feature count is empty") coverage = data.get("coverage") or {} if coverage.get("applied") is not True: raise SystemExit("Detection QA did not apply persisted tile-manifest coverage") if coverage.get("mode") != "persisted_tile_manifest_union": raise SystemExit("Detection QA returned an unexpected coverage mode") if int(coverage.get("tile_count") or 0) < 1: raise SystemExit("Detection QA coverage did not report persisted tiles") if int(coverage.get("reference_raw_count") or 0) < int(coverage.get("reference_evaluated_count") or 0): raise SystemExit("Detection QA evaluated more references than the raw population") diagnostics = data.get("box_to_footprint_diagnostics") or {} if diagnostics.get("diagnostic_only") is not True: raise SystemExit("Detection QA did not return box-to-footprint diagnostics") if diagnostics.get("canonical_method") != "candidate_polygon_vs_reference_footprint_iou": raise SystemExit("Detection QA canonical matching method changed unexpectedly") for key in ("matches", "false_positives", "false_negatives"): if int(data.get(key) or 0) < 0: raise SystemExit(f"Detection QA returned a negative {key}") PY quality_check_id="$(json_field "${TMP_DIR}/qa.json" "data.quality_check_id")" "${PYTHON_BIN}" - "${TMP_DIR}/export_request.json" "${analysis_run_id}" <<'PY' import json import sys path, analysis_run_id = sys.argv[1], sys.argv[2] payload = { "export_kind": "detection_run", "analysis_run_id": analysis_run_id, "name": "real-data-detection-run", } with open(path, "w", encoding="utf-8") as handle: json.dump(payload, handle) PY curl -fsS -X POST "${BASE_URL%/}/api/v1/exports/geojson" \ -H "Content-Type: application/json" \ --data-binary "@${TMP_DIR}/export_request.json" > "${TMP_DIR}/export.json" require_json_data "${TMP_DIR}/export.json" export_id="$(json_field "${TMP_DIR}/export.json" "data.export_id")" if [ -z "${export_id}" ] || [ "${export_id}" = "None" ] || [ "${export_id}" = "null" ]; then echo "Detection GeoJSON export did not return an export_id" >&2 exit 1 fi curl -fsS "${BASE_URL%/}/api/v1/exports/${export_id}/content" > "${TMP_DIR}/export_content.json" require_json_data "${TMP_DIR}/export_content.json" "${PYTHON_BIN}" - "${TMP_DIR}/export_content.json" "${detection_count}" <<'PY' import json import sys path, expected_count = sys.argv[1], int(sys.argv[2]) with open(path, "r", encoding="utf-8") as handle: content = json.load(handle)["data"]["content"] if content.get("type") != "FeatureCollection": raise SystemExit("Detection export content is not a FeatureCollection") if len(content.get("features") or []) != expected_count: raise SystemExit("Detection export feature count does not match detection_count") 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}" echo "Manifest: ${manifest_path}" echo "Analysis run: ${analysis_run_id}" echo "Detections: ${detection_count}" echo "Quality check: ${quality_check_id}" echo "Detection export: ${export_id}"