Normalize touching footprints as visible roof instances
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user