247 lines
14 KiB
Python
247 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Provision a reproducible, region-balanced Belgian building corpus portfolio."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import requests
|
|
from pyproj import Transformer
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Aoi:
|
|
slug: str
|
|
region: str
|
|
context: str
|
|
split: str
|
|
lon: float
|
|
lat: float
|
|
sample_role: str = "positive"
|
|
require_empty: bool = False
|
|
|
|
|
|
AOIS = (
|
|
# Flanders: all split roles plus authoritative empty/hard contexts.
|
|
Aoi("antwerp-core-train", "flanders", "dense-urban", "train", 4.402, 51.219),
|
|
Aoi("ghent-core-train", "flanders", "dense-urban", "train", 3.725, 51.052),
|
|
Aoi("genk-industry-train", "flanders", "industrial", "train", 5.500, 50.965),
|
|
Aoi("flanders-farms-train", "flanders", "rural-farms", "train", 4.850, 50.900),
|
|
Aoi("kalmthout-heath-train-bg", "flanders", "heath-negative", "train", 4.450, 51.390, "background_candidate"),
|
|
Aoi("limburg-forest-train-bg", "flanders", "forest-negative", "train", 5.550, 51.050, "background_candidate"),
|
|
Aoi("ostend-coastal-train", "flanders", "coastal-urban", "train", 2.920, 51.225),
|
|
Aoi("roeselare-industry-train", "flanders", "industrial", "train", 3.120, 50.945),
|
|
Aoi("dendermonde-suburban-train", "flanders", "suburban", "train", 4.100, 51.030),
|
|
Aoi("houthalen-forest-train-bg", "flanders", "forest-negative", "train", 5.380, 51.030, "background_candidate"),
|
|
Aoi("lier-urban-train", "flanders", "historic-urban", "train", 4.570, 51.130),
|
|
Aoi("lokeren-ribbon-train", "flanders", "ribbon-development", "train", 3.990, 51.100),
|
|
Aoi("tienen-urban-train", "flanders", "mixed-urban", "train", 4.940, 50.810),
|
|
Aoi("deinze-suburban-train", "flanders", "suburban", "train", 3.530, 50.980),
|
|
Aoi("lommel-industry-train", "flanders", "industrial", "train", 5.310, 51.230),
|
|
# Independent train-only AOIs added after the v6 gate review. They target
|
|
# the weak Flemish ribbon/mixed-urban recall and port/coastal false positives.
|
|
Aoi("aalst-mixed-train", "flanders", "mixed-urban", "train", 4.035, 50.938),
|
|
Aoi("herentals-ribbon-train", "flanders", "ribbon-development", "train", 4.835, 51.177),
|
|
Aoi("diest-suburban-train", "flanders", "suburban", "train", 5.052, 50.985),
|
|
Aoi("tongeren-urban-train", "flanders", "historic-urban", "train", 5.464, 50.781),
|
|
Aoi("ghent-port-train-hard", "flanders", "port-hard-negative", "train", 3.765, 51.135, "background_candidate"),
|
|
Aoi("zeebrugge-port-train-hard", "flanders", "port-hard-negative", "train", 3.205, 51.330, "background_candidate"),
|
|
Aoi("hoge-kempen-train-bg", "flanders", "forest-heath-negative", "train", 5.650, 50.990, "background_candidate"),
|
|
Aoi("westhoek-dunes-train-bg", "flanders", "dunes-negative", "train", 2.590, 51.095, "background_candidate"),
|
|
Aoi("bruges-val", "flanders", "historic-urban", "val", 3.224, 51.209),
|
|
Aoi("turnhout-val", "flanders", "suburban", "val", 4.944, 51.322),
|
|
Aoi("hasselt-cal", "flanders", "suburban", "calibration", 5.340, 50.930),
|
|
Aoi("kortrijk-cal", "flanders", "urban-industrial", "calibration", 3.265, 50.828),
|
|
Aoi("waregem-cal", "flanders", "ribbon-development", "calibration", 3.430, 50.890),
|
|
Aoi("leuven-test", "flanders", "urban", "test", 4.700, 50.880),
|
|
Aoi("sint-niklaas-test", "flanders", "ribbon-development", "test", 4.143, 51.165),
|
|
Aoi("mechelen-test", "flanders", "mixed-urban", "test", 4.480, 51.030),
|
|
Aoi("kempen-forest-bg", "flanders", "forest-heath", "background-test", 5.180, 51.300, "background_candidate", True),
|
|
Aoi("antwerp-port-bg", "flanders", "port-hard-negative", "background-test", 4.380, 51.280, "background_candidate", True),
|
|
# Wallonia.
|
|
Aoi("liege-core-train", "wallonia", "dense-urban", "train", 5.570, 50.640),
|
|
Aoi("charleroi-core-train", "wallonia", "dense-urban", "train", 4.440, 50.410),
|
|
Aoi("seraing-industry-train", "wallonia", "industrial-valley", "train", 5.500, 50.600),
|
|
Aoi("namur-residential-train", "wallonia", "residential", "train", 4.870, 50.470),
|
|
Aoi("ardennes-forest-train-bg", "wallonia", "forest-negative", "train", 5.700, 50.200, "background_candidate"),
|
|
Aoi("wallonia-quarry-train-hard", "wallonia", "quarry-hard-negative", "train", 5.130, 50.530, "background_candidate"),
|
|
Aoi("wavre-suburban-train", "wallonia", "suburban", "train", 4.610, 50.720),
|
|
Aoi("marche-smallcity-train", "wallonia", "small-city", "train", 5.340, 50.230),
|
|
Aoi("ath-rural-train", "wallonia", "rural-town", "train", 3.780, 50.630),
|
|
Aoi("condroz-field-train-bg", "wallonia", "farmland-negative", "train", 4.700, 50.300, "background_candidate"),
|
|
Aoi("la-louviere-train", "wallonia", "dense-urban", "train", 4.190, 50.480),
|
|
Aoi("huy-valley-train", "wallonia", "valley-urban", "train", 5.240, 50.520),
|
|
Aoi("eupen-train", "wallonia", "regional-architecture", "train", 6.030, 50.630),
|
|
Aoi("ciney-train", "wallonia", "rural-town", "train", 5.100, 50.290),
|
|
Aoi("couvin-train", "wallonia", "forest-town", "train", 4.500, 50.050),
|
|
Aoi("nivelles-mixed-train", "wallonia", "mixed-urban", "train", 4.328, 50.598),
|
|
Aoi("arlon-industry-train", "wallonia", "industrial", "train", 5.790, 49.676),
|
|
Aoi("liege-rail-train-hard", "wallonia", "rail-hard-negative", "train", 5.615, 50.625, "background_candidate"),
|
|
Aoi("famenne-quarry-train-hard", "wallonia", "quarry-hard-negative", "train", 5.080, 50.170, "background_candidate"),
|
|
Aoi("tournai-val", "wallonia", "historic-urban", "val", 3.389, 50.606),
|
|
Aoi("arlon-val", "wallonia", "small-city", "val", 5.817, 49.683),
|
|
Aoi("verviers-cal", "wallonia", "suburban", "calibration", 5.860, 50.590),
|
|
Aoi("dinant-cal", "wallonia", "valley-town", "calibration", 4.912, 50.260),
|
|
Aoi("mouscron-cal", "wallonia", "mixed-urban", "calibration", 3.210, 50.740),
|
|
Aoi("mons-test", "wallonia", "urban", "test", 3.950, 50.450),
|
|
Aoi("bastogne-test", "wallonia", "rural-town", "test", 5.720, 50.000),
|
|
Aoi("ottignies-test", "wallonia", "suburban", "test", 4.570, 50.670),
|
|
Aoi("wallonia-rural-bg", "wallonia", "open-rural", "background-test", 5.000, 50.300, "background_candidate", True),
|
|
Aoi("ardennes-forest-hard", "wallonia", "forest-hard-negative", "background-test", 5.600, 50.100, "background_candidate"),
|
|
# Brussels.
|
|
Aoi("brussels-center-train", "brussels", "dense-urban", "train", 4.352, 50.847),
|
|
Aoi("anderlecht-industry-train", "brussels", "industrial", "train", 4.320, 50.880),
|
|
Aoi("uccle-residential-train", "brussels", "detached-residential", "train", 4.350, 50.795),
|
|
Aoi("schaerbeek-train", "brussels", "dense-residential", "train", 4.380, 50.865),
|
|
Aoi("brussels-rail-train-hard", "brussels", "rail-hard-negative", "train", 4.345, 50.875, "background_candidate"),
|
|
Aoi("brussels-park-train-hard", "brussels", "park-hard-negative", "train", 4.400, 50.820, "background_candidate"),
|
|
Aoi("haren-mixed-train", "brussels", "mixed-urban", "train", 4.420, 50.890),
|
|
Aoi("ixelles-dense-train", "brussels", "dense-urban", "train", 4.370, 50.830),
|
|
Aoi("forest-residential-train", "brussels", "residential", "train", 4.320, 50.810),
|
|
Aoi("woluwe-park-train-hard", "brussels", "park-hard-negative", "train", 4.440, 50.840, "background_candidate"),
|
|
Aoi("evere-train", "brussels", "dense-residential", "train", 4.400, 50.870),
|
|
Aoi("berchem-train", "brussels", "suburban", "train", 4.290, 50.860),
|
|
Aoi("watermael-train", "brussels", "forest-edge-residential", "train", 4.400, 50.810),
|
|
Aoi("neder-over-heembeek-train", "brussels", "mixed-urban", "train", 4.385, 50.895),
|
|
Aoi("brussels-canal-south-train-hard", "brussels", "canal-hard-negative", "train", 4.320, 50.820, "background_candidate"),
|
|
Aoi("laeken-mixed-train", "brussels", "mixed-urban", "train", 4.355, 50.885),
|
|
Aoi("delta-rail-train-hard", "brussels", "rail-hard-negative", "train", 4.405, 50.815, "background_candidate"),
|
|
Aoi("josaphat-park-train-hard", "brussels", "park-hard-negative", "train", 4.385, 50.875, "background_candidate"),
|
|
Aoi("woluwe-val", "brussels", "suburban", "val", 4.430, 50.845),
|
|
Aoi("molenbeek-val", "brussels", "mixed-urban", "val", 4.325, 50.855),
|
|
Aoi("brussels-park-cal", "brussels", "park-edge", "calibration", 4.380, 50.820),
|
|
Aoi("brussels-canal-cal", "brussels", "canal-industry", "calibration", 4.340, 50.870),
|
|
Aoi("saint-gilles-cal", "brussels", "dense-urban", "calibration", 4.345, 50.825),
|
|
Aoi("brussels-rail-test", "brussels", "rail-context", "test", 4.330, 50.840),
|
|
Aoi("jette-test", "brussels", "residential-park", "test", 4.325, 50.880),
|
|
Aoi("auderghem-test", "brussels", "residential-forest-edge", "test", 4.430, 50.815),
|
|
Aoi("sonian-forest-hard", "brussels", "forest-hard-negative", "background-test", 4.420, 50.790, "background_candidate"),
|
|
Aoi("bois-cambre-hard", "brussels", "park-hard-negative", "background-test", 4.375, 50.795, "background_candidate"),
|
|
)
|
|
|
|
REGION_CONTRACT = {
|
|
"flanders": {
|
|
"area_id": "2a1a064e-cfd6-46f4-981a-d6ffa0ed5b5e",
|
|
"orthophoto_product": "2025",
|
|
"reference_path": "datasets/grb/acquire",
|
|
"reference_product": "buildings",
|
|
},
|
|
"wallonia": {
|
|
"area_id": "e5fd742a-ca18-4520-abe0-d28416aa2ece",
|
|
"orthophoto_product": "wallonia_2023",
|
|
"reference_path": "datasets/official-vector/acquire",
|
|
"reference_product": "spw_picc_buildings",
|
|
},
|
|
"brussels": {
|
|
"area_id": "304b413d-d81c-40fc-922e-49907b8efaaa",
|
|
"orthophoto_product": "brussels_2025",
|
|
"reference_path": "datasets/official-vector/acquire",
|
|
"reference_product": "urbis_buildings",
|
|
},
|
|
}
|
|
|
|
|
|
def bbox_for_center(lon: float, lat: float, side_m: float) -> dict[str, Any]:
|
|
to_metric = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
|
|
to_wgs84 = Transformer.from_crs("EPSG:31370", "EPSG:4326", always_xy=True)
|
|
x, y = to_metric.transform(lon, lat)
|
|
half = side_m / 2.0
|
|
min_lon, min_lat, max_lon, max_lat = to_wgs84.transform_bounds(x - half, y - half, x + half, y + half)
|
|
return {"min_x": min_lon, "min_y": min_lat, "max_x": max_lon, "max_y": max_lat, "crs": "EPSG:4326"}
|
|
|
|
|
|
def post(session: requests.Session, url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
response = session.post(url, json=payload, timeout=180)
|
|
if not response.ok:
|
|
raise RuntimeError(f"Provider workflow HTTP {response.status_code}: {response.text[:1000]}")
|
|
body = response.json()
|
|
if body.get("error"):
|
|
raise RuntimeError(f"{body['error']}: {body.get('message')}")
|
|
job = body["data"]
|
|
if job.get("status") != "success":
|
|
raise RuntimeError(f"Job failed: {job}")
|
|
return job
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--project-id", required=True)
|
|
parser.add_argument("--base-url", default="http://127.0.0.1:8000/api/v1")
|
|
parser.add_argument("--output-spec", type=Path, required=True)
|
|
parser.add_argument("--side-m", type=float, default=256.0)
|
|
parser.add_argument("--resolution-m", type=float, default=0.25)
|
|
parser.add_argument("--force-refresh", action="store_true")
|
|
args = parser.parse_args()
|
|
session = requests.Session()
|
|
samples: list[dict[str, Any]] = []
|
|
if args.output_spec.is_file():
|
|
existing = json.loads(args.output_spec.read_text(encoding="utf-8-sig"))
|
|
samples = list(existing.get("samples") or [])
|
|
for sample in samples:
|
|
if "sample_slug" not in sample and sample.get("slug"):
|
|
sample["sample_slug"] = sample.pop("slug")
|
|
completed = {str(sample.get("sample_slug") or sample.get("slug")) for sample in samples}
|
|
for aoi in AOIS:
|
|
if aoi.slug in completed:
|
|
print(f"{aoi.slug}: checkpoint reused", flush=True)
|
|
continue
|
|
contract = REGION_CONTRACT[aoi.region]
|
|
bbox = bbox_for_center(aoi.lon, aoi.lat, args.side_m)
|
|
common = {"bbox": bbox, "area_id": contract["area_id"], "force_refresh": args.force_refresh}
|
|
image_job = post(
|
|
session,
|
|
f"{args.base_url}/projects/{args.project_id}/datasets/orthophoto/acquire",
|
|
{**common, "product_key": contract["orthophoto_product"], "resolution_m": args.resolution_m},
|
|
)
|
|
reference_job = post(
|
|
session,
|
|
f"{args.base_url}/projects/{args.project_id}/{contract['reference_path']}",
|
|
{**common, "product_key": contract["reference_product"]},
|
|
)
|
|
feature_count = int(reference_job["result_json"]["feature_count"])
|
|
if aoi.require_empty and feature_count != 0:
|
|
raise RuntimeError(f"Pure-background AOI {aoi.slug} contains {feature_count} reference buildings")
|
|
samples.append(
|
|
{
|
|
"sample_slug": aoi.slug,
|
|
"region": aoi.region,
|
|
"context": aoi.context,
|
|
"split": aoi.split,
|
|
"sample_role": aoi.sample_role,
|
|
"require_empty": aoi.require_empty,
|
|
"bbox_epsg4326": [bbox["min_x"], bbox["min_y"], bbox["max_x"], bbox["max_y"]],
|
|
"raster_dataset_id": image_job["output_dataset_id"],
|
|
"reference_dataset_id": reference_job["output_dataset_id"],
|
|
"provider_reference_feature_count": feature_count,
|
|
}
|
|
)
|
|
checkpoint = {
|
|
"schema_version": 1,
|
|
"status": "in_progress",
|
|
"side_m": args.side_m,
|
|
"resolution_m": args.resolution_m,
|
|
"samples": samples,
|
|
}
|
|
args.output_spec.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output_spec.write_text(json.dumps(checkpoint, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(f"{aoi.slug}: {feature_count} reference buildings", flush=True)
|
|
payload = {
|
|
"schema_version": 1,
|
|
"side_m": args.side_m,
|
|
"resolution_m": args.resolution_m,
|
|
"status": "complete",
|
|
"samples": samples,
|
|
}
|
|
args.output_spec.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output_spec.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
print(json.dumps({"status": "ok", "sample_count": len(samples), "output_spec": str(args.output_spec)}))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|