Files
gig-line/Geo/tools/block_height_report.py
T
zwitschi 63aa0adf1c feat(geo): cross-check block heights against Mapbox building data
New Geo/tools/mapbox_buildings.py (stdlib urllib) Tilequeries
mapbox-streets-v8 / layer building at each building centroid and merges
height/min_height/type with our OSM height + source (tag > levels > guess,
reusing block_height_report.classify_height). Reports matches, close calls,
disagreements (sorted worst first), our-only and mapbox-only cases, and writes
optional JSON.

First run over 72 buildings: 58 match, 5 close, 3 disagree, 6 non-building.
- Mapbox independently classifies our roof/canopy/shelter volumes as roof,
  canopy or shelter -> corroborates the 0.1.2 finding.
- The 3 disagreements are rear building=yes/apartments structures rendering at
  our 2-level guess (6.4 m) vs Mapbox's 3.0 m.
- Every levels x 3.2 m building is ~0.6 m per 6 levels taller than Mapbox
  implies, i.e. Mapbox suggests 3.1 m per level. Recorded, not applied.

classify_height() now also treats building=canopy as a non-building volume
(found by this cross-check), with tests.

Data handling: Mapbox terms restrict persisting/redistributing their data, so
.gitignore covers Geo/raw/mapbox-cache/ and Geo/verification/*mapbox*; the tool
refuses to cache a response containing the token, and the token is never written
to the cache, report or JSON. Token comes from the ignored .env.

Also: 17 new tests (29 total) for the compare/nearest/centroid/render helpers
and the canopy rule; Geo/README inventory row; architecture 05 + architecture.md
tools table updated per the new upkeep rule.
2026-11-08 16:31:29 +01:00

93 lines
3.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Report which block buildings have real height data and which are guesses.
Usage:
Geo/tools/block_height_report.py Geo/raw/osm-api-map-2026-11-06.osm
`height` and `building:levels` are the only height evidence OSM carries. Where
neither exists, Blosm falls back to `defaultLevels * levelHeight`, which is a
placeholder: the demo renders a constant-height block instead of a real skyline.
This tool lists every building in the demo bounds with its height source, so the
substitutions are visible and measurable rather than buried in the model.
Exit code is 0 either way; the count of guessed buildings is the deliverable.
"""
import argparse
import math
import sys
import osm_common as oc
LEVEL_HEIGHT = 3.2
def classify_height(tags, level_height=LEVEL_HEIGHT):
"""Return (height_m, source) for one OSM way's tags.
Extracted from `main()` so the classification is testable: the roof/shelter
rule and the "height tag beats levels beats guess" order are the parts that
actually decide what the blockout should render.
"""
# Not building volumes: elevated roof/canopy structures and transit shelters.
# Found by cross-checking against Mapbox's building data
# (Geo/tools/mapbox_buildings.py), which returns roof/canopy/shelter for the
# same footprints instead of a storey count.
building = tags.get("building")
if building in ("roof", "canopy") or tags.get("amenity") == "shelter":
kind = building if building in ("roof", "canopy") else "shelter"
return 0.0, f"not a building volume ({kind})"
if tags.get("height"):
return float(tags["height"]), "height tag"
if tags.get("building:levels"):
return (float(tags["building:levels"]) * level_height,
f"levels x {level_height} m")
return 2 * level_height, f"GUESS: default 2 levels x {level_height} m"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("osm")
args = ap.parse_args()
nodes, ways = oc.load(args.osm)
line, lat0, lon0, lengths, block_len = oc.block_extent(
nodes, ways, "Hollywood Boulevard", "North Orange Drive", "North Highland Avenue")
def in_block(pts):
if min(p[1] for p in pts) > line[-1][1] or max(p[1] for p in pts) < line[0][1]:
return False
return True
rows = []
for wid, tags, refs in ways:
if "building" not in tags:
continue
pts = oc.way_points(nodes, refs)
if not pts or not in_block(pts):
continue
height, source = classify_height(tags)
nearest = min(oc.project(p, line, lat0, lon0, lengths)[0] for p in pts)
side = "S" if oc.project(pts[0], line, lat0, lon0, lengths)[2] < 0 else "N"
rows.append((side, nearest, wid, tags.get("name", "") or tags.get("addr:housenumber", ""),
height, source))
rows.sort(key=lambda r: (r[0], r[1]))
guessed = [r for r in rows if r[5].startswith("GUESS")]
print(f"{len(rows)} buildings in the block, {len(guessed)} with a guessed height\n")
for side, setback, wid, name, height, source in rows:
flag = (" <-- guessed" if source.startswith("GUESS")
else (" <-- not a building volume" if source.startswith("not a building") else ""))
print(f"{side} way {wid:>10} setback {setback:5.1f} m height {height:5.1f} m "
f"({source:26}) {name[:34]}{flag}")
if guessed:
print("\nGuessed heights to replace from the reference photography:")
for side, setback, wid, name, height, source in guessed:
print(f" {side} way {wid} {name}")
return 0
if __name__ == "__main__":
sys.exit(main())