OSCR

Multilayer MEG source modelling enables depth-resolved laminar inference in humans.

Code ↔ Paper

16 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 16 matches
  1. [1] § Methods › Simulations ↔ lameg/surf.py, lines 681 to 806 · score 0.93 · cortical patch statistics, surface normal vectors, original surface normal, downsampled surface normal, mesh normals, pial surface · Report a mismatch
  2. [2] § Methods › Empirical data preprocessing ↔ lameg/surf.py, lines 858 to 915 · score 0.83 · intermediate surfaces, FreeSurfer, removes vertices, pial surface, link vectors, corresponding vertices · Report a mismatch
  3. [3] § Results › Cortical column orientation and laminar inference ↔ lameg/surf.py, lines 681 to 806 · score 0.81 · cortical patch statistics, white matter surfaces, link vectors, surface normals, corresponding vertices, dipole orientation · Report a mismatch
  4. [4] § Methods › Empirical data analysis ↔ lameg/invert.py, lines 474 to 554 · score 0.79 · multiple sparse priors, Hann windowing, sliding window, temporal mode, overlapping, free energy · Report a mismatch
  5. [5] § Methods › Simulations ↔ lameg/util.py, lines 895 to 943 · score 0.79 · left preauricular, right preauricular, fiducial coordinates, nasion, scanner, LPA · Report a mismatch
  6. [6] § Methods › Empirical data analysis ↔ run_motor_epoch_01_mf.py, lines 294 to 379 · score 0.75 · composite anatomical suitability, lead field RMS, cortical thickness, motor, score, ranked · Report a mismatch
  7. [7] § Methods › Empirical data analysis ↔ run_motor_epoch_02_mefI.py, lines 293 to 378 · score 0.75 · composite anatomical suitability, lead field RMS, cortical thickness, motor, score, ranked · Report a mismatch
  8. [8] § Methods › Simulation analysis ↔ lameg/invert.py, lines 474 to 554 · score 0.73 · multiple sparse priors, Hann windowed, temporal modes, free energy, source reconstruction, MSP · Report a mismatch
  9. [9] § Methods › Simulation analysis ↔ 06_run_multilaminar_VEF.ipynb, lines 148 to 198 · score 0.72 · dot product, scalp normal, scalp surface, cortical column, trees, cross · Report a mismatch
  10. [10] § Methods › Simulation analysis ↔ pipeline/run_motor_epoch_01_mf.py, lines 112 to 162 · score 0.72 · dot product, scalp normal, scalp surface, cortical column, trees, cross · Report a mismatch
  11. [11] § Methods › Empirical data preprocessing ↔ lameg/surf.py, lines 917 to 957 · score 0.68 · intermediate surface, deep vertices, layer mesh, mris, hemispheres · Report a mismatch
  12. [12] § Methods › Simulations ↔ lameg/simulate.py, lines 34 to 151 · score 0.65 · noise ratio, dipole moment, White noise, forward model, density, patch · Report a mismatch
  13. [13] § Methods › Empirical data analysis ↔ lameg/invert.py, lines 288 to 359 · score 0.63 · multiple sparse priors, temporal modes, beamformer, MSP, channels, patch · Report a mismatch
  14. [14] § Methods › Simulations ↔ config/spm_cfg_opm_create.m, lines 108 to 170 · score 0.61 · inner skull, outer skull, scalp, sensor, cortical · Report a mismatch
  15. [15] § Results › SNR and co-registration constraints on laminar inference ↔ lameg/laminar.py, lines 25 to 120 · score 0.53 · empirical Bayesian beamformer, metric, EBB, free energy, algorithm, MSP · Report a mismatch
  16. [16] § Results › Anatomical features determine laminar inference accuracy ↔ figures/plot_figure_08_anatomy.ipynb, lines 589 to 639 · score 0.50 · Optimal thresholds, zero error, marginal, joint, Figure 8, mm · Report a mismatch

Paper

Loaded from Europe PMC by your browser; OSCR does not store it: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

Python · 1,216 lines · 46 KB · GPL-3.0 · 4 matches

  1. """
  2. This module provides a set of tools for handling and manipulating surface mesh data. The
  3. functionalities include computing mesh normals, interpolating mesh data, handling non-manifold
  4. edges, creating and manipulating GIFTI surface files, and downsampling meshes using the VTK
  5. library.
  6. Key functionalities include:
  7. - Normalization of vectors to unit length.
  8. - Calculation of normals for mesh surfaces using both Delaunay triangulation and custom methods.
  9. - Creation of GIFTI images from mesh data.
  10. - Removal of specified vertices from a mesh and updating the mesh topology accordingly.
  11. - Identification and handling of non-manifold edges to ensure mesh manifoldness.
  12. - Interpolation of data from a downsampled mesh back to its original high-resolution mesh.
  13. - Downsampling of meshes using VTK's decimation algorithms.
  14. - Combination and adjustment of multiple surface meshes into a single mesh.
  15. """
  16. # pylint: disable=C0302
  17. import os
  18. import copy
  19. import subprocess
  20. from collections import defaultdict
  21. from joblib import Parallel, delayed
  22. import nibabel as nib
  23. import numpy as np
  24. from scipy.spatial import cKDTree # pylint: disable=E0611
  25. from scipy.sparse import csr_matrix
  26. # pylint: disable=E0611
  27. from vtkmodules.vtkCommonCore import vtkPoints
  28. from vtkmodules.vtkCommonDataModel import vtkPolyData, vtkCellArray
  29. from vtkmodules.vtkFiltersCore import vtkDecimatePro
  30. from vtkmodules.util.numpy_support import vtk_to_numpy
  31. def _normit(vectors):
  32. """
  33. Normalize a numpy array of vectors.
  34. This function normalizes each row in the array of vectors to have unit length. If the length of
  35. a vector is below a certain threshold (machine epsilon), it is set to 1 to avoid division by
  36. zero.
  37. Parameters
  38. ----------
  39. vectors : ndarray
  40. Array of vectors to be normalized. Each row represents a vector.
  41. Returns
  42. -------
  43. ndarray
  44. Normalized array of vectors where each row has unit length.
  45. """
  46. norm_n = np.sqrt(np.sum(vectors ** 2, axis=1))
  47. norm_n[norm_n < np.finfo(float).eps] = 1
  48. return vectors / norm_n[:, np.newaxis]
  49. def mesh_normals(vertices, faces, unit=False):
  50. """
  51. Normalize a numpy array of vectors.
  52. This function normalizes each row in the array of vectors to have unit length. If the length of
  53. a vector is below a certain threshold (machine epsilon), it is set to 1 to avoid division by
  54. zero.
  55. Parameters
  56. ----------
  57. vectors : ndarray
  58. Array of vectors to be normalized. Each row represents a vector.
  59. Returns
  60. -------
  61. ndarray
  62. Normalized array of vectors where each row has unit length.
  63. """
  64. face_normal = np.cross(
  65. vertices[faces[:, 1], :] - vertices[faces[:, 0], :],
  66. vertices[faces[:, 2], :] - vertices[faces[:, 0], :]
  67. )
  68. face_normal = _normit(face_normal)
  69. vertex_normal = np.zeros_like(vertices)
  70. for i in range(len(faces)):
  71. for j in range(3):
  72. vertex_normal[faces[i, j], :] += face_normal[i, :]
  73. centered_vertices = vertices - np.mean(vertices, axis=0)
  74. if np.count_nonzero(np.sign(np.sum(centered_vertices * vertex_normal, axis=1))) > \
  75. len(centered_vertices) / 2:
  76. vertex_normal = -vertex_normal
  77. face_normal = -face_normal
  78. if unit:
  79. vertex_normal = _normit(vertex_normal)
  80. face_normal = _normit(face_normal)
  81. return vertex_normal, face_normal
  82. def create_surf_gifti(vertices, faces, normals=None):
  83. """
  84. Create a Gifti image object from surface mesh data.
  85. This function creates a GiftiImage object from the provided vertices, faces, and optional
  86. normals. The vertices and faces are required, while normals are optional. If normals are
  87. provided, they are added to the Gifti image. The function returns the GiftiImage object.
  88. Parameters
  89. ----------
  90. vertices : numpy.ndarray
  91. Array of vertices. Each row represents a vertex with its x, y, z coordinates.
  92. faces : numpy.ndarray
  93. Array of faces. Each row represents a face with three integers corresponding to vertex
  94. indices.
  95. normals : numpy.ndarray, optional
  96. Array of vertex normals. Each row represents a normal vector corresponding to a vertex.
  97. Returns
  98. -------
  99. new_gifti : nibabel.gifti.GiftiImage
  100. The GiftiImage object created from the provided mesh data.
  101. Notes
  102. -----
  103. - Vertex, face, and normal arrays should be NumPy arrays.
  104. - Vertices and normals should be in float32 format, and faces should be in int32 format.
  105. Example
  106. -------
  107. >>> import numpy as np
  108. >>> import nibabel as nib
  109. >>> vertices = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]])
  110. >>> faces = np.array([[0, 1, 2], [0, 2, 3]])
  111. >>> normals = np.array([[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]])
  112. >>> gifti_img = create_surf_gifti(vertices, faces, normals)
  113. """
  114. # Create new gifti object
  115. new_gifti = nib.gifti.GiftiImage()
  116. # Cast vertices and faces to the appropriate data types
  117. vertices = vertices.astype(np.float32)
  118. faces = faces.astype(np.int32)
  119. # Add the vertices and faces to the gifti object
  120. new_gifti.add_gifti_data_array(
  121. nib.gifti.GiftiDataArray(
  122. data=vertices,
  123. intent=nib.nifti1.intent_codes['NIFTI_INTENT_POINTSET']
  124. )
  125. )
  126. new_gifti.add_gifti_data_array(
  127. nib.gifti.GiftiDataArray(
  128. data=faces,
  129. intent=nib.nifti1.intent_codes['NIFTI_INTENT_TRIANGLE']
  130. )
  131. )
  132. # If normals are provided and not empty, cast them to float32 and add them to the Gifti image
  133. if normals is not None:
  134. normals = np.array(normals).astype(np.float32)
  135. new_gifti.add_gifti_data_array(
  136. nib.gifti.GiftiDataArray(
  137. data=normals,
  138. intent=nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR']
  139. ))
  140. return new_gifti
  141. def remove_unconnected_vertices(gifti_surf):
  142. """
  143. Remove vertices that are not connected to any faces from a Gifti surface object.
  144. Parameters
  145. ----------
  146. gifti_surf : nibabel.gifti.GiftiImage
  147. The Gifti surface object to be processed.
  148. Returns
  149. -------
  150. cleaned_gifti_surf : nibabel.gifti.GiftiImage
  151. A new GiftiImage object with unconnected vertices removed.
  152. """
  153. # Get the pointset (vertices) and triangle array (faces) from the Gifti surface
  154. vertices = gifti_surf.darrays[0].data
  155. faces = gifti_surf.darrays[1].data
  156. # Find all unique vertex indices that are used in faces
  157. connected_vertices = np.unique(faces.flatten())
  158. # Determine which vertices are not connected to any faces
  159. all_vertices = np.arange(vertices.shape[0])
  160. unconnected_vertices = np.setdiff1d(all_vertices, connected_vertices)
  161. # Remove unconnected vertices using the provided remove_vertices function
  162. cleaned_gifti_surf = remove_vertices(gifti_surf, unconnected_vertices)
  163. return cleaned_gifti_surf
  164. def remove_vertices(gifti_surf, vertices_to_remove):
  165. """
  166. Remove specified vertices from a Gifti surface and update the faces accordingly.
  167. This function modifies a Gifti surface by removing the specified vertices. It also updates
  168. the faces of the surface so that they only reference the remaining vertices. If normals
  169. are present in the surface, they are also updated to correspond to the new set of vertices.
  170. Parameters
  171. ----------
  172. gifti_surf : nibabel.gifti.GiftiImage
  173. The Gifti surface object from which vertices will be removed.
  174. vertices_to_remove : array_like
  175. An array of vertex indices to be removed from the surface.
  176. Returns
  177. -------
  178. new_gifti : nibabel.gifti.GiftiImage
  179. A new GiftiImage object with the specified vertices removed and faces updated.
  180. Notes
  181. -----
  182. - The function assumes that the GiftiImage object contains at least two data arrays: one for
  183. vertices and one for faces. If normals are present, they are also updated.
  184. - Vertex indices in `vertices_to_remove` should be zero-based (following Python's indexing
  185. convention).
  186. - The returned GiftiImage object is a new object; the original `gifti_surf` object is not
  187. modified in place.
  188. Example
  189. -------
  190. >>> import nibabel as nib
  191. >>> gifti_surf = nib.load('path_to_gifti_file.gii')
  192. >>> vertices_to_remove = np.array([0, 2, 5]) # Indices of vertices to remove
  193. >>> new_gifti_surf = remove_vertices(gifti_surf, vertices_to_remove)
  194. """
  195. # Extract vertices and faces from the gifti object
  196. vertices_data = [da for da in gifti_surf.darrays
  197. if da.intent == nib.nifti1.intent_codes['NIFTI_INTENT_POINTSET']][0]
  198. faces_data = [da for da in gifti_surf.darrays
  199. if da.intent == nib.nifti1.intent_codes['NIFTI_INTENT_TRIANGLE']][0]
  200. vertices = vertices_data.data
  201. faces = faces_data.data
  202. # Determine vertices to keep
  203. vertices_to_keep = np.setdiff1d(np.arange(vertices.shape[0]), vertices_to_remove)
  204. # Create new array of vertices
  205. new_vertices = vertices[vertices_to_keep, :]
  206. # Find which faces to keep - ones that point to kept vertices
  207. if faces.shape[0]>0:
  208. face_x = np.isin(faces[:, 0], vertices_to_keep)
  209. face_y = np.isin(faces[:, 1], vertices_to_keep)
  210. face_z = np.isin(faces[:, 2], vertices_to_keep)
  211. faces_to_keep = np.where(face_x & face_y & face_z)[0]
  212. # Re-index faces
  213. x_faces = faces[faces_to_keep, :].reshape(-1)
  214. idxs = np.searchsorted(vertices_to_keep, x_faces)
  215. new_faces = idxs.reshape(-1, 3)
  216. else:
  217. new_faces = faces
  218. # Create new gifti object
  219. normals = None
  220. normals_data = [da for da in gifti_surf.darrays
  221. if da.intent == nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR']]
  222. if normals_data:
  223. normals = normals_data[0].data[vertices_to_keep, :]
  224. new_gifti = create_surf_gifti(new_vertices, new_faces, normals=normals)
  225. return new_gifti
  226. def find_non_manifold_edges(faces):
  227. """
  228. Identify non-manifold edges in a given mesh represented by its faces.
  229. A non-manifold edge is defined as an edge that is shared by more than two faces. This function
  230. processes an array of faces, each face represented by a tuple of vertex indices, and identifies
  231. edges that meet the non-manifold criteria.
  232. Parameters
  233. ----------
  234. faces : np.ndarray
  235. An array where each row represents a face as a tuple of three vertex indices.
  236. Returns
  237. -------
  238. non_manifold_edges : dict
  239. A dictionary where keys are tuples representing non-manifold edges (vertex indices are
  240. sorted), and values are lists of face indices that share the edge.
  241. Notes
  242. -----
  243. The function uses a `defaultdict` to collect face indices for each edge encountered in the mesh.
  244. It then filters out edges that are associated with more than two faces, identifying them as
  245. non-manifold.
  246. """
  247. edge_faces = defaultdict(list)
  248. for i, (vertex_1, vertex_2, vertex_3) in enumerate(faces):
  249. for edge in [(vertex_1, vertex_2), (vertex_2, vertex_3), (vertex_3, vertex_1)]:
  250. edge_faces[tuple(sorted(edge))].append(i)
  251. non_manifold_edges = {edge: fcs for edge, fcs in edge_faces.items() if len(fcs) > 2}
  252. return non_manifold_edges
  253. def fix_non_manifold_edges(vertices, faces):
  254. """
  255. Remove faces associated with non-manifold edges from a mesh defined by vertices and faces.
  256. Non-manifold edges are edges that are shared by more than two faces, which can cause issues
  257. in various mesh processing tasks such as mesh simplification, smoothing, or 3D printing. This
  258. function identifies such edges and removes all faces associated with them to ensure
  259. manifoldness of the mesh.
  260. Parameters
  261. ----------
  262. vertices : np.ndarray
  263. An array of vertices, where each row represents a vertex as [x, y, z] coordinates.
  264. faces : np.ndarray
  265. An array of faces, where each row represents a face as indices into the vertices array.
  266. Returns
  267. -------
  268. vertices : np.ndarray
  269. The unchanged array of vertices.
  270. new_faces : np.ndarray
  271. The modified array of faces, with faces associated with non-manifold edges removed.
  272. Examples
  273. --------
  274. >>> vertices = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]])
  275. >>> faces = np.array([[0, 1, 2], [0, 2, 3], [1, 2, 3]])
  276. >>> new_vertices, new_faces = fix_non_manifold_edges(vertices, faces)
  277. >>> new_faces
  278. array([[0, 1, 2], [0, 2, 3]]) # Assuming face [1, 2, 3] was associated with a non-manifold
  279. edge.
  280. """
  281. non_manifold_edges = find_non_manifold_edges(faces)
  282. conflicting_faces = set()
  283. for faces_list in non_manifold_edges.values():
  284. conflicting_faces.update(faces_list)
  285. # Create a new face list excluding the conflicting faces
  286. new_faces = np.array(
  287. [face for i, face in enumerate(faces) if i not in conflicting_faces],
  288. dtype=np.int32
  289. )
  290. return vertices, new_faces
  291. def downsample_single_surface(gifti_surf, ds_factor=0.1):
  292. """
  293. Downsample a Gifti surface using the VTK library.
  294. This function takes a Gifti surface defined by its vertices and faces, and downsamples it using
  295. VTK's `vtkDecimatePro` algorithm. The reduction ratio determines the degree of downsampling.
  296. The function returns the downsampled Gifti surface.
  297. Parameters
  298. ----------
  299. gifti_surf : nibabel.gifti.GiftiImage
  300. The Gifti surface object to be downsampled.
  301. reduction_ratio : float
  302. The proportion of the mesh to remove. For example, a reduction ratio of 0.1 retains 90% of
  303. the original mesh.
  304. Returns
  305. -------
  306. new_gifti_surf : nibabel.gifti.GiftiImage
  307. A new GiftiImage object with the downsampled surface.
  308. Notes
  309. -----
  310. - The input faces array should be triangulated, i.e., each face should consist of exactly three
  311. vertex indices.
  312. - The VTK library is used for mesh decimation, which must be installed and properly configured.
  313. - The returned GiftiImage object is a new object; the original `gifti_surf` object is not
  314. modified in place.
  315. Example
  316. -------
  317. >>> import numpy as np
  318. >>> gifti_surf = nib.load('path_to_gifti_file.gii')
  319. >>> new_gifti_surf = downsample_single_surface(gifti_surf, 0.1)
  320. """
  321. vertices = gifti_surf.darrays[0].data
  322. faces = gifti_surf.darrays[1].data
  323. # Convert vertices and faces to a VTK PolyData object
  324. points = vtkPoints()
  325. for point in vertices:
  326. points.InsertNextPoint(point)
  327. cells = vtkCellArray()
  328. for face in faces:
  329. cells.InsertNextCell(len(face))
  330. for vertex in face:
  331. cells.InsertCellPoint(vertex)
  332. polydata = vtkPolyData()
  333. polydata.SetPoints(points)
  334. polydata.SetPolys(cells)
  335. # Apply vtkDecimatePro for decimation
  336. decimate = vtkDecimatePro()
  337. decimate.SetInputData(polydata)
  338. decimate.SetTargetReduction(1 - ds_factor)
  339. decimate.Update()
  340. # Extract the decimated mesh
  341. decimated_polydata = decimate.GetOutput()
  342. # Convert back to numpy arrays
  343. reduced_vertices = vtk_to_numpy(decimated_polydata.GetPoints().GetData())
  344. # Extract and reshape the face data
  345. face_data = vtk_to_numpy(decimated_polydata.GetPolys().GetData())
  346. # Assuming the mesh is triangulated, every fourth item is the size (3), followed by three
  347. # vertex indices
  348. reduced_faces = face_data.reshape(-1, 4)[:, 1:4]
  349. # Find the original vertices closest to the downsampled vertices
  350. kdtree = cKDTree(gifti_surf.darrays[0].data)
  351. _, orig_vert_idx = kdtree.query(reduced_vertices, k=1)
  352. orig_vert_idx = np.squeeze(orig_vert_idx)
  353. reduced_normals = None
  354. if len(gifti_surf.darrays) > 2 and \
  355. gifti_surf.darrays[2].intent == nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR'] and \
  356. gifti_surf.darrays[2].data.shape[0]==gifti_surf.darrays[0].data.shape[0]:
  357. reduced_normals = gifti_surf.darrays[2].data[orig_vert_idx]
  358. new_gifti_surf = create_surf_gifti(reduced_vertices, reduced_faces, normals=reduced_normals)
  359. return new_gifti_surf
  360. def iterative_downsample_single_surface(gifti_surf, ds_factor=0.1):
  361. """
  362. Iteratively downsample a single surface mesh to a target number of vertices.
  363. This function reduces the number of vertices in a surface mesh (in GIFTI format) to a specified
  364. fraction of its original size. Downsampling is performed iteratively until the target number of
  365. vertices is reached or closely approximated.
  366. Parameters
  367. ----------
  368. gifti_surf : nibabel.gifti.GiftiImage
  369. The surface mesh to be downsampled, provided as a GIFTI image object.
  370. ds_factor : float, optional
  371. The downsampling factor representing the target fraction of the original number of vertices.
  372. Default is 0.1.
  373. Returns
  374. -------
  375. current_surf : nibabel.gifti.GiftiImage
  376. The downsampled surface mesh as a GIFTI image object.
  377. Notes
  378. -----
  379. - The downsampling process is iterative. In each iteration, the mesh is downsampled by a factor
  380. calculated to approach the target number of vertices.
  381. - If the calculated downsampling factor in an iteration equals or exceeds 1, the process is
  382. terminated to prevent upsampling or infinite loops.
  383. """
  384. current_surf = gifti_surf
  385. current_vertices = gifti_surf.darrays[0].data.shape[0]
  386. target_vertices = int(current_vertices * ds_factor)
  387. current_ds_factor = target_vertices / current_vertices
  388. while current_vertices > target_vertices:
  389. # Downsample the mesh
  390. current_surf = downsample_single_surface(current_surf, ds_factor=current_ds_factor)
  391. # Update the current vertices
  392. current_vertices = current_surf.darrays[0].data.shape[0]
  393. current_ds_factor = (target_vertices / current_vertices) * 1.25
  394. if current_ds_factor >= 1:
  395. break
  396. # Remove non-manifold edges
  397. ds_vertices = current_surf.darrays[0].data
  398. ds_faces = current_surf.darrays[1].data
  399. nonmani_vertices, nonmani_faces = fix_non_manifold_edges(ds_vertices, ds_faces)
  400. normals = None
  401. if len(current_surf.darrays) > 2 and \
  402. current_surf.darrays[2].intent == nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR'] and \
  403. current_surf.darrays[2].data.shape[0]==current_surf.darrays[0].data.shape[0]:
  404. normals = current_surf.darrays[2].data
  405. current_surf = create_surf_gifti(nonmani_vertices, nonmani_faces, normals=normals)
  406. # Remove unconnected vertices
  407. current_surf = remove_unconnected_vertices(current_surf)
  408. return current_surf
  409. def downsample_multiple_surfaces(in_surfs, ds_factor):
  410. """
  411. Downsample multiple surface meshes using the VTK decimation algorithm.
  412. This function takes a list of input surface meshes (in Gifti format) and applies a downsampling
  413. process to each surface. The downsampling is performed using VTK's `vtkDecimatePro` algorithm.
  414. The first surface in the list is downsampled, and its vertex mapping is then applied to all
  415. other surfaces in the list. The function returns a list of downsampled surface meshes.
  416. Parameters
  417. ----------
  418. in_surfs : list of nibabel.gifti.GiftiImage
  419. Input Gifti surface meshes to be downsampled.
  420. ratio : float
  421. The reduction ratio for the downsampling process. For example, a ratio of 0.1
  422. implies that the mesh will be reduced to 90% of its original size.
  423. Returns
  424. -------
  425. out_surfs : list of nibabel.gifti.GiftiImage
  426. List of downsampled Gifti surface meshes.
  427. Notes
  428. -----
  429. - The function prints the percentage of vertices retained in the first surface after
  430. downsampling.
  431. - If normals are present in the input surfaces, they are also downsampled and mapped to the
  432. new surfaces.
  433. - The resulting surfaces maintain the original topology and are suitable for visualization and
  434. further processing.
  435. Example
  436. -------
  437. >>> import nibabel as nib
  438. >>> in_surfs = [nib.load('path/to/input_surf1.gii'), nib.load('path/to/input_surf2.gii')]
  439. >>> ratio = 0.1
  440. >>> out_surfs = downsample_multiple_surfaces(in_surfs, ratio)
  441. >>> for i, ds_surf in enumerate(out_surfs):
  442. ... nib.save(ds_surf, f'path/to/output_surf{i+1}.gii')
  443. """
  444. out_surfs = []
  445. primary_surf = in_surfs[0]
  446. ds_primary_surf = iterative_downsample_single_surface(primary_surf, ds_factor=ds_factor)
  447. reduced_vertices = ds_primary_surf.darrays[0].data
  448. reduced_faces = ds_primary_surf.darrays[1].data
  449. # Find the original vertices closest to the downsampled vertices
  450. kdtree = cKDTree(primary_surf.darrays[0].data)
  451. # Calculate the percentage of vertices retained
  452. decim_orig_dist, orig_vert_idx = kdtree.query(reduced_vertices, k=1)
  453. orig_vert_idx = np.squeeze(orig_vert_idx)
  454. print(
  455. f"{(1 - np.mean(decim_orig_dist > 0)) * 100}% of the vertices in the decimated surface "
  456. f"belong to the original surface."
  457. )
  458. # Save the downsampled primary surface with normals
  459. out_surfs.append(ds_primary_surf)
  460. # Process other surfaces
  461. for i in range(1, len(in_surfs)):
  462. surf = in_surfs[i]
  463. reduced_normals = None
  464. if len(surf.darrays) > 2 and \
  465. surf.darrays[2].intent == nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR'] and \
  466. surf.darrays[2].data.shape[0] == surf.darrays[0].data.shape[0]:
  467. reduced_normals = surf.darrays[2].data[orig_vert_idx]
  468. surf_verts=surf.darrays[0].data[orig_vert_idx, :]
  469. nonmani_vertices, nonmani_faces = fix_non_manifold_edges(surf_verts, reduced_faces)
  470. ds_surf = create_surf_gifti(nonmani_vertices, nonmani_faces, normals=reduced_normals)
  471. out_surfs.append(ds_surf)
  472. return out_surfs
  473. def combine_surfaces(surfaces):
  474. """
  475. Combine multiple surface meshes into a single surface mesh.
  476. This function takes a list of Gifti surface meshes and combines them into a single surface
  477. mesh. It concatenates the vertices, faces, and normals (if present) from each surface. The
  478. faces are re-indexed appropriately to maintain the correct references to the combined vertex
  479. array.
  480. Parameters
  481. ----------
  482. surfaces : list of nibabel.gifti.GiftiImage
  483. List of Gifti surface meshes to be combined.
  484. Returns
  485. -------
  486. combined_surf : nibabel.gifti.GiftiImage
  487. A single combined Gifti surface mesh.
  488. Notes
  489. -----
  490. - The vertices, faces, and normals (if present) from each surface are concatenated.
  491. - The faces are re-indexed to reference the correct vertices in the combined vertex array.
  492. - If normals are present in any of the input surfaces, they are also combined.
  493. Raises
  494. ------
  495. ValueError
  496. If the vertex or face arrays do not have the expected dimensions.
  497. Example
  498. -------
  499. >>> import nibabel as nib
  500. >>> surfaces = [nib.load('path/to/surface1.gii'), nib.load('path/to/surface2.gii')]
  501. >>> combined_surf = combine_surfaces(surfaces)
  502. >>> nib.save(combined_surf, 'path/to/combined_surface.gii')
  503. """
  504. combined_vertices = []
  505. combined_faces = []
  506. combined_normals = []
  507. face_offset = 0
  508. for mesh in surfaces:
  509. # Extract vertices and faces
  510. vertices = np.concatenate(
  511. [da.data for da in mesh.darrays
  512. if da.intent == nib.nifti1.intent_codes['NIFTI_INTENT_POINTSET']])
  513. faces = np.concatenate(
  514. [da.data for da in mesh.darrays
  515. if da.intent == nib.nifti1.intent_codes['NIFTI_INTENT_TRIANGLE']])
  516. # Check for normals
  517. normal_arrays = [da.data for da in mesh.darrays
  518. if da.intent == nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR']]
  519. normals = np.concatenate(normal_arrays) if normal_arrays else np.array([])
  520. combined_vertices.append(vertices)
  521. combined_faces.append(faces + face_offset)
  522. if normals.size:
  523. combined_normals.append(normals)
  524. face_offset += vertices.shape[0]
  525. # Combine the arrays
  526. combined_vertices = np.vstack(combined_vertices).astype(np.float32)
  527. combined_faces = np.vstack(combined_faces).astype(np.int32)
  528. if combined_normals:
  529. combined_normals = np.vstack(combined_normals).astype(np.float32)
  530. combined_surf = create_surf_gifti(
  531. combined_vertices,
  532. combined_faces,
  533. normals=combined_normals
  534. )
  535. return combined_surf
  536. # pylint: disable=R0912,R0915
  537. def compute_dipole_orientations(method, layer_names, surf_dir, fixed=True):
  538. """
  539. Compute dipole orientations for cortical layers using different methods.
  540. Parameters
  541. ----------
  542. method : str
  543. Method for computing dipole orientations ('link_vector', 'ds_surf_norm', 'orig_surf_norm',
  544. or 'cps').
  545. link_vector: Vectors connecting pial vertices to corresponding white matter vertices.
  546. ds_surf_norm: Surface normal vectors computed from the downsampled surface.
  547. orig_surf_norm: Surface normal vectors computed from the original (non-downsampled) surface.
  548. cps: Cortical patch statistics - mean surface normal vectors from connected vertices in the
  549. original (non-downsampled) surface.
  550. layer_names : list
  551. Names of the cortical layers.
  552. surf_dir : str
  553. Directory where the surface files are stored.
  554. fixed : bool, optional
  555. Flag to ensure that orientation of corresponding vertices across layers is the same (True
  556. by default). If True, for 'ds_surf_norm', 'orig_surf_norm', and 'cps', orientations
  557. computed from the pial surface are used for all layers.
  558. Returns
  559. -------
  560. orientations : np.ndarray
  561. An array of dipole orientations for each vertex in each layer.
  562. Raises
  563. ------
  564. ValueError
  565. If the number of vertices in pial and white matter surfaces do not match.
  566. """
  567. orientations = None
  568. if method == 'link_vector':
  569. # Method: Use link vectors between pial and white surfaces as dipole orientations
  570. # Load downsampled pial and white surfaces
  571. pial_surf = nib.load(os.path.join(surf_dir, 'pial.ds.gii'))
  572. white_surf = nib.load(os.path.join(surf_dir, 'white.ds.gii'))
  573. # Extract vertices
  574. pial_vertices = pial_surf.darrays[0].data
  575. white_vertices = white_surf.darrays[0].data
  576. # Ensure same number of vertices in pial and white surfaces
  577. if pial_vertices.shape[0] != white_vertices.shape[0]:
  578. raise ValueError("Pial and white surfaces must have the same number of vertices")
  579. # Compute link vectors
  580. link_vectors = white_vertices - pial_vertices
  581. link_vectors = _normit(link_vectors)
  582. # Replicate link vectors for each layer
  583. orientations = np.tile(link_vectors, (len(layer_names), 1, 1))
  584. elif method == 'ds_surf_norm':
  585. # Method: Use normals of the downsampled surfaces
  586. orientations = []
  587. for l_idx, layer_name in enumerate(layer_names):
  588. if l_idx == 0 or not fixed:
  589. in_surf_path = os.path.join(surf_dir, f'{layer_name}.ds.gii')
  590. surf = nib.load(in_surf_path)
  591. vtx_norms, _ = mesh_normals(
  592. surf.darrays[0].data,
  593. surf.darrays[1].data,
  594. unit=True
  595. )
  596. orientations.append(vtx_norms)
  597. orientations = np.array(orientations)
  598. elif method == 'orig_surf_norm':
  599. # Method: Use normals of the original surfaces, mapped to downsampled surfaces
  600. orientations = []
  601. for l_idx, layer_name in enumerate(layer_names):
  602. if l_idx == 0 or not fixed:
  603. in_surf_path = os.path.join(surf_dir, f'{layer_name}.gii')
  604. orig_surf = nib.load(in_surf_path)
  605. ds_surf_path = os.path.join(surf_dir, f'{layer_name}.ds.gii')
  606. ds_surf = nib.load(ds_surf_path)
  607. kdtree = cKDTree(orig_surf.darrays[0].data)
  608. _, orig_vert_idx = kdtree.query(ds_surf.darrays[0].data, k=1)
  609. orig_vert_idx = np.squeeze(orig_vert_idx)
  610. vtx_norms, _ = mesh_normals(
  611. orig_surf.darrays[0].data,
  612. orig_surf.darrays[1].data,
  613. unit=True
  614. )
  615. orientations.append(vtx_norms[orig_vert_idx, :])
  616. orientations = np.array(orientations)
  617. elif method == 'cps':
  618. # Method: Use cortical patch statistics for normals
  619. orientations = []
  620. for l_idx, layer_name in enumerate(layer_names):
  621. if l_idx == 0 or not fixed:
  622. in_surf_path = os.path.join(surf_dir, f'{layer_name}.gii')
  623. orig_surf = nib.load(in_surf_path)
  624. ds_surf_path = os.path.join(surf_dir, f'{layer_name}.ds.gii')
  625. ds_surf = nib.load(ds_surf_path)
  626. kdtree = cKDTree(ds_surf.darrays[0].data)
  627. _, ds_vert_idx = kdtree.query(orig_surf.darrays[0].data, k=1)
  628. ds_vert_idx = np.squeeze(ds_vert_idx)
  629. orig_vtx_norms, _ = mesh_normals(
  630. orig_surf.darrays[0].data,
  631. orig_surf.darrays[1].data,
  632. unit=True
  633. )
  634. vtx_norms, _ = mesh_normals(
  635. ds_surf.darrays[0].data,
  636. ds_surf.darrays[1].data,
  637. unit=True
  638. )
  639. for v_idx in range(vtx_norms.shape[0]):
  640. orig_idxs = np.where(ds_vert_idx == v_idx)[0]
  641. if len(orig_idxs):
  642. vtx_norms[v_idx, :] = np.mean(orig_vtx_norms[orig_idxs, :], axis=0)
  643. vtx_norms = _normit(vtx_norms)
  644. orientations.append(vtx_norms)
  645. orientations = np.array(orientations)
  646. return orientations
  647. def create_layer_mesh(layer, hemispheres, fs_subject_dir) -> None:
  648. """
  649. Create or retrieve a specified cortical layer mesh file name or path based on the provided
  650. layer proportional thickness or identifier.
  651. Parameters
  652. ----------
  653. layer : float or int
  654. Specifies the cortical layer. The value 1 corresponds to the 'pial' surface, values between
  655. 0 and 1 (exclusive) correspond to intermediate layers (specified as a decimal), and the
  656. value 0 corresponds to the 'white matter' surface.
  657. hemispheres : list of str
  658. A list of hemisphere identifiers (e.g., ['lh', 'rh']) for which meshes should be created or
  659. retrieved.
  660. fs_subject_dir : str
  661. Path to the subject directory within the FreeSurfer environment. This directory should
  662. include a 'surf' directory where mesh files are stored.
  663. Returns
  664. -------
  665. layer_name : str or None
  666. Returns a string representing the mesh layer ('pial', 'white', or a specific intermediate
  667. layer as a formatted string). Returns None if the input layer does not match any recognized
  668. pattern (e.g., a negative number or a number greater than 1).
  669. Notes
  670. -----
  671. For intermediate layers (0 < layer < 1), the function will check for the existence of the mesh
  672. file corresponding to each hemisphere. If it does not exist, it uses 'mris_expand' to generate
  673. it using the white matter surface file. If the layer exactly matches 0 or 1, it returns the
  674. corresponding standard FreeSurfer mesh identifier ('white' or 'pial').
  675. """
  676. if layer == 1:
  677. return 'pial'
  678. if 0 < layer < 1:
  679. layer_name = f'{layer:.3f}'
  680. for hemi in hemispheres:
  681. wm_file = os.path.join(fs_subject_dir, 'surf', f'{hemi}.white')
  682. out_file = os.path.join(fs_subject_dir, 'surf', f'{hemi}.{layer_name}')
  683. if not os.path.exists(out_file):
  684. cmd = ['mris_expand', '-thickness', wm_file, f'{layer}', out_file]
  685. subprocess.run(cmd, check=True)
  686. return layer_name
  687. if layer == 0:
  688. return 'white'
  689. return None
  690. # pylint: disable=R0912,R0915
  691. def postprocess_freesurfer_surfaces(subj_id,
  692. out_dir,
  693. out_fname,
  694. n_surfaces=11,
  695. ds_factor=0.1,
  696. orientation='link_vector',
  697. fix_orientation=True,
  698. remove_deep=True,
  699. n_jobs=-1):
  700. """
  701. Process and combine FreeSurfer surface meshes for a subject.
  702. This function processes FreeSurfer surface meshes for a given subject by creating intermediate
  703. surfaces, adjusting for RAS offset, removing deep vertices, combining hemispheres,
  704. downsampling, and computing link vectors. The resulting surfaces are combined and saved to a
  705. specified output file.
  706. Parameters
  707. ----------
  708. subj_id : str
  709. Subject ID corresponding to the FreeSurfer subject directory.
  710. out_dir : str
  711. Output directory where the processed files will be saved.
  712. out_fname : str
  713. Filename for the final combined surface mesh.
  714. n_surfaces : int, optional
  715. Number of intermediate surfaces to create between white and pial surfaces.
  716. ds_factor : float, optional
  717. Downsampling factor for surface decimation.
  718. orientation : str, optional
  719. Method to compute orientation vectors ('link_vector' for pial-white link, 'ds_surf_norm'
  720. for downsampled surface normals, 'orig_surf_norm' for original surface normals, and 'cps'
  721. for cortical patch statistics).
  722. fix_orientation : bool, optional
  723. Flag to ensure that orientation of corresponding vertices across layers is the same (True
  724. by default).
  725. remove_deep : bool, optional
  726. Flag to remove vertices located in deep regions (labeled as 'unknown').
  727. n_jobs : int, optional
  728. Number of parallel processes to run. -1 for all available cores (default is -1).
  729. Notes
  730. -----
  731. - This function assumes the FreeSurfer 'SUBJECTS_DIR' environment variable is set.
  732. - Surfaces are processed in Gifti format and combined into a single surface mesh.
  733. Example
  734. -------
  735. >>> postprocess_freesurfer_surfaces('subject1', '/path/to/output', 'combined_surface.gii')
  736. """
  737. hemispheres = ['lh', 'rh']
  738. fs_subjects_dir = os.getenv('SUBJECTS_DIR')
  739. fs_subject_dir = os.path.join(fs_subjects_dir, subj_id)
  740. layers = np.linspace(1, 0, n_surfaces)
  741. ## Create intermediate surfaces if needed
  742. layer_names = Parallel(n_jobs=n_jobs, backend='loky')(
  743. delayed(create_layer_mesh)(layer, hemispheres, fs_subject_dir) for layer in layers
  744. )
  745. orig_mgz = os.path.join(fs_subject_dir, 'mri', 'orig.mgz')
  746. def _mat(flag):
  747. out = subprocess.check_output(['mri_info', flag, orig_mgz]).decode().split()
  748. return np.array([float(x) for x in out]).reshape(4, 4)
  749. t_orig = _mat('--vox2ras-tkr') # vox -> tkRAS (mm)
  750. n_orig = _mat('--vox2ras') # vox -> scanner RAS (mm)
  751. inv_t_orig = np.linalg.inv(t_orig)
  752. def tkras_to_scanner_ras(coords_mm):
  753. # coords_mm: (N,3) tkRAS
  754. xyz1 = np.c_[coords_mm, np.ones((coords_mm.shape[0], 1))]
  755. out = (n_orig @ (inv_t_orig @ xyz1.T)).T[:, :3]
  756. return out
  757. ## Convert to gifti, adjust for RAS offset, and remove deep vertices
  758. surfaces_to_process = copy.copy(layer_names)
  759. surfaces_to_process.append('inflated')
  760. # pylint: disable=R1702
  761. for surface_name in surfaces_to_process:
  762. for hemi in hemispheres:
  763. # Construct the original and new file names
  764. orig_name = os.path.join(fs_subject_dir, 'surf', f'{hemi}.{surface_name}')
  765. new_name = os.path.join(out_dir, f'{hemi}.{surface_name}.gii')
  766. rm_deep_name = os.path.join(out_dir, f'{hemi}.{surface_name}.nodeep.gii')
  767. # Convert the surface file to Gifti format
  768. subprocess.run(['mris_convert', orig_name, new_name], check=True)
  769. # Load the Gifti file
  770. surf_g = nib.load(new_name)
  771. # Set transformation matrix to identity
  772. surf_g.affine = np.eye(4)
  773. # Adjust for RAS offset
  774. n_vertices = 0
  775. for data_array in surf_g.darrays:
  776. if data_array.intent == nib.nifti1.intent_codes['NIFTI_INTENT_POINTSET']:
  777. data_array.data = tkras_to_scanner_ras(data_array.data)
  778. n_vertices = data_array.data.shape[0]
  779. nib.save(surf_g, new_name)
  780. annotation = os.path.join(fs_subject_dir, 'label', f'{hemi}.aparc.annot')
  781. label, _, names = nib.freesurfer.read_annot(annotation)
  782. # Remove vertices created by cutting the hemispheres
  783. if remove_deep:
  784. vertices_to_remove = []
  785. for vtx in range(n_vertices):
  786. region_name = names[label[vtx]]
  787. if isinstance(region_name, bytes):
  788. region_name = region_name.decode('utf-8', errors='ignore')
  789. if label[vtx] <= 0 or region_name.lower() == 'unknown':
  790. vertices_to_remove.append(vtx)
  791. surf_g = remove_vertices(surf_g, np.array(vertices_to_remove))
  792. # Save the modified Gifti file
  793. nib.save(surf_g, rm_deep_name)
  794. ## Combine hemispheres
  795. for surface_name in surfaces_to_process:
  796. # Load left and right hemisphere surfaces
  797. lh_fname = os.path.join(out_dir, f'lh.{surface_name}.nodeep.gii')
  798. l_hemi = nib.load(lh_fname)
  799. rh_fname = os.path.join(out_dir, f'rh.{surface_name}.nodeep.gii')
  800. r_hemi = nib.load(rh_fname)
  801. if surface_name == 'inflated':
  802. lh_width = np.max(l_hemi.darrays[0].data[:, 0])-np.min(l_hemi.darrays[0].data[:, 0])
  803. r_hemi.darrays[0].data[:, 0] = r_hemi.darrays[0].data[:, 0] + \
  804. np.max(l_hemi.darrays[0].data[:, 0]) + \
  805. (.5*lh_width)
  806. # Combine the surfaces
  807. combined = combine_surfaces([l_hemi, r_hemi])
  808. combined_fname = os.path.join(out_dir, f'{surface_name}.gii')
  809. nib.save(combined, combined_fname)
  810. ## Downsample surfaces at the same time
  811. # Get list of surfaces
  812. in_surfs = []
  813. for surface_name in surfaces_to_process:
  814. in_surf_fname = os.path.join(out_dir, f'{surface_name}.gii')
  815. in_surf = nib.load(in_surf_fname)
  816. in_surfs.append(in_surf)
  817. # Downsample multiple surfaces
  818. out_surfs = downsample_multiple_surfaces(in_surfs, ds_factor)
  819. for surface_name, out_surf in zip(surfaces_to_process, out_surfs):
  820. out_surf_path = os.path.join(out_dir, f'{surface_name}.ds.gii')
  821. nib.save(out_surf, out_surf_path)
  822. ## Compute dipole orientations
  823. orientations = compute_dipole_orientations(
  824. orientation,
  825. layer_names,
  826. out_dir,
  827. fixed=fix_orientation
  828. )
  829. base_fname = f'ds.{orientation}.not_fixed'
  830. if fix_orientation:
  831. base_fname = f'ds.{orientation}.fixed'
  832. for l_idx, layer_name in enumerate(layer_names):
  833. in_surf_path = os.path.join(out_dir, f'{layer_name}.ds.gii')
  834. surf = nib.load(in_surf_path)
  835. # Set these link vectors as the normals for the downsampled surface
  836. ori_array=nib.gifti.GiftiDataArray(data=orientations[l_idx, :, :],
  837. intent=nib.nifti1.intent_codes['NIFTI_INTENT_VECTOR'])
  838. surf.add_gifti_data_array(ori_array)
  839. # Save the modified downsampled surface with link vectors as normals
  840. out_surf_path = os.path.join(out_dir, f'{layer_name}.{base_fname}.gii')
  841. nib.save(surf, out_surf_path)
  842. ## Combine layers
  843. all_surfs = []
  844. for layer_name in layer_names:
  845. surf_path = os.path.join(out_dir, f'{layer_name}.{base_fname}.gii')
  846. surf = nib.load(surf_path)
  847. all_surfs.append(surf)
  848. combined = combine_surfaces(all_surfs)
  849. nib.save(combined, os.path.join(out_dir, out_fname))
  850. def mesh_adjacency(faces):
  851. """
  852. Compute the adjacency matrix of a triangle mesh.
  853. Parameters
  854. ----------
  855. faces : np.array, shape (f, 3)
  856. The mesh faces, where `f` is the number of faces, and each face is represented by a tuple of
  857. three vertex indices.
  858. Returns
  859. -------
  860. adjacency : np.array, shape (v, v)
  861. The adjacency matrix of the mesh, where `v` is the number of vertices. Each entry (i, j)
  862. indicates whether vertices i and j are connected by an edge.
  863. """
  864. faces = np.asarray(faces, dtype=int)
  865. n_vertices = np.max(faces)+1 # Assuming max vertex index represents the number of vertices
  866. # Flatten the indices to create row and column indices for the adjacency matrix
  867. row_indices = np.hstack([faces[:, 0], faces[:, 0], faces[:, 1],
  868. faces[:, 1], faces[:, 2], faces[:, 2]])
  869. col_indices = np.hstack([faces[:, 1], faces[:, 2], faces[:, 0],
  870. faces[:, 2], faces[:, 0], faces[:, 1]])
  871. # Create a sparse matrix from row and column indices
  872. adjacency = csr_matrix(
  873. (np.ones_like(row_indices), (row_indices, col_indices)),
  874. shape=(n_vertices, n_vertices)
  875. )
  876. # Ensure the adjacency matrix is binary
  877. adjacency = (adjacency > 0).astype(int)
  878. return adjacency
  879. def interpolate_data(original_mesh, downsampled_mesh, downsampled_data, adjacency_matrix=None,
  880. k_neighbors=5):
  881. """
  882. Interpolate vertex data from a downsampled mesh back to the original mesh using weighted
  883. averaging of the k-nearest downsampled vertices. After initial interpolation, vertices that
  884. are in the downsampled mesh are refined using a weighted sum of their neighbors.
  885. Parameters
  886. ----------
  887. original_mesh : nibabel.gifti.GiftiImage
  888. The original high-resolution mesh as a nibabel Gifti object.
  889. downsampled_mesh : nibabel.gifti.GiftiImage
  890. The downsampled version of the original mesh.
  891. downsampled_data : np.ndarray
  892. Data values at each vertex in 'downsampled_mesh'.
  893. adjacency_matrix : scipy.sparse matrix, optional
  894. The adjacency matrix of the original mesh.
  895. If None, it will be computed.
  896. k_neighbors : int, optional
  897. Number of nearest downsampled neighbors to use for interpolation.
  898. Returns
  899. -------
  900. vertex_data : np.ndarray
  901. An array of interpolated data for each vertex in 'original_mesh'.
  902. """
  903. original_vertices = original_mesh.darrays[0].data
  904. downsampled_vertices = downsampled_mesh.darrays[0].data
  905. # Build a KD-tree for the downsampled mesh
  906. tree = cKDTree(downsampled_vertices)
  907. # Find the k nearest downsampled vertices for each original vertex
  908. distances, indices = tree.query(original_vertices, k=k_neighbors)
  909. indices = np.squeeze(indices)
  910. # Initialize interpolated data array
  911. vertex_data = np.full(len(original_vertices), np.nan)
  912. # Compute weighted interpolation from k-nearest downsampled vertices
  913. valid_mask = indices[:, 0] < len(downsampled_data) # Ensure indices are within bounds
  914. for i in np.where(valid_mask)[0]:
  915. valid_indices = indices[i, :]
  916. valid_distances = distances[i, :]
  917. # Avoid division by zero (replace zero distances with a small number)
  918. valid_distances[valid_distances == 0] = 1e-6
  919. weights = 1 / valid_distances
  920. weights /= weights.sum() # Normalize weights
  921. vertex_data[i] = np.dot(weights, downsampled_data[valid_indices])
  922. # Second pass: Refine values for vertices that were in the downsampled mesh
  923. if adjacency_matrix is None:
  924. adjacency_matrix = mesh_adjacency(original_mesh.darrays[1].data)
  925. downsampled_vertex_mask = np.isin(np.arange(len(original_vertices)), indices[:, 0])
  926. for i in np.where(downsampled_vertex_mask)[0]:
  927. neighbors = adjacency_matrix[i].nonzero()[1]
  928. if len(neighbors) > 0:
  929. distances = np.linalg.norm(original_vertices[neighbors] - original_vertices[i], axis=1)
  930. distances[distances == 0] = 1e-6 # Avoid division by zero
  931. weights = 1 / distances
  932. weights /= weights.sum() # Normalize weights
  933. vertex_data[i] = np.dot(weights, vertex_data[neighbors])
  934. return vertex_data
  935. def split_fv(faces, vertices):
  936. """
  937. Split faces and vertices into connected pieces based on the connectivity of the faces.
  938. Parameters
  939. ----------
  940. faces : np.ndarray
  941. A 2D numpy array of faces, where each row represents a face and each element is an index to
  942. a vertex in `vertices`.
  943. vertices : np.ndarray
  944. A 2D numpy array of vertices, where each row represents a vertex.
  945. Returns
  946. -------
  947. fv_out : list of dict
  948. A list where each element is a dictionary with keys 'faces' and 'vertices'. Each dictionary
  949. represents a separately connected patch of the mesh.
  950. Examples
  951. --------
  952. >>> faces = np.array([[1, 2, 3], [1, 3, 4], [5, 6, 1], [7, 8, 9], [11, 10, 4]])
  953. >>> vertices = np.array([[2, 4], [2, 8], [8, 4], [8, 0], [0, 4], [2, 6], [2, 2], [4, 2],
  954. >>> [4, 0], [5, 2], [5, 0]])
  955. >>> split_patches = split_fv(faces, vertices)
  956. Notes
  957. -----
  958. Faces and vertices should be defined such that faces sharing a vertex reference the same vertex
  959. number. This function does not explicitly test for duplicate vertices at the same location.
  960. """
  961. num_faces = faces.shape[0]
  962. f_sets = np.zeros(num_faces, dtype=np.uint32)
  963. current_set = 0
  964. while np.any(f_sets == 0):
  965. current_set += 1
  966. next_avail_face = np.where(f_sets == 0)[0][0]
  967. open_vertices = faces[next_avail_face]
  968. while open_vertices.size:
  969. avail_face_inds = np.where(f_sets == 0)[0]
  970. is_member = np.isin(faces[avail_face_inds], open_vertices)
  971. avail_face_sub = np.where(np.any(is_member, axis=1))[0]
  972. f_sets[avail_face_inds[avail_face_sub]] = current_set
  973. open_vertices = np.unique(faces[avail_face_inds[avail_face_sub]])
  974. fv_out = []
  975. for set_num in range(1, current_set + 1):
  976. set_f = faces[f_sets == set_num]
  977. unique_vertices, new_vertex_indices = np.unique(set_f, return_inverse=True)
  978. fv_out.append({
  979. 'faces': new_vertex_indices.reshape(set_f.shape),
  980. 'vertices': vertices[unique_vertices]
  981. })
  982. return fv_out

surf.py, under GPL-3.0 · at the source

Overview

Authors: Maciej J. Szul1,2,3, Matteo G. Maspoli1,2,4,5, Danila Shelepenkov2,4,5, Ishita Agarwal1,2,6, Quentin Moreau1,2, Solène Gailhard1,2, Maxime Ferez2,4,5, Carolina Fernandez Pujol7,8, Yunkai Zhu8,9, Bassem Hiba1,2, Sebastien Daligault10, Franck Lamberton10,11, Denis Schwartz4,5,10, Jérémie Mattout2,4,5, Mathilde Bonnefond2,4,5, Andrew R. Dykstra8,9, Sven Bestmann12,13, Gareth R. Barnes12, James J. Bonaiuto1,2
13 affiliations
  1. Decision, Action, and Neural Computation (DANC) team, Institut des Sciences Cognitives Marc Jeannerod,CNRS UMR5229 Lyon, France
  2. Université Claude Bernard Lyon 1, Université de Lyon,Lyon, France
  3. Department of Psychiatry and Psychotherapy, Faculty of Medicine, University of Tübingen,Tübingen, Germany
  4. Lyon Neuroscience Research Center, CRNL, INSERM U1028, CNRS UMR5292,Lyon, France
  5. INRIA, Computation, Cognition and Neurophysiology (COPHY) team,Lyon, France
  6. Department of Psychological Sciences, Purdue University,West Lafayette, IN USA
  7. Department of Neuroscience, Carney Institute of Brain Science, Brown University,Providence, RI USA
  8. Department of Biomedical Engineering, University of Miami,Coral Gables, FL USA
  9. School of Communication Sciences and Disorders, University of Central Florida,Orlando, FL USA
  10. CERMEP-Imagerie du Vivant, Lyon, France
  11. Lyon-East Health Research Federation, CNRS UAR3453, INSERM US7, Université Claude Bernard Lyon 1,Lyon, France
  12. Department of Imaging Neuroscience, UCL Queen Square Institute of Neurology, University College London,London, UK
  13. Department of Clinical and Movement Neurosciences, UCL Queen Square Institute of Neurology, University College London,London, UK
Journal: Nature communications, volume 17, issue 1, article 10128
Dates: received 20 March 2026; accepted 31 August 2026; published online 30 September 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-77782-9 · PMID 42816533 · PMCID PMC13627721 · OpenAlex W7214994117
Open access: gold, a free copy (OpenAlex)
Status: code verified (how the code is found and verified)
Categories: MEG (modality), human (organism), systems (subfield)
Methods: Connectivity, Statistics, Machine learning, Preprocessing, Spectral & time-frequency, fMRI & imaging
Keywords: Neural circuits, Neuroscience
MeSH: Magnetoencephalography*, Models, Neurological*, Adult, Brain, Brain Mapping, Cerebral Cortex, Computer Simulation, Female, Humans, Male, Signal-To-Noise Ratio (* major topic)
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: European Research Council (864550, 716862); Agence Nationale de la Recherche (ANR-20-CE17-0023, ANR-25-CE37-0908-01); Fondation pour l’Audition (RD-2022-03); Wellcome Trust (Wellcome) (226793/Z/22/Z)
Citations: not cited yet (Europe PMC); 69 references in the paper

Abstract

Neural dynamics at the laminar level are critical for cortical computation. However, in humans, non-invasive methods to probe such dynamics have been limited to coarse distinctions between deep and superficial layers. Here, we present a multilayer magnetoencephalography source reconstruction framework and evaluate the conditions under which depth-resolved laminar inference may be feasible. Using simulations, we systematically assess the limits of magnetoencephalography depth resolution, showing that laminar discrimination depends on sufficiently high signal-to-noise ratio, precise co-registration, and accurate specification of cortical column orientation. We demonstrate that regional variations in cortical anatomy influence reconstruction fidelity, with lead-field separability emerging as a key determinant. We then apply this framework to empirical data from three independent datasets and find laminar activation patterns that align with canonical feedforward and feedback motifs in visual and sensorimotor circuits, supporting the plausibility of laminar inference under favorable conditions and offering opportunities to bridge invasive electrophysiology and human neuroimaging.

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 16 matches between paragraphs and lines of code.

danclab/spm

License: GPL-2.0
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: 2a3ea29c41014b0230fb78ff34401ffbe68fb1ab, 29 September 2026
Languages: MATLAB (4597), C (126), C/C++ (34), CUDA (13), C++ (5), JavaScript (2), Java (1)
Size: 5,433 files, 4,778 scripts
Software Heritage: archived
Found in: the text, “Simulations”
Holds: README, license file, continuous integration
Not found: CITATION.cff, environment file, tests, documentation
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
2,000 files
At the source: github.com/danclab/spm

danclab/multilaminar_sim

License: none: the authors keep all their rights
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: cdc682481d6afc522ac3c8de534f8b5eb394261b, 4 August 2026
Languages: Jupyter (31), Python (23), Shell (15), MATLAB (5)
Size: 78 files, 74 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, 31 notebooks
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (9 files), Matplotlib (7 files), NiBabel (7 files), pandas (7 files), SciPy (7 files), Pillow (3 files), h5py (1 file), scikit-learn (1 file), statsmodels (1 file)
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
10 files

jbonaiuto/cued_action_selection

License: GPL-3.0
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: 58ea009b36a03992609bf68bf493158972ac8102, 14 September 2022
Languages: MATLAB (15)
Size: 17 files, 15 scripts
Software Heritage: archived
Found in: “Code availability”
Holds: README, license file
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Psychtoolbox (1 file)
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
17 files

danclab/auditory_laminar

License: none: the authors keep all their rights
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: ecf322b04d855c143ffb2d86b160d11230e3cd3c, 15 June 2026
Languages: Python (24), Jupyter (2), MATLAB (2)
Size: 385 files, 28 scripts
Software Heritage: archived
Found in: “Code availability”
Holds: 2 notebooks
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (14 files), MNE-Python (12 files), Matplotlib (11 files), pandas (7 files), SciPy (7 files), NiBabel (5 files), PsychoPy (5 files), Psychtoolbox (5 files), autoreject (2 files), h5py (2 files), FreeSurfer (1 file), FSL (1 file), MEEGkit (1 file)
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
28 files

TommyClausner/meglyon-experiment-runner

License: GPL-3.0
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: 3142259d00c028187537b91c54fe6151f49f31fc, 25 January 2024
Languages: Python (17)
Size: 2,089 files, 17 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, environment (requirements.txt, setup.cfg, setup.py)
Not found: CITATION.cff, tests, continuous integration, documentation
Tools: NumPy (13 files), PsychoPy (7 files), Matplotlib (2 files)
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
19 files

danclab/laminar_motor_erf

License: GPL-3.0
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: c4a2f09d40615349d710ed32e73ea22668ef26a1, 15 June 2026
Languages: Python (16), Jupyter (2), MATLAB (1)
Size: 22 files, 19 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: license file, 2 notebooks
Not found: README, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (12 files), MNE-Python (10 files), Matplotlib (7 files), SciPy (7 files), NiBabel (5 files), pandas (4 files), h5py (2 files), autoreject (1 file), MEEGkit (1 file)
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
20 files

cophyteam/laminar_visual_erf

License: GPL-3.0
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Commit: 7361a1c56cbf0ac1edf8f5fb5ffa2f2f0d328fc0, 5 August 2026
Languages: Jupyter (9), Python (2), MATLAB (2)
Size: 17 files, 13 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README, license file, 9 notebooks
Not found: CITATION.cff, environment file, tests, continuous integration, documentation
Tools: Matplotlib (5 files), NumPy (5 files), SciPy (4 files), MNE-Python (3 files), NiBabel (3 files), pandas (3 files), autoreject (1 file), FreeSurfer (1 file), h5py (1 file)
Availability: 1 check, the latest on 3 October 2026: the link answers
  • 3 October 2026: the link answers
8 files

Zenodo 21638150

License: GPL-3.0
State: the link answers, verified on 3 October 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: the references
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: NumPy (20 files), NiBabel (13 files), Matplotlib (10 files), SciPy (7 files), h5py (4 files), MNE-Python (3 files), Elephant (2 files), FreeSurfer (2 files), Neo (2 files)
Availability: 1 check, the latest on 3 October 2026: the link answers (HTTP 200)
  • 3 October 2026: the link answers (HTTP 200)
27 files
At the source:

Code availability

The laMEG software package used for multilayer source reconstruction and laminar inference has been deposited in Zenodo65. Experimental stimulus presentation and behavioral response collection were performed using task-specific software available at: https://github.com/jbonaiuto/cued_action_selection, https://github.com/danclab/auditory_laminar, and https://gitlab.com/TommyClausner/meglyon-experiment-runner. All custom data analysis code used in this study is available at https://github.com/danclab/multilaminar_sim/, https://github.com/danclab/laminar_motor_erf/, https://github.com/danclab/auditory_laminar, and https://github.com/cophyteam/laminar_visual_erf.

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

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:

  • 8 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 2,119 scripts, each with its path and the digest of its content;
  • 16 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

Data availability

The preprocessed MEG data, structural MRI scans, cortical surface reconstructions, and fiducial locations underlying the empirical analyses have been deposited in the Zenodo repository (https://doi.org/10.5281/zenodo.20721185). Raw MEG and MRI data are not publicly available because they contain identifiable human participant data and are subject to institutional ethical and data protection requirements. Access to the raw data may be granted to qualified researchers for research purposes, subject to approval by the relevant ethics committee and completion of an appropriate data-sharing agreement. Requests should be directed to the corresponding author, who will respond within a reasonable timeframe. Access will be provided for the duration of the approved research project. Source data are provided with this paper.

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, 3 October 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 19 authors, 2 keywords, 11 MeSH terms, 4 funders, 66 references.

Cite

This paper

Szul, M. J., Maspoli, M. G., Shelepenkov, D., Agarwal, I., Moreau, Q., Gailhard, S., Ferez, M., Fernandez Pujol, C., Zhu, Y., Hiba, B., Daligault, S., Lamberton, F., Schwartz, D., Mattout, J., Bonnefond, M., Dykstra, A. R., Bestmann, S., Barnes, G. R., & Bonaiuto, J. J. (2026). Multilayer MEG source modelling enables depth-resolved laminar inference in humans. Nature communications, 17(1), 10128. https://doi.org/10.1038/s41467-026-77782-9

BibTeX

@article{szul2026multilayer,
author = {Szul, Maciej J. and Maspoli, Matteo G. and Shelepenkov, Danila and Agarwal, Ishita and Moreau, Quentin and Gailhard, Solène and Ferez, Maxime and Fernandez Pujol, Carolina and Zhu, Yunkai and Hiba, Bassem and Daligault, Sebastien and Lamberton, Franck and Schwartz, Denis and Mattout, Jérémie and Bonnefond, Mathilde and Dykstra, Andrew R. and Bestmann, Sven and Barnes, Gareth R. and Bonaiuto, James J.},
title = {{Multilayer MEG source modelling enables depth-resolved laminar inference in humans}},
journal = {Nature communications},
year = {2026},
month = sep,
volume = {17},
number = {1},
pages = {10128},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-77782-9},
url = {https://doi.org/10.1038/s41467-026-77782-9},
pmid = {42816533},
pmcid = {PMC13627721}
}

RIS

TY - JOUR
AU - Szul, Maciej J.
AU - Maspoli, Matteo G.
AU - Shelepenkov, Danila
AU - Agarwal, Ishita
AU - Moreau, Quentin
AU - Gailhard, Solène
AU - Ferez, Maxime
AU - Fernandez Pujol, Carolina
AU - Zhu, Yunkai
AU - Hiba, Bassem
AU - Daligault, Sebastien
AU - Lamberton, Franck
AU - Schwartz, Denis
AU - Mattout, Jérémie
AU - Bonnefond, Mathilde
AU - Dykstra, Andrew R.
AU - Bestmann, Sven
AU - Barnes, Gareth R.
AU - Bonaiuto, James J.
TI - Multilayer MEG source modelling enables depth-resolved laminar inference in humans
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/09/30
VL - 17
IS - 1
SP - 10128
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-77782-9
UR - https://doi.org/10.1038/s41467-026-77782-9
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-77782-9",
"type": "article-journal",
"title": "Multilayer MEG source modelling enables depth-resolved laminar inference in humans",
"container-title": "Nature communications",
"author": [
{
"family": "Szul",
"given": "Maciej J."
},
{
"family": "Maspoli",
"given": "Matteo G."
},
{
"family": "Shelepenkov",
"given": "Danila"
},
{
"family": "Agarwal",
"given": "Ishita"
},
{
"family": "Moreau",
"given": "Quentin"
},
{
"family": "Gailhard",
"given": "Solène"
},
{
"family": "Ferez",
"given": "Maxime"
},
{
"family": "Fernandez Pujol",
"given": "Carolina"
},
{
"family": "Zhu",
"given": "Yunkai"
},
{
"family": "Hiba",
"given": "Bassem"
},
{
"family": "Daligault",
"given": "Sebastien"
},
{
"family": "Lamberton",
"given": "Franck"
},
{
"family": "Schwartz",
"given": "Denis"
},
{
"family": "Mattout",
"given": "Jérémie"
},
{
"family": "Bonnefond",
"given": "Mathilde"
},
{
"family": "Dykstra",
"given": "Andrew R."
},
{
"family": "Bestmann",
"given": "Sven"
},
{
"family": "Barnes",
"given": "Gareth R."
},
{
"family": "Bonaiuto",
"given": "James J."
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "10128",
"DOI": "10.1038/s41467-026-77782-9",
"PMID": "42816533",
"PMCID": "PMC13627721",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-77782-9",
"language": "en",
"issued": {
"date-parts": [
[
2026,
9,
30
]
]
}
}

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.1101/2025.05.28.656642 [code]
Multilayer MEG source modelling enables depth-resolved laminar inference in humans
Journal: bioRxiv (preprint)
In common: MEEGkit, autoreject, Brainstorm, 24 other tools, MEG, systems, 38 references, 6 authors
[2] doi:10.1162/imag_a_00391 [code]
Surfing beta burst waveforms to improve motor imagery-based BCI.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: MEEGkit, autoreject, MNE-Python, 5 other tools, 2 references, 3 authors
[3] doi:10.64898/2026.03.06.710026 [code]
Distinct beta burst motifs exhibit opposing error relationships during motor adaptation
Journal: bioRxiv (preprint)
In common: MNE-Python, FieldTrip, statsmodels, 7 other tools, 2 references, 3 authors
[4] doi:10.1523/jneurosci.0886-23.2023 [code]
Bursting with Potential: How Sensorimotor Beta Bursts Develop from Infancy to Adulthood.
Journal: The Journal of neuroscience : the official journal of the Society for Neuroscience
In common: MEEGkit, EEGLAB, MNE-Python, 9 other tools, 2 references, 2 authors
[5] doi:10.7554/elife.108408 [code]
Frequency and laminar profile of feature-specific visual activity revealed by interleaved EEG-fMRI.
Journal: eLife
In common: FieldTrip, FreeSurfer, FSL, 11 other tools, systems, 5 references, author Mathilde Bonnefond
[6] doi:10.1162/imag.a.1189 [code]
Distorting anatomy to test MEG models and metrics.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: GIfTI library for MATLAB, SPM, Statistics and Machine Learning Toolbox, MEG, 10 references, author Gareth R. Barnes
[7] doi:10.1162/imag.a.1040 [code]
Novel 4 He-OPMs support waveform-specific beta burst analysis comparable to SQUID-MEG
Journal: n/a
In common: MEEGkit, autoreject, MNE-Python, 8 other tools, MEG, 2 references, author Denis Schwartz
[8] doi:10.1038/s41467-023-41689-6 [code]
Neurophysiological signatures of cortical micro-architecture.
Journal: Nature communications
In common: Brainstorm, GIfTI library for MATLAB, FieldTrip, 13 other tools, MEG, 2 references
[9] doi:10.1523/eneuro.0288-25.2025 [code]
Visual Speech Reduces Cognitive Effort as Measured by EEG Theta Power and Pupil Dilation.
Journal: eNeuro
In common: Brainstorm, Neurodata Without Borders (PyNWB, MatNWB), Psychtoolbox, 12 other tools
[10] doi:10.1162/imag_a_00410 [code]
Inferring laminar origins of MEG signals with optically pumped magnetometers (OPMs): A simulation study.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: GIfTI library for MATLAB, FieldTrip, SPM, 2 other tools, MEG, systems, 9 references

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.

Add the badge to the code's README

OSCR: the badge of this paper

If you are an author or a maintainer of this paper's code, you can add this badge. It says what OSCR knows of the paper (its code, its validated tracing map) and links the code to this page. Paste one of these snippets under the README's title. Keep the two marks around it, so that the badge can be replaced in place and is never added twice. OSCR changes nothing in your repository.

Markdown
<!-- oscr-badge:start -->
[![OSCR](https://openscicode.org/badge/doi/10.1038/s41467-026-77782-9.svg)](https://openscicode.org/badge/doi/10.1038/s41467-026-77782-9)
<!-- oscr-badge:end -->
reStructuredText
.. oscr-badge:start

.. image:: https://openscicode.org/badge/doi/10.1038/s41467-026-77782-9.svg
   :target: https://openscicode.org/badge/doi/10.1038/s41467-026-77782-9
   :alt: OSCR

.. oscr-badge:end
HTML
<!-- oscr-badge:start -->
<a href="https://openscicode.org/badge/doi/10.1038/s41467-026-77782-9"><img src="https://openscicode.org/badge/doi/10.1038/s41467-026-77782-9.svg" alt="OSCR"></a>
<!-- oscr-badge:end -->

On GitHub, open the README, choose Edit (the pencil), paste the snippet under the title, then "Commit changes".

If you choose to, you can have the badge kept up to date for you, with oscr badge --write from the command line or with the GitHub Action in your repository's workflows. Every other form and option (a card, AsciiDoc, LaTeX, shields.io) is on the badge 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: code errors and mismatches between the code and the paper about this paper and its code, from readers and its authors, beside the paper and its tracing map.

Report a mismatch between the code and the paper · Report a code error (signed in)

Reproductions: reports from readers who ran the authors' code and did not get the paper's result, what they ran, with which environment, commit and data.

Report a reproduction failure (signed in)

Versions of its code: the releases of its code tied to a version of this paper (the preprint, the accepted manuscript, the version of record…), each with the tracing map versioned with it.

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