Skip to main content

LuRaster: GPU-Resident Raster Analytics

@luma.gl/experimental/luraster provides optional, graph-native WebGPU operations for two-dimensional scientific and geospatial rasters. Applications control image decoding, coordinate transforms, source buffers and textures, output allocation, command submission, and optional readback; an explicitly selected residency cache can upload and own source buffers on their behalf.

GPURasterTileReader validates application-owned, asynchronously decoded raster sources without choosing a network transport, image codec, or GPU uploader. GPURasterTileCache optionally adds explicitly budgeted CPU/GPU tile residency, cancellation-safe loading, and reusable compiled graphs around that reader. GPURasterTileHaloAssembler coordinates cumulative receptive-field planning and fence-safe neighboring tile leases; GPURasterTileHaloFill and GPURasterTileCoreExtract explicitly assemble native samples and publish seam-safe owned cores through the caller's command graph. GPURasterOverview and GPURasterCategoricalOverview separately generate nodata-aware analytical overview values and coverage without mistaking source-selected samples or presentation mipmaps for scientific reductions. Explicit global initialization, tiled statistic merges, stable-domain histogram replay, and bounded percentile selection preserve caller-owned dataset-wide results across separately processed tiles. The reader, cache, and assembler never submit commands.

Current contributors cover raster metadata, explicit texture/buffer conversion, calibrated pointwise band math, normalized difference vegetation index (NDVI), validity-aware histograms, scalar summaries, contrast/gamma/equalization transforms, analytical thresholds, mask-aware neighborhood stencils, direct convolution, separable Gaussian/box smoothing, signed Sobel/Scharr/Laplacian derivatives, gradient magnitude, binary/grayscale dilation, erosion, opening, closing, deterministic connected foreground components, bounded dense region labels and counts, GPU-resident marching-squares contours, indirect vector overlays, and adapter-limit planning. They implement GPUCommandGraphContributor structurally: calling addToGraph(graph) only declares work. No contributor submits commands or reads results back.

Start here

A raster is a rectangular grid of observations, not necessarily a picture. Its cells might contain reflectance, elevation, temperature, or a land-cover category. LuRaster keeps those observations, their missing-data status, and their geographical meaning available to GPU computation instead of flattening them into display colors.

If you need to...Start with...Why
Connect a decoded raster dataset or source-provided overviewGPURasterTileSource and GPURasterTileReaderThe application keeps its existing decoder, transport, and source metadata.
Avoid repeatedly decoding and uploading bounded tilesGPURasterTileCacheExplicit CPU/GPU budgets, eviction, leases, and compatible graph reuse control residency.
Calculate NDVI, arithmetic, or a local distributionGPURasterNDVI, GPURasterBandMath, or GPURasterHistogramOperations preserve scientific values, calibration, and observation validity.
Filter across a tile boundary without seamsGPURasterTileHaloAssembler, GPURasterTileHaloFill, and GPURasterTileCoreExtractReal neighboring pixels are processed, but each output pixel has exactly one owner.
Create a meaningful lower-resolution analytical gridGPURasterOverview or GPURasterCategoricalOverviewContinuous means preserve valid-sample weights; categorical results preserve exact labels.
Calculate one histogram or threshold across many tilesGPURasterGlobalInitialize, GPURasterGlobalStatisticsMerge, and GPURasterGlobalHistogramMergeEvery tile contributes against the same final dataset-wide numerical domain.
Identify connected classified foreground regionsGPURasterConnectedComponentsFour- or eight-neighbor grouping preserves missing-data barriers and publishes an explicit GPU convergence signal.
Obtain contiguous region IDs and a bounded exact component countGPURasterDenseComponentsConverged representative roots become compact deterministic IDs with explicit capacity and overflow.

For a guided explanation of missing observations, grid ownership, replay, resource lifetimes, and common mistakes, read LuRaster Concepts and Execution Model.

What “nodata-aware” means

Nodata means an observation is missing or unusable. It does not mean its value is zero. A cloud can hide a satellite pixel; a sensor can publish a designated sentinel such as -9999; floating-point input can contain NaN; a calculation can encounter an unstable denominator. A nodata-aware operation excludes those observations without discarding meaningful values such as elevation 0, category 0, or valid binary background 0.

LuRaster represents each observation with two independent pieces of information:

sample values: [12, 0, -9999, 18, 0]
validity mask: [ 1, 1, 1, 0, 0]
nodata sentinel: -9999
observation: valid valid missing missing missing

The second observation is a real zero because its validity is 1 and it does not equal the sentinel. The third is missing even though its mask is 1: the raw sample matches the sentinel. The fourth and fifth are missing because their masks are 0, regardless of their stored values. Floating operations additionally reject non-finite observations and results. The resulting validity must travel with the resulting values into later filters, histograms, thresholds, overviews, and other analytical stages.

Tiles, cores, halos, and half-open bounds

A tile is a bounded rectangular subset of a larger raster. Its core is the set of pixels that tile owns and may publish. Bounds are half-open: [0, 4) contains columns 0, 1, 2, and 3, but not column 4.

pixel column: 0 1 2 3 | 4 5 6 7
west owned core: [0, 4)
east owned core: [4, 8)
west halo, radius 1: [0, 5)
east halo, radius 1: [3, 8)

A halo is temporary neighboring coverage needed by an operation whose result depends on nearby samples. Both expanded regions may read columns 3 and 4; only their disjoint cores publish them. This prevents artificial seams without double-counting boundary pixels. Successive neighborhoods accumulate their radii, and true outer dataset edges clip the halo.

Source overviews versus analytical overviews

An overview is a lower-resolution grid covering the same source region. A source overview already exists in application-owned decoded data; LuRaster can select it, but its sampling policy remains the source's responsibility. An analytical overview is explicitly generated on the GPU from resident observations using a documented numerical policy.

For continuous observations, the footprint [10, missing, 30, missing] has mean 20, not 10: only the two valid samples contribute. Its separate sum 40 and count 2 preserve correct weights for further pyramid levels. Categorical labels instead use an explicitly selected nearest-center policy or the most frequent valid label. An invalid nearest-selected sample remains invalid rather than silently substituting a neighbor; category 0 remains valid when its separate mask is 1.

Local results versus replayable global results

A tile-local histogram answers “what values occur in this tile?” A global histogram answers the same question for every selected, disjoint core. Local histograms with independently chosen minimum/maximum values cannot be added: their identically numbered bins represent different numerical intervals.

Replayable means the application can revisit bounded resident or reloaded tiles in multiple explicit phases while caller-owned global outputs remain on the GPU:

  1. Reset the global accumulator once for the intended dataset run.
  2. Visit every owned core to establish the complete global minimum, maximum, count, and sum.
  3. Revisit those same cores and bin every valid value against that finalized global domain.
  4. Optionally derive a GPU-resident approximate percentile or Otsu threshold.

Replay is not a background loader, an automatic second network fetch, or an implicit command submission. The application chooses tile traversal, caching, graph encoding, submission, and completion. Cached tiles can remain GPU-resident across both passes when budgets permit.

CPU and GPU ownership at a glance

StageWhere the work happensWho owns the policy or resource
Read, authenticate, decode, and select source levelsCPU/application-defined transportThe application and its chosen decoder, potentially a future loaders.gl integration.
Upload decoded samplesCPU-to-GPU transferThe application, or an explicitly selected GPURasterTileCache for cache-owned buffers.
Describe analysis and allocate final outputsCPU graph constructionThe application owns imported buffers, graph selection, and published output allocations.
Execute analysis, reductions, and replayGPU compute passesContributors declare graph work; the application encodes and submits it.
Display or inspect resultsGPU rendering; optional GPU-to-CPU transferThe application chooses indirect rendering, compact summaries, or explicit readback.
Release resident tiles and graphsCPU-controlled GPU lifetimeThe application retains leases and creates a completion fence after the final submission.

LuRaster does not ship a GeoTIFF/COG parser, HTTP range transport, reprojection system, or implicit loaders.gl dependency. An application can place loaders.gl or another decoder on the CPU side of GPURasterTileSource; future loaders.gl 5 integration does not change ownership of GPU graphs, uploads, submissions, or fences.

Analytical compute versus image effects

Use an ordinary image effect when the goal is to change how an already rendered image looks. Use LuRaster when the result must remain a scientifically meaningful GPU observation that a later histogram, threshold, filter, contour, or application can consume. Bounded tile residency, reusable graphs, separable filters, and compact scalar summaries can avoid full-image allocation, repeated upload, and unnecessary GPU-to-CPU transfers. Actual speed still depends on tile size, cache behavior, source latency, memory bandwidth, workload, and measured GPU performance; LuRaster does not promise that every analytical operation outruns a visual effect.

Import the optional subpath

import {DrawCommandBuffer, GPUCommandGraph} from '@luma.gl/experimental';
import {
GPURasterBandMath,
GPURasterBoxBlur,
GPURasterCategoricalOverview,
GPURasterClosing,
GPURasterConnectedComponents,
GPURasterContourClassifier,
GPURasterContours,
GPURasterContrast,
GPURasterConvolution,
GPURasterDenseComponents,
GPURasterDilation,
GPURasterErosion,
GPURasterGaussianBlur,
GPURasterGlobalHistogramMerge,
GPURasterGlobalInitialize,
GPURasterGlobalPercentile,
GPURasterGlobalStatisticsMerge,
GPURasterGradient,
GPURasterGradientMagnitude,
GPURasterHistogram,
GPURasterLaplacian,
GPURasterMorphology,
GPURasterNDVI,
GPURasterNeighborhood,
GPURasterOpening,
GPURasterOtsuThreshold,
GPURasterOverview,
GPURasterScharr,
GPURasterSobel,
GPURasterStatistics,
GPURasterThreshold,
GPURasterTileCache,
GPURasterTileCoreExtract,
GPURasterTileHaloAssembler,
GPURasterTileHaloFill,
GPURasterTileReader,
makeRasterOverviewMetadata,
type GPURasterBufferBand,
type GPURasterCategoricalOverviewFormat,
type GPURasterCategoricalOverviewProps,
type GPURasterConnectedComponentsProps,
type GPURasterConnectivity,
type GPURasterDecodedBand,
type GPURasterDenseComponentsProps,
type GPURasterGlobalAccumulator,
type GPURasterGlobalHistogramMergeProps,
type GPURasterGlobalInitializeProps,
type GPURasterGlobalPercentileProps,
type GPURasterGlobalStatisticsMergeProps,
type GPURasterOverviewCategoricalPolicy,
type GPURasterOverviewMetadataOptions,
type GPURasterOverviewProps,
type GPURasterOverviewScale,
type GPURasterTileGraphLease,
type GPURasterTileHaloLease,
type GPURasterTileHaloRequest,
type GPURasterTileHaloSource,
type GPURasterTileLease,
type GPURasterTileRequest,
type GPURasterTileSource
} from '@luma.gl/experimental/luraster';

The ./luraster subpath is an explicit opt-in. Its runtime symbols are not exported from @luma.gl/experimental, and the existing experimental package remains private.

Quick start: analyze valid observations on the GPU

The application supplies a device, raster dimensions, uploaded red and near-infrared bands, and explicitly allocated output views in the same command graph. In this example, createApplicationOwnedRasterViews represents application code; it is not a LuRaster loader or allocation helper.

const graph = new GPUCommandGraph(device, {id: 'vegetation-analysis'});

// Application-defined: import source buffers and allocate every published graph view.
const {
redBand,
nearInfraredBand,
vegetationValues,
vegetationValidity,
histogramBins,
histogramDomain
} = createApplicationOwnedRasterViews(graph, device);

new GPURasterNDVI({
id: 'calibrated-vegetation-index',
width,
height,
red: redBand,
nearInfrared: nearInfraredBand,
output: vegetationValues,
outputValidity: vegetationValidity,
epsilon: 0.0001
}).addToGraph(graph);

const vegetationBand: GPURasterBufferBand<'float32'> = {
id: 'vegetation-index',
format: 'float32',
storage: {kind: 'buffer', values: vegetationValues},
validity: vegetationValidity
};

new GPURasterHistogram({
id: 'valid-vegetation-distribution',
input: vegetationBand,
output: histogramBins,
domainOutput: histogramDomain
}).addToGraph(graph);

const compiled = graph.compile();
const encoder = device.createCommandEncoder();
compiled.encode(encoder, {parameters: undefined});
device.submit(encoder.finish());

The NDVI stage intersects the source masks, rejects raw nodata before calibration, and publishes a separate validity mask. The histogram consumes that mask and derives its domain only from valid index values. Compilation and encoding do not submit work; the final application-owned device.submit does. No raster pixels or histogram values are downloaded unless application code explicitly requests a readback after submission.

Try the Satellite Raster Lab

The Satellite Raster Lab visualizes deterministic synthetic red and near-infrared imagery, GPU-derived NDVI, nodata/cloud masks, and a valid-pixel histogram. Source controls load full, western, or eastern synthetic windows at either the original resolution or an application-provided 2× overview; the displayed tile key, dimensions, level-zero origin, and EPSG:32610 identity follow the actual decoded tile. Layer selection, Gaussian or box smoothing, neighborhood radius, Gaussian sigma, Sobel/Scharr/Laplacian edge operators, signed direction or gradient magnitude, contrast, gamma, manual or automatic Otsu threshold selection, denominator tolerance, and contour levels rebuild the actual GPU analysis pipeline. Morphology controls exercise all four dilation, erosion, opening, and closing operations; binary and grayscale modes; square and Manhattan-diamond footprints; radii zero through eight; strict/ignore nodata; and clamp, reflect, constant, or nodata borders, including the constant value. Grayscale morphology runs before contrast; binary morphology cleans the manual/Otsu threshold mask and exposes its boundary as a contour at 0.5. The displayed raster, histogram, and scalar statistics reflect selected, smoothed, differentiated, morphologically processed, transformed valid pixels; interpolated contour lines are generated and drawn directly from GPU buffers. Only 228 bytes of scalar summaries, histogram bins, cutoff, and contour diagnostics are read back after graph submission. The indirect draw consumes its GPU-written instance count rather than a CPU-supplied count.

Changing to an uncached source window or overview explicitly decodes and uploads its native samples; revisiting a resident window reuses its existing CPU arrays and GPU buffers. The capacity slider exposes deterministic eviction, separate CPU/GPU occupancy, cache hits and misses, active lease pins, and compiled-graph reuse. Equally sized western and eastern windows reuse one compiled graph through per-encoding source-buffer replacement; full-resolution, overview, or differently specialized pipelines use separate compatible graph entries. Rapid changes cancel stale requests without destroying submitted GPU resources. CPU-to-GPU uploads do not increase the unchanged 228-byte GPU-to-CPU summary. Select TILE ONLY to analyze one source tile independently or SEAMLESS HALO to gather real neighboring resident samples, run smoothing, derivatives, and grayscale morphology over the cumulative padded region, and extract only the selected half-open core before displaying its statistics. Cumulative halo, owned-core bounds, and resident-source counts update with analytical controls and the chosen source overview. Choose SOURCE NEAREST to use the adapter's existing nearest-sample 2× overview, or GPU MEAN to generate a validity-aware mean from native source observations; selecting GPU MEAN automatically switches a native-resolution view to its 2× target. MASK NEAREST and MASK MODE become available in GPU-generated mode and select exact categorical coverage policies. Provenance and displayed analytical valid-pixel coverage distinguish the resulting inputs without increasing the 228-byte readback. This visible coverage is the existing post-filter/threshold valid count, not the unretrieved per-parent source contribution count. The current example deliberately makes GPU MEAN and SEAMLESS HALO mutually exclusive: selecting either switches the other back to its compatible source/tile mode. All analytical filters work on generated means, and source-nearest overviews retain their complete seam-safe path; composing native halo assembly before generated overviews remains future demo work. Choose TILE to keep the current window's local analytical domain or FULL GLOBAL to merge both owned synthetic windows into one stable dataset-wide extent, histogram, and GPU Otsu threshold. WEST → EAST and EAST → WEST change the explicit traversal order while preserving the global distribution; tile count, replay count, domain, population, and threshold diagnostics expose real graph work without increasing the existing 228-byte scalar readback. The FULL GLOBAL example supports native or source-nearest overview NDVI/red/near-infrared, contrast, gamma, manual thresholds, and global Otsu. It explicitly resets Gaussian/box smoothing, edge operators, morphology, generated overviews, and seamless halos; enabling any of those unsupported combinations returns to TILE mode. A generated global median reuses the existing four-byte threshold summary slot only when automatic Otsu is inactive.

Choose COMPONENTS to classify the current foreground into actual GPU-resident regions. SPARSE ROOTS preserves their minimum-pixel representative IDs; DENSE 1..N shows their contiguous row-major ranks. 4-CONNECTED and 8-CONNECTED determine whether diagonal touching joins regions, while an explicit component capacity distinguishes the exact required population from its bounded published count. Both presentation modes run the real GPU dense relabeling and count contributor. Sparse coloring remains usable if dense capacity overflows; the dense overlay is suppressed entirely when capacity is insufficient or propagation did not converge. Missing observations remain distinct from valid background.

Region mode operates on the selected local tile, explicitly exits FULL GLOBAL, and disables contours temporarily. Three existing contour diagnostic words carry exact required component count, GPU convergence, and actual rounds; the displayed bounded count and overflow derive from that required count and visible capacity. This does not replace the contributor's genuine caller-owned GPU bounded-count and overflow outputs. The separate foreground readout still counts selected pixels rather than regions. Existing source/analytical overviews, seam-safe local processing, smoothing, derivatives, morphology, manual thresholds, and local Otsu remain available; leaving component mode restores the previous contour policy and the summary remains exactly 228 bytes.

Raster bands and validity

GPURaster combines caller-owned bands with explicit width, height, affine pixel transform, pixel interpretation, optional coordinate-reference-system identity, overview level, and tile origin. Supported scalar formats are float32, uint32, and sint32. A band uses exactly one borrowed representation: a packed GraphDataView buffer or a compatible single-mip texture view.

Band metadata can define:

  • validity: a packed, source-aligned uint32 mask; zero rejects a pixel.
  • noDataValue: a raw source-format sentinel compared before calibration.
  • scale and offset: independent per-band calibration, calibratedValue = rawValue * scale + offset.

Pointwise analysis consumes buffer-backed bands and produces separate caller-owned float32 values and canonical uint32 validity flags. Invalid pixels receive NaN and validity 0; valid pixels receive validity 1. Non-finite raw or calibrated inputs and non-finite results are rejected. Integer nodata comparisons occur before float conversion, preserving exact uint32 and sint32 sentinel identities.

How an observation becomes valid

Interpret a sample in this order:

  1. If a source validity mask exists and its entry is 0, the observation is missing.
  2. If the raw sample equals its band's raw-format noDataValue, it is missing.
  3. Floating contributors reject non-finite raw values before calibration.
  4. Apply the band's own scale and offset to the remaining raw sample.
  5. Reject non-finite calibrated values or an operation-specific invalid result, such as an NDVI denominator whose magnitude does not exceed epsilon.

For example, a uint32 band with noDataValue: 65535, scale: 0.0001, and offset: -1 rejects raw 65535 before conversion or scaling. Raw 0 instead becomes a valid calibrated -1 when its source mask is nonzero. Comparing calibrated values with the raw sentinel, or using truthiness to reject raw zero, would corrupt both observations.

Do not use stored result values as a substitute for their masks:

Stored valueOutput validityMeaning
Floating 01A real observed or calculated zero.
Floating NaN0An invalid floating-point result.
Categorical or binary 01A valid zero-valued category or valid background.
Categorical or binary 00A missing categorical observation.

GPURasterThreshold publishes a classification band, not a complete observation-validity band: both below-threshold background and invalid samples contain zero. Preserve the original observation mask separately when passing threshold values into binary morphology or categorical analysis. Neighborhood operators also have explicit invalid-neighbor policies; “ignore and renormalize” and “propagate missingness” are different scientific choices, not interchangeable rendering effects.

Use GPURasterTextureToBuffer and GPURasterBufferToTexture for explicit representation changes. Texture upload, decoding, and readback are application responsibilities.

Application-owned decoded tile sources

Use GPURasterTileSource when an application obtains raster windows from a decoded GeoTIFF, cloud-optimized GeoTIFF, microscopy container, offline fixture, service endpoint, or another custom image provider. The source implementation, transport, authentication, worker scheduling, decoder licensing, CPU arrays, and upload policy remain entirely application-owned.

GPURasterTileReader validates this interface and each asynchronous decoded result. Unlike GPURaster, whose bands already refer to caller-owned GPU graph resources, a GPURasterDecodedTile holds native CPU typed arrays that the application must upload explicitly. The reader does not allocate GPU buffers, add graph passes, submit commands, cache results, or take ownership of the source.

const source: GPURasterTileSource = {
metadata: {
id: 'application-reflectance',
width: 4096,
height: 3072,
affine: [30, 0, 500000, 0, -30, 4100000],
pixelInterpretation: 'area',
coordinateReferenceSystem: {authority: 'EPSG:32610'},
bands: [
{id: 'red', format: 'uint32', noDataValue: 65535, scale: 0.0001},
{id: 'near-infrared', format: 'uint32', noDataValue: 65535, scale: 0.0001}
],
levels: [
{
level: 0,
width: 4096,
height: 3072,
tileWidth: 512,
tileHeight: 512,
downsample: [1, 1]
},
{
level: 1,
width: 2048,
height: 1536,
tileWidth: 256,
tileHeight: 256,
downsample: [2, 2]
}
]
},
async readTile(request, signal) {
signal.throwIfAborted();
const tile = await applicationOwnedDecoder.readTile(request, signal);
signal.throwIfAborted();
return tile;
}
};

const reader = new GPURasterTileReader(source);
const controller = new AbortController();
const request = {
level: 1,
pixelBounds: [256, 128, 768, 640],
coordinateSpace: 'level-zero',
bandIds: ['red', 'near-infrared']
} satisfies GPURasterTileRequest;
const normalizedRequest = reader.normalizeTileRequest(request);
// normalizedRequest.pixelBounds is [128, 64, 384, 320] in level coordinates.
const decoded = await reader.readTile(normalizedRequest, controller.signal);

// The application uploads decoded.bands and their separate validity arrays into its own graph.

The source metadata describes the complete level-zero raster: dimensions, pixel interpretation, double-precision JavaScript affine coefficients, optional coordinate-reference-system identity, optional level-zero origin, native band descriptors, and explicitly provided overview levels. Each band preserves its scalar format, exact raw-domain nodata sentinel, and optional calibration scale/offset. The source adapter implements readTile(request, signal) and must forward the provided AbortSignal to any application-owned fetch or decoder.

Source windows, tile addressing, and selected bands

GPURasterTileRequest.level selects an explicitly declared source level. Omitting both column and row selects the entire level; providing both addresses one source tile of that level's declared tileWidth and tileHeight. Optional pixelBounds intersects that selection with a half-open [minimumColumn, minimumRow, maximumColumn, maximumRow] window. Ragged right/bottom tiles are clipped to actual level dimensions rather than padded implicitly.

coordinateSpace: 'level', the default, expresses the optional window in the selected level's own pixel grid. coordinateSpace: 'level-zero' expresses it in the dataset's original full-resolution grid. bandIds explicitly selects a subset of declared source bands; omitting it selects every band in source order. Their native formats and metadata are preserved. The Raster Lab requests both red and near-infrared bands because NDVI needs both; other applications can request only the bands required by their own operation.

Each result includes the selected level, tile column/row, half-open pixelBounds in level-local pixels, corresponding levelZeroBounds, fully translated GPURasterMetadata, and the requested decoded bands. Missing or duplicate identifiers, inconsistent dimensions, incompatible typed arrays, malformed masks, or mismatched metadata are rejected rather than silently converted. The reader passes the adapter a normalized request with explicit bandIds, level-local pixelBounds, and coordinateSpace: 'level'; the adapter returns complete matching bounds, translated metadata, and bands in the requested order.

Call reader.normalizeTileRequest(request) when application scheduling, deduplication, or cache identity needs the same validated request that the source adapter will receive. An omitted band list expands to every source band in metadata order; omitted bounds expand to the complete selected level or addressed tile; an omitted coordinate space defaults to 'level'; and equivalent level-zero windows project into the same clipped, half-open level-space bounds. Explicit tile column/row identity and selected band order remain meaningful and are preserved. Invalid requests fail before source decoding or GPU allocation.

Native typed arrays and validity

Decoded source bands retain an exact discriminated scalar representation:

const floatingBand: GPURasterDecodedBand<'float32'> = {
id: 'reflectance',
format: 'float32',
values: new Float32Array(pixelCount),
validity: new Uint32Array(pixelCount)
};

const categoricalBand: GPURasterDecodedBand<'uint32'> = {
id: 'classification',
format: 'uint32',
values: new Uint32Array(pixelCount),
noDataValue: 4294967295
};

const signedBand: GPURasterDecodedBand<'sint32'> = {
id: 'elevation',
format: 'sint32',
values: new Int32Array(pixelCount)
};

Every sample array contains exactly width * height packed, row-major elements; an optional Uint32Array validity mask has the same length. uint32 samples and nodata values remain exact even above 2²⁴; they are not rounded through an intermediate float32. Decoded nodata and calibration remain source-domain metadata. Later GPU contributors reject raw nodata before applying calibration according to their existing documented contracts.

Overview scale, odd dimensions, and spatial metadata

Every GPURasterTileLevel supplies its own positive, potentially anisotropic downsample: [horizontalFactor, verticalFactor]. Level zero must declare [1, 1]. A source overview has exactly ceil(sourceWidth / horizontalFactor) by ceil(sourceHeight / verticalFactor) pixels; odd dimensions retain their ragged final coverage. For example, a 5 × 7 source at [2, 2] has a 3 × 4 overview, while [2, 3] has a 3 × 3 overview. Deriving the factor as 5 / 3 would produce an incorrect affine transform.

A level-zero request maps to overview bounds using floor(start / factor) and ceil(end / factor), then clips them to the requested tile and actual overview dimensions. Overview pixels map back to level-zero coverage with floor(start * factor) and ceil(end * factor), clipped to the source extent. This half-open convention retains partially covered edge pixels instead of dropping or duplicating ragged coverage.

For a level-zero affine [a, b, c, d, e, f], explicit overview factors [sx, sy], and tile origin [column, row] in overview pixels, the decoded tile affine is:

[
a * sx,
b * sy,
c + a * column * sx + b * row * sy,
d * sx,
e * sy,
f + d * column * sx + e * row * sy
]

The tile retains source pixel interpretation, coordinate-reference-system identity, declared level, and level-zero origin; affine translation and metadata remain JavaScript numbers rather than being silently uploaded as low-precision world coordinates. A CRS identifier does not reproject pixels or convert derivatives into physical-world distances.

Cancellation, ownership, and current limits

The optional reader AbortSignal is checked before starting a read, supplied to the application-owned source, and respected if cancellation occurs while a result is pending or before it is published. Cancel superseded application requests when a user quickly changes source window or overview. Cancellation does not destroy application-owned arrays, GPU buffers, or in-flight command submissions; applications remain responsible for their own upload and resource-lifetime boundaries.

This reader contract returns one explicitly requested decoded result. It does not implement an HTTP range transport, GeoTIFF/COG decoder, loader package, GPU upload, or cache by itself. Applications that need bounded upload, residency, eviction, deduplicated concurrent requests, and graph reuse can explicitly compose it with GPURasterTileCache, described below. GPURasterTileHaloAssembler can then acquire the complete neighboring coverage required by an explicitly declared analytical pipeline. None of these reader, cache, or halo objects stitches contour geometry, implicitly merges global tiled statistics, or implicitly generates analytical overviews. Existing source overviews are accepted as source-provided samples; their scientific aggregation policy remains application-owned. Applications explicitly compose GPURasterOverview or GPURasterCategoricalOverview when a verified GPU-generated policy is required, and add the global accumulator contributors explicitly for stable cross-tile statistics.

GPU-generated analytical overviews

Use an existing source-provided overview when its source-side sampling or reduction policy is known, its lower resolution avoids unnecessary decoding or upload, and its analytical meaning is appropriate for the task. Use GPURasterOverview when continuous floating-point observations must be reduced on the GPU without treating nodata as zero, discarding valid-sample weights, or averaging intermediate averages. Use GPURasterCategoricalOverview when classification labels need an explicit nearest-sample or most-frequent-label policy without fractional interpolation.

Generated overviews are distinct from source-provided GPURasterTileReader levels. They do not register new source levels, replace a decoder, select a transport, discover a GeoTIFF pyramid, or move source samples between CPU and GPU. Each contributor adds one explicit compute pass to the caller's command graph and writes only caller-owned output views.

QuestionSource-provided overviewGPU-generated analytical overview
Where do its samples originate?A level the application-owned source already exposes.Existing resident observations in a caller-owned graph.
Who chooses the sampling policy?The source or application; the reader does not infer its meaning.The explicit floating mean or categorical nearest/mode contributor.
Does selecting it create GPU reduction work?No; it requests an existing decoded source level.Yes; the application explicitly adds a bounded compute pass.
Can floating means preserve nodata and future weights?Only if the source itself supplies and documents those semantics.Yes; separate sum, valid count, validity, and mean are published.
Does it become a new decoder/source level automatically?It is already declared in source metadata.No; the generated output remains application-owned graph data.

Display-oriented mipmaps and browser image resampling are not substitutes for either contract: they do not, by themselves, expose exact categorical identity, missing-observation coverage, weighted analytical sums, or the resulting raster's geospatial metadata.

Floating means, nodata, and valid coverage

GPURasterOverview consumes a float32 source band and publishes four separate packed output views: the analytical mean, canonical uint32 validity, calibrated float32 sum, and uint32 valid-sample count.

const generatedMetadata = makeRasterOverviewMetadata(sourceMetadata, [2, 3]);

new GPURasterOverview({
id: 'nodata-aware-reflectance-overview',
metadata: sourceMetadata,
scale: [2, 3],
input: reflectanceBand,
output: overviewValues,
outputValidity: overviewValidity,
sum: overviewSums,
validCount: overviewValidCounts
}).addToGraph(graph);

The reducer first intersects the explicit source validity mask, raw nodata sentinel, and finite raw observation. Source calibration is applied exactly once as rawValue * input.scale + input.offset; non-finite calibrated results are excluded. The parent value is the sum of remaining calibrated observations divided by their valid count, not by the nominal footprint area. A 2 × 2 footprint containing [10, invalid, 30, invalid] therefore publishes sum 40, count 2, mean 20, and validity 1. An entirely invalid footprint publishes sum 0, count 0, validity 0, and a canonical quiet NaN mean.

Odd right and bottom edges use only existing source observations; missing coverage is not zero-padded. Retaining the sum and count separately makes population coverage and correctly weighted downstream pyramid levels available without downloading samples.

Weighted multilevel pyramids

Forward both sum and valid count when reducing an already generated mean. Supply an explicit maximum number of raw observations that any input parent can represent:

new GPURasterOverview({
id: 'weighted-coarser-overview',
metadata: generatedMetadata,
scale: 2,
input: firstOverviewBand,
inputSum: overviewSums,
inputValidCount: overviewValidCounts,
maximumInputValidCount: 6,
output: coarserValues,
outputValidity: coarserValidity,
sum: coarserSums,
validCount: coarserValidCounts
}).addToGraph(graph);

For parent inputs with (sum, count) of (10, 1) and (60, 3), the correct next-level mean is (10 + 60) / (1 + 3) = 17.5; averaging their intermediate means (10 + 20) / 2 = 15 would bias the smaller population. Weighted levels consume the already calibrated sums directly and never apply source calibration a second time.

inputSum and inputValidCount must be supplied together, and weighted mode requires maximumInputValidCount. Construction rejects any configuration whose maximum horizontalScale * verticalScale * maximumInputValidCount could exceed the uint32 count range. An observed child count above its declared bound invalidates the parent instead of wrapping or silently publishing incorrect coverage.

Exact categorical nearest and mode

GPURasterCategoricalOverview preserves either uint32 or sint32 source identities and requires an explicit categorical policy:

new GPURasterCategoricalOverview({
id: 'land-cover-majority-overview',
metadata: sourceMetadata,
scale: [2, 2],
input: landCoverLabels,
policy: 'mode',
output: overviewLabels,
outputValidity: overviewLabelValidity,
validCount: overviewLabelCoverage
}).addToGraph(graph);

Choose 'nearest' when a representative existing sample is required. Even-sized footprints select the upper-left of their central candidates. If that selected observation is masked or matches raw nodata, the result is invalid; it does not silently substitute a different valid label. Choose 'mode' when the most frequent valid label better represents a region. Invalid samples are ignored, and equal frequencies deterministically select the numerically smallest exact label. Negative signed categories and unsigned identifiers above 2²⁴ remain native integers rather than passing through float32.

outputValidity is always explicit. An invalid categorical parent publishes value 0 with validity 0; a valid category whose identity is actually zero remains distinguishable through validity 1. Optional validCount reports the number of valid observations in the complete footprint for either policy, including valid alternatives when a nearest-selected center is itself invalid.

Spatial metadata, grid alignment, and cost

GPURasterOverviewScale accepts an integer or [horizontalScale, verticalScale]; each axis must be between 1 and 8. Output dimensions use independent ceiling division, so a 5 × 7 source at [2, 3] produces 3 × 3 parents. makeRasterOverviewMetadata derives the same target metadata as either contributor without allocating GPU resources:

const metadata = makeRasterOverviewMetadata(sourceMetadata, [2, 3], {
level: 2,
sourcePixelOrigin: [4, 6]
});

For an already positioned source affine [a, b, c, d, e, f], the generated affine is [a * sx, b * sy, c, d * sx, e * sy, f]. Existing source translations c and f stay unchanged, including rotated or sheared grids; source coordinate-reference-system identity, pixel interpretation, and level-zero origin are preserved. The target level defaults to the source level plus one, and an explicitly supplied level must be greater than its source level.

The source origin must align with both reduction axes. When a level-zero source omits sourcePixelOrigin, LuRaster infers its levelZeroOrigin or [0, 0] and rejects globally misaligned translated tiles. A higher-level source with a nonzero levelZeroOrigin must supply sourcePixelOrigin explicitly in its current-level pixel coordinates; its level-zero origin alone cannot safely recover that current-level grid position. An explicit origin verifies alignment only: source metadata already contains the correct affine translation and preserved level-zero origin, so the helper never translates either value a second time.

The floating reduction evaluates up to horizontalScale * verticalScale observations per output pixel. Categorical mode additionally compares bounded candidate frequencies and can cost quadratically more within that footprint. Lower-resolution GPU-resident outputs may reduce work in later histograms, filters, and rendering, but reductions also allocate explicit output/sum/count/mask buffers and add a graph pass. Throughput depends on scale, nodata density, categorical policy, source residency, memory bandwidth, adapter limits, and the measured GPU; neither display-oriented image effects nor ordinary texture mipmaps preserve these analytical validity, weighting, categorical, or affine contracts.

Bounded tile residency and compiled-graph reuse

Use GPURasterTileCache when repeated viewport changes would otherwise decode and upload the same source windows, or when a large dataset must be processed through explicitly bounded GPU tiles. Keep GPURasterTileReader alone when the application already owns a residency policy or only needs a one-off decoded result. The cache owns buffers it uploads; the source, decoder, network transport, graph construction, command encoder, submission, and completion fence remain application-owned.

const cache = new GPURasterTileCache({
device,
reader,
maxTiles: 4,
maxGraphs: 2,
maxCpuBytes: 64 * 1024 * 1024,
maxGpuBytes: 128 * 1024 * 1024
});

const abortController = new AbortController();
const tileLease = await cache.acquire(
{level: 0, column: 0, row: 0, bandIds: ['red', 'near-infrared']},
abortController.signal
);

const graphLease = await cache.acquireGraph(tileLease, {
pipelineKey: 'calibrated-ndvi-histogram',
halo: 0,
estimatedByteLength: 24 * 1024 * 1024,
create: () => {
const analysis = createApplicationOwnedAnalysis(tileLease);
return {
graph: analysis.compiledGraph,
value: analysis,
byteLength: analysis.ownedOutputByteLength,
destroy: () => analysis.destroy()
};
}
});

const red = tileLease.bands.find(band => band.id === 'red')!;
const nearInfrared = tileLease.bands.find(band => band.id === 'near-infrared')!;
const encoder = device.createCommandEncoder();

graphLease.graph.encode(encoder, {
parameters: undefined,
buffers: {
red: red.buffer,
'near-infrared': nearInfrared.buffer,
'source-validity': red.validity!
}
});

device.submit(encoder.finish());
const completionFence = device.createFence();
await Promise.all([
graphLease.releaseAfter(completionFence),
tileLease.releaseAfter(completionFence)
]);

The application must create a completion fence after submitting every command buffer that uses those leases. A WebGPU fence snapshots work already submitted when the fence is created; constructing it before device.submit() cannot protect the later submission. Acquire both leases before encoding, retain them across any encoded-but-unsubmitted interval, and release them only after the relevant post-submit fence resolves. releaseAfter() also accepts a completion Promise<void>. Use immediate release() only when no encoded or submitted GPU work can still reference that tile or graph. If graph creation or encoding fails before submission, release every already acquired lease during application-owned cleanup; if commands were submitted, preserve the normal post-submit fence boundary.

Independent CPU/GPU budgets and eviction

maxTiles and maxGraphs independently bound the resident entry counts. maxCpuBytes counts the full byteLength of every distinct ArrayBuffer or SharedArrayBuffer retained by each tile's decoded sample and validity views. Several views into one backing allocation, including a validity array shared by multiple bands, count that allocation once for the tile. A small subarray backed by a large pooled slab retains the entire slab and is therefore charged for its full allocation, not merely its visible view range. Use appropriately sized dedicated backing allocations or a budget large enough for the retained pool. maxGpuBytes bounds three disjoint allocation sets:

  • Unique cache-owned uploaded native band and validity buffers, counted by their actual buffer.byteLength. One shared validity array produces one shared GPU buffer.
  • Each compiled graph's graph.stats.physicalTransientResourceBytes, including its real graph-owned transient buffer and texture allocations after physical reuse.
  • Application-owned graph resources declared by GPURasterTileGraphEntry.byteLength, such as output buffers, render attachments, indirect records, or other owner-managed GPU allocations.

Imported resident tile buffers are already included in the first category and must not also be reported through byteLength. Compiled-graph transient allocations are already included in the second category and must not be added to the third. estimatedByteLength must conservatively cover the graph's expected physical transients plus separately owned graph resources before the factory allocates them. Underestimating that combined footprint breaks safe admission; actual bytes are validated again after creation.

When an additional tile or graph would exceed an entry-count or byte budget, the cache evicts the least recently used compatible unpinned entries deterministically. Tiles or graphs protected by outstanding leases are never destroyed. A resource larger than the configured capacity, or a request that cannot fit because every eviction candidate is pinned, is rejected instead of silently exceeding the budget or destroying in-flight resources. cache.setBudgets({...}) applies a feasible lower limit through the same deterministic eviction policy; a requested budget smaller than the pinned footprint is rejected atomically without changing existing budgets or evicting entries. cache.budgets exposes current limits; cache.stats exposes resident counts, CPU/GPU bytes, hits, misses, tile evictions, graph compilations/reuses, and active tile/graph pins.

cache.destroy() cancels pending source requests and immediately destroys unpinned cache-owned tile buffers and graph entries. Resources protected by tile or graph leases remain alive until their final immediate or fence-delayed release; the source reader and decoder are never owned or destroyed by the cache. Destruction does not create a fence or protect leases the application has already released: during application shutdown, call releaseAfter() with a fence created after the final analysis/render submission before destroying the cache.

Tile residency preserves native float32, uint32, and sint32 sample buffers, exact raw nodata values, validity, calibration, overview, and spatial metadata. The resident band buffers are borrowed by graphs and renderers; neither may destroy them. The cache does not merge tiles, repack an entire source raster, download samples, submit commands, or hide synchronization.

Concurrent requests and cancellation

Concurrent equivalent requests share one application-source read and one upload. Identity uses the reader's validated, normalized request rather than the raw caller spelling, so omitted and explicit all-band selections, omitted and explicit full windows, default and explicit coordinateSpace: 'level', and equivalent level-zero/level-local windows coalesce. Every caller receives its own lease and may cancel its own AbortSignal; canceling one waiter does not cancel other callers waiting for the same tile. The underlying source read can be aborted after its last interested waiter goes away. Changing the normalized overview, window, explicit tile addressing, or selected-band order creates a separate request identity.

Cancel superseded viewport requests promptly, but do not use request cancellation as a GPU fence. An already acquired tile or graph remains alive while its lease is pinned, including while commands are encoded, submitted, and executing. Releasing cache ownership is independent from canceling a pending source operation.

Shape compatibility and imported-buffer replacement

Compiled graphs are reusable only when every property baked into their topology or WGSL matches. The cache's shape identity includes tile width and height, ordered band identities/formats/ validity/native nodata/calibration, overview level, pixel interpretation, explicit halo width, and the caller's pipelineKey. Include analytical options, selected operations, specialized constants, kernel parameters, output layouts, and any other application-specific specialization in that key. Differently sized edge tiles, source overviews, band layouts, halo widths, or pipeline keys compile separate graph entries.

World-space tile origin and affine translation do not affect a shape whose WGSL and resource layout are otherwise identical. Accordingly, equally sized western and eastern windows can use the exact same compiled graph even though their geospatial origin and source buffers differ. Supply the current tile's imported buffer replacements to every compiledGraph.encode(); the buffers captured when the graph was originally compiled may already have been evicted. The existing command graph validates replacement device, usage, capacity, and alias compatibility for each encoding without mutating an already compiled graph.

GPURasterTileGraphEntry.value can retain the application-owned analysis engine associated with a compiled graph. Its destroy() callback releases that owner's output allocations and compiled graph exactly once when an unpinned graph entry is evicted. Cache-owned source buffers remain borrowed and must not be destroyed by the callback. Keep a graph lease pinned until any render submission consuming its outputs has also completed.

Seam-safe tile halos and owned cores

Use GPURasterTileHaloAssembler when a neighborhood operation crosses an independently owned tile boundary. Filtering, derivatives, and morphology need real adjacent observations; treating an interior tile edge as a dataset border produces visible seams and analytically incorrect values. Pointwise-only pipelines can omit stages or use the cache directly because neighboring pixels cannot influence their outputs.

The assembler wraps an existing bounded GPURasterTileCache; it does not install a decoder, create a CPU-side stitched raster, allocate an unbounded full-dataset image, submit GPU commands, or replace the source-owned transport policy:

const assembler = new GPURasterTileHaloAssembler(cache);
const request: GPURasterTileHaloRequest = {
level: 0,
column: 1,
row: 0,
bandIds: ['red', 'near-infrared'],
stages: [
{requiredHalo: 3}, // Gaussian smoothing, radius three.
{requiredHalo: 1}, // Sobel derivative, radius one.
{requiredHalo: 4} // Opening at radius two includes both morphology passes.
]
};

const plan = assembler.plan(request);
// plan.horizontalHalo === 8; plan.verticalHalo === 8.
// plan.corePixelBounds owns only this tile; availablePixelBounds includes its real neighbors.

const controller = new AbortController();
const haloLease = await assembler.acquire(request, controller.signal);
// haloLease.core is the first pinned tile; haloLease.tiles includes every required neighbor.

Cumulative, anisotropic, and overview receptive fields

stages is ordered, explicit, and required. Each stage advertises its complete requiredHalo; optional horizontalRadius and verticalRadius narrow its independent axes. Omitted axes use requiredHalo, and neither axis may exceed that stage's declared maximum. The planner sums each axis across the entire pipeline rather than choosing only the largest stage.

For example, Gaussian radius 3 followed by Sobel radius 1 requires four source pixels per axis. Opening or closing at radius 2 already reports requiredHalo: 4 because its erosion and dilation passes both contribute. Combining those three operations therefore requires eight source pixels, not three, four, or six. An explicitly horizontal radius-three stage followed by an explicitly vertical radius-two stage can instead declare:

stages: [
{requiredHalo: 3, horizontalRadius: 3, verticalRadius: 0},
{requiredHalo: 2, horizontalRadius: 0, verticalRadius: 2}
];
// horizontalHalo === 3; verticalHalo === 2; requiredHalo === 3.

All stage radii, corePixelBounds, and availablePixelBounds use the selected overview's own pixel grid. levelZeroHalo separately exposes the original-resolution footprint as [ceil(horizontalHalo * downsampleX), ceil(verticalHalo * downsampleY)], preserving odd source dimensions and anisotropic overview scales without conflating coordinate systems.

The core and available coverage are half-open rectangles. Available coverage expands the core by the complete per-axis halo and clips only against the selected level's actual dimensions; ragged right/bottom tiles are never implicitly padded. An explicitly addressed core acquires its canonical owning tile first, then every intersecting physical neighbor, including diagonal neighbors. An unaddressed full-level or explicit-window request instead acquires one normalized expanded source window. Source readers, formats, selected bands, budgets, and cancellation remain the existing cache's responsibility.

GPU-native neighbor assembly and core extraction

Create caller-owned packed output views for the planned expanded width/height, import each leased resident band into the same graph, and identify its absolute overview-space coverage:

const sources: GPURasterTileHaloSource<'float32'>[] =
makeApplicationOwnedGraphSources(haloLease.tiles, 'red');

new GPURasterTileHaloFill({
id: 'assemble-red-neighborhood',
pixelBounds: plan.availablePixelBounds,
sources,
output: assembledRedValues,
outputValidity: assembledRedValidity
}).addToGraph(graph);

// Run every selected neighborhood stage over plan.width × plan.height.
// Keep stage scratch and source band metadata explicit and caller-controlled.

new GPURasterTileCoreExtract({
id: 'publish-owned-red-core',
availablePixelBounds: plan.availablePixelBounds,
corePixelBounds: plan.corePixelBounds,
input: processedExpandedBand,
output: ownedCoreValues,
outputValidity: ownedCoreValidity
}).addToGraph(graph);

The application supplies makeApplicationOwnedGraphSources and every output allocation; it is not an implicit LuRaster loader. GPURasterTileHaloFill requires exact, nonoverlapping source coverage of the expanded destination and identical band identity, native format, nodata, and calibration metadata across all contributors. It declares one bounded compute pass per source, keeping even diagonal neighborhoods below portable WebGPU storage-binding limits. Exact float32, uint32, and sint32 values are copied without recalibration, and each pixel's separate validity is preserved; a source without a mask publishes valid observations.

Execute smoothing, gradients, or morphology over the expanded intermediate region before GPURasterTileCoreExtract copies exactly the owned half-open core and its validity into separate caller-owned packed views. Adjacent cores therefore never overlap or double-publish seam pixels. A neighborhood border policy is meaningful only where available coverage meets a true dataset edge; sufficient real halo coverage keeps interior tile boundaries out of every owned output's receptive field. Missing observations remain missing rather than being silently filled by adjacent tiles.

Neighbor lifetime, cost, and remaining boundaries

GPURasterTileHaloLease pins both core and every entry in tiles. Retain that composite lease while imported source buffers are encoded or submitted. After application-owned command submission, create the completion fence and release every source through the same fence:

device.submit(encoder.finish());
const completionFence = device.createFence();
await Promise.all([
graphLease.releaseAfter(completionFence),
haloLease.releaseAfter(completionFence)
]);

Use immediate haloLease.release() only before encoding or after completion is already known. Cancellation or a cache-admission failure releases partially acquired pins without destroying resources retained by another lease. Cache capacity must fit all simultaneously pinned neighbors; otherwise admission fails explicitly instead of evicting an in-flight source.

A nominal w × h core with horizontal/vertical radii rx/ry processes at most (w + 2rx) × (h + 2ry) pixels before real dataset-edge clipping. Each selected band adds one GPU gather pass per intersecting source tile; core extraction adds one more pass per published band. Larger tiles reduce repeated halo work while smaller tiles lower peak resident memory; actual throughput depends on radius, source tiling, band count, cache hits, GPU bandwidth, graph specialization, and measured workloads. This explicit numerical workflow preserves downstream analytical values and bounded residency, unlike a framebuffer-only image effect, but it does not guarantee a universal performance improvement.

Halo planning and graph contributors do not automatically write one global stitched image, deduplicate contour segments across tiles, implicitly merge dataset-wide histograms, or integrate a GeoTIFF/COG transport. Applications explicitly add GPURasterOverview or GPURasterCategoricalOverview when generated overviews are needed; halo contributors do not add them implicitly. They separately compose global statistic/histogram merge contributors when dataset-wide results are needed. Applications own result placement and command submission; automatic full-image stitching and contour seam ownership remain separate work.

Analytical tiling versus screen-space image effects

Use bounded residency when source dimensions or interaction frequency make monolithic raster allocation, repeated decode/upload, or repeated graph compilation impractical. A resident revisit avoids source decoding and buffer upload; a compatible new tile avoids pipeline and graph recompilation while still performing its required analysis. Work and memory scale with selected tile dimensions, concurrent resident budgets, graph variants, and actual scratch/output sizes, rather than requiring allocation proportional to the entire dataset.

Existing luma.gl image effects are appropriate for changing a rendered framebuffer's appearance. LuRaster retains native scientific samples, validity, calibration, explicit tile ownership, reusable compute graphs, and downstream histograms, morphology, contours, or other numerical consumers. Those differences can reduce redundant CPU/GPU work in repeated large-raster pipelines, but they do not guarantee a particular speedup: source latency, tile size, cache hit rate, memory bandwidth, graph complexity, adapter limits, and measured application workloads determine actual performance.

Bounded residency alone does not assemble neighborhoods or decide output ownership; applications explicitly compose GPURasterTileHaloAssembler, GPURasterTileHaloFill, and GPURasterTileCoreExtract when those contracts are required, or the separate analytical overview and global merge contributors when verified GPU-generated reductions or cross-tile statistics are needed. Automatic full-image output stitching and contour seam ownership remain separate roadmap tranches.

Replayable global tiled statistics

Use tile-local statistics when a displayed window intentionally defines its own numerical extent, histogram, and classification threshold. Use explicit global accumulators when independently resident windows must share one dataset-wide contrast domain, histogram, percentile, or Otsu threshold. Computing a separate automatic domain per tile and adding those bin arrays is incorrect: equivalent bin indexes then refer to different numerical ranges.

GPURasterGlobalAccumulator groups five caller-owned graph views:

const accumulator: GPURasterGlobalAccumulator = {
extent: globalExtent, // float32[2]: minimum, maximum
count: globalValidCount, // uint32[1]: saturating valid population
sum: globalValueSum, // float32[1]: calibrated population sum
histogram: globalHistogram, // uint32[1..256]: saturating persistent bins
overflow: globalOverflow // uint32[1]: sticky population/bin/sum flags
};

All five buffers remain caller-owned. Extent and sum retain calibrated scientific values, while population and histogram bins use uint32. Initialization, statistics, histogram replay, and percentile publication are separate GPUCommandGraphContributor operations. None of them decodes tiles, loads an adapter, acquires residency, submits commands, creates completion fences, downloads pixels, or polls a GPU result.

Why local histograms cannot simply be added

Suppose two owned tiles each contain three valid observations and each independently creates four bins:

west samples: [0, 5, 10] local domain [0, 10]
west local bins: [1, 0, 1, 1]

east samples: [100, 105, 110] local domain [100, 110]
east local bins: [1, 0, 1, 1]

incorrect bin sum: [2, 0, 2, 2] incomparable local intervals
global domain: [0, 110]
correct global bins: [3, 0, 0, 3] every sample uses the same intervals

Local bin 0 refers to values near zero in the west but near 100 in the east. Merely adding those arrays invents a distribution that does not correspond to any shared numerical axis. The first global pass discovers the common [0, 110] extent; only the second pass can assign both tiles to scientifically comparable bins.

Explicit initialization and first-pass global domain

Initialize one accumulator exactly once for each intended dataset-wide run:

new GPURasterGlobalInitialize({
id: 'begin-global-vegetation-analysis',
accumulator
}).addToGraph(graph);

for (const tile of applicationOwnedTiles) {
new GPURasterGlobalStatisticsMerge({
id: `global-statistics-${tile.id}`,
width: tile.width,
height: tile.height,
input: tile.observedBand,
accumulator
}).addToGraph(graph);
}

Initialization explicitly writes extent [0, 0], count 0, sum 0, every histogram bin 0, and overflow 0. Each first-pass merge excludes masked observations, exact raw nodata, and non-finite values before source calibration. A tile with no valid observations is a strict no-op, so an all-invalid dataset retains its initialized zero extent/count/sum. Valid tiles expand the persistent minimum/maximum and accumulate their calibrated count and sum.

Do not put GPURasterGlobalInitialize inside a compiled tile graph that will be encoded once per tile. Doing so resets the global results on every replay and leaves only the final tile. Keep initialization in an explicitly encoded one-time graph or register it once before every tile merge in a single graph.

Stable-domain second-pass histogram replay

After every first-pass tile has contributed to the final domain, replay those same half-open owned cores against the persistent extent:

for (const tile of applicationOwnedTiles) {
new GPURasterGlobalHistogramMerge({
id: `global-histogram-${tile.id}`,
width: tile.width,
height: tile.height,
input: tile.observedBand,
accumulator
}).addToGraph(graph);
}

new GPURasterOtsuThreshold({
id: 'global-vegetation-cutoff',
histogram: accumulator.histogram,
domain: accumulator.extent,
output: globalThreshold
}).addToGraph(graph);

Each histogram contributor clears only its bounded graph-owned tile partial, fills that partial using the already finalized GPU-resident global extent, and explicitly adds it into the separate persistent global bins. Re-encoding a compatible tile graph therefore clears stale local counts without erasing previously merged tiles. Binning retains the existing inclusive upper endpoint. Merge only disjoint owned cores; including padded source halos as separate population observations would double-count tile seams.

The application may declare all first-pass and second-pass work in one command graph when all participating tiles can remain pinned together. For larger bounded-residency datasets, import the same caller-owned accumulator buffers into independently compiled initialization, first-pass tile, second-pass tile, and finalization graphs. Replay source tiles through the application-owned decoder or explicit residency cache, preserve imported-buffer replacement, and retain every source/graph lease until its caller-created post-submit fence resolves. Never start histogram replay before global extent merging finishes.

Each graph must import the same underlying persistent GPU buffers and construct its own graph-local GraphDataView objects; views from the initialization graph cannot be passed directly to a different tile graph. Compile a merge-only tile graph once when its shape is compatible, replace imported source buffers at each encoding, and submit every first-pass encoding before any second-pass encoding. The WebGPU queue preserves submitted command order; no intermediate CPU readback is needed to discover the finalized global domain. Place reset only in the one-time initialization graph, never in either replayed tile graph.

Quantile approximation and overflow policy

GPURasterGlobalPercentile consumes the finalized global histogram, extent, count, and sticky overflow flags without synchronizing with the CPU:

new GPURasterGlobalPercentile({
id: 'global-median',
accumulator,
percentile: 0.5,
output: globalMedian,
outputValidity: globalMedianValidity
}).addToGraph(graph);

percentile is a number from 0 through 1. Rank selection uses floor(percentile * (globalCount - 1)); the 0 and 1 endpoints are the exact global minimum and maximum. Intermediate values are the center of their selected histogram bin, not exact sample-order statistics. Their quantization error is bounded by half the bin width; choosing more bins improves resolution at the cost of persistent storage and merge work. Histogram length is explicitly limited to 1 through 256 bins.

Population and individual histogram-bin counters saturate at 4,294,967,295 rather than wrapping. accumulator.overflow accumulates sticky bit flags:

  • Bit 1: the global valid population exceeded uint32 capacity.
  • Bit 2: at least one global histogram bin exceeded uint32 capacity.
  • Bit 4: an accumulated global float32 sum became non-finite.

Only explicit reinitialization clears these flags. Empty populations or any overflow flag make percentile output an invalid quiet NaN, with optional validity 0. Existing GPURasterOtsuThreshold does not inspect this separate overflow buffer automatically; use its result only when the application establishes that overflow is absent. Global extrema, exact unsaturated population/bin counts, and histogram-derived thresholds do not depend on tile arrival order. Floating sums can differ by ordinary float32 addition-order rounding.

Replay cost and explicit ownership

A complete global histogram reads each selected analytical tile twice: once to establish its valid global domain and once to bin against that stable domain. One-time initialization and optional percentile/Otsu selection add bounded graph passes. Persistent accumulator memory scales with histogram-bin count; temporary validity, calibrated samples, reduction scratch, and per-tile bin partials scale with selected tile size, never with full dataset dimensions.

Resident replay can avoid repeat decoding and GPU upload when cache budgets retain a tile; bounded applications may instead reacquire or re-decode it between phases. Command submission, source transport, loaders.gl 5 integration, decoder cancellation, residency policy, leases, fences, threshold application, and optional readback remain caller-controlled. Throughput depends on tile size, histogram bins, cache hits, source latency, dispatch count, bandwidth, and GPU; global analytical replay provides consistent scientific domains rather than a universal speedup over display-only image effects.

Pointwise band math

GPURasterBandMath accepts two packed bands on the same graph and grid:

new GPURasterBandMath({
id: 'calibrated-difference',
width,
height,
left: nearInfrared,
right: red,
operation: 'subtract',
output: difference,
outputValidity: differenceValidity
}).addToGraph(graph);

Available operations are add, subtract, multiply, divide, and normalized-difference. Both source masks intersect. For division and normalized difference, epsilon rejects denominators whose absolute value is less than or equal to the configured non-negative threshold; the default is zero. clamp: [minimum, maximum] is optional and never applied implicitly.

Sources may use different scalar formats and different calibration coefficients. Output values, output validity, and source storage must occupy separate buffers. All views must belong to the same command graph and contain exactly width * height packed rows.

NDVI

GPURasterNDVI specializes pointwise normalized difference:

const nearInfrared: GPURasterBufferBand<'uint32'> = {
id: 'near-infrared',
format: 'uint32',
storage: {kind: 'buffer', values: nearInfraredValues},
validity: nearInfraredValidity,
noDataValue: 65535,
scale: 0.0001
};

const red: GPURasterBufferBand<'uint32'> = {
id: 'red',
format: 'uint32',
storage: {kind: 'buffer', values: redValues},
validity: redValidity,
noDataValue: 65535,
scale: 0.0002,
offset: -0.01
};

new GPURasterNDVI({
id: 'vegetation-index',
width,
height,
nearInfrared,
red,
output: vegetationIndex,
outputValidity: vegetationValidity,
epsilon: 0.000001
}).addToGraph(graph);

The mathematical operation is:

nearInfraredCalibrated = nearInfraredRaw * nearInfraredScale + nearInfraredOffset
redCalibrated = redRaw * redScale + redOffset
ndvi = (nearInfraredCalibrated - redCalibrated)
/ (nearInfraredCalibrated + redCalibrated)

Raw nodata, each band's mask, non-finite calibrated values, and denominators within epsilon produce invalid output. NDVI is not implicitly limited to [-1, 1]: negative calibrated reflectance can produce valid values outside that interval. Supply clamp explicitly only when the application intends to discard that analytical range.

Validity-aware histograms

GPURasterHistogram keeps its inferred domain and binning on the GPU:

const ndviBand: GPURasterBufferBand<'float32'> = {
id: 'vegetation-index',
format: 'float32',
storage: {kind: 'buffer', values: vegetationIndex},
validity: vegetationValidity
};

new GPURasterHistogram({
id: 'vegetation-distribution',
input: ndviBand,
output: histogramBins,
domainOutput: validExtent
}).addToGraph(graph);

const compiled = graph.compile();
const encoder = device.createCommandEncoder();
compiled.encode(encoder, {parameters: undefined});
device.submit(encoder.finish());

The default domain: 'valid-auto' computes a masked extent and passes that two-row GPU result explicitly to the existing histogram primitive. Finite raw nodata sentinels are excluded before the extent and histogram; source validity is preserved. domainOutput optionally publishes the caller-owned extent. If omitted, the extent is graph-owned transient storage.

An application may instead supply a fixed [minimum, maximum] tuple or a caller-owned two-row GPU domain in the same scalar format. domainOutput is valid only with the automatic domain. Output bins are cleared on every encoding, so re-encoding a compiled graph recomputes the distribution instead of accumulating stale counts.

The generic GPUHistogram({mask, domain: 'auto'}) intentionally infers its domain from all input values before applying its mask. GPURasterHistogram does not change that shared primitive contract; it creates the raster-specific valid-pixel domain explicitly.

Valid-pixel scalar statistics

GPURasterStatistics normalizes a floating-point band's raw nodata, non-finite samples, source validity, and calibration before composing graph-native reductions:

new GPURasterStatistics({
id: 'vegetation-summary',
width,
height,
input: ndviBand,
count: validPixelCount,
sum: validPixelSum,
mean: validPixelMean,
extent: validPixelExtent
}).addToGraph(graph);

The caller provides one uint32 count row, one float32 sum row, one float32 mean row, and a two-row float32 extent. Empty or all-invalid selections have count, sum, mean, and extent equal to zero. Integer-wide sums, exact or standalone tile-local percentile estimation, and automatic large-raster partitioning remain separate future work; GPURasterGlobalPercentile already provides histogram-estimated percentiles for an explicit global accumulator.

Contrast, gamma, and histogram equalization

GPURasterContrast transforms calibrated samples while preserving a separate validity mask:

new GPURasterContrast({
id: 'vegetation-contrast',
width,
height,
input: ndviBand,
domain: [-1, 1],
contrast: 1.4,
gamma: 1.15,
mode: 'gamma',
output: adjustedValues,
outputValidity: adjustedValidity
}).addToGraph(graph);

mode: 'linear' applies midpoint-centered contrast and never applies gamma, even when a nondefault gamma value is supplied. mode: 'gamma' additionally applies the normalized gamma transform; select it explicitly when nonlinear correction is intended. Both literal [minimum, maximum] domains and caller-owned two-row GPU domains are supported. For mode: 'equalize', pass an existing uint32 histogram in the same domain; the contributor computes an inclusive CDF in graph-owned transient storage. Degenerate or all-invalid histograms have explicit behavior, and no source pixels are copied back. Percentile-domain estimation and rgba8unorm presentation conversion are not yet implemented.

Analytical threshold masks

GPURasterThreshold writes a caller-owned, canonical uint32 selection mask:

new GPURasterThreshold({
id: 'dense-vegetation',
width,
height,
input: adjustedBand,
threshold: 0.4,
operation: 'above',
inclusive: true,
output: selectedPixelValidity
}).addToGraph(graph);

Operations are above, below, and range. Scalar thresholds accept one finite literal or a one-row GPU float32 view; range thresholds accept an ordered literal pair or a two-row GPU view. Raw native-format nodata, source validity, non-finite values, and source calibration are applied before classification. Feeding the resulting mask to GPURasterHistogram or GPURasterStatistics changes actual counts and distributions rather than merely dimming a presentation layer.

For automatic segmentation, GPURasterOtsuThreshold selects a threshold from a bounded caller-owned histogram without CPU synchronization:

new GPURasterOtsuThreshold({
id: 'automatic-vegetation-threshold',
histogram: histogramBins,
domain: validExtent,
output: automaticThreshold
}).addToGraph(graph);

new GPURasterThreshold({
id: 'automatic-vegetation-mask',
width,
height,
input: adjustedBand,
threshold: automaticThreshold,
output: selectedPixelValidity
}).addToGraph(graph);

Histograms contain between one and 256 bins, with combined counts that fit in uint32. Equal between-class scores deterministically select the lowest threshold; an empty histogram produces zero. The selected threshold remains GPU-resident when consumed by the downstream classification pass.

Neighborhood stencils and boundary policies

GPURasterNeighborhood evaluates an explicit two-dimensional kernel against calibrated, buffer-backed raster samples. Use it when an analytical operator needs complete control over its radius, individual weights, boundary behavior, normalization, and invalid-neighbor policy; use the convolution or smoothing contributors below when their higher-level contracts already describe the operation.

new GPURasterNeighborhood({
id: 'weighted-local-mean',
width,
height,
input: ndviBand,
radius: 1,
kernel: [1, 2, 1, 2, 4, 2, 1, 2, 1],
normalize: true,
borderMode: 'reflect',
noDataPolicy: 'ignore-renormalize',
output: neighborhoodValues,
outputValidity: neighborhoodValidity
}).addToGraph(graph);

The radius is either one number for a square kernel or [horizontalRadius, verticalRadius] for a rectangular kernel. Each axis is bounded to eight pixels, and the kernel must contain exactly (2 * horizontalRadius + 1) * (2 * verticalRadius + 1) coefficients representable as finite float32 values. Compute workgroups cooperatively cache neighborhood samples and validity in workgroup-local tiles. Output values and canonical uint32 validity are separate caller-owned buffers: in-place updates are forbidden because neighboring invocations would otherwise observe partially overwritten source pixels. Radius zero is the single-pixel identity when its sole weight is one.

Choose the border mode according to the meaning of samples beyond the known raster:

Border modeOut-of-bounds behaviorWhen to use it
clampRepeats the closest edge sample.Continuous fields whose outermost measured value is the best available extension.
reflectMirrors the interior without duplicating the boundary sample; a single-pixel axis repeats itself.Smoothing terrain, reflectance, or microscopy without the flat edge plateaus introduced by clamping.
constantUses the caller-provided borderValue.Padding against a known background, such as zero-valued calibrated intensity.
nodataTreats out-of-bounds samples as invalid neighbors.Keeping missing exterior coverage explicit at genuine dataset boundaries.

The default is borderMode: 'clamp'; constant defaults to borderValue: 0 in the calibrated sample domain. For samples [a, b, c], reflect-101 maps both the left position -1 and right position 3 to b instead of repeating the edge pixel.

Raw-format nodata comparisons precede calibration, source masks remain authoritative, and non-finite samples never participate. An invalid center pixel always remains invalid even when the surrounding neighborhood contains valid samples, so clouds and source nodata are never silently inpainted. Neighbor handling is explicit:

  • noDataPolicy: 'propagate' invalidates the output when a nonzero-weight neighbor is missing. This is the default. Use it for exact analytical stencils, derivative kernels, or workflows that require complete local coverage.
  • noDataPolicy: 'ignore-renormalize' drops invalid neighbors and rescales the surviving smoothing weights. Use it for nonnegative averaging kernels near clouds, image boundaries, or sparse nodata; a neighborhood without usable weight remains invalid. Signed kernels are rejected with this policy because renormalizing positive and negative response weights would change the operator's meaning.

Direct convolution

GPURasterConvolution applies an arbitrary odd-sized two-dimensional kernel in a single GPU compute pass:

new GPURasterConvolution({
id: 'weighted-spatial-filter',
width,
height,
input: ndviBand,
kernelWidth: 3,
kernelHeight: 3,
kernel: [1, 2, 1, 2, 4, 2, 1, 2, 1],
normalize: true,
borderMode: 'reflect',
noDataPolicy: 'ignore-renormalize',
output: convolutionValues,
outputValidity: convolutionValidity
}).addToGraph(graph);

Use direct convolution for custom two-dimensional kernels that cannot be factored into independent horizontal and vertical filters, including asymmetric and signed response operators. Its spatial work grows with the kernel area: a square radius-r kernel evaluates (2r + 1)² taps per pixel. Keep normalization explicit for kernels whose coefficient sum should preserve constant-valued imagery; normalize defaults to false, and normalizing a zero-sum kernel is rejected. Signed response operators should retain their intended weights and use the strict propagate validity policy.

Separable Gaussian smoothing

GPURasterGaussianBlur composes independent horizontal and vertical passes around graph-owned intermediate scratch:

new GPURasterGaussianBlur({
id: 'denoise-vegetation-index',
width,
height,
input: ndviBand,
radius: 3,
sigma: 1.4,
borderMode: 'reflect',
noDataPolicy: 'ignore-renormalize',
output: smoothedValues,
outputValidity: smoothedValidity
}).addToGraph(graph);

Use Gaussian smoothing to suppress sensor noise or small-scale variation before thresholding, segmentation, contour extraction, or scientific visualization. radius sets the bounded kernel footprint; sigma controls how broadly its normalized weights spread within that footprint. Increasing radius permits a wider neighborhood, while increasing sigma gives more influence to distant neighbors. Omitted sigma defaults to max(radius / 2, 0.5). Gaussian weights are normalized and nonnegative, making valid-neighbor renormalization appropriate beside masked clouds or nodata.

The horizontal and vertical passes evaluate 2 * (2r + 1) taps per pixel instead of (2r + 1)² for an equivalent direct square kernel: O(r) rather than O(r²) spatial work. This is an algorithmic scaling comparison, not a benchmark or a claim that every adapter and image size runs faster. Scratch values and intermediate validity occupy two graph-owned transient buffers, reused according to the existing graph allocator rather than downloaded to the CPU. Radius zero uses one identity/calibration pass without allocating smoothing scratch.

With ignore-renormalize, each separable axis independently renormalizes its own valid neighbors. Fully valid imagery matches the corresponding separable two-dimensional kernel, but irregular nodata holes can produce different weights from a direct full-neighborhood masked renormalization. Use GPURasterConvolution when that exact two-dimensional masked behavior is required.

Separable box smoothing

GPURasterBoxBlur applies the same two-pass graph structure with uniform, normalized weights:

new GPURasterBoxBlur({
id: 'local-mean-vegetation-index',
width,
height,
input: ndviBand,
radius: 2,
borderMode: 'reflect',
noDataPolicy: 'ignore-renormalize',
output: localMeanValues,
outputValidity: localMeanValidity
}).addToGraph(graph);

Use box smoothing when a local arithmetic mean is the intended measurement or when uniformly weighted denoising is sufficient. Gaussian smoothing is generally preferable when nearby observations should contribute more strongly than distant ones; neither filter should be used where preserving sharp boundaries exactly is the primary requirement. As with Gaussian smoothing, the separable box kernel performs O(r) taps per pixel and publishes GPU-resident values and validity for later graph contributors.

Directional Sobel and Scharr gradients

GPURasterSobel, GPURasterScharr, and the configurable GPURasterGradient apply signed first-derivative operators to calibrated raster samples. Use a directional gradient when the orientation and sign of a boundary matter, such as locating transitions between water and vegetation or measuring horizontal versus vertical microscopy intensity changes.

new GPURasterSobel({
id: 'vegetation-horizontal-gradient',
width,
height,
input: ndviBand,
direction: 'x',
scale: 1 / 8,
borderMode: 'reflect',
output: horizontalGradientValues,
outputValidity: horizontalGradientValidity
}).addToGraph(graph);

new GPURasterGradient({
id: 'vegetation-vertical-scharr-gradient',
width,
height,
input: ndviBand,
operator: 'scharr',
direction: 'y',
scale: 1 / 32,
output: verticalGradientValues,
outputValidity: verticalGradientValidity
}).addToGraph(graph);

GPURasterSobel is equivalent to GPURasterGradient with operator: 'sobel'; GPURasterScharr selects operator: 'scharr'. All three contributors use a single bounded 3 × 3 neighborhood pass with radius one. Their raw, row-major coefficients are:

Sobel x: [-1, 0, 1] Sobel y: [-1, -2, -1]
[-2, 0, 2] [ 0, 0, 0]
[-1, 0, 1] [ 1, 2, 1]

Scharr x: [ -3, 0, 3] Scharr y: [ -3, -10, -3]
[-10, 0, 10] [ 0, 0, 0]
[ -3, 0, 3] [ 3, 10, 3]

A value increasing with raster column produces a positive x derivative; a value increasing with raster row produces a positive y derivative. Raster rows increase downward, so y does not automatically mean north or positive projected-world Y. A unit-per-pixel ramp produces raw interior responses of 8 for Sobel and 32 for Scharr. The optional positive, finite scale multiplies every response and defaults to 1: use 1 / 8 or 1 / 32 when a unit ramp should return approximately 1. Signed direction and magnitude remain meaningful rather than being clamped to display colors.

Choose Sobel for a familiar, compact first derivative with moderate perpendicular smoothing. Choose Scharr when improved angular symmetry makes diagonal or orientation-sensitive edges more useful; its stronger perpendicular weights do not imply a larger source footprint. Both operators evaluate the same 3 × 3 neighborhood, so Scharr does not have an algorithmic tap-count advantage or disadvantage here. Actual GPU cost depends on the adapter, dispatch, memory traffic, and measured workload.

All derivatives are expressed per raster pixel, not in projected meters, geographic degrees, or other CRS/world units. Non-square pixel spacing, rotation, shear, orientation, or geographic distance requires an explicit application-side conversion using the raster affine transform and appropriate coordinate-reference-system semantics.

The default borderMode is clamp; reflect, constant, and nodata reuse the same explicit neighborhood boundary contract described above. Raw nodata and source masks are checked before calibration. An invalid center or an invalid nonzero-coefficient neighbor invalidates the output; signed derivative coefficients cannot use smoothing-style missing-neighbor renormalization. A zero-coefficient position does not participate and therefore does not invalidate the response. Each derivative publishes separate caller-owned float32 values and canonical uint32 validity; invalid outputs remain NaN.

Laplacian response and second-order edges

GPURasterLaplacian measures the signed second derivative across either four cardinal neighbors or all eight neighboring pixels:

new GPURasterLaplacian({
id: 'vegetation-second-derivative',
width,
height,
input: ndviBand,
connectivity: 4,
borderMode: 'reflect',
output: laplacianValues,
outputValidity: laplacianValidity
}).addToGraph(graph);

connectivity: 4 is the default and uses [0, 1, 0, 1, -4, 1, 0, 1, 0]; connectivity: 8 uses [1, 1, 1, 1, -8, 1, 1, 1, 1]. Both compute neighboring samples minus the center-weighted sample, so an isolated bright impulse has a negative response at its center, while an isolated dark depression has a positive response. A constant field has zero interior response. The same optional positive scale, border policy, raw nodata, and strict validity rules apply; missing diagonal neighbors do not affect four-connected output because their coefficients are zero.

Use the Laplacian when curvature, signed ridge/valley response, isotropic-looking boundary emphasis, or subsequent zero-crossing detection matters more than first-derivative orientation. Choose four connectivity for cardinal-only neighborhoods and eight connectivity when diagonal samples should participate explicitly. Second derivatives amplify local sensor noise, so apply GPURasterGaussianBlur first when the source is noisy; smoothing and Laplacian remain separate, declared graph stages with separately defined validity behavior. The Laplacian is not a replacement for directional Sobel/Scharr response when edge orientation is required.

Gradient magnitude and graph-owned scratch

GPURasterGradientMagnitude combines horizontal and vertical first derivatives into the nonnegative response sqrt(x * x + y * y):

new GPURasterGradientMagnitude({
id: 'vegetation-edge-strength',
width,
height,
input: ndviBand,
operator: 'scharr',
scale: 1 / 32,
borderMode: 'reflect',
output: edgeMagnitudeValues,
outputValidity: edgeMagnitudeValidity
}).addToGraph(graph);

Use magnitude when total boundary strength matters regardless of direction: threshold candidate field boundaries, prepare a segmentation mask, compare edge contrast, or colorize transitions without treating opposite edge orientations differently. operator defaults to sobel; select scharr for the same improved angular response described above. The positive scale applies to both directional derivatives before their magnitude is combined, so 1 / 8 or 1 / 32 normalizes a unit horizontal or vertical ramp for its selected operator.

One contributor declares three ordered GPU graph passes: the horizontal derivative, vertical derivative, and magnitude combination. It allocates four graph-owned transient buffers: one float32 response and one uint32 validity mask for each direction. Their logical scratch cost is 16 * width * height bytes in addition to caller-owned output values and validity. The combination intersects both directional masks; missing samples cannot be revived or silently renormalized. Its magnitude pass uses a ratio-scaled hypot calculation to avoid unnecessarily overflowing intermediate squares and requires six available storage-buffer bindings; an unrepresentable final response remains invalid. Graph compilation owns and eventually releases scratch and compute allocations, while inputs and final outputs remain caller-owned.

Directional Sobel, Scharr, and Laplacian each need one fixed-footprint 3 × 3 pass without additional global scratch; full gradient magnitude adds the two intermediate derivative pairs and a third pass. Every option scales linearly with bounded pixel count, with different dispatch and memory constants. These structural costs and reusable GPU-resident outputs explain when a particular operation is appropriate; they do not establish an unmeasured speedup over another implementation or a screen-space image effect.

Binary and grayscale morphology

Morphology changes a raster according to the minimum or maximum within a selected neighborhood. Use it after classification when foreground shapes, small islands, holes, or narrow connections matter, or use grayscale morphology when local brightness/intensity extrema are themselves the intended analytical result.

GPURasterMorphology exposes one explicit operation: 'dilate' | 'erode' pass; GPURasterDilation and GPURasterErosion name those operations directly. GPURasterOpening composes erosion followed by dilation, while GPURasterClosing composes dilation followed by erosion. Every contributor supports two strictly typed data domains:

  • mode: 'binary' requires a packed GPURasterBufferBand<'uint32'> input and a caller-owned GraphDataView<'uint32'> output. Every nonzero input is foreground; valid output values are canonical 0 or 1. Binary input calibration must be identity: scale can only be omitted or 1, and offset can only be omitted or 0.
  • mode: 'grayscale', or omitted mode, accepts any supported scalar input format and publishes caller-owned GraphDataView<'float32'> extrema. Raw nodata is checked in the original source format before applying source scale and offset exactly once, including opening/closing.

Both domains require a separate caller-owned GraphDataView<'uint32'> outputValidity. An invalid binary result contains value 0 and validity 0; a valid background pixel contains the same value 0 with validity 1. Invalid grayscale results contain NaN and validity 0.

Binary classification and observation validity

GPURasterThreshold writes zero for both legitimate below-threshold background and invalid observations. Therefore, its output is the binary morphology value band, not the binary validity mask. Supply the original analyzed observation validity separately:

new GPURasterThreshold({
id: 'vegetation-foreground',
width,
height,
input: adjustedBand,
threshold: 0.35,
output: vegetationForeground
}).addToGraph(graph);

const classifiedBand: GPURasterBufferBand<'uint32'> = {
id: 'classified-vegetation',
format: 'uint32',
storage: {kind: 'buffer', values: vegetationForeground},
validity: analyzedObservationValidity
};

new GPURasterDilation({
id: 'expand-vegetation-foreground',
width,
height,
mode: 'binary',
input: classifiedBand,
radius: 2,
structuringElement: 'cross',
noDataPolicy: 'ignore',
borderMode: 'reflect',
output: expandedForeground,
outputValidity: expandedObservationValidity
}).addToGraph(graph);

Setting validity: vegetationForeground would incorrectly treat every background pixel as missing and prevent dilation from growing foreground into valid background. Likewise, do not set noDataValue: 0 on an ordinary threshold mask: background zero is a meaningful observation. Use the morphology output values together with their separately published output validity when creating a later histogram selection or contour overlay. A binary boundary crosses level 0.5.

Dilation and erosion

Dilation selects the maximum over its valid participating footprint. In binary mode, this is an OR of foreground flags: it expands foreground, can connect sufficiently close valid regions, and can fill gaps smaller than the chosen footprint. In grayscale mode, it computes a local maximum and enlarges bright features.

new GPURasterMorphology({
id: 'bright-feature-maximum',
width,
height,
operation: 'dilate',
input: vegetationBand,
radius: 2,
structuringElement: 'square',
output: localMaximum,
outputValidity: localMaximumValidity
}).addToGraph(graph);

Erosion selects the footprint minimum. In binary mode, this is an AND of participating foreground flags: it shrinks foreground, can remove sufficiently small islands, and can separate thin connections. In grayscale mode, it computes a local minimum and enlarges dark features.

new GPURasterErosion({
id: 'local-vegetation-minimum',
width,
height,
mode: 'grayscale',
input: vegetationBand,
radius: 2,
structuringElement: 'square',
output: localMinimum,
outputValidity: localMinimumValidity
}).addToGraph(graph);

Choose single-pass dilation or erosion when the actual expansion, contraction, local maximum, or local minimum is the desired product. These are analytical operations on stored samples and validity, not temporary outlines painted over a rendered image. The exact result depends on footprint, radius, border policy, and missing observations; no operation guarantees preservation of source topology.

Opening and closing

Opening applies erosion first and dilation second. Use it to remove isolated foreground islands, suppress small bright speckles, or break thin foreground connections while allowing surviving features to recover their footprint-dependent extent:

new GPURasterOpening({
id: 'remove-small-vegetation-islands',
width,
height,
mode: 'binary',
input: classifiedBand,
radius: 1,
structuringElement: 'cross',
noDataPolicy: 'ignore',
output: openedForeground,
outputValidity: openedObservationValidity
}).addToGraph(graph);

Closing applies dilation first and erosion second. Use it to reduce small background holes, suppress dark speckles, or reconnect sufficiently narrow breaks while approximately restoring the surviving foreground's outside extent:

new GPURasterClosing({
id: 'repair-small-vegetation-holes',
width,
height,
mode: 'binary',
input: classifiedBand,
radius: 1,
structuringElement: 'square',
noDataPolicy: 'ignore',
output: closedForeground,
outputValidity: closedObservationValidity
}).addToGraph(graph);

Opening and closing operate on either binary or grayscale rasters. For grayscale composition, the first pass calibrates the original scalar band; the second pass consumes already-calibrated float32 scratch without reapplying scale or offset. Binary composition preserves canonical uint32 foreground throughout. Neither operation revives an invalid center.

Both contributors snapshot their normalized options and input-band metadata at construction. Later mutations to the caller's props, band description, or storage descriptor cannot change the scheduled passes, borrowed source/output views, or reported requiredHalo.

Structuring elements, borders, and nodata

Every morphology contributor requires an integer radius between 0 and 8, inclusive. structuringElement: 'square' is the default; it includes offsets whose Chebyshev distance is at most the radius. structuringElement: 'cross' includes offsets whose Manhattan distance is at most the radius. At radius one, these correspond to eight-connected and four-connected neighborhoods:

radius 1 square: ### radius 1 cross: .#.
### ###
### .#.

radius 2 cross: ..#..
.###.
#####
.###.
..#..

For radii greater than one, cross is a Manhattan diamond, not merely one horizontal and one vertical arm. A square includes (2r + 1)² positions; a diamond includes 2r(r + 1) + 1. At radius two that is 25 versus 13 samples; at radius eight, 289 versus 145. Both footprints scale as O(r²) per pixel, although the diamond visits fewer positions.

The default borderMode: 'clamp' repeats the nearest edge observation. reflect mirrors without repeating the nearest edge sample, constant supplies the explicit calibrated borderValue (default 0), and nodata treats exterior positions as missing. For binary constant borders, zero becomes background 0 and every nonzero finite representable value, including negative or fractional values, becomes foreground 1.

noDataPolicy: 'propagate' is the default: any invalid included footprint sample invalidates the output. noDataPolicy: 'ignore' skips invalid participating neighbors instead. Under either policy, an invalid center always remains invalid; an all-invalid footprint cannot produce a valid output. Ignoring missing positions is not a geodesic barrier: with radius greater than one, another valid sample can still influence a center across an intervening nodata gap.

Morphology graph costs and ownership

Dilation, erosion, and GPURasterMorphology register one bounded two-dimensional compute pass; their requiredHalo equals radius. For positive radii, opening and closing register two ordered passes and expose requiredHalo = 2 * radius. They allocate exactly two graph-owned intermediate buffers: a domain-typed uint32 or float32 value band and a separate uint32 validity band. Their combined logical scratch footprint is 8 * width * height bytes, excluding the caller-owned inputs/outputs, implementation allocation alignment, and graph bookkeeping.

At radius: 0, every operation performs one identity pass that still canonicalizes binary flags, applies grayscale calibration, and publishes source validity; opening/closing allocate no intermediate scratch. Compatible nonoverlapping transient lifetimes can share graph-managed allocations. Contributors declare resource hazards explicitly and never read back pixels, submit commands, or silently replace caller-owned buffers.

The square and diamond both perform O(r²) neighborhood work per pixel; opening/closing repeat that footprint across two passes. These explicit pass counts, buffer formats, and residency properties describe scalability and composability, not a measured throughput claim. Actual performance depends on pixel count, radius, GPU limits, memory bandwidth, and workload. Halo metadata can drive explicit GPURasterTileHaloAssembler planning, but does not partition oversized rasters or schedule neighborhood assembly automatically.

Connected-component labeling

A connected component is one maximal group of foreground pixels reachable through a selected pixel-neighbor relationship. Use GPURasterConnectedComponents after thresholding or binary morphology when you need to identify which selected observations form the same contiguous field, cloud, water body, microscopy object, or other classified region.

The input is a packed GPURasterBufferBand<'uint32'>. Every nonzero valid value is foreground; zero with valid observation status is background. A separate zero validity flag or exact raw noDataValue is missing data, not background, and cannot connect foreground pixels across a nodata barrier. Classification has identity calibration only: scale must be absent or 1, and offset must be absent or 0.

const classifiedForeground: GPURasterBufferBand<'uint32'> = {
id: 'classified-vegetation',
format: 'uint32',
storage: {kind: 'buffer', values: thresholdMask},
validity: analyzedObservationValidity
};

new GPURasterConnectedComponents({
id: 'vegetation-components',
width,
height,
input: classifiedForeground,
connectivity: 8,
maximumIterations: 24,
output: representativeLabels,
outputValidity: componentValidity,
converged: componentConvergence,
iterationCount: completedComponentIterations
}).addToGraph(graph);

output and outputValidity are separate caller-owned GraphDataView<'uint32'> buffers containing one element per input pixel. converged is a required caller-owned, one-element uint32 result. Optional iterationCount is another one-element caller-owned uint32 output. All source and output views must belong to the same graph and must not alias incompatible resources. The contributor declares work only; decoding, graph encoding, submission, leases, fences, rendering, and any optional result inspection remain application-owned.

Four-connected versus eight-connected regions

With connectivity: 4, pixels connect only through their north, south, east, and west neighbors. With connectivity: 8, diagonal neighbors also connect. Four-connectivity is the default.

Foreground classification: Four-connected labels: Eight-connected labels:

1 0 1 1 0 3 1 0 1
0 1 0 0 5 0 0 1 0
1 0 1 7 0 9 1 0 1

All five foreground pixels remain distinct under four-connectivity. Under eight-connectivity, the central pixel connects them into one component. If that center is instead invalid, it cannot bridge diagonal neighbors under either policy; valid zero-valued background remains separately valid but never joins a component.

Every converged foreground label is the smallest row-major pixel index in its component plus one. The +1 reserves label 0 for background while preserving a deterministic representative independent of workgroup execution order. Component identifiers are deliberately sparse: labels 1, 3, 5, 7, and 9 do not mean nine components. Compose the separate GPURasterDenseComponents contributor when contiguous IDs or an actual component count are required. Per-region measurements and cross-tile component merging remain separate contracts.

Convergence is required, not assumed

Connected components compose initialization, minimum-root hooking, pointer compression, GPU-resident convergence detection, and final publication in one bounded command graph. maximumIterations fixes the largest number of declared rounds; it is not a promise that a long thin region has already converged. GPU-controlled indirect dispatch can skip remaining rounds after a fixed point is reached without reading or polling convergence on the CPU.

Explicit iteration budgets must be integers from 1 through 64. Omitting the option selects max(1, ceil(log2(width * height)) + 2). This default is a bounded practical estimate, not a proof that every foreground geometry converges. An application requiring stronger certainty must inspect the published GPU convergence state or gate its dependent graph work on that state.

When convergence is established, converged[0] is 1; foreground labels hold exact sparse representatives, valid background is label 0 with output validity 1, and missing observations are label 0 with output validity 0. If the round budget is insufficient, converged[0] remains 0 and every output label and every output-validity element is cleared. This fail-closed contract prevents a downstream renderer, count, region measurement, or another GPU stage from mistaking partial labels for finished segmentation.

Optional iterationCount reports the number of rounds that actually performed GPU work, including the final unchanged round that proves convergence. Empty-foreground and all-background inputs converge without turning valid background into missing data. Increase a visibly insufficient iteration budget only by explicitly rebuilding or selecting a graph with the new bounded specialization.

One initialization pass, three passes for each declared round, and one final gated publication produce exactly 3 * maximumIterations + 2 graph stages. Each stage uses at most eight storage bindings. Graph-owned scratch contains 4 * width * height parent bytes, one four-byte change flag, and one twelve-byte indirect dispatch record before allocation alignment; caller-owned values, output validity, convergence, and optional iteration counters are additional. Pointer compression and GPU-controlled zero-workgroup dispatch reduce active execution after convergence but do not remove already declared graph stages. Source masks, labels, output validity, and published status remain caller-owned. This sparse contributor does not itself partition a full image, remap dense labels, resolve cross-tile equivalences, or synchronize with the CPU.

Dense component labels and bounded counts

Use GPURasterDenseComponents after a converged GPURasterConnectedComponents result when component IDs must index a compact output or the application needs the actual number of regions. Sparse representative 9 means “the region rooted at pixel index 8,” not “nine regions.” Dense relabeling instead orders genuine roots by their row-major source pixel and assigns contiguous IDs starting at 1; zero remains reserved for valid background.

new GPURasterDenseComponents({
id: 'bounded-vegetation-regions',
width,
height,
input: representativeLabels,
inputValidity: componentValidity,
converged: componentConvergence,
output: denseRegionLabels,
outputValidity: denseRegionValidity,
componentCount: publishedRegionCount,
overflow: regionCapacityOverflow,
requiredComponentCount: exactRequiredRegionCount,
capacity: 3
}).addToGraph(graph);

Every input and output is a caller-owned GraphDataView<'uint32'> from the same command graph. input, inputValidity, output, and outputValidity each contain width * height elements; converged, componentCount, overflow, and optional requiredComponentCount each contain one. Capacity is an integer from 0 through width * height; omitting it admits the entire raster. The contributor marks canonical representative roots, composes the shared unsigned exclusive GPUScan, and scatters dense IDs entirely on the GPU. Root markers, prefix ranks, and scan scratch remain graph-owned; no label or count is polled or downloaded by the contributor.

Sparse roots, dense ranks, and capacity

For five disconnected foreground roots:

Sparse representative labels: Root flags: Dense labels:

1 0 3 1 0 1 1 0 2
0 5 0 0 1 0 0 3 0
7 0 9 1 0 1 4 0 5

The exact required count is 5, not the largest sparse representative 9. With capacity: 3, only dense labels 1, 2, and 3 are published. The result becomes:

Bounded dense labels: Output validity: Meaning:

1 0 2 1 1 1 two retained regions and real background
0 3 0 1 1 1 one retained region and real background
0 0 0 0 1 0 two dropped regions and real background

requiredComponentCount = 5
componentCount = 3
overflow = 1

A dropped foreground pixel is label = 0, validity = 0; a genuine background pixel is label = 0, validity = 1. Missing input remains invalid. The zero labels in the last row therefore have two different meanings: the outer pixels exceeded capacity, while the center is a real valid background observation. Consumers must inspect validity and overflow instead of treating all zero labels as interchangeable.

componentCount is always the capacity-clamped published population: min(requiredComponentCount, capacity). overflow is 1 exactly when the converged required population exceeds capacity; it is reset on each graph encoding rather than remaining sticky. requiredComponentCount is optional, but when supplied it receives the exact unclamped count. With zero capacity, every foreground output is invalid; an empty/background-only input still has count 0, overflow 0, and valid background masks.

Convergence gates every dense output

Dense relabeling consumes the exact GPU converged scalar published by its sparse upstream contributor. If that scalar is zero, every dense label, output-validity flag, bounded count, optional required count, and overflow value is cleared. Thus overflow = 0 alone does not prove a usable result: consumers must also require converged = 1. Out-of-range or malformed sparse representatives are rejected at the affected pixels without out-of-bounds root access.

Capacity is a bound on published component IDs, not a count of foreground pixels and not a global multi-tile region limit. Ordering is deterministic within the current raster/core; per-region measurements, centroid/area tables, and cross-tile region identity reconciliation remain future contributors. Source decoding, graph submission, fences, and any optional compact readback remain application-owned.

Raster compute versus image effects

Existing luma.gl image-processing effects are useful for presentation: they transform rendered color textures or screen-space imagery as part of an effects pipeline. LuRaster neighborhood and edge, and morphology contributors instead process explicit scientific raster bands with raw nodata, source validity, calibration, signed derivative conventions, analytical boundary policies, foreground/background topology, and separately published validity masks. Their output can flow directly into GPU histograms, statistics, thresholding, contour extraction, or later raster algorithms without copying intermediate pixels to the CPU.

Prefer ordinary image effects when the desired result is only a visual postprocess. Prefer LuRaster when filtered values must retain scientific meaning, compose with reusable command graphs and transient scratch, or fit an explicitly tiled analytical pipeline. Separable Gaussian and box kernels have a documented tap-count advantage over equivalent direct square convolution; end-to-end speed and large-raster throughput remain adapter- and workload-dependent until they are measured. Halo assembly remains explicitly requested; transparent oversized-raster partitioning and FFT-backed filtering are not provided by these contributors.

Marching-squares contour classification

Use GPURasterContourClassifier when another GPU algorithm needs to know where a scalar surface crosses a value, but should own geometry emission or downstream topology itself. For example, classify vegetation-index boundaries before creating custom polygon overlays, or mark temperature isotherms without copying the source raster to the CPU.

new GPURasterContourClassifier({
id: 'vegetation-boundary-cells',
width,
height,
input: ndviBand,
level: 0.35,
cases: contourCases,
segmentCounts: contourSegmentCounts
}).addToGraph(graph);

The caller allocates one uint32 case and count for each of (width - 1) * (height - 1) cells. Corners are classified with value >= level; the low four case bits represent top-left, top-right, bottom-right, and bottom-left. Ambiguous diagonal cases 5 and 10 use a deterministic bilinear/asymptotic decider, recorded in the 0x10 flag. A cell touching masked, nodata, or non-finite corners produces no segments. Each valid case emits zero, one, or two segments. A one-row GPU float32 view can replace a literal level when another graph pass computes the threshold.

Classification is useful when callers need compact topology, custom styling, or a later segmentation step. It is not a replacement for a screen-space shader when the only goal is a temporary visual highlight.

GPU contour geometry and indirect overlays

Use GPURasterContours when an analytical boundary should become reusable line geometry: vegetation isolines, elevation contours, concentration thresholds, or map overlays. The contributor composes case classification, a GPU prefix scan, bounded segment scattering, and an optional GPU-written indirect draw count without reading the raster or segment count.

const contourCommands = new DrawCommandBuffer(device, {
id: 'vegetation-contour-draw',
type: 'draw',
commands: [{vertexCount: 2, instanceCount: 0, firstVertex: 0, firstInstance: 0}]
});

new GPURasterContours({
id: 'vegetation-contours',
width,
height,
input: ndviBand,
level: 0.35,
vertices: contourVertices,
segmentCount: visibleSegmentCount,
overflow: contourOverflow,
requiredSegmentCount: totalSegmentCount,
draw: contourCommands.importToGraph(graph),
metadata: rasterMetadata
}).addToGraph(graph);

// Bind a line-list model whose vertex shader reads contourVertices, then:
contourCommands.draw(renderPass, 0);

vertices is a caller-owned packed float32x2 view containing two vertices per segment. Its length must therefore be even. segmentCount is clamped to vertex capacity, overflow signals truncated output, and optional requiredSegmentCount reports the original unclamped requirement. Cases are emitted in deterministic row-major order. The draw record describes a non-indexed, two-vertex instanced line. Every encoding publishes the complete four-word indirect command: vertexCount: 2, the capacity-clamped instanceCount, firstVertex: 0, and firstInstance: 0. This remains valid when the selected indirect-command slot was zero-initialized or reused for a different draw.

Coordinates remain raster-local float32 pixel positions. Pixel-area rasters use pixel centers; point-sampled rasters use integer sample coordinates. Preserve the supplied affine transform, tile origin, and CRS on the CPU when projecting positions into a geographic overlay; silently converting large world coordinates to float32 would lose precision. Multi-tile seam ownership and deck.gl-specific adapters remain separate future work.

Analytical contours versus presentation effects

Existing luma.gl image effects primarily shade or filter a rendered framebuffer. They are appropriate when the output only needs to look different on screen. LuRaster contours process the original scalar samples and nodata mask, produce reusable numerical vector geometry, and keep classification, scan, scatter, and indirect rendering in one GPU-owned workflow.

Classification and bounded scattering scale linearly with raster-cell count, while emitted geometry scales with crossing-segment count. Prefix scanning introduces explicit additional passes and scratch storage. GPU-written indirect counts also let an application draw without waiting for a CPU count, although this particular dashboard separately reads compact scalar summaries for its controls. These structural advantages matter for repeated analysis or downstream vector consumers, but they are not a universal speed guarantee: source resolution, memory bandwidth, segment density, adapter limits, and application benchmarks determine actual cost.

Adapter limits and ownership

getRasterDeviceLimits(device) reports effective dispatch, allocation, and storage-binding ceilings. planRasterDispatchStripes(device, {width, height}) returns caller-managed, whole-row stripes that respect those limits; it does not automatically rewrite large inputs.

Pointwise and neighborhood contributors use bounded two-dimensional dispatch. Histogram and extent primitives still use bounded 256-invocation one-dimensional passes. On adapters allowing 65,535 workgroups per dimension, a 4096 × 4096 single-view histogram needs 65,536 workgroups and is rejected; the application must process smaller tiles or explicitly managed stripes. Replayable global histogram merges combine those explicit bounded tiles; transparent automatic large-raster partitioning remains unimplemented. Halo planning, neighbor assembly, and core extraction are explicit caller-composed operations; they do not automatically partition an oversized source or bypass adapter limits.

Compiled graph destruction releases graph-owned transient allocations and computations but not caller-owned imported buffers or textures. The application controls graph encoding, submission, resource lifetimes, coordinate reprojection, and any synchronization or readback.

Current scope and clean-room implementation

Percentile-driven contrast application, built-in GeoTIFF/COG decoding, per-region measurements, cross-tile region identity, tiled contour stitching, automatic whole-image result placement, and FFT-backed raster convolution are not part of the current implementation. Application-owned tile ingress, source-provided overviews/windows, independently budgeted multi-tile CPU/GPU residency, fence-safe eviction, compatible compiled-graph reuse, explicit cumulative neighborhood halo planning and native-format GPU assembly, half-open core extraction, nodata-aware calibrated floating-point overview means and weighted pyramids, exact integer categorical nearest/mode overviews, generated affine/CRS metadata, Sobel, Scharr, Laplacian, gradient magnitude, bounded spatial smoothing, binary/grayscale dilation, erosion, opening, closing, deterministic four/eight-connected sparse representative labels with fail-closed GPU convergence, deterministic dense root ranks, exact/bounded component counts and per-execution capacity overflow, replayable global tiled extent/population/sum/histogram merges, explicit sticky/saturating overflow diagnostics, bounded histogram-based percentiles, global Otsu input, and single-raster contour extraction are implemented.

The design is informed by public cuCIM documentation, but all TypeScript and WGSL are independently implemented for browser WebGPU. LuRaster does not copy cuCIM kernels or bundle CUDA, OpenSlide, NVIDIA codecs, or image decoders. Review the cuCIM third-party license notices when selecting separately licensed application-side decoders.