From 8b5f3fe10871310785cf35609f3b74b7e780f71f Mon Sep 17 00:00:00 2001 From: Steve Van Hooser Date: Wed, 9 Sep 2026 09:53:02 -0400 Subject: [PATCH 1/4] Add ndr.format.omezarr.probe and .reduce helpers for lightsheet pyramid ingest Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w --- src/ndr/format/omezarr/probe.py | 77 ++++++++++++++++++++++++++++ src/ndr/format/omezarr/reduce.py | 87 ++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/ndr/format/omezarr/probe.py create mode 100644 src/ndr/format/omezarr/reduce.py diff --git a/src/ndr/format/omezarr/probe.py b/src/ndr/format/omezarr/probe.py new file mode 100644 index 0000000..75fee7e --- /dev/null +++ b/src/ndr/format/omezarr/probe.py @@ -0,0 +1,77 @@ +"""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 typing import List, Optional + +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..2aa918a --- /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 typing import Sequence, Union + +import numpy as np + + +def reduce( + arr: np.ndarray, + factor: Union[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"] From 8b94fee52f1a13abb4c62b4f8a89c985594c210f Mon Sep 17 00:00:00 2001 From: Steve Van Hooser Date: Wed, 9 Sep 2026 10:05:32 -0400 Subject: [PATCH 2/4] Record probe.m and reduce.m in the omezarr MATLAB<->Python bridge The bridge-completeness test requires every MATLAB function under +ndr/+format/+omezarr to have a bridge entry naming its Python counterpart, its last-sync commit hash, and a decision log. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w --- .../omezarr/ndr_matlab_python_bridge.yaml | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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" From 4ef83ee7d85abb5c0b4e3622b5cd3decd90a2c33 Mon Sep 17 00:00:00 2001 From: Steve Van Hooser Date: Wed, 9 Sep 2026 10:08:17 -0400 Subject: [PATCH 3/4] Modernize typing in omezarr.probe for ruff UP006 Replace typing.List with builtin list. from __future__ import annotations is already in place so this is a syntactic swap. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w --- src/ndr/format/omezarr/probe.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/ndr/format/omezarr/probe.py b/src/ndr/format/omezarr/probe.py index 75fee7e..1325b78 100644 --- a/src/ndr/format/omezarr/probe.py +++ b/src/ndr/format/omezarr/probe.py @@ -10,7 +10,6 @@ import os from dataclasses import dataclass, field -from typing import List, Optional from ndr.format.omezarr.isOMEZarr import isOMEZarr from ndr.format.omezarr.listPyramids import listPyramids @@ -26,15 +25,15 @@ class OMEZarrProbe: ok: bool = False n_pyramids: int = 0 - pyramid_names: List[str] = field(default_factory=list) - pyramid_types: List[str] = field(default_factory=list) + 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) + 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) + n_levels_per_pyramid: list[int] = field(default_factory=list) def probe(zarr_path: str) -> OMEZarrProbe: From 3271c813cdf8e070367653f1e828ced7d8bb52a7 Mon Sep 17 00:00:00 2001 From: Steve Van Hooser Date: Wed, 9 Sep 2026 10:08:31 -0400 Subject: [PATCH 4/4] Modernize typing in omezarr.reduce for ruff UP035/UP007 - typing.Sequence -> collections.abc.Sequence (UP035). - Union[a, b] -> a | b (UP007), possible with `from __future__ import annotations`. Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01N67xH9BejMzZp4GNAq8m7w --- src/ndr/format/omezarr/reduce.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ndr/format/omezarr/reduce.py b/src/ndr/format/omezarr/reduce.py index 2aa918a..1a33df9 100644 --- a/src/ndr/format/omezarr/reduce.py +++ b/src/ndr/format/omezarr/reduce.py @@ -7,14 +7,14 @@ from __future__ import annotations -from typing import Sequence, Union +from collections.abc import Sequence import numpy as np def reduce( arr: np.ndarray, - factor: Union[int, Sequence[int]], + factor: int | Sequence[int], reduction: str, ) -> np.ndarray: """Downsample ``arr`` by ``factor`` using ``reduction``.