Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/ndr/format/omezarr/ndr_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
76 changes: 76 additions & 0 deletions src/ndr/format/omezarr/probe.py
Original file line number Diff line number Diff line change
@@ -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"]
87 changes: 87 additions & 0 deletions src/ndr/format/omezarr/reduce.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading