Skip to content

Commit a20f13a

Browse files
authored
Merge pull request #1084 from nipy/biap/0009
BIAP: CoordinateImage API
2 parents b8445fc + 0bb3c02 commit a20f13a

File tree

4 files changed

+343
-3
lines changed

4 files changed

+343
-3
lines changed

doc/source/_static/nibabel.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ body {
44
background-color: #E5E2E2;
55
}
66

7+
div.sphinxsidebar {
8+
position: relative;
9+
}
10+
711
div.sphinxsidebar h4, div.sphinxsidebar h3 {
812
background-color: #2F83C8;
913
}

doc/source/_templates/layout.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
<li><a href="legal.html">License</a> |&nbsp;</li>
99
{% endblock %}
1010

11+
{% block sidebar1 %}{{ sidebar() }}{% endblock %}
12+
{% block sidebar2 %}{% endblock %}
13+
1114
{% block extrahead %}
1215
<meta name="keywords" content="nipy, neuroimaging, python, neuroscience">
1316
{% endblock %}
@@ -30,6 +33,3 @@ <h3 style="margin-top:-5px;color:#2f83c8">Access a cacophony of neuro-imaging fi
3033
{% endblock %}
3134

3235
{% block relbar2 %}{% endblock %}
33-
34-
{% block sidebar1 %}{{ sidebar() }}{% endblock %}
35-
{% block sidebar2 %}{% endblock %}

doc/source/devel/biaps/biap_0009.rst

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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+
...

doc/source/devel/biaps/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ proposals.
1919
biap_0006
2020
biap_0007
2121
biap_0008
22+
biap_0009
2223

2324
.. toctree::
2425
:hidden:

0 commit comments

Comments
 (0)