diff --git a/backend/tests/test_operator_polygon_label_qa.py b/backend/tests/test_operator_polygon_label_qa.py new file mode 100644 index 00000000..dc8e33fb --- /dev/null +++ b/backend/tests/test_operator_polygon_label_qa.py @@ -0,0 +1,22 @@ +from scripts.render_operator_polygon_label_qa import geometry_rings + + +def test_geometry_rings_yields_polygon_exterior_and_hole() -> None: + exterior = [[0, 0], [1, 0], [1, 1], [0, 0]] + hole = [[0.2, 0.2], [0.4, 0.2], [0.2, 0.2]] + assert list(geometry_rings({"type": "Polygon", "coordinates": [exterior, hole]})) == [ + exterior, + hole, + ] + + +def test_geometry_rings_flattens_multipolygon_rings() -> None: + first = [[0, 0], [1, 0], [0, 0]] + second = [[2, 2], [3, 2], [2, 2]] + assert list( + geometry_rings({"type": "MultiPolygon", "coordinates": [[first], [second]]}) + ) == [first, second] + + +def test_geometry_rings_ignores_non_polygon_geometry() -> None: + assert list(geometry_rings({"type": "Point", "coordinates": [0, 0]})) == [] diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index e5ce8767..8b20284d 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -12047,3 +12047,28 @@ Open: checkpoint (`iteration-005`, SHA-256 `be8c5a4d27dfcd03e29e59ce78ea66d3c772c3e612c08b71fd457d3006001b71`) and trains against the expanded V44 corpus on the NVIDIA server. +## 2026-07-30 - Direct polygon QA and rejected automatic roof-alignment routes + +- Revalidated the three independent V66 Flemish AOIs against their immutable + EPSG:31370 rasters and CRS84 GRB responses. Raster bounds, OGC `Content-Crs`, + feature coordinates and tile transforms are internally consistent; no CRS or + WMS axis-order defect was found. +- Proved that the existing YOLO contact sheet can make rotated and concave GRB + polygons look substantially worse by showing only their axis-aligned boxes. + Added `scripts/render_operator_polygon_label_qa.py` so governed polygon/raster + alignment is reviewed directly before lossy bounding-box export. +- Rejected streamed automatic SAM2 masks: the broad trial matched 365/413 source + objects but selected trees, roads, parking and open ground; a strict trial kept + only 42/413 and still contained false roofs. +- Rejected local polygon-edge registration: 27/73 Zutendaal, 5/66 Zoersel and + 32/110 Landen objects passed numerical uniqueness gates, but direct overlays + still contained vegetation, road and shadow-edge matches. +- Rejected a swapped Lambert WMS-axis hypothesis and a live `most_recent` image + refresh after isolated visual trials. Neither output entered a training corpus. + +### Gate consequence + +- V66 remains candidate-only and is not promotion or protected-test evidence. +- No new training is authorized from these rejected outputs. The next corpus + revision must use independently image-visible roof annotations or an official + roof-surface product, with direct polygon QA before YOLO box generation. diff --git a/scripts/render_operator_polygon_label_qa.py b/scripts/render_operator_polygon_label_qa.py new file mode 100644 index 00000000..531a3ce7 --- /dev/null +++ b/scripts/render_operator_polygon_label_qa.py @@ -0,0 +1,102 @@ +"""Render governed source polygons directly on their raster before YOLO box export.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render direct polygon/raster alignment evidence from an operator samples manifest." + ) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--line-width", type=int, default=3) + parser.add_argument("--force", action="store_true") + return parser.parse_args() + + +def geometry_rings(geometry: dict[str, Any]): + if geometry.get("type") == "Polygon": + yield from geometry.get("coordinates") or [] + elif geometry.get("type") == "MultiPolygon": + for polygon in geometry.get("coordinates") or []: + yield from polygon + + +def render(manifest_path: Path, output_dir: Path, line_width: int, force: bool) -> dict[str, Any]: + try: + import rasterio + from PIL import Image, ImageDraw + from pyproj import Transformer + except ImportError as exc: # pragma: no cover - runtime dependency guard + raise SystemExit("Polygon QA rendering requires rasterio, Pillow and pyproj") from exc + + if output_dir.exists() and any(output_dir.iterdir()) and not force: + raise SystemExit(f"Output directory is not empty: {output_dir}; use --force") + output_dir.mkdir(parents=True, exist_ok=True) + manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig")) + evidence = [] + for sample in manifest.get("samples") or []: + slug = str(sample["sample_slug"]) + raster_path = Path(sample["raster_path"]) + reference_path = Path(sample["reference_path"]) + reference = json.loads(reference_path.read_text(encoding="utf-8-sig")) + feature_count = 0 + with rasterio.open(raster_path) as raster: + if raster.crs is None: + raise SystemExit(f"Raster CRS is required for {slug}") + bands = raster.read([1, 2, 3]).transpose(1, 2, 0) + image = Image.fromarray(bands) + draw = ImageDraw.Draw(image) + transformer = Transformer.from_crs("EPSG:4326", raster.crs, always_xy=True) + for feature in reference.get("features") or []: + drew_feature = False + for ring in geometry_rings(feature.get("geometry") or {}): + pixels = [] + for coordinate in ring: + if len(coordinate) < 2: + continue + x, y = transformer.transform(float(coordinate[0]), float(coordinate[1])) + row, column = raster.index(x, y) + pixels.append((column, row)) + if len(pixels) >= 3: + draw.line(pixels + [pixels[0]], fill=(255, 220, 0), width=line_width) + drew_feature = True + feature_count += int(drew_feature) + output_path = output_dir / f"{slug}-polygon-overlay.png" + image.save(output_path) + evidence.append( + { + "sample_slug": slug, + "raster_path": str(raster_path), + "reference_path": str(reference_path), + "rendered_feature_count": feature_count, + "output_path": str(output_path), + } + ) + summary = { + "schema_version": 1, + "status": "ok", + "manifest_path": str(manifest_path), + "label_semantics": "direct_governed_polygon_overlay_before_box_export", + "samples": evidence, + } + (output_dir / "operator_polygon_label_qa_summary.json").write_text( + json.dumps(summary, indent=2), encoding="utf-8" + ) + return summary + + +def main() -> int: + args = parse_args() + summary = render(args.manifest, args.output_dir, args.line_width, args.force) + print(json.dumps({"status": summary["status"], "sample_count": len(summary["samples"])}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())