|
27 | 27 | import logging |
28 | 28 | import os |
29 | 29 | import shutil |
| 30 | +from concurrent.futures import ThreadPoolExecutor |
30 | 31 | from itertools import product as _iproduct |
31 | 32 | from pathlib import Path |
32 | 33 | from typing import Any, Union |
33 | 34 |
|
34 | 35 | import numpy as np |
35 | 36 | import zarr |
36 | 37 |
|
| 38 | +from ._chunks import cpu_allocation |
| 39 | + |
37 | 40 | logger = logging.getLogger(__name__) |
38 | 41 |
|
39 | 42 | DEFAULT_BLOCK = (1, 128, 128) |
@@ -93,8 +96,17 @@ def _level_array(root: zarr.Group, level: int, store_path: str) -> Any: |
93 | 96 |
|
94 | 97 |
|
95 | 98 | def occupancy_path(image_store: Union[str, Path], level: int = 0) -> str: |
96 | | - """Path of the occupancy map for one pyramid level of *image_store*.""" |
97 | | - return f"{image_store}/occupancy/{level}" |
| 99 | + """Path of the occupancy map for one pyramid level of *image_store*. |
| 100 | +
|
| 101 | + Deliberately a **sibling** of the image store, not a node inside it. The |
| 102 | + map is not an NGFF array, and zarr refuses to walk a hierarchy containing |
| 103 | + one ("Object at ... is not recognized as a component of a Zarr |
| 104 | + hierarchy"), which would make every ``members()``/``arrays()`` call on the |
| 105 | + user's image warn. |
| 106 | + """ |
| 107 | + store = str(image_store).rstrip("/") |
| 108 | + base = store[:-5] if store.endswith(".zarr") else store |
| 109 | + return f"{base}.occupancy.zarr/{level}" |
98 | 110 |
|
99 | 111 |
|
100 | 112 | def build_occupancy_map( |
@@ -179,21 +191,36 @@ def build_occupancy_map( |
179 | 191 | dtype=src.dtype, |
180 | 192 | ) |
181 | 193 |
|
182 | | - try: |
| 194 | + ranges = [range(0, g, s) for g, s in zip(grid, steps)] |
| 195 | + regions = list(_iproduct(*ranges)) |
| 196 | + |
| 197 | + def _one(starts: tuple[int, ...]) -> None: |
| 198 | + out_sl = tuple( |
| 199 | + slice(o, min(o + s, g)) for o, s, g in zip(starts, steps, grid) |
| 200 | + ) |
| 201 | + src_sl = tuple( |
| 202 | + slice(o.start * b, min(o.stop * b, s)) |
| 203 | + for o, b, s in zip(out_sl, block, sp_shape) |
| 204 | + ) |
| 205 | + # Read every channel of this region in ONE go. Looping channels on the |
| 206 | + # outside would traverse the whole image once per channel -- three |
| 207 | + # full reads for a three-channel stack, where one does. |
183 | 208 | for channel in range(n_channels): |
184 | 209 | prefix = _leading_index(src.ndim, n_spatial, channel) |
185 | | - ranges = [range(0, g, s) for g, s in zip(grid, steps)] |
186 | | - for starts in _iproduct(*ranges): |
187 | | - out_sl = tuple( |
188 | | - slice(o, min(o + s, g)) |
189 | | - for o, s, g in zip(starts, steps, grid) |
190 | | - ) |
191 | | - src_sl = tuple( |
192 | | - slice(o.start * b, min(o.stop * b, s)) |
193 | | - for o, b, s in zip(out_sl, block, sp_shape) |
194 | | - ) |
195 | | - region = np.asarray(src[prefix + src_sl]) |
196 | | - dst[(channel, *out_sl)] = _block_max(region, block) |
| 210 | + region = np.asarray(src[prefix + src_sl]) |
| 211 | + dst[(channel, *out_sl)] = _block_max(region, block) |
| 212 | + |
| 213 | + try: |
| 214 | + n_workers = max(1, min(cpu_allocation(), len(regions))) |
| 215 | + if n_workers <= 1: |
| 216 | + for starts in regions: |
| 217 | + _one(starts) |
| 218 | + else: |
| 219 | + # Reads and decompression release the GIL, so threads are enough |
| 220 | + # and there is no worker payload to pickle. |
| 221 | + with ThreadPoolExecutor(max_workers=n_workers) as pool: |
| 222 | + for _ in pool.map(_one, regions): |
| 223 | + pass |
197 | 224 | dst.attrs["block"] = list(block) |
198 | 225 | dst.attrs["level"] = int(level) |
199 | 226 | dst.attrs["source_shape"] = list(sp_shape) |
|
0 commit comments