patchworks 2.1.0__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.0 → patchworks-2.1.2}/PKG-INFO +1 -1
  2. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/snakemake.md +15 -0
  3. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/ome_zarr.py +211 -23
  4. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_ome_zarr.py +177 -0
  5. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/run_multi.py +57 -1
  6. {patchworks-2.1.0 → patchworks-2.1.2}/.github/workflows/docs.yml +0 -0
  7. {patchworks-2.1.0 → patchworks-2.1.2}/.github/workflows/lint.yml +0 -0
  8. {patchworks-2.1.0 → patchworks-2.1.2}/.github/workflows/release.yml +0 -0
  9. {patchworks-2.1.0 → patchworks-2.1.2}/.gitignore +0 -0
  10. {patchworks-2.1.0 → patchworks-2.1.2}/.markdownlint-cli2.yaml +0 -0
  11. {patchworks-2.1.0 → patchworks-2.1.2}/LICENSE +0 -0
  12. {patchworks-2.1.0 → patchworks-2.1.2}/README.md +0 -0
  13. {patchworks-2.1.0 → patchworks-2.1.2}/cliff.toml +0 -0
  14. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/chunks.md +0 -0
  15. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/cluster.md +0 -0
  16. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/io.md +0 -0
  17. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/merge_tile_labels.md +0 -0
  18. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/cellpose.md +0 -0
  19. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/dog.md +0 -0
  20. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/napari.md +0 -0
  21. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/plugins/ome_zarr.md +0 -0
  22. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/postprocess.md +0 -0
  23. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/relabel.md +0 -0
  24. {patchworks-2.1.0 → patchworks-2.1.2}/docs/api/tile_process.md +0 -0
  25. {patchworks-2.1.0 → patchworks-2.1.2}/docs/assets/logo.png +0 -0
  26. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_2d.md +0 -0
  27. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_2d.py +0 -0
  28. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_3d.md +0 -0
  29. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/cellpose_3d.py +0 -0
  30. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/custom.md +0 -0
  31. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/custom_method.py +0 -0
  32. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/dog.md +0 -0
  33. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/dog.py +0 -0
  34. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/standalone_merge.md +0 -0
  35. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/stardist.md +0 -0
  36. {patchworks-2.1.0 → patchworks-2.1.2}/docs/examples/stardist_2d.py +0 -0
  37. {patchworks-2.1.0 → patchworks-2.1.2}/docs/getting_started.md +0 -0
  38. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/custom_segmentation.md +0 -0
  39. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/gpu_distributed.md +0 -0
  40. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/label_relations.md +0 -0
  41. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/measurements.md +0 -0
  42. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/merging.md +0 -0
  43. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/ome_zarr_napari.md +0 -0
  44. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/performance.md +0 -0
  45. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/pitfalls.md +0 -0
  46. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/skip_empty.md +0 -0
  47. {patchworks-2.1.0 → patchworks-2.1.2}/docs/guide/tiling.md +0 -0
  48. {patchworks-2.1.0 → patchworks-2.1.2}/docs/index.md +0 -0
  49. {patchworks-2.1.0 → patchworks-2.1.2}/mkdocs.yml +0 -0
  50. {patchworks-2.1.0 → patchworks-2.1.2}/pyproject.toml +0 -0
  51. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/__init__.py +0 -0
  52. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_chunks.py +0 -0
  53. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_cluster.py +0 -0
  54. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_core.py +0 -0
  55. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_distributed.py +0 -0
  56. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_gpu.py +0 -0
  57. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_io.py +0 -0
  58. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_merge.py +0 -0
  59. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_occupancy.py +0 -0
  60. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_postprocess.py +0 -0
  61. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_relabel.py +0 -0
  62. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/_relations.py +0 -0
  63. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/__init__.py +0 -0
  64. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/cellpose.py +0 -0
  65. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/dog.py +0 -0
  66. {patchworks-2.1.0 → patchworks-2.1.2}/src/patchworks/plugins/napari.py +0 -0
  67. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_allocation.py +0 -0
  68. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_core.py +0 -0
  69. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_distributed.py +0 -0
  70. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_dog.py +0 -0
  71. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_gpu.py +0 -0
  72. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_napari.py +0 -0
  73. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_occupancy.py +0 -0
  74. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_postprocess.py +0 -0
  75. {patchworks-2.1.0 → patchworks-2.1.2}/tests/test_relations.py +0 -0
  76. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/README.md +0 -0
  77. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/Snakefile +0 -0
  78. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config.yaml +0 -0
  79. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config_cilia.yaml +0 -0
  80. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config_cyto.yaml +0 -0
  81. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/config_nuclei.yaml +0 -0
  82. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/config/multi.yaml +0 -0
  83. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/pixi.toml +0 -0
  84. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/profile/slurm/config.yaml +0 -0
  85. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/common.smk +0 -0
  86. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/convert.smk +0 -0
  87. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/merge.smk +0 -0
  88. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/rules/segment.smk +0 -0
  89. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/_pw.py +0 -0
  90. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/convert.py +0 -0
  91. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/fetch_model.py +0 -0
  92. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/merge.py +0 -0
  93. {patchworks-2.1.0 → patchworks-2.1.2}/workflow/scripts/prepare_tiles.py +0 -0
  94. {patchworks-2.1.0 → 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.0
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
@@ -346,6 +346,19 @@ the GPU partition stays busy instead of idling through every config's
346
346
  `prepare` and multi-hour `merge` in turn. A config that fails does **not**
347
347
  abort the others; you get a per-config status and a non-zero exit.
348
348
 
349
+ !!! tip "After a killed run"
350
+ Snakemake only releases its lock on a clean exit, so a run that was killed
351
+ (Ctrl-C, an SSH drop, an OOM) leaves the directory locked. Each phase has
352
+ its own state directory, so releasing them by hand means reconstructing
353
+ several paths — use the flag instead:
354
+
355
+ ```bash
356
+ pixi run multi -- --unlock # then re-run normally
357
+ ```
358
+
359
+ Running the orchestrator under `tmux` avoids most of these in the first
360
+ place: it survives a dropped connection.
361
+
349
362
  !!! warning "`jobs:` is per config"
350
363
  The profile's `jobs:` caps one Snakemake process. Running three configs
351
364
  concurrently can therefore have 3× that many jobs in flight — lower it if
@@ -450,6 +463,8 @@ prologue. The simplest path is a single shared env that the compute nodes see.
450
463
  | Symptom | Fix |
451
464
  |---------|-----|
452
465
  | `snakemake: command not found` | use `python -m snakemake` |
466
+ | `Directory cannot be locked` | a previous run was killed instead of exiting cleanly. For a multi-run: `pixi run multi -- --unlock` (it covers every state directory, including the conversion one). For a single config: add `--unlock --directory <the same one you ran with>` |
467
+ | `BioImage does not support the image: '.../*tif'` | a glob input needs `sequence_pattern` — see [A folder of single-plane TIFFs](ome_zarr_napari.md#a-folder-of-single-plane-tiffs) |
453
468
  | Segment jobs pend forever | wrong `slurm_partition`/GPU request; on scicore use `gres: "gpu:1"` |
454
469
  | Segment dies, `Network is unreachable` | offline GPU nodes — the `fetch_model` localrule caches the model on the submit host first; if it still fails, your submit host has no network either (pre-download manually) |
455
470
  | `cellpose is not installed` in a job | the job's env lacks `patchworks[cellpose]` |
@@ -45,9 +45,11 @@ Usage
45
45
 
46
46
  from __future__ import annotations
47
47
 
48
+ import glob
48
49
  import logging
49
50
  import math
50
51
  from concurrent.futures import ThreadPoolExecutor
52
+ from contextlib import nullcontext as _nullcontext
51
53
  from itertools import product as _iproduct
52
54
  from pathlib import Path
53
55
  from typing import Union
@@ -170,22 +172,41 @@ def _strides(axes: str, downscale: int) -> tuple[int, ...]:
170
172
  return tuple(downscale if a in _DOWNSAMPLE_AXES else 1 for a in axes)
171
173
 
172
174
 
173
- 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, ...]:
174
180
  """Bounded chunk shape so writing a level never blows up RAM.
175
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
+
176
192
  Parameters
177
193
  ----------
178
194
  shape : tuple of int
179
195
  Array shape.
180
196
  axes : str
181
197
  One letter per axis (selects the per-axis cap).
198
+ source_chunks : tuple of int, optional
199
+ The input array's own chunking.
182
200
 
183
201
  Returns
184
202
  -------
185
203
  tuple of int
186
- Per-axis chunk size, capped by ``_CHUNK_CAP``.
204
+ Per-axis chunk size, capped by ``_CHUNK_CAP`` and by *source_chunks*.
187
205
  """
188
- 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)
189
210
 
190
211
 
191
212
  ShardSpec = Union[bool, tuple[int, ...]]
@@ -445,6 +466,70 @@ def _stream_strided_level(
445
466
  pass
446
467
 
447
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
+
448
533
  def _progress_ctx(progress: bool, label: str):
449
534
  """Return a progress-bar context manager.
450
535
 
@@ -647,7 +732,12 @@ def _write_pyramid(
647
732
  strides = _strides(axes, downscale)
648
733
 
649
734
  if write_base:
650
- 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)
651
741
  _to_zarr_level(
652
742
  arr.rechunk(base_chunks), group_path, base_name, shard, progress
653
743
  )
@@ -871,16 +961,75 @@ _UNIT_TO_UM = {
871
961
  _RESUNIT_TO_UM = {2: 25400.0, 3: 10000.0}
872
962
 
873
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
+
874
1017
  def _tiff_pixel_size(path: str) -> PixelSize:
875
1018
  """Read physical voxel size from a TIFF file's own metadata.
876
1019
 
877
- Z comes from ImageJ metadata (``spacing`` + ``unit``), if present. X/Y
878
- come from the page's ``XResolution``/``YResolution`` tags (pixels per
879
- unit); the unit itself is taken from the ``ResolutionUnit`` tag when set
880
- (plain TIFFs), or falls back to ImageJ metadata's ``unit`` — ImageJ
881
- itself writes ``ResolutionUnit=NONE`` and keeps the unit as a string in
882
- its metadata instead. Unrecognized units are ignored (treated as
883
- 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.
884
1033
 
885
1034
  Parameters
886
1035
  ----------
@@ -897,9 +1046,18 @@ def _tiff_pixel_size(path: str) -> PixelSize:
897
1046
 
898
1047
  pixel_size: PixelSize = {}
899
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
+
900
1058
  ij = tif.imagej_metadata or {}
901
1059
  spacing = ij.get("spacing")
902
- if spacing:
1060
+ if spacing and "z" not in pixel_size:
903
1061
  factor = _UNIT_TO_UM.get(str(ij.get("unit", "micron")).lower(), 1.0)
904
1062
  pixel_size["z"] = float(spacing) * factor
905
1063
 
@@ -910,6 +1068,8 @@ def _tiff_pixel_size(path: str) -> PixelSize:
910
1068
  ) or _UNIT_TO_UM.get(str(ij.get("unit", "")).lower())
911
1069
  if um_per_unit is not None:
912
1070
  for axis, tag_name in (("y", "YResolution"), ("x", "XResolution")):
1071
+ if axis in pixel_size:
1072
+ continue
913
1073
  tag = page.tags.get(tag_name)
914
1074
  if tag and tag.value[0]:
915
1075
  num, den = tag.value
@@ -1013,6 +1173,18 @@ def _open_tiff_sequence(
1013
1173
  arr.shape,
1014
1174
  pixel_size,
1015
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
+ )
1016
1188
  return arr, axes, pixel_size
1017
1189
 
1018
1190
 
@@ -1201,6 +1373,21 @@ def _to_dask(
1201
1373
  if sequence_pattern is not None:
1202
1374
  arr, detected, ps = _open_tiff_sequence(path, sequence_pattern)
1203
1375
  return arr, axes or detected, ps
1376
+ if any(ch in path for ch in "*?[") or (
1377
+ not Path(path).exists() and glob.glob(path)
1378
+ ):
1379
+ # Without sequence_pattern this would be handed to bioio, which reads
1380
+ # single files and reports only that it cannot handle the "format" --
1381
+ # pointing at the glob rather than the missing setting.
1382
+ raise ValueError(
1383
+ f"{path!r} looks like a glob over several files, but "
1384
+ "sequence_pattern is not set, so there is no way to tell which "
1385
+ "part of each filename is Z, C or T.\n"
1386
+ "Set it to a regex with named groups, e.g.\n"
1387
+ " sequence_pattern: '_Z(?P<Z>\\d+)_C(?P<C>\\d+)_V\\d+'\n"
1388
+ "for files like sample_Z001_C0_V0.tif. Pass a single file "
1389
+ "instead if you did not mean a sequence."
1390
+ )
1204
1391
  if path.endswith(".zarr"):
1205
1392
  arr = load_ome_zarr(source, channel=None)
1206
1393
  ax = axes or _default_axes(arr.ndim)
@@ -1345,17 +1532,18 @@ def to_ome_zarr(
1345
1532
 
1346
1533
  out = str(out_path)
1347
1534
  zarr.open_group(out, mode="w" if overwrite else "w-")
1348
- datasets = _write_pyramid(
1349
- arr,
1350
- axes,
1351
- out,
1352
- n_levels=n_levels,
1353
- downscale=downscale,
1354
- chunks=chunks,
1355
- base_scale=base_scale,
1356
- shard=shard,
1357
- progress=progress,
1358
- )
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
+ )
1359
1547
  _write_multiscales(out, axes, datasets, Path(out).stem, calibrated=bool(ps))
1360
1548
  return out
1361
1549
 
@@ -399,3 +399,180 @@ def test_sharding(tmp_path):
399
399
  assert (
400
400
  getattr(_zarr.open_array(f"{out3}/0", mode="r"), "shards", None) is None
401
401
  )
402
+
403
+
404
+ def test_glob_without_sequence_pattern_says_so(tmp_path):
405
+ """A glob input must name the missing setting, not blame the format.
406
+
407
+ Without sequence_pattern the glob fell through to bioio, which reads one
408
+ file and could only report "does not support the image" -- pointing at the
409
+ path instead of at the config key that was missing.
410
+ """
411
+ with pytest.raises(ValueError, match="sequence_pattern is not set"):
412
+ to_ome_zarr(str(tmp_path / "*.tif"), tmp_path / "out.zarr")
413
+
414
+ # A real single file must still reach the normal readers.
415
+ with pytest.raises(Exception) as exc:
416
+ to_ome_zarr(str(tmp_path / "scan.ims"), tmp_path / "o2.zarr")
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 == ""
@@ -46,6 +46,7 @@ def _snakemake_cmd(
46
46
  dry_run: bool,
47
47
  state_dir: Path | None = None,
48
48
  targets: list[str] | None = None,
49
+ extra: list[str] | None = None,
49
50
  ) -> list[str]:
50
51
  """Build one snakemake invocation.
51
52
 
@@ -69,6 +70,8 @@ def _snakemake_cmd(
69
70
  cmd += ["--cores", str(cores), "--rerun-triggers", "mtime"]
70
71
  if dry_run:
71
72
  cmd += ["-n", "-p"]
73
+ if extra:
74
+ cmd += extra
72
75
  if targets:
73
76
  # "--" ends option parsing: --rerun-triggers takes a variable number
74
77
  # of values and would otherwise swallow the target path.
@@ -114,6 +117,18 @@ def _validate_configs(paths: list[Path], cfgs: list[dict]) -> str:
114
117
  f"share a chunk layout; got {_spread(key)}"
115
118
  )
116
119
 
120
+ for path, cfg in zip(paths, cfgs):
121
+ source = str(cfg.get("input", ""))
122
+ if any(ch in source for ch in "*?[") and not cfg.get(
123
+ "sequence_pattern"
124
+ ):
125
+ problems.append(
126
+ f"{path.name}: input {source!r} is a glob over several files "
127
+ "but sequence_pattern is unset, so nothing says which part of "
128
+ "each filename is Z/C/T. Set e.g. "
129
+ r"sequence_pattern: '_Z(?P<Z>\d+)_C(?P<C>\d+)_V\d+'"
130
+ )
131
+
117
132
  names = [cfg.get("label_name") for cfg in cfgs]
118
133
  duplicates = {n for n in names if names.count(n) > 1}
119
134
  if duplicates:
@@ -160,6 +175,15 @@ def main() -> None:
160
175
  action="store_true",
161
176
  help="pass -n -p to every Snakemake run; skips relations",
162
177
  )
178
+ parser.add_argument(
179
+ "--unlock",
180
+ action="store_true",
181
+ help=(
182
+ "release stale Snakemake locks in every state directory this "
183
+ "script manages, then exit. Needed after a run was killed or "
184
+ "died: the lock is only released on a clean exit."
185
+ ),
186
+ )
163
187
  args = parser.parse_args()
164
188
 
165
189
  workflow_dir = Path(__file__).resolve().parent.parent
@@ -173,6 +197,32 @@ def main() -> None:
173
197
  work_dir = _validate_configs(seg_config_paths, seg_cfgs)
174
198
  image_store = f"{work_dir}/image.zarr"
175
199
 
200
+ # Each phase gets its own Snakemake state directory (the lock lives in the
201
+ # working directory, not the config), so unlocking has to cover all of
202
+ # them -- and nobody should have to reconstruct these paths by hand.
203
+ state_dirs = [Path(work_dir) / ".snakemake_convert"] + [
204
+ Path(cfg["work_dir"]) / cfg["label_name"] / ".snakemake"
205
+ for cfg in seg_cfgs
206
+ ]
207
+ if args.unlock:
208
+ for state_dir in state_dirs:
209
+ if not state_dir.exists():
210
+ continue
211
+ _run(
212
+ _snakemake_cmd(
213
+ seg_config_paths[0],
214
+ workflow_dir=workflow_dir,
215
+ profile=args.profile,
216
+ cores=args.cores,
217
+ dry_run=False,
218
+ state_dir=state_dir,
219
+ extra=["--unlock"],
220
+ ),
221
+ workflow_dir,
222
+ )
223
+ print("[run_multi] unlocked; re-run without --unlock", flush=True)
224
+ return
225
+
176
226
  # Phase A: convert exactly once. The three runs are about to go concurrent
177
227
  # and `convert` writes with overwrite=True, so letting them race on it
178
228
  # would have them clobbering one store. Ask for its marker explicitly.
@@ -189,7 +239,13 @@ def main() -> None:
189
239
  workflow_dir,
190
240
  )
191
241
  if rc != 0:
192
- print("[run_multi] ERROR: conversion failed", file=sys.stderr)
242
+ print(
243
+ "[run_multi] ERROR: conversion failed.\n"
244
+ " If the log says the directory cannot be locked, a previous run "
245
+ "was killed rather than exiting cleanly; release it with:\n"
246
+ f" {Path(sys.argv[0]).name} --config {args.config} --unlock",
247
+ file=sys.stderr,
248
+ )
193
249
  sys.exit(rc)
194
250
 
195
251
  # Still phase A: build the occupancy map here too. Every config's `prepare`
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