Skip to content

fix(deck.gl-geotiff): render band-separate (PlanarConfiguration=2) images - #635

Open
thatbudakguy wants to merge 1 commit into
developmentseed:mainfrom
thatbudakguy:fix/band-separate-render
Open

fix(deck.gl-geotiff): render band-separate (PlanarConfiguration=2) images#635
thatbudakguy wants to merge 1 commit into
developmentseed:mainfrom
thatbudakguy:fix/band-separate-render

Conversation

@thatbudakguy

@thatbudakguy thatbudakguy commented Aug 12, 2026

Copy link
Copy Markdown

What

getTileData in createUnormPipeline throws for any COG stored with PlanarConfiguration=2 (GDAL's INTERLEAVE=BAND):

if (array.layout === "band-separate") {
  throw new Error("Band-separate images not yet implemented.");
}

Because it throws per tile inside the tile loader, a COGLayer over such a file draws nothing at all and the only sign is onTileError firing once per tile.

This interleaves the tile instead. toPixelInterleaved in @developmentseed/geotiff already does exactly this — the array helpers just weren't exported from that package's index, although their types were.

Why this came up

A librarian at UMN reported a scanned map that wouldn't display in a viewer I'm working on that uses this library. The file is a 4-band RGBA JPEG COG with an alpha collar, written with -of COG -co COMPRESS=JPEG -co INTERLEAVE=BAND, and the reader handles it fine — only the render pipeline refuses it.

Changes

  • packages/geotiff — export toPixelInterleaved, toBandSeparate, reorderBands, packBandsToRGBA and PackBandsToRGBAOptions from the index. The RasterArray* types were already exported; the functions that produce them weren't.
  • render-pipeline.ts — interleave a band-separate tile before the RGB padding step, rather than throwing. Doing it first means the padding and texture-format logic below see the same shape they would for a pixel-interleaved image, so 3-band INTERLEAVE=BAND is fixed alongside 4-band.
  • geotiff.tsaddAlphaChannel carried a second copy of the same throw, and unlike the pipeline it's part of the public API. It now interleaves too. Its return type narrows from RasterArray to RasterArrayPixelInterleaved, which every path through it has always satisfied; that's what lets the narrowing in getTileData survive the call.
  • examples/vermont-cog-comparison — the narrowed return type makes that example's layout === "band-separate" guard statically unreachable, so it's removed.

Tests

  • tests/render-pipeline.test.tsgetTileData interleaves a 4-band tile into one rgba8unorm texture, and interleaves and pads a 3-band one. The tile is handed in directly rather than read from a fixture: the only band-separate fixture is int8_3band_zstd_block64, and inferRenderPipeline doesn't build a pipeline for non-unsigned samples. Happy to add a uint8 band-separate fixture to geotiff-test-data as a follow-up if you'd like the coverage to be end-to-end.
  • tests/geotiff.test.ts (new) — addAlphaChannel over band-separate RGB and RGBA input, plus the existing pixel-interleaved path.

I also checked the interleave against real data outside the suite: for a band-separate DEFLATE COG built from the reported file, toPixelInterleaved output is byte-identical to geotiff.js's independent readRasters({ interleave: true }) across four full 256×256 tiles (~1M samples), with the alpha channel spanning 0–255 rather than a constant. And with this change the reported COG renders correctly in ogm-viewer, transparent collar included.

pnpm build, pnpm -r --filter='!docs' typecheck, biome ci . and the geotiff / deck.gl-geotiff / deck.gl-raster suites all pass locally. The 17 failures in packages/geotiff/tests/integration-rasterio.test.ts are the .npy reference fixtures that CI generates with pixi run generate-npy; I don't have pixi here, and they fail the same way on a clean checkout.

Note on approach

Uploading one texture per band and sampling a sampler2DArray — the idea in #159 — would avoid the per-tile copy this adds, and is the better long-term shape. It needs the render pipeline to know how many textures a tile has, which is a larger change. This is the fallback path packBandsToRGBA's docstring already describes ("useful as a fallback path when a single 4-channel texture upload is preferred over one texture per band"), and it makes these files render today. Glad to rework it if you'd rather go straight to the texture-array version.

🤖 Generated with Claude Code

…ages

`getTileData` threw "Band-separate images not yet implemented." for any COG
stored with `PlanarConfiguration=2` (GDAL's `INTERLEAVE=BAND`), so the layer
drew nothing and the reason only reached `onTileError`. Interleave the tile
instead, which is what `toPixelInterleaved` in `@developmentseed/geotiff`
already does - it just wasn't exported from that package's index.

`addAlphaChannel` carried the same throw, and is exported; it now interleaves
too, and its return type narrows to `RasterArrayPixelInterleaved`, which it has
always satisfied. That lets the pixel-interleaved narrowing in `getTileData`
survive the call, and makes the layout guard in the vermont-cog-comparison
example dead code.

Uploading one texture per band and sampling a `sampler2DArray` would avoid the
per-tile copy this adds, and remains the better fix (developmentseed#159); this is the fallback
path `packBandsToRGBA` was already documented for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the fix label Aug 12, 2026
@thatbudakguy

Copy link
Copy Markdown
Author

Apologies for the largely AI-generated PR; this came up in some work I was doing and seemed like a straightforward fix.

Let me know if these changes aren't desirable!

thatbudakguy added a commit to OpenGeoMetadata/ogm-viewer that referenced this pull request Aug 12, 2026
Every other preview reports a failure through <ogm-preview>'s alert. A COG
drawn by deck.gl had one path there - a file that refused to be opened, which
rejects preview() - and nothing for the failures that arrive after that. Tiles
come in one at a time, and deck.gl reports one that couldn't be built by
calling onTileError rather than by rejecting anything. Left unhandled, its own
handler logs the tile and the map stays empty: a record zoomed to the right
place with no layer on it and no reason given.

That is what #158 looks like from the outside. The COG in it is stored band-
separate, which the version of @developmentseed/deck.gl-geotiff we build
against refuses outright, so every tile of it fails the same way. The fix for
that file is upstream (developmentseed/deck.gl-raster#635); this is about the
viewer having nothing to say when a COG can't be drawn, whatever the reason.

MapPreviewer gains an onError, which is where a failure that arrives after
preview() has resolved goes. Nothing MapLibre draws needs it - it fires those
on the map itself, and <ogm-map> has listened to that since alerts existed -
so this is for the previews that paint with their own WebGL and have no such
channel. <ogm-map> binds it to the same reportError() a failed load takes,
which already dedupes to one alert per load attempt, so a viewport's worth of
failing tiles reports once rather than forty times. It is bound to the
previewer it came from rather than to whichever is current, because a tile of
the record the user just left would otherwise report against the one that
replaced it.

Only the first failure of a COG that has drawn nothing reaches that alert. A
COG can be sparse by design, and the alert covers the map completely - so a
tile that failed among tiles that didn't would replace a preview the user can
see with an error about a hole in it. Those are logged and left alone.
Aborted reads are dropped rather than logged: deck.gl discards a cancelled
tile before calling back, so a pan that abandons its reads doesn't arrive here
at all, but a decoder that notices the abort itself can still throw one.

The message the user sees is deck.gl's own, through referenceError, which is
cryptic for the band-separate case in particular. Naming it here would mean
matching on an upstream string that the upstream fix removes, so it stays as
it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant