Skip to content

Commit 54e0c19

Browse files
lguerardclaude
andcommitted
fix(dog): 🐛 restore the field of view cudaDecon crops away
An edge tile failed with "could not broadcast input array from shape (13,1020,1020) into shape (14,1024,1024)" raised six frames deep inside zarr, which named neither the tile nor the function responsible. pycudadecon returns a slightly smaller volume for some input sizes. patchworks needs one label per input voxel: both the halo trim and the destination slice come from the tile geometry, so a shrunken result has nowhere to go. The decon output is now re-centred to the input shape, logged at WARNING with both shapes so a large or asymmetric crop -- which would shift labels rather than just cost a voxel of halo -- cannot pass silently. stage_tile also checks the returned shape itself and names the offending function, so any custom method that changes shape fails with a readable message instead of a zarr broadcast error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent e5ad25c commit 54e0c19

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)