Skip to content

Commit 3eae081

Browse files
lguerardclaude
andcommitted
fix(occupancy): πŸ› keep the map out of image.zarr and build it once
Three defects from the occupancy and merge work, found on a re-read. capped_output_chunks searched for the largest divisor at or below the cap and accepted whatever it found -- including 1. `auto` tile sizing takes a square root, so a tile axis can be any integer, and a prime one above the cap (1031, 1093) has no other divisor. That would have chunked the merged labels one voxel wide along that axis. It now refuses a divisor below a fraction of the cap and keeps the tile's own size, warning when it does; whatever it returns still divides the tile, which is what keeps concurrent writes chunk-aligned. The occupancy map was written inside image.zarr. It is not an NGFF array, so zarr refused to walk the hierarchy: members() and arrays() raised "Object at occupancy is not recognized as a component of a Zarr hierarchy" on the user's own image. It now lives beside the store as <name>.occupancy.zarr. The build looped channels on the outside, traversing the whole image once per channel -- three full reads for a three-channel stack. Channels are now filled per region, so one traversal serves them all, and the regions run on a thread pool sized to the allocation. Finally, run_multi builds the map in phase A. Every config's prepare needs it and they now run concurrently, so leaving it to them had all three streaming the image and all but one discarding the result. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 0285ec4 commit 3eae081

5 files changed

Lines changed: 178 additions & 17 deletions

File tree

β€Žsrc/patchworks/_merge.pyβ€Ž

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,10 @@ def _make_globally_unique(arr, shape: tuple, chunk_shape: tuple) -> int:
346346

347347

348348
def capped_output_chunks(
349-
chunk_shape: Sequence[int], caps: Sequence[int]
349+
chunk_shape: Sequence[int],
350+
caps: Sequence[int],
351+
*,
352+
min_fraction: float = 0.25,
350353
) -> tuple[int, ...]:
351354
"""Shrink each chunk to at most *cap*, staying an exact divisor.
352355
@@ -356,12 +359,21 @@ def capped_output_chunks(
356359
A non-divisor cap would break that, so the largest divisor at or below the
357360
cap is used instead.
358361
362+
A tile axis with no useful divisor keeps its own size rather than being
363+
shredded: ``auto`` tile sizing produces arbitrary integers (it takes a
364+
square root), and a prime axis above the cap has no divisor except 1 --
365+
which would mean one chunk per voxel along that axis.
366+
359367
Parameters
360368
----------
361369
chunk_shape : sequence of int
362370
Staged chunk (= tile) shape.
363371
caps : sequence of int
364372
Maximum chunk size per axis.
373+
min_fraction : float, optional
374+
Reject a divisor smaller than this fraction of the cap and keep the
375+
uncapped size instead. A slightly oversized chunk is a far smaller
376+
problem than a pathologically tiny one.
365377
366378
Returns
367379
-------
@@ -374,14 +386,28 @@ def capped_output_chunks(
374386
(16, 1024, 1024)
375387
>>> capped_output_chunks((16, 1024, 1024), (16, 1024, 1024))
376388
(16, 1024, 1024)
389+
>>> capped_output_chunks((1031,), (1024,)) # prime: no usable divisor
390+
(1031,)
377391
"""
378392
out = []
379393
for c, cap in zip(chunk_shape, caps):
380394
c, cap = int(c), int(cap)
381395
if c <= cap:
382396
out.append(c)
383397
continue
384-
out.append(next(d for d in range(cap, 0, -1) if c % d == 0))
398+
divisor = next(d for d in range(cap, 0, -1) if c % d == 0)
399+
if divisor < max(1, int(cap * min_fraction)):
400+
logger.warning(
401+
"chunk axis %d has no divisor above %d under the cap %d; "
402+
"keeping %d rather than splitting it into %d-wide chunks.",
403+
c,
404+
int(cap * min_fraction),
405+
cap,
406+
c,
407+
divisor,
408+
)
409+
divisor = c
410+
out.append(divisor)
385411
return tuple(out)
386412

387413

β€Žsrc/patchworks/_occupancy.pyβ€Ž

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@
2727
import logging
2828
import os
2929
import shutil
30+
from concurrent.futures import ThreadPoolExecutor
3031
from itertools import product as _iproduct
3132
from pathlib import Path
3233
from typing import Any, Union
3334

3435
import numpy as np
3536
import zarr
3637

38+
from ._chunks import cpu_allocation
39+
3740
logger = logging.getLogger(__name__)
3841

3942
DEFAULT_BLOCK = (1, 128, 128)
@@ -93,8 +96,17 @@ def _level_array(root: zarr.Group, level: int, store_path: str) -> Any:
9396

9497

9598
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}"
98110

99111

100112
def build_occupancy_map(
@@ -179,21 +191,36 @@ def build_occupancy_map(
179191
dtype=src.dtype,
180192
)
181193

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.
183208
for channel in range(n_channels):
184209
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
197224
dst.attrs["block"] = list(block)
198225
dst.attrs["level"] = int(level)
199226
dst.attrs["source_shape"] = list(sp_shape)

β€Žtests/test_distributed.pyβ€Ž

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from patchworks import (
88
auto_overlap,
9+
capped_output_chunks,
910
create_stage,
1011
merge_tile_labels,
1112
normalize_overlap,
@@ -75,6 +76,34 @@ def test_per_axis_overlap_reads_less_but_still_stitches(tmp_path):
7576
assert np.array_equal(results["scalar"], results["per_axis"])
7677

7778

79+
def test_capped_output_chunks_never_shreds_an_axis():
80+
"""A tile axis with no usable divisor keeps its size, not chunk-size 1.
81+
82+
`auto` tile sizing takes a square root, so an axis can be any integer. A
83+
prime one above the cap has no divisor but 1 -- which would mean one chunk
84+
per voxel along that axis.
85+
"""
86+
assert capped_output_chunks((16, 2048, 2048), (16, 1024, 1024)) == (
87+
16,
88+
1024,
89+
1024,
90+
)
91+
assert capped_output_chunks((16, 1024, 1024), (16, 1024, 1024)) == (
92+
16,
93+
1024,
94+
1024,
95+
)
96+
assert capped_output_chunks((1500,), (1024,)) == (750,) # a real divisor
97+
for prime in (1031, 1093):
98+
assert capped_output_chunks((prime,), (1024,)) == (prime,)
99+
100+
# Whatever it returns must still divide the tile, or concurrent workers
101+
# would read-modify-write a chunk they share.
102+
for tile in (1024, 2048, 1500, 1031, 697, 3000):
103+
(chunk,) = capped_output_chunks((tile,), (1024,))
104+
assert tile % chunk == 0, f"{chunk} does not divide {tile}"
105+
106+
78107
def _relabel_canonical(arr):
79108
"""Canonical form so two labellings can be compared up to renumbering."""
80109
out = np.zeros_like(arr, dtype=np.int64)

β€Žtests/test_occupancy.pyβ€Ž

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,70 @@ def test_build_is_idempotent_and_covers_ragged_edges(tmp_path):
124124
assert build_occupancy_map(store, block=(1, 8, 8)) == path
125125

126126

127+
def test_map_lives_outside_the_image_store(tmp_path):
128+
"""The map must not make the image store an invalid zarr hierarchy.
129+
130+
It is not an NGFF array, so zarr refuses to walk a hierarchy containing
131+
it -- putting it inside image.zarr made every members()/arrays() call on
132+
the user's own image raise a ZarrUserWarning.
133+
"""
134+
import warnings
135+
136+
data = np.zeros((1, 2, 16, 16), "uint16")
137+
store = _write_store(tmp_path, data)
138+
path = build_occupancy_map(store, block=(1, 8, 8))
139+
140+
assert not path.startswith(store + "/"), "map must be a sibling, not a node"
141+
142+
with warnings.catch_warnings():
143+
warnings.simplefilter("error")
144+
members = sorted(
145+
k for k, _ in zarr.open_group(store, mode="r").members()
146+
)
147+
assert members == ["0"], (
148+
f"image store should hold only its levels: {members}"
149+
)
150+
151+
152+
def test_all_channels_built_in_one_traversal(tmp_path):
153+
"""Every channel is filled, and the source is read once, not once each.
154+
155+
Looping channels on the outside traversed the whole image per channel --
156+
three full reads for a three-channel stack.
157+
"""
158+
data = np.zeros((3, 2, 16, 16), "uint16")
159+
for c in range(3):
160+
data[c, 0, 0, 0] = 100 * (c + 1) # a distinct value per channel
161+
store = _write_store(tmp_path, data)
162+
163+
reads = {"n": 0}
164+
real_getitem = zarr.Array.__getitem__
165+
166+
def _counting(self, key):
167+
if self.basename == "0": # the image level, not the map being written
168+
reads["n"] += 1
169+
return real_getitem(self, key)
170+
171+
zarr.Array.__getitem__ = _counting
172+
try:
173+
build_occupancy_map(store, block=(1, 8, 8))
174+
finally:
175+
zarr.Array.__getitem__ = real_getitem
176+
177+
pooled = np.asarray(zarr.open_array(occupancy_path(store, 0), mode="r"))
178+
assert pooled.shape[0] == 3
179+
for c in range(3):
180+
assert pooled[c].max() == 100 * (c + 1), f"channel {c} not built"
181+
182+
# 2x2 blocks per plane x 2 planes = 4 output regions... but the region
183+
# step is sized to ~128 MB, so this tiny image is a single region: one
184+
# read per channel, and no more.
185+
assert reads["n"] == 3, (
186+
f"expected one read per channel, got {reads['n']} -- the image is "
187+
"being traversed more than once"
188+
)
189+
190+
127191
def test_tile_shape_dimensionality_is_checked(tmp_path):
128192
data = np.zeros((1, 2, 16, 16), "uint16")
129193
store = _write_store(tmp_path, data)

β€Žworkflow/scripts/run_multi.pyβ€Ž

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ def main() -> None:
192192
print("[run_multi] ERROR: conversion failed", file=sys.stderr)
193193
sys.exit(rc)
194194

195+
# Still phase A: build the occupancy map here too. Every config's `prepare`
196+
# needs it, and they are about to run concurrently -- so leaving it to them
197+
# means all of them stream the whole image, and all but one throw the
198+
# result away. It covers every channel, so one build serves them all.
199+
if not args.dry_run:
200+
from patchworks import build_occupancy_map
201+
202+
levels = {int(cfg.get("level", 0)) for cfg in seg_cfgs}
203+
for level in sorted(levels):
204+
print(
205+
f"[run_multi] building occupancy map for level {level} …",
206+
flush=True,
207+
)
208+
build_occupancy_map(image_store, level=level)
209+
195210
# Phase B: the segmentations touch disjoint files under
196211
# work_dir/<label_name>/, so run them together and let the GPU partition
197212
# stay busy instead of idling through each config's prepare and merge.

0 commit comments

Comments
Β (0)