#!/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"), # v11 full-AOI review: independent train-only coverage for the Flemish # image/roof domain that remained the limiting release region. These # centers are separated from the frozen calibration and test AOIs. Aoi("geel-urban-train-v12", "flanders", "mixed-urban", "train", 4.989, 51.165), Aoi("mol-urban-train-v12", "flanders", "ribbon-development", "train", 5.116, 51.190), Aoi("beringen-train-v12", "flanders", "suburban", "train", 5.226, 51.050), Aoi("bilzen-train-v12", "flanders", "small-city", "train", 5.519, 50.871), Aoi("bree-train-v12", "flanders", "small-city", "train", 5.597, 51.141), Aoi("maaseik-train-v12", "flanders", "historic-urban", "train", 5.790, 51.095), Aoi("peer-train-v12", "flanders", "ribbon-development", "train", 5.459, 51.130), Aoi("diksmuide-train-v12", "flanders", "small-city", "train", 2.865, 51.034), Aoi("ieper-train-v12", "flanders", "historic-urban", "train", 2.885, 50.851), Aoi("poperinge-train-v12", "flanders", "rural-town", "train", 2.726, 50.855), Aoi("eeklo-train-v12", "flanders", "mixed-urban", "train", 3.570, 51.185), Aoi("oudenaarde-train-v12", "flanders", "historic-urban", "train", 3.600, 50.845), Aoi("geraardsbergen-train-v12", "flanders", "hilly-urban", "train", 3.882, 50.771), Aoi("ninove-train-v12", "flanders", "mixed-urban", "train", 4.025, 50.835), Aoi("zottegem-train-v12", "flanders", "ribbon-development", "train", 3.815, 50.870), Aoi("ronse-train-v12", "flanders", "regional-architecture", "train", 3.600, 50.745), Aoi("halle-train-v12", "flanders", "dense-urban", "train", 4.235, 50.735), Aoi("vilvoorde-train-v12", "flanders", "urban-industrial", "train", 4.430, 50.930), Aoi("boom-train-v12", "flanders", "dense-urban", "train", 4.370, 51.090), Aoi("heist-op-den-berg-train-v12", "flanders", "ribbon-development", "train", 4.715, 51.075), Aoi("aalter-train-v12", "flanders", "suburban", "train", 3.445, 51.090), Aoi("wetteren-train-v12", "flanders", "mixed-urban", "train", 3.885, 51.005), Aoi("sint-truiden-train-v12", "flanders", "historic-urban", "train", 5.190, 50.815), Aoi("brasschaat-heath-train-v12-bg", "flanders", "heath-hard-negative", "train", 4.525, 51.350, "background_candidate"), Aoi("limburg-pine-train-v12-bg", "flanders", "forest-hard-negative", "train", 5.680, 51.020, "background_candidate"), Aoi("westhoek-field-train-v12-bg", "flanders", "farmland-hard-negative", "train", 2.720, 50.990, "background_candidate"), # v31 failure-driven corpus expansion: independent examples matching the # protected Genk/Lokeren/Ostend calibration failure contexts. Aoi("tessenderlo-industry-train-v31", "flanders", "industrial", "train", 5.087, 51.065), Aoi("maasmechelen-industry-train-v31", "flanders", "industrial", "train", 5.691, 50.963), Aoi("izegem-industry-train-v31", "flanders", "industrial", "train", 3.209, 50.920), Aoi("zele-ribbon-train-v31", "flanders", "ribbon-development", "train", 4.040, 51.066), Aoi("putte-ribbon-train-v31", "flanders", "ribbon-development", "train", 4.632, 51.053), Aoi("overpelt-ribbon-train-v31", "flanders", "ribbon-development", "train", 5.424, 51.211), Aoi("nieuwpoort-coastal-train-v31", "flanders", "coastal-urban", "train", 2.749, 51.130), Aoi("knokke-coastal-train-v31", "flanders", "coastal-urban", "train", 3.289, 51.340), Aoi("bredene-coastal-train-v31", "flanders", "coastal-urban", "train", 2.976, 51.235), # v42 stagnation correction: additional independent coastal positives and # semantically adjacent negatives. These do not replace or relax the # protected Oostende calibration sample. Aoi("de-haan-coastal-train-v42", "flanders", "coastal-urban", "train", 3.045, 51.270), Aoi("koksijde-coastal-train-v42", "flanders", "coastal-urban", "train", 2.650, 51.115), Aoi("blankenberge-coastal-train-v42", "flanders", "coastal-urban", "train", 3.130, 51.313), Aoi("zelzate-industry-train-v42", "flanders", "industrial", "train", 3.807, 51.198), Aoi("veurne-ribbon-train-v42", "flanders", "ribbon-development", "train", 2.666, 51.073), Aoi("zwin-dunes-train-v42-bg", "flanders", "dunes-negative", "train", 3.360, 51.350, "background_candidate"), Aoi("nieuwpoort-port-train-v42-bg", "flanders", "port-hard-negative", "train", 2.735, 51.146, "background_candidate"), # v44 corpus correction after six closed-loop assessments isolated the # remaining blocker to Genk/Lokeren/Ostend-like Flemish roof domains. # These centers are independent from the protected calibration footprints # and expand real evidence rather than repeating the same capped tiles. Aoi("middelkerke-coastal-train-v44", "flanders", "coastal-urban", "train", 2.820, 51.185), Aoi("heist-coastal-train-v44", "flanders", "coastal-urban", "train", 3.245, 51.338), Aoi("westende-coastal-train-v44", "flanders", "coastal-urban", "train", 2.768, 51.170), Aoi("dessel-industry-train-v44", "flanders", "industrial", "train", 5.112, 51.238), Aoi("olen-industry-train-v44", "flanders", "industrial", "train", 4.858, 51.143), Aoi("evergem-industry-train-v44", "flanders", "industrial", "train", 3.710, 51.113), Aoi("waasmunster-ribbon-train-v44", "flanders", "ribbon-development", "train", 4.085, 51.110), Aoi("wingene-ribbon-train-v44", "flanders", "ribbon-development", "train", 3.274, 51.058), Aoi("kasterlee-ribbon-train-v44", "flanders", "ribbon-development", "train", 4.966, 51.241), Aoi("de-panne-dunes-train-v44-bg", "flanders", "dunes-negative", "train", 2.590, 51.080, "background_candidate"), Aoi("zeebrugge-dock-train-v44-bg", "flanders", "port-hard-negative", "train", 3.190, 51.345, "background_candidate"), Aoi("diksmuide-field-train-v44-bg", "flanders", "farmland-hard-negative", "train", 2.830, 51.020, "background_candidate"), # v56 visual-error expansion: the closed V51 calibration review showed # false detections on parked cars, tree crowns, asphalt and roof shadows. # These independent train-only AOIs target those exact visual modes while # retaining any real GRB buildings as positives. Aoi("ostend-park-road-train-v56-bg", "flanders", "park-road-hard-negative", "train", 2.890, 51.210, "background_candidate"), Aoi("bruges-parking-train-v56-bg", "flanders", "parking-hard-negative", "train", 3.180, 51.198, "background_candidate"), Aoi("genk-tree-industry-train-v56", "flanders", "tree-industrial", "train", 5.470, 50.982), Aoi("lokeren-tree-ribbon-train-v56", "flanders", "tree-ribbon-development", "train", 4.020, 51.115), Aoi("hasselt-parking-train-v56-bg", "flanders", "parking-hard-negative", "train", 5.375, 50.946, "background_candidate"), Aoi("oostduinkerke-park-train-v56-bg", "flanders", "coastal-park-hard-negative", "train", 2.690, 51.125, "background_candidate"), Aoi("turnhout-tree-industry-train-v56", "flanders", "tree-industrial", "train", 4.900, 51.300), Aoi("sint-niklaas-parking-train-v56-bg", "flanders", "parking-hard-negative", "train", 4.105, 51.180, "background_candidate"), Aoi("deinze-tree-ribbon-train-v56", "flanders", "tree-ribbon-development", "train", 3.565, 50.995), Aoi("roeselare-parking-industry-v56-bg", "flanders", "parking-industrial-hard-negative", "train", 3.155, 50.960, "background_candidate"), Aoi("lommel-tree-road-train-v56-bg", "flanders", "park-road-hard-negative", "train", 5.345, 51.215, "background_candidate"), Aoi("nieuwpoort-marina-parking-v56-bg", "flanders", "coastal-parking-hard-negative", "train", 2.770, 51.142, "background_candidate"), # v63 calibration remediation candidates. The former Genk/Lokeren/Oostende # calibration labels failed visual roof-alignment review. These start as # train-role candidates so the immutable holdout rotator can assign only # visually accepted, spatially independent samples to calibration. Aoi("aarschot-mixed-cal-candidate-v63", "flanders", "mixed-urban", "train", 4.837, 50.985), Aoi("beveren-ribbon-cal-candidate-v63", "flanders", "ribbon-development", "train", 4.256, 51.212), Aoi("oostkamp-suburban-cal-candidate-v63", "flanders", "suburban", "train", 3.226, 51.154), # v66 replacement candidates after V63/V65 visual QA rejected dense, # parallax-heavy labels. These spatially independent low-rise contexts # intentionally reduce roof displacement and instance ambiguity. Aoi("zutendaal-lowrise-cal-candidate-v66", "flanders", "detached-lowrise", "train", 5.573, 50.933), Aoi("zoersel-lowrise-cal-candidate-v66", "flanders", "detached-lowrise", "train", 4.712, 51.268), Aoi("landen-rural-cal-candidate-v66", "flanders", "rural-lowrise", "train", 5.079, 50.746), 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("spa-train-v12", "wallonia", "regional-architecture", "train", 5.867, 50.492), Aoi("malmedy-train-v12", "wallonia", "small-city", "train", 6.027, 50.426), Aoi("rochefort-train-v12", "wallonia", "rural-town", "train", 5.222, 50.159), Aoi("beauraing-train-v12", "wallonia", "rural-town", "train", 4.956, 50.110), Aoi("thuin-train-v12", "wallonia", "valley-urban", "train", 4.286, 50.339), Aoi("peruwelz-train-v12", "wallonia", "mixed-urban", "train", 3.591, 50.509), Aoi("ardenne-clearing-train-v12-bg", "wallonia", "forest-hard-negative", "train", 5.460, 50.080, "background_candidate"), Aoi("condroz-open-train-v12-bg", "wallonia", "farmland-hard-negative", "train", 5.020, 50.390, "background_candidate"), # v31 precision correction: independent positives plus difficult negatives # for the weak Walloon dense/rural/regional-architecture contexts. Aoi("herstal-dense-train-v31", "wallonia", "dense-urban", "train", 5.630, 50.665), Aoi("dison-dense-train-v31", "wallonia", "dense-urban", "train", 5.854, 50.612), Aoi("gembloux-rural-train-v31", "wallonia", "rural-town", "train", 4.692, 50.561), Aoi("soignies-rural-train-v31", "wallonia", "rural-town", "train", 4.071, 50.579), Aoi("stavelot-architecture-train-v31", "wallonia", "regional-architecture", "train", 5.931, 50.395), Aoi("aubel-architecture-train-v31", "wallonia", "regional-architecture", "train", 5.859, 50.704), # v42 precision correction: new Walloon examples for the persistent rural, # regional-architecture and difficult-negative calibration errors. Aoi("theux-architecture-train-v42", "wallonia", "regional-architecture", "train", 5.812, 50.535), Aoi("florenville-rural-train-v42", "wallonia", "rural-town", "train", 5.307, 49.700), Aoi("jodoigne-rural-train-v42", "wallonia", "rural-town", "train", 4.870, 50.724), Aoi("engis-industry-train-v42", "wallonia", "industrial", "train", 5.405, 50.583), Aoi("lessines-quarry-train-v42-bg", "wallonia", "quarry-hard-negative", "train", 3.835, 50.710, "background_candidate"), Aoi("saint-hubert-forest-train-v42-bg", "wallonia", "forest-hard-negative", "train", 5.365, 50.025, "background_candidate"), Aoi("hesbaye-field-train-v42-bg", "wallonia", "farmland-hard-negative", "train", 5.080, 50.690, "background_candidate"), Aoi("liege-brownfield-train-v31-bg", "wallonia", "industrial-hard-negative", "train", 5.607, 50.624, "background_candidate"), Aoi("charleroi-rail-train-v31-bg", "wallonia", "rail-hard-negative", "train", 4.467, 50.421, "background_candidate"), Aoi("condroz-quarry-train-v31-bg", "wallonia", "quarry-hard-negative", "train", 5.190, 50.365, "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") parser.add_argument( "--only-slug", action="append", default=[], help="Provision only the named AOI slug; repeat to select multiple AOIs.", ) parser.add_argument( "--session-token-file", type=Path, help="Read an operator session token from a local mode-0600 file instead of exposing it on the command line.", ) args = parser.parse_args() known_slugs = {aoi.slug for aoi in AOIS} unknown_slugs = sorted(set(args.only_slug) - known_slugs) if unknown_slugs: raise SystemExit(f"Unknown --only-slug value(s): {', '.join(unknown_slugs)}") selected_aois = tuple(aoi for aoi in AOIS if not args.only_slug or aoi.slug in args.only_slug) session = requests.Session() if args.session_token_file: token = args.session_token_file.read_text(encoding="utf-8").strip() if not token: raise SystemExit("--session-token-file is empty") session.cookies.set("geointel_session", token) 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 selected_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())