Skip to content

ZarrLayer: support band/channel-last arrays ([y, x, band]) — multiscale GeoZarr RGB(A) pyramids fail validateSpatialDimOrder #604

Description

@tylere

ZarrLayer: support band/channel-last arrays ([y, x, band]) — multiscale GeoZarr RGB(A) pyramids fail validateSpatialDimOrder

Summary

ZarrLayer requires the two spatial dimensions to be the last entries in an
array's dimension_names. A GeoZarr store that lays its data out as
[lat, lon, band] (spatial first, channel/band last) — a natural layout for a
pre-rendered RGB(A) pyramid, where each pixel's RGBA is contiguous — cannot be
rendered. prepare/_parseZarr throws before any tile loads.

This blocks an otherwise-valid, georeferenced, multiscale GeoZarr pyramid whose
only "problem" is channel-last axis order.

Environment

  • @developmentseed/deck.gl-zarr 0.6.1 (monorepo developmentseed/deck.gl-raster)
  • zarrita v3 store, consolidated metadata

Reproduction

Public store (Zarr v3, no auth):

https://tessera-embeddings.s3.us-west-2.amazonaws.com/v1.1/cambridge.zarr

The renderable layer is the multiscale RGB pyramid subgroup global_rgb/:

  • Root group attrs declare the zarr-conventions/multiscales v1 layout
    (multiscales: { layout: [{ asset: "0", transform: { scale, translation } }, …] },
    levels 0=native … 9=coarsest) plus the spatial:/proj: conventions
    (proj:code: "EPSG:4326", spatial:bbox: [-180, -90, 180, 90]).
  • Each level is an array at global_rgb/<level>/rgb with:
    • dimension_names: ["lat", "lon", "band"]
    • shape: [H, W, 4] (e.g. level 0 = [1800000, 3600000, 4], level 9 = [3515, 7031, 4])
    • dtype: uint8, chunk_shape: [512, 512, 4]

Minimal driver (pass the global_rgb group as node, with synthesized GeoZarr
metadata whose spatial:dimensions = ["lat", "lon"] and one layout entry
per level pointing at <level>/rgb):

new ZarrLayer({
  node: globalRgbGroup,            // zarr.Group
  metadata: synthesizedGeoZarr,    // multiscales.layout → "<level>/rgb", spatial:dimensions ["lat","lon"]
  selection: { band: null },       // band is the only non-spatial dim
  getTileData,                     // would upload [H, W, 4] uint8 as rgba8unorm
  renderTile,
});

What happens

_parseZarr()validateSpatialDimOrder() throws:

Spatial dims must be the last two entries in dimension_names:
expected ["lat", "lon"] at positions [1, 2], got ["lon", "band"].
Transpose is not implemented yet.

Even if that check were relaxed, a second assumption blocks it — tile sizes are
read from the trailing dims in zarr-layer.js:

const chunkSizes = arrays.map((arr) => ({
  width: arr.chunks[arr.chunks.length - 1],   // = 4 (band), should be 512 (lon)
  height: arr.chunks[arr.chunks.length - 2],  // = 512 (lat), should be 512
}));

So the tiler would derive a 4-px-wide tile from the band chunk.

Why it matters

[y, x, band] (channel-last) is a common interleaved layout for RGB(A) imagery —
each pixel's channels are contiguous, which is exactly what a GPU rgba8unorm
texture upload wants (no transpose, no per-channel gather). A producer writing a
pre-rendered web pyramid will reach for it. Today such stores are unrenderable
through ZarrLayer regardless of being fully GeoZarr-compliant.

Expected

ZarrLayer should render a georeferenced multiscale array whose spatial dims are
not the last two — at minimum the common channel-last RGB(A) case
([y, x, band]).

Possible directions

  1. Honor spatial:dimensions positionally. Use the declared spatial-dim
    indices (yAxisIndex/xAxisIndex) to drive both the sliceSpec and the
    chunkSizes (arr.chunks[yAxisIndex] / arr.chunks[xAxisIndex]) instead of
    assuming len-2 / len-1. The non-spatial dims (and their order) then fall
    out of selection, and getTileData receives the chunk in the store's native
    axis order — the consumer can upload it as-is.
  2. Implement the transpose the error message already anticipates ("Transpose
    is not implemented yet") — at least for a trailing channel dim.
  3. If neither is in scope short-term, a documented escape hatch (e.g. let the
    caller supply chunkSizes / a tile-shape override and skip
    validateSpatialDimOrder) would unblock channel-last sources.

Code pointers (0.6.1)

  • dist/validation.jsvalidateSpatialDimOrder (the throw)
  • dist/zarr-layer.js_parseZarr, chunkSizes = arr.chunks[len-1] / [len-2]

Workaround

None through ZarrLayer without rewriting the store to [band, y, x]. The
alternative is to bypass ZarrLayer and drive RasterTileLayer with a
hand-built AffineTileset descriptor, doing the channel-last chunk upload in a
custom getTileData.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions