patchworks 2.1.1__tar.gz → 2.1.2__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 (94) hide show
  1. {patchworks-2.1.1 → patchworks-2.1.2}/PKG-INFO +1 -1
  2. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/plugins/ome_zarr.py +195 -23
  3. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_ome_zarr.py +161 -0
  4. {patchworks-2.1.1 → patchworks-2.1.2}/.github/workflows/docs.yml +0 -0
  5. {patchworks-2.1.1 → patchworks-2.1.2}/.github/workflows/lint.yml +0 -0
  6. {patchworks-2.1.1 → patchworks-2.1.2}/.github/workflows/release.yml +0 -0
  7. {patchworks-2.1.1 → patchworks-2.1.2}/.gitignore +0 -0
  8. {patchworks-2.1.1 → patchworks-2.1.2}/.markdownlint-cli2.yaml +0 -0
  9. {patchworks-2.1.1 → patchworks-2.1.2}/LICENSE +0 -0
  10. {patchworks-2.1.1 → patchworks-2.1.2}/README.md +0 -0
  11. {patchworks-2.1.1 → patchworks-2.1.2}/cliff.toml +0 -0
  12. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/chunks.md +0 -0
  13. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/cluster.md +0 -0
  14. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/io.md +0 -0
  15. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/merge_tile_labels.md +0 -0
  16. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/plugins/cellpose.md +0 -0
  17. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/plugins/dog.md +0 -0
  18. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/plugins/napari.md +0 -0
  19. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/plugins/ome_zarr.md +0 -0
  20. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/postprocess.md +0 -0
  21. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/relabel.md +0 -0
  22. {patchworks-2.1.1 → patchworks-2.1.2}/docs/api/tile_process.md +0 -0
  23. {patchworks-2.1.1 → patchworks-2.1.2}/docs/assets/logo.png +0 -0
  24. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/cellpose_2d.md +0 -0
  25. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/cellpose_2d.py +0 -0
  26. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/cellpose_3d.md +0 -0
  27. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/cellpose_3d.py +0 -0
  28. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/custom.md +0 -0
  29. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/custom_method.py +0 -0
  30. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/dog.md +0 -0
  31. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/dog.py +0 -0
  32. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/standalone_merge.md +0 -0
  33. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/stardist.md +0 -0
  34. {patchworks-2.1.1 → patchworks-2.1.2}/docs/examples/stardist_2d.py +0 -0
  35. {patchworks-2.1.1 → patchworks-2.1.2}/docs/getting_started.md +0 -0
  36. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/custom_segmentation.md +0 -0
  37. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/gpu_distributed.md +0 -0
  38. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/label_relations.md +0 -0
  39. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/measurements.md +0 -0
  40. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/merging.md +0 -0
  41. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/ome_zarr_napari.md +0 -0
  42. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/performance.md +0 -0
  43. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/pitfalls.md +0 -0
  44. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/skip_empty.md +0 -0
  45. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/snakemake.md +0 -0
  46. {patchworks-2.1.1 → patchworks-2.1.2}/docs/guide/tiling.md +0 -0
  47. {patchworks-2.1.1 → patchworks-2.1.2}/docs/index.md +0 -0
  48. {patchworks-2.1.1 → patchworks-2.1.2}/mkdocs.yml +0 -0
  49. {patchworks-2.1.1 → patchworks-2.1.2}/pyproject.toml +0 -0
  50. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/__init__.py +0 -0
  51. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_chunks.py +0 -0
  52. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_cluster.py +0 -0
  53. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_core.py +0 -0
  54. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_distributed.py +0 -0
  55. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_gpu.py +0 -0
  56. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_io.py +0 -0
  57. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_merge.py +0 -0
  58. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_occupancy.py +0 -0
  59. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_postprocess.py +0 -0
  60. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_relabel.py +0 -0
  61. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/_relations.py +0 -0
  62. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/plugins/__init__.py +0 -0
  63. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/plugins/cellpose.py +0 -0
  64. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/plugins/dog.py +0 -0
  65. {patchworks-2.1.1 → patchworks-2.1.2}/src/patchworks/plugins/napari.py +0 -0
  66. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_allocation.py +0 -0
  67. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_core.py +0 -0
  68. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_distributed.py +0 -0
  69. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_dog.py +0 -0
  70. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_gpu.py +0 -0
  71. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_napari.py +0 -0
  72. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_occupancy.py +0 -0
  73. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_postprocess.py +0 -0
  74. {patchworks-2.1.1 → patchworks-2.1.2}/tests/test_relations.py +0 -0
  75. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/README.md +0 -0
  76. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/Snakefile +0 -0
  77. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/config/config.yaml +0 -0
  78. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/config/config_cilia.yaml +0 -0
  79. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/config/config_cyto.yaml +0 -0
  80. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/config/config_nuclei.yaml +0 -0
  81. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/config/multi.yaml +0 -0
  82. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/pixi.toml +0 -0
  83. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/profile/slurm/config.yaml +0 -0
  84. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/rules/common.smk +0 -0
  85. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/rules/convert.smk +0 -0
  86. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/rules/merge.smk +0 -0
  87. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/rules/segment.smk +0 -0
  88. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/_pw.py +0 -0
  89. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/convert.py +0 -0
  90. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/fetch_model.py +0 -0
  91. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/merge.py +0 -0
  92. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/prepare_tiles.py +0 -0
  93. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/run_multi.py +0 -0
  94. {patchworks-2.1.1 → patchworks-2.1.2}/workflow/scripts/segment_tile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 2.1.1
3
+ Version: 2.1.2
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
@@ -49,6 +49,7 @@ import glob
49
49
  import logging
50
50
  import math
51
51
  from concurrent.futures import ThreadPoolExecutor
52
+ from contextlib import nullcontext as _nullcontext
52
53
  from itertools import product as _iproduct
53
54
  from pathlib import Path
54
55
  from typing import Union
@@ -171,22 +172,41 @@ def _strides(axes: str, downscale: int) -> tuple[int, ...]:
171
172
  return tuple(downscale if a in _DOWNSAMPLE_AXES else 1 for a in axes)
172
173
 
173
174
 
174
- def _default_chunks(shape: tuple[int, ...], axes: str) -> tuple[int, ...]:
175
+ def _default_chunks(
176
+ shape: tuple[int, ...],
177
+ axes: str,
178
+ source_chunks: Union[tuple[int, ...], None] = None,
179
+ ) -> tuple[int, ...]:
175
180
  """Bounded chunk shape so writing a level never blows up RAM.
176
181
 
182
+ With *source_chunks*, the result never spans more than one source chunk on
183
+ any axis. That matters when the source's own granularity is coarse: a
184
+ folder of stitched TIFFs gives one chunk per **file**, so a plane can be
185
+ gigabytes. Grouping 16 of those into one output chunk (the ``z`` cap) means
186
+ holding 16 whole planes to write a 32 MB chunk -- tens of GB for the
187
+ smallest unit of work, which is how conversion gets OOM-killed.
188
+
189
+ Splitting a source chunk is fine (that is a slice of something already
190
+ read); combining several is not.
191
+
177
192
  Parameters
178
193
  ----------
179
194
  shape : tuple of int
180
195
  Array shape.
181
196
  axes : str
182
197
  One letter per axis (selects the per-axis cap).
198
+ source_chunks : tuple of int, optional
199
+ The input array's own chunking.
183
200
 
184
201
  Returns
185
202
  -------
186
203
  tuple of int
187
- Per-axis chunk size, capped by ``_CHUNK_CAP``.
204
+ Per-axis chunk size, capped by ``_CHUNK_CAP`` and by *source_chunks*.
188
205
  """
189
- return tuple(min(s, _CHUNK_CAP.get(a, s)) for s, a in zip(shape, axes))
206
+ caps = [min(s, _CHUNK_CAP.get(a, s)) for s, a in zip(shape, axes)]
207
+ if source_chunks is not None:
208
+ caps = [min(c, max(1, int(sc))) for c, sc in zip(caps, source_chunks)]
209
+ return tuple(caps)
190
210
 
191
211
 
192
212
  ShardSpec = Union[bool, tuple[int, ...]]
@@ -446,6 +466,70 @@ def _stream_strided_level(
446
466
  pass
447
467
 
448
468
 
469
+ # One chunk is one file without sharding. A shared cluster filesystem starts
470
+ # to hurt well before a million of them, and the cost lands on every later
471
+ # read too, not just the write.
472
+ _CHUNK_COUNT_WARN = 200_000
473
+
474
+
475
+ def _warn_if_many_chunks(
476
+ shape: tuple[int, ...], chunks: tuple[int, ...], shard: ShardSpec
477
+ ) -> None:
478
+ """Warn when a level will be written as a very large number of files.
479
+
480
+ A coarse source forces fine output chunks (see :func:`_default_chunks`),
481
+ and fine chunks mean many files. Sharding packs them into far fewer
482
+ without changing the chunking, so the fix is cheap -- but only if someone
483
+ notices before the store is written.
484
+ """
485
+ if shard:
486
+ return
487
+ count = int(np.prod([math.ceil(s / c) for s, c in zip(shape, chunks)]))
488
+ if count < _CHUNK_COUNT_WARN:
489
+ return
490
+ logger.warning(
491
+ "level 0 will be %s chunks, i.e. that many files: the source's own "
492
+ "granularity forced chunks of %s. Set shard=True (config: "
493
+ "shard: true) to pack them into far fewer files -- same chunking, "
494
+ "same memory, and kinder to a shared filesystem.",
495
+ f"{count:,}",
496
+ chunks,
497
+ )
498
+
499
+
500
+ def _bounded_scheduler(arr: da.Array):
501
+ """Cap dask's threads by the memory one **source** chunk costs.
502
+
503
+ Dask keeps a source chunk alive while every task that reads it runs, so
504
+ peak memory tracks the *input* granularity, not the output's. A folder of
505
+ stitched TIFFs gives one chunk per file -- gigabytes each -- and the
506
+ default worker count is the machine's core count, so 32 threads x a 3.6 GB
507
+ plane is over 100 GB before anything is written.
508
+
509
+ Sizing from the source chunk against the job's real budget keeps that
510
+ bounded. Returns a context manager; a no-op when a distributed client is
511
+ driving, since it schedules with its own memory awareness.
512
+ """
513
+ import dask
514
+
515
+ from .._chunks import cpu_allocation, safe_worker_count
516
+ from .._cluster import _distributed_client
517
+
518
+ if _distributed_client() is not None:
519
+ return _nullcontext()
520
+ chunk_nbytes = int(np.prod(arr.chunksize)) * arr.dtype.itemsize
521
+ # Two live copies per worker: the source chunk plus the slice being written.
522
+ workers = min(
523
+ cpu_allocation(), safe_worker_count(chunk_nbytes, fn_overhead=2)
524
+ )
525
+ logger.info(
526
+ "writing with %d dask worker(s): source chunks are %.2f GB each",
527
+ workers,
528
+ chunk_nbytes / 1024**3,
529
+ )
530
+ return dask.config.set(scheduler="threads", num_workers=workers)
531
+
532
+
449
533
  def _progress_ctx(progress: bool, label: str):
450
534
  """Return a progress-bar context manager.
451
535
 
@@ -648,7 +732,12 @@ def _write_pyramid(
648
732
  strides = _strides(axes, downscale)
649
733
 
650
734
  if write_base:
651
- base_chunks = chunks or _default_chunks(arr.shape, axes)
735
+ # Cap by the source's own chunking: combining several source chunks
736
+ # into one output chunk can mean holding gigabytes to write megabytes.
737
+ base_chunks = chunks or _default_chunks(
738
+ arr.shape, axes, source_chunks=arr.chunksize
739
+ )
740
+ _warn_if_many_chunks(arr.shape, base_chunks, shard)
652
741
  _to_zarr_level(
653
742
  arr.rechunk(base_chunks), group_path, base_name, shard, progress
654
743
  )
@@ -872,16 +961,75 @@ _UNIT_TO_UM = {
872
961
  _RESUNIT_TO_UM = {2: 25400.0, 3: 10000.0}
873
962
 
874
963
 
964
+ def _ome_xml_pixel_size(xml: str) -> PixelSize:
965
+ """Voxel size from an OME-XML ``Pixels`` element.
966
+
967
+ Stitching and acquisition software commonly writes OME-XML into
968
+ ``ImageDescription`` and nothing else -- no ImageJ block, and resolution
969
+ tags left at their defaults. Readers that only look at the TIFF tags then
970
+ report an uncalibrated image even though the size is right there.
971
+
972
+ Parameters
973
+ ----------
974
+ xml : str
975
+ The OME-XML document.
976
+
977
+ Returns
978
+ -------
979
+ dict
980
+ ``{axis: micrometers}`` for whichever axes carry a size.
981
+ """
982
+ from xml.etree import ElementTree
983
+
984
+ try:
985
+ root = ElementTree.fromstring(xml)
986
+ except ElementTree.ParseError:
987
+ return {}
988
+ # The OME namespace is versioned, so match on the local tag name.
989
+ pixels = next(
990
+ (el for el in root.iter() if el.tag.rsplit("}", 1)[-1] == "Pixels"),
991
+ None,
992
+ )
993
+ if pixels is None:
994
+ return {}
995
+ out: PixelSize = {}
996
+ for axis in "zyx":
997
+ value = pixels.get(f"PhysicalSize{axis.upper()}")
998
+ if not value:
999
+ continue
1000
+ unit = pixels.get(f"PhysicalSize{axis.upper()}Unit", "µm")
1001
+ factor = _UNIT_TO_UM.get(str(unit).lower())
1002
+ if factor is None:
1003
+ logger.warning(
1004
+ "OME-XML PhysicalSize%s has unit %r, which is not recognised; "
1005
+ "ignoring it.",
1006
+ axis.upper(),
1007
+ unit,
1008
+ )
1009
+ continue
1010
+ try:
1011
+ out[axis] = float(value) * factor
1012
+ except ValueError:
1013
+ continue
1014
+ return out
1015
+
1016
+
875
1017
  def _tiff_pixel_size(path: str) -> PixelSize:
876
1018
  """Read physical voxel size from a TIFF file's own metadata.
877
1019
 
878
- Z comes from ImageJ metadata (``spacing`` + ``unit``), if present. X/Y
879
- come from the page's ``XResolution``/``YResolution`` tags (pixels per
880
- unit); the unit itself is taken from the ``ResolutionUnit`` tag when set
881
- (plain TIFFs), or falls back to ImageJ metadata's ``unit`` — ImageJ
882
- itself writes ``ResolutionUnit=NONE`` and keeps the unit as a string in
883
- its metadata instead. Unrecognized units are ignored (treated as
884
- uncalibrated).
1020
+ Checks every place the size is commonly written, most explicit first:
1021
+
1022
+ 1. **OME-XML** ``PhysicalSizeX/Y/Z`` (+ their units) in
1023
+ ``ImageDescription`` what stitched and OME-TIFF output usually
1024
+ carries, and the only one of the three that can give ``z`` for a
1025
+ sequence of single planes.
1026
+ 2. **ImageJ metadata** — ``spacing`` (+ ``unit``) for ``z``.
1027
+ 3. **Resolution tags** — ``XResolution``/``YResolution`` in pixels per
1028
+ unit, the unit from ``ResolutionUnit`` (plain TIFFs) or from ImageJ's
1029
+ ``unit`` string (ImageJ writes ``ResolutionUnit=NONE``).
1030
+
1031
+ Earlier sources win per axis, so a file carrying both keeps the explicit
1032
+ OME value. Unrecognized units are ignored rather than guessed at.
885
1033
 
886
1034
  Parameters
887
1035
  ----------
@@ -898,9 +1046,18 @@ def _tiff_pixel_size(path: str) -> PixelSize:
898
1046
 
899
1047
  pixel_size: PixelSize = {}
900
1048
  with tifffile.TiffFile(path) as tif:
1049
+ description = tif.pages[0].tags.get("ImageDescription")
1050
+ xml = tif.ome_metadata or (
1051
+ description.value
1052
+ if description and "<OME" in str(description.value)
1053
+ else None
1054
+ )
1055
+ if xml:
1056
+ pixel_size.update(_ome_xml_pixel_size(str(xml)))
1057
+
901
1058
  ij = tif.imagej_metadata or {}
902
1059
  spacing = ij.get("spacing")
903
- if spacing:
1060
+ if spacing and "z" not in pixel_size:
904
1061
  factor = _UNIT_TO_UM.get(str(ij.get("unit", "micron")).lower(), 1.0)
905
1062
  pixel_size["z"] = float(spacing) * factor
906
1063
 
@@ -911,6 +1068,8 @@ def _tiff_pixel_size(path: str) -> PixelSize:
911
1068
  ) or _UNIT_TO_UM.get(str(ij.get("unit", "")).lower())
912
1069
  if um_per_unit is not None:
913
1070
  for axis, tag_name in (("y", "YResolution"), ("x", "XResolution")):
1071
+ if axis in pixel_size:
1072
+ continue
914
1073
  tag = page.tags.get(tag_name)
915
1074
  if tag and tag.value[0]:
916
1075
  num, den = tag.value
@@ -1014,6 +1173,18 @@ def _open_tiff_sequence(
1014
1173
  arr.shape,
1015
1174
  pixel_size,
1016
1175
  )
1176
+ missing = [a for a in axes if a in _SPATIAL_AXES and a not in pixel_size]
1177
+ if missing:
1178
+ logger.warning(
1179
+ "no voxel size for %s in %s -- checked OME-XML PhysicalSize*, "
1180
+ "ImageJ spacing/unit, and the resolution tags. The store will be "
1181
+ "uncalibrated on %s, which also leaves anything deriving physical "
1182
+ "units from it (e.g. deconvolution voxel sizes) with nothing to "
1183
+ "read. Pass pixel_size= to supply it.",
1184
+ "/".join(missing),
1185
+ Path(ts[0]).name,
1186
+ "/".join(missing),
1187
+ )
1017
1188
  return arr, axes, pixel_size
1018
1189
 
1019
1190
 
@@ -1361,17 +1532,18 @@ def to_ome_zarr(
1361
1532
 
1362
1533
  out = str(out_path)
1363
1534
  zarr.open_group(out, mode="w" if overwrite else "w-")
1364
- datasets = _write_pyramid(
1365
- arr,
1366
- axes,
1367
- out,
1368
- n_levels=n_levels,
1369
- downscale=downscale,
1370
- chunks=chunks,
1371
- base_scale=base_scale,
1372
- shard=shard,
1373
- progress=progress,
1374
- )
1535
+ with _bounded_scheduler(arr):
1536
+ datasets = _write_pyramid(
1537
+ arr,
1538
+ axes,
1539
+ out,
1540
+ n_levels=n_levels,
1541
+ downscale=downscale,
1542
+ chunks=chunks,
1543
+ base_scale=base_scale,
1544
+ shard=shard,
1545
+ progress=progress,
1546
+ )
1375
1547
  _write_multiscales(out, axes, datasets, Path(out).stem, calibrated=bool(ps))
1376
1548
  return out
1377
1549
 
@@ -415,3 +415,164 @@ def test_glob_without_sequence_pattern_says_so(tmp_path):
415
415
  with pytest.raises(Exception) as exc:
416
416
  to_ome_zarr(str(tmp_path / "scan.ims"), tmp_path / "o2.zarr")
417
417
  assert "sequence_pattern" not in str(exc.value)
418
+
419
+
420
+ def test_base_chunks_never_group_source_chunks(tmp_path):
421
+ """One output chunk must not require several source chunks.
422
+
423
+ A folder of stitched TIFFs gives one dask chunk per file, so a plane can
424
+ be gigabytes. The z cap of 16 would group sixteen of them into one output
425
+ chunk -- ~58 GB held to write 32 MB, against a 64 GB job. Splitting a
426
+ source chunk is fine; combining several is not.
427
+ """
428
+ from patchworks.plugins.ome_zarr import _default_chunks
429
+
430
+ shape = (4, 126, 45961, 42072)
431
+ # one chunk per file: (c=1, z=1, whole plane)
432
+ assert _default_chunks(
433
+ shape, "czyx", source_chunks=(1, 1, 45961, 42072)
434
+ ) == (
435
+ 1,
436
+ 1,
437
+ 1024,
438
+ 1024,
439
+ )
440
+ # a normally-chunked source is unaffected by the cap
441
+ assert _default_chunks(shape, "czyx", source_chunks=shape) == (
442
+ 1,
443
+ 16,
444
+ 1024,
445
+ 1024,
446
+ )
447
+ # and omitting it keeps the previous behaviour
448
+ assert _default_chunks(shape, "czyx") == (1, 16, 1024, 1024)
449
+
450
+
451
+ def test_tiff_sequence_keeps_one_plane_per_chunk(tmp_path):
452
+ """End to end: a per-file source must not be z-grouped on write."""
453
+ tifffile = pytest.importorskip("tifffile")
454
+ n_z, n_c, size = 4, 2, 8
455
+ for z in range(n_z):
456
+ for c in range(n_c):
457
+ tifffile.imwrite(
458
+ tmp_path / f"s_Z{z:03d}_C{c}_V0.tif",
459
+ np.full((size, size), z * 10 + c, "uint16"),
460
+ )
461
+
462
+ out = tmp_path / "seq.zarr"
463
+ to_ome_zarr(
464
+ str(tmp_path / "*.tif"),
465
+ out,
466
+ sequence_pattern=r"_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+",
467
+ n_levels=1,
468
+ progress=False,
469
+ )
470
+ level0 = zarr.open_array(str(out) + "/0", mode="r")
471
+ assert level0.chunks[1] == 1, (
472
+ f"z was grouped into {level0.chunks[1]} planes per chunk; each source "
473
+ "chunk is a whole file, so that multiplies the read"
474
+ )
475
+ # and the data still round-trips
476
+ result = np.asarray(load_ome_zarr(out, channel=None))
477
+ assert result.shape == (n_c, n_z, size, size)
478
+ assert (
479
+ result[:, :, 0, 0]
480
+ == [[z * 10 + c for z in range(n_z)] for c in range(n_c)]
481
+ ).all()
482
+
483
+
484
+ def _ome_xml(x, y, z, unit="um"):
485
+ return (
486
+ '<?xml version="1.0"?>'
487
+ '<OME xmlns="http://www.openmicroscopy.org/Schemas/OME/2016-06">'
488
+ f'<Image><Pixels PhysicalSizeX="{x}" PhysicalSizeXUnit="{unit}"'
489
+ f' PhysicalSizeY="{y}" PhysicalSizeYUnit="{unit}"'
490
+ f' PhysicalSizeZ="{z}" PhysicalSizeZUnit="{unit}"/></Image></OME>'
491
+ )
492
+
493
+
494
+ def test_pixel_size_from_ome_xml(tmp_path):
495
+ """OME-XML PhysicalSize* must be read, not just the resolution tags.
496
+
497
+ Stitched output commonly writes OME-XML into ImageDescription and leaves
498
+ the TIFF tags at their defaults, so a tag-only reader calls a perfectly
499
+ calibrated image uncalibrated -- and it is the only source that can give
500
+ z for a sequence of single planes.
501
+ """
502
+ tifffile = pytest.importorskip("tifffile")
503
+ from patchworks.plugins.ome_zarr import _tiff_pixel_size
504
+
505
+ path = tmp_path / "ome.tif"
506
+ tifffile.imwrite(
507
+ path,
508
+ np.zeros((8, 8), "uint16"),
509
+ description=_ome_xml(0.325, 0.325, 1.5),
510
+ )
511
+ assert _tiff_pixel_size(str(path)) == pytest.approx(
512
+ {"z": 1.5, "y": 0.325, "x": 0.325}
513
+ )
514
+
515
+ # mm is converted, not taken at face value
516
+ mm = tmp_path / "mm.tif"
517
+ tifffile.imwrite(
518
+ mm,
519
+ np.zeros((8, 8), "uint16"),
520
+ description=_ome_xml(0.001, 0.001, 0.002, "mm"),
521
+ )
522
+ assert _tiff_pixel_size(str(mm)) == pytest.approx(
523
+ {"z": 2.0, "y": 1.0, "x": 1.0}
524
+ )
525
+
526
+
527
+ def test_ome_xml_wins_over_the_resolution_tags(tmp_path):
528
+ """The explicit source wins per axis when a file carries both."""
529
+ tifffile = pytest.importorskip("tifffile")
530
+ from patchworks.plugins.ome_zarr import _tiff_pixel_size
531
+
532
+ path = tmp_path / "both.tif"
533
+ tifffile.imwrite(
534
+ path,
535
+ np.zeros((8, 8), "uint16"),
536
+ description=_ome_xml(0.325, 0.325, 1.5),
537
+ resolution=(20000.0, 20000.0), # would say 0.5 µm
538
+ resolutionunit="CENTIMETER",
539
+ )
540
+ got = _tiff_pixel_size(str(path))
541
+ assert got["x"] == pytest.approx(0.325), "OME-XML must win over the tag"
542
+ assert got["z"] == pytest.approx(1.5)
543
+
544
+
545
+ def test_uncalibrated_tiff_reports_nothing(tmp_path):
546
+ """No metadata gives an empty dict rather than an invented default."""
547
+ tifffile = pytest.importorskip("tifffile")
548
+ from patchworks.plugins.ome_zarr import _tiff_pixel_size
549
+
550
+ path = tmp_path / "bare.tif"
551
+ tifffile.imwrite(path, np.zeros((8, 8), "uint16"))
552
+ assert _tiff_pixel_size(str(path)) == {}
553
+
554
+
555
+ def test_many_chunks_suggests_sharding(caplog):
556
+ """A coarse source forces fine chunks; fine chunks mean many files.
557
+
558
+ That trade-off is invisible until a shared filesystem is carrying a
559
+ million of them, so it has to be said at write time -- with the remedy.
560
+ """
561
+ import logging
562
+
563
+ from patchworks.plugins.ome_zarr import _warn_if_many_chunks
564
+
565
+ real = (4, 126, 45961, 42072) # ~950k chunks at (1, 1, 1024, 1024)
566
+ with caplog.at_level(logging.WARNING, logger="patchworks.plugins.ome_zarr"):
567
+ _warn_if_many_chunks(real, (1, 1, 1024, 1024), shard=False)
568
+ assert "shard: true" in caplog.text
569
+ assert "952,560" in caplog.text
570
+
571
+ # Sharding already asked for, or a modest chunk count: nothing to say.
572
+ caplog.clear()
573
+ with caplog.at_level(logging.WARNING, logger="patchworks.plugins.ome_zarr"):
574
+ _warn_if_many_chunks(real, (1, 1, 1024, 1024), shard=True)
575
+ _warn_if_many_chunks(
576
+ (4, 126, 1024, 1024), (1, 16, 1024, 1024), shard=False
577
+ )
578
+ assert caplog.text == ""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes