Skip to content

Commit cfba52c

Browse files
Donglai Weiclaude
andcommitted
affinity_mask: spec-only fast path; legacy load is fallback
_maybe_apply_affinity_mask used to read the full (X, Y, Z) uint8 mask volume, allocate a same-shape boolean zero_idx, and zero each prediction channel via fancy indexing. On large volumes (12+ G voxels here) that dominated decoding runtime. build_affinity_mask already writes the full mask spec (low_z, high_z, border_width, bg_thresh, axis_order, source_image) to the dataset attrs, so we can reconstruct the mask implicitly: - Z-gate: two contiguous slice writes per channel-axis end. - Border + intensity: only the outer XY ring of width border_width is read from the source zarr; voxels with intensity <= bg_thresh are zeroed across all channels. Corner overlap removed by slicing the Y strips between [bw, X-bw] on the X axis. apply_affinity_mask_from_spec(predictions, spec) is the new in-place helper, exported from connectomics.decoding.qc. Stage code first tries _read_affinity_mask_spec(mask_path); when all five expected attrs are present and axis_order=="XYZ" it takes the fast path, otherwise it falls back to the legacy full-volume load. Output log prefixes the legacy path with "(legacy load)" to make the active path obvious. Verified parity on a synthetic (12, 14, 8) fixture: spec-only apply produces byte-identical predictions to the legacy load (n_zero matches, np.array_equal == True). All 40 existing affinity/QC unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d57aeaf commit cfba52c

3 files changed

Lines changed: 154 additions & 5 deletions

File tree

connectomics/decoding/qc/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
AffinityQCAccumulator,
55
AffinityQCParams,
66
AffinityQCReport,
7+
apply_affinity_mask_from_spec,
78
begin_streaming_qc,
89
build_affinity_mask,
910
finish_streaming_qc,
@@ -16,6 +17,7 @@
1617
"AffinityQCAccumulator",
1718
"AffinityQCParams",
1819
"AffinityQCReport",
20+
"apply_affinity_mask_from_spec",
1921
"begin_streaming_qc",
2022
"build_affinity_mask",
2123
"finish_streaming_qc",

connectomics/decoding/qc/affinity.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,92 @@ def read_block(z0: int, z1: int) -> np.ndarray:
506506
)
507507

508508

509+
def apply_affinity_mask_from_spec(predictions: np.ndarray, spec: dict) -> tuple[int, int]:
510+
"""Apply an affinity mask in-place using only the spec, skipping the full mask load.
511+
512+
Equivalent to building a mask via :func:`build_affinity_mask` with the same
513+
parameters and then zeroing every prediction voxel where the mask is 0, but
514+
without materializing the (X, Y, Z) mask array. ``predictions`` is modified
515+
in place; the (zero, total) prediction-voxel counts are returned for logging.
516+
517+
``spec`` requires keys ``low_z``, ``high_z``, ``border_width``, ``bg_thresh``,
518+
``image_path`` (a zarr 3D/4D source for the border-intensity check).
519+
``axis_order`` is optional and must be ``"XYZ"`` if present.
520+
521+
The two rules from :func:`build_affinity_mask` are applied:
522+
523+
- Z-gate: contiguous slice writes zero everything outside ``[low_z, high_z)``.
524+
- Border + intensity: only the outer XY ring of width ``border_width`` is
525+
read from the source image; voxels with intensity <= ``bg_thresh`` are
526+
zeroed across all channels. Corners are zeroed twice (idempotent).
527+
"""
528+
if predictions.ndim != 4:
529+
raise ValueError(
530+
f"apply_affinity_mask_from_spec expects 4D predictions, got {predictions.shape}"
531+
)
532+
axis_order = str(spec.get("axis_order", "XYZ"))
533+
if axis_order != "XYZ":
534+
raise ValueError(f"unsupported axis_order={axis_order!r}; only 'XYZ' is supported")
535+
536+
_C, X, Y, Z = predictions.shape
537+
low_z = int(spec["low_z"])
538+
high_z = int(spec["high_z"])
539+
border_width = int(spec["border_width"])
540+
bg_thresh = int(spec["bg_thresh"])
541+
image_path = spec.get("image_path") or spec.get("source_image") or ""
542+
if not (0 <= low_z <= high_z <= Z):
543+
raise ValueError(f"invalid z range [{low_z}, {high_z}) for Z={Z}")
544+
545+
n_total_pred = int(predictions.size)
546+
n_zero_voxels = 0 # counts in mask-volume coords (X*Y*Z), not predictions
547+
548+
# Z-gate: contiguous slice writes.
549+
if low_z > 0:
550+
predictions[:, :, :, :low_z] = 0
551+
n_zero_voxels += X * Y * low_z
552+
if high_z < Z:
553+
predictions[:, :, :, high_z:] = 0
554+
n_zero_voxels += X * Y * (Z - high_z)
555+
556+
# Border + intensity.
557+
if border_width > 0 and bg_thresh > 0 and image_path and high_z > low_z:
558+
bw = border_width
559+
if bw * 2 > X or bw * 2 > Y:
560+
raise ValueError(
561+
f"border_width={bw} too large for shape (X={X}, Y={Y})"
562+
)
563+
import zarr
564+
565+
img = zarr.open(image_path, mode="r")
566+
if img.ndim == 4:
567+
def read_strip(xs: slice, ys: slice) -> np.ndarray:
568+
return np.asarray(img[xs, ys, low_z:high_z, 0])
569+
elif img.ndim == 3:
570+
def read_strip(xs: slice, ys: slice) -> np.ndarray:
571+
return np.asarray(img[xs, ys, low_z:high_z])
572+
else:
573+
raise ValueError(f"image must be 3D or 4D, got shape {img.shape}")
574+
575+
strips = [
576+
(slice(0, bw), slice(None, None)), # left X edge
577+
(slice(X - bw, X), slice(None, None)), # right X edge
578+
(slice(bw, X - bw), slice(0, bw)), # left Y edge (no corner dup)
579+
(slice(bw, X - bw), slice(Y - bw, Y)), # right Y edge (no corner dup)
580+
]
581+
for sx, sy in strips:
582+
img_strip = read_strip(sx, sy)
583+
zero_strip = img_strip <= bg_thresh
584+
if not zero_strip.any():
585+
continue
586+
# pred_view is a basic-slice view of predictions, so the fancy-index
587+
# assignment below writes through to predictions.
588+
pred_view = predictions[:, sx, sy, low_z:high_z]
589+
pred_view[:, zero_strip] = 0
590+
n_zero_voxels += int(zero_strip.sum())
591+
592+
return n_zero_voxels, X * Y * Z
593+
594+
509595
def _cfg_get(cfg: Any, name: str, default: Any = None) -> Any:
510596
if cfg is None:
511597
return default

connectomics/decoding/stage.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,51 @@ def apply_decoding_postprocessing(cfg: Any, data: np.ndarray) -> np.ndarray:
116116
return output
117117

118118

119+
_AFFINITY_MASK_SPEC_ATTRS = ("low_z", "high_z", "border_width", "bg_thresh", "source_image")
120+
121+
122+
def _read_affinity_mask_spec(mask_path: str) -> dict | None:
123+
"""Return the affinity-mask spec from the h5 file's attrs, or None.
124+
125+
Files produced by :func:`connectomics.decoding.qc.build_affinity_mask` carry
126+
the mask spec on the ``main`` dataset's ``attrs``. When all required attrs
127+
are present (and ``axis_order`` is the expected XYZ), the spec is enough to
128+
apply the mask without reading the full mask volume.
129+
"""
130+
import h5py
131+
132+
with h5py.File(mask_path, "r") as f:
133+
if "main" not in f:
134+
return None
135+
attrs = dict(f["main"].attrs)
136+
if not all(k in attrs for k in _AFFINITY_MASK_SPEC_ATTRS):
137+
return None
138+
axis_order = str(attrs.get("axis_order", "XYZ"))
139+
if axis_order != "XYZ":
140+
return None
141+
return {
142+
"low_z": int(attrs["low_z"]),
143+
"high_z": int(attrs["high_z"]),
144+
"border_width": int(attrs["border_width"]),
145+
"bg_thresh": int(attrs["bg_thresh"]),
146+
"image_path": str(attrs["source_image"]),
147+
"axis_order": axis_order,
148+
}
149+
150+
119151
def _maybe_apply_affinity_mask(cfg: Any, predictions: np.ndarray) -> np.ndarray:
120152
"""Zero affinity channels at masked-out voxels per ``decoding.affinity_mask_path``.
121153
122-
The mask file must contain a single 3D ``uint8`` dataset matching the spatial
123-
shape of ``predictions[0]`` (the affinity volume). 0 = drop, 1 = keep. Mask is
124-
broadcast across the channel dimension; voxels where ``mask==0`` get zeroed
125-
so that connected-components decoders sever all outgoing edges there.
154+
Two code paths:
155+
156+
- **Spec fast path** (preferred): if the mask h5 was produced by
157+
:func:`build_affinity_mask`, its dataset ``attrs`` carry the spec
158+
(``low_z``, ``high_z``, ``border_width``, ``bg_thresh``, ``source_image``).
159+
We apply the rules directly via slice writes + a tiny border-strip read
160+
from the source image, skipping the multi-GB mask load entirely.
161+
- **Legacy slow path**: if the spec attrs are missing (older files or
162+
external producers), fall back to reading the full ``(X, Y, Z) uint8``
163+
mask and zeroing per channel via boolean fancy indexing.
126164
"""
127165
decoding_cfg = _cfg_get(cfg, "decoding", None)
128166
mask_path = _cfg_get(decoding_cfg, "affinity_mask_path", "") or ""
@@ -133,6 +171,28 @@ def _maybe_apply_affinity_mask(cfg: Any, predictions: np.ndarray) -> np.ndarray:
133171
"affinity_mask_path requires 4D predictions (C, *spatial); "
134172
f"got shape {predictions.shape}"
135173
)
174+
175+
spec = _read_affinity_mask_spec(mask_path)
176+
if spec is not None:
177+
from .qc.affinity import apply_affinity_mask_from_spec
178+
179+
if spec["high_z"] - spec["low_z"] > predictions.shape[-1]:
180+
raise ValueError(
181+
f"affinity_mask spec z range exceeds prediction z extent: "
182+
f"[{spec['low_z']}, {spec['high_z']}) vs Z={predictions.shape[-1]}"
183+
)
184+
t0 = time.time()
185+
n_zero, n_total = apply_affinity_mask_from_spec(predictions, spec)
186+
logger.info(
187+
"Applied affinity mask via spec %s in %.2fs: z=[%d,%d) "
188+
"border_width=%d bg_thresh=%d zeroed=%d/%d (%.2f%%) across %d channels",
189+
mask_path, time.time() - t0,
190+
spec["low_z"], spec["high_z"], spec["border_width"], spec["bg_thresh"],
191+
n_zero, n_total, 100.0 * n_zero / max(n_total, 1),
192+
predictions.shape[0],
193+
)
194+
return predictions
195+
136196
import h5py
137197

138198
with h5py.File(mask_path, "r") as f:
@@ -155,7 +215,8 @@ def _maybe_apply_affinity_mask(cfg: Any, predictions: np.ndarray) -> np.ndarray:
155215
n_zero = int(zero_idx.sum())
156216
n_total = int(zero_idx.size)
157217
logger.info(
158-
"Applying affinity mask %s: zeroing %d/%d voxels (%.2f%%) across %d channels",
218+
"Applying affinity mask (legacy load) %s: zeroing %d/%d voxels "
219+
"(%.2f%%) across %d channels",
159220
mask_path,
160221
n_zero,
161222
n_total,

0 commit comments

Comments
 (0)