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
67 changes: 49 additions & 18 deletions ts/src/io/to_ngff_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -13,10 +15,47 @@ export interface ToNgffImageOptions {
axesTypes?: Record<string, string> | 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<number>): 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
*/
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<number>);
}
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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;
}
235 changes: 235 additions & 0 deletions ts/test/to_ngff_image_dtype_test.ts
Original file line number Diff line number Diff line change
@@ -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<zarr.DataType, zarr.Readable> },
): Promise<{ values: number[]; constructor: string }> {
const chunk = await zarr.get(image.data, null);
return {
values: Array.from(chunk.data as ArrayLike<number>),
constructor: (chunk.data as object).constructor.name,
};
}

Deno.test("toNgffImage preserves each typed array's element type", async () => {
const cases: [ArrayLike<number>, 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<number>) => {
const image = await toNgffImage(data, { dims: ["y", "x"], shape: [4, 6] });
const store = image.data.store as Map<string, Uint8Array>;
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<string, unknown>;
}[]).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<string, Uint8Array>();
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<number>), Array.from(data));
});
8 changes: 8 additions & 0 deletions ts/test/typed_array_support_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
Expand Down
Loading