diff --git a/src/ndr/format/omezarr/ndr_matlab_python_bridge.yaml b/src/ndr/format/omezarr/ndr_matlab_python_bridge.yaml index 19bd0c7..8fc6866 100644 --- a/src/ndr/format/omezarr/ndr_matlab_python_bridge.yaml +++ b/src/ndr/format/omezarr/ndr_matlab_python_bridge.yaml @@ -80,6 +80,36 @@ functions: (v2, C-order, Blosc/Zstd or uncompressed, fixed-length numeric dtypes). A store the MATLAB reader accepts is also accepted here. + - name: probe + matlab_path: "+ndr/+format/+omezarr/probe.m" + status: regular_port + matlab_last_sync_hash: "82e1d58" + python_path: "ndr/format/omezarr/probe.py" + decision_log: >- + Cheap metadata-only summary of an OME-Zarr store: pyramid count, + names, types, first-pyramid axes order and units, and the first + pyramid's level-0 shape, chunks, scale and dtype. Wraps isOMEZarr + and listPyramids for callers that only need a "should we accept + this?" answer (an ingest / GUI confirmation screen). Straight port; + Python returns an OMEZarrProbe dataclass where MATLAB returns a + struct, field-for-field equivalent (Python snake_case, MATLAB + camelCase per the Strict MATLAB Mirror policy for user-facing + names). + + - name: reduce + matlab_path: "+ndr/+format/+omezarr/reduce.m" + status: regular_port + matlab_last_sync_hash: "82e1d58" + python_path: "ndr/format/omezarr/reduce.py" + decision_log: >- + Block-reduce a numeric array by a scalar or per-axis factor with + reduction 'mean' or 'max', used when materializing a coarser pyramid + level. Trailing partial blocks are kept so level sizing matches + bioformats2raw / the lab's make_zarr_levels.py. Straight port; both + sides preserve dtype for 'max' and promote to float64 for 'mean'. + Python uses numpy indexing where MATLAB uses ind2sub; the block + boundaries and reduction semantics are identical. + - name: decompressBlosc matlab_path: "+ndr/+format/+omezarr/private/decompressBlosc.m" matlab_last_sync_hash: "a847431" diff --git a/src/ndr/format/omezarr/probe.py b/src/ndr/format/omezarr/probe.py new file mode 100644 index 0000000..1325b78 --- /dev/null +++ b/src/ndr/format/omezarr/probe.py @@ -0,0 +1,76 @@ +"""Cheap metadata probe of an OME-Zarr store. + +Python mirror of ``+ndr/+format/+omezarr/probe.m``. Use this from an +ingest / GUI confirmation screen that needs "should we accept this?" +info before the user commits. For anything that must distinguish +pyramids or reach deeper levels, use :func:`listPyramids` instead. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field + +from ndr.format.omezarr.isOMEZarr import isOMEZarr +from ndr.format.omezarr.listPyramids import listPyramids + + +@dataclass +class OMEZarrProbe: + """Result of :func:`probe`. + + ``ok`` is False when the directory does not parse as an OME-Zarr + store; every other field is then at its default. + """ + + ok: bool = False + n_pyramids: int = 0 + pyramid_names: list[str] = field(default_factory=list) + pyramid_types: list[str] = field(default_factory=list) + axes_order: str = "" + axes_units: list[str] = field(default_factory=list) + level0_shape: list[int] = field(default_factory=list) + level0_chunks: list[int] = field(default_factory=list) + level0_scale: list[float] = field(default_factory=list) + dtype: str = "" + n_levels_per_pyramid: list[int] = field(default_factory=list) + + +def probe(zarr_path: str) -> OMEZarrProbe: + """Metadata-only summary of an OME-Zarr store.""" + out = OMEZarrProbe() + + if not os.path.isdir(zarr_path): + return out + try: + if not isOMEZarr(zarr_path): + return out + except Exception: + return out + + pyramids = listPyramids(zarr_path) + if not pyramids: + return out + + out.ok = True + out.n_pyramids = len(pyramids) + out.pyramid_names = [str(p.get("name", "") or "") for p in pyramids] + out.pyramid_types = [str(p.get("type", "") or "") for p in pyramids] + + axes = pyramids[0].get("axes") or [] + out.axes_order = "".join(str(a.get("name", "") or "") for a in axes) + out.axes_units = [str(a.get("unit", "") or "") for a in axes] + + levels = pyramids[0].get("levels") or [] + if levels: + L0 = levels[0] + out.level0_shape = list(L0.get("shape") or []) + out.level0_chunks = list(L0.get("chunks") or []) + out.level0_scale = list(L0.get("scale") or []) + out.dtype = str(L0.get("dtype") or "") + + out.n_levels_per_pyramid = [len(p.get("levels") or []) for p in pyramids] + return out + + +__all__ = ["OMEZarrProbe", "probe"] diff --git a/src/ndr/format/omezarr/reduce.py b/src/ndr/format/omezarr/reduce.py new file mode 100644 index 0000000..1a33df9 --- /dev/null +++ b/src/ndr/format/omezarr/reduce.py @@ -0,0 +1,87 @@ +"""Block-reduce an array for pyramid building. + +Python mirror of ``+ndr/+format/+omezarr/reduce.m``. Only supports +'mean' and 'max'; those are the reductions first-class in the +lightsheet NGFF layout the lab writes. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + + +def reduce( + arr: np.ndarray, + factor: int | Sequence[int], + reduction: str, +) -> np.ndarray: + """Downsample ``arr`` by ``factor`` using ``reduction``. + + Parameters + ---------- + arr + The input array. Must be numeric. + factor + Scalar downsampling factor applied to every axis, or one + per-axis factor aligned with ``arr.shape``. + reduction + ``"mean"`` or ``"max"``. + + Returns + ------- + numpy.ndarray + The reduced array. dtype is preserved for ``max``; ``mean`` + returns ``float64``. + + Notes + ----- + Trailing partial blocks are kept (an axis of length 5 with factor + 2 produces 3 samples: blocks of 2, 2, 1). This matches how the + lab's NGFF writers size coarser levels. + """ + if reduction not in ("mean", "max"): + raise ValueError(f"reduction must be 'mean' or 'max'; got {reduction!r}") + if not np.issubdtype(arr.dtype, np.number): + raise TypeError(f"reduce needs a numeric array; got dtype {arr.dtype}") + + nd = arr.ndim + if np.isscalar(factor): + factors = [int(factor)] * nd + else: + factors = [int(f) for f in factor] + if len(factors) != nd: + raise ValueError( + f"factor must be scalar or one per axis; got {len(factors)} for a {nd}-D array" + ) + if any(f < 1 for f in factors): + raise ValueError("factor entries must be positive integers") + + shape = arr.shape + out_shape = tuple(-(-shape[d] // factors[d]) for d in range(nd)) # ceil div + + if reduction == "mean": + out = np.zeros(out_shape, dtype=np.float64) + else: + out = np.zeros(out_shape, dtype=arr.dtype) + + # Loop-based; correct and easy to trust for the small ranks used by + # existing writers (factor 2 across z/y/x). Swap to a strided view + # for performance when a benchmark says to. + it = np.ndindex(out_shape) + for coord in it: + slices = tuple( + slice(coord[d] * factors[d], min(shape[d], (coord[d] + 1) * factors[d])) + for d in range(nd) + ) + block = arr[slices] + if reduction == "mean": + out[coord] = float(np.mean(block)) + else: + out[coord] = np.max(block) + + return out + + +__all__ = ["reduce"]