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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions