Owner decision: adopt the two findings from the Mapbox cross-check. - LEVEL_HEIGHT 3.2 -> 3.1 m in block_height_report.py and the Blosm --level-height default; every levels-derived building drops ~0.6 m per 6 levels, matching Mapbox. - HEIGHT_OVERRIDES for the three rear building=yes/apartments structures that Mapbox gives as single storey (3.0 m) where our 2-level fallback rendered 6.4 m blocks. - classify_height() takes an optional way_id so overrides apply; the Mapbox cross-check passes it and now reports 66 match / 6 non-building / 0 disagree. - Geometry follows: blockout regenerated at 3.1 (frontage object names preserved, 6/6 exported), frontage re-imported, DemoMap rebuilt, packaged -qaselftest PASS (walk, reset, pause, transect 0 holes, wall probe 0/35). - Docs: Art/blender/README scale table + a note that Blosm adds ~1.9 m roof mass above levels x levelHeight; TODO item closed; MEMORY records the gotcha that blockout regeneration must re-verify the element.* frontage mapping.
120 lines
4.9 KiB
Python
Executable File
120 lines
4.9 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 evidence, in priority order:
|
|
|
|
1. the OSM `height` tag (a direct measurement);
|
|
2. an explicit override in `HEIGHT_OVERRIDES` (currently Mapbox-derived — see below);
|
|
3. `building:levels` x `LEVEL_HEIGHT`;
|
|
4. a flagged placeholder guess.
|
|
|
|
`LEVEL_HEIGHT` is 3.1 m, not the 3.2 m first assumed: cross-checking every block
|
|
building against Mapbox's building data (`Geo/tools/mapbox_buildings.py`) showed
|
|
our `levels x 3.2 m` figure running 0.6 m high per six levels, i.e. 3.1 m per
|
|
level. That calibration was adopted on 2026-11-09.
|
|
|
|
`HEIGHT_OVERRIDES` carries the few buildings where Mapbox disagrees with a guess;
|
|
each entry records why, so the number is not orphaned from its evidence.
|
|
|
|
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.1
|
|
|
|
# Per-way height overrides, applied after the OSM height tag and before the
|
|
# levels/guess derivation. Keep `reason` short but specific: a height without its
|
|
# evidence is a guess wearing a costume.
|
|
HEIGHT_OVERRIDES = {
|
|
# Rear outbuildings tagged only `building=yes`/`apartments`; the two-level
|
|
# fallback rendered them as 6.4 m blocks. Mapbox's building layer gives 3.0 m
|
|
# for the same footprints (single storey plus parapet), adopted 2026-11-09.
|
|
1490910971: (3.0, "Mapbox building layer: 3.0 m (single storey)"),
|
|
1492058875: (3.0, "Mapbox building layer: 3.0 m (single storey)"),
|
|
1492747608: (3.0, "Mapbox building layer: 3.0 m (single storey)"),
|
|
}
|
|
|
|
|
|
def classify_height(tags, level_height=LEVEL_HEIGHT, way_id=None):
|
|
"""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 way_id is not None and int(way_id) in HEIGHT_OVERRIDES:
|
|
height, reason = HEIGHT_OVERRIDES[int(way_id)]
|
|
return height, f"override: {reason}"
|
|
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, way_id=wid)
|
|
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())
|