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 +48 -0
- patchworks/_chunks.py +258 -0
- patchworks/_cluster.py +93 -0
- patchworks/_core.py +352 -0
- patchworks/_io.py +218 -0
- patchworks/_merge.py +405 -0
- patchworks/_relabel.py +83 -0
- patchworks/plugins/__init__.py +1 -0
- patchworks/plugins/cellpose.py +188 -0
- patchworks-0.2.0.dist-info/METADATA +294 -0
- patchworks-0.2.0.dist-info/RECORD +12 -0
- patchworks-0.2.0.dist-info/WHEEL +4 -0
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
|