diff --git a/apps/storybook/package.json b/apps/storybook/package.json index dcca070f4..6db97541d 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -20,7 +20,7 @@ "d3-format": "3.1.2", "greenlet": "1.1.0", "modern-normalize": "3.0.1", - "ndarray": "1.0.19", + "ndarray": "1.1.1", "react": "18.3.1", "react-dom": "18.3.1", "react-icons": "5.4.0", @@ -33,7 +33,7 @@ "@storybook/react-vite": "10.4.6", "@types/d3-array": "~3.2.2", "@types/d3-format": "~3.0.4", - "@types/ndarray": "1.0.14", + "@types/ndarray": "1.1.0", "@types/node": "^24.13.2", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", diff --git a/packages/app/package.json b/packages/app/package.json index 2527178b3..c80e1e148 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -57,9 +57,9 @@ "@react-hookz/web": "25.1.1", "@react-three/fiber": "8.18.0", "@types/d3-format": "~3.0.4", - "@types/ndarray": "1.0.14", + "@types/ndarray": "1.1.0", "d3-format": "3.1.2", - "ndarray": "1.0.19", + "ndarray": "1.1.1", "ndarray-ops": "1.2.2", "react-error-boundary": "6.1.2", "react-icons": "5.4.0", diff --git a/packages/app/src/__tests__/CorePack.browser.test.ts b/packages/app/src/__tests__/CorePack.browser.test.ts index 7be49b383..2937944ea 100644 --- a/packages/app/src/__tests__/CorePack.browser.test.ts +++ b/packages/app/src/__tests__/CorePack.browser.test.ts @@ -123,6 +123,23 @@ test('visualize 2D dataset', async () => { expect(figure.getByLabelText('Max: 4e+2')).toBeVisible(); }); +test('visualize 2D float16 dataset', async () => { + // Half-precision values reach the heatmap in a `Float16Array`, which has to + // survive `ndarray` and reach the GPU as a HALF_FLOAT texture. + const { selectVisTab } = await renderApp('/arrays/typed/float16'); + + expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line, Vis.Heatmap]); + expect(getSelectedVisTab()).toBe(Vis.Heatmap); + + const figure = page.getByRole('figure', { name: 'float16' }); + expect(figure).toBeVisible(); + expect(figure.getByLabelText('Max: 3')).toBeVisible(); + + await selectVisTab(Vis.Matrix); + expect(page.getByText('1.000e+0').first()).toBeVisible(); + expect(page.getByText('3.000e+0').first()).toBeVisible(); +}); + test('visualize 2D dataset as line', async () => { const { selectVisTab } = await renderApp('/arrays/twoD'); await selectVisTab(Vis.Line); diff --git a/packages/app/src/providers/mock/mock-file.ts b/packages/app/src/providers/mock/mock-file.ts index d32351614..6e77b0872 100644 --- a/packages/app/src/providers/mock/mock-file.ts +++ b/packages/app/src/providers/mock/mock-file.ts @@ -123,6 +123,7 @@ export function makeMockFile(): GroupWithChildren { array('uint8', { type: intType(false, 8) }), array('int16', { type: intType(true, 16) }), array('int64', { type: intType(true, 64) }), + array('float16', { type: floatType(16) }), array('float32', { type: floatType(32) }), array('float64', { type: floatType(64) }), withImageAttr(array('uint8_rgb', { type: intType(false, 8) })), diff --git a/packages/lib/package.json b/packages/lib/package.json index 89d45a966..2af7463ff 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -63,7 +63,7 @@ "@types/d3-interpolate": "~3.0.4", "@types/d3-scale": "~4.0.9", "@types/d3-scale-chromatic": "~3.1.0", - "@types/ndarray": "~1.0.14", + "@types/ndarray": "~1.1.0", "@types/react-slider": "~1.3.6", "@visx/axis": "4.0.0", "@visx/drag": "4.0.0", @@ -76,7 +76,7 @@ "d3-interpolate": "3.0.1", "d3-scale": "4.0.2", "d3-scale-chromatic": "3.1.0", - "ndarray": "1.0.19", + "ndarray": "1.1.1", "ndarray-ops": "1.2.2", "react-icons": "5.4.0", "react-is": "^18.3.1", diff --git a/packages/lib/src/vis/heatmap/utils.test.ts b/packages/lib/src/vis/heatmap/utils.test.ts index 1b45d5233..ac03763bc 100644 --- a/packages/lib/src/vis/heatmap/utils.test.ts +++ b/packages/lib/src/vis/heatmap/utils.test.ts @@ -1,9 +1,14 @@ import { ScaleType } from '@h5web/shared/vis-models'; import { interpolateGreys } from 'd3-scale-chromatic'; +import ndarray from 'ndarray'; import { describe, expect, it } from 'vitest'; import { DomainError } from '../models'; -import { getLinearGradient, getSafeDomain } from './utils'; +import { + getLinearGradient, + getSafeDomain, + toTextureSafeNdArray, +} from './utils'; const white = 'rgb(255, 255, 255)'; const black = 'rgb(0, 0, 0)'; @@ -73,3 +78,27 @@ describe('getLinearGradient', () => { expect(colorStops).toBe(`${white}, ${white} 50%, ${black} 50%, ${black}`); }); }); + +describe('toTextureSafeNdArray', () => { + it('should pass float16 through as uint16 without copying', () => { + const values = Float16Array.from([1.5, -2.5, 0.5, 3]); + const safe = toTextureSafeNdArray(ndarray(values, [2, 2])); + + // `TEXTURE_TYPES` is keyed by dtype, and only "uint16" maps to + // `HalfFloatType`; "float16" would give an undefined texture type. + expect(safe.dtype).toBe('uint16'); + // Same memory, so no conversion pass and no second allocation. + expect(safe.data.buffer).toBe(values.buffer); + expect(safe.shape).toEqual([2, 2]); + // The bits are unchanged: reading them back as halves gives the input. + expect([...new Float16Array(safe.data.buffer)]).toEqual([ + 1.5, -2.5, 0.5, 3, + ]); + }); + + it('should widen other numeric types to float32', () => { + const safe = toTextureSafeNdArray(ndarray(Int16Array.from([1, 2]), [2])); + expect(safe.dtype).toBe('float32'); + expect([...safe.data]).toEqual([1, 2]); + }); +}); diff --git a/packages/lib/src/vis/heatmap/utils.ts b/packages/lib/src/vis/heatmap/utils.ts index 57bb92e2c..091fdc6d8 100644 --- a/packages/lib/src/vis/heatmap/utils.ts +++ b/packages/lib/src/vis/heatmap/utils.ts @@ -153,15 +153,15 @@ export function scaleDomain( export function toTextureSafeNdArray( ndArr: NdArray, -): NdArray; +): NdArray; // uint16 values are treated as half floats export function toTextureSafeNdArray( ndArr: NdArray | undefined, -): NdArray | undefined; +): NdArray | undefined; export function toTextureSafeNdArray( ndArr: NdArray | undefined, -): NdArray | undefined { +): NdArray | undefined { if (!ndArr) { return undefined; } @@ -170,6 +170,27 @@ export function toTextureSafeNdArray( return ndArr as NdArray; } + if (ndArr.dtype === 'float16') { + /* A `Float16Array` already holds the bits a HALF_FLOAT texture wants, so + * view the same buffer as uint16 instead of widening to float32: no copy, + * and half the texture memory. Element width is unchanged, so the stride + * and offset carry over exactly. three's `DataTexture` does not accept a + * `Float16Array` at all, so the view is required rather than merely + * cheaper. (`Float16Array` is not named below: bare, it means + * `Float16Array`, which does not satisfy `ndarray`'s + * `Float16Array` constraint.) */ + const { shape, stride, offset } = ndArr; + const { buffer, byteOffset, length } = ndArr.data as ArrayBufferView & { + length: number; + }; + return ndarray( + new Uint16Array(buffer, byteOffset, length), + shape, + stride, + offset, + ); + } + return toTypedNdArray(ndArr, Float32Array); } diff --git a/packages/shared/package.json b/packages/shared/package.json index 562e2c760..0a8dfbbe7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -76,14 +76,14 @@ "@esrf/eslint-config": "3.1.0", "@types/d3-array": "~3.2.2", "@types/d3-format": "~3.0.4", - "@types/ndarray": "~1.0.14", + "@types/ndarray": "~1.1.0", "@types/ndarray-ops": "~1.2.7", "@types/node": "^24.13.2", "@types/react": "^18.3.31", "d3-array": "3.2.4", "d3-format": "3.1.2", "eslint": "9.39.4", - "ndarray": "1.0.19", + "ndarray": "1.1.1", "ndarray-ops": "1.2.2", "react": "18.3.1", "typescript": "6.0.3", diff --git a/packages/shared/src/guards.test.ts b/packages/shared/src/guards.test.ts index 22d6999cc..0ad8efce2 100644 --- a/packages/shared/src/guards.test.ts +++ b/packages/shared/src/guards.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { assertScalarValue, assertValue } from './guards'; +import { assertScalarValue, assertValue, isTypedArray } from './guards'; import { arrayShape, arrayType, @@ -103,6 +103,20 @@ describe('assertScalarValue', () => { }); }); +describe('isTypedArray', () => { + it('should accept every typed array a dataset value can hold', () => { + expect(isTypedArray(new Float16Array([1.5]))).toBe(true); + expect(isTypedArray(new Float32Array([1.5]))).toBe(true); + expect(isTypedArray(new Uint8Array([1]))).toBe(true); + }); + + it('should reject bigint typed arrays and non-typed arrays', () => { + expect(isTypedArray(new BigInt64Array([1n]))).toBe(false); + expect(isTypedArray([1.5])).toBe(false); + expect(isTypedArray(undefined)).toBe(false); + }); +}); + describe('assertValue', () => { it('should not throw when value satisfies dataset type and shape', () => { expect(() => diff --git a/packages/shared/src/guards.ts b/packages/shared/src/guards.ts index 5a56f05bd..0bea2a5b1 100644 --- a/packages/shared/src/guards.ts +++ b/packages/shared/src/guards.ts @@ -1,4 +1,9 @@ -import { type Data, type NdArray, type TypedArray } from 'ndarray'; +import { + type Data, + type MaybeFloat16Array, + type NdArray, + type TypedArray, +} from 'ndarray'; import { type ArrayShape, @@ -169,6 +174,13 @@ export function isComplexArray(val: unknown): val is H5WebComplex[] { return Array.isArray(val) && isComplex(val[0]); } +export function isFloat16Array(val: unknown): val is MaybeFloat16Array { + // Unlike the other typed arrays, `Float16Array` is recent enough (Node 24, + // Chrome 135, Firefox 129, Safari 26) that referencing it directly would + // throw a `ReferenceError` in older runtimes. + return 'Float16Array' in globalThis && val instanceof globalThis.Float16Array; +} + export function isTypedArray(val: unknown): val is TypedArray { return ( val instanceof Int8Array || @@ -178,6 +190,7 @@ export function isTypedArray(val: unknown): val is TypedArray { val instanceof Uint8ClampedArray || val instanceof Uint16Array || val instanceof Uint32Array || + isFloat16Array(val) || val instanceof Float32Array || val instanceof Float64Array ); diff --git a/packages/shared/src/mock-values.ts b/packages/shared/src/mock-values.ts index ed7b80d38..03134bcd3 100644 --- a/packages/shared/src/mock-values.ts +++ b/packages/shared/src/mock-values.ts @@ -254,6 +254,7 @@ export const mockValues = { uint8: () => ndarray(Uint8Array.from(range7()), [2, 2]), int16: () => ndarray(Int16Array.from(range7()), [2, 2]), int64: () => ndarray(BigInt64Array.from(range7(), BigInt), [2, 2]), + float16: () => ndarray(Float16Array.from(range7()), [2, 2]), float32: () => ndarray(Float32Array.from(range7()), [2, 2]), float64: () => ndarray(Float64Array.from(range7()), [2, 2]), int8_rgb: () => diff --git a/packages/shared/src/vis-utils.test.ts b/packages/shared/src/vis-utils.test.ts new file mode 100644 index 000000000..9afbce5fe --- /dev/null +++ b/packages/shared/src/vis-utils.test.ts @@ -0,0 +1,38 @@ +import ndarray from 'ndarray'; +import { describe, expect, it } from 'vitest'; + +import { getBounds, toTypedNdArray } from './vis-utils'; + +function values() { + return ndarray(Float16Array.from([0, 1.5, -2.5, 3]), [2, 2]); +} + +// `ndarray` dispatches on the array's type to decide between indexing and +// getters; an unrecognised typed array falls back to "generic", whose getters a +// typed array does not have, so `.get()` throws. Half-precision datasets +// therefore only work once `ndarray` knows the type. +describe('float16 values', () => { + it('should be indexable through ndarray', () => { + const arr = values(); + expect(arr.dtype).toBe('float16'); + expect(arr.get(0, 1)).toBe(1.5); + expect(arr.get(1, 0)).toBe(-2.5); + }); + + it('should have computable bounds', () => { + expect(getBounds(values())).toEqual({ + min: -2.5, + max: 3, + positiveMin: 0, // zero is in the data; `strictPositiveMin` is the `> 0` one + strictPositiveMin: 1.5, + }); + }); + + it('should convert losslessly to a texture-safe array', () => { + // Every half is exactly representable in float32, so widening for the GPU + // must not perturb the values. + const converted = toTypedNdArray(values(), Float32Array); + expect(converted.dtype).toBe('float32'); + expect([...converted.data]).toEqual([0, 1.5, -2.5, 3]); + }); +});