patchworks 1.3.4__tar.gz → 1.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {patchworks-1.3.4 → patchworks-1.4.1}/PKG-INFO +3 -1
  2. {patchworks-1.3.4 → patchworks-1.4.1}/README.md +2 -0
  3. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/dog.md +3 -0
  4. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/ome_zarr_napari.md +54 -6
  5. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/plugins/ome_zarr.py +182 -8
  6. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_ome_zarr.py +66 -0
  7. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/config/config.yaml +7 -1
  8. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/_pw.py +3 -1
  9. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/convert.py +1 -0
  10. {patchworks-1.3.4 → patchworks-1.4.1}/.github/workflows/docs.yml +0 -0
  11. {patchworks-1.3.4 → patchworks-1.4.1}/.github/workflows/lint.yml +0 -0
  12. {patchworks-1.3.4 → patchworks-1.4.1}/.github/workflows/release.yml +0 -0
  13. {patchworks-1.3.4 → patchworks-1.4.1}/.gitignore +0 -0
  14. {patchworks-1.3.4 → patchworks-1.4.1}/.markdownlint-cli2.yaml +0 -0
  15. {patchworks-1.3.4 → patchworks-1.4.1}/LICENSE +0 -0
  16. {patchworks-1.3.4 → patchworks-1.4.1}/cliff.toml +0 -0
  17. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/chunks.md +0 -0
  18. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/cluster.md +0 -0
  19. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/io.md +0 -0
  20. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/merge_tile_labels.md +0 -0
  21. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/plugins/cellpose.md +0 -0
  22. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/plugins/dog.md +0 -0
  23. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/plugins/napari.md +0 -0
  24. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/plugins/ome_zarr.md +0 -0
  25. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/postprocess.md +0 -0
  26. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/relabel.md +0 -0
  27. {patchworks-1.3.4 → patchworks-1.4.1}/docs/api/tile_process.md +0 -0
  28. {patchworks-1.3.4 → patchworks-1.4.1}/docs/assets/logo.png +0 -0
  29. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/cellpose_2d.md +0 -0
  30. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/cellpose_2d.py +0 -0
  31. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/cellpose_3d.md +0 -0
  32. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/cellpose_3d.py +0 -0
  33. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/custom.md +0 -0
  34. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/custom_method.py +0 -0
  35. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/dog.py +0 -0
  36. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/standalone_merge.md +0 -0
  37. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/stardist.md +0 -0
  38. {patchworks-1.3.4 → patchworks-1.4.1}/docs/examples/stardist_2d.py +0 -0
  39. {patchworks-1.3.4 → patchworks-1.4.1}/docs/getting_started.md +0 -0
  40. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/custom_segmentation.md +0 -0
  41. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/gpu_distributed.md +0 -0
  42. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/label_relations.md +0 -0
  43. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/measurements.md +0 -0
  44. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/merging.md +0 -0
  45. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/performance.md +0 -0
  46. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/pitfalls.md +0 -0
  47. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/skip_empty.md +0 -0
  48. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/snakemake.md +0 -0
  49. {patchworks-1.3.4 → patchworks-1.4.1}/docs/guide/tiling.md +0 -0
  50. {patchworks-1.3.4 → patchworks-1.4.1}/docs/index.md +0 -0
  51. {patchworks-1.3.4 → patchworks-1.4.1}/mkdocs.yml +0 -0
  52. {patchworks-1.3.4 → patchworks-1.4.1}/pyproject.toml +0 -0
  53. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/__init__.py +0 -0
  54. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_chunks.py +0 -0
  55. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_cluster.py +0 -0
  56. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_core.py +0 -0
  57. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_distributed.py +0 -0
  58. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_io.py +0 -0
  59. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_merge.py +0 -0
  60. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_postprocess.py +0 -0
  61. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_relabel.py +0 -0
  62. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/_relations.py +0 -0
  63. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/plugins/__init__.py +0 -0
  64. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/plugins/cellpose.py +0 -0
  65. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/plugins/dog.py +0 -0
  66. {patchworks-1.3.4 → patchworks-1.4.1}/src/patchworks/plugins/napari.py +0 -0
  67. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_core.py +0 -0
  68. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_distributed.py +0 -0
  69. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_dog.py +0 -0
  70. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_napari.py +0 -0
  71. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_postprocess.py +0 -0
  72. {patchworks-1.3.4 → patchworks-1.4.1}/tests/test_relations.py +0 -0
  73. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/README.md +0 -0
  74. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/Snakefile +0 -0
  75. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/config/config_cilia.yaml +0 -0
  76. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/config/config_cyto.yaml +0 -0
  77. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/config/config_nuclei.yaml +0 -0
  78. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/config/multi.yaml +0 -0
  79. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/pixi.toml +0 -0
  80. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/profile/slurm/config.yaml +0 -0
  81. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/rules/common.smk +0 -0
  82. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/rules/convert.smk +0 -0
  83. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/rules/merge.smk +0 -0
  84. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/rules/segment.smk +0 -0
  85. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/fetch_model.py +0 -0
  86. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/merge.py +0 -0
  87. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/prepare_tiles.py +0 -0
  88. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/run_multi.py +0 -0
  89. {patchworks-1.3.4 → patchworks-1.4.1}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 1.3.4
3
+ Version: 1.4.1
4
4
  Summary: Tiled processing of arbitrarily large images with globally consistent labels
5
5
  Project-URL: Homepage, https://github.com/imcf/patchworks
6
6
  Project-URL: Issues, https://github.com/imcf/patchworks/issues
@@ -143,6 +143,8 @@ pip install "patchworks[all]" # Everything, incl. the napari viewer
143
143
  > `[imaris]` adds native `.ims` support (HDF5, no JVM). Physical pixel
144
144
  > calibration is read from the input and written into the OME-ZARR.
145
145
 
146
+ <!-- -->
147
+
146
148
  > **`cupy` is never installed automatically**, unlike Cellpose's GPU support
147
149
  > (which comes for free via PyTorch's self-contained CUDA wheels). Any
148
150
  > `use_gpu=True`/`dilate_gpu: true` option (the `dog` plugin, `dilate_labels`)
@@ -53,6 +53,8 @@ pip install "patchworks[all]" # Everything, incl. the napari viewer
53
53
  > `[imaris]` adds native `.ims` support (HDF5, no JVM). Physical pixel
54
54
  > calibration is read from the input and written into the OME-ZARR.
55
55
 
56
+ <!-- -->
57
+
56
58
  > **`cupy` is never installed automatically**, unlike Cellpose's GPU support
57
59
  > (which comes for free via PyTorch's self-contained CUDA wheels). Any
58
60
  > `use_gpu=True`/`dilate_gpu: true` option (the `dog` plugin, `dilate_labels`)
@@ -6,6 +6,9 @@ label the connected components. CPU (scipy) by default, GPU (cupy) optional.
6
6
  Optionally deconvolve each tile first with
7
7
  [pycudadecon](https://github.com/tlambert03/pycudadecon).
8
8
 
9
+ > Cilia DoG + deconvolution approach courtesy of
10
+ > [angelo-angonezi](https://github.com/angelo-angonezi).
11
+
9
12
  ## Installation
10
13
 
11
14
  `dog_label_fn` itself only needs patchworks' core deps (scipy). The
@@ -39,9 +39,10 @@ existed.
39
39
  ## Convert any image to OME-ZARR
40
40
 
41
41
  `to_ome_zarr` accepts a dask/NumPy array, an existing `.zarr` store, an
42
- **Imaris `.ims`** file, or **any format** readable by
43
- [bioio](https://github.com/bioio-devs/bioio) (CZI, LIF, ND2, OME-TIFF, …). File
44
- inputs are read **lazily**.
42
+ **Imaris `.ims`** file, **any format** readable by
43
+ [bioio](https://github.com/bioio-devs/bioio) (CZI, LIF, ND2, OME-TIFF, …), or
44
+ a **folder of single-plane TIFFs** (see below). File inputs are read
45
+ **lazily**.
45
46
 
46
47
  ```python
47
48
  from patchworks.plugins.ome_zarr import to_ome_zarr
@@ -50,6 +51,51 @@ to_ome_zarr("scan.czi", "scan.zarr", n_levels=5) # via bioio
50
51
  to_ome_zarr("scan.ims", "scan.zarr") # Imaris, native HDF5
51
52
  ```
52
53
 
54
+ ### A folder of single-plane TIFFs
55
+
56
+ Some acquisitions/stitching tools save **one TIFF per Z/C plane** instead of a
57
+ single multi-page file, e.g.:
58
+
59
+ ```text
60
+ sample_T0_Z000_C0_V0.tif
61
+ sample_T0_Z000_C1_V0.tif
62
+ sample_T0_Z001_C0_V0.tif
63
+ sample_T0_Z001_C1_V0.tif
64
+ ...
65
+ ```
66
+
67
+ Pass `sequence_pattern=` and let `source` be a glob over the folder instead of
68
+ one file path. The pattern is a regex whose **named groups** map to axis
69
+ labels — one file per group of indices:
70
+
71
+ ```python
72
+ to_ome_zarr(
73
+ "sample/*.tif",
74
+ "sample.zarr",
75
+ sequence_pattern=r"_T(?P<T>\d+)_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+",
76
+ shard=True, # recommended for large sequences, see Sharding below
77
+ )
78
+ ```
79
+
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
+
87
+ Each file becomes exactly **one dask chunk**, decoded lazily on access — no
88
+ data is duplicated or eagerly loaded, so this scales to huge (multi-TB)
89
+ sequences (built on `tifffile.TiffSequence`, the same mechanism Cellpose's
90
+ own distributed pipeline uses). Pixel calibration is read automatically from
91
+ the first file's own metadata — see [Pixel calibration](#pixel-calibration)
92
+ below.
93
+
94
+ Available on the cluster too: the Snakemake `convert` rule reads a
95
+ `sequence_pattern:` key from the config (`input:` then being the glob), so a
96
+ folder-of-TIFFs conversion runs through the same SLURM profile as any other
97
+ input — see [Cluster usage](snakemake.md).
98
+
53
99
  !!! note "Imaris pyramids: rebuild (default) or reuse"
54
100
  `.ims` files carry their own resolution pyramid. By default `to_ome_zarr`
55
101
  reads only the **full-resolution** level and **builds a fresh NGFF pyramid**
@@ -64,9 +110,11 @@ to_ome_zarr("scan.ims", "scan.zarr") # Imaris, native HDF5
64
110
  ### Pixel calibration
65
111
 
66
112
  The physical voxel size is read from the input — bioio's `physical_pixel_sizes`,
67
- the Imaris resolution metadata, or an existing OME-ZARR's scale and written
68
- into the NGFF `coordinateTransformations` (in micrometers), so calibration is
69
- preserved regardless of input. Override or supply it for bare arrays with
113
+ the Imaris resolution metadata, an existing OME-ZARR's scale, or (for a TIFF
114
+ sequence) the first file's own ImageJ metadata (`spacing`/`unit`) or
115
+ `XResolution`/`YResolution` tags and written into the NGFF
116
+ `coordinateTransformations` (in micrometers), so calibration is preserved
117
+ regardless of input. Override or supply it for bare arrays with
70
118
  `pixel_size={"z": 2.0, "y": 0.32, "x": 0.32}`.
71
119
 
72
120
  ### Won't OOM
@@ -9,14 +9,18 @@ Input handling, in order:
9
9
  * a ``.zarr`` path → read through :func:`patchworks.load_ome_zarr`;
10
10
  * a ``.ims`` (Imaris) path → read lazily with ``imaris-ims-file-reader``
11
11
  (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;
12
15
  * any other path (CZI, LIF, ND2, OME-TIFF, …) → opened lazily with
13
16
  `bioio <https://github.com/bioio-devs/bioio>`_.
14
17
 
15
18
  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.
20
24
 
21
25
  Downsampling uses strided (nearest-neighbour) subsampling — the correct,
22
26
  label-preserving choice — and only on **X and Y**; ``z`` (and channel/time)
@@ -620,6 +624,146 @@ def _open_bioio(path: str, scene: int) -> tuple[da.Array, str, PixelSize]:
620
624
  return arr, axes, pixel_size
621
625
 
622
626
 
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 — 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`).
710
+
711
+ Returns
712
+ -------
713
+ tuple
714
+ ``(array, axes, pixel_size)`` — a lazy dask array, its axes string
715
+ (parsed axes, canonicalised, + trailing ``"yx"``) and a
716
+ ``{axis: micrometers}`` calibration dict read from the first file.
717
+ """
718
+ try:
719
+ import tifffile
720
+ except ImportError as exc:
721
+ raise ImportError(
722
+ "reading a folder of TIFFs requires tifffile. Install it with:\n"
723
+ " pip install tifffile\n"
724
+ "(already pulled in by patchworks[bioio] via bioio-tifffile)."
725
+ ) from exc
726
+
727
+ ts = tifffile.TiffSequence(pattern, pattern=sequence_pattern)
728
+ arr = da.from_zarr(zarr.open(store=ts.aszarr()))
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
+
756
+ pixel_size = _tiff_pixel_size(ts[0])
757
+ logger.info(
758
+ "tifffile sequence opened %s files as %s %s cal=%s",
759
+ len(ts),
760
+ axes,
761
+ arr.shape,
762
+ pixel_size,
763
+ )
764
+ return arr, axes, pixel_size
765
+
766
+
623
767
  def _open_imaris(path: str, level: int = 0) -> tuple[da.Array, str, PixelSize]:
624
768
  """Open one Imaris ``.ims`` resolution level lazily.
625
769
 
@@ -769,12 +913,14 @@ def _to_dask(
769
913
  source: Union[da.Array, np.ndarray, str, Path],
770
914
  axes: Union[str, None],
771
915
  scene: int,
916
+ sequence_pattern: Union[str, None] = None,
772
917
  ) -> tuple[da.Array, str, PixelSize]:
773
918
  """Resolve *source* into a lazy ``(array, axes, pixel_size)`` triple.
774
919
 
775
920
  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.
921
+ OME-ZARR loader; ``.ims`` paths use the Imaris reader; a *sequence_pattern*
922
+ treats *source* as a glob over a folder of single-plane TIFFs; anything
923
+ else uses bioio.
778
924
 
779
925
  Parameters
780
926
  ----------
@@ -784,6 +930,10 @@ def _to_dask(
784
930
  Explicit axes, or ``None`` to infer them.
785
931
  scene : int
786
932
  Scene index for bioio inputs.
933
+ sequence_pattern : str or None
934
+ When given, *source* is a glob pattern over a folder of single-plane
935
+ TIFFs and this is the filename-parsing regex; see
936
+ :func:`_open_tiff_sequence`.
787
937
 
788
938
  Returns
789
939
  -------
@@ -796,6 +946,9 @@ def _to_dask(
796
946
  return da.asarray(source), axes or _default_axes(source.ndim), {}
797
947
 
798
948
  path = str(source)
949
+ if sequence_pattern is not None:
950
+ arr, detected, ps = _open_tiff_sequence(path, sequence_pattern)
951
+ return arr, axes or detected, ps
799
952
  if path.endswith(".zarr"):
800
953
  arr = load_ome_zarr(source, channel=None)
801
954
  ax = axes or _default_axes(arr.ndim)
@@ -815,6 +968,7 @@ def to_ome_zarr(
815
968
  axes: Union[str, None] = None,
816
969
  pixel_size: Union[PixelSize, tuple, None] = None,
817
970
  scene: int = 0,
971
+ sequence_pattern: Union[str, None] = None,
818
972
  n_levels: int = 5,
819
973
  downscale: int = 2,
820
974
  chunks: Union[tuple[int, ...], None] = None,
@@ -826,7 +980,8 @@ def to_ome_zarr(
826
980
  """Write *source* as a pyramidal, calibrated OME-ZARR store.
827
981
 
828
982
  *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, …).
983
+ file, any image format readable by bioio (CZI, LIF, ND2, OME-TIFF, …), or
984
+ (with *sequence_pattern* set) a glob over a folder of single-plane TIFFs.
830
985
  File inputs are read lazily; the pyramid is built level-by-level from disk
831
986
  with bounded chunks, so the full volume never needs to fit in RAM. Only
832
987
  ``x``/``y`` are downsampled; ``z`` (and channel/time) stay full-resolution.
@@ -847,6 +1002,18 @@ def to_ome_zarr(
847
1002
  arrays.
848
1003
  scene : int, optional
849
1004
  Scene index for multi-scene bioio files.
1005
+ sequence_pattern : str, optional
1006
+ When given, *source* is treated as a glob pattern over a folder of
1007
+ single-plane TIFFs (e.g. ``"folder/*.tif"``) instead of a single
1008
+ file, and this is the regex parsing each file name into axis labels
1009
+ and indices via named groups, e.g.
1010
+ ``r"_T(?P<T>\\d+)_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+"``. Each file
1011
+ becomes exactly one chunk, read lazily on access (no data
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.
850
1017
  n_levels : int, optional
851
1018
  Maximum number of pyramid levels including full resolution.
852
1019
  downscale : int, optional
@@ -881,6 +1048,13 @@ def to_ome_zarr(
881
1048
  >>> from patchworks.plugins.ome_zarr import to_ome_zarr
882
1049
  >>> to_ome_zarr("scan.ims", "scan.zarr", n_levels=4)
883
1050
  'scan.zarr'
1051
+ >>> to_ome_zarr(
1052
+ ... "ZT18_Male4_Left/*.tif",
1053
+ ... "ZT18_Male4_Left.zarr",
1054
+ ... sequence_pattern=r"_T(?P<T>\\d+)_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+",
1055
+ ... shard=True,
1056
+ ... ) # doctest: +SKIP
1057
+ 'ZT18_Male4_Left.zarr'
884
1058
  """
885
1059
  if downscale < 2:
886
1060
  raise ValueError("downscale must be >= 2")
@@ -908,7 +1082,7 @@ def to_ome_zarr(
908
1082
  exc,
909
1083
  )
910
1084
 
911
- arr, axes, detected = _to_dask(source, axes, scene)
1085
+ arr, axes, detected = _to_dask(source, axes, scene, sequence_pattern)
912
1086
  if len(axes) != arr.ndim:
913
1087
  raise ValueError(
914
1088
  f"axes {axes!r} has {len(axes)} entries but array is {arr.ndim}-D"
@@ -79,6 +79,72 @@ def test_non_spatial_axis_not_downsampled(tmp_path):
79
79
  assert load_ome_zarr(out, channel=None, level=1).shape == (3, 8, 8)
80
80
 
81
81
 
82
+ def test_tiff_sequence_conversion(tmp_path):
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
+ """
89
+ tifffile = pytest.importorskip("tifffile")
90
+ n_z, n_c, size = 3, 2, 8
91
+ for z in range(n_z):
92
+ for c in range(n_c):
93
+ img = np.full((size, size), z * 10 + c, dtype="uint16")
94
+ tifffile.imwrite(
95
+ tmp_path / f"sample_Z{z:03d}_C{c}_V0.tif",
96
+ img,
97
+ resolution=(20000.0, 20000.0),
98
+ resolutionunit="CENTIMETER",
99
+ )
100
+
101
+ out = tmp_path / "out.zarr"
102
+ to_ome_zarr(
103
+ str(tmp_path / "*.tif"),
104
+ out,
105
+ sequence_pattern=r"_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+",
106
+ n_levels=1,
107
+ )
108
+
109
+ result = np.asarray(load_ome_zarr(out, channel=None))
110
+ assert result.shape == (n_c, n_z, size, size) # channel-first, not z-first
111
+ # each plane's constant value encodes its (z, c) position.
112
+ assert (
113
+ result[:, :, 0, 0]
114
+ == [[z * 10 + c for z in range(n_z)] for c in range(n_c)]
115
+ ).all()
116
+ assert _level_scale(out, 0) == pytest.approx([1.0, 1.0, 0.5, 0.5])
117
+
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
+
147
+
82
148
  def test_axes_length_mismatch(tmp_path):
83
149
  with pytest.raises(ValueError):
84
150
  to_ome_zarr(
@@ -5,7 +5,9 @@
5
5
  # GPUs run in parallel; the merge is a single CPU job.
6
6
 
7
7
  # ---- 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
9
11
  work_dir: "/path/to/results" # everything is written under here
10
12
  # Final outputs (under work_dir):
11
13
  # image.zarr — converted, pyramidal OME-ZARR
@@ -15,6 +17,10 @@ work_dir: "/path/to/results" # everything is written under here
15
17
  reuse_pyramid: false # for .ims: copy its own pyramid (fast); else rebuild
16
18
  convert_chunks: null # null → patchworks' bounded auto chunks; or [c,z,y,x]
17
19
  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
18
24
  # Re-running reuses an existing image.zarr automatically (skips conversion).
19
25
  # To force a rebuild: delete image.zarr, or `snakemake --forcerun convert`.
20
26
 
@@ -152,7 +152,9 @@ def build_fn(cfg):
152
152
  if dilate:
153
153
  from patchworks import dilate_labels
154
154
 
155
- fn = dilate_labels(fn, iterations=dilate, use_gpu=cfg.get("dilate_gpu", False))
155
+ fn = dilate_labels(
156
+ fn, iterations=dilate, use_gpu=cfg.get("dilate_gpu", False)
157
+ )
156
158
 
157
159
  return fn
158
160
 
@@ -16,6 +16,7 @@ chunks = cfg.get("convert_chunks")
16
16
  to_ome_zarr(
17
17
  cfg["input"],
18
18
  str(snakemake.output[0]).removesuffix("/zarr.json"), # noqa: F821
19
+ sequence_pattern=cfg.get("sequence_pattern"),
19
20
  chunks=tuple(chunks) if chunks else None,
20
21
  shard=bool(cfg.get("shard", False)),
21
22
  reuse_pyramid=bool(cfg.get("reuse_pyramid", False)),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes