Gutters sit at -10 m south and +10..12 m north, a ~2% crown between them, and the envelope runs 0.6-1.0 m per section. Gutter spacing independently confirms the 20.9 m carriageway measured from the crossing. The sharpest breaks are 0.09-0.12 m/m, so 3DEP does not resolve a kerb upstand: kerb height stays a recorded assumption rather than a derived value.
188 lines
6.5 KiB
Python
Executable File
188 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Cross-sections of the street from the local 3DEP DEM.
|
|
|
|
Usage:
|
|
Geo/scripts/street_section.py Geo/raw/osm-api-map-2026-11-06.osm \
|
|
Geo/raw/dem-3dep-1m-26911-2026-11-06.tif --step 0.5
|
|
|
|
For several positions along the block it samples the DEM on a line perpendicular
|
|
to the boulevard centreline, from 18 m south to 18 m north, and reports:
|
|
|
|
* the height profile and total cross-fall;
|
|
* where the surface breaks most sharply, which is the best available candidate
|
|
for the kerb line;
|
|
* whether a kerb step is visible in the DEM at all.
|
|
|
|
The OSM geometry is WGS84, the DEM is EPSG:26911, so points are batch-projected
|
|
through the PROJ binary that ships with the QGIS flatpak (the host has no PROJ).
|
|
|
|
Caveat: 3DEP is bare-earth lidar at 1 m. Kerbs are short vertical steps and are
|
|
usually smoothed or removed by the DEM production process. If no break shows at
|
|
the expected offset, the DEM simply does not resolve kerbs and kerb height has to
|
|
come from a stated assumption instead.
|
|
"""
|
|
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import dem_stats
|
|
import osm_common as oc
|
|
|
|
|
|
def find_proj():
|
|
env = os.environ.get("PROJ_BIN")
|
|
if env and os.path.exists(env):
|
|
return env
|
|
which = shutil.which("proj")
|
|
if which:
|
|
return which
|
|
pattern = os.path.expanduser(
|
|
"~/.local/share/flatpak/app/org.qgis.qgis/x86_64/stable/*/files/bin/proj"
|
|
)
|
|
matches = sorted(glob.glob(pattern))
|
|
if matches:
|
|
return matches[0]
|
|
raise SystemExit(
|
|
"no PROJ binary found. Set PROJ_BIN, or install pyproj, or use the QGIS flatpak."
|
|
)
|
|
|
|
|
|
def project_batch(points, proj_bin):
|
|
"""WGS84 (lat, lon) -> EPSG:26911 (E, N) for a list of points, one PROJ call."""
|
|
app_files = os.path.dirname(os.path.dirname(proj_bin))
|
|
env = dict(os.environ)
|
|
env["LD_LIBRARY_PATH"] = f"{app_files}/lib:{env.get('LD_LIBRARY_PATH', '')}"
|
|
payload = "\n".join(f"{lon:.8f} {lat:.8f}" for lat, lon in points) + "\n"
|
|
result = subprocess.run(
|
|
[proj_bin, "+proj=utm", "+zone=11", "+datum=NAD83", "-f", "%.4f"],
|
|
input=payload,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
check=True,
|
|
)
|
|
out = []
|
|
for line in result.stdout.strip().splitlines():
|
|
parts = line.split()
|
|
out.append((float(parts[0]), float(parts[1])))
|
|
if len(out) != len(points):
|
|
raise SystemExit(f"PROJ returned {len(out)} points for {len(points)} inputs")
|
|
return out
|
|
|
|
|
|
class Dem:
|
|
def __init__(self, path):
|
|
entries, width, height, values = dem_stats.read_tiff(path)
|
|
self.w, self.h, self.v = width, height, values
|
|
scale = entries[33550]
|
|
tie = entries[33922]
|
|
self.x0 = tie[3] - tie[0] * scale[0]
|
|
self.y0 = tie[4] + tie[1] * scale[1]
|
|
self.sx, self.sy = scale[0], scale[1]
|
|
nodata = entries.get(42113)
|
|
self.nodata = float(nodata) if nodata not in (None, "") else None
|
|
|
|
def at(self, e, n):
|
|
"""Bilinear sample; returns None outside the raster or on nodata."""
|
|
col = (e - self.x0) / self.sx - 0.5
|
|
row = (self.y0 - n) / self.sy - 0.5
|
|
c0, r0 = int(col // 1), int(row // 1)
|
|
if c0 < 0 or r0 < 0 or c0 + 1 >= self.w or r0 + 1 >= self.h:
|
|
return None
|
|
fx, fy = col - c0, row - r0
|
|
p = []
|
|
for r, c in ((r0, c0), (r0, c0 + 1), (r0 + 1, c0), (r0 + 1, c0 + 1)):
|
|
value = self.v[r * self.w + c]
|
|
if self.nodata is not None and value == self.nodata:
|
|
return None
|
|
p.append(value)
|
|
return (
|
|
p[0] * (1 - fx) * (1 - fy)
|
|
+ p[1] * fx * (1 - fy)
|
|
+ p[2] * (1 - fx) * fy
|
|
+ p[3] * fx * fy
|
|
)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("osm")
|
|
ap.add_argument("dem")
|
|
ap.add_argument("--blvd", default="Hollywood Boulevard")
|
|
ap.add_argument("--west", default="North Orange Drive")
|
|
ap.add_argument("--east", default="North Highland Avenue")
|
|
ap.add_argument("--step", type=float, default=0.5, help="sampling step in metres")
|
|
ap.add_argument("--reach", type=float, default=18.0, help="sample +/- this many metres")
|
|
ap.add_argument("--section", type=float, action="append", default=[],
|
|
help="position along the block in metres; repeatable. Default: every 40 m")
|
|
args = ap.parse_args()
|
|
|
|
nodes, ways = oc.load(args.osm)
|
|
line, lat0, lon0, lengths, block_len = oc.block_extent(
|
|
nodes, ways, args.blvd, args.west, args.east
|
|
)
|
|
dem = Dem(args.dem)
|
|
proj_bin = find_proj()
|
|
|
|
sections = args.section or [40.0 * i for i in range(8) if 40.0 * i <= block_len]
|
|
offsets = []
|
|
x = -args.reach
|
|
while x <= args.reach + 1e-9:
|
|
offsets.append(round(x, 2))
|
|
x += args.step
|
|
|
|
# build every sample point, project them in one batch
|
|
grid = []
|
|
for s in sections:
|
|
row = []
|
|
for off in offsets:
|
|
row.append(oc.point_at(line, lengths, s, off, lat0, lon0))
|
|
grid.append(row)
|
|
flat = [p for row in grid for p in row]
|
|
projected = project_batch(flat, proj_bin)
|
|
|
|
print(f"block {block_len:.1f} m, {len(sections)} sections, "
|
|
f"offsets {offsets[0]:+.1f}..{offsets[-1]:+.1f} m at {args.step} m\n")
|
|
|
|
index = 0
|
|
for s, row in zip(sections, grid):
|
|
heights = []
|
|
for _ in row:
|
|
e, n = projected[index]
|
|
index += 1
|
|
heights.append(dem.at(e, n))
|
|
|
|
valid = [(o, h) for o, h in zip(offsets, heights) if h is not None]
|
|
if not valid:
|
|
print(f"s={s:6.1f} m: no DEM data")
|
|
continue
|
|
|
|
hs = [h for _, h in valid]
|
|
high_off, high_h = max(valid, key=lambda t: t[1])
|
|
low_off, low_h = min(valid, key=lambda t: t[1])
|
|
cross_fall = high_h - low_h
|
|
|
|
# sharpest break in the profile = candidate kerb
|
|
breaks = []
|
|
for (o1, h1), (o2, h2) in zip(valid, valid[1:]):
|
|
slope = abs(h2 - h1) / max(1e-9, abs(o2 - o1))
|
|
breaks.append((slope, (o1 + o2) / 2, h2 - h1))
|
|
breaks.sort(reverse=True)
|
|
top = breaks[:3]
|
|
|
|
print(f"s={s:6.1f} m crown {high_h:.2f} m at {high_off:+.1f} "
|
|
f"low {low_h:.2f} m at {low_off:+.1f} cross-fall {cross_fall:.2f} m")
|
|
print(" profile (2 m): " + " ".join(
|
|
f"{o:+.0f}:{h:.2f}" for o, h in valid if abs(o % 2) < 1e-6))
|
|
print(" sharpest breaks: " + ", ".join(
|
|
f"{slope:.2f} m/m at {off:+.1f} m ({dh:+.2f} m)" for slope, off, dh in top))
|
|
print()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|