Skip to content

Commit 83f7739

Browse files
committed
fix(ome_zarr): πŸ› canonicalize TIFF-sequence axes to channel-first
sequence_pattern derived axis order straight from the regex's named groups, so a filename layout like _T0_Z000_C0_ put the channel axis at position 2 instead of 0 β€” load_ome_zarr/tile_process hard-assume axis 0 is the channel (arr[channel]), so channel= silently sliced the wrong axis for any multi-Z, multi-channel sequence. Reorder to patchworks' czyx convention and drop singleton non-spatial axes (e.g. a constant T0), matching what _open_bioio/_open_imaris already do, regardless of the pattern's group order.
1 parent da8de4f commit 83f7739

3 files changed

Lines changed: 85 additions & 11 deletions

File tree

β€Ždocs/guide/ome_zarr_napari.mdβ€Ž

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ sample_T0_Z001_C1_V0.tif
6666

6767
Pass `sequence_pattern=` and let `source` be a glob over the folder instead of
6868
one file path. The pattern is a regex whose **named groups** map to axis
69-
labels β€” axis order follows the order the groups appear in the pattern:
69+
labels β€” one file per group of indices:
7070

7171
```python
7272
to_ome_zarr(
@@ -77,6 +77,13 @@ to_ome_zarr(
7777
)
7878
```
7979

80+
You don't need to order the named groups to match how the axes should come
81+
out: the result is always reordered to patchworks' `c, z, y, x` convention β€”
82+
required for `channel=` selection (`load_ome_zarr`/`tile_process` always read
83+
the channel from axis 0) β€” and a constant axis like `T0` above (one value, no
84+
real time series) is dropped automatically, the same way the bioio/Imaris
85+
readers already drop a singleton time axis.
86+
8087
Each file becomes exactly **one dask chunk**, decoded lazily on access β€” no
8188
data is duplicated or eagerly loaded, so this scales to huge (multi-TB)
8289
sequences (built on `tifffile.TiffSequence`, the same mechanism Cellpose's

β€Žsrc/patchworks/plugins/ome_zarr.pyβ€Ž

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -701,15 +701,19 @@ def _open_tiff_sequence(
701701
sequence_pattern : str
702702
Regular expression parsing each file name into axis labels and
703703
indices, e.g. ``r"_T(?P<T>\\d+)_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+"``.
704-
Named groups become axis labels directly; axis order follows the
705-
order groups appear in the pattern.
704+
Named groups become axis labels directly β€” the order they appear in
705+
the *pattern* (not the filename) doesn't matter, the result is
706+
always reordered to patchworks' ``tczyx`` convention and any
707+
singleton non-spatial axis (e.g. a constant ``T0``) is dropped, same
708+
as the bioio/Imaris readers, so a real channel axis always ends up
709+
first (required by :func:`patchworks.load_ome_zarr`).
706710
707711
Returns
708712
-------
709713
tuple
710714
``(array, axes, pixel_size)`` β€” a lazy dask array, its axes string
711-
(parsed axes + trailing ``"yx"``) and a ``{axis: micrometers}``
712-
calibration dict read from the first file.
715+
(parsed axes, canonicalised, + trailing ``"yx"``) and a
716+
``{axis: micrometers}`` calibration dict read from the first file.
713717
"""
714718
try:
715719
import tifffile
@@ -722,7 +726,33 @@ def _open_tiff_sequence(
722726

723727
ts = tifffile.TiffSequence(pattern, pattern=sequence_pattern)
724728
arr = da.from_zarr(zarr.open(store=ts.aszarr()))
725-
axes = ts.axes.lower() + "yx"
729+
full_axes = ts.axes.lower() + "yx"
730+
731+
# Canonicalise to patchworks' tczyx axis order regardless of the order
732+
# sequence_pattern's groups happen to appear in (unrecognised axes sort
733+
# last, before y/x).
734+
order = sorted(
735+
range(len(full_axes)),
736+
key=lambda i: (
737+
_DEFAULT_ORDER.index(full_axes[i])
738+
if full_axes[i] in _DEFAULT_ORDER
739+
else len(_DEFAULT_ORDER)
740+
),
741+
)
742+
arr = arr.transpose(order)
743+
ordered_axes = "".join(full_axes[i] for i in order)
744+
745+
# Drop singleton non-spatial axes (e.g. a constant T), matching
746+
# _open_bioio/_open_imaris, so a real channel axis lands at position 0.
747+
keep = [
748+
i
749+
for i, a in enumerate(ordered_axes)
750+
if a in _SPATIAL_AXES or arr.shape[i] > 1
751+
]
752+
index = tuple(slice(None) if i in keep else 0 for i in range(arr.ndim))
753+
arr = arr[index]
754+
axes = "".join(ordered_axes[i] for i in keep)
755+
726756
pixel_size = _tiff_pixel_size(ts[0])
727757
logger.info(
728758
"tifffile sequence opened %s files as %s %s cal=%s",
@@ -979,8 +1009,11 @@ def to_ome_zarr(
9791009
and indices via named groups, e.g.
9801010
``r"_T(?P<T>\\d+)_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+"``. Each file
9811011
becomes exactly one chunk, read lazily on access (no data
982-
duplicated) β€” see :func:`tifffile.TiffSequence`. Axis order follows
983-
the order named groups appear in the pattern.
1012+
duplicated) β€” see :func:`tifffile.TiffSequence`. The result is
1013+
always reordered to patchworks' ``czyx`` convention regardless of
1014+
the order named groups appear in the pattern, and any singleton
1015+
non-spatial axis (e.g. a constant ``T0``) is dropped, so a real
1016+
channel axis always ends up first.
9841017
n_levels : int, optional
9851018
Maximum number of pyramid levels including full resolution.
9861019
downscale : int, optional

β€Žtests/test_ome_zarr.pyβ€Ž

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,12 @@ def test_non_spatial_axis_not_downsampled(tmp_path):
8080

8181

8282
def test_tiff_sequence_conversion(tmp_path):
83-
"""A folder of single-plane TIFFs is wrapped lazily and converted."""
83+
"""A folder of single-plane TIFFs is wrapped lazily and converted.
84+
85+
The filename pattern lists Z before C, but the output must still come
86+
out channel-first (patchworks' tczyx convention), since load_ome_zarr /
87+
tile_process hard-assume axis 0 is the channel axis.
88+
"""
8489
tifffile = pytest.importorskip("tifffile")
8590
n_z, n_c, size = 3, 2, 8
8691
for z in range(n_z):
@@ -102,14 +107,43 @@ def test_tiff_sequence_conversion(tmp_path):
102107
)
103108

104109
result = np.asarray(load_ome_zarr(out, channel=None))
105-
assert result.shape == (n_z, n_c, size, size)
110+
assert result.shape == (n_c, n_z, size, size) # channel-first, not z-first
106111
# each plane's constant value encodes its (z, c) position.
107112
assert (
108113
result[:, :, 0, 0]
109-
== [[z * 10 + c for c in range(n_c)] for z in range(n_z)]
114+
== [[z * 10 + c for z in range(n_z)] for c in range(n_c)]
110115
).all()
111116
assert _level_scale(out, 0) == pytest.approx([1.0, 1.0, 0.5, 0.5])
112117

118+
# per-channel selection picks the right plane regardless of pattern order.
119+
ch1 = np.asarray(load_ome_zarr(out, channel=1))
120+
assert ch1.shape == (n_z, size, size)
121+
assert (ch1[:, 0, 0] == [z * 10 + 1 for z in range(n_z)]).all()
122+
123+
124+
def test_tiff_sequence_drops_singleton_time_axis(tmp_path):
125+
"""A constant T in the pattern is dropped, keeping channel at axis 0."""
126+
tifffile = pytest.importorskip("tifffile")
127+
n_z, n_c, size = 2, 3, 8
128+
for z in range(n_z):
129+
for c in range(n_c):
130+
img = np.full((size, size), z * 10 + c, dtype="uint16")
131+
tifffile.imwrite(tmp_path / f"sample_T0_Z{z:03d}_C{c}_V0.tif", img)
132+
133+
out = tmp_path / "out.zarr"
134+
to_ome_zarr(
135+
str(tmp_path / "*.tif"),
136+
out,
137+
sequence_pattern=r"_T(?P<T>\d+)_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+",
138+
n_levels=1,
139+
)
140+
141+
result = np.asarray(load_ome_zarr(out, channel=None))
142+
assert result.shape == (n_c, n_z, size, size) # no leftover T axis
143+
ch2 = np.asarray(load_ome_zarr(out, channel=2))
144+
assert ch2.shape == (n_z, size, size)
145+
assert (ch2[:, 0, 0] == [z * 10 + 2 for z in range(n_z)]).all()
146+
113147

114148
def test_axes_length_mismatch(tmp_path):
115149
with pytest.raises(ValueError):

0 commit comments

Comments
Β (0)