diff --git a/web/src/layout.ts b/web/src/layout.ts index d48c91f..f546088 100644 --- a/web/src/layout.ts +++ b/web/src/layout.ts @@ -6,10 +6,10 @@ // one chunk, as the routes serve it. Its OME metadata follows worldarray's ome_multiscales // (web/test/metadata.test.ts runs worldarray's fixture of it), and it imports only what // Node's tests can run. -import { spatialAxes } from "./placement.ts"; +import { axisTypes, TO_SECONDS } from "./placement.ts"; import type { ViewAxis, ViewInfo } from "./types.ts"; -export const TO_SECONDS: Record = { s: 1, second: 1, millisecond: 1e-3, ms: 1e-3, minute: 60, hour: 3600, day: 86400 }; +export { TO_SECONDS }; const OME_UNIT: Record = { nm: "nanometer", um: "micrometer", "µm": "micrometer", m: "meter", s: "second" }; /** zarr v2's dtypes, by numpy's name (the engine's dtypes); little endian. */ export const V2_DTYPE: Record = { @@ -17,14 +17,6 @@ export const V2_DTYPE: Record = { }; export const isTime = (a: ViewAxis) => a.unit in TO_SECONDS; -/** Each axis's OME type, as worldarray types it: time when named so or in a time unit, - * channel when named c, space when spatial (named z, y or x, else the last three), else - * none. */ -function axisTypes(axes: ViewAxis[]): (string | undefined)[] { - const spatial = new Set(spatialAxes(axes.map((a) => a.name))); - return axes.map((a, i) => (a.name === "t" || a.name === "time" || isTime(a) ? "time" : a.name === "c" ? "channel" : spatial.has(i) ? "space" : undefined)); -} - /** A level of a view as one of a group's datasets: its path in the group, and which of the * view's levels it is; `lo` (a box of the level, materialize.ts): the level's voxel that is * the dataset's voxel 0, on the level's own axes (none: the level's voxel 0). */ @@ -46,7 +38,7 @@ export const translation = (v: ViewInfo, level: number, lo?: number[]) => { * lands where the view does; a dataset that starts at voxel `lo` of its level is translated * by `lo` voxels more, so its voxel 0 lands on the level's voxel `lo`. */ export function multiscale(name: string, v: ViewInfo, datasets: Dataset[]) { - const added = (v.added ?? []).map(() => 0), types = axisTypes(v.axes); + const added = (v.added ?? []).map(() => 0), types = axisTypes(v.axes.map((a) => a.name), v.axes.map((a) => a.unit)); return { name, axes: [...added.map(() => ({ name: "c", type: "channel" })), diff --git a/web/src/ome.ts b/web/src/ome.ts index 1e61d03..a820fc4 100644 --- a/web/src/ome.ts +++ b/web/src/ome.ts @@ -3,7 +3,7 @@ // sharded or not, with the usual codecs (zstd and blosc through numcodecs). import * as zarr from "zarrita"; import { asReals, TYPED, type Numbers, type Reals, type Settable, type TypedCtor } from "./asread"; -import { multiscalesOf, placement, placeZarr, type Attrs, type Placement } from "./placement"; +import { axisTypes, multiscalesOf, placement, placeZarr, type Attrs, type Placement } from "./placement"; import type { ControlGrid } from "./types"; import { fetchUrl } from "./urls"; @@ -46,7 +46,7 @@ export function fetchStore(url: string): zarr.FetchStore { * worldarray places it (placement.ts). */ export async function openImage(url: string): Promise { const root = zarr.root(fetchStore(url.replace(/\/+$/, ""))); - const attrs = (await zarr.open(root, { kind: "group" })).attrs as Attrs; + const attrs = ((await zarr.open(root, { kind: "group" })).attrs ?? {}) as Attrs; const ms = multiscalesOf(attrs)[0]; if (!ms) throw new Error(`${url}: no OME-Zarr multiscales metadata`); const levels = await Promise.all((ms.datasets as { path: string }[]).map(async (d) => { @@ -58,13 +58,13 @@ export async function openImage(url: string): Promise { return img; } -/** An image of `levels`, each an array and where it is placed; its axes and units are the - * first level's. */ +/** An image of `levels`, each an array and where it is placed; its axes, their units and + * OME types (which pages writing OME metadata for it keep) are the first level's. */ export function image(url: string, levels: { arr: zarr.Array; placed: Placement }[]): Image { - const { axes: names, units } = levels[0].placed; + const { axes: names, units } = levels[0].placed, types = axisTypes(names, units); return { url: url.replace(/\/+$/, ""), names, dtype: levels[0].arr.dtype, lead: names.length - 3, - axes: names.map((name, a) => (units[a] ? { name, unit: units[a] } : { name })), + axes: names.map((name, a) => ({ name, ...(types[a] ? { type: types[a] } : {}), ...(units[a] ? { unit: units[a] } : {}) })), levels: levels.map(({ arr, placed: { voxel, origin } }) => ({ arr, fullShape: arr.shape, scale: voxel, shift: origin, shape: arr.shape.slice(-3), voxel: voxel.slice(-3), origin: origin.slice(-3), })), diff --git a/web/src/placement.ts b/web/src/placement.ts index 2b1cc9b..179fd02 100644 --- a/web/src/placement.ts +++ b/web/src/placement.ts @@ -27,6 +27,17 @@ export function spatialAxes(axes: string[]): number[] { return named.length ? named : axes.map((_, i) => i).slice(-3); } +/** Seconds in each time unit the browser knows. */ +export const TO_SECONDS: Record = { s: 1, second: 1, millisecond: 1e-3, ms: 1e-3, minute: 60, hour: 3600, day: 86400 }; + +/** Each axis's OME type, as worldarray types it: time when named t or time or in a time + * unit, channel when named c, space when spatial (named z, y or x, else the last three), + * else none. */ +export function axisTypes(names: string[], units: string[]): (string | undefined)[] { + const spatial = new Set(spatialAxes(names)); + return names.map((a, i) => (a === "t" || a === "time" || units[i] in TO_SECONDS ? "time" : a === "c" ? "channel" : spatial.has(i) ? "space" : undefined)); +} + /** Voxel 0's centre from its corner: half a voxel on along the spatial axes. */ export function cornerToCentre(corner: number[], voxel: number[], axes: string[] | null): number[] { const sp = new Set(spatialAxes(axes ?? defaultAxes(corner.length))); @@ -124,7 +135,8 @@ function arrayAttrs(attrs: Attrs, n: number): Found { /** A zarr array: the parent group's OME multiscales listing it, else its own funlib or COSEM * attributes, else funlib's keys on the parent; its axes named by _ARRAY_DIMENSIONS or zarr * v3's dimension_names where nothing else names them. */ -export function placeZarr(attrs: Attrs, group: Attrs, name: string, n: number, dimensionNames?: (string | null)[] | null): Found { +export function placeZarr(attrs: Attrs | undefined, group: Attrs, name: string, n: number, dimensionNames?: (string | null)[] | null): Found { + attrs ??= {}; // an array with no attributes of its own const hit = datasetFor(group, name); let found = hit ? ome(hit[0], hit[1], n) : none(); if (!found.voxel) { diff --git a/web/src/sources.ts b/web/src/sources.ts index 996c18f..2418423 100644 --- a/web/src/sources.ts +++ b/web/src/sources.ts @@ -122,8 +122,8 @@ async function arraySource(url: string, select: Record): Promise const base = url.replace(/\/+$/, ""), cut = base.lastIndexOf("/"), parent = base.slice(0, cut); const arr = await zarr.open(zarr.root(fetchStore(base)), { kind: "array" }), n = arr.shape.length; if (n < 3) throw new Error(`${url}: an array of ${n} axes; three or more are read`); - const group = await zarr.open(zarr.root(fetchStore(parent)), { kind: "group" }).then((g) => g.attrs as Attrs, () => ({})); - const attrs = arr.attrs as Attrs, placed = placement(placeZarr(attrs, group, base.slice(cut + 1), n, arr.dimensionNames), n); + const group = await zarr.open(zarr.root(fetchStore(parent)), { kind: "group" }).then((g) => (g.attrs ?? {}) as Attrs, () => ({})); + const attrs = (arr.attrs ?? {}) as Attrs, placed = placement(placeZarr(attrs, group, base.slice(cut + 1), n, arr.dimensionNames), n); if (!placed.placedBy && placed.named) { const coords = await Promise.all(placed.axes.map((d, a) => (a < n - 3 ? null : coordinate(parent, d, arr.shape[a])))); coords.forEach((c, a) => { if (c) { placed.voxel[a] = c.step; placed.origin[a] = c.first; placed.units[a] = c.unit; } }); diff --git a/web/test/metadata.test.ts b/web/test/metadata.test.ts index 45871f0..20df517 100644 --- a/web/test/metadata.test.ts +++ b/web/test/metadata.test.ts @@ -7,7 +7,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { attrs2, group3 } from "../src/layout.ts"; -import { placement, placeN5, placePrecomputed, placeZarr, type Attrs } from "../src/placement.ts"; +import { axisTypes, placement, placeN5, placePrecomputed, placeZarr, type Attrs } from "../src/placement.ts"; import type { ViewInfo } from "../src/types.ts"; const fixture = (name: string): Attrs[] => JSON.parse(readFileSync(new URL(`fixtures/worldarray/${name}`, import.meta.url), "utf8")).cases; @@ -54,3 +54,10 @@ for (const c of fixture("write.json").filter((c) => !c.multiscale_type && !c.met assert.deepEqual(JSON.parse(JSON.stringify(got)), c.expect); }); } + +test("an array with no attributes of its own is placed by its group, its axes typed for OME", () => { + const group = { multiscales: [{ axes: ["t", "c", "z", "y", "x"], datasets: [{ path: "0", coordinateTransformations: [{ type: "scale", scale: [1, 1, 4, 2, 2] }] }] }] }; + const p = placement(placeZarr(undefined, group, "0", 5), 5); // zarrita gives an array without .zattrs no attrs + assert.deepEqual([p.voxel, p.placedBy], [[1, 1, 4, 2, 2], "ome"]); + assert.deepEqual(axisTypes(p.axes, p.units), ["time", "channel", "space", "space", "space"]); // the register page writes them +});