Skip to content

Add VoxelHeadModel for voxel-based DOT image reconstruction#155

Open
harmening wants to merge 8 commits intodevfrom
voxelhm
Open

Add VoxelHeadModel for voxel-based DOT image reconstruction#155
harmening wants to merge 8 commits intodevfrom
voxelhm

Conversation

@harmening
Copy link
Copy Markdown
Contributor

This PR is an updated redesign of the earlier prototype (OneSurfaceFewVoxelsHeadModel) for the new release with its current cedalion.dot layout. For reference, see the old PR#38.

Summary

Adds a new VoxelHeadModel class alongside TwoSurfaceHeadModel so DOT/fNIRS image reconstruction can target a reduced set of brain voxels directly, preserving depth information that surface-based reconstruction discards. The class mirrors the public API of TwoSurfaceHeadModel, so it drops into ForwardModel, ImageRecon, and the rest of the cedalion.dot pipeline without changes to consumers.

Motivation

  • TwoSurfaceHeadModel projects voxel-space fluence onto the cortical mesh, restricting reconstruction to surface vertices and discarding depth.
  • As fNIRS/DOT montages grow denser and reach deeper, surface-only reconstruction may become a limitation for HD/UHD probes.
  • Voxel-based reconstruction keeps depth information and can be a better fit for special purposes.

What's new

Class

  • cedalion.dot.VoxelHeadModel (src/cedalion/dot/voxel_head_model.py) — a dataclass with the same shape as TwoSurfaceHeadModel except:
    • brain is a cdc.Voxels cloud rather than a cdc.Surface mesh.
    • A new brain_mask: np.ndarray (3D bool over the segmentation grid) lets reducers be applied repeatedly while preserving the link to the original grid.
    • A voxel_to_voxel_brain property alias is provided for user code that prefers the more honest name (see Naming below).

Standard atlases

  • dot.get_standard_headmodel(model, kind="surface" | "voxel")kind="voxel" builds a VoxelHeadModel from the same Colin27 / ICBM152 segmentation files used by the surface variant. Default kind="surface" preserves the existing call signature.

Reducers (three layered, each returns a new instance)

How to reduce all brain voxels to those that are needed in the end?

  • reduce_voxels_to_probe(geo3d, max_dist) — drop voxels far from any optode. Pre-MCX, geometric.
  • reduce_voxels_by_fluence(fluence_fname, rel_threshold) — NeuroDOT-style mask (Make_Good_Vox.m:41). Per-voxel Σ_optodes |fluence| (max over wavelengths); drop voxels below rel_threshold × max. Runs between MCX and compute_sensitivity so the sensitivity matrix is small from the start.
  • reduce_voxels_to_sensitivity(Adot, sensitivity_threshold) — post-Adot absolute-threshold cleanup.

Helpers

  • cedalion.dot.utils.reduce_and_map_brain_voxels(...) — porting of the old utility into the current dot layout, with cdt.QLength-typed max_dist.
  • cedalion.dot.head_model.align_and_snap_to_scalp(...) and snap_to_scalp_voxels(...) — extracted to module-level so both head-model classes reuse the same implementation.

Backwards compatibility

  • TwoSurfaceHeadModel is unchanged behaviourally; its align_and_snap_to_scalp / snap_to_scalp_voxels methods now delegate to the new module-level helpers.
  • ForwardModel.compute_sensitivity is unchanged. The duck-typed attribute names (brain.nvertices, voxel_to_vertex_brain, voxel_to_vertex_scalp) match both head models.
  • ImageRecon and the GaussianSpatialBasisFunctions family operate on point clouds via KDTree (no mesh connectivity / geodesic distance), so they work on cdc.Voxels.vertices unchanged. One defensive guard was added at image_recon.py:820 (getattr(brain, "vertex_coords", {})) so the parcel-aware SBF branch does not crash when the brain has no vertex_coords attribute. Surface behaviour is unchanged — Surface always exposes that attribute, so the getattr returns the same dict the old code accessed directly.
  • get_standard_headmodel adds an optional kind kwarg with a back-compatible default.

Implementation notes

  • Naming. The sparse projection is still called voxel_to_vertex_brain and Adot's vertex axis still indexes the kept voxels. This is a deliberate misnomer: keeping the names lets compute_sensitivity, compute_stacked_sensitivity, plotting, and BIDS I/O all work without forks. The class docstring defines "vertex" here as "the generalized row index of the brain target representation". A voxel_to_voxel_brain property alias gives user code a clearer name. A future refactor that renames the dim everywhere (e.g. target) is a larger separate PR.
  • No from_surfaces constructor. A VoxelHeadModel has no brain mesh to consume. The only realistic external-mesh use case (re-using an atlas's precomputed scalp mesh) is covered by an optional scalp_surface_file kwarg on from_segmentation.
  • get_brain_mni152_coords raises NotImplementedError for now; voxels do not carry per-vertex MNI coords. Users can call head.brain.apply_transform(t_ijk2mni) to get MNI positions.

Test plan

  • python -m pytest tests/test_dot_voxel_head_model.py -v — 8 new tests cover smoke build, save/load round-trip, apply_transform round-trip, all three reducers, the kind="voxel" atlas loader, and the alias property.
  • python -m pytest tests/test_dot_forward_model.py tests/test_dot_image_recon.py -v — 74 existing dot tests continue to pass (regression check on the surface path).
  • ruff check src/cedalion/dot/ tests/test_dot_voxel_head_model.py — clean.

Example jupter notebook

  • examples/head_models/51_voxel_head_model.ipynb — runs end-to-end on Colin27 with the precomputed fingertappingDOT fluence file: ~1.6M brain voxels → 921k after probe reduction → 501k after fluence reduction → 238k after sensitivity reduction; final ImageRecon.reconstruct returns dims (time, chromo, vertex) with the expected vertex count.

Files

New

  • src/cedalion/dot/voxel_head_model.py
  • tests/test_dot_voxel_head_model.py
  • examples/head_models/51_voxel_head_model.ipynb

Modified

  • src/cedalion/dot/__init__.py — re-export VoxelHeadModel.
  • src/cedalion/dot/utils.py — add reduce_and_map_brain_voxels.
  • src/cedalion/dot/head_model.py — extract two scalp helpers to module level; extend get_standard_headmodel with kind="voxel".
  • src/cedalion/dot/forward_model.py — widen the head-model type hint in ForwardModel.__init__.
  • src/cedalion/dot/image_recon.pygetattr(brain, "vertex_coords", {}) guard so the parcel-aware SBF branch tolerates voxel brains.

@harmening harmening requested review from avolu and emiddell May 2, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant