Skip to main content

GPURaster regions and contours

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. Compose GPURasterRegionMeasurements after dense relabeling for bounded per-region geometry and intensity; cross-tile component merging remains a separate contract.

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. GPURasterRegionMeasurements can consume those converged dense IDs directly; cross-tile region identity reconciliation remains a separate future contributor. Source decoding, graph submission, fences, and any optional compact readback remain application-owned.

Per-region intensity and spatial measurements

Use GPURasterRegionMeasurements when contiguous classified regions need actual measurements: the number of region pixels, the population and distribution of a scientific intensity band, their raster-local centroid, or affine region area. The contributor consumes converged, nonoverflowing dense region IDs and writes 11 independently allocated caller-owned GPU columns.

new GPURasterRegionMeasurements({
id: 'vegetation-region-measurements',
metadata: rasterMetadata,
labels: denseRegionLabels,
labelValidity: denseRegionValidity,
converged: componentConvergence,
componentCount: publishedRegionCount,
overflow: regionCapacityOverflow,
intensity: vegetationIndexBand,
capacity: 256,
output: {
pixelCounts: regionPixelCounts,
intensityCounts: regionIntensityCounts,
intensitySums: regionIntensitySums,
intensityMinimums: regionIntensityMinimums,
intensityMaximums: regionIntensityMaximums,
intensityMeans: regionIntensityMeans,
columnSums: regionColumnSums,
rowSums: regionRowSums,
centroidColumns: regionCentroidColumns,
centroidRows: regionCentroidRows,
areas: regionAreas
}
}).addToGraph(graph);

The first two result columns are exact GraphDataView<'uint32'>; the remaining nine are GraphDataView<'float32'>. Every column has the same caller-allocated length; row zero describes dense region ID 1. capacity defaults to that length and may be explicitly reduced to any integer from zero through the allocated length. Zero-length columns are valid and add no grouped passes. Nonempty columns with capacity: 0 are cleared into their empty-row state. Every input/output belongs to the same graph, and all 11 output buffers must remain distinct.

Region geometry and measured intensity are different populations

The region mask and intensity mask answer different questions. Consider a valid three-pixel region whose intensity sensor missed its middle sample:

Dense region IDs: [ 1, 1, 1]
Region validity: [ 1, 1, 1]
Intensity values: [ 10, 999, 20]
Intensity validity: [ 1, 0, 1]

pixelCounts[0] = 3
intensityCounts[0] = 2
intensitySums[0] = 30
intensityMeans[0] = 15
intensityMinimums[0] = 10
intensityMaximums[0] = 20

The region still has three geometric pixels; its centroid and area include all three. Only the two valid, finite, non-nodata intensity observations contribute to intensity count, sum, minimum, maximum, or mean. Dividing 30 by the geometric count 3 would incorrectly report 10; the correct mean is 30 / intensityCounts[0] = 15.

Intensity accepts GPURasterBufferBand<'float32'> only. Raw-domain validity and exact nodata are checked before applying raw * scale + offset exactly once; nonfinite raw or calibrated values are rejected. Integer source intensities require an explicit caller-controlled float conversion with its precision tradeoff; uint32 values above 2^24 are not all exactly representable as float32. Floating atomic accumulation order can vary, so sums, means, and centroid moments should be compared with an appropriate numerical tolerance.

Pixel centroids, affine translation, and coordinate units

columnSums and rowSums are mergeable moments of every valid geometric region pixel. Area-interpreted pixels contribute their centers (column + 0.5, row + 0.5); point-interpreted pixels contribute (column, row). The local centroid is:

centroidColumns[region] = columnSums[region] / pixelCounts[region]
centroidRows[region] = rowSums[region] / pixelCounts[region]

Keeping these GPU outputs in local pixel coordinates avoids adding a large projected world origin in float32. Once an application explicitly inspects a selected centroid, use the retained JavaScript-double affine metadata:

const worldCentroid = getRasterRegionWorldCentroid(
rasterMetadata,
selectedCentroidColumn,
selectedCentroidRow
);

For affine = [a, b, c, d, e, f]:

worldX = a * centroidColumn + b * centroidRow + c
worldY = d * centroidColumn + e * centroidRow + f
pixelArea = abs(a * e - b * d)
regionArea = pixelCounts[region] * pixelArea

The centroid already includes the appropriate area/point center offset; do not add another half-pixel. Source/tile affine metadata also already contains its origin; do not add levelZeroOrigin again. Rotation, shear, negative scale, and non-square pixels are included in the affine determinant. Area is always expressed in squared CRS coordinate units: a projected meter-based CRS yields square meters, while a geographic degree-based CRS yields square degrees. GPURaster does not perform geodesic area conversion or reproject coordinates.

Empty rows, capacity failure, and execution ownership

Valid topology with no accepted intensity observations publishes a real nonzero pixelCounts entry, zero intensityCounts and sum, and NaN intensity minimum/maximum/mean; geometric centroid and area remain valid. Completely unused rows publish zero counts, sums, moments, and area, with NaN intensity extrema/mean and NaN centroid coordinates.

If the upstream dense stage did not converge, its overflow flag is nonzero, its published count exceeds region capacity, or a dense label is invalid/out of range, region grouping refuses the unsafe rows. Global convergence/overflow/count failures reset every output row to the empty contract on each encoding; partially valid-looking tables never survive a failed execution. Group keys are zero-based scratch derived from bounded 1-based labels, never unbounded sparse representatives. Existing GPUGroupAggregation contributors perform the typed grouped work; intermediate keys, masks, calibrated values, and moments are graph-owned transient resources.

All result columns, metadata, source samples, submission, completion fences, optional inspection, and coordinate policy remain application-owned. No full label array, region table, source imagery, or CPU result is read by the contributor. Cross-tile region identity reconciliation, global region merges, polygon-zonal metrics, and exact integer intensity aggregation remain separate future integrations.

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. GPURaster 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, 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, grouped geometric and valid-intensity populations, float-only intensity sum/min/max/mean, mergeable local centroid moments and affine area, 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. GPURaster 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.