From 3a07a0cb954fb77589ca73d34b04525ed349469a Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Fri, 21 Aug 2026 21:16:58 +0200 Subject: [PATCH] fix(ts): write toNgffImage's buffer as the type it holds 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 #665 --- ts/src/io/to_ngff_image.ts | 67 +++++--- ts/test/to_ngff_image_dtype_test.ts | 235 ++++++++++++++++++++++++++++ ts/test/typed_array_support_test.ts | 8 + 3 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 ts/test/to_ngff_image_dtype_test.ts diff --git a/ts/src/io/to_ngff_image.ts b/ts/src/io/to_ngff_image.ts index 6b60b9d4..b3cedea6 100644 --- a/ts/src/io/to_ngff_image.ts +++ b/ts/src/io/to_ngff_image.ts @@ -2,6 +2,8 @@ import * as zarr from "zarrita"; import { defaultCodecs } from "../utils/codecs.ts"; import { NgffImage } from "../types/ngff_image.ts"; import type { MemoryStore } from "../io/from_ngff_zarr.ts"; +import { calculateStride } from "../utils/transpose.ts"; +import type { NumericTypedArray } from "../utils/transpose.ts"; import { zarrSet } from "../utils/worker_pool.ts"; export interface ToNgffImageOptions { @@ -13,10 +15,47 @@ export interface ToNgffImageOptions { axesTypes?: Record | undefined; } +/** A buffer paired with the zarr data type its elements are stored as. */ +interface TypedInput { + data: NumericTypedArray; + dataType: zarr.DataType; +} + +/** + * Pair a typed array with the zarr data type of its elements. + * + * The buffer reaches the store as raw bytes sized by the array's declared + * `data_type`, so the type named here has to be the type the buffer holds. + * + * Covers the typed arrays whose elements are numbers and that this package + * can encode. A generic `ArrayLike` or a `Float16Array` returns `null`, and + * the caller materializes a float32 copy. The 64-bit integer arrays hold + * `bigint` rather than `number`, so they fall outside the declared input + * type. + */ +function typedInputOf(data: ArrayLike): TypedInput | null { + if (data instanceof Uint8Array) return { data, dataType: "uint8" }; + if (data instanceof Uint8ClampedArray) { + // Elements are 8-bit, but zarr writes "uint8" from a Uint8Array. + return { data: new Uint8Array(data), dataType: "uint8" }; + } + if (data instanceof Int8Array) return { data, dataType: "int8" }; + if (data instanceof Uint16Array) return { data, dataType: "uint16" }; + if (data instanceof Int16Array) return { data, dataType: "int16" }; + if (data instanceof Uint32Array) return { data, dataType: "uint32" }; + if (data instanceof Int32Array) return { data, dataType: "int32" }; + if (data instanceof Float32Array) return { data, dataType: "float32" }; + if (data instanceof Float64Array) return { data, dataType: "float64" }; + return null; +} + /** * Convert array data to NgffImage * - * @param data - Input data as typed array or regular array + * @param data - Input data as typed array or regular array. A typed array's + * element type is preserved: the zarr array is created with the matching + * `data_type` and holds the caller's values. Plain JavaScript arrays, and + * any other `ArrayLike`, are converted to float32. * @param options - Configuration options for NgffImage creation * @returns NgffImage instance */ @@ -34,7 +73,8 @@ export async function toNgffImage( } = options; // Determine data shape and create typed array - let typedData: Float32Array | Uint8Array | Uint16Array; + let typedData: NumericTypedArray; + let dataType: zarr.DataType = "float32"; let shape: number[]; if (Array.isArray(data)) { @@ -84,10 +124,10 @@ export async function toNgffImage( } // Preserve the original typed array type - if (data instanceof Uint8Array) { - typedData = data; - } else if (data instanceof Uint16Array) { - typedData = data; + const typedInput = typedInputOf(data); + if (typedInput) { + typedData = typedInput.data; + dataType = typedInput.dataType; } else { typedData = new Float32Array(data as ArrayLike); } @@ -116,15 +156,15 @@ export async function toNgffImage( const zarrArray = await zarr.create(root.resolve("data"), { shape, chunk_shape: chunkShape, - data_type: "float32", + data_type: dataType, fill_value: 0, - codecs: defaultCodecs("float32"), + codecs: defaultCodecs(dataType), }); // Write data to zarr array; a null selection targets the full array (an // empty selection list writes nothing). await zarrSet(zarrArray, null, { - data: typedData as Float32Array, + data: typedData, shape, stride: calculateStride(shape), }); @@ -161,12 +201,3 @@ export async function toNgffImage( computedCallbacks: undefined, }); } - -function calculateStride(shape: number[]): number[] { - const stride = new Array(shape.length); - stride[shape.length - 1] = 1; - for (let i = shape.length - 2; i >= 0; i--) { - stride[i] = stride[i + 1] * shape[i + 1]; - } - return stride; -} diff --git a/ts/test/to_ngff_image_dtype_test.ts b/ts/test/to_ngff_image_dtype_test.ts new file mode 100644 index 00000000..d99b8318 --- /dev/null +++ b/ts/test/to_ngff_image_dtype_test.ts @@ -0,0 +1,235 @@ +// SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC +// SPDX-License-Identifier: MIT +/** + * The element type `toNgffImage` stores, and the values it stores. + * + * A zarr chunk is copied as raw bytes sized by the array's declared + * `data_type`, so a buffer written under a type it does not hold is + * reinterpreted rather than converted, and the shapes stay right either way. + * Every test here reads the values back. + */ + +import { assert, assertEquals } from "@std/assert"; +import * as zarr from "zarrita"; + +import { fromOmeZarr } from "../src/io/from_ngff_zarr.ts"; +import type { MemoryStore } from "../src/io/from_ngff_zarr.ts"; +import { toNgffImage } from "../src/io/to_ngff_image.ts"; +import { toOmeZarr } from "../src/io/to_ngff_zarr.ts"; +import { toMultiscales } from "../src/process/to_multiscales-node.ts"; + +/** The values stored in `image`, in row-major order. */ +async function readBack( + image: { data: zarr.Array }, +): Promise<{ values: number[]; constructor: string }> { + const chunk = await zarr.get(image.data, null); + return { + values: Array.from(chunk.data as ArrayLike), + constructor: (chunk.data as object).constructor.name, + }; +} + +Deno.test("toNgffImage preserves each typed array's element type", async () => { + const cases: [ArrayLike, string][] = [ + [new Uint8Array(24), "uint8"], + [new Uint8ClampedArray(24), "uint8"], + [new Int8Array(24), "int8"], + [new Uint16Array(24), "uint16"], + [new Int16Array(24), "int16"], + [new Uint32Array(24), "uint32"], + [new Int32Array(24), "int32"], + [new Float32Array(24), "float32"], + [new Float64Array(24), "float64"], + ]; + + for (const [data, dataType] of cases) { + const image = await toNgffImage(data, { + dims: ["y", "x"], + shape: [4, 6], + }); + assertEquals(image.data.dtype, dataType, data.constructor.name); + } +}); + +Deno.test("toNgffImage round-trips uint8 values", async () => { + // 255 catches a narrower type, and a byte-wise reinterpretation would + // populate only the first six of the twenty-four elements. + const data = new Uint8Array(24); + for (let i = 0; i < data.length; i++) { + data[i] = i + 1; + } + data[23] = 255; + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(image.data.dtype, "uint8"); + assertEquals(constructor, "Uint8Array"); + assertEquals(values, Array.from(data)); +}); + +Deno.test("toNgffImage round-trips uint16 values", async () => { + // Above 255, so a truncation to uint8 shows up here too. + const data = new Uint16Array(24); + for (let i = 0; i < data.length; i++) { + data[i] = (i + 1) * 1000; + } + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(image.data.dtype, "uint16"); + assertEquals(constructor, "Uint16Array"); + assertEquals(values, Array.from(data)); +}); + +Deno.test("toNgffImage round-trips signed values", async () => { + const int8 = new Int8Array(24); + const int16 = new Int16Array(24); + const int32 = new Int32Array(24); + for (let i = 0; i < 24; i++) { + int8[i] = i % 2 === 0 ? i + 1 : -(i + 1); + int16[i] = -(i + 1) * 1000; + int32[i] = -(i + 1) * 100000; + } + + for (const data of [int8, int16, int32]) { + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(constructor, data.constructor.name); + assertEquals(values, Array.from(data)); + } +}); + +Deno.test("toNgffImage round-trips 32-bit unsigned values", async () => { + // Beyond float32's exact integer range, where a widening to float32 is + // lossy. + const data = new Uint32Array(24); + for (let i = 0; i < data.length; i++) { + data[i] = 16777216 + i + 1; + } + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(image.data.dtype, "uint32"); + assertEquals(constructor, "Uint32Array"); + assertEquals(values, Array.from(data)); +}); + +Deno.test("toNgffImage round-trips float32 values", async () => { + // Multiples of 0.25 are exact in float32, so the comparison is exact. + const data = new Float32Array(24); + for (let i = 0; i < data.length; i++) { + data[i] = (i + 1) * 0.25; + } + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(image.data.dtype, "float32"); + assertEquals(constructor, "Float32Array"); + assertEquals(values, Array.from(data)); +}); + +Deno.test("toNgffImage keeps float64 precision", async () => { + // 1/3 is not representable in float32, so a widening is visible in the + // read-back. + const data = new Float64Array(24); + for (let i = 0; i < data.length; i++) { + data[i] = (i + 1) / 3; + } + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(image.data.dtype, "float64"); + assertEquals(constructor, "Float64Array"); + assertEquals(values, Array.from(data)); +}); + +Deno.test("toNgffImage stores a Uint8ClampedArray as uint8", async () => { + // What canvas ImageData hands over. It is not a Uint8Array, so it needs + // its own branch in the element-type chain. + const data = new Uint8ClampedArray(24); + for (let i = 0; i < data.length; i++) { + data[i] = i + 1; + } + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const { values, constructor } = await readBack(image); + + assertEquals(image.data.dtype, "uint8"); + assertEquals(constructor, "Uint8Array"); + assertEquals(values, Array.from(data)); +}); + +Deno.test("toNgffImage converts plain arrays to float32", async () => { + const twoDimensional = await toNgffImage([[1, 2, 3], [4, 5, 6]], { + dims: ["y", "x"], + }); + assertEquals(twoDimensional.data.dtype, "float32"); + assertEquals((await readBack(twoDimensional)).values, [1, 2, 3, 4, 5, 6]); + + const oneDimensional = await toNgffImage([1, 2, 3, 4], { dims: ["x"] }); + assertEquals(oneDimensional.data.dtype, "float32"); + assertEquals((await readBack(oneDimensional)).values, [1, 2, 3, 4]); + + const threeDimensional = await toNgffImage([[[1, 2], [3, 4]]], { + dims: ["z", "y", "x"], + }); + assertEquals(threeDimensional.data.dtype, "float32"); + assertEquals((await readBack(threeDimensional)).values, [1, 2, 3, 4]); +}); + +Deno.test("toNgffImage compresses for the element type it wrote", async () => { + // defaultCodecs derives the blosc typesize and shuffle mode from the data + // type; a 1-byte type takes noshuffle. + const blosc = async (data: ArrayLike) => { + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const store = image.data.store as Map; + const key = [...store.keys()].find((k) => k.endsWith("zarr.json")); + assert(key, "no array metadata in store"); + const metadata = JSON.parse(new TextDecoder().decode(store.get(key)!)); + const codec = (metadata.codecs as { + name: string; + configuration: Record; + }[]).find((entry) => entry.name === "blosc"); + assert(codec, "no blosc codec declared in array metadata"); + return codec.configuration; + }; + + assertEquals(await blosc(new Uint8Array(24)), { + cname: "zstd", + clevel: 5, + shuffle: "noshuffle", + typesize: 1, + blocksize: 0, + }); + const uint16 = await blosc(new Uint16Array(24)); + assertEquals(uint16.shuffle, "shuffle"); + assertEquals(uint16.typesize, 2); + assertEquals((await blosc(new Float64Array(24))).typesize, 8); +}); + +Deno.test("uint8 values survive the write to a store", async () => { + // What the image holds is what lands in the store, so a data type the + // pipeline reports but does not write is caught here. + const data = new Uint8Array(4 * 6); + for (let i = 0; i < data.length; i++) { + data[i] = i * 10 + 1; + } + + const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] }); + const multiscales = await toMultiscales(image, { scaleFactors: [] }); + const store: MemoryStore = new Map(); + await toOmeZarr(store, multiscales, { version: "0.5" }); + + const read = await fromOmeZarr(store); + const scale0 = read.images[0].data; + assertEquals(scale0.dtype, "uint8"); + + const chunk = await zarr.get(scale0, null); + assertEquals(Array.from(chunk.data as ArrayLike), Array.from(data)); +}); diff --git a/ts/test/typed_array_support_test.ts b/ts/test/typed_array_support_test.ts index 0e5b5cfb..bf6b998f 100644 --- a/ts/test/typed_array_support_test.ts +++ b/ts/test/typed_array_support_test.ts @@ -20,6 +20,8 @@ Deno.test("support Int8Array", async () => { }); assertEquals(multiscales.images.length, 2); + assertEquals(multiscales.images[0].data.dtype, "int8"); + assertEquals(multiscales.images[1].data.dtype, "int8"); assertEquals(multiscales.images[1].data.shape[0], 128); assertEquals(multiscales.images[1].data.shape[1], 128); }); @@ -41,6 +43,8 @@ Deno.test("support Uint32Array", async () => { }); assertEquals(multiscales.images.length, 2); + assertEquals(multiscales.images[0].data.dtype, "uint32"); + assertEquals(multiscales.images[1].data.dtype, "uint32"); assertEquals(multiscales.images[1].data.shape[0], 64); assertEquals(multiscales.images[1].data.shape[1], 64); }); @@ -62,6 +66,8 @@ Deno.test("support Int32Array", async () => { }); assertEquals(multiscales.images.length, 2); + assertEquals(multiscales.images[0].data.dtype, "int32"); + assertEquals(multiscales.images[1].data.dtype, "int32"); assertEquals(multiscales.images[1].data.shape[0], 64); assertEquals(multiscales.images[1].data.shape[1], 64); }); @@ -83,6 +89,8 @@ Deno.test("support Float64Array", async () => { }); assertEquals(multiscales.images.length, 2); + assertEquals(multiscales.images[0].data.dtype, "float64"); + assertEquals(multiscales.images[1].data.dtype, "float64"); assertEquals(multiscales.images[1].data.shape[0], 64); assertEquals(multiscales.images[1].data.shape[1], 64); });