Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,16 @@ from itkwasm import (
addKwargs += ` kwargs["${camelCase(parameter.name)}"] = to_js(${snakeCase(parameter.name)})\n`
}
} else {
addKwargs += ` if ${snakeCase(parameter.name)}:\n`
// Use a presence check (not truthiness) for numeric scalar options so that valid falsy values --
// notably 0 for an integer/float option -- are still forwarded. BOOL, list, and TEXT options keep the
// truthiness guard (an explicit False, an empty list, or an unset string is a no-op).
const numericScalar =
parameter.itemsExpectedMax <= 1 &&
parameter.type !== 'BOOL' &&
!parameter.type.startsWith('TEXT')
addKwargs += numericScalar
? ` if ${snakeCase(parameter.name)} is not None:\n`
: ` if ${snakeCase(parameter.name)}:\n`
addKwargs += ` kwargs["${camelCase(parameter.name)}"] = to_js(${snakeCase(parameter.name)})\n`
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,12 @@ from itkwasm import (
args += ` input_count += 1\n`
}
} else {
args += ` if ${snake}:\n`
// Use a presence check (not truthiness) for numeric scalar options so that valid falsy values --
// notably 0 for an integer/float option -- are still forwarded to the pipeline. TEXT options keep the
// truthiness guard so an unset (empty-string) option is not forwarded.
args += parameter.type.startsWith('TEXT')
? ` if ${snake}:\n`
: ` if ${snake} is not None:\n`
if (parameter.type.startsWith('TEXT:{')) {
const choices = parameter.type.split('{')[1].split('}')[0].split(', ')
args += ` if ${snake} not in (${choices.map((c) => `'${c}'`).join(',')}):\n`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,9 @@ function functionModule(
return
}
const camel = camelCase(parameter.name)
functionContent += ` if (options.${camel}) {\n`
// Use a presence check (not truthiness) so that valid falsy values -- notably 0 for a numeric option --
// are still forwarded. BOOL options are additionally guarded below, so an explicit `false` is a no-op.
functionContent += ` if (typeof options.${camel} !== "undefined") {\n`
if (parameter.type === 'BOOL') {
functionContent += ` options.${camel} && args.push('--${parameter.name}')\n`
} else if (parameter.itemsExpectedMax > 1) {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/typescript/itk-wasm/src/cli/bindgen.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ function bindgen(options) {
(binary) => !path.basename(binary).startsWith('lib')
)

// Honor an optional, opt-in exclude list so build-only helper executables (e.g. self-contained
// test-input generators used by C++ CTests) are not emitted as public language bindings. The list is
// configured as string pipeline names under "itk-wasm.bindgen-exclude" in the package's package.json,
// where each name matches a binary's stem after stripping the toolchain and .wasm suffixes
// (e.g. "foo-generate-inputs" matches both "foo-generate-inputs.wasm" and "foo-generate-inputs.wasi.wasm").
let bindgenExclude = []
try {
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'))
bindgenExclude = packageJson['itk-wasm']?.['bindgen-exclude'] ?? []
} catch (err) {
// No package.json or no config in the current directory: nothing to exclude.
}
if (bindgenExclude.length > 0) {
filteredWasmBinaries = filteredWasmBinaries.filter((binary) => {
const stem = path
.basename(binary)
.replace(/\.wasm$/, '')
.replace(/\.(wasi|emscripten)$/, '')
return !bindgenExclude.includes(stem)
})
}

switch (iface) {
case 'typescript':
typescriptBindgen(outputDir, buildDir, filteredWasmBinaries, options)
Expand Down
96 changes: 95 additions & 1 deletion packages/downsample/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ elseif(WASI)
else()
set(io_components
ITKImageIO
ITKTransformIO
)
endif()

Expand All @@ -21,12 +22,34 @@ find_package(ITK REQUIRED
WebAssemblyInterface
ITKSmoothing
ITKImageGrid
ITKTransform
GenericLabelInterpolator
${io_components}
)
include(${ITK_USE_FILE})

foreach(pipeline downsample downsample-sigma gaussian-kernel-radius downsample-bin-shrink downsample-label-image)
set(pipelines
downsample
downsample-sigma
gaussian-kernel-radius
downsample-bin-shrink
downsample-label-image
resample-bounding-box
)

# resample-bounding-box-generate-inputs fabricates the self-contained inputs (fixed image, moving image,
# transform) for the resample-bounding-box C++ CTests, and resample-bounding-box-test is an in-process unit
# test that asserts the computed regions directly. Both run under the WASI/native toolchains and are
# deliberately NOT built for Emscripten so they never leak into the browser/Node bindings (TypeScript bindgen
# scans the Emscripten build). They ARE present in the WASI build, however, which the Python bindgen scans --
# neither is an itk-wasm pipeline, so both MUST also be listed under "itk-wasm.bindgen-exclude" in package.json
# or Python bindgen crashes trying to read their (empty) interface JSON. The real, public pipeline is
# resample-bounding-box.
if(NOT EMSCRIPTEN)
list(APPEND pipelines resample-bounding-box-generate-inputs resample-bounding-box-test)
endif()

foreach(pipeline ${pipelines})
add_executable(${pipeline} ${pipeline}.cxx)
target_link_libraries(${pipeline} PUBLIC ${ITK_LIBRARIES})
target_include_directories(${pipeline} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
Expand Down Expand Up @@ -69,3 +92,74 @@ add_test(NAME downsample-label-image
${CMAKE_CURRENT_BINARY_DIR}/cthead1_downsampled_label_image.png
--shrink-factors 2 2
)

# Generate the resample-bounding-box inputs (fixed image, moving image, transform), then feed them
# to the pipeline. The pipeline's positional argument order (transform, fixed, moving, output) matches
# the option declaration order in resample-bounding-box.cxx. The generator is only built for the
# WASI/native toolchains (see above), so these tests are scoped to those toolchains as well; the
# in-memory Node/Python bindings exercise the pipeline for the Emscripten target instead.
if(NOT EMSCRIPTEN)
add_test(NAME resample-bounding-box-generate-inputs
COMMAND resample-bounding-box-generate-inputs
${CMAKE_CURRENT_BINARY_DIR}/rbb-fixed.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-moving.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-transform.iwt
)

add_test(NAME resample-bounding-box
COMMAND resample-bounding-box
${CMAKE_CURRENT_BINARY_DIR}/rbb-transform.iwt
${CMAKE_CURRENT_BINARY_DIR}/rbb-fixed.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-moving.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-output.json
--padding 1
)

set_tests_properties(resample-bounding-box PROPERTIES DEPENDS resample-bounding-box-generate-inputs)

# 3D translation: generate the 3D inputs, then run the pipeline end-to-end through the dimension dispatch.
add_test(NAME resample-bounding-box-generate-inputs-3d
COMMAND resample-bounding-box-generate-inputs
--case 3d-translation
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-fixed.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-moving.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-transform.iwt
)

add_test(NAME resample-bounding-box-3d
COMMAND resample-bounding-box
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-transform.iwt
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-fixed.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-moving.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-3d-output.json
--padding 1
)

set_tests_properties(resample-bounding-box-3d PROPERTIES DEPENDS resample-bounding-box-generate-inputs-3d)

# 2D affine rotation: transformed grid corners are non-axis-aligned, stressing full-boundary sampling.
add_test(NAME resample-bounding-box-generate-inputs-rotation
COMMAND resample-bounding-box-generate-inputs
--case 2d-rotation
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-fixed.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-moving.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-transform.iwt
)

add_test(NAME resample-bounding-box-rotation
COMMAND resample-bounding-box
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-transform.iwt
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-fixed.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-moving.iwi
${CMAKE_CURRENT_BINARY_DIR}/rbb-rot-output.json
--padding 1
)

set_tests_properties(resample-bounding-box-rotation PROPERTIES DEPENDS resample-bounding-box-generate-inputs-rotation)

# In-process unit test: asserts the exact regions for the 2D/3D/rotation cases and the Phase-04 hardening
# (full-boundary sampling, symmetric padding, padding-independent corners, degenerate axis, instance reuse).
add_test(NAME resample-bounding-box-test
COMMAND resample-bounding-box-test
)
endif()
158 changes: 158 additions & 0 deletions packages/downsample/docs/resample-bounding-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
type: reference
title: Resample Bounding Box Pipeline
created: 2026-07-02
tags:
- itk-wasm
- resampling
- bounding-box
- transform
related:
- '[[downsample]]'
- '[[affine-ops]]'
---

# Resample Bounding Box Pipeline

`resample-bounding-box` computes the padded region of a **moving** image that is needed to resample a **fixed**
image's grid through a spatial transform — *without touching a single pixel*. It reads only image metadata
(size, spacing, origin, direction), so both images may be supplied with empty data buffers. The result is a small
JSON object describing exactly which sub-region of the moving image a caller must fetch before performing the real,
pixel-level resample (for example with [[downsample]] or an affine warp built from [[affine-ops]]).

The pipeline is part of the `@itk-wasm/downsample` / `itkwasm-downsample` packages and runs identically in C++
(WASI/native CTest), TypeScript (browser/Node), and Python.

## Motivation

Resampling a fixed grid into a moving image only ever reads moving-image samples inside the transformed footprint
of the fixed grid. When the moving image is large (or remote, or chunked), materializing all of it just to resample
a small overlapping region is wasteful. `resample-bounding-box` answers the question **"which moving-image indices
will the resample actually read?"** using arithmetic on metadata alone, so a caller can fetch only that block.

## Inputs

Positional arguments are, in order, `transform`, `fixed`, `moving`, `bounding-box` (output), matching the CLI and
the generated bindings.

| Input | Type | Notes |
| --- | --- | --- |
| `transform` | `INPUT_TRANSFORM` | Spatial transform mapping **fixed**-image physical points into **moving**-image physical space. Any single, non-composite parameterization at dimension 2, 3, or 4 (e.g. `TranslationTransform`, `AffineTransform`). Read at double precision regardless of stored precision. |
| `fixed` | `INPUT_IMAGE` | Fixed image whose grid is resampled. **Metadata only** — the pixel buffer is never dereferenced and may be empty. |
| `moving` | `INPUT_IMAGE` | Moving image to be sampled. **Metadata only** — buffer may be empty. |
| `--padding` | `int`, default `1` | Pixels of padding added per side (symmetrically). The default of `1` covers linear interpolation, which reads one neighbor beyond the continuous-index bound. Use `0` for the tight region, or a larger value for wider-support interpolators. |

Both images must share the transform's dimension. The pipeline dispatches on the transform dimension itself, so a
2D transform expects 2D images, a 3D transform 3D images, and so on.

### Metadata-only contract

The fixed and moving pixel buffers are **never** read. In the bindings this means the caller can construct images
whose `data` is an empty array of the correct component type and still receive a correct region. This is the whole
point: describe two images and a transform with a few numbers, learn precisely which moving-image block to fetch,
and only then move real pixels.

## Algorithm

For a fixed image of dimension *N*:

1. **Sample selection.** Ask the transform whether it is linear (`itk::Transform::IsLinear()`, i.e.
`GetTransformCategory() == Linear` — true for translation, rigid, similarity, and affine transforms).
- **Linear (fast path).** Sample only the `2^N` grid *corners* (4 in 2D, 8 in 3D, …). A linear map sends the
fixed rectangle to a convex region, so its axis-aligned bound is already attained at the transformed corners —
the corners give the exact same box as the full boundary would, at `2^N` transforms independent of image size.
- **Nonlinear.** Enumerate every *boundary* pixel of the fixed grid — every pixel with at least one index
component equal to `0` or `size_d - 1` (all faces and edges, not merely the corners). Interior pixels are
skipped efficiently (the fastest axis is fast-forwarded to its last column), so the cost is proportional to the
number of boundary pixels, not the total pixel count.
2. **Transform.** Map each sampled point's physical location (corners on the linear fast path, boundary pixels
otherwise) through the transform into moving-image physical space, and convert to a continuous index in the
moving image (`TransformPhysicalPointToContinuousIndex`). All math is done at double precision.
3. **Bounding box.** Accumulate the per-axis min/max of the transformed physical points (`corners`) and of the
moving-image continuous indices.
4. **Moving index region.** Convert the continuous-index bounds to an integer region: `floor` the min and `ceil`
the max per axis.
5. **Padding.** Expand the integer region outward by `--padding` on every side (`start -= padding`,
`end += padding`). The resulting per-axis size is clamped to a minimum of `0` (never negative).

### Corner fast path vs. full-boundary sampling

For a **linear** transform (translation, rigid, similarity, affine — rotation, scale, shear) the image of the fixed
rectangle is convex, so its axis-aligned bound is already achieved at the `2^N` transformed corners. The pipeline
detects this via `itk::Transform::IsLinear()` and samples **only the corners** — a `2^N`-point computation
independent of image size, and provably identical to what a full-boundary walk would return.

Enumerating the entire boundary is required only for **nonlinear** transforms (e.g. B-spline, displacement field),
where an interior edge pixel can map *outside* the hull of the transformed corners; sampling only the corners would
then *under-bound* the region and the later resample would read outside the fetched block. The pipeline therefore
walks the full boundary whenever the transform is not linear. (The C++ unit test covers both paths: it asserts the
linear cases sample exactly `2^N` corners yet reproduce the same region a boundary walk gives, and includes a
nonlinear "bulge" transform whose right-edge midpoint maps beyond the transformed corners — confirming the full
boundary is walked, and matters, precisely when the transform is nonlinear.)

### Edge cases

- A **degenerate fixed image** with any zero-length axis has no boundary pixels; the pipeline reports an all-zero,
empty region rather than producing garbage indices.
- **Negative effective sizes** (e.g. a large negative `--padding`) are clamped so `paddedSize` is never negative.
- `padding` is applied **symmetrically** per side, and the unpadded `corners` are the tight transformed-point
extremes **regardless** of the padding value.

## Output JSON schema

The `bounding-box` output is a single JSON object (`OUTPUT_JSON`). All arrays are length *N* (the image
dimension), ordered fastest-axis-first (x, y, z, …).

```json
{
"paddedStartIndex": [int, ...],
"paddedSize": [uint, ...],
"paddedCorners": { "min": [double, ...], "max": [double, ...] },
"corners": { "min": [double, ...], "max": [double, ...] }
}
```

| Field | Meaning |
| --- | --- |
| `paddedStartIndex` | Integer start index, **in the moving image**, of the region to fetch (padding included). May be negative if the transformed grid extends past the moving-image origin. |
| `paddedSize` | Integer size, in moving-image pixels, of the region to fetch. Clamped to ≥ 0. |
| `paddedCorners.min` / `.max` | Physical-space coordinates of the padded region's start index and inclusive-end index (from the moving-image grid). |
| `corners.min` / `.max` | The **tight** (unpadded) transformed-point extremes in moving-image physical space. Padding-independent. |

### Worked example (2D translation)

Fixed `16×16`, spacing `(2, 2)`, origin `(10, 20)`; moving `64×64`, spacing `(1, 1)`, origin `(0, 0)`;
translation `(10, 5)`; `--padding 1`:

```json
{"paddedStartIndex":[19,24],"paddedSize":[33,33],"paddedCorners":{"min":[19,24],"max":[51,56]},"corners":{"min":[20,25],"max":[50,55]}}
```

The output is compact (no whitespace). The `corners` / `paddedCorners` values are JSON numbers of type *double*, but
an integer-valued coordinate is emitted without a trailing `.0` (e.g. `19`, not `19.0`) — the value is identical and
every binding parses it as a floating-point number. A non-integer coordinate keeps its fraction at full precision:
the 2D affine rotation below yields, for example, `"corners":{"min":[9.2,5.200000000000001],"max":[29.799999999999997,23.800000000000004]}`.

The tight corners `[20,25]–[50,55]` are the translated fixed grid; padding 1 grows the integer region outward by
one pixel per side. A 3D translation and a 2D affine rotation (non-axis-aligned corners `[9.2,5.2]–[29.8,23.8]`)
are covered by the same tests and produce identical results across C++, TypeScript, and Python.

## Intended downstream use

The reported region is a *fetch plan*. A typical pipeline:

1. Call `resample-bounding-box(transform, fixed, moving, padding=…)` with **metadata-only** fixed and moving images.
2. Read `paddedStartIndex` and `paddedSize` and fetch only that sub-region of the moving image (a streamed read,
a chunk request, or an `itk::RegionOfInterestImageFilter`-style crop) — intersecting with the moving image's
largest possible region if you need to stay in bounds.
3. Perform the real resample of the fixed grid through the same `transform`, now reading pixels only from the
fetched block. See [[downsample]] for the resampling/anti-aliasing side, and [[affine-ops]] for constructing
and composing the affine transforms fed in here.

Because the region is computed from metadata alone, steps 1–2 are cheap enough to run before deciding whether a
resample is even worthwhile (e.g. skipping non-overlapping tiles).

## See also

- [[downsample]] — the resampling / anti-aliasing pipelines in this package.
- [[affine-ops]] — building, inverting, and composing the affine transforms consumed by this pipeline.
9 changes: 7 additions & 2 deletions packages/downsample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
"typescript-package-name": "@itk-wasm/downsample",
"python-package-name": "itkwasm-downsample",
"package-description": "Pipelines for downsampling images.",
"repository": "https://github.com/InsightSoftwareConsortium/ITK-Wasm"
"repository": "https://github.com/InsightSoftwareConsortium/ITK-Wasm",
"bindgen-exclude": [
"resample-bounding-box-generate-inputs",
"resample-bounding-box-test"
]
},
"scripts": {
"build": "pnpm build:gen:typescript && pnpm build:gen:python",
Expand All @@ -41,6 +45,7 @@
"@itk-wasm/dam": "^1.1.0",
"itk-wasm": "workspace:^",
"@itk-wasm/compare-images-build": "workspace:^",
"@itk-wasm/image-io-build": "workspace:^"
"@itk-wasm/image-io-build": "workspace:^",
"@itk-wasm/transform-io-build": "workspace:^"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
from .downsample_sigma_async import downsample_sigma_async
from .downsample_async import downsample_async
from .gaussian_kernel_radius_async import gaussian_kernel_radius_async
from .resample_bounding_box_async import resample_bounding_box_async

from ._version import __version__
Loading
Loading