From 350884b530ff68b6a7a2abba5e87553901b5d560 Mon Sep 17 00:00:00 2001 From: "mia-autodev[bot]" <341115074+mia-autodev[bot]@users.noreply.github.com> Date: Sun, 15 Nov 2026 00:15:09 -0500 Subject: [PATCH] ome-zarr2-http-timepoint: one level of an OME-Zarr v2 image at one timepoint (t), tczyx presented as czyx Reads a level array of an OME-Zarr image (Zarr v2, chunk keys from .zarray, no listing) and fixes its time axis at params.t, so the rank-5 tczyx arrays of allen-emt-timelapse-hips-colonies open as czyx. Axis order and voxel size come from the image group's multiscales; a one-entry channel axis is not presented, so a one-channel mask pairs with every channel of its raw. Closes #75 Co-Authored-By: Claude Fable 5.2 --- snippets/ome-zarr2-http-timepoint/README.md | 89 +++++++ .../allen-emt-timelapse-hips-colonies.yaml | 21 ++ snippets/ome-zarr2-http-timepoint/snippet.py | 248 ++++++++++++++++++ .../ome-zarr2-http-timepoint/snippet.yaml | 10 + 4 files changed, 368 insertions(+) create mode 100644 snippets/ome-zarr2-http-timepoint/README.md create mode 100644 snippets/ome-zarr2-http-timepoint/examples/allen-emt-timelapse-hips-colonies.yaml create mode 100644 snippets/ome-zarr2-http-timepoint/snippet.py create mode 100644 snippets/ome-zarr2-http-timepoint/snippet.yaml diff --git a/snippets/ome-zarr2-http-timepoint/README.md b/snippets/ome-zarr2-http-timepoint/README.md new file mode 100644 index 0000000..bae4474 --- /dev/null +++ b/snippets/ome-zarr2-http-timepoint/README.md @@ -0,0 +1,89 @@ +# ome-zarr2-http-timepoint + +Reads one resolution level of an OME-Zarr image stored as Zarr v2 objects under an HTTP(S) URL (public S3 +included), **at one timepoint**. An `Array` has no time axis, so a `tczyx` level array is presented as `czyx` +with `params.t` fixed on `t`. Axis order and voxel size come from the image group's `multiscales` metadata. +Like `zarr2-http` it never lists the store: chunk keys are computed from `.zarray`. + +- **URL.** Exactly one: a level's array directory (`…/.ome.zarr/0`, trailing slash or not) or its + `.zarray` object (`…/.ome.zarr/0/.zarray`). **Prefer the `.zarray` form in a binding:** `harness + fetch` and the prefetch of `harness stage` fetch every binding URL whole, and the directory URL is not an + object (404). The URL names the level, so any level can be bound; `0` is full resolution. The image group + URL itself (`…/.ome.zarr`) is not accepted. +- **Group metadata.** The image group is the directory one up from the array, and its `.zattrs` is read once + at open. In `multiscales`, the first entry with a dataset whose `path` is the array's directory name gives: + - **axes**: OME-NGFF 0.4 (`[{name, type, unit}, …]`) or 0.3 (`["t", "c", "z", "y", "x"]`), names from + `t c z y x` (case-insensitive), distinct, one per array dimension, with `y` and `x`. Anything else raises. + With no `axes` (OME-NGFF 0.1 / 0.2, or no `.zattrs` at all) a rank-5 array is read as `tczyx`, which those + versions require; any other rank raises. + - **voxel size**: the dataset's `coordinateTransformations` `scale` (times the multiscale-level `scale`, if + there is one), converted to nm by each space axis's `unit` (`picometer`, `angstrom`, `nanometer`, + `micrometer`, `millimeter`, `centimeter`, `decimeter`, `meter`). `voxel_size_nm` is `None` when there is + no scale, a space axis has no unit or another unit, or a value is not positive (so always for 0.1–0.3, + which carry no unit). `translation` is not read. +- **Time axis.** Not presented. `params.t` is the index read on it; see Params. +- **Channel axis.** Presented as `c` when it has more than one entry. A channel axis with **one entry is not + presented** (`tczyx` (1, 1, 30, 1248, 1848) opens as `zyx`). The harness reads a label that has a `c` axis + at the channel index of the raw crop, so a one-channel mask kept as `czyx` could only be read for crops of + raw channel 0; without the axis it pairs with every channel of its raw. +- **Array metadata.** `.zarray` must say `zarr_format: 2`, a bool, integer or float `dtype` (any byte order; + the array reports the native one), `order` `C` or `F`, no `filters`, and a `compressor` that is `null`, + `zlib`, `gzip`, `blosc` or `zstd`. `dimension_separator` `/` and `.` (the default) are both read. +- **Access.** Always `chunk`. `open_array` reads the array's `.zarray` and the group's `.zattrs` only. `read` + fetches the chunk objects its region intersects, each whole, through `io.open` (`io.fetch` when the server + gives no Content-Length or ignores Range), and decodes them. With the usual one-plane chunks + (`1, 1, 1, Y, X`) a 256×256 patch costs one chunk object. Up to 128 MiB of decoded chunks are kept per array. +- **Missing chunks.** A chunk object the server answers **404** for was never written and reads as + `fill_value` (`null` reads as 0), as in Zarr. Only 404 means that; any other status raises. + +## Params + +- `t` (optional int): 0-based index on the time axis. + - Time axis longer than 1: **required**. Without it open raises, naming the valid range, rather than pick a + timepoint silently. Out of range raises. + - Time axis of length 1 (a fixed sample stored as `tczyx`): may be left out; 0 is the only timepoint. + - No time axis: must be left out. + + A label gets the same `t` as the raw it pairs with; the snippet opens one array and cannot check that. + Give the reason for the chosen timepoint in the binding. + +## Known limits + +- **One timepoint per bound array.** To crop several timepoints of a timelapse, list the store several times + in the binding's `raw` (and its label alongside), each with its own `t`. +- **Raw and label timepoints are matched by index only.** For this record's H2B nuclear segmentations the + label has 120 timepoints against 121 in the raw, and nothing in the stores says which raw timepoint is + missing; do not bind those with this snippet until that is known. +- **Channels are not named.** `omero.channels` labels are not read. In this record the content of a channel + index differs per image (the manifest lists it), and crops are drawn from every channel of a raw. +- The group must be the array's parent directory (dataset `path` of one component, as written by + bioformats2raw, ome-zarr-py and bioio); an array under a nested dataset path is read as if it had no group metadata. Only the + `multiscales` entry that lists the array is used; `labels`, `plate`, `well` and `bioformats2raw.layout` + groups are not walked. +- **Only `.zarray` is in the binding's `urls`.** The group's `.zattrs` and the chunk objects are read through + `io` (counted, host-checked, validators recorded) but are not prefetched, so an offline re-stage from the + cache cannot reach them. +- A server that answers 403 instead of 404 for a missing key makes an unwritten chunk an error. +- Each chunk object costs a HEAD and a GET, and is read whole even for one row. +- Zarr v2 only: no `zarr.json` (OME-Zarr 0.5 / Zarr v3), no shards, no filters, no structured, string or object + dtypes, no `lz4`/`bz2`/`lzma` compressors (blosc with any inner codec is read). + +## Example + +`examples/allen-emt-timelapse-hips-colonies.yaml`: the Allen EMT dataset's fixed image `3500007015_10` on +`allencell.s3.amazonaws.com`, raw `tczyx` (1, 4, 30, 1248, 1848) uint16 presented as `czyx`, and its +all-cells mask (1, 1, 30, 1248, 1848) uint8 {0, 255} presented as `zyx`; OME-NGFF 0.4, blosc-lz4 one-plane +chunks, `/` separator; voxel size z 2880, y 270.83, x 270.83 nm from the multiscales scale. No `t`: both +have one timepoint. + +Tested on that data with `harness smoke` and `harness crop --n 4` (crops from raw channels 0, 1 and 3, mask +outlines following the cells in the bright-field channel). Outside the binding, the live timelapse +`3500006256_11` (raw (121, 3, 30, 1248, 1848), all-cells mask (121, 1, 30, 1248, 1848)) passes smoke and +`crop --n 4` with `t: 60` on both, and without `t` open raises "has 121 timepoints … set params.t (0..120…)". +Checked against arrays written by zarr-python over a local range server: every `t` of a `tczyx` array against +the source data, `blosc`/`zlib`/`zstd`/`gzip`/uncompressed chunks, both separators, a missing chunk, +big-endian data, stepped and negative slices, 0.3 axes, arrays without a time axis or without group metadata, +units and the multiscale-level scale, and that open requests only `.zarray` and `.zattrs`. + +The example binding's `snippets_commit` is the commit before this snippet's own (a binding can't name the +commit that adds it); a binding written by the worker names a commit that contains the snippet. diff --git a/snippets/ome-zarr2-http-timepoint/examples/allen-emt-timelapse-hips-colonies.yaml b/snippets/ome-zarr2-http-timepoint/examples/allen-emt-timelapse-hips-colonies.yaml new file mode 100644 index 0000000..e3b3e35 --- /dev/null +++ b/snippets/ome-zarr2-http-timepoint/examples/allen-emt-timelapse-hips-colonies.yaml @@ -0,0 +1,21 @@ +record: allen-emt-timelapse-hips-colonies +record_commit: 33a53209f11e2173560c3230304c194297b342ed +snippets_commit: 3621d82de687d68ad865eed4f26cdf6bfffc15d1 +raw: + - snippet: ome-zarr2-http-timepoint + urls: ["https://allencell.s3.amazonaws.com/aics/emt_timelapse_dataset/data/3500007015_10_E3_N-cadherin_E-cadherin_raw_converted.ome.zarr/0/.zarray"] + params: {} +labels: + - snippet: ome-zarr2-http-timepoint + urls: ["https://allencell.s3.amazonaws.com/aics/emt_timelapse_dataset/data/3500007015_10_EcadNcad_all_cells_mask.ome.zarr/0/.zarray"] + params: {} + pairs_with: 0 + label_key: unknown-cell-semantic + segmentation_type: binary +reasons: + - "ome-zarr2-http-timepoint: the record's stores are OME-Zarr images (Zarr v2, OME-NGFF 0.4, blosc-lz4, dimension_separator '/') in an S3 bucket read over https, and their level arrays are rank 5 in tczyx order; an Array has no t axis, so the reader presents one timepoint as czyx. zarr2-http, the other zarr reader, has no way to drop the time axis and does not read the multiscales scale" + - "urls are the record's technical.sample.urls (fixed 4-channel image 3500007015_10 and its all-cells mask, paired by Data ID in the manifest although the file stems differ) with '/0/.zarray' appended: the snippet takes one level array, not the image group; level 0 is the full-resolution array (1, 4, 30, 1248, 1848), and the .zarray is an object that exists, so `harness fetch` and the prefetch of `harness stage`, which fetch every binding url whole, get a file with validators instead of the directory's 404" + - "no t param on either array: both stores are fixed samples with a time axis of length 1 (raw (1, 4, 30, 1248, 1848), mask (1, 1, 30, 1248, 1848)), so the only timepoint, 0, is taken on the raw and on its label alike. A live timelapse of this record (T up to 1001) needs an explicit t, the same on the raw and its all-cells mask" + - "axes: the group's multiscales axes are t, c, z, y, x, matching the record's tczyx. The raw is presented without t as czyx 4 x 30 x 1248 x 1848 (channels bright-field, mEGFP and two immunostains per the store's omero metadata); the mask's channel axis has one entry, which the snippet does not present, so the label is zyx 30 x 1248 x 1848 and is read at the raw's z, y, x whichever raw channel a crop is drawn from" + - "voxel size z 2880, y 270.83, x 270.83 nm comes from the multiscales scale of dataset '0' (2.88, 0.2708, 0.2708 micrometer); the record's imaging.voxel_size_nm is null because it differs per image" + - "label_key: annotations.source 'mixed' -> unknown; all_cells_mask marks cells -> cell; encoding binary -> semantic; segmentation_type binary (uint8 values {0, 255})" diff --git a/snippets/ome-zarr2-http-timepoint/snippet.py b/snippets/ome-zarr2-http-timepoint/snippet.py new file mode 100644 index 0000000..17630e7 --- /dev/null +++ b/snippets/ome-zarr2-http-timepoint/snippet.py @@ -0,0 +1,248 @@ +"""One resolution level of an OME-Zarr image (Zarr v2) at a plain HTTP(S) URL, at one timepoint. + +The URL is the level's array directory or its `.zarray` object (`.ome.zarr/0/.zarray`). The image group +one directory up gives, in its `.zattrs` `multiscales` (OME-NGFF 0.3 / 0.4), the axis order and this level's +`scale`; an array of rank 5 without that metadata is `tczyx` (OME-NGFF 0.1 / 0.2). A time axis is not presented: +`params.t` picks one index on it (optional when the axis has one entry), and the array is the rest, e.g. `czyx`. +A channel axis with one entry is not presented either (`zyx`), so a one-channel label pairs with a raw of several. +Chunk keys are derived from `.zarray`, so a store with no directory index (an S3 bucket over https) works; a +chunk object the server answers 404 for is an unwritten chunk and reads as `fill_value`. +""" +import json +import zlib +from typing import Callable + +import imagecodecs +import numpy as np + +from mia_harness.api import IO, Array, NeedsFetch, Source + +SPEC_VERSION = 1 + +STORED_AXES = "tczyx" # letters an OME-Zarr axis name may map to; t is taken by params.t +NGFF_01_AXES = "tczyx" # OME-NGFF 0.1 / 0.2: every array is 5D in this order +CACHE_BYTES = 128 << 20 # decoded chunks kept per array (most recently used) +NM_PER_UNIT: dict[str, float] = { # OME-NGFF 0.4 space units (UDUNITS-2 names) this snippet converts + "picometer": 1e-3, "angstrom": 0.1, "nanometer": 1.0, "micrometer": 1e3, "millimeter": 1e6, + "centimeter": 1e7, "decimeter": 1e8, "meter": 1e9, +} +DECODERS: dict[str, Callable[[bytes], bytes]] = { + "zlib": zlib.decompress, + "gzip": lambda b: zlib.decompress(b, 31), + "blosc": lambda b: bytes(imagecodecs.blosc_decode(b)), + "zstd": lambda b: bytes(imagecodecs.zstd_decode(b)), +} + + +class _Meta: + """`.zarray` of one level array, and what the image group's `.zattrs` says about it.""" + + def __init__(self, io: IO, url: str): + self.url = _array_url(url) + meta = _json(io, f"{self.url}/.zarray") + if meta is None: + raise FileNotFoundError(f"{self.url}/.zarray not found (HTTP 404): the url must be one level array of " + "the OME-Zarr image (.zarr/0) or its .zarray, not the image group and " + "not a Zarr v3 array (zarr.json)") + if meta.get("zarr_format") != 2: + raise ValueError(f"{self.url}: zarr_format {meta.get('zarr_format')!r}, this snippet reads Zarr v2") + self.shape = tuple(int(n) for n in meta["shape"]) + self.chunks = tuple(int(n) for n in meta["chunks"]) + if not self.shape or len(self.chunks) != len(self.shape) or min(self.chunks) < 1: + raise ValueError(f"{self.url}: shape {self.shape} with chunks {self.chunks}") + self.stored = np.dtype(meta["dtype"]) # as in the chunks, byte order included + if self.stored.kind not in "biuf": + raise ValueError(f"{self.url}: dtype {meta['dtype']!r} is not a plain bool, integer or float type") + if meta.get("filters"): + raise ValueError(f"{self.url}: filters {meta['filters']!r} are not supported") + compressor = meta.get("compressor") + if compressor is not None and compressor.get("id") not in DECODERS: + raise ValueError(f"{self.url}: compressor {compressor.get('id')!r} is not one of {sorted(DECODERS)}") + self.decode: Callable[[bytes], bytes] | None = DECODERS[compressor["id"]] if compressor else None + self.order: str = meta.get("order", "C") + if self.order not in ("C", "F"): + raise ValueError(f"{self.url}: order {self.order!r}") + self.separator: str = meta.get("dimension_separator") or "." + fill = meta.get("fill_value") + self.fill = np.zeros((), self.stored.newbyteorder("="))[()] if fill is None else \ + np.array(float(fill) if isinstance(fill, str) else fill).astype(self.stored.newbyteorder("="))[()] + group, _, level = self.url.rpartition("/") + self.axes, self.voxel_size_nm = _ngff(_json(io, f"{group}/.zattrs") or {}, group, level, self.url, self.shape) + + +class _Timepoint: + """The stored array with its time axis, if it has one, fixed at one index, and a channel axis of one entry at 0.""" + + def __init__(self, io: IO, meta: _Meta, t: int | None): + self._io, self._m = io, meta + self._fixed: dict[str, int] = {} # stored axis -> the one index read on it; not presented + if "t" in meta.axes: + n = meta.shape[meta.axes.index("t")] + if t is None: + if n != 1: + raise ValueError(f"{meta.url} has {n} timepoints ({meta.axes} {meta.shape}); set params.t " + f"(0..{n - 1}, the same on a raw and its labels, with a reason in the binding)") + t = 0 + if not 0 <= t < n: + raise ValueError(f"params.t {t} is outside the {n} timepoints of {meta.url} ({meta.axes} {meta.shape})") + self._fixed["t"] = t + elif t is not None: + raise ValueError(f"params.t {t} given, but {meta.url} has no time axis ({meta.axes} {meta.shape})") + if "c" in meta.axes and meta.shape[meta.axes.index("c")] == 1: + self._fixed["c"] = 0 # the harness reads a label that has a c axis at its raw's channel index + self.axes = "".join(a for a in meta.axes if a not in self._fixed) + self.shape: tuple[int, ...] = tuple(n for a, n in zip(meta.axes, meta.shape) if a not in self._fixed) + self.dtype = meta.stored.newbyteorder("=") + self.voxel_size_nm = meta.voxel_size_nm + self.access = "chunk" + self._missing: set[str] = set() # chunk keys the server answered 404 for + self._cache: dict[str, np.ndarray] = {} # decoded chunks, oldest first + + def read(self, region: dict[str, int | slice]) -> np.ndarray: + unknown = sorted(set(region) - set(self.axes)) + if unknown: + raise ValueError(f"region keys {unknown} are not axes of this array ({self.axes})") + sels = [self._fixed[a] if a in self._fixed else region.get(a, slice(None)) for a in self._m.axes] + picks = [range(n)[s] if isinstance(s, slice) else range(range(n)[s], range(n)[s] + 1) + for s, n in zip(sels, self._m.shape)] # an int out of range raises IndexError, like numpy + if any(len(p) == 0 for p in picks): + block = np.empty([len(p) for p in picks], self.dtype) + else: + lo = [min(p[0], p[-1]) for p in picks] + hi = [max(p[0], p[-1]) + 1 for p in picks] + block = self._box(lo, hi) + for i, p in enumerate(picks): + if p.step != 1: + block = np.take(block, np.asarray(p) - lo[i], axis=i) + return block[tuple(slice(None) if isinstance(s, slice) else 0 for s in sels)] + + def _box(self, lo: list[int], hi: list[int]) -> np.ndarray: + """Voxels [lo, hi) of the stored array (every stored dimension, the fixed ones included).""" + m = self._m + out = np.full([h - l for l, h in zip(lo, hi)], m.fill, self.dtype) + spans = [range(l // c, (h - 1) // c + 1) for l, h, c in zip(lo, hi, m.chunks)] + for idx in np.ndindex(*[len(s) for s in spans]): + cidx = [s[i] for s, i in zip(spans, idx)] + chunk = self._chunk(cidx) + if chunk is None: + continue + src, dst = [], [] + for k, c, l, h in zip(cidx, m.chunks, lo, hi): + start, stop = max(l, k * c), min(h, (k + 1) * c) + src.append(slice(start - k * c, stop - k * c)) + dst.append(slice(start - l, stop - l)) + out[tuple(dst)] = chunk[tuple(src)] + return out + + def _chunk(self, cidx: list[int]) -> np.ndarray | None: + """One decoded chunk (always the full chunk shape, as Zarr v2 stores it), or None when it was never written.""" + m = self._m + key = m.separator.join(str(k) for k in cidx) + if key in self._missing: + return None + if key in self._cache: + self._cache[key] = self._cache.pop(key) # most recently used last + return self._cache[key] + data = _body(self._io, f"{m.url}/{key}") + if data is None: + self._missing.add(key) + return None + if m.decode is not None: + data = m.decode(data) + if len(data) != int(np.prod(m.chunks)) * m.stored.itemsize: + raise ValueError(f"{m.url}/{key}: {len(data)} bytes decoded, a {m.stored} chunk of {m.chunks} holds " + f"{int(np.prod(m.chunks)) * m.stored.itemsize}") + chunk = np.frombuffer(data, m.stored).reshape(m.chunks, order=m.order).astype(self.dtype, copy=False) + self._cache[key] = chunk + while len(self._cache) > 1 and sum(c.nbytes for c in self._cache.values()) > CACHE_BYTES: + del self._cache[next(iter(self._cache))] + return chunk + + +def _array_url(url: str) -> str: + """The array directory's url without a trailing slash, from the directory url or its `.zarray` url.""" + url = url.rstrip("/") + return url[:-len("/.zarray")] if url.endswith("/.zarray") else url + + +def _body(io: IO, url: str) -> bytes | None: + """The whole object, or None when the server says 404 (in a Zarr store: this key was never written).""" + try: + try: + with io.open(url) as fh: + return fh.read() + except NeedsFetch: # no Content-Length, or a server without Range: stage the object whole + return io.fetch(url).read_bytes() + except Exception as e: # io raises its HTTP client's status error; 404 is the only status read as absence + if getattr(getattr(e, "response", None), "status_code", None) == 404: + return None + raise + + +def _json(io: IO, url: str) -> dict | None: + data = _body(io, url) + if data is None: + return None + doc = json.loads(data) + if not isinstance(doc, dict): + raise ValueError(f"{url} is not a JSON object") + return doc + + +def _ngff(attrs: dict, group: str, level: str, url: str, shape: tuple[int, ...]) -> tuple[str, dict[str, float] | None]: + """(stored axes, voxel size in nm or None) of the level array `level` from its image group's `.zattrs`.""" + entries = attrs.get("multiscales") + found = [(m, d) for m in entries if isinstance(m, dict) for d in m.get("datasets") or [] + if isinstance(d, dict) and str(d.get("path", "")).strip("/") == level] if isinstance(entries, list) else [] + image, dataset = found[0] if found else ({}, {}) + listed = image.get("axes") + if isinstance(listed, list) and listed: + names = [str(a.get("name") if isinstance(a, dict) else a).lower() for a in listed] + if any(len(n) != 1 or n not in STORED_AXES for n in names) or len(set(names)) != len(names): + raise ValueError(f"{group}/.zattrs: multiscales axes {names} are not distinct names from {STORED_AXES}") + axes = "".join(names) + if len(axes) != len(shape): + raise ValueError(f"{group}/.zattrs: multiscales axes {axes!r} do not give one axis per dimension of " + f"{url} {shape}") + elif len(shape) == len(NGFF_01_AXES): + axes = NGFF_01_AXES # 0.1 / 0.2 name no axes: always tczyx. Also a rank-5 array with no group metadata + else: + raise ValueError(f"{group}/.zattrs names no multiscales axes for dataset {level!r}, and {url} has rank " + f"{len(shape)} {shape}: only a rank-5 array is read without them (as {NGFF_01_AXES})") + if not {"y", "x"} <= set(axes): + raise ValueError(f"{group}/.zattrs: multiscales axes {axes!r} have no y and x") + scale = _scale(dataset.get("coordinateTransformations"), len(shape)) + outer = _scale(image.get("coordinateTransformations"), len(shape)) # 0.4: applied after the dataset's own + if scale is None or "coordinateTransformations" in image and outer is None: + return axes, None + voxel: dict[str, float] = {} + for i, a in enumerate(axes): + if a not in "zyx": + continue + unit = listed[i].get("unit") if isinstance(listed, list) and isinstance(listed[i], dict) else None + size = scale[i] * (outer[i] if outer else 1.0) * NM_PER_UNIT.get(str(unit), 0.0) + if not size > 0: # no unit, a unit this snippet does not convert, or a non-positive scale + return axes, None + voxel[a] = size + return axes, voxel or None + + +def _scale(transforms: object, rank: int) -> tuple[float, ...] | None: + """The `scale` of an OME-NGFF 0.4 coordinateTransformations list (one number per dimension), else None.""" + if not isinstance(transforms, list): + return None + scales = [t.get("scale") for t in transforms if isinstance(t, dict) and t.get("type") == "scale"] + if len(scales) != 1 or not isinstance(scales[0], list) or len(scales[0]) != rank: + return None # none, several, or a scale given as a path to a binary file + if not all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in scales[0]): + return None + return tuple(float(v) for v in scales[0]) + + +def open_array(src: Source, io: IO) -> Array: + if len(src.urls) != 1: + raise ValueError(f"ome-zarr2-http-timepoint reads one level array, got {len(src.urls)} urls") + t = src.params.get("t") + if t is not None and (isinstance(t, bool) or not isinstance(t, int)): + raise ValueError(f"params.t must be an integer index on the time axis, got {t!r}") + return _Timepoint(io, _Meta(io, src.urls[0]), t) diff --git a/snippets/ome-zarr2-http-timepoint/snippet.yaml b/snippets/ome-zarr2-http-timepoint/snippet.yaml new file mode 100644 index 0000000..8ae80d9 --- /dev/null +++ b/snippets/ome-zarr2-http-timepoint/snippet.yaml @@ -0,0 +1,10 @@ +name: ome-zarr2-http-timepoint +spec_version: 1 +summary: One resolution level of an OME-Zarr image (Zarr v2, OME-NGFF 0.1-0.4) at a plain HTTP(S) URL (the level's array directory or its .zarray), at one index of its time axis (t), so a tczyx array is presented as czyx (zyx when it has one channel); axis order and voxel size from the image group's .zattrs multiscales; read chunk by chunk with chunk keys derived from .zarray, so no directory listing is needed (S3 over https). +formats: [ome-zarr] +containers: [http, s3] +params: + t: {type: int, required: false, doc: "0-based index on the array's time axis of the one timepoint to present. Required when the time axis is longer than 1 (open raises, naming the range); may be left out when it has one entry (0 is then the only timepoint); an error when the array has no time axis. A label gets the same t as the raw it pairs with; reason required in binding"} +requires: [numpy, imagecodecs] +tested_on: [allen-emt-timelapse-hips-colonies] +supersedes: null