|
25 | 25 | Downsampling uses strided (nearest-neighbour) subsampling — the correct, |
26 | 26 | label-preserving choice — and only on **X and Y**; ``z`` (and channel/time) |
27 | 27 | stay at full resolution. Every level is built by reading the *previous level |
28 | | -back from disk* and streaming the downsampled result out through dask with |
29 | | -bounded chunks, so the pyramid never materialises a whole volume in RAM. |
| 28 | +back from disk*, so the pyramid never materialises a whole volume in RAM. |
| 29 | +Because decimation needs no halo, levels are written zarr-natively: each |
| 30 | +level's chunks are chosen as ``ceil(src_chunk / stride)`` so one task reads |
| 31 | +exactly one source chunk and writes exactly one output chunk, making peak |
| 32 | +memory ``n_workers × one chunk`` by construction. Explicit ``chunks=`` or |
| 33 | +sharding fall back to dask, which must rechunk and therefore reads several |
| 34 | +source chunks per output chunk. |
30 | 35 |
|
31 | 36 | This module also exposes :func:`add_pyramid` (add levels to an existing store) |
32 | 37 | and :func:`write_labels` (store a label image under the NGFF ``labels/`` group). |
|
42 | 47 |
|
43 | 48 | import logging |
44 | 49 | import math |
| 50 | +from concurrent.futures import ThreadPoolExecutor |
| 51 | +from itertools import product as _iproduct |
45 | 52 | from pathlib import Path |
46 | 53 | from typing import Union |
47 | 54 |
|
@@ -273,6 +280,97 @@ def _shard_for( |
273 | 280 | return _effective_shard(tuple(shard), chunks, shape) |
274 | 281 |
|
275 | 282 |
|
| 283 | +# Don't let deep pyramid levels fragment into thousands of tiny chunks. |
| 284 | +_CHUNK_FLOOR = {"y": 128, "x": 128} |
| 285 | + |
| 286 | + |
| 287 | +def _level_chunks( |
| 288 | + src_chunks: tuple[int, ...], |
| 289 | + strides: tuple[int, ...], |
| 290 | + axes: str, |
| 291 | + level_shape: tuple[int, ...], |
| 292 | +) -> tuple[int, ...]: |
| 293 | + """Chunking for a strided level that keeps one source chunk per task. |
| 294 | +
|
| 295 | + Choosing ``ceil(src_chunk / stride)`` makes each output chunk the exact |
| 296 | + image of one source chunk, so a task reads one chunk and writes one chunk |
| 297 | + with nothing shared between tasks. Capped by :data:`_CHUNK_CAP` and floored |
| 298 | + so deep levels don't fragment. |
| 299 | + """ |
| 300 | + out = [] |
| 301 | + for c, st, a, s in zip(src_chunks, strides, axes, level_shape): |
| 302 | + v = -(-c // st) |
| 303 | + v = min(v, _CHUNK_CAP.get(a, v)) |
| 304 | + v = max(v, _CHUNK_FLOOR.get(a, 1)) |
| 305 | + out.append(max(1, min(v, s))) |
| 306 | + return tuple(out) |
| 307 | + |
| 308 | + |
| 309 | +def _create_level_array( |
| 310 | + group: "zarr.Group", |
| 311 | + name: str, |
| 312 | + shape: tuple[int, ...], |
| 313 | + chunks: tuple[int, ...], |
| 314 | + dtype, |
| 315 | +) -> "zarr.Array": |
| 316 | + """Create (replacing any existing) a pyramid level array in *group*.""" |
| 317 | + if name in group: |
| 318 | + del group[name] |
| 319 | + if _ZARR_V3: |
| 320 | + return group.create_array(name, shape=shape, chunks=chunks, dtype=dtype) |
| 321 | + return group.zeros( |
| 322 | + name, shape=shape, chunks=chunks, dtype=dtype, overwrite=True |
| 323 | + ) |
| 324 | + |
| 325 | + |
| 326 | +def _stream_strided_level( |
| 327 | + src: "zarr.Array", |
| 328 | + dst: "zarr.Array", |
| 329 | + strides: tuple[int, ...], |
| 330 | + n_workers: int = 4, |
| 331 | +) -> None: |
| 332 | + """Write *dst* as the strided subsample of *src*, one chunk at a time. |
| 333 | +
|
| 334 | + Labels are downsampled by plain decimation, which needs no halo, so each |
| 335 | + output chunk depends only on the source region that maps onto it. Peak |
| 336 | + memory is therefore ``n_workers x (one source region + its subsample)`` |
| 337 | + however large the image is -- unlike a dask ``rechunk``, whose threaded |
| 338 | + scheduler holds intermediates with no backpressure. |
| 339 | +
|
| 340 | + Parameters |
| 341 | + ---------- |
| 342 | + src, dst : zarr.Array |
| 343 | + Source level and the (already created) destination level. |
| 344 | + strides : tuple of int |
| 345 | + Per-axis decimation step. |
| 346 | + n_workers : int |
| 347 | + Threads used for the copy. zarr's codecs release the GIL, so threads |
| 348 | + are enough and avoid pickling a worker payload. |
| 349 | + """ |
| 350 | + grid = [-(-s // c) for s, c in zip(dst.shape, dst.chunks)] |
| 351 | + take = tuple(slice(None, None, st) for st in strides) |
| 352 | + |
| 353 | + def _one(idx: tuple[int, ...]) -> None: |
| 354 | + out_sl = tuple( |
| 355 | + slice(i * c, min((i + 1) * c, s)) |
| 356 | + for i, c, s in zip(idx, dst.chunks, dst.shape) |
| 357 | + ) |
| 358 | + src_sl = tuple( |
| 359 | + slice(o.start * st, min(o.stop * st, s)) |
| 360 | + for o, st, s in zip(out_sl, strides, src.shape) |
| 361 | + ) |
| 362 | + dst[out_sl] = np.asarray(src[src_sl])[take] |
| 363 | + |
| 364 | + indices = list(_iproduct(*[range(g) for g in grid])) |
| 365 | + if n_workers <= 1: |
| 366 | + for idx in indices: |
| 367 | + _one(idx) |
| 368 | + return |
| 369 | + with ThreadPoolExecutor(max_workers=n_workers) as pool: |
| 370 | + for _ in pool.map(_one, indices): |
| 371 | + pass |
| 372 | + |
| 373 | + |
276 | 374 | def _progress_ctx(progress: bool, label: str): |
277 | 375 | """Return a progress-bar context manager. |
278 | 376 |
|
@@ -484,19 +582,51 @@ def _write_pyramid( |
484 | 582 | prev_name = base_name |
485 | 583 | prev_shape = arr.shape |
486 | 584 | for i in range(1, n_levels): |
487 | | - next_shape = tuple(s // st for s, st in zip(prev_shape, strides)) |
| 585 | + next_shape = tuple(-(-s // st) for s, st in zip(prev_shape, strides)) |
488 | 586 | if min(next_shape) < 1: |
489 | 587 | logger.info("stopping pyramid at level %d (next too small)", i) |
490 | 588 | break |
491 | | - src = da.from_zarr(group_path, component=prev_name) |
492 | | - nxt = src[tuple(slice(None, None, st) for st in strides)] |
493 | | - nxt = nxt.rechunk(chunks or _default_chunks(nxt.shape, axes)) |
494 | | - _to_zarr_level(nxt, group_path, str(i), shard, progress) |
| 589 | + if shard or chunks: |
| 590 | + # Sharding needs whole shards written atomically, and an explicit |
| 591 | + # chunking may not line up with the source chunks -- both want the |
| 592 | + # dask path. |
| 593 | + src = da.from_zarr(group_path, component=prev_name) |
| 594 | + nxt = src[tuple(slice(None, None, st) for st in strides)] |
| 595 | + nxt = nxt.rechunk(chunks or _default_chunks(nxt.shape, axes)) |
| 596 | + _to_zarr_level(nxt, group_path, str(i), shard, progress) |
| 597 | + next_shape = nxt.shape |
| 598 | + else: |
| 599 | + # Stream it: decimation needs no halo, so with chunks chosen as |
| 600 | + # ceil(src_chunk / stride) each task reads exactly one source chunk |
| 601 | + # and writes exactly one output chunk. Bounded by construction -- |
| 602 | + # the dask route rechunks *upward* here (e.g. (16,512,512) back to |
| 603 | + # (16,1024,1024)), pulling four source chunks per output chunk and |
| 604 | + # letting the threaded scheduler stockpile the intermediates. |
| 605 | + grp = zarr.open_group(group_path, mode="a") |
| 606 | + src_arr = grp[prev_name] |
| 607 | + out_chunks = _level_chunks( |
| 608 | + tuple(src_arr.chunks), strides, axes, next_shape |
| 609 | + ) |
| 610 | + dst_arr = _create_level_array( |
| 611 | + grp, str(i), next_shape, out_chunks, src_arr.dtype |
| 612 | + ) |
| 613 | + if progress: |
| 614 | + logger.info( |
| 615 | + "writing %s/%s (streaming %d chunks)", |
| 616 | + Path(group_path).name, |
| 617 | + i, |
| 618 | + int( |
| 619 | + np.prod( |
| 620 | + [-(-s // c) for s, c in zip(next_shape, out_chunks)] |
| 621 | + ) |
| 622 | + ), |
| 623 | + ) |
| 624 | + _stream_strided_level(src_arr, dst_arr, strides) |
495 | 625 | scale = [base_scale[k] * (strides[k] ** i) for k in range(len(axes))] |
496 | 626 | datasets.append(_dataset(str(i), scale)) |
497 | | - logger.info("pyramid level %d: shape=%s", i, nxt.shape) |
| 627 | + logger.info("pyramid level %d: shape=%s", i, next_shape) |
498 | 628 | prev_name = str(i) |
499 | | - prev_shape = nxt.shape |
| 629 | + prev_shape = next_shape |
500 | 630 | return datasets |
501 | 631 |
|
502 | 632 |
|
|
0 commit comments