Panning or zooming the map quickly on the PicoCalc could stop the whole app
for many seconds. A watchdog run on the device found two causes behind it,
plus the port walk fixed in the previous commit.
Memory. A decoded tile holds 1 to 4.6 MB of RAM on the device (65 bytes a
point), not the 0.9 MB the code assumed. The source kept up to 24 of them,
counted as tiles, and the map screen kept 8 or more of its own on top, so a
few pans and a zoom out filled a 104 MB device. Swap is on the SD card, so
the process then stopped while the kernel read its code back in: 16,000
major page faults and a 15 s freeze in one run. Now the source holds decoded
tiles to a byte budget, one eighth of the machine's RAM (12.8 MB on the
PicoCalc, at most 128 MB on a desktop), weighed by mvt.resident_bytes. That
estimate was fitted against tracemalloc on the device's 157 cached tiles: 1%
under to 9% over there, and 6% under to 21% over on a 64-bit desktop. The
screen keeps only the tiles in view, and takes any tile already in RAM
straight from the source on the paint (BasemapSource.resident), with no trip
through a thread. The prefetcher warms the disk cache (BasemapSource.warm)
instead of filling the RAM with guesses, and passes over any tile already
written down. Tile loads take turns, two at a time, and a tile the view has
left before its turn comes is dropped, not loaded.
CPU. fill_polygon asked every edge about every scanline, which at z7 means
coastlines with tens of thousands of edges: 6.8 s of a 7.6 s frame. Each
edge is now filed under only the rows it crosses, and draws the same dots
(checked against the old fill on 3,000 random polygons, and in a test).
_draw_tile also tests each ring's bounds before projecting it, and skips a
ring wholly outside the view. That changes no dot: 1,500 frames from the
device's tiles match rendering with no culling at all. The one frame that
differed from HEAD was HEAD's own building cull, which dropped a footprint
whose fill rounds onto the canvas edge.
Measured on the PicoCalc with the profile-picocalc map_burst tour (keys sent
at 10 a second, from a pinned z10 view, real radio and database). The new
code ran first, so the baseline had the warmer tile cache:
pans, catch-up after a burst HEAD 1.0-1.8 s now 0.4-0.6 s
longest silence, whole tour HEAD 11.6 s now 0.6 s
whole-process freezes HEAD 8.0, 3.1, 2.1, 11.6 s
now one, 1.6 s (idle pages swapped back in)
swap at its peak HEAD 25 MB now 7 MB
warm raster, z7 / z8 / z9 HEAD 7.6 / 1.9 / 1.8 s
now 0.57 / 0.29 / 0.81 s
warm raster, z10 / z13 HEAD 0.49 / 0.56 s now 0.26 / 0.26 s
Still open: z9 spends 0.23 s drawing lines; a prefetch decode of a heavy z7
tile is still 1.3 to 1.7 s on a worker thread; and pages swapped out while
the map sits idle can still cost a second or two to come back.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
444 lines
17 KiB
Python
444 lines
17 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""A tiny, dependency-free Mapbox Vector Tile (MVT) decoder.
|
|
|
|
Vector tiles are Protocol-Buffer messages (spec: github.com/mapbox/vector-tile-spec). Rather
|
|
than pull in a protobuf runtime and a geometry library, this decodes the small subset the map
|
|
needs by hand: the protobuf wire format (varints, length-delimited fields) and the MVT
|
|
geometry command encoding (MoveTo/LineTo/ClosePath with zig-zag deltas). It is pure and
|
|
offline — the network fetch lives in :mod:`meshterm.services.basemap`.
|
|
|
|
The result is a list of :class:`Layer`, each with decoded :class:`Feature` geometry in
|
|
*tile-local* integer coordinates (``0..extent``) and a resolved ``tags`` dict, ready to be
|
|
projected to the screen by :class:`meshterm.core.geo.Viewport`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gzip
|
|
import marshal
|
|
import struct
|
|
from collections.abc import Container
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
# MVT geometry types (Feature.type).
|
|
GEOM_POINT = 1
|
|
GEOM_LINE = 2
|
|
GEOM_POLYGON = 3
|
|
|
|
# Geometry command ids (low 3 bits of a command integer).
|
|
_CMD_MOVE_TO = 1
|
|
_CMD_LINE_TO = 2
|
|
_CMD_CLOSE = 7
|
|
|
|
|
|
class _Reader:
|
|
"""A minimal protobuf wire-format reader over a byte buffer."""
|
|
|
|
__slots__ = ("b", "i")
|
|
|
|
def __init__(self, b: bytes) -> None:
|
|
self.b = b
|
|
self.i = 0
|
|
|
|
def eof(self) -> bool:
|
|
return self.i >= len(self.b)
|
|
|
|
def varint(self) -> int:
|
|
"""Read a base-128 varint."""
|
|
result = shift = 0
|
|
while True:
|
|
byte = self.b[self.i]
|
|
self.i += 1
|
|
result |= (byte & 0x7F) << shift
|
|
if not byte & 0x80:
|
|
return result
|
|
shift += 7
|
|
|
|
def tag(self) -> tuple[int, int]:
|
|
"""Read a field tag, returning ``(field_number, wire_type)``."""
|
|
key = self.varint()
|
|
return key >> 3, key & 0x7
|
|
|
|
def blob(self) -> bytes:
|
|
"""Read a length-delimited byte string."""
|
|
n = self.varint()
|
|
v = self.b[self.i : self.i + n]
|
|
self.i += n
|
|
return v
|
|
|
|
def skip(self, wire_type: int) -> None:
|
|
"""Skip a field of the given wire type."""
|
|
if wire_type == 0:
|
|
self.varint()
|
|
elif wire_type == 2:
|
|
# Step over the bytes rather than slicing them out: skipping is how the
|
|
# decoder walks past everything it doesn't want, and a discarded copy of
|
|
# every feature in an undrawn layer is the bulk of that cost. The length
|
|
# must land in a temporary first — ``self.i += self.varint()`` would add to
|
|
# the offset as it was *before* the varint moved it.
|
|
n = self.varint()
|
|
self.i += n
|
|
elif wire_type == 5:
|
|
self.i += 4
|
|
elif wire_type == 1:
|
|
self.i += 8
|
|
else: # pragma: no cover - groups are obsolete and never appear in MVT
|
|
raise ValueError(f"unsupported wire type {wire_type}")
|
|
|
|
|
|
def _zigzag(n: int) -> int:
|
|
"""Decode a protobuf zig-zag encoded signed integer."""
|
|
return (n >> 1) ^ -(n & 1)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Feature:
|
|
"""One decoded vector-tile feature.
|
|
|
|
Attributes:
|
|
geom_type: :data:`GEOM_POINT`, :data:`GEOM_LINE`, or :data:`GEOM_POLYGON`.
|
|
rings: The geometry as a list of parts, each a list of ``(x, y)`` integer points in
|
|
tile-local coordinates (``0..extent``). Points have one part holding every point;
|
|
lines/polygons have one part per line-string / ring.
|
|
tags: Resolved attribute dict (e.g. ``{"class": "primary", "name": "Rue X"}``).
|
|
"""
|
|
|
|
geom_type: int
|
|
rings: list[list[tuple[int, int]]]
|
|
tags: dict[str, Any]
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
"""Return a tag value, or ``default`` if absent."""
|
|
return self.tags.get(key, default)
|
|
|
|
@property
|
|
def name(self) -> str | None:
|
|
"""The feature's display name, preferring a romanized form over the local script.
|
|
|
|
The terminal map draws labels in a fixed-width cell grid with whatever font the
|
|
user has, so a local-script name (CJK, Arabic, Thai…) tends to render as tofu or,
|
|
being double-width, shove the row out of alignment. OpenMapTiles ships a
|
|
transliterated ``name:latin`` (and often ``name:en``) beside the local ``name``, so
|
|
prefer those; fall back to the local ``name`` only when no latin form exists.
|
|
"""
|
|
for key in ("name:latin", "name:en", "name_en", "name_int", "name"):
|
|
val = self.tags.get(key)
|
|
if val:
|
|
return str(val)
|
|
return None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Layer:
|
|
"""A named vector-tile layer and its decoded features.
|
|
|
|
Attributes:
|
|
name: Layer id (e.g. ``transportation``, ``water``, ``place``).
|
|
extent: The tile's internal coordinate extent (typically 4096).
|
|
features: The decoded features.
|
|
"""
|
|
|
|
name: str
|
|
extent: int
|
|
features: list[Feature] = field(default_factory=list)
|
|
|
|
|
|
def _decode_geometry(cmds: list[int]) -> list[list[tuple[int, int]]]:
|
|
"""Decode an MVT geometry command stream into a list of point rings."""
|
|
rings: list[list[tuple[int, int]]] = []
|
|
current: list[tuple[int, int]] = []
|
|
x = y = 0
|
|
i = 0
|
|
n = len(cmds)
|
|
while i < n:
|
|
command = cmds[i]
|
|
i += 1
|
|
cmd_id = command & 0x7
|
|
count = command >> 3
|
|
if cmd_id == _CMD_MOVE_TO:
|
|
for _ in range(count):
|
|
x += _zigzag(cmds[i])
|
|
y += _zigzag(cmds[i + 1])
|
|
i += 2
|
|
# Each MoveTo starts a new part (multipoint keeps them in one part below).
|
|
if current:
|
|
rings.append(current)
|
|
current = [(x, y)]
|
|
elif cmd_id == _CMD_LINE_TO:
|
|
for _ in range(count):
|
|
x += _zigzag(cmds[i])
|
|
y += _zigzag(cmds[i + 1])
|
|
i += 2
|
|
current.append((x, y))
|
|
elif cmd_id == _CMD_CLOSE:
|
|
if current:
|
|
current.append(current[0]) # close the ring back to its start
|
|
if current:
|
|
rings.append(current)
|
|
return rings
|
|
|
|
|
|
def _decode_value(buf: bytes) -> Any:
|
|
"""Decode an MVT attribute Value message into a Python scalar."""
|
|
r = _Reader(buf)
|
|
while not r.eof():
|
|
field_no, wire = r.tag()
|
|
if field_no == 1 and wire == 2:
|
|
return r.blob().decode("utf-8", "ignore") # string_value
|
|
if field_no == 2 and wire == 5: # float_value
|
|
import struct
|
|
|
|
v = struct.unpack("<f", r.b[r.i : r.i + 4])[0]
|
|
r.i += 4
|
|
return v
|
|
if field_no == 3 and wire == 1: # double_value
|
|
import struct
|
|
|
|
v = struct.unpack("<d", r.b[r.i : r.i + 8])[0]
|
|
r.i += 8
|
|
return v
|
|
if field_no in (4, 5) and wire == 0: # int_value / uint_value
|
|
return r.varint()
|
|
if field_no == 6 and wire == 0: # sint_value
|
|
return _zigzag(r.varint())
|
|
if field_no == 7 and wire == 0: # bool_value
|
|
return bool(r.varint())
|
|
r.skip(wire)
|
|
return None
|
|
|
|
|
|
def _packed_uints(buf: bytes) -> list[int]:
|
|
"""Decode a packed repeated uint32 field into a list of ints."""
|
|
r = _Reader(buf)
|
|
out: list[int] = []
|
|
while not r.eof():
|
|
out.append(r.varint())
|
|
return out
|
|
|
|
|
|
def _decode_feature(buf: bytes, keys: list[str], values: list[Any]) -> Feature | None:
|
|
"""Decode a single Feature message, resolving its tags against the layer's key/value pools."""
|
|
r = _Reader(buf)
|
|
geom_type = 0
|
|
tag_ints: list[int] = []
|
|
geom_ints: list[int] = []
|
|
while not r.eof():
|
|
field_no, wire = r.tag()
|
|
if field_no == 2 and wire == 2:
|
|
tag_ints = _packed_uints(r.blob())
|
|
elif field_no == 3 and wire == 0:
|
|
geom_type = r.varint()
|
|
elif field_no == 4 and wire == 2:
|
|
geom_ints = _packed_uints(r.blob())
|
|
else:
|
|
r.skip(wire)
|
|
if not geom_ints:
|
|
return None
|
|
tags: dict[str, Any] = {}
|
|
# A malformed tile may carry an odd tag list; drop the dangling key rather than
|
|
# raise — the rest of this decoder is deliberately lenient about bad tiles.
|
|
for k, v in zip(tag_ints[0::2], tag_ints[1::2], strict=False):
|
|
if 0 <= k < len(keys) and 0 <= v < len(values):
|
|
tags[keys[k]] = values[v]
|
|
return Feature(geom_type=geom_type, rings=_decode_geometry(geom_ints), tags=tags)
|
|
|
|
|
|
def _decode_layer(buf: bytes, wanted: Container[str] | None = None) -> Layer:
|
|
"""Decode a single Layer message, and its features unless the caller doesn't want them.
|
|
|
|
The name arrives in field 1, which in practice is the first thing written, so once it
|
|
has been read the walk already knows whether any of the rest is worth keeping — from
|
|
there an unwanted layer is stepped over rather than copied out. Protobuf permits any
|
|
field order though, so ``skipping`` is only ever an optimisation: the decision is the
|
|
check after the loop, which is correct however the fields arrived (and covers a layer
|
|
that declares no name at all).
|
|
|
|
Args:
|
|
buf: The Layer message's bytes.
|
|
wanted: Layer names worth decoding features for; ``None`` decodes every layer.
|
|
|
|
Returns:
|
|
The layer. One the caller didn't ask for comes back named and empty.
|
|
"""
|
|
r = _Reader(buf)
|
|
name = ""
|
|
extent = 4096
|
|
keys: list[str] = []
|
|
values: list[Any] = []
|
|
feature_blobs: list[bytes] = []
|
|
skipping = False
|
|
while not r.eof():
|
|
field_no, wire = r.tag()
|
|
if field_no == 1 and wire == 2:
|
|
name = r.blob().decode("utf-8", "ignore")
|
|
skipping = wanted is not None and name not in wanted
|
|
elif field_no == 5 and wire == 0:
|
|
extent = r.varint()
|
|
elif skipping:
|
|
r.skip(wire)
|
|
elif field_no == 2 and wire == 2:
|
|
feature_blobs.append(r.blob())
|
|
elif field_no == 3 and wire == 2:
|
|
keys.append(r.blob().decode("utf-8", "ignore"))
|
|
elif field_no == 4 and wire == 2:
|
|
values.append(_decode_value(r.blob()))
|
|
else:
|
|
r.skip(wire)
|
|
layer = Layer(name=name, extent=extent)
|
|
if wanted is not None and name not in wanted:
|
|
return layer
|
|
for blob in feature_blobs:
|
|
feat = _decode_feature(blob, keys, values)
|
|
if feat is not None:
|
|
layer.features.append(feat)
|
|
return layer
|
|
|
|
|
|
def decode_tile(data: bytes, *, layers: Container[str] | None = None) -> list[Layer]:
|
|
"""Decode a vector tile (optionally gzip-compressed) into its layers.
|
|
|
|
Args:
|
|
data: Raw ``.pbf`` bytes, gzip-compressed or not.
|
|
layers: When given, only layers whose name is in it are decoded. The others are
|
|
still returned — named, with their extent, and no features — so the result
|
|
still describes the whole tile and a caller can tell a real tile from junk
|
|
without paying for geometry it will never draw. ``None`` decodes everything.
|
|
|
|
Returns:
|
|
The tile's layers, in the order they appear in it. An empty tile yields an empty
|
|
list rather than raising; truncated or junk bytes raise, which is what lets
|
|
:class:`~meshterm.services.basemap.BasemapSource` tell a corrupt cache entry from
|
|
a tile that simply holds nothing it draws.
|
|
"""
|
|
if data[:2] == b"\x1f\x8b":
|
|
data = gzip.decompress(data)
|
|
r = _Reader(data)
|
|
out: list[Layer] = []
|
|
while not r.eof():
|
|
field_no, wire = r.tag()
|
|
if field_no == 3 and wire == 2: # Tile.layers
|
|
out.append(_decode_layer(r.blob(), layers))
|
|
else:
|
|
r.skip(wire)
|
|
return out
|
|
|
|
|
|
# -- the decoded form, for callers that would rather not decode twice ------------------
|
|
|
|
#: Bumped whenever :func:`dumps_layers` changes the shape it writes.
|
|
_WIRE_VERSION = 1
|
|
|
|
|
|
def dumps_layers(layers: list[Layer], *, stamp: str = "") -> bytes:
|
|
"""Serialise decoded layers to bytes that :func:`loads_layers` can restore.
|
|
|
|
Decoding a vector tile is by far the most expensive thing this app does (a 153 KB
|
|
tile costs ~365 ms on the PicoCalc's Cortex-A7 even after the layer narrowing), and
|
|
the result is a pure function of the bytes and the layer set. Writing it down means a
|
|
later session pays a ``marshal`` load and some object construction — measured ~14x
|
|
cheaper — instead of parsing the protobuf again.
|
|
|
|
``marshal`` is the format because it is stdlib, C-speed, and understands the plain
|
|
tuples/lists/dicts/scalars a decoded tile reduces to. It is deliberately *not*
|
|
``pickle``: this reads a file off disk, and marshal cannot be made to import a module
|
|
or call a constructor. It is still only safe against data we wrote ourselves, which
|
|
is why the cache lives under the app's own directory and why every load is guarded —
|
|
see :func:`loads_layers`.
|
|
|
|
Args:
|
|
layers: The decoded layers to write down.
|
|
stamp: An opaque caller token describing *how* these were decoded (the layer set,
|
|
typically). :func:`loads_layers` refuses a blob whose stamp differs, which is
|
|
what stops a narrowed decode from being served to a caller that wants more.
|
|
|
|
Returns:
|
|
The encoded bytes.
|
|
"""
|
|
payload = [
|
|
(layer.name, layer.extent, [(f.geom_type, f.rings, f.tags) for f in layer.features])
|
|
for layer in layers
|
|
]
|
|
return marshal.dumps((_WIRE_VERSION, marshal.version, stamp, payload))
|
|
|
|
|
|
def loads_layers(blob: bytes, *, stamp: str = "") -> list[Layer] | None:
|
|
"""Restore layers written by :func:`dumps_layers`, or ``None`` if they can't be used.
|
|
|
|
Every way the blob can fail to be what this build expects — a bumped wire version, a
|
|
Python whose ``marshal`` writes a different format, a different layer set, a file
|
|
truncated by a power cut — resolves to ``None``, meaning "decode the tile again".
|
|
Nothing here raises into the map: a derived cache that can't be read is not an error,
|
|
it is just a cache miss.
|
|
|
|
Args:
|
|
blob: Bytes previously produced by :func:`dumps_layers`.
|
|
stamp: The token the caller expects; a blob written under any other is rejected.
|
|
|
|
Returns:
|
|
The layers, or ``None`` to mean "re-decode".
|
|
"""
|
|
try:
|
|
version, marshal_version, written_stamp, payload = marshal.loads(blob)
|
|
if (version, marshal_version, written_stamp) != (_WIRE_VERSION, marshal.version, stamp):
|
|
return None
|
|
return [
|
|
Layer(
|
|
name=name,
|
|
extent=extent,
|
|
features=[Feature(geom_type=g, rings=r, tags=t) for g, r, t in feats],
|
|
)
|
|
for name, extent, feats in payload
|
|
]
|
|
except Exception: # noqa: BLE001 - any malformed blob is simply a cache miss
|
|
return None
|
|
|
|
|
|
# -- what a decoded tile weighs ------------------------------------------------------
|
|
|
|
#: What a decoded tile costs in RAM, in pointer widths: per point (its ``(x, y)`` tuple,
|
|
#: the ints in it, its slot in the ring), per feature (the object, its tag dict, its ring
|
|
#: lists), and per tag. Fitted by least squares against ``tracemalloc`` over every tile in
|
|
#: the PicoCalc's cache (32-bit) and the same tiles decoded on a 64-bit desktop, which came
|
|
#: out at the same counts of words to within a few percent: 15-16 a point, 42-44 a
|
|
#: feature, 1.5-2 a tag.
|
|
_POINT_WORDS = 16
|
|
_FEATURE_WORDS = 44
|
|
_TAG_WORDS = 2
|
|
|
|
#: A pointer, in bytes — the unit the above are counted in. A tag's string *value* costs
|
|
#: half of one per UTF-8 byte (2.1 bytes measured on the device, 3.4 on the desktop).
|
|
_WORD = struct.calcsize("P")
|
|
|
|
|
|
def resident_bytes(layers: list[Layer]) -> int:
|
|
"""Roughly how much RAM ``layers`` hold, for a cache that must budget in bytes.
|
|
|
|
A tile's weight is no use counted in *tiles*: on the PicoCalc a decoded tile runs from
|
|
1 MB (a suburb at z10) to 4.6 MB (a coastline at z7), and a cache bounded by count held
|
|
50-odd MB of them on a 100 MB device, which ended in the SD-card swap — the map frozen
|
|
for fifteen seconds while the kernel paged the interpreter back in.
|
|
|
|
Points alone are not the measure either: a zoomed-out tile is mostly place labels, each
|
|
carrying its name in dozens of languages, and a z2 tile weighing 1.6 MB has almost no
|
|
geometry at all. So the tags count too, and their text. The walk never touches a
|
|
coordinate, so it is cheap beside the decode that produced the layers.
|
|
|
|
An estimate: on the device's 157 cached tiles it lands from 1% under to 9% over what
|
|
``tracemalloc`` measures, and from 6% under to 21% over on a 64-bit desktop.
|
|
"""
|
|
points = features = tags = text = 0
|
|
for layer in layers:
|
|
for feat in layer.features:
|
|
features += 1
|
|
tags += len(feat.tags)
|
|
for value in feat.tags.values():
|
|
if isinstance(value, str):
|
|
# Bytes, not characters: a name in another script is stored wider,
|
|
# and those are what fill a zoomed-out tile.
|
|
text += len(value.encode())
|
|
for ring in feat.rings:
|
|
points += len(ring)
|
|
words = points * _POINT_WORDS + features * _FEATURE_WORDS + tags * _TAG_WORDS
|
|
return words * _WORD + text * _WORD // 2
|