Skip to content

Commit e278981

Browse files
committed
DOC: Commit current thinking on BIAP0009
1 parent a3adfe7 commit e278981

File tree

1 file changed

+138
-113
lines changed

1 file changed

+138
-113
lines changed

doc/source/devel/biaps/biap_0009.rst

+138-113
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
.. _biap9:
22

3-
#############################
4-
BIAP9 - The Surface Image API
5-
#############################
3+
################################
4+
BIAP9 - The Coordinate Image API
5+
################################
66

77
:Author: Chris Markiewicz
88
:Status: Draft
@@ -18,7 +18,7 @@ Surface data is generally kept separate from geometric metadata
1818

1919
In contrast to volumetric data, whose geometry can be fully encoded in the
2020
shape of a data array and a 4x4 affine matrix, data sampled to a surface
21-
requires the location of each sample to be explicitly represented by a
21+
require the location of each sample to be explicitly represented by a
2222
coordinate. In practice, the most common approach is to have a geometry file
2323
and a data file.
2424

@@ -42,10 +42,12 @@ For the purposes of this BIAP, the following terms are used:
4242
* Topology - vertex adjacency data, independent of vertex coordinates,
4343
typically in the form of a list of triangles
4444
* Geometry - topology + a specific set of coordinates for a surface
45-
* Patch - a connected subset of vertices (can be the full topology)
45+
* Parcel - a subset of vertices; can be the full topology. Special cases include:
46+
* Patch - a connected subspace
47+
* Decimated mesh - a subspace that has a desired density of vertices
48+
* Subspace sequence - an ordered set of subspaces
4649
* Data array - an n-dimensional array with one axis corresponding to the
47-
vertices (typical) OR faces (more rare) in a patch
48-
50+
vertices (typical) OR faces (more rare) in a patch sequence
4951

5052
Currently supported surface formats
5153
===================================
@@ -65,14 +67,29 @@ Currently supported surface formats
6567
coordinates, topology, or data (further subdivided by type and intent)
6668
* CIFTI-2: :py:class:`~nibabel.cifti2.cifti2.Cifti2Image`
6769
* 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
6874
* Geometry referred to by an associated ``wb.spec`` file
6975
(no current implementation in NiBabel)
7076
* Possible to have one with no geometric information, e.g., parcels x time
7177

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
7289

73-
*********************************
74-
Desiderata for a SurfaceImage API
75-
*********************************
90+
*****************************************
91+
Desiderata for an API supporting surfaces
92+
*****************************************
7693

7794
The following are provisional guiding principles
7895

@@ -116,7 +133,55 @@ make sense for NiBabel to provide methods.
116133
Proposal
117134
********
118135

119-
The basic API is as follows:
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:
120185

121186
.. code-block:: python
122187
@@ -150,54 +215,40 @@ The basic API is as follows:
150215
preserves the geometry of the original """
151216
# To be overridden when a format provides optimization opportunities
152217
153-
def load_vertex_data(self, filename):
154-
""" Return a SurfaceImage with data corresponding to each vertex """
155-
156-
def load_face_data(self, filename):
157-
""" Return a SurfaceImage with data corresponding to each face """
158-
159-
160-
class SurfaceHeader(FileBasedHeader):
161-
def get_geometry(self):
162-
""" Construct a geometry from the header if possible, or return None """
163218
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 """
164228
165-
class SurfaceImage(FileBasedImage):
166-
@property
167-
def header(self):
168-
""" A SurfaceHeader """
169-
170-
@property
171-
def geometry(self):
172-
""" A Pointset or None """
173-
174-
@property
175-
def dataobj(self):
176-
""" An ndarray or ArrayProxy with one of the following properties:
177-
178-
1) self.dataobj.shape[0] == self.header.ncoords
179-
2) self.dataobj.shape[0] == self.header.nfaces
180-
"""
181-
182-
def load_geometry(self, pathlike):
183-
""" Specify a geometry for a data-only image """
184229
230+
The ``NdGrid`` class allows raveled volumetric data to be treated the same as
231+
triangular mesh or other coordinate data.
185232

186-
To enable a similar interface to raveled voxel data:
233+
Finally, a structure for containing a collection of related geometric files is
234+
defined:
187235

188236
.. code-block:: python
189237
190-
class VolumeGeometry(Pointset):
191-
_affine # Or _affines, if we want multiple, e.g. qform, sform
192-
_shape
193-
_ijk_coords
194-
195-
def n_coords(self):
196-
""" Number of coordinates """
238+
class GeometryCollection:
239+
"""
240+
Attributes
241+
----------
242+
structures : dict
243+
Mapping from structure names to ``Pointset``
244+
"""
197245
198-
def get_coords(self):
199-
""" Nx3 array of coordinates in RAS+ space """
246+
@classmethod
247+
def from_spec(klass, pathlike):
248+
""" Load a collection of geometries from a specification. """
200249
250+
The canonical example of a geometry collection is a left hemisphere mesh,
251+
right hemisphere mesh.
201252

202253
Here we present common use cases:
203254

@@ -209,30 +260,35 @@ Modeling
209260
210261
from nilearn.glm.first_level import make_first_level_design_matrix, run_glm
211262
212-
bold = SurfaceImage.from_filename("/data/func/hemi-L_bold.func.gii")
263+
bold = CoordinateImage.from_filename("/data/func/hemi-L_bold.func.gii")
213264
dm = make_first_level_design_matrix(...)
214-
labels, results = run_glm(bold.get_data(), dm)
215-
betas = bold.__class__(results["betas"], bold.header)
265+
labels, results = run_glm(bold.get_fdata(), dm)
266+
betas = CoordinateImage(results["betas"], bold.coordaxis, bold.header)
216267
betas.to_filename("/data/stats/hemi-L_betas.mgz")
217268
218-
Data images have their own metadata apart from geometry that needs handling in a header:
219-
220-
* Axis labels (time series, labels, tensors)
221-
* Data dtype
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.
222273

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.
223277

224278
Smoothing
225279
=========
226280

227281
.. code-block:: python
228282
229-
bold = SurfaceImage.from_filename("/data/func/hemi-L_bold.func.gii")
230-
bold.load_geometry("/data/anat/hemi-L_midthickness.surf.gii")
231-
# Not implementing networkx weighted graph here, so assume we have a method
232-
graph = bold.geometry.get_graph()
233-
distances = distance_matrix(graph) # n_coords x n_coords matrix
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
234289
weights = normalize(gaussian(distances, sigma))
235-
smoothed = bold.__class__(bold.get_fdata() @ weights, bold.header)
290+
# Wildly inefficient smoothing algorithm
291+
smoothed = CoordinateImage(weights @ bold.get_fdata(), bold.coordaxis, bold.header)
236292
smoothed.to_filename(f"/data/func/hemi-L_smooth-{sigma}_bold.func.gii")
237293
238294
@@ -247,64 +303,33 @@ With the proposed API, we could interface as follows:
247303
248304
def plot_surf_img(img, surface="inflated"):
249305
from nilearn.plotting import plot_surf
250-
coords, triangles = img.geometry.get_mesh(name=surface)
306+
coords, triangles = img.coordaxis.parcels[0].get_mesh(name=surface)
251307
252-
data = tstats.get_data()
308+
data = img.get_fdata()
253309
254310
return plot_surf((triangles, coords), data)
255311
256-
tstats = SurfaceImage.from_filename("/data/stats/hemi-L_contrast-taskVsBase_tstat.mgz")
257-
tstats.load_geometry("/data/subjects/fsaverage5")
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"))
258316
plot_surf_img(tstats)
259317
260-
This assumes that ``load_geometry()`` will be able to identify a FreeSurfer subject directory.
261-
262-
263-
**************
264-
Open questions
265-
**************
266-
267-
1) Can/should image and geometry objects be promiscuous? If I load a GIFTI geometry,
268-
should ``geometry.load_vertex_data()`` accept a FreeSurfer curvature file, or
269-
should there be a bit more ecosystem-local logic?
270-
271-
272-
********************************
273-
Concatenable structures proposal
274-
********************************
275-
276-
A brain is typically described with multiple structures,
277-
such as left and right hemispheres and a volumetric subcortical region.
278-
We will consider two reference cases:
279-
280-
1) FreeSurfer, in which a subject directory can be used to retrieve
281-
multiple surfaces or volumetric regions. Data arrays are specific to a
282-
single structure.
283-
2) CIFTI-2, which permits an axis to have type ``CIFTI_INDEX_TYPE_BRAIN_MODELS``,
284-
indicating that each index along an axis corresponds to a vertex or voxel in
285-
an associated surface or volume.
286-
The data array thus may span multiple structures.
287-
288-
In the former case, the structures may be considered an unordered collection;
289-
in the latter, the sequence of structures directly corresponds to segments of
290-
the data array.
318+
Subsampling CIFTI-2
319+
===================
291320

292321
.. code-block:: python
293322
294-
class GeometryCollection:
295-
def get_geometry(self, name):
296-
""" Return a geometry by name """
297-
298-
@property
299-
def names(self):
300-
""" A set of structure names """
301-
302-
@classmethod
303-
def from_spec(klass, pathlike):
304-
""" Load a collection of geometries from a specification, broadly construed. """
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)
305328
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)
306331
307-
class PointsetSequence(GeometryCollection, Pointset):
308-
# Inherit and override get_coords, n_coords from Pointset
309-
def get_indices(self, name):
310-
""" Return indices for data array corresponding to a named geometry """
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

Comments
 (0)