Skip to content

Commit 3849052

Browse files
lguerardclaude
andcommitted
Merge branch dev: fix decon output shape on edge tiles
pycudadecon returns a smaller volume for some input sizes, which failed edge tiles with an unreadable broadcast error from inside zarr. The output is re-centred to the input shape, and stage_tile now rejects any segmentation function that changes shape by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents e5ad25c + 54e0c19 commit 3849052

3 files changed

Lines changed: 133 additions & 0 deletions

File tree

src/patchworks/_distributed.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,22 @@ def stage_tile(
213213
trims.append((s.start - lo, hi - s.stop))
214214
block = np.asarray(image[tuple(expanded)])
215215
out = np.asarray(fn(block))
216+
if out.shape != block.shape:
217+
# Caught here rather than 6 frames deep in zarr's codec pipeline as
218+
# "could not broadcast input array from shape (13,1020,1020) into
219+
# shape (14,1024,1024)", which says nothing about which function is
220+
# at fault. A segmentation function must label the voxels it was
221+
# given: the halo trim and the destination slice are both computed
222+
# from the tile's geometry, so a different shape has no defined
223+
# placement.
224+
name = getattr(fn, "__name__", type(fn).__name__)
225+
raise ValueError(
226+
f"segmentation function {name!r} returned shape {out.shape} for "
227+
f"a tile of shape {block.shape} (tile {index}). It must return "
228+
"one label per input voxel. Some deconvolution backends crop "
229+
"their output -- pad or centre it back to the input shape before "
230+
"returning."
231+
)
216232
sel = tuple(
217233
slice(left, out.shape[i] - right)
218234
for i, (left, right) in enumerate(trims)

src/patchworks/plugins/dog.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,61 @@ def _run(block: np.ndarray, dog_dict: dict[str, Any]) -> np.ndarray:
238238
)
239239

240240

241+
def _restore_shape(arr: np.ndarray, shape: tuple[int, ...]) -> np.ndarray:
242+
"""Centre *arr* back into an array of *shape*, cropping or edge-padding.
243+
244+
Deconvolution must not change the field of view: patchworks writes the
245+
result into a destination slice derived from the tile's geometry, so one
246+
label per input voxel is required.
247+
248+
Centring is the right correction for a symmetric crop, which is what
249+
apodisation produces. The discrepancies observed are small (a voxel in z,
250+
a few in x/y) and land inside the halo, which is discarded anyway -- so
251+
the labels that survive the trim are unaffected. It is logged at WARNING
252+
with the exact shapes so a larger, non-symmetric crop cannot pass
253+
silently.
254+
255+
Parameters
256+
----------
257+
arr : np.ndarray
258+
The deconvolved volume.
259+
shape : tuple of int
260+
The shape it must be returned at (the input tile's).
261+
262+
Returns
263+
-------
264+
np.ndarray
265+
An array of exactly *shape*.
266+
267+
Examples
268+
--------
269+
>>> import numpy as np
270+
>>> _restore_shape(np.ones((13, 1020)), (14, 1024)).shape
271+
(14, 1024)
272+
"""
273+
logger.warning(
274+
"deconvolution returned %s for a %s input; re-centring to the input "
275+
"shape. patchworks needs one label per input voxel. A large or "
276+
"asymmetric difference here would shift labels -- check the PSF and "
277+
"voxel sizes if this is more than a few voxels.",
278+
arr.shape,
279+
shape,
280+
)
281+
# Crop first, so an axis that grew is handled before padding the rest.
282+
crop = tuple(
283+
slice((a - s) // 2, (a - s) // 2 + s) if a > s else slice(None)
284+
for a, s in zip(arr.shape, shape)
285+
)
286+
arr = arr[crop]
287+
pad = tuple(
288+
((s - a) // 2, s - a - (s - a) // 2) if a < s else (0, 0)
289+
for a, s in zip(arr.shape, shape)
290+
)
291+
if any(lo or hi for lo, hi in pad):
292+
arr = np.pad(arr, pad, mode="edge")
293+
return arr
294+
295+
241296
def _segment_once(
242297
block: np.ndarray, dog_dict: dict[str, Any], use_gpu: bool
243298
) -> np.ndarray:
@@ -253,7 +308,16 @@ def _segment_once(
253308
# in the per-tile timing that tile_process logs.
254309
from pycudadecon import decon
255310

311+
before = img.shape
256312
img = decon(images=img, **decon_kwargs)
313+
if img.shape != before:
314+
# cudaDecon returns a slightly smaller volume for some input
315+
# sizes (e.g. (14,1024,1024) -> (13,1020,1020) on an edge tile).
316+
# patchworks needs one label per input voxel: the halo trim and
317+
# the destination slice are both derived from the tile geometry,
318+
# so a shrunken result has nowhere to go and used to surface as
319+
# an unreadable broadcast error from inside zarr.
320+
img = _restore_shape(img, before)
257321

258322
if use_gpu:
259323
import cupy as cp

tests/test_dog.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,56 @@ def _decon(images, **kwargs):
107107
assert captured["dxdata"] == 0.1 # filled from the calibration
108108
assert captured["dzdata"] == 0.2
109109
assert captured["dzpsf"] == 0.2
110+
111+
112+
def test_restore_shape_recentres_a_cropped_decon():
113+
"""cudaDecon can hand back a smaller volume than it was given.
114+
115+
Observed on a real edge tile: (14, 1024, 1024) in, (13, 1020, 1020) out.
116+
patchworks needs one label per input voxel, so the field of view has to be
117+
restored before the DoG step.
118+
"""
119+
from patchworks.plugins.dog import _restore_shape
120+
121+
arr = np.arange(13 * 1020 * 1020, dtype="float32").reshape(13, 1020, 1020)
122+
out = _restore_shape(arr, (14, 1024, 1024))
123+
assert out.shape == (14, 1024, 1024)
124+
# The original content is preserved, centred, not resampled.
125+
assert np.array_equal(out[0:13, 2:1022, 2:1022], arr)
126+
127+
128+
def test_restore_shape_handles_growth_and_exact_fit():
129+
"""It must be a no-op when shapes already match, and crop when larger."""
130+
from patchworks.plugins.dog import _restore_shape
131+
132+
same = np.ones((4, 8, 8), dtype="float32")
133+
assert _restore_shape(same, (4, 8, 8)).shape == (4, 8, 8)
134+
bigger = np.ones((6, 12, 12), dtype="float32")
135+
assert _restore_shape(bigger, (4, 8, 8)).shape == (4, 8, 8)
136+
# And a mix: one axis short, one long.
137+
mixed = np.ones((2, 12), dtype="float32")
138+
assert _restore_shape(mixed, (4, 8)).shape == (4, 8)
139+
140+
141+
def test_stage_tile_rejects_a_shape_changing_function(tmp_path):
142+
"""A wrong-shaped return must name the culprit, not blow up inside zarr.
143+
144+
This used to surface as "could not broadcast input array from shape
145+
(13,1020,1020) into shape (14,1024,1024)" six frames deep in zarr's codec
146+
pipeline, which says nothing about which function misbehaved.
147+
"""
148+
import dask.array as da
149+
import pytest
150+
151+
from patchworks import create_stage, stage_tile
152+
153+
image = da.zeros((8, 32, 32), chunks=(4, 16, 16), dtype="uint16")
154+
stage = str(tmp_path / "stage.zarr")
155+
create_stage(stage, image.shape, (4, 16, 16))
156+
157+
def crops(block):
158+
"""Stand-in for a deconvolution backend that trims its output."""
159+
return np.zeros(tuple(s - 1 for s in block.shape), dtype="int32")
160+
161+
with pytest.raises(ValueError, match="one label per input voxel"):
162+
stage_tile(image, crops, stage, 0, tile_shape=(4, 16, 16), overlap=2)

0 commit comments

Comments
 (0)