Harden SAM roof refinement geometry gates
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-30 04:00:24 +02:00
parent 5ac3238c27
commit 29378bba42
4 changed files with 74 additions and 6 deletions
@@ -13,9 +13,19 @@ SPEC.loader.exec_module(MODULE)
def test_plausible_refinement_is_fail_closed() -> None: def test_plausible_refinement_is_fail_closed() -> None:
source = (10.0, 10.0, 30.0, 30.0) 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) limits = dict(
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) min_iou=0.15,
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) 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: def test_yolo_round_trip_shape() -> None:
+28
View File
@@ -3746,6 +3746,34 @@ Validation:
- `cd backend && python -m alembic heads && python -m alembic upgrade head --sql` - `cd backend && python -m alembic heads && python -m alembic upgrade head --sql`
# Codex Execution Log # 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) ## Post-V1 national coverage completion: Wallonia (2026-07-22)
- Located and live-validated the stable official WALOUS 2018 GeoTIFF - Located and live-validated the stable official WALOUS 2018 GeoTIFF
+2
View File
@@ -8,6 +8,8 @@ Uitvoeringsbord: `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md`.
- [x] Generaliseer de single-class tile-export op klasse, referentiebron en referentielaag. - [x] Generaliseer de single-class tile-export op klasse, referentiebron en referentielaag.
- [x] Laat productietraining fail-closed stoppen wanneer vereiste CUDA ontbreekt. - [x] Laat productietraining fail-closed stoppen wanneer vereiste CUDA ontbreekt.
- [ ] Materialiseer geografisch gescheiden GRB/PICC/URBIS-gebouwcorpora met temporeel passende orthofoto's. - [ ] 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. - [ ] Train en evalueer een Belgische gebouwchallenger onafhankelijk; promoveer alleen zonder achtergrondregressie.
- [ ] Houd zonnepanelen en segmentatie `not_configured` tot gereviewde taaklabels en hold-outs bestaan. - [ ] Houd zonnepanelen en segmentatie `not_configured` tot gereviewde taaklabels en hold-outs bestaan.
+31 -3
View File
@@ -7,6 +7,7 @@ import argparse
import gc import gc
import hashlib import hashlib
import json import json
import math
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -38,11 +39,29 @@ def plausible_refinement(
min_iou: float, min_iou: float,
min_area_ratio: float, min_area_ratio: float,
max_area_ratio: float, max_area_ratio: float,
max_center_shift_ratio: float,
min_dimension_ratio: float,
max_dimension_ratio: float,
) -> bool: ) -> bool:
source_area = (source[2] - source[0]) * (source[3] - source[1]) source_width, source_height = source[2] - source[0], source[3] - source[1]
refined_area = (refined[2] - refined[0]) * (refined[3] - refined[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 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]]: 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-source-iou", type=float, default=0.15)
parser.add_argument("--min-area-ratio", type=float, default=0.25) 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-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("--max-prompts-per-pass", type=int, default=96)
parser.add_argument("--force", action="store_true") parser.add_argument("--force", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -139,6 +161,9 @@ def main() -> int:
min_iou=args.min_source_iou, min_iou=args.min_source_iou,
min_area_ratio=args.min_area_ratio, min_area_ratio=args.min_area_ratio,
max_area_ratio=args.max_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 output_boxes[start + local_index] = candidate
unmatched.remove(candidate_index) unmatched.remove(candidate_index)
@@ -184,6 +209,9 @@ def main() -> int:
"min_source_iou": args.min_source_iou, "min_source_iou": args.min_source_iou,
"min_area_ratio": args.min_area_ratio, "min_area_ratio": args.min_area_ratio,
"max_area_ratio": args.max_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, "max_prompts_per_pass": args.max_prompts_per_pass,
"refined_label_count": refined_count, "refined_label_count": refined_count,
"fallback_label_count": fallback_count, "fallback_label_count": fallback_count,