OSCR

Distributed hierarchical filters partition the Danionella vocal repertoire

Code ↔ Paper

2 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 2 matches
  1. [1] § Methods › Analysis of imaging data › Segmentation and calculation of ΔF/F ↔ src/warpfield/register.py, lines 621–665 · score 0.57 · Gaussians kernel, Gaussian filter, ratio, convolved, correlation
  2. [2] § Methods › Whole-brain imaging › Image postprocessing ↔ src/warpfield/ndimage.py, lines 547–574 · score 0.51 · Richardson Lucy deconvolution, sheared, warpfield, volume

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 809 lines · 35 KB · MIT · 1 match

  1. import warnings
  2. import gc
  3. import pathlib
  4. import os
  5. from typing import List, Union, Callable
  6. import numpy as np
  7. import scipy.signal
  8. import cupy as cp
  9. import cupyx
  10. import cupyx.scipy.ndimage
  11. from pydantic import BaseModel, ValidationError
  12. from tqdm.auto import tqdm
  13. import h5py
  14. from .warp import warp_volume
  15. from .utils import create_rgb_video, mips_callback
  16. from .ndimage import (
  17. accumarray,
  18. dogfilter,
  19. gausskernel_sheared,
  20. infill_nans,
  21. ndwindow,
  22. periodic_smooth_decomposition_nd_rfft,
  23. sliding_block,
  24. upsampled_dft_rfftn,
  25. soften_edges,
  26. )
  27. _ArrayType = Union[np.ndarray, cp.ndarray]
  28. class WarpMap:
  29. """Represents a 3D displacement field
  30. Args:
  31. warp_field (numpy.array): the displacement field data (3-x-y-z)
  32. block_size (3-element list or numpy.array):
  33. block_stride (3-element list or numpy.array):
  34. ref_shape (tuple): shape of the reference volume
  35. mov_shape (tuple): shape of the moving volume
  36. """
  37. def __init__(self, warp_field, block_size, block_stride, ref_shape, mov_shape):
  38. self.warp_field = cp.array(warp_field, dtype="float32")
  39. self.block_size = cp.array(block_size, dtype="float32")
  40. self.block_stride = cp.array(block_stride, dtype="float32")
  41. self.ref_shape = ref_shape
  42. self.mov_shape = mov_shape
  43. def warp(self, vol, out=None):
  44. """Apply the warp to a volume. Can be thought of as pulling the moving volume to the fixed volume space.
  45. Args:
  46. vol (cupy.array): the volume to be warped
  47. Returns:
  48. cupy.array: warped volume
  49. """
  50. if np.any(vol.shape != np.array(self.mov_shape)):
  51. warnings.warn(f"Volume shape {vol.shape} does not match the expected shape {self.mov_shape}.")
  52. if out is None:
  53. out = cp.zeros(self.ref_shape, dtype="float32", order="C")
  54. vol_out = warp_volume(
  55. vol, self.warp_field, self.block_stride, cp.array(-self.block_size / self.block_stride / 2), out=out
  56. )
  57. return vol_out
  58. def apply(self, *args, **kwargs):
  59. """Alias of warp method"""
  60. return self.warp(*args, **kwargs)
  61. def fit_affine(self, target=None):
  62. """Fit affine transformation and return new fitted WarpMap
  63. Args:
  64. target (dict): dict with keys "blocks_shape", "block_size", and "block_stride"
  65. Returns:
  66. WarpMap:
  67. numpy.array: affine tranformation coefficients
  68. """
  69. if target is None:
  70. warp_field_shape = self.warp_field.shape
  71. block_size = self.block_size
  72. block_stride = self.block_stride
  73. else:
  74. warp_field_shape = target["warp_field_shape"]
  75. block_size = cp.array(target["block_size"]).astype("float32")
  76. block_stride = cp.array(target["block_stride"]).astype("float32")
  77. ix = cp.indices(self.warp_field.shape[1:]).reshape(3, -1).T
  78. ix = ix * self.block_stride + self.block_size / 2
  79. M = cp.zeros(self.warp_field.shape[1:])
  80. #M[1:-1, 1:-1, 1:-1] = 1
  81. M[:,:,:] = 1
  82. ixg = cp.where(M.flatten() > 0)[0]
  83. a = cp.hstack([ix[ixg], cp.ones((len(ixg), 1))])
  84. b = ix[ixg] + self.warp_field.reshape(3, -1).T[ixg]
  85. coeff = cp.linalg.lstsq(a, b, rcond=None)[0]
  86. ix_out = cp.indices(warp_field_shape[1:]).reshape(3, -1).T * block_stride + block_size / 2
  87. linfit = ((ix_out @ (coeff[:3] - cp.eye(3))) + coeff[3]).T.reshape(warp_field_shape)
  88. return WarpMap(linfit, block_size, block_stride, self.ref_shape, self.mov_shape), coeff
  89. def median_filter(self):
  90. """Apply median filter to the displacement field
  91. Returns:
  92. WarpMap: new WarpMap with median filtered displacement field
  93. """
  94. warp_field = cupyx.scipy.ndimage.median_filter(self.warp_field, size=[1, 3, 3, 3], mode="nearest")
  95. return WarpMap(warp_field, self.block_size, self.block_stride, self.ref_shape, self.mov_shape)
  96. def resize_to(self, target):
  97. """Resize to target WarpMap, using linear interpolation
  98. Args:
  99. target (WarpMap or WarpMapper): target to resize to
  100. or a dict with keys "shape", "block_size", and "block_stride"
  101. Returns:
  102. WarpMap: resized WarpMap
  103. """
  104. if isinstance(target, WarpMap):
  105. t_sh, t_bsz, t_bst = target.warp_field.shape[1:], target.block_size, target.block_stride
  106. elif isinstance(target, WarpMapper):
  107. t_sh, t_bsz, t_bst = target.blocks_shape[:3], cp.array(target.block_size), cp.array(target.block_stride)
  108. elif isinstance(target, dict):
  109. t_sh, t_bsz, t_bst = (
  110. target["warp_field_shape"][1:],
  111. cp.array(target["block_size"]),
  112. cp.array(target["block_stride"]),
  113. )
  114. else:
  115. raise ValueError("target must be a WarpMap, WarpMapper, or dict")
  116. ix = cp.array(cp.indices(t_sh).reshape(3, -1))
  117. # ix = (ix + 0.5) / cp.array(self.block_size / t_bsz)[:, None] - 0.5
  118. ix = (ix * t_bst[:, None] + (t_bsz - self.block_size)[:, None] / 2) / self.block_stride[:, None]
  119. dm_r = cp.array(
  120. [
  121. cupyx.scipy.ndimage.map_coordinates(cp.array(self.warp_field[i]), ix, mode="nearest", order=1).reshape(
  122. t_sh
  123. )
  124. for i in range(3)
  125. ]
  126. )
  127. return WarpMap(dm_r, t_bsz, t_bst, self.ref_shape, self.mov_shape)
  128. def chain(self, target):
  129. """Chain displacement maps
  130. Args:
  131. target (WarpMap): WarpMap to be added to existing map
  132. Returns:
  133. WarpMap: new WarpMap with chained displacement field
  134. """
  135. indices = cp.indices(target.warp_field.shape[1:])
  136. warp_field = self.warp_field.copy()
  137. warp_field += target.warp_field
  138. return WarpMap(warp_field, target.block_size, target.block_stride, self.ref_shape, self.mov_shape)
  139. def invert(self, **kwargs):
  140. """alias for invert_fast method"""
  141. return self.invert_fast(**kwargs)
  142. def invert_fast(self, sigma=0.5, truncate=20):
  143. """Invert the displacement field using accumulation and Gaussian basis interpolation.
  144. Args:
  145. sigma (float): standard deviation for Gaussian basis interpolation
  146. truncate (float): truncate parameter for Gaussian basis interpolation
  147. Returns:
  148. WarpMap: inverted WarpMap
  149. """
  150. warp_field = self.warp_field.get()
  151. target_coords = np.indices(warp_field.shape[1:]) + warp_field / self.block_stride[:, None, None, None].get()
  152. wf_shape = np.ceil(np.array(self.mov_shape) / self.block_stride.get() + 1).astype("int")
  153. num_coords = accumarray(target_coords, wf_shape)
  154. inv_field = np.zeros((3, *wf_shape), dtype=warp_field.dtype)
  155. for i in range(3):
  156. inv_field[i] = -accumarray(target_coords, wf_shape, weights=warp_field[i].ravel())
  157. with np.errstate(invalid="ignore"):
  158. inv_field[i] /= num_coords
  159. inv_field[i][num_coords == 0] = np.nan
  160. inv_field[i] = infill_nans(inv_field[i], sigma=sigma, truncate=truncate)
  161. return WarpMap(inv_field, self.block_size, self.block_stride, self.mov_shape, self.ref_shape)
  162. def push_coordinates(self, coords, negative_shifts=False):
  163. """Push voxel coordinates from fixed to moving space.
  164. Args:
  165. coords (numpy.array): 3D *voxel* coordinates to be warped (3-by-n array)
  166. Returns:
  167. numpy.array: transformed voxel coordinates
  168. """
  169. assert coords.shape[0] == 3
  170. coords = cp.array(coords, dtype="float32")
  171. # coords_blocked = coords / self.block_size[:, None] - 0.5
  172. coords_blocked = coords / self.block_stride[:, None] - (self.block_size / (2 * self.block_stride))[:, None]
  173. warp_field = self.warp_field.copy()
  174. shifts = cp.zeros_like(coords)
  175. for idim in range(3):
  176. shifts[idim] = cupyx.scipy.ndimage.map_coordinates(
  177. warp_field[idim], coords_blocked, order=1, mode="nearest"
  178. )
  179. if negative_shifts:
  180. shifts = -shifts
  181. return coords + shifts
  182. def pull_coordinates(self, coords):
  183. """Pull voxel coordinates through the warp field. Involves inversion, followed by pushing coordinates.
  184. Args:
  185. coords (numpy.array): 3D *voxel* coordinates to be warped (3-by-n array)
  186. Returns:
  187. numpy.array: transformed voxel coordinates
  188. """
  189. return self.invert().push_coordinates(coords, negative_shifts=True)
  190. def jacobian_det(self, units_per_voxel=[1, 1, 1], edge_order=1):
  191. """
  192. Compute det J = det(∇φ) for φ(x)=x+u(x), using np.indices for the identity grid.
  193. Args:
  194. edge_order : passed to np.gradient (1 or 2)
  195. Returns:
  196. detJ: cp.ndarray of shape spatial
  197. """
  198. scaling = cp.array(units_per_voxel, dtype="float32") * self.block_stride
  199. coords = cp.indices(self.warp_field.shape[1:], dtype="float32") * scaling[:, None, None, None]
  200. phi = coords + self.warp_field
  201. J = cp.empty(self.warp_field.shape[1:] + (3, 3), dtype="float32")
  202. for i in range(3):
  203. grads = cp.gradient(phi[i], *scaling, edge_order=edge_order)
  204. for j in range(3):
  205. J[..., i, j] = grads[j]
  206. return cp.linalg.det(J)
  207. def as_ants_image(self, voxel_size_um=1):
  208. """Convert to ANTsImage
  209. Args:
  210. voxel_size_um (scalar or array): voxel size (default is 1)
  211. Returns:
  212. ants.core.ants_image.ANTsImage:
  213. """
  214. try:
  215. import ants
  216. except ImportError:
  217. raise ImportError("ANTs is not installed. Please install it using 'pip install ants'")
  218. ants_image = ants.from_numpy(
  219. self.warp_field.get().transpose(1, 2, 3, 0),
  220. origin=list((self.block_size.get() - 1) / 2 * voxel_size_um),
  221. spacing=list(self.block_stride.get() * voxel_size_um),
  222. has_components=True,
  223. )
  224. return ants_image
  225. def __repr__(self):
  226. """String representation of the WarpMap object."""
  227. info = (
  228. f"WarpMap("
  229. f"warp_field_shape={self.warp_field.shape}, "
  230. f"block_size={self.block_size.get()}, "
  231. f"block_stride={self.block_stride.get()}, "
  232. f"transformation: {str(self.mov_shape)} --> {str(self.ref_shape)}"
  233. )
  234. return info
  235. def to_h5(self, h5_path, group="warp_map", compression="gzip", overwrite=True):
  236. """
  237. Save this WarpMap to an HDF5 file.
  238. Args:
  239. h5_path (str or os.PathLike): Path to the HDF5 file.
  240. group (str): Group path inside the HDF5 file to store the WarpMap (created if missing).
  241. compression (str or None): Dataset compression (e.g., 'gzip', None).
  242. overwrite (bool): If True, overwrite existing datasets/attrs inside the group.
  243. """
  244. with h5py.File(h5_path, "a") as f:
  245. if overwrite and (group not in (None, "", "/")) and (group in f):
  246. del f[group]
  247. if (not overwrite) and (group in f):
  248. raise ValueError(f"Group '{group}' already exists in {h5_path}. Set 'overwrite=True' to overwrite it.")
  249. grp = f.require_group(group) if group not in (None, "", "/") else f
  250. grp.create_dataset("warp_field", data=self.warp_field.get(), compression=compression)
  251. grp.create_dataset("block_size", data=self.block_size.get())
  252. grp.create_dataset("block_stride", data=self.block_stride.get())
  253. grp.create_dataset("ref_shape", data=np.array(self.ref_shape, dtype="int64"))
  254. grp.create_dataset("mov_shape", data=np.array(self.mov_shape, dtype="int64"))
  255. grp.attrs["class"] = "WarpMap"
  256. @classmethod
  257. def from_h5(cls, h5_path, group="warp_map"):
  258. """
  259. Load a WarpMap from an HDF5 file.
  260. Args:
  261. h5_path (str or os.PathLike): Path to the HDF5 file.
  262. group (str): Group path inside the HDF5 file where the WarpMap is stored.
  263. Returns:
  264. WarpMap: The loaded WarpMap object.
  265. """
  266. with h5py.File(h5_path, "r") as f:
  267. grp = f[group]
  268. warp_field = grp["warp_field"][:]
  269. block_size = grp["block_size"][:]
  270. block_stride = grp["block_stride"][:]
  271. ref_shape = tuple(grp["ref_shape"][:].tolist())
  272. mov_shape = tuple(grp["mov_shape"][:].tolist())
  273. return cls(warp_field, block_size, block_stride, ref_shape, mov_shape)
  274. class WarpMapper:
  275. """Class that estimates warp field using cross-correlation, based on a piece-wise rigid model.
  276. Args:
  277. ref_vol (numpy.array): The reference volume
  278. block_size (3-element list or numpy.array): shape of blocks, whose rigid displacement is estimated
  279. block_stride (3-element list or numpy.array): stride (usually identical to block_size)
  280. proj_method (str or callable): Projection method
  281. """
  282. def __init__(
  283. self, ref_vol, block_size, block_stride=None, proj_method=None, subpixel=4, epsilon=1e-6, tukey_alpha=0.5
  284. ):
  285. if np.any(block_size > np.array(ref_vol.shape)):
  286. raise ValueError(f"Block size (currently: {block_size}) must be smaller than the volume shape ({np.array(ref_vol.shape)}).")
  287. self.proj_method = proj_method
  288. self.plan_rev = [None, None, None]
  289. self.subpixel = subpixel
  290. self.epsilon = epsilon
  291. self.tukey_alpha = tukey_alpha
  292. self.update_reference(ref_vol, block_size, block_stride)
  293. self.ref_shape = np.array(ref_vol.shape)
  294. def update_reference(self, ref_vol, block_size, block_stride=None):
  295. ft = lambda arr: cp.fft.rfftn(arr, axes=(-2, -1))
  296. block_size = np.array(block_size)
  297. block_stride = block_size if block_stride is None else np.array(block_stride)
  298. ref_blocks = sliding_block(cp.array(ref_vol), block_size=block_size, block_stride=block_stride)
  299. self.blocks_shape = ref_blocks.shape
  300. ref_blocks_proj = [self.proj_method(ref_blocks, axis=iax) for iax in [-3, -2, -1]]
  301. if self.tukey_alpha < 1:
  302. ref_blocks_proj = [
  303. ref_blocks_proj[i]
  304. * cp.array(
  305. ndwindow(
  306. [1, 1, 1, *ref_blocks_proj[i].shape[-2:]], lambda n: scipy.signal.windows.tukey(n, alpha=0.5)
  307. )
  308. ).astype("float32")
  309. for i in range(3)
  310. ]
  311. self.plan_fwd = [
  312. cupyx.scipy.fft.get_fft_plan(ref_blocks_proj[i], axes=(-2, -1), value_type="R2C") for i in range(3)
  313. ]
  314. self.ref_blocks_proj_ft_conj = [
  315. cupyx.scipy.fft.rfftn(ref_blocks_proj[i], axes=(-2, -1), plan=self.plan_fwd[i]).conj() for i in range(3)
  316. ]
  317. self.block_size = block_size
  318. self.block_stride = block_stride
  319. def get_displacement(self, vol, smooth_func=None):
  320. """Estimate the displacement of vol with the reference volume, via piece-wise rigid cross-correlation with the pre-saved blocks.
  321. Args:
  322. vol (numpy.array): Input volume
  323. smooth_func (callable): Smoothing function to be applied to the cross-correlation volume
  324. Returns:
  325. WarpMap
  326. """
  327. vol_blocks = sliding_block(vol, block_size=self.block_size, block_stride=self.block_stride)
  328. vol_blocks_proj = [self.proj_method(vol_blocks, axis=iax) for iax in [-3, -2, -1]]
  329. del vol_blocks
  330. disp_field = []
  331. for i in range(3):
  332. R = (
  333. cupyx.scipy.fft.rfftn(vol_blocks_proj[i], axes=(-2, -1), plan=self.plan_fwd[i])
  334. * self.ref_blocks_proj_ft_conj[i]
  335. )
  336. if self.plan_rev[i] is None:
  337. self.plan_rev[i] = cupyx.scipy.fft.get_fft_plan(R, axes=(-2, -1), value_type="C2R")
  338. xcorr_proj = cp.fft.fftshift(cupyx.scipy.fft.irfftn(R, axes=(-2, -1), plan=self.plan_rev[i]), axes=(-2, -1))
  339. if smooth_func is not None:
  340. xcorr_proj = smooth_func(xcorr_proj, self.block_size)
  341. xcorr_proj[..., xcorr_proj.shape[-2] // 2, xcorr_proj.shape[-1] // 2] += self.epsilon
  342. max_ix = cp.array(cp.unravel_index(cp.argmax(xcorr_proj, axis=(-2, -1)), xcorr_proj.shape[-2:]))
  343. max_ix = max_ix - cp.array(xcorr_proj.shape[-2:])[:, None, None, None] // 2
  344. del xcorr_proj
  345. i0, j0 = max_ix.reshape(2, -1)
  346. shifts = upsampled_dft_rfftn(
  347. R.reshape(-1, *R.shape[-2:]),
  348. upsampled_region_size=int(self.subpixel * 2 + 1),
  349. upsample_factor=self.subpixel,
  350. axis_offsets=(i0, j0),
  351. )
  352. del R
  353. max_sub = cp.array(cp.unravel_index(cp.argmax(shifts, axis=(-2, -1)), shifts.shape[-2:]))
  354. max_sub = (
  355. max_sub.reshape(max_ix.shape) - cp.array(shifts.shape[-2:])[:, None, None, None] // 2
  356. ) / self.subpixel
  357. del shifts
  358. disp_field.append(max_ix + max_sub)
  359. disp_field = cp.array(disp_field)
  360. disp_field = (
  361. cp.array(
  362. [
  363. disp_field[1, 0] + disp_field[2, 0],
  364. disp_field[0, 0] + disp_field[2, 1],
  365. disp_field[0, 1] + disp_field[1, 1],
  366. ]
  367. ).astype("float32")
  368. / 2
  369. )
  370. return WarpMap(disp_field, self.block_size, self.block_stride, self.ref_shape, vol.shape)
  371. class RegistrationPyramid:
  372. """A class for performing multi-resolution registration.
  373. Args:
  374. ref_vol (numpy.array): Reference volume
  375. settings (pandas.DataFrame): Settings for each level of the pyramid.
  376. IMPORTANT: the block sizea in the last level cannot be larger than the block_size in any previous level.
  377. reg_mask (numpy.array): Mask for registration
  378. clip_thresh (float): Threshold for clipping the reference volume
  379. """
  380. def __init__(self, ref_vol, recipe, reg_mask=1):
  381. recipe.model_validate(recipe.model_dump())
  382. self.recipe = recipe
  383. self.reg_mask = cp.array(reg_mask, dtype="float32", copy=False, order="C")
  384. self.mappers = []
  385. ref_vol = cp.array(ref_vol, dtype="float32", copy=False, order="C")
  386. self.ref_shape = ref_vol.shape
  387. if self.recipe.pre_filter is not None:
  388. ref_vol = self.recipe.pre_filter(ref_vol, reg_mask=self.reg_mask)
  389. self.mapper_ix = []
  390. for i in range(len(recipe.levels)):
  391. if recipe.levels[i].repeats < 1:
  392. continue
  393. block_size = np.array(recipe.levels[i].block_size)
  394. tmp = np.r_[ref_vol.shape] // -block_size
  395. block_size[block_size < 0] = tmp[block_size < 0]
  396. if isinstance(recipe.levels[i].block_stride, (int, float)):
  397. block_stride = (block_size * recipe.levels[i].block_stride).astype("int")
  398. else:
  399. block_stride = np.array(recipe.levels[i].block_stride)
  400. self.mappers.append(
  401. WarpMapper(
  402. ref_vol,
  403. block_size,
  404. block_stride=block_stride,
  405. proj_method=recipe.levels[i].project,
  406. tukey_alpha=recipe.levels[i].tukey_ref,
  407. )
  408. )
  409. self.mapper_ix.append(i)
  410. assert len(self.mappers) > 0, "At least one level of registration is required"
  411. def register_single(self, vol, callback=None, verbose=False):
  412. """Register a single volume to the reference volume.
  413. Args:
  414. vol (array_like): Volume to be registered (numpy or cupy array)
  415. callback (function): Callback function to be called after each level of registration
  416. Returns:
  417. - vol (array_like): Registered volume (numpy or cupy array, depending on input)
  418. - warp_map (WarpMap): Displacement field
  419. - callback_output (list): List of outputs from the callback function
  420. """
  421. was_numpy = isinstance(vol, np.ndarray)
  422. vol = cp.array(vol, "float32", copy=False, order="C")
  423. offsets = (cp.array(vol.shape) - cp.array(self.ref_shape)) / 2
  424. warp_map = WarpMap(offsets[:, None, None, None], cp.ones(3), cp.ones(3), self.ref_shape, vol.shape)
  425. warp_map = warp_map.resize_to(self.mappers[-1])
  426. callback_output = []
  427. vol_tmp0 = self.recipe.pre_filter(vol, reg_mask=self.reg_mask) if self.recipe.pre_filter is not None else vol
  428. vol_tmp = cp.zeros(self.ref_shape, dtype="float32", order="C")
  429. warp_map.warp(vol_tmp0, out=vol_tmp)
  430. min_block_stride = np.min([mapper.block_stride for mapper in self.mappers], axis=0)
  431. if callback is not None:
  432. callback_output.append(callback(vol_tmp))
  433. if np.any(self.mappers[-1].block_stride > min_block_stride[0]):
  434. warnings.warn(
  435. "The block stride (in voxels) in the last level should not be larger than the block stride in any previous level (along any axis)."
  436. )
  437. for k, mapper in enumerate(tqdm(self.mappers, desc=f"Levels", disable=not verbose)):
  438. for _ in tqdm(
  439. range(self.recipe.levels[self.mapper_ix[k]].repeats), leave=False, desc=f"Repeats", disable=not verbose
  440. ):
  441. wm = mapper.get_displacement(
  442. vol_tmp, smooth_func=self.recipe.levels[self.mapper_ix[k]].smooth # * self.reg_mask,
  443. )
  444. wm.warp_field *= self.recipe.levels[self.mapper_ix[k]].update_rate
  445. if self.recipe.levels[self.mapper_ix[k]].median_filter:
  446. wm = wm.median_filter()
  447. if self.recipe.levels[self.mapper_ix[k]].affine:
  448. if (np.array(mapper.blocks_shape[:3]) < 2).sum() > 1:
  449. raise ValueError(
  450. f"Affine fit needs at least two axes with at least 2 blocks! Volume shape: {self.ref_shape}; block size: {mapper.block_size}"
  451. )
  452. wm, _ = wm.fit_affine(
  453. target=dict(
  454. warp_field_shape=(3, *self.mappers[-1].blocks_shape[:3]),
  455. block_size=self.mappers[-1].block_size,
  456. block_stride=self.mappers[-1].block_stride,
  457. )
  458. )
  459. else:
  460. wm = wm.resize_to(self.mappers[-1])
  461. warp_map = warp_map.chain(wm)
  462. warp_map.warp(vol_tmp0, out=vol_tmp)
  463. if callback is not None:
  464. # callback_output.append(callback(warp_map.unwarp(vol)))
  465. callback_output.append(callback(vol_tmp))
  466. warp_map.warp(vol, out=vol_tmp)
  467. if was_numpy:
  468. vol_tmp = vol_tmp.get()
  469. return vol_tmp, warp_map, callback_output
  470. def register_volumes(ref, vol, recipe, reg_mask=1, callback=None, verbose=True, video_path=None, vmax=None):
  471. """Register a volume to a reference volume using a registration pyramid.
  472. Args:
  473. ref (numpy.array or cupy.array): Reference volume
  474. vol (numpy.array or cupy.array): Volume to be registered
  475. recipe (Recipe): Registration recipe
  476. reg_mask (numpy.array): Mask to be multiplied with the reference volume. Default is 1 (no mask)
  477. callback (function): Callback function to be called on the volume after each iteration. Default is None.
  478. Can be used to monitor and optimize registration. Example: `callback = lambda vol: vol.mean(1).get()`
  479. (note that `vol` is a 3D cupy array. Use `.get()` to turn the output into a numpy array and save GPU memory).
  480. Callback outputs for each registration step will be returned as a list.
  481. verbose (bool): If True, show progress bars. Default is True
  482. video_path (str): Save a video of the registration process, using callback outputs. The callback has to return 2D frames. Default is None.
  483. vmax (float): Maximum pixel value (to scale video brightness). If none, set to 99.9 percentile of pixel values.
  484. Returns:
  485. - numpy.array or cupy.array (depending on vol input): Registered volume
  486. - WarpMap: Displacement field
  487. - list: List of outputs from the callback function
  488. """
  489. recipe.model_validate(recipe.model_dump())
  490. reg = RegistrationPyramid(ref, recipe, reg_mask=reg_mask)
  491. registered_vol, warp_map, cbout = reg.register_single(vol, callback=callback, verbose=verbose)
  492. del reg
  493. gc.collect()
  494. cp.fft.config.get_plan_cache().clear()
  495. if video_path is not None:
  496. try:
  497. assert cbout[0].ndim == 2, "Callback output must be a 2D array"
  498. ref = callback(recipe.pre_filter(ref))
  499. vmax = np.percentile(ref, 99.9).item() if vmax is None else vmax
  500. create_rgb_video(video_path, ref / vmax, np.array(cbout) / vmax, fps=10)
  501. except (ValueError, AssertionError) as e:
  502. warnings.warn(f"Video generation failed with error: {e}")
  503. return registered_vol, warp_map, cbout
  504. class Projector(BaseModel):
  505. """A class to apply a 2D projection and filters to a volume block
  506. Parameters:
  507. max: if True, apply a max filter to the volume block. Default is True
  508. normalize: if True, normalize projections by the L2 norm (to get correlations, not covariances). Default is False
  509. dog: if True, apply a DoG filter to the volume block. Default is True
  510. low: the lower sigma value for the DoG filter. Default is 0.5
  511. high: the higher sigma value for the DoG filter. Default is 10.0
  512. tukey_env: if True, apply a Tukey window to the output. Default is False
  513. gauss_env: if True, apply a Gaussian window to the output. Default is False
  514. """
  515. max: bool = True
  516. normalize: Union[bool, float] = False
  517. dog: bool = True
  518. low: Union[Union[int, float], List[Union[int, float]]] = 0.5
  519. high: Union[Union[int, float], List[Union[int, float]]] = 10.0
  520. periodic_smooth: bool = False
  521. def __call__(self, vol_blocks, axis):
  522. """Apply a 2D projection and filters to a volume block
  523. Args:
  524. vol_blocks (cupy.array): Blocked volume to be projected (6D dataset, with the first 3 dimensions being blocks and the last 3 dimensions being voxels)
  525. axis (int): Axis along which to project
  526. Returns:
  527. cupy.array: Projected volume block (5D dataset, with the first 3 dimensions being blocks and the last 2 dimensions being 2D projections)
  528. """
  529. if self.max:
  530. out = vol_blocks.max(axis)
  531. else:
  532. out = vol_blocks.mean(axis)
  533. if self.periodic_smooth:
  534. out = periodic_smooth_decomposition_nd_rfft(out)
  535. low = np.delete(np.r_[1,1,1] * self.low, axis)
  536. high = np.delete(np.r_[1,1,1] * self.high, axis)
  537. if self.dog:
  538. out = dogfilter(out, [0, 0, 0, *low], [0, 0, 0, *high], mode="reflect")
  539. elif not np.all(np.array(self.low) == 0):
  540. out = cupyx.scipy.ndimage.gaussian_filter(out, [0, 0, 0, *low], mode="reflect", truncate=5.0)
  541. if self.normalize > 0:
  542. out /= cp.sqrt(cp.sum(out**2, axis=(-2, -1), keepdims=True)) ** self.normalize + 1e-9
  543. return out
  544. class Smoother(BaseModel):
  545. """Smooth blocks with a Gaussian kernel
  546. Args:
  547. sigmas (list): [sigma0, sigma1, sigma2]. If None, no smoothing is applied.
  548. truncate (float): truncate parameter for gaussian kernel. Default is 5.
  549. shear (float): shear parameter for gaussian kernel. Default is None.
  550. long_range_ratio (float): long range ratio for double gaussian kernel. Default is None.
  551. """
  552. sigmas: Union[float, List[float]] = [1.0, 1.0, 1.0]
  553. shear: Union[float, None] = None
  554. long_range_ratio: Union[float, None] = 0.05
  555. def __call__(self, xcorr_proj, block_size=None):
  556. """Apply a Gaussian filter to the cross-correlation data
  557. Args:
  558. xcorr_proj (cupy.array): cross-correlation data (5D array, with the first 3 dimensions being the blocks and the last 2 dimensions being the 2D projection)
  559. block_size (list): shape of blocks, whose rigid displacement is estimated
  560. Returns:
  561. cupy.array: smoothed cross-correlation volume
  562. """
  563. truncate = 4.0
  564. if self.sigmas is None:
  565. return xcorr_proj
  566. if self.shear is not None:
  567. shear_blocks = self.shear * (block_size[1] / block_size[0])
  568. gw = gausskernel_sheared(self.sigma[:2], shear_blocks, truncate=truncate)
  569. gw = cp.array(gw[:, :, None, None, None])
  570. xcorr_proj = cupyx.scipy.ndimage.convolve(xcorr_proj, gw, mode="constant")
  571. xcorr_proj = cupyx.scipy.ndimage.gaussian_filter1d(
  572. xcorr_proj, self.sigmas[2], axis=2, mode="constant", truncate=truncate
  573. )
  574. else: # shear is None:
  575. xcorr_proj = cupyx.scipy.ndimage.gaussian_filter(
  576. xcorr_proj, [*self.sigmas, 0, 0], mode="constant", truncate=truncate
  577. )
  578. if self.long_range_ratio is not None:
  579. xcorr_proj *= 1 - self.long_range_ratio
  580. xcorr_proj += (
  581. cupyx.scipy.ndimage.gaussian_filter(
  582. xcorr_proj, [*np.array(self.sigmas) * 5, 0, 0], mode="constant", truncate=truncate
  583. )
  584. * self.long_range_ratio
  585. )
  586. return xcorr_proj
  587. class RegFilter(BaseModel):
  588. """A class to apply a filter to the volume before registration
  589. Parameters:
  590. clip_thresh: threshold for clipping the reference volume. Default is 0
  591. dog: if True, apply a DoG filter to the volume. Default is True
  592. low: the lower sigma value for the DoG filter. Default is 0.5
  593. high: the higher sigma value for the DoG filter. Default is 10.0
  594. """
  595. clip_thresh: float = 0
  596. dog: bool = True
  597. low: float = 0.5
  598. high: float = 10.0
  599. soft_edge: Union[Union[int, float], List[Union[int, float]]] = 0.0
  600. def __call__(self, vol, reg_mask=None):
  601. """Apply the filter to the volume
  602. Args:
  603. vol (cupy or numpy array): 3D volume to be filtered
  604. reg_mask (array): Mask for registration
  605. Returns:
  606. cupy.ndarray: Filtered volume
  607. """
  608. vol = cp.clip(cp.array(vol, "float32", copy=False) - self.clip_thresh, 0, None)
  609. if np.any(np.array(self.soft_edge) > 0):
  610. vol = soften_edges(vol, soft_edge=self.soft_edge, copy=False)
  611. if reg_mask is not None:
  612. vol *= cp.array(reg_mask, dtype="float32", copy=False)
  613. if self.dog:
  614. vol = dogfilter(vol, self.low, self.high, mode="reflect")
  615. return vol
  616. class LevelConfig(BaseModel):
  617. """Configuration for each level of the registration pyramid
  618. Args:
  619. block_size (list): shape of blocks, whose rigid displacement is estimated
  620. block_stride (list): stride (usually identical to block_size)
  621. repeats (int): number of iterations for this level (deisable level by setting repeats to 0)
  622. smooth (Smoother or None): Smoother object
  623. project (Projector, callable or None): Projector object. The callable should take a volume block and an axis as input and return a projected volume block.
  624. tukey_ref (float): if not None, apply a Tukey window to the reference volume (alpha = tukey_ref). Default is 0.5
  625. affine (bool): if True, apply affine transformation to the displacement field
  626. median_filter (bool): if True, apply median filter to the displacement field
  627. update_rate (float): update rate for the displacement field. Default is 1.0. Can be lowered to dampen oscillations.
  628. """
  629. block_size: Union[List[int]]
  630. block_stride: Union[List[int], float] = 1.0
  631. project: Union[Projector, Callable[[_ArrayType, int], _ArrayType]] = Projector()
  632. tukey_ref: Union[float, None] = 0.5
  633. smooth: Union[Smoother, None] = Smoother()
  634. affine: bool = False
  635. median_filter: bool = True
  636. update_rate: float = 1.0
  637. repeats: int = 5
  638. class Recipe(BaseModel):
  639. """Configuration for the registration recipe. Recipe is initialized with a single affine level.
  640. Args:
  641. reg_filter (RegFilter, callable or None): Filter to be applied to the reference volume
  642. levels (list): List of LevelConfig objects
  643. """
  644. pre_filter: Union[RegFilter, Callable[[_ArrayType], _ArrayType], None] = RegFilter()
  645. levels: List[LevelConfig] = [
  646. LevelConfig(block_size=[-1, -1, -1], repeats=3), # translation level
  647. LevelConfig( # affine level
  648. block_size=[-2, -2, -2],
  649. block_stride=0.5,
  650. repeats=10,
  651. affine=True,
  652. median_filter=False,
  653. smooth=Smoother(sigmas=[0.5, 0.5, 0.5]),
  654. ),
  655. ]
  656. def add_level(self, block_size, **kwargs):
  657. """Add a level to the registration recipe
  658. Args:
  659. block_size (list): shape of blocks, whose rigid displacement is estimated
  660. **kwargs: additional arguments for LevelConfig
  661. """
  662. if isinstance(block_size, (int, float)):
  663. block_size = [block_size] * 3
  664. if len(block_size) != 3:
  665. raise ValueError("block_size must be a list of 3 integers")
  666. self.levels.append(LevelConfig(block_size=block_size, **kwargs))
  667. def insert_level(self, index, block_size, **kwargs):
  668. """Insert a level to the registration recipe
  669. Args:
  670. index (int): A number specifying in which position to insert the level
  671. block_size (list): shape of blocks, whose rigid displacement is estimated
  672. **kwargs: additional arguments for LevelConfig
  673. """
  674. if isinstance(block_size, (int, float)):
  675. block_size = [block_size] * 3
  676. if len(block_size) != 3:
  677. raise ValueError("block_size must be a list of 3 integers")
  678. self.levels.insert(index, LevelConfig(block_size=block_size, **kwargs))
  679. @classmethod
  680. def from_yaml(cls, yaml_path):
  681. """Load a recipe from a YAML file
  682. Args:
  683. yaml_path (str): path to the YAML file
  684. Returns:
  685. Recipe: Recipe object
  686. """
  687. import yaml
  688. this_file_dir = pathlib.Path(__file__).resolve().parent
  689. if os.path.isfile(yaml_path):
  690. yaml_path = yaml_path
  691. else:
  692. yaml_path = os.path.join(this_file_dir, "recipes", yaml_path)
  693. with open(yaml_path, "r") as f:
  694. data = yaml.safe_load(f)
  695. return cls.model_validate(data)
  696. def to_yaml(self, yaml_path):
  697. """Save the recipe to a YAML file
  698. Args:
  699. yaml_path (str): path to the YAML file
  700. """
  701. import yaml
  702. with open(yaml_path, "w") as f:
  703. yaml.dump(self.model_dump(), f)
  704. print(f"Recipe saved to {yaml_path}")

register.py at commit 338810a, under MIT · at the source

Overview

Authors: Jörg Henninger1, Maximilian Hoffmann1, Mykola Kadobianskyi1, Johannes Veith1, Caroline Berlage1, Antonia Groneberg1, Daniil Markov1, Lisanne Schulze1, Ana Svanidze1, Leonard Maler2, Benjamin Judkewitz1
  1. Charité Universitätsmedizin Berlin, Germany
  2. University of Ottawa, Canada
Dates: published online 4 March 2026
Type: Preprint
License: CC BY-NC-ND
Identifiers: DOI 10.64898/2026.03.04.709502 · OpenAlex W7133857928
Open access: green, a free copy (OpenAlex)
Status: code verified
Categories: systems (subfield)
Methods: Spectral & time-frequency, Connectivity, Statistics, Preprocessing, Evoked potentials, fMRI & imaging, Single-unit activity, calcium imaging
Keywords: Social communication, brain-wide imaging, Danionella
Topic: Marine animal studies overview (Ecology, Environmental Science), according to OpenAlex
Funding: European Research Council (101043615)
Citations: not cited yet (Europe PMC); 51 references in the paper

Abstract

The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.

Repositories

Its files are read in the Code ↔ Paper reader above, with 2 matches between paragraphs and lines of code.

danionella/opm_unshear

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 81dcebb1985686bdf05b3245313583110d64aba2, 8 May 2025
Languages: Python (5)
Size: 17 files, 5 scripts
Software Heritage: not archived
Found in: the text, “Image postprocessing”
Holds: README, license file, environment (environment.yml, pyproject.toml), tests, continuous integration
Not found: CITATION.cff, documentation
Tools: NumPy (5 files), CuPy (3 files), h5py (1 file), Numba (1 file), SciPy (1 file), tifffile (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
7 files

danionella/warpfield

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: 338810a602f6d4142495d3e5bd71741d1fd7f4d6, 26 August 2025
Languages: Python (7), Jupyter (1)
Size: 21 files, 8 scripts
Software Heritage: not archived
Found in: the text, “Image postprocessing”
Holds: README, license file, environment (environment.yml, pyproject.toml), tests, continuous integration, 1 notebook
Not found: CITATION.cff, documentation
Tools: NumPy (6 files), CuPy (5 files), h5py (4 files), SciPy (3 files), NiBabel (2 files), tifffile (2 files), ANTs (1 file), imageio (1 file), Matplotlib (1 file), pydicom (1 file), scikit-image (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
10 files

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 13 scripts, each with its path and the digest of its content;
  • 2 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

No dataset and no data link were found in the paper.

Data availability statement

The paper has a data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:

  • it says that the data are available on request

Read it in the paper: doi.org/10.64898/2026.03.04.709502.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 30 September 2026: the first record

Recorded: type, journal, dates, 11 authors, 3 keywords, 1 funder, 45 references.

Cite

This paper

Henninger, J., Hoffmann, M., Kadobianskyi, M., Veith, J., Berlage, C., Groneberg, A., Markov, D., Schulze, L., Svanidze, A., Maler, L., & Judkewitz, B. (2026). Distributed hierarchical filters partition the Danionella vocal repertoire. bioRxiv (preprint). https://doi.org/10.64898/2026.03.04.709502

BibTeX

@article{henninger2026distributed,
author = {Henninger, Jörg and Hoffmann, Maximilian and Kadobianskyi, Mykola and Veith, Johannes and Berlage, Caroline and Groneberg, Antonia and Markov, Daniil and Schulze, Lisanne and Svanidze, Ana and Maler, Leonard and Judkewitz, Benjamin},
title = {{Distributed hierarchical filters partition the Danionella vocal repertoire}},
journal = {bioRxiv (preprint)},
year = {2026},
month = mar,
publisher = {bioRxiv},
issn = {2692-8205},
doi = {10.64898/2026.03.04.709502},
url = {https://doi.org/10.64898/2026.03.04.709502}
}

RIS

TY - JOUR
AU - Henninger, Jörg
AU - Hoffmann, Maximilian
AU - Kadobianskyi, Mykola
AU - Veith, Johannes
AU - Berlage, Caroline
AU - Groneberg, Antonia
AU - Markov, Daniil
AU - Schulze, Lisanne
AU - Svanidze, Ana
AU - Maler, Leonard
AU - Judkewitz, Benjamin
TI - Distributed hierarchical filters partition the Danionella vocal repertoire
T2 - bioRxiv (preprint)
J2 - bioRxiv
PY - 2026
DA - 2026/03/04
SN - 2692-8205
PB - bioRxiv
DO - 10.64898/2026.03.04.709502
UR - https://doi.org/10.64898/2026.03.04.709502
ER -

CSL-JSON

{
"id": "10.64898/2026.03.04.709502",
"type": "article",
"title": "Distributed hierarchical filters partition the Danionella vocal repertoire",
"container-title": "bioRxiv (preprint)",
"author": [
{
"family": "Henninger",
"given": "Jörg"
},
{
"family": "Hoffmann",
"given": "Maximilian"
},
{
"family": "Kadobianskyi",
"given": "Mykola"
},
{
"family": "Veith",
"given": "Johannes"
},
{
"family": "Berlage",
"given": "Caroline"
},
{
"family": "Groneberg",
"given": "Antonia"
},
{
"family": "Markov",
"given": "Daniil"
},
{
"family": "Schulze",
"given": "Lisanne"
},
{
"family": "Svanidze",
"given": "Ana"
},
{
"family": "Maler",
"given": "Leonard"
},
{
"family": "Judkewitz",
"given": "Benjamin"
}
],
"container-title-short": "bioRxiv",
"DOI": "10.64898/2026.03.04.709502",
"ISSN": "2692-8205",
"publisher": "bioRxiv",
"URL": "https://doi.org/10.64898/2026.03.04.709502",
"issued": {
"date-parts": [
[
2026,
3,
4
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.64898/2026.03.09.710483 [code]
Multimodal reference brain atlas of adult <i>Danionella cerebrum</i>
Journal: bioRxiv (preprint)
In common: ANTs, SciPy, Matplotlib, 1 other tool, 3 references, 2 authors
[2] doi:10.7554/elife.100880 [code]
An applicable and efficient retrograde monosynaptic circuit mapping tool for larval zebrafish.
Journal: eLife
In common: tifffile, Numba, scikit-image, 5 other tools, systems, 1 reference
[3] doi:10.1038/s41467-026-73373-w [code]
Mapping neuro-vascular unit communications reveals distinct angiogenic programs across developing mouse brain regions.
Journal: Nature communications
In common: imageio, tifffile, ANTs, 6 other tools
[4] doi:10.1038/s41598-026-57519-w [code]
Automated segmentation of neurons and spinal cord structures in immunofluorescence images using SpineDL.
Journal: Scientific reports
In common: imageio, tifffile, Numba, 6 other tools
[5] doi:10.1371/journal.pcbi.1014555 [code]
Body surface potential driven personalisation of electrophysiological digital twins in hypertrophic cardiomyopathy.
Journal: PLoS computational biology
In common: CuPy, pydicom, ANTs, 5 other tools
[6] doi:10.7554/elife.109717 [code]
Retrosplenial cortex enables context-dependent goal-directed sensorimotor transformation.
Journal: eLife
In common: imageio, tifffile, Numba, 5 other tools, systems
[7] doi:10.1002/mrm.70496 [code]
A Deep Nonlinear Subspace Modeling and Reconstruction for Diffusion-Weighted Imaging Using Denoising Auto-Encoder.
Journal: Magnetic resonance in medicine
In common: CuPy, pydicom, Numba, 5 other tools
[8] doi:10.1162/imag.a.1276 [code]
High-resolution whole-brain magnetic resonance spectroscopic imaging in youth at risk for psychosis.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: CuPy, ANTs, Numba, 5 other tools
[9] doi: [code]
Real-time closed-loop feedback system for mouse mesoscale cortical signal and movement control
Journal: eLife
In common: imageio, tifffile, Numba, 5 other tools
[10] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: imageio, tifffile, Numba, 5 other tools

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.