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