patchworks 0.2.0__py3-none-any.whl

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.
patchworks/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """patchworks — tiled processing for any image, any function.
2
+
3
+ Process arbitrarily large images by splitting them into overlapping tiles,
4
+ running any callable on each tile, and stitching the results back into globally
5
+ consistent labels.
6
+
7
+ 📖 **Full documentation, guides and tutorials:**
8
+ <https://imcf.one/patchworks/>
9
+
10
+ Quick start
11
+ -----------
12
+ >>> from patchworks import tile_process
13
+ >>>
14
+ >>> def my_fn(tile):
15
+ ... from skimage.filters import threshold_otsu
16
+ ... from skimage.measure import label
17
+ ... return label(tile > threshold_otsu(tile)).astype("int32")
18
+ >>>
19
+ >>> result = tile_process("image.zarr", my_fn, write_to="labels.zarr")
20
+
21
+ With Cellpose:
22
+
23
+ >>> from patchworks.plugins.cellpose import cellpose_fn
24
+ >>> fn = cellpose_fn("cyto3", gpu=True, diameter=30)
25
+ >>> tile_process("image.zarr", fn, tile_shape=(1, 2048, 2048),
26
+ ... overlap=20, write_to="labels.zarr", progress=True)
27
+ """
28
+
29
+ from ._chunks import auto_overlap, auto_tile_shape, auto_tile_shape_cellpose
30
+ from ._cluster import make_local_cluster
31
+ from ._core import tile_process
32
+ from ._io import estimate_empty_tiles, load_ome_zarr
33
+ from ._merge import merge_tile_labels
34
+ from ._relabel import relabel_sequential_array, relabel_sequential_zarr
35
+
36
+ __version__ = "0.2.0"
37
+ __all__ = [
38
+ "tile_process",
39
+ "merge_tile_labels",
40
+ "auto_overlap",
41
+ "auto_tile_shape",
42
+ "auto_tile_shape_cellpose",
43
+ "load_ome_zarr",
44
+ "estimate_empty_tiles",
45
+ "make_local_cluster",
46
+ "relabel_sequential_array",
47
+ "relabel_sequential_zarr",
48
+ ]
patchworks/_chunks.py ADDED
@@ -0,0 +1,258 @@
1
+ """Auto tile-shape estimation."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def auto_overlap(diameter: float, safety: float = 1.0) -> int:
14
+ """Recommended overlap (halo) for a given cell diameter.
15
+
16
+ Rule: overlap >= diameter so the segmentation function always sees at
17
+ least one full cell's worth of context on every tile edge. Cells near
18
+ tile boundaries are then segmented correctly and only genuinely split
19
+ cells produce touching labels at the boundary → correct merge.
20
+
21
+ Parameters
22
+ ----------
23
+ diameter:
24
+ Expected cell diameter in pixels (same unit as your image).
25
+ safety:
26
+ Multiplier on top of diameter. Default 1.0 (= one cell width).
27
+ Use 1.5–2.0 for elongated or irregularly-shaped cells.
28
+
29
+ Returns
30
+ -------
31
+ int
32
+ Overlap depth to pass to ``tile_process(..., overlap=...)``.
33
+
34
+ Examples
35
+ --------
36
+ >>> from patchworks import auto_overlap, tile_process
37
+ >>> from patchworks.plugins.cellpose import cellpose_fn
38
+ >>>
39
+ >>> fn = cellpose_fn("cyto3", gpu=True, diameter=30)
40
+ >>> result = tile_process("image.zarr", fn,
41
+ ... tile_shape=(1, 2048, 2048),
42
+ ... overlap=auto_overlap(30))
43
+ """
44
+ return max(1, int(np.ceil(diameter * safety)))
45
+
46
+ _GPU_MEMORY_FALLBACK = 8 * 1024**3
47
+
48
+
49
+ def _get_available_memory() -> int:
50
+ try:
51
+ import psutil
52
+ return int(psutil.virtual_memory().available)
53
+ except Exception:
54
+ return 8 * 1024**3
55
+
56
+
57
+ def _get_gpu_memory() -> int:
58
+ """Return free GPU VRAM in bytes. Falls back to 8 GiB default."""
59
+ try:
60
+ import pynvml
61
+ pynvml.nvmlInit()
62
+ handle = pynvml.nvmlDeviceGetHandleByIndex(0)
63
+ info = pynvml.nvmlDeviceGetMemoryInfo(handle)
64
+ pynvml.nvmlShutdown()
65
+ return int(info.free)
66
+ except Exception:
67
+ logger.warning(
68
+ "GPU memory query failed (nvidia-ml-py not installed?); "
69
+ "using %.0f GiB default.",
70
+ _GPU_MEMORY_FALLBACK / 1024**3,
71
+ )
72
+ return _GPU_MEMORY_FALLBACK
73
+
74
+
75
+ def auto_tile_shape(
76
+ shape: tuple[int, ...],
77
+ dtype: Any,
78
+ target_bytes: int = 64 * 1024**2,
79
+ use_gpu: bool = False,
80
+ gpu_memory: int | None = None,
81
+ available_memory: int | None = None,
82
+ n_workers: int | None = None,
83
+ verbose: bool = False,
84
+ ) -> tuple[int, ...]:
85
+ """Balanced tile shape for general-purpose 3-D processing.
86
+
87
+ Sizes the last three axes (spatial) to stay within the memory budget while
88
+ keeping the shape as cubic as possible. Leading axes (t, c) are always 1.
89
+
90
+ Parameters
91
+ ----------
92
+ shape:
93
+ Full array shape, e.g. ``(z, y, x)`` or ``(t, c, z, y, x)``.
94
+ dtype:
95
+ Array dtype.
96
+ target_bytes:
97
+ Memory ceiling per tile. Default 64 MiB.
98
+ use_gpu:
99
+ Size tiles against GPU VRAM rather than host RAM.
100
+ gpu_memory:
101
+ Available GPU VRAM in bytes; auto-queried when None.
102
+ available_memory:
103
+ Available host RAM in bytes; auto-queried when None.
104
+ n_workers:
105
+ Number of parallel workers (divides the RAM budget).
106
+ verbose:
107
+ Log the chosen shape and estimated tile size.
108
+
109
+ Returns
110
+ -------
111
+ tuple[int, ...]
112
+ Tile shape with the same number of dimensions as *shape*.
113
+
114
+ Examples
115
+ --------
116
+ >>> tile = auto_tile_shape((128, 2048, 2048), "uint16")
117
+ >>> tile
118
+ (8, 2048, 2048)
119
+ """
120
+ n_workers = n_workers or os.cpu_count() or 1
121
+ itemsize = np.dtype(dtype).itemsize
122
+ n_spatial = min(3, len(shape))
123
+
124
+ if use_gpu:
125
+ mem = gpu_memory if gpu_memory is not None else _get_gpu_memory()
126
+ budget = min(target_bytes * 2, mem // 2)
127
+ else:
128
+ mem = available_memory or _get_available_memory()
129
+ budget = min(target_bytes, mem // (n_workers * 4))
130
+
131
+ budget = max(32 * 1024**2, budget)
132
+
133
+ leading = [1] * (len(shape) - n_spatial)
134
+ spatial = list(shape[-n_spatial:])
135
+ target_voxels = budget / itemsize
136
+ target_side = int(target_voxels ** (1.0 / n_spatial))
137
+ chunk_spatial = [min(s, target_side) for s in spatial]
138
+
139
+ capped = [i for i, (c, s) in enumerate(zip(chunk_spatial, spatial)) if c == s]
140
+ uncapped = [i for i in range(n_spatial) if i not in capped]
141
+ if uncapped:
142
+ used_by_capped = np.prod([chunk_spatial[i] for i in capped]) if capped else 1
143
+ remaining = target_voxels / max(1, used_by_capped)
144
+ new_side = int(remaining ** (1.0 / len(uncapped)))
145
+ for i in uncapped:
146
+ chunk_spatial[i] = min(spatial[i], new_side)
147
+
148
+ result = tuple(leading + chunk_spatial)
149
+
150
+ if verbose:
151
+ mib = np.prod(result) * itemsize / 1024**2
152
+ logger.info(
153
+ "auto_tile_shape: shape=%s dtype=%s → tiles=%s (~%.0f MiB/tile)",
154
+ shape, np.dtype(dtype).name, result, mib,
155
+ )
156
+
157
+ return result
158
+
159
+
160
+ def auto_tile_shape_cellpose(
161
+ shape: tuple[int, ...],
162
+ dtype: Any,
163
+ diameter: float | None = None,
164
+ do_3D: bool = False,
165
+ use_gpu: bool = False,
166
+ gpu_memory: int | None = None,
167
+ available_memory: int | None = None,
168
+ n_workers: int | None = None,
169
+ model_memory_bytes: int = 2 * 1024**3,
170
+ cellpose_memory_factor: int = 20,
171
+ verbose: bool = False,
172
+ ) -> tuple[int, ...]:
173
+ """Cellpose-optimised tile shape.
174
+
175
+ Cellpose is fundamentally 2-D: even in 3-D mode it runs 2-D segmentation
176
+ on orthogonal planes and takes a consensus.
177
+
178
+ **do_3D=False (default)**
179
+ z is set to 1. Each tile is one 2-D ``(y, x)`` slice.
180
+
181
+ **do_3D=True**
182
+ z is kept at its full extent per tile. y and x are tiled based on the
183
+ available memory, accounting for the 3× overhead of three plane orientations.
184
+
185
+ Parameters
186
+ ----------
187
+ shape:
188
+ Spatial shape, e.g. ``(z, y, x)``.
189
+ dtype:
190
+ Array dtype.
191
+ diameter:
192
+ Expected cell diameter in pixels. Tile will be at least ``4 × diameter``.
193
+ do_3D:
194
+ Whether Cellpose will run in 3-D mode.
195
+ use_gpu:
196
+ Size tiles for GPU VRAM.
197
+ gpu_memory, available_memory, n_workers:
198
+ Memory parameters (auto-queried when None).
199
+ model_memory_bytes:
200
+ Memory consumed by the Cellpose model weights (default 2 GiB).
201
+ cellpose_memory_factor:
202
+ Cellpose allocates roughly this multiple of raw input bytes (default 20×).
203
+ verbose:
204
+ Log the chosen shape and memory estimates.
205
+
206
+ Returns
207
+ -------
208
+ tuple[int, ...]
209
+ Tile shape with the same number of dimensions as *shape*.
210
+
211
+ Examples
212
+ --------
213
+ >>> tile = auto_tile_shape_cellpose((128, 2048, 2048), "uint16", diameter=30)
214
+ >>> tile
215
+ (1, 2048, 2048)
216
+ """
217
+ n_workers = n_workers or os.cpu_count() or 1
218
+ itemsize = np.dtype(dtype).itemsize
219
+
220
+ if use_gpu:
221
+ total_mem = gpu_memory if gpu_memory is not None else _get_gpu_memory()
222
+ else:
223
+ total_mem = (available_memory or _get_available_memory()) // n_workers
224
+
225
+ usable = max(32 * 1024**2, total_mem - model_memory_bytes)
226
+ max_raw_bytes = usable // cellpose_memory_factor
227
+
228
+ n_spatial = min(3, len(shape))
229
+ leading = [1] * (len(shape) - n_spatial)
230
+ min_tile = int(4 * diameter) if diameter is not None else 1
231
+
232
+ if n_spatial == 2 or not do_3D:
233
+ max_pixels_2d = max(1, max_raw_bytes // itemsize)
234
+ tile_side = max(min_tile, int(max_pixels_2d**0.5))
235
+ if n_spatial == 2:
236
+ y, x = shape[-2], shape[-1]
237
+ chunk_spatial = [min(y, tile_side), min(x, tile_side)]
238
+ else:
239
+ z, y, x = shape[-3], shape[-2], shape[-1]
240
+ chunk_spatial = [1, min(y, tile_side), min(x, tile_side)]
241
+ else:
242
+ z, y, x = shape[-3], shape[-2], shape[-1]
243
+ max_pixels_per_slice = max(1, (max_raw_bytes // 3) // (z * itemsize))
244
+ tile_side = max(min_tile, int(max_pixels_per_slice**0.5))
245
+ chunk_spatial = [z, min(y, tile_side), min(x, tile_side)]
246
+
247
+ result = tuple(leading + chunk_spatial)
248
+
249
+ if verbose:
250
+ raw_mib = np.prod(result) * itemsize / 1024**2
251
+ logger.info(
252
+ "auto_tile_shape_cellpose: shape=%s dtype=%s do_3D=%s "
253
+ "→ tiles=%s (~%.0f MiB raw, ~%.0f MiB Cellpose estimate)",
254
+ shape, np.dtype(dtype).name, do_3D, result,
255
+ raw_mib, raw_mib * cellpose_memory_factor,
256
+ )
257
+
258
+ return result
patchworks/_cluster.py ADDED
@@ -0,0 +1,93 @@
1
+ """Dask cluster helpers."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def _distributed_client():
11
+ """Return the active dask.distributed Client, or None."""
12
+ try:
13
+ from dask.distributed import get_client
14
+ return get_client()
15
+ except Exception:
16
+ return None
17
+
18
+
19
+ def _client_is_in_process(client) -> bool:
20
+ """True if *client* runs its worker in this process (processes=False).
21
+
22
+ An in-process worker shares the GIL. A long task that holds the GIL
23
+ (e.g. a Cellpose/torch eval) starves the worker heartbeat, the scheduler
24
+ declares it dead, and the P2P merge barrier drops its inputs →
25
+ "FutureCancelledError: lost dependencies".
26
+ """
27
+ try:
28
+ for addr in client.scheduler_info().get("workers", {}):
29
+ if str(addr).startswith("inproc://"):
30
+ return True
31
+ except Exception:
32
+ pass
33
+ return False
34
+
35
+
36
+ def make_local_cluster(
37
+ use_gpu: bool = False,
38
+ n_workers: int | None = None,
39
+ threads_per_worker: int = 1,
40
+ memory_limit: str | None = None,
41
+ **cluster_kwargs,
42
+ ):
43
+ """Create a process-based Dask cluster for tiled processing.
44
+
45
+ Always uses worker subprocesses (``processes=True``). An in-process
46
+ (threaded) worker breaks the label merge when ``segment_fn`` holds the
47
+ GIL — see the patchworks docs for details.
48
+
49
+ For GPU work defaults to a single worker (one CUDA context, no contention).
50
+ For CPU scales to available cores.
51
+
52
+ Parameters
53
+ ----------
54
+ use_gpu:
55
+ Single-worker cluster for GPU. When False, use multiple CPU workers.
56
+ n_workers:
57
+ Override the worker count. Defaults to 1 for GPU, min(8, cpu_count).
58
+ threads_per_worker:
59
+ Keep at 1 so a GIL-holding tile function doesn't block heartbeats.
60
+ memory_limit:
61
+ Per-worker memory cap (e.g. ``"8GB"``).
62
+ **cluster_kwargs:
63
+ Extra arguments forwarded to ``dask.distributed.LocalCluster``.
64
+
65
+ Returns
66
+ -------
67
+ (client, cluster)
68
+
69
+ Examples
70
+ --------
71
+ >>> client, cluster = make_local_cluster(use_gpu=True)
72
+ >>> print("dashboard:", client.dashboard_link)
73
+ >>> result = tile_process("image.zarr", fn, write_to="labels.zarr")
74
+ >>> client.close(); cluster.close()
75
+ """
76
+ from dask.distributed import Client, LocalCluster
77
+
78
+ if n_workers is None:
79
+ n_workers = 1 if use_gpu else min(8, os.cpu_count() or 1)
80
+
81
+ cluster = LocalCluster(
82
+ processes=True,
83
+ n_workers=n_workers,
84
+ threads_per_worker=threads_per_worker,
85
+ memory_limit=memory_limit,
86
+ **cluster_kwargs,
87
+ )
88
+ client = Client(cluster)
89
+ logger.info(
90
+ "Started %d-worker process cluster (use_gpu=%s). Dashboard: %s",
91
+ n_workers, use_gpu, client.dashboard_link,
92
+ )
93
+ return client, cluster