|
| 1 | +.. _biap9: |
| 2 | + |
| 3 | +################################ |
| 4 | +BIAP9 - The Coordinate Image API |
| 5 | +################################ |
| 6 | + |
| 7 | +:Author: Chris Markiewicz |
| 8 | +:Status: Draft |
| 9 | +:Type: Standards |
| 10 | +:Created: 2021-09-16 |
| 11 | + |
| 12 | +********** |
| 13 | +Background |
| 14 | +********** |
| 15 | + |
| 16 | +Surface data is generally kept separate from geometric metadata |
| 17 | +=============================================================== |
| 18 | + |
| 19 | +In contrast to volumetric data, whose geometry can be fully encoded in the |
| 20 | +shape of a data array and a 4x4 affine matrix, data sampled to a surface |
| 21 | +require the location of each sample to be explicitly represented by a |
| 22 | +coordinate. In practice, the most common approach is to have a geometry file |
| 23 | +and a data file. |
| 24 | + |
| 25 | +A geometry file consists of a vertex coordinate array and a triangle array |
| 26 | +describing the adjacency of vertices, while a data file is an n-dimensional |
| 27 | +array with one axis corresponding to vertex. |
| 28 | + |
| 29 | +Keeping these files separate is a pragmatic optimization to avoid costly |
| 30 | +reproductions of geometric data, but presents an administrative burden to |
| 31 | +direct consumers of the data. |
| 32 | + |
| 33 | +Terminology |
| 34 | +=========== |
| 35 | + |
| 36 | +For the purposes of this BIAP, the following terms are used: |
| 37 | + |
| 38 | +* Coordinate - a triplet of floating point values in RAS+ space |
| 39 | +* Vertex - an index into a table of coordinates |
| 40 | +* Triangle (or face) - a triplet of adjacent vertices (A-B-C); |
| 41 | + the normal vector for the face is ($\overline{AB}\times\overline{AC}$) |
| 42 | +* Topology - vertex adjacency data, independent of vertex coordinates, |
| 43 | + typically in the form of a list of triangles |
| 44 | +* Geometry - topology + a specific set of coordinates for a surface |
| 45 | +* Parcel - a subset of vertices; can be the full topology. Special cases include: |
| 46 | + * Patch - a connected parcel |
| 47 | + * Decimated mesh - a parcel that has a desired density of vertices |
| 48 | +* Parcel sequence - an ordered set of parcels |
| 49 | +* Data array - an n-dimensional array with one axis corresponding to the |
| 50 | + vertices (typical) OR faces (more rare) in a patch sequence |
| 51 | + |
| 52 | +Currently supported surface formats |
| 53 | +=================================== |
| 54 | + |
| 55 | +* FreeSurfer |
| 56 | + * Geometry (e.g. ``lh.pial``): |
| 57 | + :py:func:`~nibabel.freesurfer.io.read_geometry` / |
| 58 | + :py:func:`~nibabel.freesurfer.io.write_geometry` |
| 59 | + * Data |
| 60 | + * Morphometry: |
| 61 | + :py:func:`~nibabel.freesurfer.io.read_morph_data` / |
| 62 | + :py:func:`~nibabel.freesurfer.io.write_morph_data` |
| 63 | + * Labels: :py:func:`~nibabel.freesurfer.io.read_label` |
| 64 | + * MGH: :py:class:`~nibabel.freesurfer.mghformat.MGHImage` |
| 65 | +* GIFTI: :py:class:`~nibabel.gifti.gifti.GiftiImage` |
| 66 | + * Every image contains a collection of data arrays, which may be |
| 67 | + coordinates, topology, or data (further subdivided by type and intent) |
| 68 | +* CIFTI-2: :py:class:`~nibabel.cifti2.cifti2.Cifti2Image` |
| 69 | + * Pure data array, with image header containing flexible axes |
| 70 | + * The ``BrainModelAxis`` is a subspace sequence including patches for |
| 71 | + each hemisphere (cortex without the medial wall) and subcortical |
| 72 | + structures defined by indices into three-dimensional array and an |
| 73 | + affine matrix |
| 74 | + * Geometry referred to by an associated ``wb.spec`` file |
| 75 | + (no current implementation in NiBabel) |
| 76 | + * Possible to have one with no geometric information, e.g., parcels x time |
| 77 | + |
| 78 | +Other relevant formats |
| 79 | +====================== |
| 80 | + |
| 81 | +* MNE's STC (source time course) format. Contains: |
| 82 | + * Subject name (resolvable with a FreeSurfer ``SUBJECTS_DIR``) |
| 83 | + * Index arrays into left and right hemisphere surfaces (subspace sequence) |
| 84 | + * Data, one of: |
| 85 | + * ndarray of shape ``(n_verts, n_times)`` |
| 86 | + * tuple of ndarrays of shapes ``(n_verts, n_sensors)`` and ``(n_sensors, n_times)`` |
| 87 | + * Time start |
| 88 | + * Time step |
| 89 | + |
| 90 | +***************************************** |
| 91 | +Desiderata for an API supporting surfaces |
| 92 | +***************************************** |
| 93 | + |
| 94 | +The following are provisional guiding principles: |
| 95 | + |
| 96 | +1. A surface image (data array) should carry a reference to geometric metadata |
| 97 | + that is easily transferred to a new image. |
| 98 | +2. Partial images (data only or geometry only) should be possible. Absence of |
| 99 | + components should have a well-defined signature, such as a property that is |
| 100 | + ``None`` or a specific ``Exception`` is raised. |
| 101 | +3. All arrays (coordinates, triangles, data arrays) should be proxied to |
| 102 | + avoid excess memory consumption |
| 103 | +4. Selecting among coordinates (e.g., gray/white boundary, inflated surface) |
| 104 | + for a single topology should be possible. |
| 105 | +5. Combining multiple brain structures (canonically, left and right hemispheres) |
| 106 | + in memory should be easy; serializing to file may be format-specific. |
| 107 | +6. Splitting a data array into independent patches that can be separately |
| 108 | + operated on and serialized should be possible. |
| 109 | + |
| 110 | + |
| 111 | +Prominent use cases |
| 112 | +=================== |
| 113 | + |
| 114 | +We consider the following use cases for working with surface data. |
| 115 | +A good API will make retrieving the components needed for each use case |
| 116 | +straightforward, as well as storing the results in new images. |
| 117 | + |
| 118 | +* Arithmetic/modeling - per-vertex mathematical operations |
| 119 | +* Smoothing - topology/geometry-respecting smoothing |
| 120 | +* Plotting - paint the data array as a texture on a surface |
| 121 | +* Decimation - subsampling a topology (possibly a subset, possibly with |
| 122 | + interpolated vertex locations) |
| 123 | +* Resampling to a geometrically-aligned surface |
| 124 | + * Downsampling by decimating, smoothing, resampling |
| 125 | + * Inter-subject resampling by using ``?h.sphere.reg`` |
| 126 | +* Interpolation of per-vertex and per-face data arrays |
| 127 | + |
| 128 | +When possible, we prefer to expose NumPy ``ndarray``\s and |
| 129 | +allow use of numpy, scipy, scikit-learn. In some cases, it may |
| 130 | +make sense for NiBabel to provide methods. |
| 131 | + |
| 132 | +******** |
| 133 | +Proposal |
| 134 | +******** |
| 135 | + |
| 136 | +A ``CoordinateImage`` is an N-dimensional array, where one axis corresponds |
| 137 | +to a sequence of points in one or more parcels. |
| 138 | + |
| 139 | +.. code-block:: python |
| 140 | +
|
| 141 | + class CoordinateImage: |
| 142 | + """ |
| 143 | + Attributes |
| 144 | + ---------- |
| 145 | + header : a file-specific header |
| 146 | + coordaxis : ``CoordinateAxis`` |
| 147 | + dataobj : array-like |
| 148 | + """ |
| 149 | +
|
| 150 | + class CoordinateAxis: |
| 151 | + """ |
| 152 | + Attributes |
| 153 | + ---------- |
| 154 | + parcels : list of ``Parcel`` objects |
| 155 | + """ |
| 156 | +
|
| 157 | + def load_structures(self, mapping): |
| 158 | + """ |
| 159 | + Associate parcels to ``Pointset`` structures |
| 160 | + """ |
| 161 | +
|
| 162 | + def __getitem__(self, slicer): |
| 163 | + """ |
| 164 | + Return a sub-sampled CoordinateAxis containing structures |
| 165 | + matching the indices provided. |
| 166 | + """ |
| 167 | +
|
| 168 | + def get_indices(self, parcel, indices=None): |
| 169 | + """ |
| 170 | + Return the indices in the full axis that correspond to the |
| 171 | + requested parcel. If indices are provided, further subsample |
| 172 | + the requested parcel. |
| 173 | + """ |
| 174 | +
|
| 175 | + class Parcel: |
| 176 | + """ |
| 177 | + Attributes |
| 178 | + ---------- |
| 179 | + name : str |
| 180 | + structure : ``Pointset`` |
| 181 | + indices : object that selects a subset of coordinates in structure |
| 182 | + """ |
| 183 | +
|
| 184 | +To describe coordinate geometry, the following structures are proposed: |
| 185 | + |
| 186 | +.. code-block:: python |
| 187 | +
|
| 188 | + class Pointset: |
| 189 | + @property |
| 190 | + def n_coords(self): |
| 191 | + """ Number of coordinates """ |
| 192 | +
|
| 193 | + def get_coords(self, name=None): |
| 194 | + """ Nx3 array of coordinates in RAS+ space """ |
| 195 | +
|
| 196 | +
|
| 197 | + class TriangularMesh(Pointset): |
| 198 | + @property |
| 199 | + def n_triangles(self): |
| 200 | + """ Number of faces """ |
| 201 | +
|
| 202 | + def get_triangles(self, name=None): |
| 203 | + """ Mx3 array of indices into coordinate table """ |
| 204 | +
|
| 205 | + def get_mesh(self, name=None): |
| 206 | + return self.get_coords(name=name), self.get_triangles(name=name) |
| 207 | +
|
| 208 | + def get_names(self): |
| 209 | + """ List of surface names that can be passed to |
| 210 | + ``get_{coords,triangles,mesh}`` |
| 211 | + """ |
| 212 | +
|
| 213 | + def decimate(self, *, n_coords=None, ratio=None): |
| 214 | + """ Return a TriangularMesh with a smaller number of vertices that |
| 215 | + preserves the geometry of the original """ |
| 216 | + # To be overridden when a format provides optimization opportunities |
| 217 | +
|
| 218 | +
|
| 219 | + class NdGrid(Pointset): |
| 220 | + """ |
| 221 | + Attributes |
| 222 | + ---------- |
| 223 | + shape : 3-tuple |
| 224 | + number of coordinates in each dimension of grid |
| 225 | + """ |
| 226 | + def get_affine(self, name=None): |
| 227 | + """ 4x4 array """ |
| 228 | +
|
| 229 | +
|
| 230 | +The ``NdGrid`` class allows raveled volumetric data to be treated the same as |
| 231 | +triangular mesh or other coordinate data. |
| 232 | + |
| 233 | +Finally, a structure for containing a collection of related geometric files is |
| 234 | +defined: |
| 235 | + |
| 236 | +.. code-block:: python |
| 237 | +
|
| 238 | + class GeometryCollection: |
| 239 | + """ |
| 240 | + Attributes |
| 241 | + ---------- |
| 242 | + structures : dict |
| 243 | + Mapping from structure names to ``Pointset`` |
| 244 | + """ |
| 245 | +
|
| 246 | + @classmethod |
| 247 | + def from_spec(klass, pathlike): |
| 248 | + """ Load a collection of geometries from a specification. """ |
| 249 | +
|
| 250 | +The canonical example of a geometry collection is a left hemisphere mesh, |
| 251 | +right hemisphere mesh. |
| 252 | + |
| 253 | +Here we present common use cases: |
| 254 | + |
| 255 | + |
| 256 | +Modeling |
| 257 | +======== |
| 258 | + |
| 259 | +.. code-block:: python |
| 260 | +
|
| 261 | + from nilearn.glm.first_level import make_first_level_design_matrix, run_glm |
| 262 | +
|
| 263 | + bold = CoordinateImage.from_filename("/data/func/hemi-L_bold.func.gii") |
| 264 | + dm = make_first_level_design_matrix(...) |
| 265 | + labels, results = run_glm(bold.get_fdata(), dm) |
| 266 | + betas = CoordinateImage(results["betas"], bold.coordaxis, bold.header) |
| 267 | + betas.to_filename("/data/stats/hemi-L_betas.mgz") |
| 268 | +
|
| 269 | +In this case, no reference to the surface structure is needed, as the operations |
| 270 | +occur on a per-vertex basis. |
| 271 | +The coordinate axis and header are preserved to ensure that any metadata is |
| 272 | +not lost. |
| 273 | + |
| 274 | +Here we assume that ``CoordinateImage`` is able to make the appropriate |
| 275 | +translations between formats (GIFTI, MGH). This is not guaranteed in the final |
| 276 | +API. |
| 277 | + |
| 278 | +Smoothing |
| 279 | +========= |
| 280 | + |
| 281 | +.. code-block:: python |
| 282 | +
|
| 283 | + bold = CoordinateImage.from_filename("/data/func/hemi-L_bold.func.gii") |
| 284 | + bold.coordaxis.load_structures({"lh": "/data/anat/hemi-L_midthickness.surf.gii"}) |
| 285 | + # Not implementing networkx weighted graph here, so assume we have a function |
| 286 | + # that retrieves a graph for each structure |
| 287 | + graphs = get_graphs(bold.coordaxis) |
| 288 | + distances = distance_matrix(graphs['lh']) # n_coords x n_coords matrix |
| 289 | + weights = normalize(gaussian(distances, sigma)) |
| 290 | + # Wildly inefficient smoothing algorithm |
| 291 | + smoothed = CoordinateImage(weights @ bold.get_fdata(), bold.coordaxis, bold.header) |
| 292 | + smoothed.to_filename(f"/data/func/hemi-L_smooth-{sigma}_bold.func.gii") |
| 293 | +
|
| 294 | +
|
| 295 | +Plotting |
| 296 | +======== |
| 297 | + |
| 298 | +Nilearn currently provides a |
| 299 | +`plot_surf <https://nilearn.github.io/modules/generated/nilearn.plotting.plot_surf.html>`_ function. |
| 300 | +With the proposed API, we could interface as follows: |
| 301 | + |
| 302 | +.. code-block:: python |
| 303 | +
|
| 304 | + def plot_surf_img(img, surface="inflated"): |
| 305 | + from nilearn.plotting import plot_surf |
| 306 | + coords, triangles = img.coordaxis.parcels[0].get_mesh(name=surface) |
| 307 | +
|
| 308 | + data = img.get_fdata() |
| 309 | +
|
| 310 | + return plot_surf((triangles, coords), data) |
| 311 | +
|
| 312 | + tstats = CoordinateImage.from_filename("/data/stats/hemi-L_contrast-taskVsBase_tstat.mgz") |
| 313 | + # Assume a GeometryCollection that reads a FreeSurfer subject directory |
| 314 | + fs_subject = FreeSurferSubject.from_spec("/data/subjects/fsaverage5") |
| 315 | + tstats.coordaxis.load_structures(fs_subject.get_structure("lh")) |
| 316 | + plot_surf_img(tstats) |
| 317 | +
|
| 318 | +Subsampling CIFTI-2 |
| 319 | +=================== |
| 320 | + |
| 321 | +.. code-block:: python |
| 322 | +
|
| 323 | + img = nb.load("sub-01_task-rest_bold.dtseries.nii") # Assume CIFTI CoordinateImage |
| 324 | + parcel = nb.load("sub-fsLR_hemi-L_label-DLPFC_mask.label.gii") # GiftiImage |
| 325 | + structure = parcel.meta.metadata['AnatomicalStructurePrimary'] # "CortexLeft" |
| 326 | + vtx_idcs = np.where(parcel.agg_data())[0] |
| 327 | + dlpfc_idcs = img.coordaxis.get_indices(parcel=structure, indices=vtx_idcs) |
| 328 | +
|
| 329 | + # Subsampled coordinate axes will override any duplicate information from header |
| 330 | + dlpfc_img = CoordinateImage(img.dataobj[dlpfc_idcs], img.coordaxis[dlpfc_idcs], img.header) |
| 331 | +
|
| 332 | + # Now load geometry so we can plot |
| 333 | + wbspec = CaretSpec("fsLR.wb.spec") |
| 334 | + dlpfc_img.coordaxis.load_structures(wbspec) |
| 335 | + ... |
0 commit comments