Geo/tools/ is now every geographic script, with Geo/README.md documenting each one; the Blender-side builders moved beside their outputs in Art/blender/*/ and import their helpers from Geo/tools/osm_common.py. That module gains to_unreal_local() and to_blender_local(): the only place in the repository that multiplies metres by 100, so a second silent scale cannot be introduced. block_height_report.py adds height provenance for the block — 3 of 15 buildings have no height data and render at a placeholder 6.4 m. asset_metadata.py writes and audits <asset>.geo.json sidecars; four now exist (hero-facade, terrain, street, blockout) and the audit recomputes each centre from the origin, failing anything that disagrees.
174 lines
6.6 KiB
Python
Executable File
174 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Read a plain float32 GeoTIFF, report its georeferencing and elevation stats.
|
|
|
|
Usage:
|
|
Geo/tools/dem_stats.py Geo/raw/dem-3dep-1m-26911-2026-11-06.tif
|
|
Geo/tools/dem_stats.py <tif> --sample 376515.389,3774224.083
|
|
|
|
Why this exists: GDAL is not runnable on the host (its flatpak libraries do not
|
|
resolve outside the sandbox), so this reads the strip-based GeoTIFF that the
|
|
3DEP export service produces with the standard library only. It handles
|
|
uncompressed, single-sample, strip-organised float32 images with the usual
|
|
GeoTIFF tags; it is a small reader, not a general GeoTIFF implementation.
|
|
|
|
Values are orthometric metres (NAVD88 for 3DEP CONUS). Nodata is excluded from
|
|
the statistics.
|
|
"""
|
|
|
|
import argparse
|
|
import struct
|
|
import sys
|
|
|
|
TYPE_SIZES = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 11: 4, 12: 8, 16: 8}
|
|
|
|
|
|
def read_tiff(path):
|
|
with open(path, "rb") as handle:
|
|
blob = handle.read()
|
|
|
|
if blob[:2] == b"II":
|
|
endian = "<"
|
|
elif blob[:2] == b"MM":
|
|
endian = ">"
|
|
else:
|
|
raise SystemExit("not a TIFF file")
|
|
if struct.unpack(endian + "H", blob[2:4])[0] != 42:
|
|
raise SystemExit("not a classic TIFF (BigTIFF is unsupported)")
|
|
|
|
ifd_offset = struct.unpack(endian + "I", blob[4:8])[0]
|
|
count = struct.unpack(endian + "H", blob[ifd_offset : ifd_offset + 2])[0]
|
|
entries = {}
|
|
for i in range(count):
|
|
base = ifd_offset + 2 + i * 12
|
|
tag, typ, n = struct.unpack(endian + "HHI", blob[base : base + 8])
|
|
size = TYPE_SIZES.get(typ, 1) * n
|
|
raw = blob[base + 8 : base + 8 + 4] if size <= 4 else blob[
|
|
struct.unpack(endian + "I", blob[base + 8 : base + 12])[0] :
|
|
][:size]
|
|
fmt = {1: "B", 3: "H", 4: "I", 7: "B", 11: "f", 12: "d", 2: "c"}.get(typ, "B")
|
|
if typ == 2:
|
|
values = raw[:size].split(b"\0")[0].decode("latin-1")
|
|
elif fmt == "c":
|
|
values = raw
|
|
else:
|
|
values = list(struct.unpack(endian + fmt * n, raw[: struct.calcsize(endian + fmt * n)]))
|
|
entries[tag] = values
|
|
|
|
width = entries[256][0]
|
|
height = entries[257][0]
|
|
bits = entries.get(258, [8])[0]
|
|
compression = entries.get(259, [1])[0]
|
|
sample_format = entries.get(339, [1])[0]
|
|
if compression != 1 or bits != 32 or sample_format != 3:
|
|
raise SystemExit(
|
|
f"unsupported TIFF layout: compression={compression} bits={bits} sample_format={sample_format}"
|
|
)
|
|
|
|
if 322 in entries:
|
|
# tiled layout: tags 322/323 give tile size, 324/325 the offsets and byte counts
|
|
tile_w = entries[322][0]
|
|
tile_h = entries[323][0]
|
|
offsets = entries[324]
|
|
counts = entries[325]
|
|
across = (width + tile_w - 1) // tile_w
|
|
down = (height + tile_h - 1) // tile_h
|
|
if len(offsets) < across * down:
|
|
raise SystemExit(f"tiled TIFF with {len(offsets)} tiles, expected {across * down}")
|
|
grid = [[0.0] * width for _ in range(height)]
|
|
for index, (offset, nbytes) in enumerate(zip(offsets, counts)):
|
|
tile_row, tile_col = divmod(index, across)
|
|
tile = struct.unpack(endian + "f" * (tile_w * tile_h), blob[offset : offset + nbytes])
|
|
for r in range(tile_h):
|
|
target_row = tile_row * tile_h + r
|
|
if target_row >= height:
|
|
break
|
|
base = r * tile_w
|
|
for c in range(tile_w):
|
|
target_col = tile_col * tile_w + c
|
|
if target_col >= width:
|
|
break
|
|
grid[target_row][target_col] = tile[base + c]
|
|
flat = [value for row in grid for value in row]
|
|
return entries, width, height, flat
|
|
|
|
# strip layout
|
|
offsets = entries[273]
|
|
counts = entries[279]
|
|
rows_per_strip = entries.get(278, [height])[0]
|
|
chunks = [blob[offset : offset + nbytes] for offset, nbytes in zip(offsets, counts)]
|
|
pixels = b"".join(chunks)
|
|
values = list(struct.unpack(endian + "f" * (width * height), pixels[: width * height * 4]))
|
|
return entries, width, height, values
|
|
|
|
|
|
def geokeys(entries):
|
|
keys = {}
|
|
raw = entries.get(34735)
|
|
if not raw:
|
|
return keys
|
|
# GeoKeyDirectory: version, revision, minor, count, then 4 shorts per key
|
|
for i in range(4, len(raw), 4):
|
|
key, tag_location, count, value = raw[i : i + 4]
|
|
keys[key] = (tag_location, count, value)
|
|
return keys
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("tif")
|
|
ap.add_argument("--sample", action="append", default=[], help="E,N to sample, repeatable")
|
|
args = ap.parse_args()
|
|
|
|
entries, width, height, values = read_tiff(args.tif)
|
|
scale = entries.get(33550)
|
|
tiepoint = entries.get(33922)
|
|
keys = geokeys(entries)
|
|
nodata = None
|
|
if 42113 in entries:
|
|
try:
|
|
nodata = float(entries[42113])
|
|
except ValueError:
|
|
nodata = None
|
|
|
|
print(f"size: {width} x {height} px, float32, uncompressed")
|
|
if nodata is not None:
|
|
print(f"nodata: {nodata}")
|
|
|
|
valid = [v for v in values if nodata is None or v != nodata]
|
|
if valid:
|
|
print(
|
|
f"elevation: min {min(valid):.2f} m, max {max(valid):.2f} m, "
|
|
f"relief {max(valid) - min(valid):.2f} m, mean {sum(valid)/len(valid):.2f} m"
|
|
)
|
|
print(f"valid pixels: {len(valid)} of {width*height}")
|
|
|
|
if scale and tiepoint:
|
|
sx, sy = scale[0], scale[1]
|
|
px, py, pz, ex, ey = tiepoint[0], tiepoint[1], tiepoint[2], tiepoint[3], tiepoint[4]
|
|
x0 = ex - px * sx
|
|
y0 = ey + py * sy # north-up: tiepoint row 0 is the top edge
|
|
print(f"pixel size: {sx} x {sy} m")
|
|
print(f"extent: E {x0:.1f} .. {x0 + width*sx:.1f}, N {y0 - height*sy:.1f} .. {y0:.1f}")
|
|
crs = keys.get(3072, (None, None, None))[2]
|
|
model = keys.get(1024, (None, None, None))[2]
|
|
print(f"model type: {model} (1 = projected), projected CRS: EPSG:{crs}")
|
|
|
|
for spec in args.sample:
|
|
try:
|
|
e, n = (float(x) for x in spec.split(","))
|
|
except ValueError:
|
|
raise SystemExit(f"bad --sample value: {spec!r}")
|
|
col = int((e - x0) / sx)
|
|
row = int((y0 - n) / sy)
|
|
if not (0 <= col < width and 0 <= row < height):
|
|
print(f" sample E{e:.1f} N{n:.1f}: outside the raster")
|
|
continue
|
|
value = values[row * width + col]
|
|
print(f" sample E{e:.3f} N{n:.3f} -> row {row}, col {col}: {value:.3f} m")
|
|
else:
|
|
print("no pixel scale or tiepoint found; georeferencing missing", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|