Page GRB references for operator samples
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-07-09 15:02:12 +02:00
parent 77c45937c6
commit a63d4eaadc
8 changed files with 254 additions and 12 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog # Changelog
## Sprint 152 GRB reference paging for operator samples (2026-07-09)
- Fixed the operator real-data sample preparer so GRB GBG reference GeoJSON is fetched through OGC API `rel=next` pagination links instead of stopping at the first `limit=1000` page.
- Added `--reference-page-limit` / `OPERATOR_GRB_PAGE_LIMIT` and `--reference-max-features` / `OPERATOR_GRB_MAX_FEATURES` safeguards for dense reference AOIs.
- Generated reference GeoJSON now records fetched page URLs, page count, truncation state and paging limits for auditability.
- Added regression coverage for paged GRB responses and the new CLI help options.
- No application provider endpoint, migration, API contract, live GRB product import, model download or active YOLO model changed.
## Sprint 151 runtime GIS upload and AOI1024 YOLO candidate (2026-07-09) ## Sprint 151 runtime GIS upload and AOI1024 YOLO candidate (2026-07-09)
- Fixed the operator YOLO training wrapper so the all-in-one runtime defaults to `/opt/geointel/venv/bin/python` when present, while still falling back to `python3` for local shells. - Fixed the operator YOLO training wrapper so the all-in-one runtime defaults to `/opt/geointel/venv/bin/python` when present, while still falling back to `python3` for local shells.
@@ -46,6 +46,8 @@ def test_prepare_operator_real_data_samples_help_does_not_require_gis_dependenci
assert "--width" in result.stdout assert "--width" in result.stdout
assert "--height" in result.stdout assert "--height" in result.stdout
assert "--half-size-scale" in result.stdout assert "--half-size-scale" in result.stdout
assert "--reference-page-limit" in result.stdout
assert "--reference-max-features" in result.stdout
def test_multi_sample_detection_quality_matrix_runs_existing_matrix_for_each_sample() -> None: def test_multi_sample_detection_quality_matrix_runs_existing_matrix_for_each_sample() -> None:
@@ -123,6 +123,81 @@ def test_background_candidate_can_write_empty_reference_geojson(tmp_path: Path,
assert '"sample_role": "background_candidate"' in payload assert '"sample_role": "background_candidate"' in payload
def test_fetch_reference_follows_grb_next_links_until_complete(tmp_path: Path, monkeypatch) -> None:
module = load_sample_preparer()
requested: list[tuple[str, dict | None]] = []
def feature(feature_id: str) -> dict:
return {
"type": "Feature",
"id": feature_id,
"geometry": {"type": "Polygon", "coordinates": []},
"properties": {},
}
class FeatureResponse:
def __init__(self, payload: dict) -> None:
self.payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return self.payload
class FakeRequests:
Request = module.requests.Request if module.requests else object
@staticmethod
def get(url, params=None, timeout=120):
requested.append((url, params))
if len(requested) == 1:
return FeatureResponse(
{
"type": "FeatureCollection",
"features": [feature("GBG.1")],
"numberReturned": 1,
"links": [
{
"rel": "next",
"type": "application/geo+json",
"href": "https://example.test/grb?page=2",
}
],
}
)
return FeatureResponse(
{
"type": "FeatureCollection",
"features": [feature("GBG.2")],
"numberReturned": 1,
"links": [],
}
)
monkeypatch.setattr(module, "requests", FakeRequests)
monkeypatch.setattr(module, "prepared_url", lambda url, params: f"{url}?prepared=true")
sample = module.OperatorSample(
slug="urban",
display_name="Urban",
center_lon=5.0,
center_lat=51.0,
)
source_url, feature_count = module.fetch_reference(sample, tmp_path / "urban.geojson", [4.9, 50.9, 5.1, 51.1])
payload = (tmp_path / "urban.geojson").read_text(encoding="utf-8")
assert source_url.endswith("?prepared=true")
assert feature_count == 2
assert requested == [
(module.GRB_GBG_URL, {"f": "application/geo+json", "limit": "1000", "bbox": "4.90000000,50.90000000,5.10000000,51.10000000"}),
("https://example.test/grb?page=2", None),
]
assert '"id": "GBG.1"' in payload
assert '"id": "GBG.2"' in payload
def test_reference_sample_still_rejects_empty_grb_response(tmp_path: Path, monkeypatch) -> None: def test_reference_sample_still_rejects_empty_grb_response(tmp_path: Path, monkeypatch) -> None:
module = load_sample_preparer() module = load_sample_preparer()
+9 -4
View File
@@ -196,10 +196,15 @@ The helper fetches explicit Digitaal Vlaanderen orthophoto/GRB GBG sample pairs
for the documented AOIs only and writes `operator_samples_manifest.json`. The for the documented AOIs only and writes `operator_samples_manifest.json`. The
default corpus includes dense reference AOIs for Geel, Mol, Turnhout, Herentals, default corpus includes dense reference AOIs for Geel, Mol, Turnhout, Herentals,
Balen, Retie and Westerlo plus explicitly marked background candidates for Balen, Retie and Westerlo plus explicitly marked background candidates for
Postel-bos, Lommel-heide and Kasterlee-bos. Background candidates may persist Postel-bos, Lommel-heide, Kasterlee-bos, Dessel-heide, Ravels-bos,
empty GRB FeatureCollections for negative-tile training; normal reference AOIs Meerhout-bos, Geel-Bel, Arendonk-heide and Herenthout-bos. Background
still fail on empty GRB responses. The application itself still does not perform candidates may persist empty GRB FeatureCollections for negative-tile training;
live provider fetching. normal reference AOIs still fail on empty GRB responses. Dense GRB references
are fetched through OGC API `rel=next` pagination links instead of trusting only
the first 1000-feature page. Generated reference GeoJSON records
`reference_pages_fetched`, `reference_truncated`, `reference_page_limit`,
`reference_max_features` and `source_urls` for auditability. The application
itself still does not perform live provider fetching.
For confidence-threshold calibration, use the sweep wrapper: For confidence-threshold calibration, use the sweep wrapper:
+26
View File
@@ -5938,6 +5938,32 @@ Open:
- Use `yolo-building-tile-uniquehardneg160` as the next safer hard-negative training dataset candidate. Benchmark after training before changing defaults. - Use `yolo-building-tile-uniquehardneg160` as the next safer hard-negative training dataset candidate. Benchmark after training before changing defaults.
# Sprint 152 - GRB reference paging for operator samples
## What changed
- Fixed `scripts/prepare_operator_real_data_samples.py` so GRB GBG reference exports follow OGC API `rel=next` pagination links instead of silently trusting only the first `limit=1000` page.
- Added operator controls:
- `--reference-page-limit` / `OPERATOR_GRB_PAGE_LIMIT`, default `1000`.
- `--reference-max-features` / `OPERATOR_GRB_MAX_FEATURES`, default `100000`.
- Generated reference GeoJSON now records `source_urls`, `reference_pages_fetched`, `reference_truncated`, `reference_page_limit` and `reference_max_features`.
- Kept the change operator-only: no GeoIntel API route calls this helper, no product provider endpoint changed, no live GRB/OSM import was added, no migration changed and no YOLO model was activated.
## What was tested
- RED: `python -m pytest backend\tests\test_sprint131_operator_sample_expansion.py::test_fetch_reference_follows_grb_next_links_until_complete -q` failed because only the first GRB page was persisted.
- GREEN: `python -m pytest backend\tests\test_sprint131_operator_sample_expansion.py::test_fetch_reference_follows_grb_next_links_until_complete -q`
- `python -m pytest backend\tests\test_sprint127_operator_sample_quality_matrix.py backend\tests\test_sprint131_operator_sample_expansion.py -q`
## Known limitations
- Existing Tower AOI1024 artifacts were generated before this fix. Regenerate the operator samples with `--force` before retraining or re-auditing dense AOI labels.
- This does not make the Sprint 7B GRB provider a live product importer; it only fixes explicit operator sample preparation.
## Next recommended pass
- Regenerate `/app/storage/operator-data/operator-samples-1024` on Tower with the paged script, re-export the AOI1024 YOLO tile dataset, rerun the dataset audit and only then consider another inactive training candidate.
# Sprint 151 - Runtime GIS upload and AOI1024 YOLO candidate # Sprint 151 - Runtime GIS upload and AOI1024 YOLO candidate
## What changed ## What changed
+2 -1
View File
@@ -472,5 +472,6 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Run AOI1024 background/hard-negative matrix for `geointel-building-yolov8s-aoi1024visible025e50-pt`. - [x] Run AOI1024 background/hard-negative matrix for `geointel-building-yolov8s-aoi1024visible025e50-pt`.
- [x] Fix promotion-report parsing for `multi_sample_quality_summary.json` inputs. - [x] Fix promotion-report parsing for `multi_sample_quality_summary.json` inputs.
- [x] Generate AOI1024 promotion report and keep recommended candidate as `none`. - [x] Generate AOI1024 promotion report and keep recommended candidate as `none`.
- [ ] Add GRB paging or smaller dense AOI sampling before trusting 1000-feature-capped dense reference exports as full ground truth. - [x] Add GRB paging before trusting dense reference exports as full ground truth.
- [ ] Regenerate Tower AOI1024 operator samples with paged GRB references, then re-export and audit labels before any new training attempt.
- [ ] Keep every local YOLO candidate inactive until positive-AOI and hard-negative promotion reports recommend default activation. - [ ] Keep every local YOLO candidate inactive until positive-AOI and hard-negative promotion reports recommend default activation.
+11
View File
@@ -190,6 +190,15 @@ returns no buildings; background candidates are explicitly marked with
negative-tile training. The helper fetches only the explicit documented AOIs, negative-tile training. The helper fetches only the explicit documented AOIs,
records Digitaal Vlaanderen attribution and reuses existing files by default. records Digitaal Vlaanderen attribution and reuses existing files by default.
Use `--force` only when the local runtime artifacts should be regenerated. Use `--force` only when the local runtime artifacts should be regenerated.
GRB building references are fetched through the provider's OGC API
`rel=next` pagination links, so dense AOIs are not silently limited to the
first 1000 features. The default page size is `1000`; override it with
`--reference-page-limit` or `OPERATOR_GRB_PAGE_LIMIT`. The safety cap defaults
to `100000` features per sample and can be adjusted with
`--reference-max-features` or `OPERATOR_GRB_MAX_FEATURES`. Generated reference
GeoJSON files record `reference_pages_fetched`, `reference_truncated`,
`reference_page_limit`, `reference_max_features` and every fetched
`source_urls` page for auditability.
For model-training candidates, prepare a larger operator-only sample manifest so For model-training candidates, prepare a larger operator-only sample manifest so
tile overlap can create meaningful context instead of one tile per source tile overlap can create meaningful context instead of one tile per source
@@ -202,6 +211,8 @@ docker exec -it geointel python3 /app/scripts/prepare_operator_real_data_samples
--width 1024 \ --width 1024 \
--height 1024 \ --height 1024 \
--half-size-scale 2 \ --half-size-scale 2 \
--reference-page-limit 1000 \
--reference-max-features 100000 \
--force --force
``` ```
+121 -7
View File
@@ -20,6 +20,8 @@ from typing import Any
WMS_URL = "https://geo.api.vlaanderen.be/omwrgbmrvl/wms" WMS_URL = "https://geo.api.vlaanderen.be/omwrgbmrvl/wms"
GRB_GBG_URL = "https://geo.api.vlaanderen.be/GRB/ogc/features/v1/collections/GBG/items" GRB_GBG_URL = "https://geo.api.vlaanderen.be/GRB/ogc/features/v1/collections/GBG/items"
DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data") DEFAULT_OUTPUT_DIR = Path("/app/storage/operator-data")
DEFAULT_GRB_PAGE_LIMIT = 1000
DEFAULT_GRB_MAX_FEATURES = 100000
requests: Any = None requests: Any = None
rasterio: Any = None rasterio: Any = None
Transformer: Any = None Transformer: Any = None
@@ -214,6 +216,18 @@ def parse_args() -> argparse.Namespace:
default=float(os.environ.get("OPERATOR_SAMPLE_HALF_SIZE_SCALE", "1")), default=float(os.environ.get("OPERATOR_SAMPLE_HALF_SIZE_SCALE", "1")),
help="Multiplier applied to each documented AOI half-size in meters.", help="Multiplier applied to each documented AOI half-size in meters.",
) )
parser.add_argument(
"--reference-page-limit",
type=int,
default=int(os.environ.get("OPERATOR_GRB_PAGE_LIMIT", str(DEFAULT_GRB_PAGE_LIMIT))),
help="GRB OGC API Features page size for reference buildings.",
)
parser.add_argument(
"--reference-max-features",
type=int,
default=int(os.environ.get("OPERATOR_GRB_MAX_FEATURES", str(DEFAULT_GRB_MAX_FEATURES))),
help="Safety cap for paged GRB reference features per sample.",
)
return parser.parse_args() return parser.parse_args()
@@ -316,6 +330,45 @@ def sample_artifact_paths(sample: OperatorSample, output_dir: Path) -> tuple[Pat
return ortho_path, reference_path return ortho_path, reference_path
def next_geojson_link(payload: dict[str, Any]) -> str | None:
for link in payload.get("links") or []:
if link.get("rel") == "next" and "geo+json" in str(link.get("type", "")).lower():
href = link.get("href")
if href:
return str(href)
for link in payload.get("links") or []:
if link.get("rel") == "next":
href = link.get("href")
if href:
return str(href)
return None
def merge_reference_page_features(
pages: list[dict[str, Any]],
*,
max_features: int,
) -> tuple[list[dict[str, Any]], bool]:
features: list[dict[str, Any]] = []
seen_feature_keys: set[str] = set()
truncated = False
for page in pages:
for feature in page.get("features") or []:
feature_key = str(feature.get("id") or json.dumps(feature.get("geometry"), sort_keys=True))
if feature_key in seen_feature_keys:
continue
if len(features) >= max_features:
truncated = True
break
seen_feature_keys.add(feature_key)
features.append(feature)
if truncated:
break
return features, truncated
def fetch_orthophoto(sample: OperatorSample, ortho_path: Path, lambert_bbox: tuple[float, float, float, float]) -> str: def fetch_orthophoto(sample: OperatorSample, ortho_path: Path, lambert_bbox: tuple[float, float, float, float]) -> str:
minx, miny, maxx, maxy = lambert_bbox minx, miny, maxx, maxy = lambert_bbox
wms_params = { wms_params = {
@@ -362,27 +415,69 @@ def fetch_orthophoto(sample: OperatorSample, ortho_path: Path, lambert_bbox: tup
return prepared_url(WMS_URL, wms_params) return prepared_url(WMS_URL, wms_params)
def fetch_reference(sample: OperatorSample, reference_path: Path, geo_bbox: list[float]) -> tuple[str, int]: def fetch_reference(
sample: OperatorSample,
reference_path: Path,
geo_bbox: list[float],
*,
page_limit: int = DEFAULT_GRB_PAGE_LIMIT,
max_features: int = DEFAULT_GRB_MAX_FEATURES,
) -> tuple[str, int]:
if page_limit <= 0:
raise SystemExit("--reference-page-limit must be a positive integer")
if max_features <= 0:
raise SystemExit("--reference-max-features must be a positive integer")
ogc_params = { ogc_params = {
"f": "application/geo+json", "f": "application/geo+json",
"limit": "1000", "limit": str(page_limit),
"bbox": ",".join(f"{value:.8f}" for value in geo_bbox), "bbox": ",".join(f"{value:.8f}" for value in geo_bbox),
} }
pages: list[dict[str, Any]] = []
page_urls = [prepared_url(GRB_GBG_URL, ogc_params)]
response = requests.get(GRB_GBG_URL, params=ogc_params, timeout=120) response = requests.get(GRB_GBG_URL, params=ogc_params, timeout=120)
response.raise_for_status()
reference = response.json() seen_next_urls: set[str] = set()
features = reference.get("features") or [] stopped_at_feature_cap = False
while True:
response.raise_for_status()
page = response.json()
pages.append(page)
next_url = next_geojson_link(page)
if not next_url:
break
if next_url in seen_next_urls:
raise SystemExit(f"GRB GBG pagination loop detected for {sample.slug}: {next_url}")
if sum(len(current_page.get("features") or []) for current_page in pages) >= max_features:
stopped_at_feature_cap = True
break
seen_next_urls.add(next_url)
page_urls.append(next_url)
response = requests.get(next_url, params=None, timeout=120)
reference = pages[0] if pages else {"type": "FeatureCollection", "features": []}
features, truncated = merge_reference_page_features(pages, max_features=max_features)
truncated = truncated or stopped_at_feature_cap
if not features and not sample.allow_empty_reference: if not features and not sample.allow_empty_reference:
raise SystemExit(f"GRB GBG returned no building features for {sample.slug} bbox {geo_bbox}") raise SystemExit(f"GRB GBG returned no building features for {sample.slug} bbox {geo_bbox}")
reference["features"] = features
reference["name"] = f"GRB GBG buildings - {sample.display_name} sample AOI" reference["name"] = f"GRB GBG buildings - {sample.display_name} sample AOI"
reference["source"] = "Digitaal Vlaanderen GRB OGC API Features collection GBG" reference["source"] = "Digitaal Vlaanderen GRB OGC API Features collection GBG"
reference["source_url"] = prepared_url(GRB_GBG_URL, ogc_params) reference["source_url"] = prepared_url(GRB_GBG_URL, ogc_params)
reference["source_urls"] = page_urls
reference["attribution"] = "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen" reference["attribution"] = "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen"
reference["bbox"] = geo_bbox reference["bbox"] = geo_bbox
reference["sample_slug"] = sample.slug reference["sample_slug"] = sample.slug
reference["sample_role"] = sample.sample_role reference["sample_role"] = sample.sample_role
reference["allow_empty_reference"] = sample.allow_empty_reference reference["allow_empty_reference"] = sample.allow_empty_reference
reference["reference_page_limit"] = page_limit
reference["reference_max_features"] = max_features
reference["reference_pages_fetched"] = len(pages)
reference["reference_truncated"] = truncated
reference["numberReturned"] = len(features)
if "links" in reference:
reference["links"] = [link for link in reference.get("links") or [] if link.get("rel") != "next"]
for feature in features: for feature in features:
props = feature.setdefault("properties", {}) props = feature.setdefault("properties", {})
props.setdefault("source_name", "grb") props.setdefault("source_name", "grb")
@@ -394,7 +489,14 @@ def fetch_reference(sample: OperatorSample, reference_path: Path, geo_bbox: list
return prepared_url(GRB_GBG_URL, ogc_params), len(features) return prepared_url(GRB_GBG_URL, ogc_params), len(features)
def prepare_sample(sample: OperatorSample, output_dir: Path, force: bool) -> dict[str, Any]: def prepare_sample(
sample: OperatorSample,
output_dir: Path,
force: bool,
*,
reference_page_limit: int = DEFAULT_GRB_PAGE_LIMIT,
reference_max_features: int = DEFAULT_GRB_MAX_FEATURES,
) -> dict[str, Any]:
ortho_path, reference_path = sample_artifact_paths(sample, output_dir) ortho_path, reference_path = sample_artifact_paths(sample, output_dir)
lambert_bbox, geo_bbox = sample_bounds(sample) lambert_bbox, geo_bbox = sample_bounds(sample)
skip_existing = ortho_path.exists() and reference_path.exists() and not force skip_existing = ortho_path.exists() and reference_path.exists() and not force
@@ -402,7 +504,13 @@ def prepare_sample(sample: OperatorSample, output_dir: Path, force: bool) -> dic
source_urls: dict[str, str | None] = {"orthophoto": None, "reference": None} source_urls: dict[str, str | None] = {"orthophoto": None, "reference": None}
if not skip_existing: if not skip_existing:
source_urls["orthophoto"] = fetch_orthophoto(sample, ortho_path, lambert_bbox) source_urls["orthophoto"] = fetch_orthophoto(sample, ortho_path, lambert_bbox)
source_urls["reference"], reference_feature_count = fetch_reference(sample, reference_path, geo_bbox) source_urls["reference"], reference_feature_count = fetch_reference(
sample,
reference_path,
geo_bbox,
page_limit=reference_page_limit,
max_features=reference_max_features,
)
else: else:
reference_feature_count = geojson_feature_count(reference_path) reference_feature_count = geojson_feature_count(reference_path)
@@ -424,6 +532,8 @@ def prepare_sample(sample: OperatorSample, output_dir: Path, force: bool) -> dic
"epsg31370_bbox": list(lambert_bbox), "epsg31370_bbox": list(lambert_bbox),
"skip_existing": skip_existing, "skip_existing": skip_existing,
"source_urls": source_urls, "source_urls": source_urls,
"reference_page_limit": reference_page_limit,
"reference_max_features": reference_max_features,
"attribution": { "attribution": {
"orthophoto": "Bron: Orthofotomozaiek Vlaanderen, Digitaal Vlaanderen", "orthophoto": "Bron: Orthofotomozaiek Vlaanderen, Digitaal Vlaanderen",
"reference": "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen", "reference": "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen",
@@ -470,6 +580,8 @@ def main() -> int:
), ),
output_dir, output_dir,
force=args.force, force=args.force,
reference_page_limit=args.reference_page_limit,
reference_max_features=args.reference_max_features,
) )
for sample in selected_samples(args.samples) for sample in selected_samples(args.samples)
] ]
@@ -482,6 +594,8 @@ def main() -> int:
"sample_width": args.width, "sample_width": args.width,
"sample_height": args.height, "sample_height": args.height,
"half_size_scale": args.half_size_scale, "half_size_scale": args.half_size_scale,
"reference_page_limit": args.reference_page_limit,
"reference_max_features": args.reference_max_features,
"samples": samples, "samples": samples,
} }
manifest_path = output_dir / args.manifest_name manifest_path = output_dir / args.manifest_name