Skip to content

fix(ts): write toNgffImage's buffer as the type it holds - #672

Open
vboussot wants to merge 1 commit into
fideus-labs:mainfrom
vboussot:fix/ts-issue-665-dtype
Open

fix(ts): write toNgffImage's buffer as the type it holds#672
vboussot wants to merge 1 commit into
fideus-labs:mainfrom
vboussot:fix/ts-issue-665-dtype

Conversation

@vboussot

@vboussot vboussot commented Aug 24, 2026

Copy link
Copy Markdown
Member

Fixes #665, found in #664.

toNgffImage created its zarr array with data_type: "float32" whatever came
in, kept a caller's Uint8Array or Uint16Array, and wrote it through an
as Float32Array cast. A chunk is copied as raw bytes sized by the destination
data type, so the buffer was reinterpreted:

const image = await toNgffImage(new Uint8Array([1, ..., 24]), {
  dims: ["y", "x"],
  shape: [4, 6],
});
// dtype: "float32"
// values: 1.54e-36, 4.06e-34, 1.07e-31, 2.82e-29, 7.43e-27, 1.95e-24, 0 x18

The other typed arrays were coerced rather than corrupted: Int8Array,
Int16Array, Int32Array, Uint32Array and Float64Array went through
new Float32Array(data), which keeps the values but drops the type and the
precision past float32's exact range. 1/3 read back as 0.3333333432674408, and
a uint32 of 4294967295 as 4294967296, outside the range of the type it was
written as.

The change

typedInputOf pairs a typed array with the zarr data type of its elements,
and that type reaches defaultCodecs as well as zarr.create:

// dtype: "uint8", values: 1, 2, 3, ..., 24

Python's to_ngff_image preserves the input dtype end to end, and this was
the only string literal in a data_type position under ts/src; every other
zarr.create that writes a buffer derives its type from the data. Deriving
the codecs from the same type matters on its own: byte shuffle on 1-byte
elements interleaves unrelated values, which costs 14% (cthead1) to 71%
(bat-cochlea-volume) of encoded size on this repository's uint8 test images,
so uint8 now gets noshuffle and typesize: 1.

Uint8ClampedArray, what canvas.getImageData() returns, is not a
Uint8Array, so it has its own branch and is stored as uint8. Plain
JavaScript arrays and any other ArrayLike become float32; Python reads
data.ndim and rejects lists, so there is no behaviour to mirror there.

Integer Gaussian downsampling for these images goes through the existing
cast-to-float32, round, cast-back path in itkwasm-shared.ts, the port of the
Python precision workaround, which a float32-typed image never entered. The
downsampled pixel values now match Python exactly: for the same 64x64 uint8
input, level 1 is byte-identical between the two ports.

Tests

to_ngff_image_dtype_test.ts reads the values back for all nine typed-array
types, pins the blosc parameters for uint8, uint16 and float64, pins the
float32 default for plain arrays, and carries a uint8 buffer through
toMultiscales and toOmeZarr into a store. Nine of its eleven tests
require the source change; the two that do not pin the plain-array default and
the float32 round-trip. The dtype assertions added in
typed_array_support_test.ts cover both pyramid levels, where shape
assertions alone hold even for a corrupted buffer.

597 passed, 0 failed.

itk-wasm limitations this uncovers, left out of scope

The float32 coercion was hiding missing type instantiations in the downsample
pipelines. Called directly, @itk-wasm/downsample 2.0.2 (JS) and
itkwasm-downsample 2.0.1 (Python) fail identically, so the gaps are in the
shared wasm, not in either binding:

pipeline scalar VariableLengthVector
downsample all 8 dtypes ok int8, int32, uint32 fail
downsampleBinShrink all 8 dtypes ok int8, int32, uint32 fail
downsampleLabelImage float64 fails every dtype fails

I did not find these reported in InsightSoftwareConsortium/ITK-Wasm; they are
worth an upstream issue.

What reaches them differs between the ports. methods/_itkwasm.py enters
vector mode only when c is the last dim, which _canonical_axis_order has
already made false, so Python iterates channels on the scalar path and only
ever hits the float64 label_image gap, which fails there too. The TypeScript
gate (itkwasm-node.ts:201 and its twins) omits the c-last condition and
takes the vector path for any c axis of 8 channels or fewer. Two
consequences, both reachable before this change on images built by
fromNgffZarr or itkImageToNgffImage and unchanged by it:

  • ITKWASM_LABEL_IMAGE with 2 to 8 channels throws for every dtype,
    float32 included.
  • ITKWASM_BIN_SHRINK with 2 to 8 channels throws for int8, int32 and
    uint32, where Python succeeds.

Aligning the TypeScript gate on the Python one, or falling back to the
per-channel loop that already exists below it, deserves its own issue.

Summary by CodeRabbit

  • Bug Fixes

    • Preserved supported numeric typed-array formats when converting images to NGFF/Zarr.
    • Maintained exact signed, unsigned, floating-point, and uint8 values through serialization and deserialization.
    • Standardized clamped byte arrays and unsupported array-like inputs for consistent handling.
    • Applied format-appropriate compression settings.
  • Tests

    • Added comprehensive coverage for data types, value round-tripping, multiscale images, and serialization behavior.

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3709e267-b7b5-4cf9-87ef-139892aea288

📥 Commits

Reviewing files that changed from the base of the PR and between 00b78af and 3a07a0c.

📒 Files selected for processing (1)
  • ts/test/to_ngff_image_dtype_test.ts

Included review availability: 8 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

toNgffImage now preserves supported numeric typed-array dtypes during Zarr serialization. Unsupported array-like inputs use float32, and tests cover dtype mapping, exact round trips, codecs, and multiscale output.

Changes

Typed-array dtype preservation

Layer / File(s) Summary
Input detection and normalization
ts/src/io/to_ngff_image.ts, ts/test/to_ngff_image_dtype_test.ts
toNgffImage detects numeric typed arrays, normalizes Uint8ClampedArray to Uint8Array, preserves supported dtypes, and converts plain arrays to float32.
Dtype-aware Zarr serialization
ts/src/io/to_ngff_image.ts, ts/test/to_ngff_image_dtype_test.ts, ts/test/typed_array_support_test.ts
Zarr arrays and Blosc codecs use the detected dtype. Tests verify exact integer and floating-point round trips, metadata, and multiscale serialization.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 3a07a

The change preserves typed-array data types through image creation and processing, with reported coverage for supported inputs and downstream paths. No actionable merge-blocking risk remains after normal checks and review.

Poem

I’m a rabbit with bytes in my den,
Signed and unsigned now round-trip again.
Float values stay precise and bright,
Clamped bytes hop to the proper type.
Zarr keeps each dtype just right—
I thump my paws with pure delight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation and tests satisfy #665 by preserving typed-array dtypes, preventing reinterpretation, and validating stored values.
Out of Scope Changes check ✅ Passed The implementation and test changes directly support dtype preservation and validation required by #665.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: preserving the input buffer type in toNgffImage.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 00b78af595

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ts/test/to_ngff_image_dtype_test.ts Outdated
Comment on lines +227 to +229
await toNgffZarr(store, multiscales, { version: "0.5" });

const read = await fromNgffZarr(store);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the legacy Zarr aliases

This newly added integration test calls toNgffZarr and fromNgffZarr, which are retained only as compatibility aliases; use toOmeZarr and fromOmeZarr instead so new tests exercise and promote the canonical API names.

AGENTS.md reference: AGENTS.md:L301-L308

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to toOmeZarr / fromOmeZarr in 3a07a0c.

The zarr array was created with `data_type: "float32"` whatever came in,
while a caller's `Uint8Array` or `Uint16Array` was kept and written through
an `as Float32Array` cast. A chunk is copied as raw bytes sized by the
destination data type, so the buffer was reinterpreted: a 24-element
`Uint8Array` of 1..24 read back as 6 float32 values (1.54e-36, 4.06e-34, ...)
followed by 18 zeros, and a `Uint16Array` of the same length as 12 values
followed by 12 zeros.

The other typed arrays were coerced rather than corrupted. `Int8Array`,
`Int16Array`, `Int32Array`, `Uint32Array` and `Float64Array` went through
`new Float32Array(data)`, which keeps the values, loses precision past
float32's exact range, and drops the type: 1/3 came back as
0.3333333432674408, and a uint32 of 4294967295 as 4294967296, outside the
range of the type it was written as. Python's `to_ngff_image` preserves the
input dtype end to end. This was the only string literal in a `data_type`
position under ts/src: every other `zarr.create` that writes a buffer takes
its type from that buffer or from the source array's dtype, and the one that
writes no data, `utils/factory.ts`, takes it as a parameter.

`typedInputOf` pairs a typed array with the zarr data type of its elements,
and that type reaches `defaultCodecs` as well as `zarr.create`, so the blosc
parameters match the data too. uint8 gets noshuffle and typesize 1: byte
shuffle on 1-byte elements interleaves unrelated values instead of separating
one element's bytes, which costs 14% (cthead1) to 71% (bat-cochlea-volume) on
this repository's own uint8 test images. `Uint8ClampedArray` is not a
`Uint8Array`, so it has its own branch and is stored as uint8. Plain
JavaScript arrays and any other `ArrayLike` become float32; Python reads
`data.ndim` and rejects lists, so there is nothing to mirror there.
`calculateStride` comes from `utils/transpose.ts`, which exports the same
function.

`to_ngff_image_dtype_test.ts` reads the values back for all nine typed-array
types, pins the blosc parameters for uint8, uint16 and float64, pins the
float32 default for plain arrays, and carries a uint8 buffer through
`toMultiscales` and `toOmeZarr` into a store. The dtype assertions in
`typed_array_support_test.ts` cover both pyramid levels, where the shape
assertions alone hold even for a corrupted buffer.

Two itk-wasm failures stop being masked by the float32 coercion, and neither
is a defect of this package alone. `downsampleLabelImage` has no float64
instantiation and `downsampleBinShrink` no VariableLengthVector instantiation
for int8, int32 or uint32, in the Python binding exactly as in the JS one.
Python still downsamples 3-channel int8/int32/uint32 images because
`methods/_itkwasm.py` enters vector mode only when `c` is the last dim, which
`_canonical_axis_order` has already made false; the TypeScript gate omits
that condition and takes the vector path where Python iterates channels.
That divergence deserves its own issue.

597 passed, 0 failed.

Closes fideus-labs#665
@vboussot
vboussot force-pushed the fix/ts-issue-665-dtype branch from 00b78af to 3a07a0c Compare August 24, 2026 11:08
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.

toNgffImage write buffer type

1 participant