# Copyright (c) 2025 The University of Washington
#
# This file is part of rapidtools.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# 3. Neither the name of the copyright holder nor the names of its contributors
# may be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# You should have received a copy of the BSD 3-Clause License along with
# rapidtools. If not, see <http://www.opensource.org/licenses/>.
#
# Contributors:
# Barbaros Cetiner
#
# Last updated:
# 09-22-2026
"""
Regularization of raster-derived road polygons into clean centerlines.
This module provides :class:`RoadwayRegularizer`, a pure-geometry pipeline
component that converts the jagged blobs produced by raster-to-vector road
segmentation (e.g., from
:class:`~rapidtools.processing.SAM3OrthoFeatureExtractor` with the prompt
``'road'``) into straight centerline segments with statistically sampled
widths, and rebuilds clean, non-overlapping road polygons from them.
All computation happens in a local azimuthal-equidistant projection in US
survey feet; inputs and outputs are WGS84.
Example:
>>> from rapidtools.core import PhysicalAssetCollection
>>> from rapidtools.processing import RoadwayRegularizer
>>> raw_roads = PhysicalAssetCollection.from_geojson('roads_raw.geojson')
>>> centerlines, polygons = RoadwayRegularizer(min_width_ft=20)(raw_roads)
>>> centerlines.to_geojson('centerlines.geojson')
>>> polygons.to_geojson('roads_clean.geojson')
"""
import logging
import math
import statistics
import uuid
from typing import Any, Generic, Literal, TypeVar, cast, overload
import networkx as nx
import pyproj
import shapely
from shapely.geometry import LineString, MultiPoint
from shapely.geometry.base import BaseGeometry
from shapely.ops import linemerge, transform, unary_union, voronoi_diagram
from tqdm import tqdm
from rapidtools.core import PhysicalAsset, PhysicalAssetCollection
from rapidtools.processing.step import Stage
logger = logging.getLogger(__name__)
# What ``RoadwayRegularizer.__call__`` returns: a ``(centerlines, polygons)``
# tuple for ``output='both'`` or a single collection otherwise.
_OutT = TypeVar('_OutT')
_Both = tuple[PhysicalAssetCollection, PhysicalAssetCollection]
ASSET_TYPE = 'road'
# Pipeline configuration constants:
# These constants define the heuristic thresholds and multipliers used by the
# geometry processing algorithms. They can be modified to tune the regularization
# behavior for different scales of assets (e.g., highways vs. alleyways).
# 1. Loading & Initial Processing
MIN_POLYGON_AREA_SQFT = 1000.0 # Ignore stray artifacts smaller than this
CLEANUP_BUFFER_FT = 0.1 # Buffer used to fuse slightly disconnected polygons
MIN_APPROX_WIDTH_FT = 10.0 # Absolute baseline width fallback for roads
# 2. Skeletonization Parameters (Multipliers of calculated approx_width)
SKELETON_SIMPLIFY_FACTOR = 0.1 # Initial smoothing tolerance
SKELETON_BUFFER_FACTOR = 0.2 # Outward/Inward buffer to remove jagged edges
SKELETON_SEGMENTIZE_FACTOR = 0.5 # Distance between points injected along boundary
SKELETON_CLIP_FACTOR = 0.05 # Negative buffer to clip Voronoi lines inside polygon
SKELETON_SNAP_GRID_FT = 0.001 # Grid used to snap Voronoi edge endpoints before merging
# 3. Graph Healing & Pruning
COORD_ROUNDING_DECIMALS = 1 # Precision used when identifying graph nodes
DEAD_END_PRUNING_FACTOR = 2.5 # Multiplier of approx_width to identify stub dead-ends
# 4. Collinear Segment Merging
COLLINEAR_SIMPLIFY_FACTOR = 0.75 # Tolerance for straightening wobbly centerlines
MIN_SEGMENT_LENGTH_FT = 1.0 # Drop segments shorter than this after simplification
MAX_MERGE_ANGLE_DEG = 25.0 # Maximum angle difference to merge two lines into one
FINAL_LINE_SIMPLIFY_TOLERANCE = 2.0 # Final smoothing tolerance applied after merge
# 5. Statistical Width Sampling
MIN_SAMPLES_PER_SEGMENT = 3 # Min width measurements per road segment
SAMPLE_SPACING_FACTOR = 0.5 # Spacing between samples (multiplier of approx_width)
DERIVATIVE_DELTA_FT = 1.0 # Delta offset used to calculate the normal vector (heading)
MEASURING_TAPE_FACTOR = 5.0 # Multiplier of approx_width defining max sampling ray cast
# 6. Output Formatting
OUTPUT_ROUNDING_DECIMALS = 2 # Decimals used when storing properties (width, azimuth)
[docs]
class RoadwayRegularizer(Generic[_OutT]):
"""
Pipeline component to regularize jagged raster-to-vector road polygons.
This regularizer processes raw input geometries, extracts a Voronoi-based
centerline skeleton, heals the graph, merges collinear segments, and
statistically samples widths. It outputs in-memory
``PhysicalAssetCollection`` objects for both the centerlines and the
reconstructed polygons.
Args:
min_width_ft (float, optional):
The minimum enforced width for any regularized road segment in
feet. Defaults to 22.0.
min_network_length_ft (float, optional):
The minimum cumulative length a connected skeleton component must
possess to be considered a valid road network. Any disconnected
stub networks shorter than this value will be filtered out.
Defaults to 100.0.
Example:
>>> from rapidtools.core import PhysicalAssetCollection
>>> from rapidtools.processing import RoadwayRegularizer
>>>
>>> raw = PhysicalAssetCollection.from_geojson('roads_raw.geojson')
>>> regularizer = RoadwayRegularizer(
... min_width_ft=22.0, min_network_length_ft=150.0
... )
>>> centerlines, polygons = regularizer(raw)
>>> centerlines[0].attributes
{'asset_type': 'road_centerline', 'width_ft': 24.5, 'azimuth_deg': 91.2}
"""
stage = Stage.REGULARIZE
@overload
def __init__(
self: 'RoadwayRegularizer[_Both]',
min_width_ft: float = ...,
min_network_length_ft: float = ...,
output: Literal['both'] = ...,
) -> None: ...
@overload
def __init__(
self: 'RoadwayRegularizer[PhysicalAssetCollection]',
min_width_ft: float = ...,
min_network_length_ft: float = ...,
*,
output: Literal['polygons', 'centerlines'],
) -> None: ...
@overload
def __init__(
self: 'RoadwayRegularizer[Any]',
min_width_ft: float = ...,
min_network_length_ft: float = ...,
output: str = ...,
) -> None: ...
def __init__(
self,
min_width_ft: float = 22.0,
min_network_length_ft: float = 100.0,
output: str = 'both',
):
"""
Initialize the regularizer thresholds.
Args:
min_width_ft (float, optional):
Minimum enforced road width in feet.
min_network_length_ft (float, optional):
Minimum cumulative length of a skeleton component in feet.
"""
self.min_width_ft = min_width_ft
self.min_network_length_ft = min_network_length_ft
if output not in ('both', 'polygons', 'centerlines'):
raise ValueError(
f"output must be 'both', 'polygons' or 'centerlines', got {output!r}."
)
# 'both' returns (centerlines, polygons); the single-collection modes make
# the regularizer usable as a Pipeline step.
self.output = output
def __call__(self, input_assets: PhysicalAssetCollection) -> _OutT:
"""
Allow the instance to be called directly like a function.
Args:
input_assets (PhysicalAssetCollection):
The raw input road polygons (WGS84).
Returns:
With ``output='both'`` (the default), a tuple containing the
centerlines collection and the reconstructed polygons collection;
with ``output='polygons'`` or ``output='centerlines'``, just that
collection.
Example:
>>> centerlines, polygons = RoadwayRegularizer()(raw_roads)
"""
centerlines, polygons = self.process(input_assets)
# The casts are backed by the ``__init__`` overloads, which tie
# ``_OutT`` to the ``output`` mode:
if self.output == 'polygons':
return cast(_OutT, polygons)
if self.output == 'centerlines':
return cast(_OutT, centerlines)
return cast(_OutT, (centerlines, polygons))
[docs]
def process(
self, input_assets: PhysicalAssetCollection
) -> tuple[PhysicalAssetCollection, PhysicalAssetCollection]:
"""
Execute the regularization logic and return asset collections.
The steps are: project to a local feet-based CRS, drop polygons
smaller than ``MIN_POLYGON_AREA_SQFT``, fuse near-touching polygons,
skeletonize with a Voronoi diagram, heal/prune the skeleton graph,
remove short stub networks, merge collinear segments, sample widths
and azimuths, and finally rebuild WGS84 centerline and polygon
assets.
Args:
input_assets (PhysicalAssetCollection):
The raw input road polygons (WGS84).
Returns:
tuple[PhysicalAssetCollection, PhysicalAssetCollection]:
A tuple containing:
- ``centerlines_collection``: Assets representing the road
centerlines (``LineString`` geometries) with the attributes
``'asset_type': 'road_centerline'``, ``'width_ft'`` and
``'azimuth_deg'``.
- ``polygons_collection``: Assets representing the regularized
polygons with ``'asset_type': 'road_polygon'`` and the same
width/azimuth attributes.
Both collections are empty when the input is empty or when no
polygon exceeds the minimum area.
Example:
>>> centerlines, polygons = RoadwayRegularizer().process(raw_roads)
>>> polygons[0].attributes['asset_type']
'road_polygon'
"""
logger.info('Starting Roadway Regularization Pipeline...')
# 1. Extract Geometries & Project
raw_geometries = [asset.geometry for asset in input_assets]
if not raw_geometries:
logger.warning('No valid geometries found in input collection. Exiting.')
return PhysicalAssetCollection(), PhysicalAssetCollection()
union_all = unary_union(raw_geometries)
project_to_feet, project_to_wgs84 = self._get_transformers(union_all)
valid_polys = [
transform(project_to_feet, g).buffer(0)
for g in raw_geometries
if transform(project_to_feet, g).area > MIN_POLYGON_AREA_SQFT
]
if not valid_polys:
logger.warning(
f'No input polygon exceeds {MIN_POLYGON_AREA_SQFT} sq ft. Exiting.'
)
return PhysicalAssetCollection(), PhysicalAssetCollection()
all_networks = unary_union(
[
p.buffer(CLEANUP_BUFFER_FT, join_style=2).buffer(
-CLEANUP_BUFFER_FT, join_style=2
)
for p in valid_polys
]
)
largest_poly = self._get_largest_polygon(all_networks)
approx_width = max(
MIN_APPROX_WIDTH_FT, 2.0 * (largest_poly.area / largest_poly.length)
)
# 2. Skeletonization
merged_lines = self._generate_voronoi_skeleton(all_networks, approx_width)
# 3. Graph Healing & Pruning
graph = self._build_and_heal_graph(merged_lines, approx_width)
# 4. Filter Stub Networks
graph = self._filter_stub_networks(graph, approx_width)
# 5. Merge Collinear Segments
straight_segments = self._merge_collinear_segments(graph, approx_width)
# 6. Sample Widths & Azimuth
analyzed_segments = self._calculate_widths_and_azimuths(
straight_segments, all_networks, approx_width
)
# 7. Build Centerlines Collection
centerlines_collection = self._build_centerlines_collection(
analyzed_segments, project_to_wgs84
)
# 8. Reconstruct Polygons & Build Collection
polygons_collection = self._build_polygons_collection(
analyzed_segments, project_to_wgs84
)
# 9. Default undefined asset types to 'road'
centerlines_collection.set_asset_type(ASSET_TYPE, overwrite=False)
polygons_collection.set_asset_type(ASSET_TYPE, overwrite=False)
logger.info('Roadway regularization complete.')
return centerlines_collection, polygons_collection
# --- Private Helper Methods ---
def _get_geoms(self, geometry: BaseGeometry) -> list[BaseGeometry]:
"""
Extract the list of sub-geometries from a (multi-part) geometry.
Args:
geometry (BaseGeometry):
Any Shapely geometry.
Returns:
list[BaseGeometry]:
The parts of a multi-part geometry, or ``[geometry]`` for a
single-part geometry.
"""
if hasattr(geometry, 'geoms'):
return list(geometry.geoms)
return [geometry]
def _get_lines(self, geom: BaseGeometry) -> list[LineString]:
"""
Recursively extract ``LineString`` parts from a geometry.
Args:
geom (BaseGeometry):
A ``LineString``, ``MultiLineString`` or nested
``GeometryCollection``.
Returns:
list[LineString]:
All ``LineString`` parts found (empty for point/polygon input).
"""
lines = []
if geom.geom_type == 'LineString':
lines.append(geom)
elif hasattr(geom, 'geoms'):
for part in geom.geoms:
lines.extend(self._get_lines(part))
return lines
def _get_transformers(self, base_geom: BaseGeometry) -> tuple[Any, Any]:
"""
Build pyproj transformers between WGS84 and a local feet-based CRS.
The local CRS is an azimuthal equidistant projection centred on the
centroid of ``base_geom`` with units of US survey feet.
Args:
base_geom (BaseGeometry):
WGS84 geometry whose centroid defines the projection origin.
Returns:
tuple[Callable, Callable]:
``(project_to_feet, project_to_wgs84)`` transform functions
suitable for :func:`shapely.ops.transform`.
"""
proj_wgs84 = pyproj.CRS('EPSG:4326')
proj_feet = pyproj.CRS(
f'+proj=aeqd +lat_0={base_geom.centroid.y} '
f'+lon_0={base_geom.centroid.x} +datum=WGS84 +units=us-ft'
)
project_to_feet = pyproj.Transformer.from_crs(
proj_wgs84, proj_feet, always_xy=True
).transform
project_to_wgs84 = pyproj.Transformer.from_crs(
proj_feet, proj_wgs84, always_xy=True
).transform
return project_to_feet, project_to_wgs84
def _get_largest_polygon(self, geom: BaseGeometry) -> BaseGeometry:
"""
Find the largest single polygon within a multi-part geometry.
Args:
geom (BaseGeometry):
A ``Polygon``, ``MultiPolygon`` or ``GeometryCollection``.
Returns:
BaseGeometry:
The polygon part with the largest area, or ``geom`` itself
when it is not multi-part.
"""
if geom.geom_type in ['MultiPolygon', 'GeometryCollection']:
return max(
[g for g in self._get_geoms(geom) if g.geom_type == 'Polygon'],
key=lambda p: p.area,
)
return geom
def _extract_all_coords(self, geom: BaseGeometry) -> list[tuple[float, float]]:
"""
Recursively extract coordinate tuples from a geometry.
Args:
geom (BaseGeometry):
Any geometry; multi-part geometries are flattened.
Returns:
list[tuple[float, float]]:
All coordinates found, in order. Objects with neither
``geoms`` nor ``coords`` yield an empty list.
"""
if hasattr(geom, 'geoms'):
pts = []
for g in geom.geoms:
pts.extend(self._extract_all_coords(g))
return pts
elif hasattr(geom, 'coords'):
return list(geom.coords)
return []
def _generate_voronoi_skeleton(
self, network_geom: BaseGeometry, approx_width: float
) -> BaseGeometry:
"""
Generate a centerline skeleton from road polygons using a Voronoi diagram.
The polygon is smoothed, its boundary densified, and the Voronoi edges
of the boundary points are clipped to the polygon interior. Edges that
remain form the medial axis approximation. Edge endpoints are snapped
to ``SKELETON_SNAP_GRID_FT`` so that adjoining edges merge cleanly.
Args:
network_geom (BaseGeometry):
The fused road polygons in the local feet CRS.
approx_width (float):
Estimated road width in feet; scales all tolerances.
Returns:
BaseGeometry:
A ``LineString`` or ``MultiLineString`` of merged skeleton
edges.
"""
logger.info('Generating Voronoi skeleton...')
smoothed_poly = (
network_geom.simplify(approx_width * SKELETON_SIMPLIFY_FACTOR)
.buffer(approx_width * SKELETON_BUFFER_FACTOR, join_style=2)
.buffer(-approx_width * SKELETON_BUFFER_FACTOR, join_style=2)
)
boundary_pts = smoothed_poly.boundary.segmentize(
approx_width * SKELETON_SEGMENTIZE_FACTOR
)
coords = self._extract_all_coords(boundary_pts)
voronoi_edges = voronoi_diagram(MultiPoint(coords), edges=True)
clipped_lines = voronoi_edges.intersection(
smoothed_poly.buffer(-approx_width * SKELETON_CLIP_FACTOR)
)
# Voronoi edge endpoints computed from different cells can differ by
# floating-point noise; snap them to a fine grid so that linemerge
# recognises them as shared vertices and produces continuous lines.
snapped_lines = shapely.set_precision(
unary_union(clipped_lines), SKELETON_SNAP_GRID_FT
)
return linemerge(snapped_lines)
def _build_and_heal_graph(
self, merged_lines: BaseGeometry, approx_width: float
) -> nx.Graph:
"""
Build a NetworkX graph from skeleton lines and prune or heal segments.
Nodes are line endpoints rounded to ``COORD_ROUNDING_DECIMALS``; the
line vertices are snapped onto those node coordinates so edges join
exactly. Edges carry ``geom`` and ``length`` attributes. Degree-2 nodes
are collapsed by merging their two edges,
and dead-end edges shorter than ``approx_width *
DEAD_END_PRUNING_FACTOR`` are removed. The two operations alternate
until the graph stops changing.
Args:
merged_lines (BaseGeometry):
Skeleton lines from :meth:`_generate_voronoi_skeleton`.
approx_width (float):
Estimated road width in feet.
Returns:
nx.Graph:
The healed and pruned skeleton graph.
"""
logger.info('Building and healing graph...')
G = nx.Graph()
# Build initial graph
for line in self._get_geoms(merged_lines):
if line.geom_type != 'LineString':
continue
start = (
round(line.coords[0][0], COORD_ROUNDING_DECIMALS),
round(line.coords[0][1], COORD_ROUNDING_DECIMALS),
)
end = (
round(line.coords[-1][0], COORD_ROUNDING_DECIMALS),
round(line.coords[-1][1], COORD_ROUNDING_DECIMALS),
)
if start != end:
# Snap the line's end vertices onto the rounded node keys so
# that every edge meeting at a node shares exactly the same
# coordinate; otherwise linemerge cannot fuse them when the
# node is healed below.
coords = list(line.coords)
coords[0] = start
coords[-1] = end
line = LineString(coords)
G.add_edge(start, end, geom=line, length=line.length)
# Heal and Prune
pruning = True
while pruning:
pruning = False
healing = True
while healing:
healing = False
deg2_nodes = [n for n in G.nodes if G.degree(n) == 2]
for n in deg2_nodes:
if G.degree(n) == 2:
u, v = list(G.neighbors(n))
if u != v and not G.has_edge(u, v):
lines_to_merge = self._get_lines(
G[u][n]['geom']
) + self._get_lines(G[n][v]['geom'])
merged_geom = linemerge(lines_to_merge)
new_length = G[u][n]['length'] + G[n][v]['length']
G.add_edge(u, v, geom=merged_geom, length=new_length)
G.remove_node(n)
healing = True
dead_ends = [n for n in G.nodes if G.degree(n) == 1]
for node in dead_ends:
if G.has_node(node) and G.degree(node) == 1:
neighbor = list(G.neighbors(node))[0]
if (
G[node][neighbor]['length']
< approx_width * DEAD_END_PRUNING_FACTOR
):
G.remove_node(node)
pruning = True
return G
def _filter_stub_networks(self, G: nx.Graph, approx_width: float) -> nx.Graph:
"""
Remove connected components whose total length is too short.
Args:
G (nx.Graph):
Skeleton graph with ``length`` edge attributes.
approx_width (float):
Estimated road width in feet (currently unused; kept for
signature symmetry with the other graph helpers).
Returns:
nx.Graph:
A new graph containing only the edges of components longer
than ``min_network_length_ft``.
"""
logger.info('Structurally filtering stub networks...')
valid_edges = []
for comp in list(nx.connected_components(G)):
subgraph = G.subgraph(comp)
total_length = sum(
data['length'] for _, _, data in subgraph.edges(data=True)
)
if total_length > self.min_network_length_ft:
valid_edges.extend(subgraph.edges(data=True))
filtered_G = nx.Graph()
filtered_G.add_edges_from(valid_edges)
return filtered_G
def _merge_collinear_segments(
self, G: nx.Graph, approx_width: float
) -> list[LineString]:
"""
Simplify graph edges and merge collinear sub-segments into straight lines.
Each edge geometry is simplified with a tolerance proportional to
``approx_width`` and split into straight parts. Pairs of parts that
share an endpoint and deviate by less than ``MAX_MERGE_ANGLE_DEG`` are
merged repeatedly until no further merge is possible.
Args:
G (nx.Graph):
Healed skeleton graph with ``geom`` edge attributes.
approx_width (float):
Estimated road width in feet.
Returns:
list[LineString]:
Straight, merged centerline segments.
"""
logger.info('Merging collinear segments into continuous roads...')
straight_segments = []
for _, _, data in G.edges(data=True):
straightened = data['geom'].simplify(
approx_width * COLLINEAR_SIMPLIFY_FACTOR, preserve_topology=False
)
for part in self._get_lines(straightened):
if part.length > MIN_SEGMENT_LENGTH_FT:
straight_segments.append(part)
merged_something = True
while merged_something:
merged_something = False
for i in range(len(straight_segments)):
for j in range(i + 1, len(straight_segments)):
l1, l2 = straight_segments[i], straight_segments[j]
c1, c2 = list(l1.coords), list(l2.coords)
shared = None
if c1[-1] == c2[0]:
shared, other1, other2 = c1[-1], c1[-2], c2[1]
new_coords = c1 + c2[1:]
elif c1[0] == c2[-1]:
shared, other1, other2 = c1[0], c1[1], c2[-2]
new_coords = c2 + c1[1:]
elif c1[-1] == c2[-1]:
shared, other1, other2 = c1[-1], c1[-2], c2[-2]
new_coords = c1 + c2[::-1][1:]
elif c1[0] == c2[0]:
shared, other1, other2 = c1[0], c1[1], c2[1]
new_coords = c1[::-1] + c2[1:]
if shared:
a1 = math.atan2(shared[1] - other1[1], shared[0] - other1[0])
a2 = math.atan2(other2[1] - shared[1], other2[0] - shared[0])
diff = math.degrees(
abs((a1 - a2 + math.pi) % (2 * math.pi) - math.pi)
)
if diff < MAX_MERGE_ANGLE_DEG:
straight_segments.pop(j)
straight_segments.pop(i)
merged_line = LineString(new_coords).simplify(
FINAL_LINE_SIMPLIFY_TOLERANCE, preserve_topology=False
)
straight_segments.append(merged_line)
merged_something = True
break
if merged_something:
break
return straight_segments
def _calculate_widths_and_azimuths(
self, segments: list[LineString], network: BaseGeometry, approx_width: float
) -> list[dict[str, Any]]:
"""
Sample the physical width and heading of each segment against the network.
At evenly spaced stations along a segment, a "measuring tape" line
perpendicular to the local heading is intersected with the road
polygon; the median intersection length (floored at
``min_width_ft``) is the segment width. The azimuth is the compass
bearing (degrees clockwise from north) from the first to the last
vertex.
Args:
segments (list[LineString]):
Straight centerline segments in the local feet CRS.
network (BaseGeometry):
The fused road polygons in the same CRS.
approx_width (float):
Estimated road width in feet; sets sample spacing and tape
length.
Returns:
list[dict[str, Any]]:
One dict per segment with keys ``'geometry'``, ``'width'``
(feet) and ``'azimuth'`` (degrees).
"""
logger.info('Statistically sampling road widths & calculating azimuth...')
analyzed = []
for seg in tqdm(segments, desc=' -> Sampling'):
length = seg.length
num_samples = max(
MIN_SAMPLES_PER_SEGMENT,
int(length / (approx_width * SAMPLE_SPACING_FACTOR)),
)
sampled_widths = []
for i in range(1, num_samples):
dist = i * length / num_samples
pt = seg.interpolate(dist)
pt1 = seg.interpolate(max(0, dist - DERIVATIVE_DELTA_FT))
pt2 = seg.interpolate(min(length, dist + DERIVATIVE_DELTA_FT))
dx, dy = pt2.x - pt1.x, pt2.y - pt1.y
norm = math.hypot(dx, dy)
if norm == 0:
continue
nx_vec, ny_vec = -dy / norm, dx / norm
measuring_tape = LineString(
[
(
pt.x + nx_vec * approx_width * MEASURING_TAPE_FACTOR,
pt.y + ny_vec * approx_width * MEASURING_TAPE_FACTOR,
),
(
pt.x - nx_vec * approx_width * MEASURING_TAPE_FACTOR,
pt.y - ny_vec * approx_width * MEASURING_TAPE_FACTOR,
),
]
)
sampled_widths.append(measuring_tape.intersection(network).length)
final_width = max(
self.min_width_ft,
statistics.median(sampled_widths)
if sampled_widths
else self.min_width_ft,
)
coords = list(seg.coords)
seg_dx, seg_dy = coords[-1][0] - coords[0][0], coords[-1][1] - coords[0][1]
azimuth = (math.degrees(math.atan2(seg_dx, seg_dy)) + 360) % 360
analyzed.append({'geometry': seg, 'width': final_width, 'azimuth': azimuth})
return analyzed
def _build_centerlines_collection(
self, analyzed_segments: list[dict[str, Any]], proj_transform: Any
) -> PhysicalAssetCollection:
"""
Convert analyzed LineStrings into a WGS84 ``PhysicalAssetCollection``.
Args:
analyzed_segments (list[dict[str, Any]]):
Data generated by :meth:`_calculate_widths_and_azimuths`
containing geometry, width, and azimuth.
proj_transform (Any):
A pyproj transform function from the local CRS to WGS84.
Returns:
PhysicalAssetCollection:
The resulting centerline assets (IDs ``'cl_<hash>'``).
"""
logger.info('Building centerlines PhysicalAssetCollection...')
collection = PhysicalAssetCollection()
for item in analyzed_segments:
seg_wgs84 = transform(proj_transform, item['geometry'])
for part in self._get_geoms(seg_wgs84):
if part.geom_type == 'LineString' and not part.is_empty:
asset = PhysicalAsset(
id=f'cl_{uuid.uuid4().hex[:8]}',
geometry=part,
attributes={
'asset_type': 'road_centerline',
'width_ft': round(item['width'], OUTPUT_ROUNDING_DECIMALS),
'azimuth_deg': round(
item['azimuth'], OUTPUT_ROUNDING_DECIMALS
),
},
)
collection.add(asset)
return collection
def _build_polygons_collection(
self, analyzed_segments: list[dict[str, Any]], proj_transform: Any
) -> PhysicalAssetCollection:
"""
Reconstruct non-overlapping road polygons from analyzed centerlines.
Segments are buffered by half their width (flat caps) from widest to
narrowest; each new polygon has all previously placed polygons
subtracted so that intersections are owned by the wider road.
Args:
analyzed_segments (list[dict[str, Any]]):
Data generated by :meth:`_calculate_widths_and_azimuths`
containing geometry, width, and azimuth. Sorted in place by
width (descending).
proj_transform (Any):
A pyproj transform function from the local CRS to WGS84.
Returns:
PhysicalAssetCollection:
The resulting polygon assets (IDs ``'poly_<hash>'``).
"""
logger.info(
'Reconstructing linear polygons and building PhysicalAssetCollection...'
)
analyzed_segments.sort(key=lambda x: x['width'], reverse=True)
collection = PhysicalAssetCollection()
placed_polys: list[tuple[BaseGeometry, tuple[float, float, float, float]]] = []
for item in analyzed_segments:
poly = item['geometry'].buffer(item['width'] / 2.0, cap_style=2)
for p, p_bounds in placed_polys:
poly_bounds = poly.bounds
if not (
poly_bounds[2] < p_bounds[0]
or poly_bounds[0] > p_bounds[2]
or poly_bounds[3] < p_bounds[1]
or poly_bounds[1] > p_bounds[3]
):
if poly.intersects(p):
poly = poly.difference(p)
if poly.is_empty:
break
if not poly.is_empty:
placed_polys.append((poly, poly.bounds))
for part in self._get_geoms(transform(proj_transform, poly)):
if part.geom_type == 'Polygon' and not part.is_empty:
asset = PhysicalAsset(
id=f'poly_{uuid.uuid4().hex[:8]}',
geometry=part,
attributes={
'asset_type': 'road_polygon',
'width_ft': round(
item['width'], OUTPUT_ROUNDING_DECIMALS
),
'azimuth_deg': round(
item['azimuth'], OUTPUT_ROUNDING_DECIMALS
),
},
)
collection.add(asset)
return collection