diff --git a/backend/tests/test_sam_roof_label_refinement.py b/backend/tests/test_sam_roof_label_refinement.py index 7f7c9abd..ef981907 100644 --- a/backend/tests/test_sam_roof_label_refinement.py +++ b/backend/tests/test_sam_roof_label_refinement.py @@ -13,9 +13,19 @@ SPEC.loader.exec_module(MODULE) def test_plausible_refinement_is_fail_closed() -> None: source = (10.0, 10.0, 30.0, 30.0) - assert MODULE.plausible_refinement(source, (8.0, 9.0, 31.0, 32.0), min_iou=0.15, min_area_ratio=0.25, max_area_ratio=4.0) - assert not MODULE.plausible_refinement(source, (100.0, 100.0, 120.0, 120.0), min_iou=0.15, min_area_ratio=0.25, max_area_ratio=4.0) - assert not MODULE.plausible_refinement(source, (0.0, 0.0, 100.0, 100.0), min_iou=0.15, min_area_ratio=0.25, max_area_ratio=4.0) + limits = dict( + min_iou=0.15, + min_area_ratio=0.25, + max_area_ratio=4.0, + max_center_shift_ratio=0.75, + min_dimension_ratio=0.5, + max_dimension_ratio=2.0, + ) + assert MODULE.plausible_refinement(source, (8.0, 9.0, 31.0, 32.0), **limits) + assert not MODULE.plausible_refinement(source, (100.0, 100.0, 120.0, 120.0), **limits) + assert not MODULE.plausible_refinement(source, (0.0, 0.0, 100.0, 100.0), **limits) + assert not MODULE.plausible_refinement(source, (10.0, 10.0, 51.0, 20.0), **limits) + assert not MODULE.plausible_refinement(source, (24.0, 24.0, 44.0, 44.0), **limits) def test_yolo_round_trip_shape() -> None: diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 10689b0c..378bf923 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -3746,6 +3746,34 @@ Validation: - `cd backend && python -m alembic heads && python -m alembic upgrade head --sql` # Codex Execution Log +## 2026-07-30 - Fail-closed SAM roof-refinement gate hardening + +### What changed + +- Reproduced the V64 Flemish calibration-candidate export for Aarschot, Beveren and Oostkamp from the immutable temporal-cutoff manifest. +- Ran the checksummed `/app/sam2_t.pt` model on the server RTX 4080 SUPER and rendered deterministic before/after label contact sheets. +- Rejected both the legacy refinement settings and a stricter trial because dense or temporally absent footprint prompts still produced visually invalid roof boxes; neither result was promoted into a training corpus. +- Hardened `scripts/refine_yolo_labels_with_sam.py` with configurable centre-shift and per-dimension ratio gates. The selected values and model checksum are persisted in `sam-refinement.json`. + +### What was tested + +- `python -m pytest backend/tests/test_sam_roof_label_refinement.py -q` passed (`2 passed`). +- `python -m py_compile scripts/refine_yolo_labels_with_sam.py` passed. +- CUDA refinement completed on 706 candidate labels; the strictest trial refined 659 and retained 47 explicit fallbacks. Visual QA still failed, so the run remains evidence only. + +### What remains open + +- Replace or align the rejected Flemish calibration labels with image-visible, independently validated roof boxes. +- Rebuild and retrain only after that label gate passes; protected test and pure-background evaluation remain closed. + +### Known limitations + +- Geometry plausibility alone cannot prove that a SAM mask represents the intended roof in dense scenes. + +### Next recommended pass + +- Add an auditable image/footprint alignment stage or select lower-parallax independent Flemish AOIs, then repeat visual QA before corpus composition. + ## Post-V1 national coverage completion: Wallonia (2026-07-22) - Located and live-validated the stable official WALOUS 2018 GeoTIFF diff --git a/docs/TODO.md b/docs/TODO.md index 5b19b089..27663f90 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -8,6 +8,8 @@ Uitvoeringsbord: `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md`. - [x] Generaliseer de single-class tile-export op klasse, referentiebron en referentielaag. - [x] Laat productietraining fail-closed stoppen wanneer vereiste CUDA ontbreekt. - [ ] Materialiseer geografisch gescheiden GRB/PICC/URBIS-gebouwcorpora met temporeel passende orthofoto's. + - [x] Verwerp de drie V64 Vlaamse kalibratiekandidaten fail-closed na gereproduceerde ruwe en SAM2-contact-sheets; verscherp de geometrische SAM2-gates. + - [ ] Lever onafhankelijke Vlaamse kalibratie-AOI's met aantoonbaar beeldzichtbare daklabels vóór een volgende training. - [ ] Train en evalueer een Belgische gebouwchallenger onafhankelijk; promoveer alleen zonder achtergrondregressie. - [ ] Houd zonnepanelen en segmentatie `not_configured` tot gereviewde taaklabels en hold-outs bestaan. diff --git a/scripts/refine_yolo_labels_with_sam.py b/scripts/refine_yolo_labels_with_sam.py index 2d155192..6998456e 100644 --- a/scripts/refine_yolo_labels_with_sam.py +++ b/scripts/refine_yolo_labels_with_sam.py @@ -7,6 +7,7 @@ import argparse import gc import hashlib import json +import math import os import shutil from pathlib import Path @@ -38,11 +39,29 @@ def plausible_refinement( min_iou: float, min_area_ratio: float, max_area_ratio: float, + max_center_shift_ratio: float, + min_dimension_ratio: float, + max_dimension_ratio: float, ) -> bool: - source_area = (source[2] - source[0]) * (source[3] - source[1]) - refined_area = (refined[2] - refined[0]) * (refined[3] - refined[1]) + source_width, source_height = source[2] - source[0], source[3] - source[1] + refined_width, refined_height = refined[2] - refined[0], refined[3] - refined[1] + source_area = source_width * source_height + refined_area = refined_width * refined_height ratio = refined_area / source_area if source_area > 0 else 0.0 - return min_area_ratio <= ratio <= max_area_ratio and iou(source, refined) >= min_iou + if source_width <= 0 or source_height <= 0: + return False + width_ratio = refined_width / source_width + height_ratio = refined_height / source_height + source_centre = ((source[0] + source[2]) / 2, (source[1] + source[3]) / 2) + refined_centre = ((refined[0] + refined[2]) / 2, (refined[1] + refined[3]) / 2) + centre_shift_ratio = math.dist(source_centre, refined_centre) / math.hypot(source_width, source_height) + return ( + min_area_ratio <= ratio <= max_area_ratio + and min_dimension_ratio <= width_ratio <= max_dimension_ratio + and min_dimension_ratio <= height_ratio <= max_dimension_ratio + and centre_shift_ratio <= max_center_shift_ratio + and iou(source, refined) >= min_iou + ) def read_boxes(path: Path, width: int, height: int) -> list[tuple[float, float, float, float]]: @@ -85,6 +104,9 @@ def main() -> int: parser.add_argument("--min-source-iou", type=float, default=0.15) parser.add_argument("--min-area-ratio", type=float, default=0.25) parser.add_argument("--max-area-ratio", type=float, default=4.0) + parser.add_argument("--max-center-shift-ratio", type=float, default=0.75) + parser.add_argument("--min-dimension-ratio", type=float, default=0.5) + parser.add_argument("--max-dimension-ratio", type=float, default=2.0) parser.add_argument("--max-prompts-per-pass", type=int, default=96) parser.add_argument("--force", action="store_true") args = parser.parse_args() @@ -139,6 +161,9 @@ def main() -> int: min_iou=args.min_source_iou, min_area_ratio=args.min_area_ratio, max_area_ratio=args.max_area_ratio, + max_center_shift_ratio=args.max_center_shift_ratio, + min_dimension_ratio=args.min_dimension_ratio, + max_dimension_ratio=args.max_dimension_ratio, ): output_boxes[start + local_index] = candidate unmatched.remove(candidate_index) @@ -184,6 +209,9 @@ def main() -> int: "min_source_iou": args.min_source_iou, "min_area_ratio": args.min_area_ratio, "max_area_ratio": args.max_area_ratio, + "max_center_shift_ratio": args.max_center_shift_ratio, + "min_dimension_ratio": args.min_dimension_ratio, + "max_dimension_ratio": args.max_dimension_ratio, "max_prompts_per_pass": args.max_prompts_per_pass, "refined_label_count": refined_count, "fallback_label_count": fallback_count,