Add fail-closed source class and SAM fallback filters
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:24:35 +02:00
parent 29378bba42
commit 08422ca50c
6 changed files with 74 additions and 4 deletions
@@ -166,6 +166,32 @@ def test_normalizer_rejects_same_year_feature_after_annual_mosaic_start(tmp_path
assert audit["imagery_feature_creation_cutoff"] == "2025-01-01T00:00:00+00:00"
def test_normalizer_applies_provider_native_source_class_allowlist(tmp_path: Path) -> None:
raster_path = tmp_path / "image.tif"
with rasterio.open(
raster_path, "w", driver="GTiff", width=100, height=100, count=3,
dtype="uint8", crs="EPSG:4326", transform=from_origin(4.0, 51.0, 0.001, 0.001),
) as dataset:
dataset.write(np.zeros((3, 100, 100), dtype="uint8"))
def feature(feature_id: str, source_type: int, left: float) -> dict:
return {
"type": "Feature", "id": feature_id, "properties": {"TYPE": source_type},
"geometry": {"type": "Polygon", "coordinates": [[[left, 50.99], [left + .01, 50.99], [left + .01, 50.98], [left, 50.98], [left, 50.99]]]},
}
reference_path = tmp_path / "reference.geojson"
reference_path.write_text(json.dumps({"type": "FeatureCollection", "features": [feature("main", 1, 4.01), feature("annex", 2, 4.03)]}), encoding="utf-8")
normalized, audit = module.normalize(
reference_path=reference_path, raster_path=raster_path, source_name="grb",
min_label_px=3, imagery_observed_at=None, reference_observed_at=None,
allowed_source_classes={"1"},
)
assert [item["id"] for item in normalized["features"]] == ["grb:main"]
assert audit["allowed_source_classes"] == ["1"]
assert audit["decision_counts"] == {"accepted": 1, "source_class_not_allowed": 1}
def test_normalizer_merges_only_touching_visible_roof_instances(tmp_path: Path) -> None:
raster_path = tmp_path / "image.tif"
with rasterio.open(
@@ -31,3 +31,9 @@ def test_plausible_refinement_is_fail_closed() -> None:
def test_yolo_round_trip_shape() -> None:
line = MODULE.yolo_line((10.0, 20.0, 30.0, 40.0), 100, 100)
assert line == "0 0.20000000 0.30000000 0.20000000 0.20000000"
def test_cli_exposes_explicit_fallback_policy() -> None:
source = SCRIPT.read_text(encoding="utf-8")
assert 'choices=("retain", "drop")' in source
assert '"dropped_fallback_label_count"' in source
+5
View File
@@ -3754,12 +3754,17 @@ Validation:
- 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`.
- Added an optional provider-native source-class allowlist to Belgian label normalization. A V65 diagnostic retained only GRB `TYPE=1` main buildings and auditable rejection counts, reducing the three candidate AOIs from 427 to 283 source features without claiming semantic parity with PICC or UrbIS.
- Added explicit SAM fallback policies. `retain` preserves historical behavior; `drop` creates a fail-closed visible-roof-only shard and records every removed fallback.
- Tested global image-edge translation at narrow and broad radii. The broad optima jumped to unrelated repeated urban edges, so this experiment was rejected and was not added to the production corpus.
### 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.
- `python -m pytest backend/tests/test_sam_roof_label_refinement.py backend/tests/test_building_label_normalization.py -q` passed (`10 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.
- The V65 main-building-only/drop-fallback CUDA trial retained 134 of 466 tiled labels and dropped 332 fallbacks. Its contact sheet still contained wrong tree, ground and compound masks, so it also remains rejected evidence only.
### What remains open
+1
View File
@@ -9,6 +9,7 @@ Uitvoeringsbord: `docs/PYTORCH_TRAINING_ROADMAP_BELGIUM.md`.
- [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.
- [x] Scheid GRB-hoofdgebouwen bron-native van bijgebouwen en bewijs dat ook de hoofdgebouw/SAM-dropvariant de visuele gate nog niet haalt.
- [ ] 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.
@@ -148,6 +148,7 @@ def normalize(
reference_observed_at: str | None,
imagery_valid_to: str | None = None,
merge_touching_roofs: bool = False,
allowed_source_classes: set[str] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
if source_name not in SUPPORTED_SOURCES:
raise SystemExit(f"Unsupported governed building source: {source_name}")
@@ -184,6 +185,7 @@ def normalize(
"reason": None,
"geometry_repaired": False,
}
source_class = decision["source_class"]
source_creation_at = _source_creation_at(source_name, properties)
decision["source_creation_at"] = source_creation_at.isoformat() if source_creation_at else None
try:
@@ -199,6 +201,8 @@ def normalize(
geometry = _polygonal(geometry)
if geometry is None or geometry.is_empty or not geometry.is_valid:
decision["reason"] = "invalid_geometry_unrepairable"
elif allowed_source_classes is not None and source_class not in allowed_source_classes:
decision["reason"] = "source_class_not_allowed"
elif (reason := _semantic_exclusion(properties)) is not None:
decision["reason"] = reason
elif imagery_cutoff and source_creation_at and source_creation_at > imagery_cutoff:
@@ -276,6 +280,7 @@ def normalize(
"accepted_source_feature_count": len(accepted),
"accepted_feature_count": len(normalized_features),
"merge_touching_roofs": merge_touching_roofs,
"allowed_source_classes": sorted(allowed_source_classes) if allowed_source_classes is not None else None,
"decision_counts": dict(sorted(counts.items())),
"decisions": decisions,
}
@@ -294,6 +299,12 @@ def main() -> int:
parser.add_argument("--reference-observed-at")
parser.add_argument("--imagery-valid-to")
parser.add_argument("--merge-touching-roofs", action="store_true")
parser.add_argument(
"--source-class",
action="append",
dest="source_classes",
help="Repeatable provider-native class allowlist. No cross-provider semantic mapping is inferred.",
)
args = parser.parse_args()
normalized, audit = normalize(
reference_path=args.reference,
@@ -304,6 +315,7 @@ def main() -> int:
reference_observed_at=args.reference_observed_at,
imagery_valid_to=args.imagery_valid_to,
merge_touching_roofs=args.merge_touching_roofs,
allowed_source_classes=set(args.source_classes) if args.source_classes else None,
)
args.output_reference.parent.mkdir(parents=True, exist_ok=True)
args.output_audit.parent.mkdir(parents=True, exist_ok=True)
+24 -4
View File
@@ -108,6 +108,7 @@ def main() -> int:
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("--fallback-policy", choices=("retain", "drop"), default="retain")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
if args.output_dir.exists():
@@ -134,7 +135,7 @@ def main() -> int:
with Image.open(source_image) as image:
width, height = image.size
source_boxes = read_boxes(source_label, width, height)
output_boxes = list(source_boxes)
output_boxes: list[tuple[float, float, float, float] | None] = list(source_boxes)
if source_boxes:
for start in range(0, len(source_boxes), args.max_prompts_per_pass):
source_chunk = source_boxes[start : start + args.max_prompts_per_pass]
@@ -153,6 +154,8 @@ def main() -> int:
if candidate_index < 0 or overlap < args.min_source_iou:
reason_counts["unmatched_mask"] = reason_counts.get("unmatched_mask", 0) + 1
fallback_count += 1
if args.fallback_policy == "drop":
output_boxes[start + local_index] = None
continue
candidate = candidates[candidate_index]
if plausible_refinement(
@@ -171,14 +174,22 @@ def main() -> int:
else:
reason_counts["geometry_gate"] = reason_counts.get("geometry_gate", 0) + 1
fallback_count += 1
if args.fallback_policy == "drop":
output_boxes[start + local_index] = None
del result, masks
if model.predictor is not None:
model.predictor.reset_image()
gc.collect()
torch.cuda.empty_cache()
target_label.write_text("\n".join(yolo_line(box, width, height) for box in output_boxes) + ("\n" if output_boxes else ""), encoding="utf-8")
retained_boxes = [box for box in output_boxes if box is not None]
target_label.write_text("\n".join(yolo_line(box, width, height) for box in retained_boxes) + ("\n" if retained_boxes else ""), encoding="utf-8")
output_tile = dict(tile)
output_tile.update({"image_path": str(target_image), "label_path": str(target_label)})
output_tile.update({
"image_path": str(target_image),
"label_path": str(target_label),
"label_count": len(retained_boxes),
"is_negative": not retained_boxes,
})
output_tiles.append(output_tile)
print(f"{index}/{len(summary['tiles'])} {tile['sample_slug']}: {len(source_boxes)}", flush=True)
@@ -188,7 +199,14 @@ def main() -> int:
"output_dir": str(args.output_dir),
"dataset_yaml": str(args.output_dir / "dataset.yaml"),
"tiles": output_tiles,
"label_semantics": "sam_visible_roof_with_official_footprint_fallback",
"label_semantics": (
"sam_visible_roof_only"
if args.fallback_policy == "drop"
else "sam_visible_roof_with_official_footprint_fallback"
),
"label_count": refined_count if args.fallback_policy == "drop" else summary["label_count"],
"positive_tile_count": sum(not tile["is_negative"] for tile in output_tiles),
"negative_tile_count": sum(tile["is_negative"] for tile in output_tiles),
}
)
summary_path = args.output_dir / "yolo_tile_dataset_summary.json"
@@ -213,8 +231,10 @@ def main() -> int:
"min_dimension_ratio": args.min_dimension_ratio,
"max_dimension_ratio": args.max_dimension_ratio,
"max_prompts_per_pass": args.max_prompts_per_pass,
"fallback_policy": args.fallback_policy,
"refined_label_count": refined_count,
"fallback_label_count": fallback_count,
"dropped_fallback_label_count": fallback_count if args.fallback_policy == "drop" else 0,
"fallback_reason_counts": reason_counts,
}
(args.output_dir / "sam-refinement.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")