Skip to content

feat(downsample): add resample-bounding-box pipeline (metadata-only, C++/TS/Python) - #1549

Merged
thewtex merged 9 commits into
InsightSoftwareConsortium:mainfrom
thewtex:resample-bounding-box
Jul 7, 2026
Merged

feat(downsample): add resample-bounding-box pipeline (metadata-only, C++/TS/Python)#1549
thewtex merged 9 commits into
InsightSoftwareConsortium:mainfrom
thewtex:resample-bounding-box

Conversation

@thewtex

@thewtex thewtex commented Jul 3, 2026

Copy link
Copy Markdown
Member

Summary

Adds a new resample-bounding-box pipeline to the @itk-wasm/downsample / itkwasm-downsample package. Given a spatial transform, a fixed image, and a moving image, it computes the padded sub-region of the moving image that a caller must fetch in order to resample the fixed image's grid through that transform — using image metadata only (size, spacing, origin, direction). No pixel buffer is ever dereferenced, so both images may be passed with empty data.

The pipeline is implemented once in C++ and shipped across all three runtimes — C++ (WASI/native CTest), TypeScript (browser + Node), and Python (WASI + Emscripten) — producing identical results.

Why

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, remote, or chunked, materializing all of it just to resample a small overlapping region is wasteful. This pipeline answers "which moving-image indices will the resample actually read?" from arithmetic on metadata alone, so a caller can fetch only that block (or skip non-overlapping tiles entirely) before moving any real pixels. It complements the existing resampling pipelines in this package and the affine-transform tooling used to build the transforms fed into it.

What changed

New pipeline (C++)

  • resample-bounding-box.cxx — pipeline entry point; parses transform, fixed, moving, bounding-box (output JSON) and a --padding option, and serializes the result.
  • resampleBoundingBox.h — the metadata-only bounding-box computation.
  • resampleReadInputTransform.h — generic transform reading/dimension peeking.
  • resample-bounding-box-test.cxx + resample-bounding-box-generate-inputs.cxx — CTest coverage and a self-contained test-input generator.
  • CMakeLists.txt wired up for the new targets.

Language bindings & tests

  • TypeScript: resampleBoundingBox (browser + Node) with generated options/result interfaces, exported from the package index; Node tests and a browser demo-app controller.
  • Python: resample_bounding_box (sync + async) for WASI and Emscripten packages, with WASI pytest coverage.

Documentation

  • docs/resample-bounding-box.md — motivation, inputs, the algorithm, the output JSON schema, edge cases, and intended downstream use.

Core itk-wasm bindgen improvements (needed by this pipeline, but general fixes)

  • Falsy numeric options are now forwarded. TypeScript and Python binding generators previously used a truthiness guard, which silently dropped a valid 0 for a numeric option (e.g. --padding 0). They now use a presence check (typeof !== "undefined" / is not None) for numeric options while keeping the truthiness guard for TEXT/BOOL.
  • New opt-in itk-wasm.bindgen-exclude config in package.json so build-only helper executables (here, the resample-bounding-box-generate-inputs test-input generator) are not emitted as public language bindings.

Implementation details

  • Metadata-only contract. Only size/spacing/origin/direction are read; the fixed and moving pixel buffers are never dereferenced.
  • Dimension dispatch. Supports transforms at dimension 2, 3, and 4. The pipeline dispatches on the transform dimension itself rather than via itk::wasm::SupportInputTransformTypes, because that helper deserializes the transform input (a TransformList / JSON array) as a single transform object and throws for every in-memory transform. --help/--interface-json/--version dispatch to a default 2D functor so bindgen can extract the interface.
  • Double-precision math. The transform is read into the abstract itk::Transform base at double precision — lossless for float32/float64 inputs and robust to .iwt scalar-type detection that can misreport a float64 transform as float32.
  • Full-boundary sampling, not just corners. Every boundary pixel of the fixed grid (all faces/edges, not just the 2^N corners) is transformed to a moving-image continuous index; interior pixels are skipped efficiently. For affine transforms the transformed rectangle is convex so corners would suffice, but for nonlinear transforms an interior edge pixel can map outside the corner hull — sampling the full boundary avoids under-bounding the region.
  • Padding & edge cases. --padding (default 1, covering linear interpolation's one-neighbor read) expands the integer region symmetrically per side; sizes are clamped to ≥ 0; a degenerate fixed image with a zero-length axis yields an empty all-zero region. The unpadded corners are the tight transformed-point extremes regardless of padding.

Output JSON

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

Test plan

  • C++ CTest asserts region results and the boundary-pixel count (guarding against a regression to corners-only sampling), covering 2D/3D translation and a 2D affine rotation with non-axis-aligned corners.
  • Node and Python tests assert identical results to the C++ pipeline across the same cases, including the metadata-only (empty-buffer) contract and --padding 0.

thewtex and others added 3 commits July 3, 2026 11:02
Add a metadata-only ITK-Wasm pipeline that computes the padded
moving-image region needed to resample a fixed image grid through a
spatial transform, emitting a JSON bounding box.

- resampleBoundingBox.h: reusable ResampleBoundingBoxComputer<TTransform>
  (enumerates all fixed-grid boundary pixels, accumulates tight physical
  and moving continuous-index min/max, pads outward). Metadata only.
- resample-bounding-box.cxx: thin WASM wrapper dispatching over
  float/double x dims 2/3/4; emits paddedStartIndex/paddedSize/
  paddedCorners/corners JSON via rapidjson.
- resample-bounding-box-generate-inputs.cxx: self-contained generator of
  fixed/moving .iwi metadata images and a translation .iwt.
- CMakeLists.txt: ITKTransform/ITKTransformIO, new targets, and
  DEPENDS-linked CTests (generate inputs, then run the pipeline).

Verified end-to-end under pnpm build:wasi / CTest (wasmtime): a (10,5)
translation with padding 1 yields the hand-checked bounding box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nding-box

Generate and validate the TypeScript/JavaScript bindings for the
resample-bounding-box pipeline, proving it is callable from Node with
metadata-only images (empty pixel buffers) and a transform.

- Bindings: resampleBoundingBoxNode(transform, fixed, moving, { padding })
  and the browser resampleBoundingBox(...); result field renamed from the
  generic "output" to "boundingBox" at the source (the .cxx option name).
- Exclude the test-only resample-bounding-box-generate-inputs generator from
  the Emscripten build (CMake `if(NOT EMSCRIPTEN)`) so bindgen does not expose
  it; it is still built for the WASI/native C++ CTest.
- resampleReadInputTransform.h: read a spatial transform generically into the
  abstract itk::Transform base via the ITK object factory, from the wasm memory
  store under --memory-io or the filesystem otherwise (always double precision).
- Dispatch on transform dimension in main() instead of SupportInputTransformTypes,
  whose memory-IO type detection mis-parses a TransformList (JSON array) as a
  single transform and throws for every in-memory transform.
- bindgen: forward falsy-but-valid scalar options (e.g. padding: 0) by using a
  presence check instead of truthiness in function-module.js.
- Node tests for padding 1 and padding 0 (region shrinks one pixel per side),
  both with empty-data metadata-only images; auto-discovered by the ava glob.
- Browser demo controller wired into the demo-app.

Verified: `pnpm test:node` passes the new tests; `pnpm test:wasi` still passes
all 7 C++ CTests (no regression to the filesystem read path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng-box

Generate the itkwasm-downsample Python bindings (wasi, emscripten, dispatch)
for the resample-bounding-box pipeline and add a WASI pytest validating the
padded moving-image region for a 2D translation with metadata-only images.

Framework changes:
- cli/bindgen.js: honor an opt-in `itk-wasm.bindgen-exclude` list so build-only
  helper executables (the resample-bounding-box-generate-inputs CTest fixture,
  which must be built under WASI) are not emitted as public language bindings.
- python bindgen (wasi/emscripten function modules): forward numeric scalar
  options with a presence check (`is not None`) instead of truthiness, so a
  valid `padding=0` is no longer dropped to the C++ default of 1 (the Python
  analogue of the Phase-02 TypeScript falsy-0 fix).

pnpm test:python:wasi -> 13 passed (2 new: padding=1 exact region, padding=0
shrinks one pixel per side).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 04 of the resample-bounding-box pipeline: multi-dimension coverage,
robustness, and a documentation artifact. C++ 12/12 CTests, Node 8/8, and
Python wasi 14/14 all pass, with identical regions across the three surfaces.

resampleBoundingBox.h (hardening):
- Guard a degenerate fixed image (any zero-length axis): report an empty
  region instead of floor/ceil-ing sentinel min/max into garbage indices.
- Make boundary-point storage reuse-safe via clear()+reserve()+push_back so
  the vector holds exactly the current call's points -- no stale points leak
  when one ResampleBoundingBoxComputer instance is reused across differing
  boundary counts (shrinking then growing).
- Add a diagnostic (unserialized) numberOfBoundaryPoints so tests can confirm
  full-boundary (not corners-only) sampling. paddedSize stays clamped to >= 0.

resample-bounding-box-generate-inputs.cxx:
- Add a --case selector (2d-translation default, 3d-translation, 2d-rotation)
  emitting each self-contained fixed/moving/transform set; the rotation uses
  an itk::AffineTransform (cos .8/sin .6 about center) -> non-axis-aligned
  corners.

resample-bounding-box-test.cxx (new): in-process unit test asserting the exact
region for the 2D/3D translation and 2D rotation cases at double precision,
plus the hardening (padding symmetry, padding-independent corners, degenerate
axis, instance reuse vs fresh). Built/CTest-run only outside Emscripten.

CMakeLists.txt: 3D and rotation integration CTests (generate -> run, with
DEPENDS) and the unit-test CTest.

TypeScript + Python: parametrized case tables (2D/3D translation, 2D affine
rotation) with dimension-generic image/transform helpers and an affine helper;
assert regions identical to the C++ results, including the padding-0 padded
corners (which equal the tight corners only when those fall on grid lines).

docs/resample-bounding-box.md (new): structured reference (YAML front matter +
[[downsample]]/[[affine-ops]] wiki-links) documenting the inputs, algorithm,
exact JSON schema, and intended downstream sub-region fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thewtex
thewtex force-pushed the resample-bounding-box branch from 3117fa6 to c20b089 Compare July 3, 2026 15:10
@thewtex

thewtex commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

@vboussot please take a look

thewtex and others added 5 commits July 3, 2026 12:54
…o build

The resample-bounding-box browser demo controller imports readTransform
from @itk-wasm/transform-io, but the package was never declared as a
dependency of @itk-wasm/downsample. The production `vite build` (build:demo)
therefore failed in CI with "Rollup failed to resolve import
@itk-wasm/transform-io", breaking the downsample Node.js Tests job at the
build:gen:typescript step.

Add @itk-wasm/transform-io (workspace:^) to the TypeScript package's
devDependencies alongside the other sibling io packages, and record the
corresponding entry in pnpm-lock.yaml. The demo build now resolves the
import (vite build: 716 modules transformed, previously failed at 13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Python bindings are generated by scanning the WASI build, which -- unlike
the Emscripten build that TypeScript bindgen scans -- contains the CTest-only
executables built under `if(NOT EMSCRIPTEN)`: both resample-bounding-box-
generate-inputs AND resample-bounding-box-test. Only the former was listed in
`itk-wasm.bindgen-exclude`, so bindgen tried to extract an interface JSON from
the resample-bounding-box-test unit-test binary, which is not an itk-wasm
pipeline. That yields an empty interface object, and package-dunder-init.js
then calls snakeCase(interfaceJson.name) on undefined, crashing the
`bindgen:python` step of the downsample Python CI job:

  TypeError: Cannot read properties of undefined (reading 'replaceAll')
    at snakeCase (.../bindgen/snake-case.js:2:20)

Add resample-bounding-box-test to bindgen-exclude, and expand the CMakeLists
comment to note that the WASI build (scanned by Python bindgen) contains these
binaries, so any CTest-only executable must be excluded there too.

Verified by running `itk-wasm -b wasi-build bindgen --interface python` against
the local WASI build: it now exits 0 and the generated itkwasm_downsample
__init__.py imports only the six real pipelines (no *_test / *_generate_inputs
modules); the committed Python snapshot is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The resample-bounding-box browser demo controller imports readTransform from
@itk-wasm/transform-io, so the production `vite build` (build:demo) must be
able to resolve that package's built entry. A prior commit declared
@itk-wasm/transform-io as a devDependency of the @itk-wasm/downsample
TypeScript package, which fixed the "Rollup failed to resolve import" error --
but exposed the next one: vite then failed with

  [commonjs--resolver] Failed to resolve entry for package
  "@itk-wasm/transform-io". The package may have incorrect main/module/exports
  specified in its package.json.

i.e. the package resolves, but its dist/ was never built in CI. The downsample
Node.js job builds `pnpm --filter "@itk-wasm/downsample-build..." build:gen:
typescript`; the sibling io packages get built only because their *-build
packages are devDependencies of @itk-wasm/downsample-build (image-io-build,
compare-images-build). transform-io-build was missing from that list, so
transform-io never entered the build closure and its dist was absent when the
demo bundled.

Add @itk-wasm/transform-io-build (workspace:^) to @itk-wasm/downsample-build's
devDependencies (and the matching pnpm-lock.yaml importer entry) so transform-
io's build:gen:typescript runs -- topologically before the downsample demo
build -- and its dist is present. Verified: `pnpm install` reports the lockfile
is up to date, and @itk-wasm/transform-io-build now appears in the
`@itk-wasm/downsample-build...` filter closure with a build:gen:typescript
script. transform-io is imported only by the browser demo (not the node tests,
which build the transform inline), so this fully covers the failing step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ample-bounding-box

ResampleBoundingBoxComputer sampled every fixed-grid boundary pixel (all faces
and edges) for every transform. That is only necessary for NONLINEAR transforms,
where an interior edge pixel can map outside the hull of the transformed corners.
For a LINEAR transform (translation, rigid, similarity, affine) the image of the
fixed rectangle is convex, so its axis-aligned bound is already attained at the
2^Dimension transformed corners -- 4 in 2D, 8 in 3D, 16 in 4D -- independent of
image size.

Detect linearity with itk::Transform::IsLinear() (i.e. GetTransformCategory() ==
Linear), which dispatches polymorphically through the abstract base the pipeline
already holds, and take a corners-only fast path in that case; keep the full
boundary walk for nonlinear transforms. Both paths feed the identical min/max
accumulation, so the computed region is unchanged for linear transforms -- the
existing 2D/3D translation and 2D affine-rotation assertions (and the TypeScript
and Python suites, which use only linear transforms) pass byte-for-byte, now via
the corners.

The corner enumeration is a bitmask over Dimension bits (bit d selects the low or
high index on axis d); a size-1 axis harmlessly duplicates corners. The degenerate
zero-size-axis early return is unchanged and still precedes path selection.

Tests and docs:
- Add a `usedLinearCornerPath` diagnostic to ResampleBoundingBoxResult; repurpose
  the existing numberOfBoundaryPoints assertions to confirm the linear cases now
  sample exactly 2^Dimension corners while producing the same region.
- Add test2DNonlinearBoundary with a custom nonlinear "bulge" transform whose
  right-edge midpoint maps beyond the transformed corners, asserting the
  full-boundary path is taken (56 samples) and that its +x extent exceeds the
  corners-only bound -- locking in that the boundary walk still triggers, and
  matters, exactly when the transform is nonlinear.
- Switch testInstanceReuse to the nonlinear transform so the reused, variable-size
  boundary storage is actually exercised (the linear path is a fixed 2^Dimension).
- Update docs/resample-bounding-box.md to describe both paths.

Verified natively against ITK 5.4 (-Wall -Wextra -Werror, clean): all unit checks
pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ze instead of rapidjson

Replace the hand-built rapidjson DOM in resample-bounding-box.cxx with glaze
(glz::write_json). Two small structs -- CornersJSON and BoundingBox -- mirror the
output schema, and glaze serializes their public members by name in declaration
order via compile-time reflection, so the ~55 lines of Value/AddMember/PushBack
boilerplate (and three lambdas) collapse to a struct literal plus one write call.
Write errors are reported through the same CLI::Error / pipeline.exit() path the
file already uses for the dimension-dispatch error. glaze is already a dependency
of this translation unit (resampleReadInputTransform.h uses it) and reaches the
include path transitively via the WebAssemblyInterface ITK module, so no
CMakeLists change is needed.

Two behaviors worth noting:

- Linkage: the serialization structs are placed in a NAMED namespace, not an
  anonymous one. glaze's member-name reflection instantiates a helper over the
  type, which the (emscripten/clang) toolchain rejects for a type with internal
  linkage ("its type does not have linkage"). A named namespace gives external
  linkage while still scoping the generic type names.

- Number format: glaze writes the shortest round-trippable form, so an
  integer-valued double serializes without a trailing ".0" (e.g. 19, not 19.0).
  The JSON value is identical and every binding parses it as a float. All test
  suites compare numeric values, not text: the C++ unit test uses the in-memory
  ResampleBoundingBoxResult (no JSON), and the TypeScript (closeTo / deepEqual)
  and Python (pytest.approx / ==) suites parse then compare -- and the integer
  index arrays (paddedStartIndex, paddedSize) serialize identically to before.
  Non-integer corners keep full double precision. docs/resample-bounding-box.md
  worked example updated to the actual compact output, with a note.

Verified against the real glaze 2.9.5 in the itkwasm images: the pipeline
compiles and links under emscripten, and a WASI build passes all 7
resample-bounding-box CTests. The emitted JSON for the 2D-translation, 3D-
translation, and 2D-affine-rotation cases matches the expected regions (e.g.
2D translation -> {"paddedStartIndex":[19,24],"paddedSize":[33,33],...}).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thewtex
thewtex merged commit f53aaf9 into InsightSoftwareConsortium:main Jul 7, 2026
68 of 69 checks passed
@thewtex
thewtex deleted the resample-bounding-box branch July 7, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant