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 len(normalized["features"]) == 1
assert audit["decision_counts"] == {"accepted": 1, "created_after_imagery_period": 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 audit but excluded from training as `created_after_imagery_period`. UrbIS does
not expose an equivalent feature creation field in this acquisition contract, not expose an equivalent feature creation field in this acquisition contract,
so its remaining temporal relation stays an explicit sample-level limitation. 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, 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, 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") 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") 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("--match-iou", type=float, default=0.25)
parser.add_argument("--device", default="cuda:0") parser.add_argument("--device", default="cuda:0")
parser.add_argument("--split", default="val") parser.add_argument("--split", default="val")
parser.add_argument("--augment", action="store_true", help="Enable deterministic YOLO test-time augmentation.")
args = parser.parse_args() args = parser.parse_args()
from ultralytics import YOLO 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] 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] 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]] = [] observations: list[dict[str, Any]] = []
for tile, result in zip(tiles, results, strict=True): for tile, result in zip(tiles, results, strict=True):
height, width = result.orig_shape height, width = result.orig_shape
@@ -144,6 +151,7 @@ def main() -> int:
"summary": str(args.summary), "summary": str(args.summary),
"split": args.split, "split": args.split,
"match_iou": args.match_iou, "match_iou": args.match_iou,
"test_time_augmentation": args.augment,
"tile_count": len(tiles), "tile_count": len(tiles),
"sweeps": sweeps, "sweeps": sweeps,
} }
+47 -3
View File
@@ -19,7 +19,7 @@ from typing import Any
import rasterio import rasterio
from pyproj import Transformer from pyproj import Transformer
from shapely.geometry import mapping, shape 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 from shapely.validation import make_valid
SUPPORTED_SOURCES = {"grb", "spw_picc", "urbis"} SUPPORTED_SOURCES = {"grb", "spw_picc", "urbis"}
@@ -90,6 +90,42 @@ def _polygonal(geometry: Any) -> Any | None:
return 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( def normalize(
*, *,
reference_path: Path, reference_path: Path,
@@ -99,6 +135,7 @@ def normalize(
imagery_observed_at: str | None, imagery_observed_at: str | None,
reference_observed_at: str | None, reference_observed_at: str | None,
imagery_valid_to: str | None = None, imagery_valid_to: str | None = None,
merge_touching_roofs: bool = False,
) -> tuple[dict[str, Any], dict[str, Any]]: ) -> tuple[dict[str, Any], dict[str, Any]]:
if source_name not in SUPPORTED_SOURCES: if source_name not in SUPPORTED_SOURCES:
raise SystemExit(f"Unsupported governed building source: {source_name}") 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")) reference_date = datetime.fromisoformat(reference_observed_at.replace("Z", "+00:00"))
temporal_mismatch_days = abs((imagery_date - reference_date).days) temporal_mismatch_days = abs((imagery_date - reference_date).days)
temporal_alignment_status = "measured" temporal_alignment_status = "measured"
normalized_features = (
merge_touching_roof_instances(accepted, source_name) if merge_touching_roofs else accepted
)
normalized = { normalized = {
"type": "FeatureCollection", "type": "FeatureCollection",
"name": f"canonical-building-{source_name}", "name": f"canonical-building-{source_name}",
"features": accepted, "features": normalized_features,
} }
audit = { audit = {
"schema_version": 1, "schema_version": 1,
@@ -212,7 +252,9 @@ def normalize(
"temporal_mismatch_days": temporal_mismatch_days, "temporal_mismatch_days": temporal_mismatch_days,
"temporal_alignment_status": temporal_alignment_status, "temporal_alignment_status": temporal_alignment_status,
"input_feature_count": len(payload["features"]), "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())), "decision_counts": dict(sorted(counts.items())),
"decisions": decisions, "decisions": decisions,
} }
@@ -230,6 +272,7 @@ def main() -> int:
parser.add_argument("--imagery-observed-at") parser.add_argument("--imagery-observed-at")
parser.add_argument("--reference-observed-at") parser.add_argument("--reference-observed-at")
parser.add_argument("--imagery-valid-to") parser.add_argument("--imagery-valid-to")
parser.add_argument("--merge-touching-roofs", action="store_true")
args = parser.parse_args() args = parser.parse_args()
normalized, audit = normalize( normalized, audit = normalize(
reference_path=args.reference, reference_path=args.reference,
@@ -239,6 +282,7 @@ def main() -> int:
imagery_observed_at=args.imagery_observed_at, imagery_observed_at=args.imagery_observed_at,
reference_observed_at=args.reference_observed_at, reference_observed_at=args.reference_observed_at,
imagery_valid_to=args.imagery_valid_to, 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_reference.parent.mkdir(parents=True, exist_ok=True)
args.output_audit.parent.mkdir(parents=True, exist_ok=True) args.output_audit.parent.mkdir(parents=True, exist_ok=True)