Skip to content

Commit 590b226

Browse files
authored
Merge pull request #1251 from effigies/enh/pointset
ENH: Add pointset data structures [BIAP9]
2 parents 9c568dc + 5ded851 commit 590b226

File tree

3 files changed

+381
-3
lines changed

3 files changed

+381
-3
lines changed

.pre-commit-config.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
exclude: ".*/data/.*"
22
repos:
33
- repo: https://github.com/pre-commit/pre-commit-hooks
4-
rev: v4.1.0
4+
rev: v4.4.0
55
hooks:
66
- id: trailing-whitespace
77
- id: end-of-file-fixer
@@ -21,12 +21,12 @@ repos:
2121
hooks:
2222
- id: isort
2323
- repo: https://github.com/pycqa/flake8
24-
rev: 6.0.0
24+
rev: 6.1.0
2525
hooks:
2626
- id: flake8
2727
exclude: "^(doc|nisext|tools)/"
2828
- repo: https://github.com/pre-commit/mirrors-mypy
29-
rev: v0.991
29+
rev: v1.5.1
3030
hooks:
3131
- id: mypy
3232
# Sync with project.optional-dependencies.typing

nibabel/pointset.py

+194
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Point-set structures
2+
3+
Imaging data are sampled at points in space, and these points
4+
can be described by coordinates.
5+
These structures are designed to enable operations on sets of
6+
points, as opposed to the data sampled at those points.
7+
8+
Abstractly, a point set is any collection of points, but there are
9+
two types that warrant special consideration in the neuroimaging
10+
context: grids and meshes.
11+
12+
A *grid* is a collection of regularly-spaced points. The canonical
13+
examples of grids are the indices of voxels and their affine
14+
projection into a reference space.
15+
16+
A *mesh* is a collection of points and some structure that enables
17+
adjacent points to be identified. A *triangular mesh* in particular
18+
uses triplets of adjacent vertices to describe faces.
19+
"""
20+
from __future__ import annotations
21+
22+
import math
23+
import typing as ty
24+
from dataclasses import dataclass, replace
25+
26+
import numpy as np
27+
28+
from nibabel.casting import able_int_type
29+
from nibabel.fileslice import strided_scalar
30+
from nibabel.spatialimages import SpatialImage
31+
32+
if ty.TYPE_CHECKING: # pragma: no cover
33+
from typing_extensions import Self
34+
35+
_DType = ty.TypeVar('_DType', bound=np.dtype[ty.Any])
36+
37+
38+
class CoordinateArray(ty.Protocol):
39+
ndim: int
40+
shape: tuple[int, int]
41+
42+
@ty.overload
43+
def __array__(self, dtype: None = ..., /) -> np.ndarray[ty.Any, np.dtype[ty.Any]]:
44+
... # pragma: no cover
45+
46+
@ty.overload
47+
def __array__(self, dtype: _DType, /) -> np.ndarray[ty.Any, _DType]:
48+
... # pragma: no cover
49+
50+
51+
@dataclass
52+
class Pointset:
53+
"""A collection of points described by coordinates.
54+
55+
Parameters
56+
----------
57+
coords : array-like
58+
(*N*, *n*) array with *N* being points and columns their *n*-dimensional coordinates
59+
affine : :class:`numpy.ndarray`
60+
Affine transform to be applied to coordinates array
61+
homogeneous : :class:`bool`
62+
Indicate whether the provided coordinates are homogeneous,
63+
i.e., homogeneous 3D coordinates have the form ``(x, y, z, 1)``
64+
"""
65+
66+
coordinates: CoordinateArray
67+
affine: np.ndarray
68+
homogeneous: bool = False
69+
70+
# Force use of __rmatmul__ with numpy arrays
71+
__array_priority__ = 99
72+
73+
def __init__(
74+
self,
75+
coordinates: CoordinateArray,
76+
affine: np.ndarray | None = None,
77+
homogeneous: bool = False,
78+
):
79+
self.coordinates = coordinates
80+
self.homogeneous = homogeneous
81+
82+
if affine is None:
83+
self.affine = np.eye(self.dim + 1)
84+
else:
85+
self.affine = np.asanyarray(affine)
86+
87+
if self.affine.shape != (self.dim + 1,) * 2:
88+
raise ValueError(f'Invalid affine for {self.dim}D coordinates:\n{self.affine}')
89+
if np.any(self.affine[-1, :-1] != 0) or self.affine[-1, -1] != 1:
90+
raise ValueError(f'Invalid affine matrix:\n{self.affine}')
91+
92+
@property
93+
def n_coords(self) -> int:
94+
"""Number of coordinates
95+
96+
Subclasses should override with more efficient implementations.
97+
"""
98+
return self.coordinates.shape[0]
99+
100+
@property
101+
def dim(self) -> int:
102+
"""The dimensionality of the space the coordinates are in"""
103+
return self.coordinates.shape[1] - self.homogeneous
104+
105+
def __rmatmul__(self, affine: np.ndarray) -> Self:
106+
"""Apply an affine transformation to the pointset
107+
108+
This will return a new pointset with an updated affine matrix only.
109+
"""
110+
return replace(self, affine=np.asanyarray(affine) @ self.affine)
111+
112+
def _homogeneous_coords(self):
113+
if self.homogeneous:
114+
return np.asanyarray(self.coordinates)
115+
116+
ones = strided_scalar(
117+
shape=(self.coordinates.shape[0], 1),
118+
scalar=np.array(1, dtype=self.coordinates.dtype),
119+
)
120+
return np.hstack((self.coordinates, ones))
121+
122+
def get_coords(self, *, as_homogeneous: bool = False):
123+
"""Retrieve the coordinates
124+
125+
Parameters
126+
----------
127+
as_homogeneous : :class:`bool`
128+
Return homogeneous coordinates if ``True``, or Cartesian
129+
coordiantes if ``False``.
130+
131+
name : :class:`str`
132+
Select a particular coordinate system if more than one may exist.
133+
By default, `None` is equivalent to `"world"` and corresponds to
134+
an RAS+ coordinate system.
135+
"""
136+
ident = np.allclose(self.affine, np.eye(self.affine.shape[0]))
137+
if self.homogeneous == as_homogeneous and ident:
138+
return np.asanyarray(self.coordinates)
139+
coords = self._homogeneous_coords()
140+
if not ident:
141+
coords = (self.affine @ coords.T).T
142+
if not as_homogeneous:
143+
coords = coords[:, :-1]
144+
return coords
145+
146+
147+
class Grid(Pointset):
148+
r"""A regularly-spaced collection of coordinates
149+
150+
This class provides factory methods for generating Pointsets from
151+
:class:`~nibabel.spatialimages.SpatialImage`\s and generating masks
152+
from coordinate sets.
153+
"""
154+
155+
@classmethod
156+
def from_image(cls, spatialimage: SpatialImage) -> Self:
157+
return cls(coordinates=GridIndices(spatialimage.shape[:3]), affine=spatialimage.affine)
158+
159+
@classmethod
160+
def from_mask(cls, mask: SpatialImage) -> Self:
161+
mask_arr = np.bool_(mask.dataobj)
162+
return cls(
163+
coordinates=np.c_[np.nonzero(mask_arr)].astype(able_int_type(mask.shape)),
164+
affine=mask.affine,
165+
)
166+
167+
def to_mask(self, shape=None) -> SpatialImage:
168+
if shape is None:
169+
shape = tuple(np.max(self.coordinates, axis=0)[: self.dim] + 1)
170+
mask_arr = np.zeros(shape, dtype='bool')
171+
mask_arr[tuple(np.asanyarray(self.coordinates)[:, : self.dim].T)] = True
172+
return SpatialImage(mask_arr, self.affine)
173+
174+
175+
class GridIndices:
176+
"""Class for generating indices just-in-time"""
177+
178+
__slots__ = ('gridshape', 'dtype', 'shape')
179+
ndim = 2
180+
181+
def __init__(self, shape, dtype=None):
182+
self.gridshape = shape
183+
self.dtype = dtype or able_int_type(shape)
184+
self.shape = (math.prod(self.gridshape), len(self.gridshape))
185+
186+
def __repr__(self):
187+
return f'<{self.__class__.__name__}{self.gridshape}>'
188+
189+
def __array__(self, dtype=None):
190+
if dtype is None:
191+
dtype = self.dtype
192+
193+
axes = [np.arange(s, dtype=dtype) for s in self.gridshape]
194+
return np.reshape(np.meshgrid(*axes, copy=False, indexing='ij'), (len(axes), -1)).T

0 commit comments

Comments
 (0)