OSCR

Surface-based image reconstruction optimization for high-density functional near-infrared spectroscopy.

Code ↔ Paper

23 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 23 matches
  1. [1] § Methods › Simulation Study › Regularization parameter optimization › Image metrics ↔ augmented_simulation/FIG5_STEP2_get_single_wavelength_image_metrics.py, lines 1–86 · score 0.88 · full width, ground truth, noise ratio, scalp crosstalk, optical density, localization error
  2. [2] § Methods › Simulation Study › Parameter selection validation using an augmented dataset › Data augmentation ↔ src/cedalion/math/ar_irls.py, lines 11–89 · score 0.86 · serially correlated, Legendre polynomials, autoregressive iterative, design matrix, whitening, residuals
  3. [3] § Methods › Simulation Study › Parameter selection validation using an augmented dataset › Data augmentation ↔ modules/processing_func.py, lines 183–315 · score 0.85 · Legendre polynomials, general linear model, separation regression, design matrix, squares, onset
  4. [4] § Methods › Simulation Study › Regularization parameter optimization › Image metrics ↔ augmented_simulation/batch_codes/single_wl_aug/single_wl_metrics_batch_aug.py, lines 1–79 · score 0.84 · full width, quality metrics, noise ratio, scalp crosstalk, optical density, localization error
  5. [5] § Methods › Ball-Squeezing Task › Data processing ↔ modules/processing_func.py, lines 183–315 · score 0.77 · Legendre polynomials, general linear model, separation regression, onset, drift, GLM
  6. [6] § Methods › Diffuse Optical Tomography Theory ↔ src/cedalion/dot/forward_model.py, lines 489–607 · score 0.73 · Monte Carlo, absorption changes, optical density, sensitivity matrix, head model, fluence
  7. [7] § Results › Simulation Study › Regularization parameter selection › Image metrics—single wavelength ↔ augmented_simulation/batch_codes/single_wl_aug/single_wl_metrics_batch_aug.py, lines 1–79 · score 0.73 · Full width, quality metrics, noise ratio, Contrast ratio, Localization error, seed vertex
  8. [8] § Results › Simulation Study › Regularization parameter selection › Image metrics—dual wavelength ↔ augmented_simulation/FIG6_generate_figure.py, lines 1–75 · score 0.71 · image reconstruction quality, indirect reconstruction, scalp crosstalk, contrast ratio, localization error, FWHM
  9. [9] § Methods › Diffuse Optical Tomography Theory › Spatial basis functions ↔ src/cedalion/dot/image_recon.py, lines 134–183 · score 0.65 · ill posed, Gaussian kernels, inverse problem, surface, matrix, model
  10. [10] § Methods › Simulation Study › Parameter selection validation using an augmented dataset › Image metrics ↔ augmented_simulation/FIG5_STEP2_get_single_wavelength_image_metrics.py, lines 1–86 · score 0.63 · ground truth activation, image metrics, localization error, seed vertex, augmentation, FWHM
  11. [11] § Methods › Diffuse Optical Tomography Theory ↔ examples/head_models/40_image_reconstruction.ipynb, lines 14–56 · score 0.62 · Monte Carlo, head model, optical density, fluence, sensitivity matrix, photons
  12. [12] § Methods › Simulation Study ↔ src/cedalion/dot/forward_model.py, lines 489–607 · score 0.60 · Monte Carlo, absorption coefficients, head model, volume, optode, sensitivity
  13. [13] § Methods › Simulation Study › Regularization parameter optimization › Image metrics ↔ modules/get_image_metrics.py, lines 340–417 · score 0.60 · noise free reconstructed, noise free image, maximum amplitude, single wavelength, ROI, metrics
  14. [14] § Methods › Diffuse Optical Tomography Theory › Spatial basis functions ↔ modules/spatial_basis_func.py, lines 225–287 · score 0.59 · spatial basis matrix, spatial resolution, standard deviation, smoothness, distance, kernels
  15. [15] § Methods › Diffuse Optical Tomography Theory ↔ src/cedalion/dot/image_recon.py, lines 964–998 · score 0.57 · hemoglobin concentration, directly reconstructed, Tomography, Diffuse, optical, wavelengths
  16. [16] § Methods › Simulation Study › Parameter selection validation using an augmented dataset › Image metrics ↔ augmented_simulation/batch_codes/dual_wl_aug/dual_wl_metrics_batch_aug.py, lines 1–81 · score 0.56 · dual wavelength, optical density, seed vertex, standard deviation, forward model, blob
  17. [17] § Results › Simulation Study › Parameter selection validation using an augmented dataset › Image metrics—dual wavelength ↔ augmented_simulation/FIG6_generate_figure.py, lines 1–75 · score 0.56 · image reconstruction quality, contrast ratio, localization error, FWHM, dual, CNR
  18. [18] § Results › Simulation Study › Regularization parameter selection › Image metrics—single wavelength ↔ modules/get_image_metrics.py, lines 120–185 · score 0.56 · spatial resolution, maximum amplitude, reconstructed image, FWHM, blob, metrics
  19. [19] § Methods › Simulation Study › Regularization parameter optimization › Image metrics ↔ augmented_simulation/batch_codes/dual_wl_aug/dual_wl_metrics_batch_aug.py, lines 1–81 · score 0.55 · dual wavelength, optical density, seed vertex, standard deviation, forward model, blob
  20. [20] § Methods › Diffuse Optical Tomography Theory ↔ src/cedalion/dot/image_recon.py, lines 134–183 · score 0.54 · ill posed, inverse problem, image reconstruction, dimensions
  21. [21] § Results › Ball-Squeezing Task ↔ ball_squeezing_analysis/FIG7_ballsqueezing_timeseries_plot.py, lines 1–74 · score 0.51 · magnitude images, ball squeeze, standard error, ROI, timeseries, Figure 7
  22. [22] § Methods › Simulation Study › Parameter selection validation using an augmented dataset › Data augmentation ↔ augmented_simulation/FIG5&6_STEP1_get_measurement_variance.py, lines 1–57 · score 0.51 · boxcar, convolved, gamma, synthetic, augmenting, stimulus
  23. [23] § Methods › Simulation Study › Parameter selection validation using an augmented dataset › Data augmentation ↔ augmented_simulation/batch_codes/augmentation/get_measurement_variance_per_subject.py, lines 1–57 · score 0.50 · boxcar, convolved, gamma, synthetic, augmenting, stimulus

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 · 1,813 lines · 61 KB · MIT · 3 matches

  1. """Solver for the image reconstruction problem."""
  2. import hashlib
  3. import logging
  4. from abc import ABC, abstractmethod
  5. from pathlib import Path
  6. from typing import Literal
  7. import h5py
  8. import numpy as np
  9. import pint
  10. import scipy.stats
  11. import xarray as xr
  12. from scipy.sparse import csr_array
  13. from scipy.spatial import KDTree
  14. from tqdm import tqdm
  15. import cedalion.dot.forward_model as fwm
  16. import cedalion.io.utils as ioutils
  17. import cedalion.typing as cdt
  18. import cedalion.utils
  19. import cedalion.xrutils as xrutils
  20. from cedalion import cite, nirs, units
  21. from cedalion.dot.head_model import TwoSurfaceHeadModel
  22. logger = logging.getLogger("cedalion")
  23. ReconMode = Literal["conc", "mua", "mua2conc"]
  24. # predefined parameter sets
  25. # we could define constants of parameters that work well together, e.g. based on
  26. # Laura's parameter scans:
  27. REG_TIKHONOV_ONLY = dict(
  28. alpha_meas=0.001,
  29. alpha_spatial=None,
  30. apply_c_meas=False,
  31. )
  32. REG_PAPER_MUA_SBF = dict(
  33. alpha_meas=1e4,
  34. alpha_spatial=1e-2,
  35. apply_c_meas=True,
  36. lambda_R_conc=1e-6
  37. )
  38. """Optimal set of regularization parameters according to an optimization study for a
  39. ball squeezing dataset. :cite:t:`Carlton2026`.
  40. """
  41. SBF_GAUSSIANS_DENSE = dict(
  42. mask_threshold=-2,
  43. threshold_brain=1 * units.mm,
  44. threshold_scalp=5 * units.mm,
  45. sigma_brain=1 * units.mm,
  46. sigma_scalp=5 * units.mm,
  47. )
  48. """Optimal set of Gaussian SBF parameters according to an optimization study for a
  49. ball squeezing dataset. :cite:t:`Carlton2026`.
  50. """
  51. SBF_GAUSSIANS_SPARSE = dict(
  52. mask_threshold=-2,
  53. threshold_brain=5 * units.mm,
  54. threshold_scalp=20 * units.mm,
  55. sigma_brain=5 * units.mm,
  56. sigma_scalp=20 * units.mm,
  57. )
  58. """A sparse set of gaussians SBFs."""
  59. def estimate_alpha_meas(C_meas, K=0.01):
  60. """Implements a heuristic for choosing alpha_meas.
  61. The strength of the regularization is determined by the relative scale of the C and
  62. R regularization matrices, which is encoded in the ratio:
  63. K = median(eig( lambda_meas C )) / max(eig( lambda_R A R A'))
  64. In past analyses K was about 0.01 .. 0.1. With this a heuristic for choosing
  65. alpha_meas can be formed:
  66. alpha_meas = K / median(eig(C_meas))
  67. Args:
  68. C_meas: diagonal values of C_meas matrix
  69. K : relative scale of C and R regularization matrices
  70. """
  71. return K / np.median(C_meas)
  72. class SpatialBasisFunctions(ABC):
  73. """Base for SBF implementations."""
  74. @property
  75. @abstractmethod
  76. def H(self) -> xr.DataArray:
  77. """The sensitivity in kernel space."""
  78. pass
  79. @abstractmethod
  80. def kernel_to_image_space_conc(self, conc_img: xr.DataArray) -> xr.DataArray:
  81. """Transform an image from kernel to image space in recon. mode 'conc'."""
  82. pass
  83. @abstractmethod
  84. def kernel_to_image_space_mua(self, mua_img: xr.DataArray) -> xr.DataArray:
  85. """Transform an image from kernel to image space in recon. mode 'mua'."""
  86. @abstractmethod
  87. def to_file(self, fname : Path | str):
  88. """Serialize prepared spatial basis functions into a file.
  89. Args:
  90. fname: path of the output file.
  91. """
  92. pass
  93. @classmethod
  94. @abstractmethod
  95. def from_file(cls, fname : Path | str) -> "SpatialBasisFunctions":
  96. """Load prepared spatial basis functions from a file.
  97. Args:
  98. fname: path of the file to read from.
  99. Returns:
  100. SpatialBasisFunctions: Loaded spatial basis functions instance.
  101. """
  102. pass
  103. class OriginalGaussianSpatialBasisFunctions:
  104. def __init__(
  105. self,
  106. head_model: TwoSurfaceHeadModel,
  107. Adot: xr.DataArray,
  108. threshold_brain: cdt.QLength,
  109. threshold_scalp: cdt.QLength,
  110. sigma_brain: cdt.QLength,
  111. sigma_scalp: cdt.QLength,
  112. mask_threshold: float,
  113. ):
  114. """Gaussian Spatial Basis Functions for DOT image reconstruction.
  115. Represents the unknown absorption image as a weighted sum of Gaussian
  116. kernels placed on the head surface, reducing the ill-posed inverse problem
  117. to a lower-dimensional one. Optimal parameter sets for this implementation
  118. are given in :cite:t:`Carlton2026`.
  119. Args:
  120. head_model: TwoSurfaceHeadModel providing brain and scalp surfaces.
  121. Adot: Sensitivity matrix (channel × vertex × wavelength).
  122. threshold_brain: Maximum distance from a sensitivity vertex for a brain
  123. kernel centre to be included (log-scale units).
  124. threshold_scalp: Maximum distance from a sensitivity vertex for a scalp
  125. kernel centre to be included (log-scale units).
  126. sigma_brain: Spatial width of brain Gaussian kernels.
  127. sigma_scalp: Spatial width of scalp Gaussian kernels.
  128. mask_threshold: Log-sensitivity threshold; vertices below this value are
  129. excluded from the kernel support.
  130. """
  131. cite("Carlton2026")
  132. cedalion.utils.deprecated_api(
  133. "This implementation of gaussian basis functions will be replaced."
  134. )
  135. self.threshold_brain = threshold_brain
  136. self.threshold_scalp = threshold_scalp
  137. self.sigma_brain = sigma_brain
  138. self.sigma_scalp = sigma_scalp
  139. self.mask_threshold = mask_threshold
  140. self._mask : xr.DataArray = None # shape (vertex)
  141. self.G_brain : xr.DataArray = None # shape (vertex, kernel)
  142. self.G_scalp : xr.DataArray = None # shape (vertex, kernel)
  143. self.H: xr.DataArray = None # shape(channel, kernel, wavelength)
  144. # compute _G
  145. self._compute_sensitivity_mask(Adot)
  146. self._compute_G_gaussian_kernels(head_model)
  147. self._compute_H(Adot)
  148. def _compute_sensitivity_mask(self, Adot, wavelength_idx: int = 0):
  149. """Compute sensitivity mask based on intensity threshold.
  150. The mask selects those vertices whose summed contribution to the sensitivity
  151. of all channels is above 10^(mask_threshold).
  152. Args:
  153. Adot: Sensitivity matrix.
  154. wavelength_idx: Index of wavelength to use for mask computation.
  155. """
  156. intensity = np.log10(
  157. Adot.isel(wavelength=wavelength_idx)
  158. .sum("channel")
  159. .clip(min=10 ** (self.mask_threshold - 1)) # avoid log10(0)
  160. )
  161. mask = intensity > self.mask_threshold
  162. mask = mask.drop_vars("wavelength") # keep the is_brain coordinate
  163. self._mask = mask
  164. def _downsample_mesh(
  165. self, mesh: xr.DataArray, threshold: cdt.QLength, mask: np.ndarray
  166. ) -> xr.DataArray:
  167. """Downsample the mesh to get seeds of spatial bases.
  168. Args:
  169. mesh: vertices of either the brain or scalp surface.
  170. threshold: distance between vertices in downsampled mesh.
  171. mask: boolean mask to select mesh vertices
  172. Returns:
  173. downsampled mesh vertices as a xr.DataArray
  174. Initial Contributors:
  175. - Yuanyuan Gao
  176. - Laura Carlton | [email hidden] | 2024
  177. """
  178. # Downsample the mesh using the specified method
  179. mesh_units = mesh.pint.units
  180. threshold = threshold.to(mesh_units).magnitude
  181. mesh = mesh.rename({"label": "vertex"}).pint.dequantify()
  182. mesh_masked = mesh[mask, :]
  183. mesh_new = []
  184. for vv in tqdm(mesh_masked):
  185. if len(mesh_new) == 0:
  186. mesh_new.append(vv)
  187. tree = KDTree(mesh_new) # Build KDTree for the first point
  188. continue
  189. # Query the nearest neighbor within the threshold
  190. distance, _ = tree.query(vv, distance_upper_bound=threshold)
  191. # If no point is within the threshold, append the new point
  192. if distance == float("inf"):
  193. mesh_new.append(vv)
  194. tree = KDTree(mesh_new) # Rebuild the KDTree with the new point
  195. mesh_new_xr = xr.DataArray(
  196. mesh_new,
  197. dims=mesh.dims,
  198. coords={"vertex": np.arange(len(mesh_new))},
  199. attrs={"units": mesh_units},
  200. )
  201. mesh_new_xr = mesh_new_xr.pint.quantify()
  202. return mesh_new_xr
  203. def _get_gaussian_kernels_on_mesh(
  204. self, mesh_downsampled: xr.DataArray, mesh: xr.DataArray, sigma: cdt.QLength
  205. ):
  206. """Compute the matrix containing the spatial bases.
  207. Args:
  208. mesh_downsampled: vertices of either the downsampled brain or scalp surface.
  209. This is used to define the centers of the spatial bases.
  210. mesh: the original fully sampeld mesh vertices of the brain or scalp.
  211. sigma: standard deviation used for defining the Gaussian kernel.
  212. Returns:
  213. xr.DataArray: matrix containing the spatial bases
  214. Initial Contributors:
  215. - Yuanyuan Gao
  216. - Laura Carlton | [email hidden] | 2024
  217. """
  218. # Create Gaussian kernels based on the mesh and parameters
  219. assert mesh.pint.units == mesh_downsampled.pint.units
  220. mesh_units = mesh.pint.units
  221. sigma = sigma.to(mesh_units).magnitude
  222. # Covariance matrix
  223. cov_matrix = (sigma**2) * np.eye(3)
  224. inv_cov = np.linalg.inv(cov_matrix) # Inverse of Cov_matrix
  225. det_cov = np.linalg.det(cov_matrix) # Determinant of Cov_matrix
  226. denominator = np.sqrt((2 * np.pi) ** 3 * det_cov) # Pre-calculate denominator
  227. mesh_downsampled = mesh_downsampled.pint.dequantify().values
  228. mesh = mesh.pint.dequantify().values
  229. diffs = mesh_downsampled[:, None, :] - mesh[None, :, :]
  230. # Efficient matrix multiplication using np.einsum to compute
  231. # (x-mu)' * inv_cov * (x-mu) for all pairs
  232. exponents = -0.5 * np.einsum("ijk,kl,ijl->ij", diffs, inv_cov, diffs)
  233. # Compute the kernel matrix
  234. kernel_matrix = np.exp(exponents) / denominator
  235. n_vertex = mesh.shape[0]
  236. dimensions = kernel_matrix.shape
  237. if dimensions[0] != n_vertex:
  238. dims = ["kernel", "vertex"]
  239. #n_kernel = dimensions[0]
  240. else:
  241. dims = ["vertex", "kernel"]
  242. #n_kernel = dimensions[1]
  243. kernel_matrix_xr = xr.DataArray(
  244. kernel_matrix,
  245. dims=dims,
  246. #coords={"vertex": np.arange(n_vertex), "kernel": np.arange(n_kernel)},
  247. )
  248. kernel_matrix_xr = kernel_matrix_xr.transpose("vertex", "kernel")
  249. return kernel_matrix_xr
  250. def _compute_G_gaussian_kernels(self, head_model : TwoSurfaceHeadModel):
  251. """Compute the G matrix which contains all the information of the spatial basis.
  252. Args:
  253. head_model: Head model with brain and scalp surfaces.
  254. Initial Contributors:
  255. - Yuanyuan Gao
  256. - Laura Carlton | [email hidden] | 2024
  257. """
  258. brain_downsampled = self._downsample_mesh(
  259. head_model.brain.vertices,
  260. self.threshold_brain,
  261. self._mask.sel(vertex=self._mask.is_brain),
  262. )
  263. scalp_downsampled = self._downsample_mesh(
  264. head_model.scalp.vertices,
  265. self.threshold_scalp,
  266. self._mask.sel(vertex=~self._mask.is_brain),
  267. )
  268. self.G_brain = self._get_gaussian_kernels_on_mesh(
  269. brain_downsampled, head_model.brain.vertices, self.sigma_brain
  270. )
  271. self.G_scalp = self._get_gaussian_kernels_on_mesh(
  272. scalp_downsampled, head_model.scalp.vertices, self.sigma_scalp
  273. )
  274. vertices = np.arange(head_model.brain.nvertices + head_model.scalp.nvertices)
  275. kernel = np.arange(self.G_brain.sizes["kernel"] + self.G_scalp.sizes["kernel"])
  276. self.G_brain = self.G_brain.assign_coords(
  277. {
  278. "vertex": vertices[: head_model.brain.nvertices],
  279. "kernel": kernel[: self.G_brain.sizes["kernel"]],
  280. }
  281. )
  282. self.G_scalp = self.G_scalp.assign_coords(
  283. {
  284. "vertex": vertices[head_model.brain.nvertices:],
  285. "kernel": kernel[self.G_brain.sizes["kernel"]:],
  286. }
  287. )
  288. def _compute_H(self, Adot : xr.DataArray):
  289. """Compute the H matrix for spatial basis functions.
  290. Transforms the sensitivity matrix into the spatial basis space.
  291. Args:
  292. Adot: Sensitivity matrix shape=(channel, vertex, wavelength)
  293. """
  294. assert Adot.dims == ("channel", "vertex", "wavelength")
  295. n_channel = len(Adot.channel)
  296. n_wavelength = len(Adot.wavelength)
  297. # number of kernels
  298. n_k_brain = self.G_brain.sizes["kernel"]
  299. n_k = self.G_brain.sizes["kernel"] + self.G_scalp.sizes["kernel"]
  300. H = np.zeros((n_channel, n_k, n_wavelength))
  301. for w in range(n_wavelength):
  302. Adot_w = Adot.isel(wavelength=w).values
  303. H[:, :n_k_brain, w] = Adot_w[:, Adot.is_brain] @ self.G_brain.values
  304. H[:, n_k_brain:, w] = Adot_w[:, ~Adot.is_brain] @ self.G_scalp.values
  305. is_brain = np.ones(n_k, dtype=np.bool_)
  306. is_brain[n_k_brain:] = False
  307. H = xr.DataArray(H, dims=("channel", "kernel", "wavelength"))
  308. H = H.assign_coords(
  309. {
  310. "channel": Adot.channel,
  311. "wavelength": Adot.wavelength,
  312. "kernel": np.concatenate(
  313. [self.G_brain.kernel.values, self.G_scalp.kernel.values]
  314. ),
  315. "is_brain": ("kernel", is_brain),
  316. }
  317. )
  318. self.H = H
  319. def kernel_to_image_space_mua(self, X : np.ndarray) -> np.ndarray:
  320. """Convert kernel space reconstructions to image space for mua.
  321. Args:
  322. X: Reconstruction values in kernel space. shape (kernel, ...)
  323. Returns:
  324. np.ndarray: Reconstruction values in image space.
  325. """
  326. nkernels_brain = self.G_brain.sizes["kernel"]
  327. has_scalp = X.sizes["kernel"] > nkernels_brain
  328. sb_X_brain = X[{"kernel":slice(0,nkernels_brain)}]
  329. X_brain = xrutils.contract(self.G_brain, sb_X_brain, dim="kernel")
  330. if has_scalp:
  331. sb_X_scalp = X[{"kernel": slice(nkernels_brain, None)}]
  332. X_scalp = xrutils.contract(self.G_scalp, sb_X_scalp, dim="kernel")
  333. if has_scalp:
  334. is_brain = np.zeros(
  335. X_brain.sizes["vertex"] + X_scalp.sizes["vertex"], dtype=bool
  336. )
  337. is_brain[:X_brain.sizes["vertex"]] = True
  338. X = xr.concat([X_brain, X_scalp], dim="vertex").assign_coords(
  339. {"is_brain": ("vertex", is_brain)}
  340. )
  341. else:
  342. is_brain = np.ones(X_brain.sizes["vertex"], dtype=bool)
  343. X = X_brain.assign_coords({"is_brain": ("vertex", is_brain)})
  344. return X
  345. def kernel_to_image_space_conc(self, X) -> np.ndarray:
  346. """Convert kernel space reconstructions to image space for concentration.
  347. Args:
  348. X: Reconstruction values in kernel space.
  349. Returns:
  350. np.ndarray: Reconstruction values in image space with HbO/HbR split.
  351. """
  352. assert "flat_kernel" in X.dims
  353. X = xrutils.unstack(X, "flat_kernel", ("chromo", "kernel"))
  354. # FIXME limited to two chromophores
  355. #split = X.shape[0]//2
  356. nkernels_brain = self.G_brain.sizes["kernel"]
  357. has_scalp = X.sizes["kernel"] > nkernels_brain
  358. sb_X_brain_hbo = X[{"kernel":slice(0,nkernels_brain)}].loc[{"chromo" : "HbO"}]
  359. sb_X_brain_hbr = X[{"kernel":slice(0,nkernels_brain)}].loc[{"chromo" : "HbR"}]
  360. X_hbo_brain = xrutils.contract(self.G_brain, sb_X_brain_hbo, dim="kernel")
  361. X_hbr_brain = xrutils.contract(self.G_brain, sb_X_brain_hbr, dim="kernel")
  362. X_brain = xr.concat(
  363. [X_hbo_brain, X_hbr_brain], dim="chromo", coords={"chromo": ["HbO", "HbR"]}
  364. )
  365. if has_scalp:
  366. sb_X_scalp_hbo = X[{"kernel": slice(nkernels_brain, None)}].loc[
  367. {"chromo": "HbO"}
  368. ]
  369. sb_X_scalp_hbr = X[{"kernel": slice(nkernels_brain, None)}].loc[
  370. {"chromo": "HbR"}
  371. ]
  372. X_hbo_scalp = xrutils.contract(self.G_scalp, sb_X_scalp_hbo, dim="kernel")
  373. X_hbr_scalp = xrutils.contract(self.G_scalp, sb_X_scalp_hbr, dim="kernel")
  374. X_scalp = xr.concat([X_hbo_scalp, X_hbr_scalp], dim="chromo")
  375. X_scalp = X_scalp.assign_coords({"chromo": ["HbO", "HbR"]})
  376. if has_scalp:
  377. is_brain = np.zeros(
  378. X_brain.sizes["vertex"] + X_scalp.sizes["vertex"], dtype=bool
  379. )
  380. is_brain[:X_brain.sizes["vertex"]] = True
  381. X = xr.concat([X_brain, X_scalp], dim="vertex").assign_coords(
  382. {"is_brain": ("vertex", is_brain)}
  383. )
  384. else:
  385. is_brain = np.ones(X_brain.sizes["vertex"], dtype=bool)
  386. X = X_brain.assign_coords({"is_brain": ("vertex", is_brain)})
  387. X = X.transpose("chromo", "vertex", ...)
  388. return X
  389. def to_file(self, fname : Path | str):
  390. """Serialize prepared Gaussian spatial basis functions to HDF5 file.
  391. Args:
  392. fname: path of the output file.
  393. """
  394. with h5py.File(fname, "w") as fout:
  395. ioutils.xarray_to_hdfgroup(fout, self.H, "H")
  396. ioutils.xarray_to_hdfgroup(fout, self.G_brain, "G_brain")
  397. ioutils.xarray_to_hdfgroup(fout, self.G_scalp, "G_scalp")
  398. ioutils.xarray_to_hdfgroup(fout, self._mask, "_mask")
  399. for name in [
  400. "threshold_brain",
  401. "threshold_scalp",
  402. "sigma_brain",
  403. "sigma_scalp",
  404. "mask_threshold",
  405. ]:
  406. fout["/"].attrs[name] = str(getattr(self, name))
  407. @classmethod
  408. def from_file(cls, fname : Path | str) -> "OriginalGaussianSpatialBasisFunctions":
  409. """Load prepared Gaussian spatial basis functions from HDF5 group.
  410. Args:
  411. fname: path of the file to read from.
  412. Returns:
  413. GaussianSpatialBasisFunctions: Loaded instance.
  414. """
  415. sbf = cls.__new__(cls)
  416. with h5py.File(fname, "r") as f:
  417. sbf.H = ioutils.xarray_from_hdfgroup(f, "H")
  418. sbf.G_brain = ioutils.xarray_from_hdfgroup(f, "G_brain")
  419. sbf.G_scalp = ioutils.xarray_from_hdfgroup(f, "G_scalp")
  420. sbf._mask = ioutils.xarray_from_hdfgroup(f, "_mask")
  421. for name in [
  422. "threshold_brain",
  423. "threshold_scalp",
  424. "sigma_brain",
  425. "sigma_scalp",
  426. ]:
  427. setattr(sbf, name, pint.Quantity(f["/"].attrs[name]))
  428. setattr(sbf, "mask_threshold", float(f["/"].attrs["mask_threshold"]))
  429. return sbf
  430. class GaussianSpatialBasisFunctions(SpatialBasisFunctions):
  431. """Gaussian Spatial Basis Functions.
  432. Args:
  433. head_model: a TwoSurfaceHeadModel with brain and scalp surfaces
  434. Adot : the sensitivity matrix
  435. threshold_brain: the distance between kernel centers on the brain
  436. threshold_scalp: the distance between kernel centers on scalp
  437. sigma_brain: the width of the gaussians on the brain
  438. sigma_scalp : the width of the gaussians on the scalp
  439. mask_threshold: log10(sensitivity) threshold for vertices to be considered
  440. verbose: controls visibility of status messages and progress bar
  441. """
  442. def __init__(
  443. self,
  444. head_model: TwoSurfaceHeadModel,
  445. Adot: xr.DataArray,
  446. threshold_brain: cdt.QLength,
  447. threshold_scalp: cdt.QLength,
  448. sigma_brain: cdt.QLength,
  449. sigma_scalp: cdt.QLength,
  450. mask_threshold: float,
  451. verbose: bool = True,
  452. ):
  453. self.threshold_brain = threshold_brain
  454. self.threshold_scalp = threshold_scalp
  455. self.sigma_brain = sigma_brain
  456. self.sigma_scalp = sigma_scalp
  457. self.mask_threshold = mask_threshold
  458. self.verbose = verbose
  459. self._mask: xr.DataArray = None # shape (vertex)
  460. # H integrates Adot, i.e. it describes each kernel's influence on each channel
  461. self._H: xr.DataArray = None # shape(channel, kernel, wavelength)
  462. # G contains the kernel's value for each vertex
  463. self._G: csr_array = None # shape (kernel, vertex)
  464. # coordinate arrays of G. Have to keep them separate since G is not a DataArray
  465. self._G_kernel : np.ndarray = None
  466. self._G_kernel_is_brain: np.ndarray = None
  467. self._G_vertex_is_brain: np.ndarray = None
  468. self._G_vertex_parcel: np.ndarray = None
  469. self.nkernel_brain : int = None
  470. self.nvertices_brain : int = None
  471. # compute _G
  472. self._compute_sensitivity_mask(Adot)
  473. self._compute_G_gaussian_kernels(head_model)
  474. self._compute_H(Adot)
  475. cite("Carlton2026")
  476. @property
  477. def H(self):
  478. return self._H
  479. def _compute_sensitivity_mask(self, Adot, wavelength_idx: int = 0):
  480. """Compute sensitivity mask based on intensity threshold.
  481. The mask selects those vertices whose summed contribution to the sensitivity
  482. of all channels is above 10^(mask_threshold).
  483. Args:
  484. Adot: Sensitivity matrix.
  485. wavelength_idx: Index of wavelength to use for mask computation.
  486. """
  487. intensity = np.log10(
  488. Adot.isel(wavelength=wavelength_idx) # FIXME maybe min over wavelengths?
  489. .sum("channel")
  490. .clip(min=10 ** (self.mask_threshold - 1)) # avoid log10(0)
  491. )
  492. mask = intensity > self.mask_threshold
  493. mask = mask.drop_vars("wavelength") # but keep the is_brain coordinate
  494. self._mask = mask
  495. def _downsample_mesh(
  496. self, mesh: xr.DataArray, threshold: cdt.QLength, mask: np.ndarray
  497. ) -> xr.DataArray:
  498. """Downsample the mesh to get seeds of spatial bases.
  499. Args:
  500. mesh: vertices of either the brain or scalp surface.
  501. threshold: distance between vertices in downsampled mesh.
  502. mask: boolean mask to select mesh vertices
  503. Returns:
  504. downsampled mesh vertices as a xr.DataArray
  505. Initial Contributors:
  506. - Yuanyuan Gao
  507. - Laura Carlton | [email hidden] | 2024
  508. """
  509. # Downsample the mesh using the specified method
  510. mesh_units = mesh.pint.units
  511. threshold = threshold.to(mesh_units).magnitude
  512. mesh = mesh.rename({"label": "vertex"}).pint.dequantify()
  513. mesh_masked = mesh[mask, :].values
  514. nmasked = mesh_masked.shape[0]
  515. sel_indices = [0]
  516. for vertex_index in tqdm(np.arange(1, nmasked), disable=not self.verbose):
  517. vv = mesh_masked[vertex_index, :]
  518. dists = np.linalg.norm(mesh_masked[sel_indices, :] - vv[None, :], axis=1)
  519. if not np.any(dists <= threshold):
  520. sel_indices.append(vertex_index)
  521. mesh_new = mesh_masked[sel_indices]
  522. mesh_new_xr = xr.DataArray(
  523. mesh_new,
  524. dims=mesh.dims,
  525. coords={"vertex": np.arange(len(mesh_new))},
  526. attrs={"units": mesh_units},
  527. )
  528. mesh_new_xr = mesh_new_xr.pint.quantify()
  529. return mesh_new_xr
  530. def _get_gaussian_kernels_on_mesh(
  531. self,
  532. mesh_downsampled: xr.DataArray,
  533. mesh: xr.DataArray,
  534. sigma: cdt.QLength,
  535. vertex_indices: np.ndarray,
  536. G_shape : tuple[int,int]
  537. ):
  538. """Compute the matrix containing the spatial bases.
  539. Args:
  540. mesh_downsampled: vertices of either the downsampled brain or scalp surface.
  541. This is used to define the centers of the spatial bases.
  542. mesh: the original fully sampeld mesh vertices of the brain or scalp.
  543. sigma: standard deviation used for defining the Gaussian kernel.
  544. vertex_indices: The column indices in the resulting sparse G matrix.
  545. G_shape: the shape of the resulting G matrix
  546. Returns:
  547. xr.DataArray: matrix containing the spatial bases
  548. Initial Contributors:
  549. - Yuanyuan Gao
  550. - Laura Carlton | [email hidden] | 2024
  551. """
  552. # Create Gaussian kernels based on the mesh and parameters
  553. assert mesh.pint.units == mesh_downsampled.pint.units
  554. mesh_units = mesh.pint.units
  555. sigma = sigma.to(mesh_units).magnitude
  556. mesh_downsampled = mesh_downsampled.pint.dequantify().values
  557. mesh = mesh.pint.dequantify().values
  558. n_kernel = len(mesh_downsampled)
  559. #n_vertex = len(mesh)
  560. norm_pdf = scipy.stats.norm(scale=sigma).pdf
  561. # csr data structures
  562. csr_data = []
  563. csr_indices = []
  564. csr_indptr = [0]
  565. csr_ndata = 0
  566. for i_kernel in tqdm(np.arange(n_kernel), disable=not self.verbose):
  567. dists = np.linalg.norm(mesh_downsampled[[i_kernel],:] - mesh, axis=1)
  568. kernel_values = norm_pdf(dists)
  569. # change kernel normalization to match original implementation
  570. kernel_values /= (2 * np.pi * sigma**2)
  571. indices : np.ndarray = np.flatnonzero(kernel_values >= 1e-16)
  572. csr_indices.append( vertex_indices[indices] )
  573. csr_data.append(kernel_values[indices])
  574. csr_ndata += len(indices)
  575. csr_indptr.append(csr_ndata)
  576. csr_indices = np.hstack(csr_indices)
  577. csr_data = np.hstack(csr_data)
  578. kernel_matrix = csr_array(
  579. (csr_data, csr_indices, csr_indptr), shape=(n_kernel, G_shape[1])
  580. )
  581. return kernel_matrix
  582. def _compute_G_gaussian_kernels(self, head_model : TwoSurfaceHeadModel):
  583. """Compute the G matrix which contains all the information of the spatial basis.
  584. Args:
  585. head_model: Head model with brain and scalp surfaces.
  586. Initial Contributors:
  587. - Yuanyuan Gao
  588. - Laura Carlton | [email hidden] | 2024
  589. """
  590. # downsample brain and scalp meshes. These vertices will become the centers
  591. # of the spatial basis funct
  592. brain_downsampled = self._downsample_mesh(
  593. head_model.brain.vertices,
  594. self.threshold_brain,
  595. self._mask.sel(vertex=self._mask.is_brain),
  596. )
  597. scalp_downsampled = self._downsample_mesh(
  598. head_model.scalp.vertices,
  599. self.threshold_scalp,
  600. self._mask.sel(vertex=~self._mask.is_brain),
  601. )
  602. # unique vertex indices of the brain and scalp surfaces
  603. vidx_brain = np.arange(head_model.brain.nvertices)
  604. vidx_scalp = np.arange(
  605. head_model.brain.nvertices,
  606. head_model.brain.nvertices + head_model.scalp.nvertices,
  607. )
  608. n_kernel = len(brain_downsampled) + len(scalp_downsampled)
  609. n_vertex = head_model.brain.nvertices + head_model.scalp.nvertices
  610. self.nkernel_brain = len(brain_downsampled)
  611. self.nvertices_brain = head_model.brain.nvertices
  612. G_shape = (n_kernel, n_vertex)
  613. G_brain = self._get_gaussian_kernels_on_mesh(
  614. brain_downsampled,
  615. head_model.brain.vertices,
  616. self.sigma_brain,
  617. vidx_brain,
  618. G_shape,
  619. )
  620. G_scalp = self._get_gaussian_kernels_on_mesh(
  621. scalp_downsampled,
  622. head_model.scalp.vertices,
  623. self.sigma_scalp,
  624. vidx_scalp,
  625. G_shape,
  626. )
  627. self._G = scipy.sparse.vstack((G_brain, G_scalp))
  628. self._G_kernel = np.arange(n_kernel)
  629. self._G_kernel_is_brain = np.zeros(n_kernel, dtype=bool)
  630. self._G_kernel_is_brain[:self.nkernel_brain] = True
  631. self._G_vertex_is_brain = np.zeros(n_vertex, dtype=bool)
  632. self._G_vertex_is_brain[:self.nvertices_brain] = True
  633. if "parcel" in head_model.brain.vertex_coords:
  634. self._G_vertex_parcel = np.hstack(
  635. (
  636. head_model.brain.vertex_coords["parcel"],
  637. [None] * head_model.scalp.nvertices,
  638. )
  639. )
  640. def _compute_H(self, Adot : xr.DataArray):
  641. """Compute the H matrix for spatial basis functions.
  642. Transforms the sensitivity matrix into the spatial basis space.
  643. Args:
  644. Adot: Sensitivity matrix shape=(channel, vertex, wavelength)
  645. """
  646. assert Adot.dims == ("channel", "vertex", "wavelength")
  647. H = xrutils.dot_dataarray_csr(Adot, self._G, ["kernel", "vertex"])
  648. H = H.assign_coords(
  649. {
  650. "kernel": self._G_kernel,
  651. "is_brain": ("kernel", self._G_kernel_is_brain),
  652. }
  653. )
  654. self._H = H
  655. def kernel_to_image_space_mua(self, X : np.ndarray) -> np.ndarray:
  656. """Convert kernel space reconstructions to image space for mua.
  657. Args:
  658. X: Reconstruction values in kernel space. shape (kernel, ...)
  659. Returns:
  660. np.ndarray: Reconstruction values in image space.
  661. """
  662. n_kernels = len(self._G_kernel)
  663. n_kernels_brain = self._G_kernel_is_brain.sum()
  664. coords = {}
  665. if X.sizes["kernel"] == n_kernels:
  666. img = xrutils.dot_dataarray_csr(X, self._G, ["kernel", "vertex"])
  667. coords["is_brain"] = ("vertex", self._G_vertex_is_brain)
  668. if self._G_vertex_parcel is not None:
  669. coords["parcel"] = ("vertex", self._G_vertex_parcel)
  670. elif X.sizes["kernel"] == n_kernels_brain: # brain_only == True
  671. img = xrutils.dot_dataarray_csr(
  672. X, self._G[:n_kernels_brain, :], ["kernel", "vertex"]
  673. )
  674. # FIXME even if only kernels on the brain are provided in X, G contains
  675. # vertices of the scalp which we have to select away afterwards.
  676. # It would be more efficient if these vertices would be cut from G.
  677. img = img.sel(vertex=self._G_vertex_is_brain)
  678. coords["is_brain"] = ("vertex", np.ones(img.sizes["vertex"], dtype=bool))
  679. if self._G_vertex_parcel is not None:
  680. coords["parcel"] = (
  681. "vertex",
  682. self._G_vertex_parcel[self._G_vertex_is_brain],
  683. )
  684. img = img.assign_coords(coords)
  685. return img
  686. def kernel_to_image_space_conc(self, X) -> np.ndarray:
  687. """Convert kernel space reconstructions to image space for concentration.
  688. Args:
  689. X: Reconstruction values in kernel space.
  690. Returns:
  691. np.ndarray: Reconstruction values in image space with HbO/HbR split.
  692. """
  693. assert "flat_kernel" in X.dims
  694. X = xrutils.unstack(X, "flat_kernel", ("chromo", "kernel"))
  695. n_kernels = len(self._G_kernel)
  696. n_kernels_brain = self._G_kernel_is_brain.sum()
  697. coords = {}
  698. if X.sizes["kernel"] == n_kernels:
  699. img = xrutils.dot_dataarray_csr(X, self._G, ["kernel", "vertex"])
  700. coords["is_brain"] = ("vertex", self._G_vertex_is_brain)
  701. if self._G_vertex_parcel is not None:
  702. coords["parcel"] = ("vertex", self._G_vertex_parcel)
  703. elif X.sizes["kernel"] == n_kernels_brain: # brain_only == True
  704. img = xrutils.dot_dataarray_csr(
  705. X, self._G[:n_kernels_brain, :], ["kernel", "vertex"]
  706. )
  707. # FIXME even if only kernels on the brain are provided in X, G contains
  708. # vertices of the scalp which we have to select away afterwards.
  709. # It would be more efficient if these vertices would be cut from G.
  710. img = img.sel(vertex=self._G_vertex_is_brain)
  711. coords["is_brain"] = ("vertex", np.ones(img.sizes["vertex"], dtype=bool))
  712. if self._G_vertex_parcel is not None:
  713. coords["parcel"] = (
  714. "vertex",
  715. self._G_vertex_parcel[self._G_vertex_is_brain],
  716. )
  717. img = img.assign_coords(coords)
  718. return img
  719. def to_file(self, fname : Path | str):
  720. """Serialize prepared Gaussian spatial basis functions to HDF5 file.
  721. Args:
  722. fname: path of the output file.
  723. """
  724. raise NotImplementedError()
  725. @classmethod
  726. def from_file(cls, fname : Path | str) -> "GaussianSpatialBasisFunctions":
  727. """Load prepared Gaussian spatial basis functions from HDF5 group.
  728. Args:
  729. fname: path of the file to read from.
  730. Returns:
  731. GaussianSpatialBasisFunctions: Loaded instance.
  732. """
  733. raise NotImplementedError()
  734. class ImageRecon:
  735. """Implements image reconstruction methods for diffuse optical tomography.
  736. Args:
  737. Adot: the sensitivity matrix
  738. recon_mode: select reconstruction method
  739. - 'conc': directly reconstruct hemoglobin concentrations from OD
  740. measurements at different wavelengths
  741. - 'mua': reconstruct absorption changes from OD measurements for each
  742. wavelength separately.
  743. - 'mua2conc': reconstruct absorption changes for each wavelength separately.
  744. Afterwards transform these to hemoglobin concentration changes.
  745. brain_only: if set to true, scalp vertices in Adot are ignored and the
  746. reconstruction is constrained to brain vertices
  747. alpha_meas: regularization parameter to adjust the balance between image
  748. noise and spatial resolution.
  749. alpha_spatial: regularization parameter that controls the effective depth of the
  750. reconstruction by controlling how strongly the vertex sensitivities are
  751. rescaled. A smaller alpha_spatial will more strongly suppress activation
  752. that is reconstructed on the scalp.
  753. lambda_R_conc: regularization parameter that sets the expected magnitude of the
  754. image covariance.
  755. apply_c_meas: controls whether the provided measurement covariance should be
  756. used for measurement regularization.
  757. spatial_basis_functions: if given reconstruct in the kernel space defined by the
  758. provided spatial-basis-function implementation. The result is returned in
  759. image space.
  760. """
  761. def __init__(
  762. self,
  763. Adot,
  764. *,
  765. alpha_meas: float = 0.001,
  766. alpha_spatial: float | None = None,
  767. lambda_R_conc: float | None = None,
  768. apply_c_meas: bool = False,
  769. recon_mode: ReconMode = "mua",
  770. brain_only: bool = False,
  771. spatial_basis_functions: SpatialBasisFunctions | None = None,
  772. ):
  773. cite("Carlton2026")
  774. cite("Markow2025")
  775. if recon_mode not in ["conc", "mua", "mua2conc"]:
  776. raise ValueError(
  777. "recon_mode must be set to either 'conc', 'mua' or 'mua2conc'!"
  778. )
  779. # error handling of invalid params
  780. self.recon_mode = recon_mode
  781. # regularization parameters
  782. self.alpha_meas = alpha_meas
  783. self.alpha_spatial = alpha_spatial
  784. self.apply_c_meas = apply_c_meas
  785. self.lambda_R_conc = lambda_R_conc
  786. self.sbf = spatial_basis_functions
  787. self.Adot = Adot # FIXME can we remove this?
  788. self.brain_only = brain_only
  789. # cache intermediate matrices to avoid recomputations
  790. # These would invalidate when Adot or reg./sbf. params. change. Changing
  791. # these requires a new instance of ImageRecon, so they are considered constants
  792. # here. Depending on recon_mode they have different shapes.
  793. self._D: xr.DataArray = None # R * A.T
  794. self._F: xr.DataArray = None # A @ R @ A.T
  795. # The matrix to transform from absorption changes to concentrations
  796. self._mua2conc : xr.DataArray = None
  797. # W invalidates when C_meas changes
  798. self._W: xr.DataArray = None # the pseudo_inverse (W=D@inv(F + lambda_meas C))
  799. self._W_input_hash: str = None # a hash of C_meas. recompute W on C_meas change
  800. if self.sbf is not None:
  801. self._prepare(self.sbf.H)
  802. else:
  803. self._prepare(self.Adot)
  804. def reconstruct(
  805. self,
  806. y: cdt.NDTimeSeries,
  807. c_meas: xr.DataArray | None = None,
  808. ) -> cdt.NDTimeSeries:
  809. """Reconstruct images from measurement data.
  810. Args:
  811. y: optical density time series or time point data.
  812. c_meas: Diagonal elements of the measurement covariance matrix (optional).
  813. dims: wavelength x channel.
  814. Returns:
  815. Reconstructed images.
  816. """
  817. # y is optical density and dimensionless. Dequantify.
  818. y = y.pint.dequantify()
  819. # check if c_meas changed
  820. c_meas, new_W_input_hash = self._update_and_hash_cmeas(c_meas)
  821. # (re-)calculate W when C_meas is new
  822. if (self._W_input_hash is None) or (new_W_input_hash != self._W_input_hash):
  823. self._W_input_hash = new_W_input_hash
  824. self._W = self._get_W(c_meas)
  825. if self.recon_mode == "conc":
  826. conc_img = self._get_image_conc(y)
  827. conc_img = conc_img.pint.quantify("M").pint.to("uM")
  828. return conc_img
  829. mua_img = self._get_image_mua(y)
  830. mua_img = mua_img.pint.quantify("1/mm")
  831. if self.recon_mode == "mua":
  832. return mua_img
  833. elif self.recon_mode == "mua2conc":
  834. conc_img = xrutils.contract(self._mua2conc, mua_img, dim=["wavelength"])
  835. return conc_img.pint.to("uM")
  836. else:
  837. raise ValueError() # unreachable
  838. def get_image_noise(self, c_meas: xr.DataArray):
  839. """Compute image noise/variance estimates.
  840. Args:
  841. c_meas: Measurement covariance matrix.
  842. Returns:
  843. xr.DataArray: Image noise estimates.
  844. """
  845. c_meas, new_W_input_hash = self._update_and_hash_cmeas(c_meas)
  846. # (re-)calculate W when C_meas is new
  847. if (self._W_input_hash is None) or (new_W_input_hash != self._W_input_hash):
  848. self._W_input_hash = new_W_input_hash
  849. self._W = self._get_W(c_meas)
  850. if self.recon_mode == "conc":
  851. c_meas = fwm.stack_flat_channel(c_meas)
  852. conc_img = self._get_image_noise_conc(c_meas)
  853. return conc_img
  854. elif self.recon_mode in ["mua", "mua2conc"]:
  855. mua_img = self._get_image_noise_mua(c_meas)
  856. if self.recon_mode == "mua":
  857. return mua_img
  858. else:
  859. return xrutils.contract(
  860. self._mua2conc**2, mua_img / units.mm**2, "wavelength"
  861. )
  862. else:
  863. raise ValueError() # unreachable
  864. # --- PREPARATION METHODS ---
  865. def _update_and_hash_cmeas(self, c_meas):
  866. if self.apply_c_meas:
  867. if c_meas is None:
  868. raise NotImplementedError(
  869. "c_meas must be provided when apply_c_meas is set."
  870. )
  871. else:
  872. c_meas = c_meas.pint.dequantify()
  873. else:
  874. # override any provided c_meas if apply_c_meas == False
  875. c_meas = None
  876. if c_meas is not None:
  877. # average over time if c_meas should still have a time dimension
  878. time_dim = self._get_time_dimension(c_meas)
  879. if time_dim is not None:
  880. c_meas = c_meas.mean(time_dim)
  881. # calculate a hash value for c_meas
  882. new_W_input_hash = hashlib.blake2b(
  883. c_meas.pint.dequantify().values.tobytes()
  884. ).hexdigest()
  885. else:
  886. new_W_input_hash = "no_c_meas"
  887. return c_meas, new_W_input_hash
  888. def _prepare(self, Adot):
  889. """Precompute everything that depends only on inputs in the constructor."""
  890. if self.brain_only:
  891. Adot = self.Adot.sel(vertex=self.Adot.is_brain.values)
  892. # calculate D and F for the selected choice of recon_mode and sbf.
  893. if self.recon_mode == "conc":
  894. #Adot_stacked = get_stacked_sensitivity(Adot)
  895. Adot_stacked = fwm.ForwardModel.compute_stacked_sensitivity(Adot)
  896. self._D, self._F = self._calculate_DF_conc(Adot_stacked)
  897. elif self.recon_mode in ["mua", "mua2conc"]:
  898. self._D, self._F = self._calculate_DF_mua(Adot)
  899. else:
  900. raise ValueError() # unreachable
  901. if self.recon_mode == "mua2conc":
  902. # calculate _mua2conc, which transforms absorption to concentration
  903. # changes
  904. E = nirs.get_extinction_coefficients("prahl", Adot.wavelength)
  905. self._mua2conc = xrutils.pinv(E)
  906. def _get_W(self, C_meas=None):
  907. """Get the pseudoinverse matrix W for reconstruction.
  908. Args:
  909. C_meas: Measurement covariance matrix (optional).
  910. Returns:
  911. xr.DataArray: pseudoinverse matrix W.
  912. """
  913. D = None
  914. # without spatial regularization:
  915. if self.alpha_spatial is None:
  916. # with spatial basis functions:
  917. if self.sbf is not None:
  918. if self.recon_mode == "conc":
  919. if self.brain_only:
  920. D = fwm.ForwardModel.compute_stacked_sensitivity(
  921. self.sbf.H.sel(kernel=self.sbf.H.is_brain.values)
  922. ).T
  923. else:
  924. D = fwm.ForwardModel.compute_stacked_sensitivity(
  925. self.sbf.H
  926. ).T
  927. else:
  928. if self.brain_only:
  929. D = self.sbf.H.sel(kernel=self.sbf.H.is_brain.values)
  930. else:
  931. D = self.sbf.H
  932. D = D.transpose('kernel', 'channel', 'wavelength')
  933. # without spatial basis functions:
  934. else:
  935. if self.recon_mode == "conc":
  936. if self.brain_only:
  937. D = fwm.ForwardModel.compute_stacked_sensitivity(
  938. self.Adot.sel(vertex=self.Adot.is_brain.values)
  939. ).T
  940. else:
  941. D = fwm.ForwardModel.compute_stacked_sensitivity(
  942. self.Adot
  943. ).T
  944. else:
  945. if self.brain_only:
  946. D = self.Adot.sel(vertex=self.Adot.is_brain.values)
  947. else:
  948. D = self.Adot
  949. D = D.transpose('vertex', 'channel', 'wavelength')
  950. # with spatial regularization:
  951. else:
  952. D = self._D
  953. if self.recon_mode == "conc":
  954. return self._calculate_W_conc(D, C_meas)
  955. if self.recon_mode in ["mua", "mua2conc"]:
  956. return self._calculate_W_mua(D, C_meas)
  957. # --- MATRIX COMPUTATION METHODS ---
  958. def _calculate_prior_R(self, A: xr.DataArray):
  959. """Compute spatial regularization prior (column scaling matrix).
  960. Calculates diagonal regularization matrix based on forward model sensitivity:
  961. R_j = 1 / (sum_i A_ij^2 + λ_spatial) where λ_spatial is scaled by max
  962. sensitivity. Vertices with high sensitivity get less regularization; low
  963. sensitivity vertices are smoothed more heavily.
  964. Parameters:
  965. A : numpy.ndarray or xr.DataArray
  966. Forward model matrix with shape (n_channels, n_vertices) or similar.
  967. alpha_spatial : float
  968. Spatial regularization weight controlling smoothness strength.
  969. Returns:
  970. R : numpy.ndarray or xr.DataArray
  971. Diagonal regularization matrix (as 1D array of diagonal elements)
  972. with same shape as columns of A.
  973. """
  974. B = np.sum((A**2), axis=0)
  975. b = B.max()
  976. if self.alpha_spatial is None:
  977. lambda_spatial = 1.
  978. else:
  979. lambda_spatial = self.alpha_spatial * b
  980. L = np.sqrt(B + lambda_spatial)
  981. Linv = 1 / L
  982. R = Linv**2
  983. return R
  984. def _calculate_DF(self, A: xr.DataArray):
  985. """Calculate intermediate D and F matrices for regularization.
  986. Args:
  987. A: Sensitivity matrix.
  988. Returns:
  989. D matrix as xr.DataArray
  990. F matrix as xr.DataArray
  991. """
  992. if self.alpha_spatial is None:
  993. dim = A.dims[0]
  994. F = A.values @ A.values.T
  995. F_xr = xr.DataArray(F, dims=(f"{dim}_1", f"{dim}_2"))
  996. D_xr = None
  997. else:
  998. #% GET spatial prior R
  999. R = self._calculate_prior_R(A)
  1000. AR = A * R
  1001. dim = AR.dims[0]
  1002. #% GET F and D
  1003. F = AR.values @ A.values.T
  1004. D = R.values[:, np.newaxis] * A.values.T
  1005. if self.sbf:
  1006. if self.recon_mode in ["mua", "mua2conc"]:
  1007. vertex_dim = "kernel"
  1008. channel_dim = "channel"
  1009. else:
  1010. vertex_dim = "flat_kernel"
  1011. channel_dim = "flat_channel"
  1012. else:
  1013. if self.recon_mode in ["mua", "mua2conc"]:
  1014. vertex_dim = "vertex"
  1015. channel_dim = "channel"
  1016. else:
  1017. vertex_dim = "flat_vertex"
  1018. channel_dim = "flat_channel"
  1019. #D_xr = xr.DataArray(D, dims=("flat_vertex", "flat_channel"))
  1020. D_xr = xr.DataArray(D, dims=(vertex_dim, channel_dim))
  1021. D_xr = D_xr.assign_coords(xrutils.coords_from_other(A,dims=D_xr.dims))
  1022. F_xr = xr.DataArray(F, dims=(f"{dim}_1", f"{dim}_2"))
  1023. return D_xr, F_xr
  1024. def _calculate_DF_conc(self, Adot):
  1025. """Calculate D and F matrices for concentration reconstruction.
  1026. Args:
  1027. Adot: Stacked sensitivity matrix for concentration.
  1028. Returns:
  1029. D matrix as xr.DataArray
  1030. F matrix as xr.DataArray
  1031. """
  1032. return self._calculate_DF(Adot)
  1033. def _calculate_DF_mua(self, Adot):
  1034. """Calculate D and F matrices for mua reconstruction.
  1035. Args:
  1036. Adot: Sensitivity matrix with wavelength dimension.
  1037. Returns:
  1038. D matrix as xr.DataArray
  1039. F matrix as xr.DataArray
  1040. """
  1041. D_lst = []
  1042. F_lst = []
  1043. for w in Adot.wavelength:
  1044. D, F = self._calculate_DF(Adot.sel(wavelength=w.values))
  1045. D_lst.append(D)
  1046. F_lst.append(F)
  1047. if all(d is not None for d in D_lst):
  1048. D = xr.concat(D_lst, dim="wavelength")
  1049. D = D.assign_coords({"wavelength": Adot.wavelength})
  1050. else:
  1051. D = None
  1052. F = xr.concat(F_lst, dim="wavelength")
  1053. F = F.assign_coords({"wavelength": Adot.wavelength})
  1054. return D, F
  1055. def _calculate_W(self, A, F, lambda_R, c_meas=None):
  1056. """Calculate pseudoinverse W from sensitivity and regularization.
  1057. Args:
  1058. A: Sensitivity matrix.
  1059. F: Regularization matrix F.
  1060. c_meas: Measurement covariance matrix (optional).
  1061. lambda_R: sets the expected magnitude of the image covariance
  1062. Returns:
  1063. xr.DataArray: pseudoinverse W.
  1064. """
  1065. # FIXME: lambda_R cancels out in the calculation of W. It could be removed here.
  1066. if lambda_R is None:
  1067. lambda_R = 1.
  1068. lambda_meas = lambda_R * self.alpha_meas * np.max(np.linalg.eigh(F)[0])
  1069. # A is 2D. Either (vertex x channel) or (kernel x channel)
  1070. if c_meas is not None:
  1071. W = lambda_R * A.values @ np.linalg.inv(lambda_R * F.values + lambda_meas * c_meas) # noqa:E501
  1072. else:
  1073. W = lambda_R * A.values @ np.linalg.inv(lambda_R * F.values + lambda_meas * np.eye(A.shape[1])) # noqa:E501
  1074. vertex_dim = A.dims[0] # flat_vertex, flat_kernel, kernel
  1075. channel_dim = A.dims[1] # flat_channel, channel
  1076. W_xr = xr.DataArray(W, dims=(vertex_dim, channel_dim))
  1077. W_xr = W_xr.assign_coords(
  1078. xrutils.coords_from_other(A, dims=W_xr.dims)
  1079. )
  1080. return W_xr
  1081. def _calculate_W_conc(self, A, c_meas=None):
  1082. """Calculate pseudoinverse W for concentration reconstruction.
  1083. Args:
  1084. A: Stacked sensitivity matrix.
  1085. c_meas: Measurement covariance matrix (optional).
  1086. Returns:
  1087. xr.DataArray: Pseudoinverse matrix for concentration reconstruction.
  1088. """
  1089. if c_meas is not None:
  1090. c_meas = fwm.stack_flat_channel(c_meas)
  1091. c_meas = np.diag(c_meas)
  1092. return self._calculate_W(A, self._F, self.lambda_R_conc, c_meas)
  1093. def _calculate_W_mua(self, A, c_meas=None):
  1094. """Calculate pseudoinverse W for mua reconstruction.
  1095. Args:
  1096. A: Sensitivity matrix with wavelength dimension.
  1097. c_meas: Measurement covariance matrix (optional).
  1098. Returns:
  1099. xr.DataArray: Pseudoinverse matrix for mua reconstruction with wavelength
  1100. dimension.
  1101. """
  1102. W = []
  1103. lambda_R_indirect = self.compute_lambda_R_indirect()
  1104. for wavelength in A.wavelength:
  1105. if c_meas is not None:
  1106. c_meas_w = c_meas.sel(wavelength=wavelength)
  1107. c_meas_w = np.diag(c_meas_w)
  1108. else:
  1109. c_meas_w = None
  1110. W_xr = self._calculate_W(
  1111. A.sel(wavelength=wavelength),
  1112. self._F.sel(wavelength=wavelength),
  1113. lambda_R_indirect.sel(wavelength=wavelength).values,
  1114. c_meas_w,
  1115. )
  1116. W.append(W_xr)
  1117. W_xr = xr.concat(W, dim="wavelength")
  1118. W_xr = W_xr.assign_coords({"wavelength": A.wavelength})
  1119. return W_xr
  1120. # --- IMAGE RECONSTRUCTION METHODS ---
  1121. def _get_image_conc(self, y: cdt.NDTimeSeries) -> cdt.NDTimeSeries:
  1122. y = fwm.stack_flat_channel(y)
  1123. y = y.reset_index("flat_channel")
  1124. # make sure that ordering of channels is consistent
  1125. # y may contain less channels then W due to pruning
  1126. try:
  1127. sel_channels = [
  1128. i for i in self._W.flat_channel.values if i in y.flat_channel.values
  1129. ]
  1130. y = y.sel(flat_channel = sel_channels)
  1131. W = self._W.sel(flat_channel = sel_channels)
  1132. except KeyError:
  1133. raise ValueError(
  1134. "This time series contains channel(s) which is/are not in the "
  1135. "sensitivity matrix!"
  1136. )
  1137. conc_img = xrutils.contract(W, y, "flat_channel")
  1138. if self.sbf is None:
  1139. conc_img = fwm.unstack_flat_vertex(conc_img)
  1140. else:
  1141. # direct recon with spatial basis
  1142. conc_img = self.sbf.kernel_to_image_space_conc(conc_img)
  1143. return conc_img
  1144. def _get_image_mua(self, y):
  1145. """Compute absorption coefficient image from measurements.
  1146. Args:
  1147. y: Optical density measurement data with wavelength dimension.
  1148. Returns:
  1149. xr.DataArray: Absorption coefficient image.
  1150. """
  1151. # make sure that ordering of channels is consistent y may contain less channels
  1152. # then W due to pruning.
  1153. try:
  1154. sel_channels = [i for i in self._W.channel.values if i in y.channel.values]
  1155. y = y.sel(channel=sel_channels)
  1156. W = self._W.sel(channel=sel_channels)
  1157. except KeyError:
  1158. raise ValueError(
  1159. "This time series contains channel(s) which is/are not in the "
  1160. "sensitivity matrix!"
  1161. )
  1162. mua_img = xrutils.contract(W, y, dim="channel")
  1163. if self.sbf is not None:
  1164. mua_img = self.sbf.kernel_to_image_space_mua(mua_img)
  1165. return mua_img
  1166. def _get_image_noise_conc(self, c_meas: xr.DataArray | None = None):
  1167. """Compute concentration image noise/variance.
  1168. Args:
  1169. c_meas: Measurement covariance matrix
  1170. Returns:
  1171. xr.DataArray: Image noise with proper dimensions and coordinates
  1172. """
  1173. if c_meas is None:
  1174. raise ValueError("c_meas cannot be None for noise computation")
  1175. # Detect time dimension
  1176. time_dim = self._get_time_dimension(c_meas)
  1177. has_time = time_dim is not None
  1178. # Compute noise variance: diag(W @ C_meas @ W.T)
  1179. if has_time:
  1180. # Vectorized computation over time
  1181. c_meas = c_meas.transpose('flat_channel', time_dim)
  1182. noise_var = self._compute_time_varying_noise(self._W, c_meas)
  1183. else:
  1184. # Single timepoint
  1185. noise_var = self._compute_single_noise(self._W, c_meas)
  1186. # Apply spatial basis transformation if needed
  1187. if self.sbf is not None:
  1188. noise_var = self.sbf.kernel_to_image_space_conc(noise_var)
  1189. # if has_time:
  1190. # noise_var = noise_var # Transpose back to (vertex, time)
  1191. else:
  1192. # Reshape for HbO/HbR concentration
  1193. # = self._reshape_conc(noise_var, has_time)
  1194. noise_var = fwm.unstack_flat_vertex(noise_var)
  1195. return noise_var
  1196. # Create properly formatted xarray
  1197. #return self._create_conc_dataarray(noise_var, c_meas, time_dim)
  1198. def _get_image_noise_mua(self, c_meas: xr.DataArray | None = None):
  1199. """Compute absorption coefficient image noise/variance.
  1200. Args:
  1201. c_meas: Measurement covariance matrix
  1202. Returns:
  1203. xr.DataArray: Image noise with proper dimensions and coordinates
  1204. """
  1205. if c_meas is None:
  1206. raise ValueError("c_meas cannot be None for noise computation")
  1207. # Detect time dimension
  1208. time_dim = self._get_time_dimension(c_meas)
  1209. has_time = time_dim is not None
  1210. noise_var_list = []
  1211. # Process each wavelength separately
  1212. for wavelength in self._W.wavelength:
  1213. W_wl = self._W.sel(wavelength=wavelength)
  1214. c_wl = c_meas.sel(wavelength=wavelength)
  1215. # Compute noise for this wavelength
  1216. if has_time:
  1217. noise_wl = self._compute_time_varying_noise(W_wl, c_wl)
  1218. else:
  1219. noise_wl = self._compute_single_noise(W_wl, c_wl)
  1220. # Apply spatial basis transformation if needed
  1221. if self.sbf is not None:
  1222. noise_wl = self.sbf.kernel_to_image_space_mua(noise_wl)
  1223. noise_var_list.append(noise_wl)
  1224. # Combine wavelengths
  1225. #noise_var = np.stack(noise_var_list, axis=0) # (wavelength, vertex, time)
  1226. noise_var = xr.concat(noise_var_list, dim="wavelength")
  1227. noise_var = noise_var.assign_coords({
  1228. "wavelength" : self._W.wavelength.values
  1229. })
  1230. return noise_var
  1231. # Create properly formatted xarray
  1232. #return self._create_mua_dataarray(noise_var, c_meas, time_dim)
  1233. def get_image_noise_posterior(self, c_meas : xr.DataArray | None = None):
  1234. """Compute posterior variance of reconstructed images.
  1235. Calculates the diagonal of the posterior covariance matrix:
  1236. Cov(X|y) = R - R * A^T @ (F + λ*C)^(-1) @ A * R
  1237. where R is the spatial prior. Returns only the diagonal (variance at each
  1238. vertex).
  1239. Parameters:
  1240. c_meas : Measurement covariance matrix.
  1241. Returns:
  1242. xr.DataArray: Posterior variance of reconstructed images.
  1243. """
  1244. c_meas, new_W_input_hash = self._update_and_hash_cmeas(c_meas)
  1245. # (re-)calculate W when C_meas is new
  1246. if (self._W_input_hash is None) or (new_W_input_hash != self._W_input_hash):
  1247. self._W_input_hash = new_W_input_hash
  1248. self._W = self._get_W(c_meas)
  1249. if self.recon_mode == "conc":
  1250. conc_img = self._get_posterior_cov_conc()
  1251. return conc_img
  1252. elif self.recon_mode in ["mua", "mua2conc"]:
  1253. mua_img = self._get_posterior_cov_mua()
  1254. if self.recon_mode == "mua":
  1255. return mua_img
  1256. else:
  1257. return xrutils.contract(
  1258. self._mua2conc**2, mua_img / units.mm**2, "wavelength"
  1259. )
  1260. else:
  1261. raise ValueError() # unreachable
  1262. def _get_posterior_cov_conc(self):
  1263. if self.sbf is not None:
  1264. A = self.sbf.H
  1265. Adot_stacked = fwm.ForwardModel.compute_stacked_sensitivity(A)
  1266. else:
  1267. A = self.Adot
  1268. Adot_stacked = fwm.ForwardModel.compute_stacked_sensitivity(A)
  1269. W = self._W
  1270. R = self._calculate_prior_R(Adot_stacked)
  1271. R = self.lambda_R_conc * R
  1272. # ---------------------------------------------------------
  1273. # Posterior variance (diagonal only)
  1274. # mse_post(j) = R_j * (1 - (W A^T)_{jj})
  1275. # ---------------------------------------------------------
  1276. s = np.sum(W * Adot_stacked.T, axis=1) # elementwise multiply row i with col. i
  1277. mse_post = R * (1.0 - s)
  1278. if self.sbf is not None:
  1279. mse_post = self.sbf.kernel_to_image_space_conc(mse_post).T
  1280. else:
  1281. mse_post = fwm.unstack_flat_vertex(mse_post)
  1282. # FIXME should probably not assume units here
  1283. mse_post = mse_post.pint.quantify("molar**2")
  1284. return mse_post
  1285. def _get_posterior_cov_mua(self):
  1286. """Compute W and mse_posterior for a given wavelength.
  1287. It use spatial regularization (via column scaling) and measurement
  1288. regularization in data space.
  1289. """
  1290. if self.sbf is not None:
  1291. A = self.sbf.H
  1292. else:
  1293. A = self.Adot
  1294. lambda_R_indirect = self.compute_lambda_R_indirect()
  1295. mse_lst = []
  1296. W = self._W
  1297. for wl in A.wavelength:
  1298. lambda_R_wl = lambda_R_indirect.sel(wavelength=wl).values
  1299. A_wl = A.sel(wavelength=wl)
  1300. W_wl = W.sel(wavelength=wl)
  1301. R = self._calculate_prior_R(A_wl)
  1302. R = lambda_R_wl * R
  1303. # ---------------------------------------------------------
  1304. # Posterior variance (diagonal only)
  1305. # mse_post(j) = R_j * (1 - (W A^T)_{jj})
  1306. # ---------------------------------------------------------
  1307. s = np.sum(W_wl * A_wl.T, axis=1) # elementwise multiply row i with col. i
  1308. mse_post = R * (1.0 - s)
  1309. mse_lst.append(mse_post)
  1310. mse_post_xr = xr.concat(mse_lst, dim='wavelength')
  1311. if self.sbf is not None:
  1312. mse_post_xr = self.sbf.kernel_to_image_space_mua(mse_post_xr)
  1313. return mse_post_xr
  1314. # --- HELPER METHODS FOR IMAGE COMPUTATION ---
  1315. def _get_time_dimension(self, data: xr.DataArray) -> str | None:
  1316. """Detect time dimension in data."""
  1317. for dim in ['time', 'reltime']:
  1318. if dim in data.dims:
  1319. return dim
  1320. return None
  1321. def _get_spatial_dimension(self, data: xr.DataArray) -> str | None:
  1322. for dim in ['vertex', 'kernel', 'parcel', 'channel']:
  1323. if dim in data.dims:
  1324. return dim
  1325. return None
  1326. def _compute_single_noise(
  1327. self, W: xr.DataArray, c_meas: xr.DataArray
  1328. ) -> xr.DataArray:
  1329. """Compute noise for single timepoint: diag(W @ C @ W.T)."""
  1330. return ((W * np.sqrt(c_meas))**2).sum(axis=1)
  1331. def _compute_time_varying_noise(
  1332. self, W: xr.DataArray, c_meas: xr.DataArray
  1333. ) -> np.ndarray:
  1334. """Compute noise for multiple timepoints efficiently.
  1335. This unified method works for both:
  1336. - Full multi-wavelength case (concentration reconstruction)
  1337. - Single wavelength case (mua reconstruction)
  1338. Args:
  1339. W: Weight matrix with dims (vertex, flat_channel) or (vertex, channel)
  1340. c_meas: Covariance with dims (time, flat_channel) or (time, channel)
  1341. Returns:
  1342. np.ndarray: Noise variance with dims (vertex, time)
  1343. """
  1344. # Vectorized computation over time
  1345. c_sqrt = np.sqrt(c_meas.values) # (time, flat_channel) or (time, channel)
  1346. W_expanded = W.values[:, :, np.newaxis] # (vertex, flat_channel/channel, 1)
  1347. # Broadcasting: (vertex, flat_channel/channel, time)
  1348. weighted = W_expanded * c_sqrt
  1349. return np.nansum(weighted**2, axis=1) # (vertex, time)
  1350. def compute_lambda_R_indirect(self):
  1351. """Compute wavelength-specific prior scaling parameter for indirect recon.
  1352. Scales lambda_R to ensure consistency between direct
  1353. (chromophore space) and indirect (wavelength space) methods. Uses extinction
  1354. coefficients to relate chromophore regularization strength to OD regularization.
  1355. Returns:
  1356. lambda_R_indirect : xr.DataArray
  1357. Wavelength-specific parameter with dimension (wavelength,).
  1358. Scaled to match direct method's effective regularization strength.
  1359. """
  1360. # FIXME catch earlier?
  1361. if self.lambda_R_conc is None:
  1362. return xr.DataArray(
  1363. [1.0, 1.0],
  1364. dims=["wavelength"],
  1365. coords={"wavelength": self.Adot.wavelength},
  1366. )
  1367. conc2mua = nirs.get_extinction_coefficients("prahl", self.Adot.wavelength)
  1368. A_stacked = fwm.ForwardModel.compute_stacked_sensitivity(self.Adot)
  1369. nV_brain = self.Adot.is_brain.sum().values
  1370. nV_head = self.Adot.shape[1]
  1371. R_direct = self._calculate_prior_R(A_stacked)
  1372. R_direct = self.lambda_R_conc * R_direct
  1373. R_direct_max = [
  1374. R_direct[:nV_brain].max().values,
  1375. R_direct[nV_head : nV_head + nV_brain].max().values,
  1376. ]
  1377. # Convert direct prior to indirect (OD space)
  1378. R_indirect_wl1 = self._calculate_prior_R(self.Adot.isel(wavelength=0))
  1379. R_indirect_wl2 = self._calculate_prior_R(self.Adot.isel(wavelength=1))
  1380. conc2mua = conc2mua.pint.dequantify() # FIXME: check units
  1381. R_indirect_converted = conc2mua.values**2 @ R_direct_max # / units.mm**2
  1382. lambda_wl1 = R_indirect_converted[0] / R_indirect_wl1[:nV_brain].max()
  1383. lambda_wl2 = R_indirect_converted[1] / R_indirect_wl2[:nV_brain].max()
  1384. lambda_R_indirect = xr.DataArray(
  1385. [lambda_wl1, lambda_wl2],
  1386. dims=["wavelength"],
  1387. coords={"wavelength": self.Adot.wavelength},
  1388. )
  1389. return lambda_R_indirect

image_recon.py at commit c463978, under MIT · at the source

Overview

  1. Boston University, Neurophotonics Center, Department of Biomedical Engineering, Boston, Massachusetts, United States
  2. Technische Universität Berlin, Intelligent Biomedical Sensing Lab, Berlin, Germany
  3. Berlin Institute for the Foundations of Learning and Data, Berlin, Germany
  4. Boston University, Department of Mathematics and Statistics, Boston, Massachusetts, United States
Institutions: Boston University (United States); Technische Universität Berlin (Germany)
Journal: Neurophotonics, volume 13, issue 2, article 025001
Dates: received 17 September 2025; accepted 20 February 2026; published online 14 March 2026; in print April 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1117/1.nph.13.2.025001 · PMID 41847175 · PMCID PMC12990250 · OpenAlex W7135370416
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: fNIRS (modality), human (organism)
Methods: Statistics, Machine learning, fMRI & imaging, Spectral & time-frequency
Keywords: functional near-infrared spectroscopy, diffuse optical tomography, human brain function, inverse problem
Topic: Optical Imaging and Spectroscopy Techniques (Radiology, Nuclear Medicine and Imaging, Medicine), according to OpenAlex
Funding: NIBIB NIH HHS (U01 EB029856, UG3 EB036035)
Citations: cited by 2 papers (Europe PMC); 53 references in the paper

Abstract

Significance: Diffuse optical tomography (DOT) enables mapping of functional near-infrared spectroscopy channel-based optical density changes to spatial images of oxy- and deoxyhemoglobin. Accurate reconstruction requires optimization for specific probe geometries. Although prior work focused on volumetric voxel reconstructions with grid arrays, here we examine high-density hexagonal arrays for surface-based reconstructions of the brain and scalp.

Aim: We evaluate measurement and spatial regularization, spatial basis functions, and reconstruction strategies to reduce crosstalk and improve localization. Both single-wavelength (indirect) and dual-wavelength (direct) approaches are compared.

Approach: Simulations with a white-noise model guided parameter optimization using image quality metrics. Resting-state data were augmented with synthetic hemodynamic response functions (HRFs) to incorporate real measurement variance into the parameter optimization pipeline, and results were validated with a ball-squeezing motor task.

Results: Gaussian spatial bases reduced brain–scalp crosstalk but lowered contrast-to-noise ratio and increased localization error. Indirect hemoglobin reconstruction decreased oxy–deoxy crosstalk. Validation data showed strong, lateralized motor cortex activation contralateral to the active hand.

Conclusions: High-density hexagonal arrays enable accurate surface DOT reconstructions when optimized. Resting-state data augmented with synthetic HRFs provide an effective strategy for parameter selection, yielding localized activation with a high contrast-to-noise ratio.

Reproduced under the paper's license (CC BY), from the paper cited above.

Repositories

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

ibs-lab/cedalion

License: MIT
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: c463978a799e843dfde0631500336948cbdd63ee, 27 May 2026
Languages: Python (137), Jupyter (52), Shell (2), JavaScript (1)
Size: 265 files, 192 scripts
Software Heritage: archived
Found in: “Code and Data Availability”
Holds: README, license file, CITATION.cff, environment (cedalion.def, Dockerfile, environment_dev.yml, environment_doc.yml, pyproject.toml), tests, continuous integration, documentation, 52 notebooks
Tools: NumPy (127 files), xarray (104 files), Matplotlib (50 files), pandas (39 files), SciPy (36 files), scikit-learn (7 files), NiBabel (6 files), statsmodels (6 files), h5py (4 files), MNE-Python (1 file), NetworkX (1 file), Nilearn (1 file), OpenCV (1 file), Pillow (1 file), PyWavelets (1 file), scikit-image (1 file)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
194 files

lauracarlton/image_reconstruction_optimization

License: none: the authors keep all their rights
State: the link answers, verified on 30 September 2026
Evidence: files inventoried
Commit: ab9b4d88ab37c8a660d5f3548ceca7ad1cbe9870, 17 March 2026
Languages: Python (28), Shell (5)
Size: 41 files, 33 scripts
Software Heritage: not archived
Found in: “Code and Data Availability”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (20 files), xarray (19 files), Matplotlib (6 files), pandas (3 files), SciPy (3 files), seaborn (2 files)
Availability: 1 check, the latest on 30 September 2026: the link answers
  • 30 September 2026: the link answers
34 files

The paper's code and data availability statement is in the Data section.

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;
  • 225 scripts, each with its path and the digest of its content;
  • 23 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

Datasets cited

Code and Data Availability

All methods developed in this dataset are available in Cedalion for adoption by the community (https://github.com/ibs-lab/cedalion. All data are available on OpenNeuro (doi:10.18112/openneuro.ds006673.v1.0.2). Code for running the augmented simulation and generating Figs. 5–7 is publicly available on GitHub (https://github.com/lauracarlton/image_reconstruction_optimization.git).

Reproduced under the paper's license (CC BY), from the paper cited above.

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, language, journal, volume, issue, pages, dates, 10 authors, 4 keywords, 1 funder, 50 references.

Cite

This paper

Carlton, L. B., Altınkaynak, M., Kelley, S. M., Zimmermann, B. B., Kura, S., Middell, E., von Lühmann, A., Stephen, E. P., Yücel, M. A., & Boas, D. A. (2026). Surface-based image reconstruction optimization for high-density functional near-infrared spectroscopy. Neurophotonics, 13(2), 025001. https://doi.org/10.1117/1.nph.13.2.025001

BibTeX

@article{carlton2026surface,
author = {Carlton, Laura B and Altınkaynak, Miray and Kelley, Shannon M and Zimmermann, Bernhard B and Kura, Sreekanth and Middell, Eike and von Lühmann, Alexander and Stephen, Emily P and Yücel, Meryem A and Boas, David A},
title = {{Surface-based image reconstruction optimization for high-density functional near-infrared spectroscopy}},
journal = {Neurophotonics},
year = {2026},
month = mar,
volume = {13},
number = {2},
pages = {025001},
publisher = {Society of Photo-Optical Instrumentation Engineers},
issn = {2329-423X},
doi = {10.1117/1.nph.13.2.025001},
url = {https://doi.org/10.1117/1.nph.13.2.025001},
pmid = {41847175},
pmcid = {PMC12990250}
}

RIS

TY - JOUR
AU - Carlton, Laura B
AU - Altınkaynak, Miray
AU - Kelley, Shannon M
AU - Zimmermann, Bernhard B
AU - Kura, Sreekanth
AU - Middell, Eike
AU - von Lühmann, Alexander
AU - Stephen, Emily P
AU - Yücel, Meryem A
AU - Boas, David A
TI - Surface-based image reconstruction optimization for high-density functional near-infrared spectroscopy
T2 - Neurophotonics
J2 - Neurophotonics
PY - 2026
DA - 2026/03/14
VL - 13
IS - 2
SP - 025001
SN - 2329-423X
PB - Society of Photo-Optical Instrumentation Engineers
DO - 10.1117/1.nph.13.2.025001
UR - https://doi.org/10.1117/1.nph.13.2.025001
LA - en
ER -

CSL-JSON

{
"id": "10.1117/1.nph.13.2.025001",
"type": "article-journal",
"title": "Surface-based image reconstruction optimization for high-density functional near-infrared spectroscopy",
"container-title": "Neurophotonics",
"author": [
{
"family": "Carlton",
"given": "Laura B"
},
{
"family": "Altınkaynak",
"given": "Miray"
},
{
"family": "Kelley",
"given": "Shannon M"
},
{
"family": "Zimmermann",
"given": "Bernhard B"
},
{
"family": "Kura",
"given": "Sreekanth"
},
{
"family": "Middell",
"given": "Eike"
},
{
"family": "von Lühmann",
"given": "Alexander"
},
{
"family": "Stephen",
"given": "Emily P"
},
{
"family": "Yücel",
"given": "Meryem A"
},
{
"family": "Boas",
"given": "David A"
}
],
"container-title-short": "Neurophotonics",
"volume": "13",
"issue": "2",
"page": "025001",
"DOI": "10.1117/1.nph.13.2.025001",
"PMID": "41847175",
"PMCID": "PMC12990250",
"ISSN": "2329-423X",
"publisher": "Society of Photo-Optical Instrumentation Engineers",
"URL": "https://doi.org/10.1117/1.nph.13.2.025001",
"language": "en",
"issued": {
"date-parts": [
[
2026,
3,
14
]
]
}
}

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.1117/1.nph.13.s3.s32602 [code]
Cedalion tutorial: a Python-based framework for comprehensive analysis of multimodal fNIRS and DOT from the lab to the everyday world.
Journal: Neurophotonics
In common: fNIRS, 10 references, 4 authors
[2] doi:10.1117/1.nph.12.2.025011 [code]
NIRSTORM: a Brainstorm extension dedicated to functional near-infrared spectroscopy data analysis, advanced 3D reconstructions, and optimal probe design
Journal: n/a
In common: Matplotlib, NumPy, fNIRS, 11 references
[3] doi:10.1162/imag.a.1208 [code]
Brain network analysis in Alzheimer's disease and mild cognitive impairment using high-density diffuse optical tomography.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: NetworkX, statsmodels, seaborn, 5 other tools, fNIRS, 5 references
[4] doi:10.1038/s41467-026-72057-9 [code]
Sex-specific behavioral feedback modulates sensorimotor processing and drives flexible social behavior.
Journal: Nature communications
In common: PyWavelets, xarray, OpenCV, 10 other tools
[5] doi:10.1162/imag.a.1289 [code]
Measurement prediction and power analysis for fNIRS and DOT.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: MNE-Python, h5py, SciPy, 2 other tools, fNIRS, 6 references
[6] doi:10.1093/nc/niag029 [code]
A data-driven approach to identifying and evaluating connectivity-based neural correlates of conscious visual perception.
Journal: Neuroscience of consciousness
In common: xarray, MNE-Python, Nilearn, 10 other tools
[7] doi:10.1038/s41593-026-02232-0 [code]
Entorhinal cortex represents task-relevant remote locations independently of CA1.
Journal: Nature neuroscience
In common: xarray, NetworkX, OpenCV, 10 other tools
[8] doi:10.1117/1.nph.13.3.035006 [code]
Characterizing developmental changes in infant habituation using functional change point detection.
Journal: Neurophotonics
In common: NetworkX, scikit-image, h5py, 7 other tools, fNIRS, 2 references
[9] 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: MNE-Python, Nilearn, NetworkX, 9 other tools, 1 reference
[10] doi:10.7554/elife.107933 [code]
Modality-agnostic decoding of vision and language from fMRI.
Journal: eLife
In common: Nilearn, OpenCV, scikit-image, 10 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.