feat(geo): adopt Mapbox building calibration (3.1 m per level)

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.
This commit is contained in:
zwitschi committed 2026-11-08 16:38:04 +01:00
1 parent 81f7cd3991
commit 1b22d551fe
37 files changed
+126 -64

No files matched your search

+33 -6
View File
@@ -4,9 +4,21 @@
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.
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.
@@ -19,10 +31,22 @@ import sys
import osm_common as oc
LEVEL_HEIGHT = 3.2
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):
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
@@ -39,6 +63,9 @@ def classify_height(tags, level_height=LEVEL_HEIGHT):
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")
@@ -66,7 +93,7 @@ def main():
pts = oc.way_points(nodes, refs)
if not pts or not in_block(pts):
continue
height, source = classify_height(tags)
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", ""),
+1 -1
View File
@@ -184,7 +184,7 @@ def build_rows(nodes, ways, token, radius, max_distance, delay) -> list[dict]:
lat, lon = centroid_lat_lon(points)
payload, cached = tilequery(lat, lon, token, radius, delay)
feature = nearest_building(payload, max_distance)
ours_height, ours_source = bhr.classify_height(tags)
ours_height, ours_source = bhr.classify_height(tags, way_id=wid)
theirs = None if feature is None else feature.get("height")
verdict, delta = compare(ours_height, ours_source, theirs)
rows.append({