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
1518Pixel 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
2125Downsampling uses strided (nearest-neighbour) subsampling — the correct,
2226label-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+
623737def _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"
0 commit comments