patchworks 0.6.0__tar.gz → 0.8.0__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 (50) hide show
  1. {patchworks-0.6.0 → patchworks-0.8.0}/PKG-INFO +1 -1
  2. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/ome_zarr_napari.md +19 -4
  3. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/performance.md +16 -0
  4. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/_core.py +28 -1
  5. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/plugins/napari.py +27 -7
  6. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/plugins/ome_zarr.py +67 -4
  7. {patchworks-0.6.0 → patchworks-0.8.0}/tests/test_napari.py +29 -0
  8. {patchworks-0.6.0 → patchworks-0.8.0}/tests/test_ome_zarr.py +11 -0
  9. {patchworks-0.6.0 → patchworks-0.8.0}/.github/workflows/docs.yml +0 -0
  10. {patchworks-0.6.0 → patchworks-0.8.0}/.github/workflows/lint.yml +0 -0
  11. {patchworks-0.6.0 → patchworks-0.8.0}/.github/workflows/release.yml +0 -0
  12. {patchworks-0.6.0 → patchworks-0.8.0}/.gitignore +0 -0
  13. {patchworks-0.6.0 → patchworks-0.8.0}/README.md +0 -0
  14. {patchworks-0.6.0 → patchworks-0.8.0}/cliff.toml +0 -0
  15. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/chunks.md +0 -0
  16. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/cluster.md +0 -0
  17. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/io.md +0 -0
  18. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/merge_tile_labels.md +0 -0
  19. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/plugins/cellpose.md +0 -0
  20. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/plugins/napari.md +0 -0
  21. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/plugins/ome_zarr.md +0 -0
  22. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/relabel.md +0 -0
  23. {patchworks-0.6.0 → patchworks-0.8.0}/docs/api/tile_process.md +0 -0
  24. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/cellpose_2d.md +0 -0
  25. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/cellpose_2d.py +0 -0
  26. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/cellpose_3d.md +0 -0
  27. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/cellpose_3d.py +0 -0
  28. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/custom.md +0 -0
  29. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/custom_method.py +0 -0
  30. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/standalone_merge.md +0 -0
  31. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/stardist.md +0 -0
  32. {patchworks-0.6.0 → patchworks-0.8.0}/docs/examples/stardist_2d.py +0 -0
  33. {patchworks-0.6.0 → patchworks-0.8.0}/docs/getting_started.md +0 -0
  34. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/gpu_distributed.md +0 -0
  35. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/merging.md +0 -0
  36. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/pitfalls.md +0 -0
  37. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/skip_empty.md +0 -0
  38. {patchworks-0.6.0 → patchworks-0.8.0}/docs/guide/tiling.md +0 -0
  39. {patchworks-0.6.0 → patchworks-0.8.0}/docs/index.md +0 -0
  40. {patchworks-0.6.0 → patchworks-0.8.0}/mkdocs.yml +0 -0
  41. {patchworks-0.6.0 → patchworks-0.8.0}/pyproject.toml +0 -0
  42. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/__init__.py +0 -0
  43. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/_chunks.py +0 -0
  44. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/_cluster.py +0 -0
  45. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/_io.py +0 -0
  46. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/_merge.py +0 -0
  47. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/_relabel.py +0 -0
  48. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/plugins/__init__.py +0 -0
  49. {patchworks-0.6.0 → patchworks-0.8.0}/src/patchworks/plugins/cellpose.py +0 -0
  50. {patchworks-0.6.0 → patchworks-0.8.0}/tests/test_core.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchworks
3
- Version: 0.6.0
3
+ Version: 0.8.0
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
@@ -50,6 +50,17 @@ to_ome_zarr("scan.czi", "scan.zarr", n_levels=5) # via bioio
50
50
  to_ome_zarr("scan.ims", "scan.zarr") # Imaris, native HDF5
51
51
  ```
52
52
 
53
+ !!! note "Imaris pyramids: rebuild (default) or reuse"
54
+ `.ims` files carry their own resolution pyramid. By default `to_ome_zarr`
55
+ reads only the **full-resolution** level and **builds a fresh NGFF pyramid**
56
+ (XY-only, nearest-neighbour, calibrated) for consistency. Pass
57
+ `reuse_pyramid=True` to instead **copy the Imaris levels** as-is — faster,
58
+ no recompute, keeping each level's native scale:
59
+
60
+ ```python
61
+ to_ome_zarr("scan.ims", "scan.zarr", reuse_pyramid=True)
62
+ ```
63
+
53
64
  ### Pixel calibration
54
65
 
55
66
  The physical voxel size is read from the input — bioio's `physical_pixel_sizes`,
@@ -96,13 +107,17 @@ write_labels("scan.zarr", my_labels, name="nuclei")
96
107
  layer in one call. OME-ZARR pyramids are handed to napari as a lazy multi-scale
97
108
  list, so even huge stores open instantly and only on-screen data is fetched.
98
109
 
110
+ Because `tile_process` writes labels **into** the store by default, you usually
111
+ need no `labels=` argument at all — `view_in_napari` auto-loads every label
112
+ image found under `scan.zarr/labels/`:
113
+
99
114
  ```python
100
115
  from patchworks.plugins.napari import view_in_napari
101
116
 
102
- # one store holding both image and labels/<name>:
103
- view_in_napari("scan.zarr", labels="scan.zarr/labels/labels")
117
+ # auto-loads scan.zarr/labels/* as Labels layers:
118
+ view_in_napari("scan.zarr")
104
119
 
105
- # or a separate plain label store written with write_to=:
120
+ # or point at a separate plain label store written with write_to=:
106
121
  view_in_napari("scan.zarr", labels="labels.zarr")
107
122
  ```
108
123
 
@@ -120,7 +135,7 @@ from patchworks.plugins.napari import view_in_napari
120
135
  tile_process("scan.zarr", fn, progress=True)
121
136
 
122
137
  # 2. inspect image + labels together, straight from the one store
123
- view_in_napari("scan.zarr", labels="scan.zarr/labels/labels")
138
+ view_in_napari("scan.zarr") # labels auto-loaded from scan.zarr/labels/
124
139
  ```
125
140
 
126
141
  Plugging in a different segmentation method is just swapping `fn` — any
@@ -17,6 +17,22 @@ merge step are sized to the host automatically:
17
17
  The RAM figure is read live via `psutil`; without it, a conservative default is
18
18
  used instead of guessing high.
19
19
 
20
+ ## Live progress dashboard (GPU runs)
21
+
22
+ A single-GPU run still gets a **Dask dashboard**: patchworks spins up a tiny
23
+ 1-worker / 1-thread in-process cluster, which keeps GPU evaluations serial (no
24
+ VRAM contention) while exposing the dashboard so you can watch tiles stream
25
+ through. The URL is logged at the start of staging:
26
+
27
+ ```text
28
+ INFO:patchworks._core:Dask dashboard for this run: http://127.0.0.1:8787/status
29
+ ```
30
+
31
+ This needs `distributed` (and `bokeh` for the UI) installed; if they are
32
+ missing, patchworks logs a warning and falls back to the threaded scheduler
33
+ (no dashboard, same result). A cluster you start yourself
34
+ (`make_local_cluster`) is used as-is instead.
35
+
20
36
  ## Overriding the worker count
21
37
 
22
38
  ```python
@@ -332,7 +332,31 @@ def tile_process(
332
332
  import dask as _dask
333
333
 
334
334
  _tile_nbytes = int(np.prod(labeled.chunksize)) * labeled.dtype.itemsize
335
- if _active is None:
335
+ _temp_cluster = None
336
+ _temp_client = None
337
+ if _active is None and use_gpu:
338
+ # Single-GPU runs still get a live Dask dashboard: a 1-worker /
339
+ # 1-thread in-process cluster keeps GPU evals serial (no VRAM
340
+ # contention) while exposing the dashboard for progress.
341
+ try:
342
+ from dask.distributed import Client, LocalCluster
343
+
344
+ _temp_cluster = LocalCluster(
345
+ n_workers=1, threads_per_worker=1, processes=False
346
+ )
347
+ _temp_client = Client(_temp_cluster)
348
+ logger.info(
349
+ "Dask dashboard for this run: %s",
350
+ _temp_client.dashboard_link,
351
+ )
352
+ except Exception as exc: # no distributed/bokeh → threaded fallback
353
+ logger.warning(
354
+ "Could not start a dashboard cluster (%s); "
355
+ "falling back to the threaded scheduler.",
356
+ exc,
357
+ )
358
+
359
+ if _distributed_client() is None:
336
360
  _workers = (
337
361
  max_workers
338
362
  if max_workers is not None
@@ -363,6 +387,9 @@ def tile_process(
363
387
  logger.info("Staging tiles to %s …", stage_path)
364
388
  with _sched_ctx:
365
389
  _stage_to_zarr(labeled, stage_path, "staged", progress)
390
+ if _temp_client is not None:
391
+ _temp_client.close()
392
+ _temp_cluster.close()
366
393
  labeled = da.from_zarr(stage_path, component="staged")
367
394
 
368
395
  # NB: no post-staging skip-count pass here — counting skipped tiles by
@@ -14,13 +14,12 @@ napari is an optional, GUI-heavy dependency. Install it with
14
14
  Usage
15
15
  -----
16
16
  >>> from patchworks import tile_process
17
- >>> from patchworks.plugins.ome_zarr import to_ome_zarr
18
17
  >>> from patchworks.plugins.napari import view_in_napari
19
18
  >>>
20
- >>> tile_process("scan.zarr", fn, write_to="labels.zarr")
21
- >>> to_ome_zarr("scan.zarr", "scan_pyramid.zarr") # optional, for speed
22
- >>>
23
- >>> view_in_napari("scan_pyramid.zarr", labels="labels.zarr")
19
+ >>> # labels are written into scan.zarr/labels/ by default …
20
+ >>> tile_process("scan.zarr", fn)
21
+ >>> # … so the viewer finds and overlays them with no labels= argument:
22
+ >>> view_in_napari("scan.zarr")
24
23
  """
25
24
 
26
25
  from __future__ import annotations
@@ -86,6 +85,15 @@ def _resolve_image(
86
85
  return source
87
86
 
88
87
 
88
+ def _inner_label_names(store: Union[str, Path]) -> list[str]:
89
+ """Names registered under an OME-ZARR's NGFF ``labels/`` group, if any."""
90
+ try:
91
+ grp = zarr.open_group(f"{store}/labels", mode="r")
92
+ except Exception:
93
+ return []
94
+ return list(grp.attrs.get("labels", []))
95
+
96
+
89
97
  def _resolve_labels(
90
98
  source: Union[da.Array, str, Path], component: str
91
99
  ) -> Union[da.Array, list[da.Array]]:
@@ -123,7 +131,10 @@ def view_in_napari(
123
131
  labels : da.Array, str, Path or None
124
132
  Label array to overlay. A plain ``.zarr`` store written by
125
133
  ``tile_process`` is read from its ``labels_component``; an OME-ZARR
126
- pyramid is shown multi-scale; ``None`` shows the image only.
134
+ pyramid is shown multi-scale. ``None`` (default) **auto-loads** every
135
+ label image stored inside the OME-ZARR under ``labels/<name>/`` — the
136
+ place ``tile_process`` writes them by default — each as its own Labels
137
+ layer. (Falls back to image-only if there are none.)
127
138
  channel : int or None, optional
128
139
  Channel to display from the image (``None`` keeps all channels).
129
140
  labels_component : str, optional
@@ -145,7 +156,7 @@ def view_in_napari(
145
156
 
146
157
  Examples
147
158
  --------
148
- >>> view_in_napari("scan.zarr", labels="labels.zarr") # doctest: +SKIP
159
+ >>> view_in_napari("scan.zarr") # auto-loads scan.zarr/labels/* # doctest: +SKIP
149
160
  """
150
161
  napari = _require_napari()
151
162
 
@@ -161,6 +172,15 @@ def view_in_napari(
161
172
  if labels is not None:
162
173
  lab = _resolve_labels(labels, labels_component)
163
174
  viewer.add_labels(lab, name=labels_name)
175
+ elif _is_zarr(image):
176
+ # No labels given → auto-overlay every label image stored inside the
177
+ # OME-ZARR under labels/<name>/ (the default place tile_process writes
178
+ # them), each as its own multi-scale Labels layer.
179
+ for name in _inner_label_names(image):
180
+ levels = _multiscale_levels(f"{image}/labels/{name}", None)
181
+ lab = [lvl.astype("int32") for lvl in levels]
182
+ viewer.add_labels(lab if len(lab) > 1 else lab[0], name=name)
183
+ logger.info("auto-loaded labels/%s from %s", name, image)
164
184
 
165
185
  if show:
166
186
  napari.run()
@@ -255,8 +255,8 @@ def _open_bioio(path: str, scene: int) -> tuple[da.Array, str, PixelSize]:
255
255
  return arr, axes, pixel_size
256
256
 
257
257
 
258
- def _open_imaris(path: str) -> tuple[da.Array, str, PixelSize]:
259
- """Open an Imaris ``.ims`` file lazily → ``(array, axes, pixel_size)``."""
258
+ def _open_imaris(path: str, level: int = 0) -> tuple[da.Array, str, PixelSize]:
259
+ """Open an Imaris ``.ims`` *level* lazily → ``(array, axes, pixel_size)``."""
260
260
  try:
261
261
  from imaris_ims_file_reader.ims import ims
262
262
  except ImportError as exc:
@@ -265,8 +265,8 @@ def _open_imaris(path: str) -> tuple[da.Array, str, PixelSize]:
265
265
  "Install it with:\n pip install 'patchworks[imaris]'"
266
266
  ) from exc
267
267
 
268
- # Full-resolution level; the object is array-like and h5py-backed (lazy).
269
- reader = ims(path, ResolutionLevelLock=0)
268
+ # The object is array-like and h5py-backed (lazy).
269
+ reader = ims(path, ResolutionLevelLock=level)
270
270
  order = _DEFAULT_ORDER[len(_DEFAULT_ORDER) - reader.ndim :]
271
271
  arr = da.from_array(reader, chunks=_default_chunks(reader.shape, order))
272
272
 
@@ -293,6 +293,46 @@ def _open_imaris(path: str) -> tuple[da.Array, str, PixelSize]:
293
293
  return arr, axes, pixel_size
294
294
 
295
295
 
296
+ def _write_imaris_pyramid(
297
+ path: str,
298
+ out: str,
299
+ *,
300
+ chunks: Union[tuple[int, ...], None],
301
+ overwrite: bool,
302
+ ) -> str:
303
+ """Copy an Imaris file's own resolution levels into an OME-ZARR.
304
+
305
+ Each Imaris ``ResolutionLevel`` is written as a pyramid level with its own
306
+ physical scale, so no downsampling is recomputed. Lazy (h5py-backed) reads
307
+ stream straight to disk.
308
+ """
309
+ from imaris_ims_file_reader.ims import ims
310
+
311
+ base = ims(path, ResolutionLevelLock=0)
312
+ n_levels = int(getattr(base, "ResolutionLevels", 1) or 1)
313
+
314
+ zarr.open_group(out, mode="w" if overwrite else "w-")
315
+ datasets: list[dict] = []
316
+ axes = ""
317
+ calibrated = False
318
+ for level in range(n_levels):
319
+ arr, axes, ps = _open_imaris(path, level=level)
320
+ scale = _base_scale(axes, ps)
321
+ calibrated = calibrated or bool(ps)
322
+ da.to_zarr(
323
+ arr.rechunk(chunks or _default_chunks(arr.shape, axes)),
324
+ out,
325
+ component=str(level),
326
+ overwrite=True,
327
+ )
328
+ datasets.append(_dataset(str(level), scale))
329
+ logger.info("imaris level %d copied: shape=%s", level, arr.shape)
330
+ _write_multiscales(
331
+ out, axes, datasets, Path(out).stem, calibrated=calibrated
332
+ )
333
+ return out
334
+
335
+
296
336
  def _to_dask(
297
337
  source: Union[da.Array, np.ndarray, str, Path],
298
338
  axes: Union[str, None],
@@ -327,6 +367,7 @@ def to_ome_zarr(
327
367
  n_levels: int = 5,
328
368
  downscale: int = 2,
329
369
  chunks: Union[tuple[int, ...], None] = None,
370
+ reuse_pyramid: bool = False,
330
371
  overwrite: bool = False,
331
372
  ) -> str:
332
373
  """Write *source* as a pyramidal, calibrated OME-ZARR store.
@@ -359,6 +400,12 @@ def to_ome_zarr(
359
400
  Per-level X/Y downsampling factor (default 2).
360
401
  chunks : tuple of int, optional
361
402
  Chunk shape for the written levels. ``None`` → a bounded default.
403
+ reuse_pyramid : bool, optional
404
+ *Imaris ``.ims`` only.* Copy the file's **own** resolution levels
405
+ instead of rebuilding the pyramid (faster, no recompute), keeping each
406
+ level's native scale. Ignored for other inputs; falls back to a
407
+ rebuild if the Imaris levels can't be read. Default ``False`` (rebuild,
408
+ for a consistent XY-only, nearest-neighbour NGFF pyramid).
362
409
  overwrite : bool, optional
363
410
  Overwrite an existing store at *out_path*.
364
411
 
@@ -378,6 +425,22 @@ def to_ome_zarr(
378
425
  if n_levels < 1:
379
426
  raise ValueError("n_levels must be >= 1")
380
427
 
428
+ # Reuse an Imaris file's own resolution pyramid instead of rebuilding it.
429
+ if (
430
+ reuse_pyramid
431
+ and isinstance(source, (str, Path))
432
+ and str(source).lower().endswith(".ims")
433
+ ):
434
+ try:
435
+ return _write_imaris_pyramid(
436
+ str(source), str(out_path), chunks=chunks, overwrite=overwrite
437
+ )
438
+ except Exception as exc:
439
+ logger.warning(
440
+ "reuse_pyramid failed (%s); rebuilding the pyramid instead.",
441
+ exc,
442
+ )
443
+
381
444
  arr, axes, detected = _to_dask(source, axes, scene)
382
445
  if len(axes) != arr.ndim:
383
446
  raise ValueError(
@@ -39,3 +39,32 @@ def test_require_napari_message(monkeypatch):
39
39
  nplugin._require_napari()
40
40
  else:
41
41
  assert nplugin._require_napari() is napari
42
+
43
+
44
+ def test_inner_label_discovery(tmp_path):
45
+ """Labels written into a store are discoverable for auto-overlay."""
46
+ import numpy as np
47
+
48
+ from patchworks.plugins.ome_zarr import to_ome_zarr, write_labels
49
+
50
+ store = to_ome_zarr(
51
+ np.zeros((8, 8, 8), "uint16"), tmp_path / "scan.zarr", n_levels=2
52
+ )
53
+ write_labels(store, np.ones((8, 8, 8), "int32"), name="cells", n_levels=2)
54
+
55
+ assert nplugin._inner_label_names(store) == ["cells"]
56
+ levels = nplugin._multiscale_levels(f"{store}/labels/cells", None)
57
+ assert len(levels) == 2
58
+ assert levels[1].shape == (8, 4, 4) # Z preserved, XY downsampled
59
+
60
+
61
+ def test_inner_label_discovery_none(tmp_path):
62
+ """A store without labels yields an empty list (image-only view)."""
63
+ import numpy as np
64
+
65
+ from patchworks.plugins.ome_zarr import to_ome_zarr
66
+
67
+ store = to_ome_zarr(
68
+ np.zeros((8, 8, 8), "uint16"), tmp_path / "img.zarr", n_levels=1
69
+ )
70
+ assert nplugin._inner_label_names(store) == []
@@ -128,3 +128,14 @@ def test_write_labels_into_store(tmp_path):
128
128
  assert load_ome_zarr(group, channel=None, level=1).shape == (8, 4, 4)
129
129
  lg = zarr.open_group(group, mode="r")
130
130
  assert lg.attrs["image-label"]["version"]
131
+
132
+
133
+ def test_reuse_pyramid_ignored_for_arrays(tmp_path):
134
+ """reuse_pyramid only affects .ims inputs; arrays still rebuild."""
135
+ out = to_ome_zarr(
136
+ np.zeros((8, 8, 8), "uint16"),
137
+ tmp_path / "arr.zarr",
138
+ n_levels=2,
139
+ reuse_pyramid=True,
140
+ )
141
+ assert load_ome_zarr(out, channel=None, level=1).shape == (8, 4, 4)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes