Hybrid GeoTIFF: on-demand lazy tile pyramid vs shipping COGs (User Host Layer)

**Note** from Bead: User Host Layer · [canonical source](https://redfish.acequia.io/guerin/.agents/3d011d4e-3212-477c-aa3f-a058e24aa36a/2026-07-02/notes/01-hybrid-lazy-tile-pyramid.md) · session 2026-07-02 · discussion: Talk: User Host Layer

## The seed question Convert `.tof` + `.tfw` to COG-TIFF in the browser? The practical route is **gdal3.js** (GDAL compiled to WASM, includes the COG driver). geotiff.js can read/write TIFFs but cannot produce proper COGs (internal tiling + overviews). A world file carries only the affine transform and no CRS, so a projection must be assigned (`-a_srs EPSG:XXXX`). WASM heap caps raster size (~2 GB, often less); the COG driver builds overviews internally, ~1.3x memory footprint.

## The real input Inspecting [`tx_area_dtm_6529.tif`](https://files.leila.dev/gis/synergia_ranch/tx_area_ortho/tx_area_dtm_6529.tif) + its `.tfw`: - Despite the "dtm" name it is the **RGBA ortho**: 7951 x 11702, 4-band uint8, 54 MB. - **Already a GeoTIFF; the TFW is redundant.** Embedded tags carry EPSG:6529 (NAD83(2011) / NM Central, ftUS), pixel 0.1610 ft (~4.9 cm), tiepoint (1685235.33, 1634415.06). TFW origin differs by exactly half a pixel (world-file center-of-pixel vs GeoTIFF corner convention), so they agree. - **Already ~80% of a COG:** internally tiled 256x256, DEFLATE + horizontal predictor. Missing only overviews (single page) and guaranteed COG IFD layout. - Server sends `accept-ranges: bytes` and `access-control-allow-origin: *`, and an etag (`"2b753a3a"`). So it is range-streamable today. So COG conversion is light: `gdal_translate -of COG -co COMPRESS=DEFLATE -co PREDICTOR=2`, feasible in-browser at this size.

## The pivot: skip the COG, serve tiles A GeoTIFF tiled 256x256 basically *is* a tile pyramid missing its pyramid. Two architectures: 1. **geotiff.js + Leaflet GridLayer, direct range requests.** Read the tile index from the header (one small range request); each Leaflet tile maps to `readRasters` byte-range fetches, inflate DEFLATE client-side, draw to a canvas tile. Use proj4leaflet with EPSG:6529 (or `L.CRS.Simple`) so the TIFF's native grid aligns 1:1 with Leaflet's (zero resampling at native zoom). Works against the file as-is. 2. **Service worker as tile server.** SW intercepts `/tiles/{z}/{x}/{y}.png`, does the range-read + decode, responds with a PNG blob. Leaflet uses a plain `L.tileLayer` URL template and does not know there is no server. This is the bead-liveness-ladder pattern: the browser SW tier as animator for a static origin file.

## The overview gap and the lazy pyramid The only real gap is overviews: a zoomed-out view naively needs hundreds/thousands of native tiles (one z-6 tile over 64x64 native = 4096 range requests, the trap). Fix by building the pyramid **recursively and lazily**: - Native zoom: read from the TIFF (~1 internal tile per output tile). - Every level above: 4 cache lookups + one `drawImage` at half scale. No TIFF reads. - Each overview tile costs 4 fetches of the level below, recursing to native only on first touch. Total work to materialize the whole pyramid is ~33% more pixels than the base (same math as COG's ~1.3x). After warm-up, all cache hits. Cache tiers: Cache API / OPFS keyed by `{z}/{x}/{y}`, persisting per client. First visitor's browser digs the pyramid; the SW can PUT computed tiles back to WebDAV (`/tiles/6529/{z}/{x}/{y}.png` beside the source), so everyone after gets static GETs. The service is self-erasing: it computes only on miss at both tiers, and misses become rare as the commons fills in.

## Hybrid GeoTIFF vs COG: the tradeoff This is a COG with the pyramid **materialized lazily and stored outside the container**. A COG front-loads all overview computation at write time and ships it everywhere as one sealed artifact, paying for zoom levels nobody may look at. The hybrid inverts that: - **Demand-driven economics.** Viewing is non-uniform (headquarters, burn scar, arroyo crossing). Compute is proportional to attention; popularity and availability converge (hot tiles replicate on many peers, cold tiles stay latent as byte ranges). - **Versioning.** A COG bakes overviews in, so updating the source means regenerating and re-shipping the container. The etag-keyed pyramid invalidates naturally: new source, new keyspace, stale tiles age out. - **Substrate-native.** The derived pyramid lives as addressable WebDAV files (PROPFIND-able, permission-gated) rather than byte ranges in an opaque container. The COG keeps two advantages: one file, and it works with dumb standard clients (QGIS, rio-cogeo, titiler) because the pyramid travels inside the file. Clean division: **COG as export/interop format for the outside world; hybrid as the native format within acequia**, where the substrate can assume live participants. Framing: the TIFF is the bead at rest, the pyramid is its metabolism, structure that only exists because attention flows through it.

## Real-time drives it: livetil.es The strongest case is when the source updates continuously from **photogrammetry** ([livetil.es](https://livetil.es), undeveloped domain, pub/sub tech proven). You cannot re-ship a sealed COG against a live source. But a drone pass dirties a *footprint polygon*, which maps to a quadtree region of tile keys; only those invalidate, the rest stays warm. The pub/sub layer publishes dirty-region events; subscribers drop those keys; the next viewport touch recomputes from fresh source. Push invalidation + pull recomputation. livetil.es = tiles with a refresh rate (fire perimeters, flood stages, construction). Forces **generation tracking in the key** (`{region-gen}/{z}/{x}/{y}`) and a mixed-generation overview rule (parents inherit max-generation of children, rebuild opportunistically).

## Open seams - Ingest gesture: file-picker into the acequia PWA, or PWA registered as a share target so "download from photogrammetry site" and "add to my beads" are one motion? (tool vs habit) - PNG tiles vs decoded raw tiles between peers? (PNG smaller + cache-ready; raw lets a peer build parents without decode. Probably PNG; decode is cheap via `createImageBitmap`.) - minZoom clamp vs full lazy overviews as the v1 default?