Normalize touching footprints as visible roof instances
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-27 05:06:20 +02:00
parent 7744803461
commit de0406130c
5 changed files with 112 additions and 4 deletions
@@ -132,3 +132,50 @@ def test_normalizer_rejects_features_created_after_dated_imagery(tmp_path: Path)
)
assert len(normalized["features"]) == 1
assert audit["decision_counts"] == {"accepted": 1, "created_after_imagery_period": 1}
def test_normalizer_merges_only_touching_visible_roof_instances(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"))
polygons = [
[[[4.01, 50.99], [4.02, 50.99], [4.02, 50.98], [4.01, 50.98], [4.01, 50.99]]],
[[[4.02, 50.99], [4.03, 50.99], [4.03, 50.98], [4.02, 50.98], [4.02, 50.99]]],
[[[4.04, 50.99], [4.05, 50.99], [4.05, 50.98], [4.04, 50.98], [4.04, 50.99]]],
]
reference_path = tmp_path / "reference.geojson"
reference_path.write_text(
json.dumps(
{
"type": "FeatureCollection",
"features": [
{"type": "Feature", "id": str(index), "properties": {}, "geometry": {"type": "Polygon", "coordinates": coordinates}}
for index, coordinates in enumerate(polygons)
],
}
),
encoding="utf-8",
)
normalized, audit = module.normalize(
reference_path=reference_path,
raster_path=raster_path,
source_name="urbis",
min_label_px=3,
imagery_observed_at=None,
reference_observed_at=None,
merge_touching_roofs=True,
)
assert len(normalized["features"]) == 2
assert sorted(item["properties"]["source_feature_count"] for item in normalized["features"]) == [1, 2]
assert audit["accepted_source_feature_count"] == 3
assert audit["accepted_feature_count"] == 2
+8
View File
@@ -94,3 +94,11 @@ end of the imagery period. A feature created afterward is retained in the
audit but excluded from training as `created_after_imagery_period`. UrbIS does
not expose an equivalent feature creation field in this acquisition contract,
so its remaining temporal relation stays an explicit sample-level limitation.
The detector target is an imagery-visible roof instance, not an administrative
address or cadastral unit. Source footprints that truly touch or overlap are
dissolved into one roof instance before tiling; separated footprints are never
bridged. The audit retains every contributing native feature identifier and
reports both the accepted source-feature count and resulting visible-instance
count. This prevents dense row-house subdivisions that cannot be distinguished
from the orthophoto from becoming contradictory image labels.
@@ -150,6 +150,7 @@ def main() -> int:
),
reference_observed_at=reference.observed_at.isoformat() if reference.observed_at else None,
imagery_valid_to=raster.valid_to.isoformat() if raster.valid_to else None,
merge_touching_roofs=True,
)
normalized_target.write_text(json.dumps(normalized, ensure_ascii=False), encoding="utf-8")
audit_target.write_text(json.dumps(audit, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -80,6 +80,7 @@ def main() -> int:
parser.add_argument("--match-iou", type=float, default=0.25)
parser.add_argument("--device", default="cuda:0")
parser.add_argument("--split", default="val")
parser.add_argument("--augment", action="store_true", help="Enable deterministic YOLO test-time augmentation.")
args = parser.parse_args()
from ultralytics import YOLO
@@ -98,7 +99,13 @@ def main() -> int:
}
tiles = [item for item in summary["tiles"] if item.get("kept", True) and item["split"] == args.split]
image_paths = [item["image_path"] for item in tiles]
results = YOLO(str(args.model)).predict(image_paths, conf=min(args.thresholds), device=args.device, verbose=False)
results = YOLO(str(args.model)).predict(
image_paths,
conf=min(args.thresholds),
device=args.device,
augment=args.augment,
verbose=False,
)
observations: list[dict[str, Any]] = []
for tile, result in zip(tiles, results, strict=True):
height, width = result.orig_shape
@@ -144,6 +151,7 @@ def main() -> int:
"summary": str(args.summary),
"split": args.split,
"match_iou": args.match_iou,
"test_time_augmentation": args.augment,
"tile_count": len(tiles),
"sweeps": sweeps,
}
+47 -3
View File
@@ -19,7 +19,7 @@ from typing import Any
import rasterio
from pyproj import Transformer
from shapely.geometry import mapping, shape
from shapely.ops import transform as shapely_transform
from shapely.ops import transform as shapely_transform, unary_union
from shapely.validation import make_valid
SUPPORTED_SOURCES = {"grb", "spw_picc", "urbis"}
@@ -90,6 +90,42 @@ def _polygonal(geometry: Any) -> Any | None:
return None
def merge_touching_roof_instances(features: list[dict[str, Any]], source_name: str) -> list[dict[str, Any]]:
"""Dissolve only touching/overlapping footprints into imagery-visible roof instances."""
if not features:
return []
source_geometries = [(feature, shape(feature["geometry"])) for feature in features]
dissolved = unary_union([geometry for _feature, geometry in source_geometries])
components = list(dissolved.geoms) if dissolved.geom_type == "MultiPolygon" else [dissolved]
merged: list[dict[str, Any]] = []
for component in components:
contributors = [
feature
for feature, geometry in source_geometries
if geometry.intersects(component)
]
source_ids = sorted(str(feature["properties"]["source_feature_id"]) for feature in contributors)
properties = dict(contributors[0]["properties"])
properties.update(
{
"label_semantics": "visible_touching_roof_instance",
"source_feature_ids": source_ids,
"source_feature_count": len(source_ids),
"source_feature_id": source_ids[0],
}
)
digest = hashlib.sha256(component.normalize().wkb).hexdigest()[:24]
merged.append(
{
"type": "Feature",
"id": f"{source_name}:roof-instance:{digest}",
"properties": properties,
"geometry": mapping(component),
}
)
return merged
def normalize(
*,
reference_path: Path,
@@ -99,6 +135,7 @@ def normalize(
imagery_observed_at: str | None,
reference_observed_at: str | None,
imagery_valid_to: str | None = None,
merge_touching_roofs: bool = False,
) -> tuple[dict[str, Any], dict[str, Any]]:
if source_name not in SUPPORTED_SOURCES:
raise SystemExit(f"Unsupported governed building source: {source_name}")
@@ -194,10 +231,13 @@ def normalize(
reference_date = datetime.fromisoformat(reference_observed_at.replace("Z", "+00:00"))
temporal_mismatch_days = abs((imagery_date - reference_date).days)
temporal_alignment_status = "measured"
normalized_features = (
merge_touching_roof_instances(accepted, source_name) if merge_touching_roofs else accepted
)
normalized = {
"type": "FeatureCollection",
"name": f"canonical-building-{source_name}",
"features": accepted,
"features": normalized_features,
}
audit = {
"schema_version": 1,
@@ -212,7 +252,9 @@ def normalize(
"temporal_mismatch_days": temporal_mismatch_days,
"temporal_alignment_status": temporal_alignment_status,
"input_feature_count": len(payload["features"]),
"accepted_feature_count": len(accepted),
"accepted_source_feature_count": len(accepted),
"accepted_feature_count": len(normalized_features),
"merge_touching_roofs": merge_touching_roofs,
"decision_counts": dict(sorted(counts.items())),
"decisions": decisions,
}
@@ -230,6 +272,7 @@ def main() -> int:
parser.add_argument("--imagery-observed-at")
parser.add_argument("--reference-observed-at")
parser.add_argument("--imagery-valid-to")
parser.add_argument("--merge-touching-roofs", action="store_true")
args = parser.parse_args()
normalized, audit = normalize(
reference_path=args.reference,
@@ -239,6 +282,7 @@ def main() -> int:
imagery_observed_at=args.imagery_observed_at,
reference_observed_at=args.reference_observed_at,
imagery_valid_to=args.imagery_valid_to,
merge_touching_roofs=args.merge_touching_roofs,
)
args.output_reference.parent.mkdir(parents=True, exist_ok=True)
args.output_audit.parent.mkdir(parents=True, exist_ok=True)