Skip to content

Commit b858818

Browse files
committed
merge dev into main
2 parents 1bb994a + 83b9eb4 commit b858818

5 files changed

Lines changed: 189 additions & 9 deletions

File tree

docs/examples/dog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ label the connected components. CPU (scipy) by default, GPU (cupy) optional.
66
Optionally deconvolve each tile first with
77
[pycudadecon](https://github.com/tlambert03/pycudadecon).
88

9+
> Cilia DoG + deconvolution approach courtesy of
10+
> [angelo-angonezi](https://github.com/angelo-angonezi).
11+
912
## Installation
1013

1114
`dog_label_fn` itself only needs patchworks' core deps (scipy). The

src/patchworks/plugins/ome_zarr.py

Lines changed: 149 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,18 @@
99
* a ``.zarr`` path → read through :func:`patchworks.load_ome_zarr`;
1010
* a ``.ims`` (Imaris) path → read lazily with ``imaris-ims-file-reader``
1111
(HDF5, no JVM); install with ``pip install "patchworks[imaris]"``;
12+
* with ``sequence_pattern=`` set, a glob over a folder of single-plane TIFFs
13+
→ each file becomes one chunk of a lazy dask array via
14+
:func:`tifffile.TiffSequence`, no data duplicated;
1215
* any other path (CZI, LIF, ND2, OME-TIFF, …) → opened lazily with
1316
`bioio <https://github.com/bioio-devs/bioio>`_.
1417
1518
Pixel calibration (physical voxel size) is read from the input — bioio's
16-
``physical_pixel_sizes``, the Imaris resolution metadata, or an existing
17-
OME-ZARR's scale transform — and written into the NGFF ``coordinate
18-
Transformations`` so the µm/pixel sizing is preserved. Pass ``pixel_size=`` to
19-
override or to supply it for bare arrays.
19+
``physical_pixel_sizes``, the Imaris resolution metadata, an existing
20+
OME-ZARR's scale transform, or (for a TIFF sequence) the first file's own
21+
ImageJ/resolution tags — and written into the NGFF ``coordinateTransformations``
22+
so the µm/pixel sizing is preserved. Pass ``pixel_size=`` to override or to
23+
supply it for bare arrays.
2024
2125
Downsampling uses strided (nearest-neighbour) subsampling — the correct,
2226
label-preserving choice — and only on **X and Y**; ``z`` (and channel/time)
@@ -620,6 +624,116 @@ def _open_bioio(path: str, scene: int) -> tuple[da.Array, str, PixelSize]:
620624
return arr, axes, pixel_size
621625

622626

627+
_UNIT_TO_UM = {
628+
"micron": 1.0,
629+
"um": 1.0,
630+
"µm": 1.0,
631+
"mm": 1000.0,
632+
"cm": 10000.0,
633+
"centimeter": 10000.0,
634+
"inch": 25400.0,
635+
}
636+
# TIFF ResolutionUnit tag values (RESUNIT): 2 == INCH, 3 == CENTIMETER.
637+
_RESUNIT_TO_UM = {2: 25400.0, 3: 10000.0}
638+
639+
640+
def _tiff_pixel_size(path: str) -> PixelSize:
641+
"""Read physical voxel size from a TIFF file's own metadata.
642+
643+
Z comes from ImageJ metadata (``spacing`` + ``unit``), if present. X/Y
644+
come from the page's ``XResolution``/``YResolution`` tags (pixels per
645+
unit); the unit itself is taken from the ``ResolutionUnit`` tag when set
646+
(plain TIFFs), or falls back to ImageJ metadata's ``unit`` — ImageJ
647+
itself writes ``ResolutionUnit=NONE`` and keeps the unit as a string in
648+
its metadata instead. Unrecognized units are ignored (treated as
649+
uncalibrated).
650+
651+
Parameters
652+
----------
653+
path : str
654+
Path of one TIFF file in the sequence.
655+
656+
Returns
657+
-------
658+
dict
659+
``{axis: micrometers}`` for whichever axes could be determined
660+
(empty if none).
661+
"""
662+
import tifffile
663+
664+
pixel_size: PixelSize = {}
665+
with tifffile.TiffFile(path) as tif:
666+
ij = tif.imagej_metadata or {}
667+
spacing = ij.get("spacing")
668+
if spacing:
669+
factor = _UNIT_TO_UM.get(str(ij.get("unit", "micron")).lower(), 1.0)
670+
pixel_size["z"] = float(spacing) * factor
671+
672+
page = tif.pages[0]
673+
unit_tag = page.tags.get("ResolutionUnit")
674+
um_per_unit = _RESUNIT_TO_UM.get(
675+
unit_tag.value if unit_tag else None
676+
) or _UNIT_TO_UM.get(str(ij.get("unit", "")).lower())
677+
if um_per_unit is not None:
678+
for axis, tag_name in (("y", "YResolution"), ("x", "XResolution")):
679+
tag = page.tags.get(tag_name)
680+
if tag and tag.value[0]:
681+
num, den = tag.value
682+
pixel_size[axis] = um_per_unit / (num / den)
683+
return pixel_size
684+
685+
686+
def _open_tiff_sequence(
687+
pattern: str, sequence_pattern: str
688+
) -> tuple[da.Array, str, PixelSize]:
689+
"""Open a folder of single-plane TIFFs as one lazy dask array.
690+
691+
Each file becomes exactly one chunk, decoded on access — no data is
692+
duplicated or eagerly loaded. See
693+
:func:`tifffile.TiffSequence`/``ZarrFileSequenceStore`` (the same
694+
mechanism used by Cellpose's own distributed pipeline,
695+
``cellpose.contrib.distributed_segmentation.wrap_folder_of_tiffs``).
696+
697+
Parameters
698+
----------
699+
pattern : str
700+
Glob pattern matching all the files, e.g. ``"folder/*.tif"``.
701+
sequence_pattern : str
702+
Regular expression parsing each file name into axis labels and
703+
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.
706+
707+
Returns
708+
-------
709+
tuple
710+
``(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.
713+
"""
714+
try:
715+
import tifffile
716+
except ImportError as exc:
717+
raise ImportError(
718+
"reading a folder of TIFFs requires tifffile. Install it with:\n"
719+
" pip install tifffile\n"
720+
"(already pulled in by patchworks[bioio] via bioio-tifffile)."
721+
) from exc
722+
723+
ts = tifffile.TiffSequence(pattern, pattern=sequence_pattern)
724+
arr = da.from_zarr(zarr.open(store=ts.aszarr()))
725+
axes = ts.axes.lower() + "yx"
726+
pixel_size = _tiff_pixel_size(ts[0])
727+
logger.info(
728+
"tifffile sequence opened %s files as %s %s cal=%s",
729+
len(ts),
730+
axes,
731+
arr.shape,
732+
pixel_size,
733+
)
734+
return arr, axes, pixel_size
735+
736+
623737
def _open_imaris(path: str, level: int = 0) -> tuple[da.Array, str, PixelSize]:
624738
"""Open one Imaris ``.ims`` resolution level lazily.
625739
@@ -769,12 +883,14 @@ def _to_dask(
769883
source: Union[da.Array, np.ndarray, str, Path],
770884
axes: Union[str, None],
771885
scene: int,
886+
sequence_pattern: Union[str, None] = None,
772887
) -> tuple[da.Array, str, PixelSize]:
773888
"""Resolve *source* into a lazy ``(array, axes, pixel_size)`` triple.
774889
775890
Dispatches by type: dask/NumPy arrays pass through; ``.zarr`` paths use the
776-
OME-ZARR loader; ``.ims`` paths use the Imaris reader; anything else uses
777-
bioio.
891+
OME-ZARR loader; ``.ims`` paths use the Imaris reader; a *sequence_pattern*
892+
treats *source* as a glob over a folder of single-plane TIFFs; anything
893+
else uses bioio.
778894
779895
Parameters
780896
----------
@@ -784,6 +900,10 @@ def _to_dask(
784900
Explicit axes, or ``None`` to infer them.
785901
scene : int
786902
Scene index for bioio inputs.
903+
sequence_pattern : str or None
904+
When given, *source* is a glob pattern over a folder of single-plane
905+
TIFFs and this is the filename-parsing regex; see
906+
:func:`_open_tiff_sequence`.
787907
788908
Returns
789909
-------
@@ -796,6 +916,9 @@ def _to_dask(
796916
return da.asarray(source), axes or _default_axes(source.ndim), {}
797917

798918
path = str(source)
919+
if sequence_pattern is not None:
920+
arr, detected, ps = _open_tiff_sequence(path, sequence_pattern)
921+
return arr, axes or detected, ps
799922
if path.endswith(".zarr"):
800923
arr = load_ome_zarr(source, channel=None)
801924
ax = axes or _default_axes(arr.ndim)
@@ -815,6 +938,7 @@ def to_ome_zarr(
815938
axes: Union[str, None] = None,
816939
pixel_size: Union[PixelSize, tuple, None] = None,
817940
scene: int = 0,
941+
sequence_pattern: Union[str, None] = None,
818942
n_levels: int = 5,
819943
downscale: int = 2,
820944
chunks: Union[tuple[int, ...], None] = None,
@@ -826,7 +950,8 @@ def to_ome_zarr(
826950
"""Write *source* as a pyramidal, calibrated OME-ZARR store.
827951
828952
*source* may be a dask/NumPy array, a ``.zarr`` store, an Imaris ``.ims``
829-
file, or any image format readable by bioio (CZI, LIF, ND2, OME-TIFF, …).
953+
file, any image format readable by bioio (CZI, LIF, ND2, OME-TIFF, …), or
954+
(with *sequence_pattern* set) a glob over a folder of single-plane TIFFs.
830955
File inputs are read lazily; the pyramid is built level-by-level from disk
831956
with bounded chunks, so the full volume never needs to fit in RAM. Only
832957
``x``/``y`` are downsampled; ``z`` (and channel/time) stay full-resolution.
@@ -847,6 +972,15 @@ def to_ome_zarr(
847972
arrays.
848973
scene : int, optional
849974
Scene index for multi-scene bioio files.
975+
sequence_pattern : str, optional
976+
When given, *source* is treated as a glob pattern over a folder of
977+
single-plane TIFFs (e.g. ``"folder/*.tif"``) instead of a single
978+
file, and this is the regex parsing each file name into axis labels
979+
and indices via named groups, e.g.
980+
``r"_T(?P<T>\\d+)_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+"``. Each file
981+
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.
850984
n_levels : int, optional
851985
Maximum number of pyramid levels including full resolution.
852986
downscale : int, optional
@@ -881,6 +1015,13 @@ def to_ome_zarr(
8811015
>>> from patchworks.plugins.ome_zarr import to_ome_zarr
8821016
>>> to_ome_zarr("scan.ims", "scan.zarr", n_levels=4)
8831017
'scan.zarr'
1018+
>>> to_ome_zarr(
1019+
... "ZT18_Male4_Left/*.tif",
1020+
... "ZT18_Male4_Left.zarr",
1021+
... sequence_pattern=r"_T(?P<T>\\d+)_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+",
1022+
... shard=True,
1023+
... ) # doctest: +SKIP
1024+
'ZT18_Male4_Left.zarr'
8841025
"""
8851026
if downscale < 2:
8861027
raise ValueError("downscale must be >= 2")
@@ -908,7 +1049,7 @@ def to_ome_zarr(
9081049
exc,
9091050
)
9101051

911-
arr, axes, detected = _to_dask(source, axes, scene)
1052+
arr, axes, detected = _to_dask(source, axes, scene, sequence_pattern)
9121053
if len(axes) != arr.ndim:
9131054
raise ValueError(
9141055
f"axes {axes!r} has {len(axes)} entries but array is {arr.ndim}-D"

tests/test_ome_zarr.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,35 @@ def test_non_spatial_axis_not_downsampled(tmp_path):
7979
assert load_ome_zarr(out, channel=None, level=1).shape == (3, 8, 8)
8080

8181

82+
def test_tiff_sequence_conversion(tmp_path):
83+
"""A folder of single-plane TIFFs is wrapped lazily and converted."""
84+
tifffile = pytest.importorskip("tifffile")
85+
n_z, n_c, size = 3, 2, 8
86+
for z in range(n_z):
87+
for c in range(n_c):
88+
img = np.full((size, size), z * 10 + c, dtype="uint16")
89+
tifffile.imwrite(
90+
tmp_path / f"sample_Z{z:03d}_C{c}_V0.tif",
91+
img,
92+
resolution=(20000.0, 20000.0),
93+
resolutionunit="CENTIMETER",
94+
)
95+
96+
out = tmp_path / "out.zarr"
97+
to_ome_zarr(
98+
str(tmp_path / "*.tif"),
99+
out,
100+
sequence_pattern=r"_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+",
101+
n_levels=1,
102+
)
103+
104+
result = np.asarray(load_ome_zarr(out, channel=None))
105+
assert result.shape == (n_z, n_c, size, size)
106+
# each plane's constant value encodes its (z, c) position.
107+
assert (result[:, :, 0, 0] == [[z * 10 + c for c in range(n_c)] for z in range(n_z)]).all()
108+
assert _level_scale(out, 0) == pytest.approx([1.0, 1.0, 0.5, 0.5])
109+
110+
82111
def test_axes_length_mismatch(tmp_path):
83112
with pytest.raises(ValueError):
84113
to_ome_zarr(

workflow/config/config.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
# GPUs run in parallel; the merge is a single CPU job.
66

77
# ---- input / output ---------------------------------------------------------
8-
input: "/path/to/scan.ims" # .ims, .czi, .lif, .nd2, ome-tiff, or an .zarr
8+
input: "/path/to/scan.ims" # .ims, .czi, .lif, .nd2, ome-tiff, .zarr, or a
9+
# glob (e.g. "/path/*.tif") when sequence_pattern
10+
# is set below
911
work_dir: "/path/to/results" # everything is written under here
1012
# Final outputs (under work_dir):
1113
# image.zarr — converted, pyramidal OME-ZARR
@@ -15,6 +17,10 @@ work_dir: "/path/to/results" # everything is written under here
1517
reuse_pyramid: false # for .ims: copy its own pyramid (fast); else rebuild
1618
convert_chunks: null # null → patchworks' bounded auto chunks; or [c,z,y,x]
1719
shard: false # true → pack chunks into shards (zarr v3; fewer files)
20+
# For a folder of single-plane TIFFs (input: a glob), set the filename regex
21+
# below; named groups become axes, e.g. one file per Z/C plane:
22+
# sequence_pattern: '_T(?P<T>\d+)_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+'
23+
sequence_pattern: null
1824
# Re-running reuses an existing image.zarr automatically (skips conversion).
1925
# To force a rebuild: delete image.zarr, or `snakemake --forcerun convert`.
2026

workflow/scripts/convert.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
to_ome_zarr(
1717
cfg["input"],
1818
str(snakemake.output[0]).removesuffix("/zarr.json"), # noqa: F821
19+
sequence_pattern=cfg.get("sequence_pattern"),
1920
chunks=tuple(chunks) if chunks else None,
2021
shard=bool(cfg.get("shard", False)),
2122
reuse_pyramid=bool(cfg.get("reuse_pyramid", False)),

0 commit comments

Comments
 (0)