Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 590b226

Browse files
authoredSep 19, 2023
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

Lines changed: 3 additions & 3 deletions
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

Lines changed: 194 additions & 0 deletions
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

‎nibabel/tests/test_pointset.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
from math import prod
2+
from pathlib import Path
3+
from unittest import skipUnless
4+
5+
import numpy as np
6+
import pytest
7+
8+
from nibabel import pointset as ps
9+
from nibabel.affines import apply_affine
10+
from nibabel.arrayproxy import ArrayProxy
11+
from nibabel.fileslice import strided_scalar
12+
from nibabel.onetime import auto_attr
13+
from nibabel.optpkg import optional_package
14+
from nibabel.spatialimages import SpatialImage
15+
from nibabel.tests.nibabel_data import get_nibabel_data
16+
17+
h5, has_h5py, _ = optional_package('h5py')
18+
19+
FS_DATA = Path(get_nibabel_data()) / 'nitest-freesurfer'
20+
21+
22+
class TestPointsets:
23+
rng = np.random.default_rng()
24+
25+
@pytest.mark.parametrize('shape', [(5, 2), (5, 3), (5, 4)])
26+
@pytest.mark.parametrize('homogeneous', [True, False])
27+
def test_init(self, shape, homogeneous):
28+
coords = self.rng.random(shape)
29+
30+
if homogeneous:
31+
coords = np.column_stack([coords, np.ones(shape[0])])
32+
33+
points = ps.Pointset(coords, homogeneous=homogeneous)
34+
assert np.allclose(points.affine, np.eye(shape[1] + 1))
35+
assert points.homogeneous is homogeneous
36+
assert (points.n_coords, points.dim) == shape
37+
38+
points = ps.Pointset(coords, affine=np.diag([2] * shape[1] + [1]), homogeneous=homogeneous)
39+
assert np.allclose(points.affine, np.diag([2] * shape[1] + [1]))
40+
assert points.homogeneous is homogeneous
41+
assert (points.n_coords, points.dim) == shape
42+
43+
# Badly shaped affine
44+
with pytest.raises(ValueError):
45+
ps.Pointset(coords, affine=[0, 1])
46+
47+
# Badly valued affine
48+
with pytest.raises(ValueError):
49+
ps.Pointset(coords, affine=np.ones((shape[1] + 1, shape[1] + 1)))
50+
51+
@pytest.mark.parametrize('shape', [(5, 2), (5, 3), (5, 4)])
52+
@pytest.mark.parametrize('homogeneous', [True, False])
53+
def test_affines(self, shape, homogeneous):
54+
orig_coords = coords = self.rng.random(shape)
55+
56+
if homogeneous:
57+
coords = np.column_stack([coords, np.ones(shape[0])])
58+
59+
points = ps.Pointset(coords, homogeneous=homogeneous)
60+
assert np.allclose(points.get_coords(), orig_coords)
61+
62+
# Apply affines
63+
scaler = np.diag([2] * shape[1] + [1])
64+
scaled = scaler @ points
65+
assert np.array_equal(scaled.coordinates, points.coordinates)
66+
assert np.array_equal(scaled.affine, scaler)
67+
assert np.allclose(scaled.get_coords(), 2 * orig_coords)
68+
69+
flipper = np.eye(shape[1] + 1)
70+
# [[1, 0, 0], [0, 1, 0], [0, 0, 1]] becomes [[0, 1, 0], [1, 0, 0], [0, 0, 1]]
71+
flipper[:-1] = flipper[-2::-1]
72+
flipped = flipper @ points
73+
assert np.array_equal(flipped.coordinates, points.coordinates)
74+
assert np.array_equal(flipped.affine, flipper)
75+
assert np.allclose(flipped.get_coords(), orig_coords[:, ::-1])
76+
77+
# Concatenate affines, with any associativity
78+
for doubledup in [(scaler @ flipper) @ points, scaler @ (flipper @ points)]:
79+
assert np.array_equal(doubledup.coordinates, points.coordinates)
80+
assert np.allclose(doubledup.affine, scaler @ flipper)
81+
assert np.allclose(doubledup.get_coords(), 2 * orig_coords[:, ::-1])
82+
83+
def test_homogeneous_coordinates(self):
84+
ccoords = self.rng.random((5, 3))
85+
hcoords = np.column_stack([ccoords, np.ones(5)])
86+
87+
cartesian = ps.Pointset(ccoords)
88+
homogeneous = ps.Pointset(hcoords, homogeneous=True)
89+
90+
for points in (cartesian, homogeneous):
91+
assert np.array_equal(points.get_coords(), ccoords)
92+
assert np.array_equal(points.get_coords(as_homogeneous=True), hcoords)
93+
94+
affine = np.diag([2, 3, 4, 1])
95+
cart2 = affine @ cartesian
96+
homo2 = affine @ homogeneous
97+
98+
exp_c = apply_affine(affine, ccoords)
99+
exp_h = (affine @ hcoords.T).T
100+
for points in (cart2, homo2):
101+
assert np.array_equal(points.get_coords(), exp_c)
102+
assert np.array_equal(points.get_coords(as_homogeneous=True), exp_h)
103+
104+
105+
def test_GridIndices():
106+
# 2D case
107+
shape = (2, 3)
108+
gi = ps.GridIndices(shape)
109+
110+
assert gi.dtype == np.dtype('u1')
111+
assert gi.shape == (6, 2)
112+
assert repr(gi) == '<GridIndices(2, 3)>'
113+
114+
gi_arr = np.asanyarray(gi)
115+
assert gi_arr.dtype == np.dtype('u1')
116+
assert gi_arr.shape == (6, 2)
117+
# Tractable to write out
118+
assert np.array_equal(gi_arr, [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2]])
119+
120+
shape = (2, 3, 4)
121+
gi = ps.GridIndices(shape)
122+
123+
assert gi.dtype == np.dtype('u1')
124+
assert gi.shape == (24, 3)
125+
assert repr(gi) == '<GridIndices(2, 3, 4)>'
126+
127+
gi_arr = np.asanyarray(gi)
128+
assert gi_arr.dtype == np.dtype('u1')
129+
assert gi_arr.shape == (24, 3)
130+
# Separate implementation
131+
assert np.array_equal(gi_arr, np.mgrid[:2, :3, :4].reshape(3, -1).T)
132+
133+
134+
class TestGrids(TestPointsets):
135+
@pytest.mark.parametrize('shape', [(5, 5, 5), (5, 5, 5, 5), (5, 5, 5, 5, 5)])
136+
def test_from_image(self, shape):
137+
# Check image is generates voxel coordinates
138+
affine = np.diag([2, 3, 4, 1])
139+
img = SpatialImage(strided_scalar(shape), affine)
140+
grid = ps.Grid.from_image(img)
141+
grid_coords = grid.get_coords()
142+
143+
assert grid.n_coords == prod(shape[:3])
144+
assert grid.dim == 3
145+
assert np.allclose(grid.affine, affine)
146+
147+
assert np.allclose(grid_coords[0], [0, 0, 0])
148+
# Final index is [4, 4, 4], scaled by affine
149+
assert np.allclose(grid_coords[-1], [8, 12, 16])
150+
151+
def test_from_mask(self):
152+
affine = np.diag([2, 3, 4, 1])
153+
mask = np.zeros((3, 3, 3))
154+
mask[1, 1, 1] = 1
155+
img = SpatialImage(mask, affine)
156+
157+
grid = ps.Grid.from_mask(img)
158+
grid_coords = grid.get_coords()
159+
160+
assert grid.n_coords == 1
161+
assert grid.dim == 3
162+
assert np.array_equal(grid_coords, [[2, 3, 4]])
163+
164+
def test_to_mask(self):
165+
coords = np.array([[1, 1, 1]])
166+
167+
grid = ps.Grid(coords)
168+
169+
mask_img = grid.to_mask()
170+
assert mask_img.shape == (2, 2, 2)
171+
assert np.array_equal(mask_img.get_fdata(), [[[0, 0], [0, 0]], [[0, 0], [0, 1]]])
172+
assert np.array_equal(mask_img.affine, np.eye(4))
173+
174+
mask_img = grid.to_mask(shape=(3, 3, 3))
175+
assert mask_img.shape == (3, 3, 3)
176+
assert np.array_equal(
177+
mask_img.get_fdata(),
178+
[
179+
[[0, 0, 0], [0, 0, 0], [0, 0, 0]],
180+
[[0, 0, 0], [0, 1, 0], [0, 0, 0]],
181+
[[0, 0, 0], [0, 0, 0], [0, 0, 0]],
182+
],
183+
)
184+
assert np.array_equal(mask_img.affine, np.eye(4))

0 commit comments

Comments
 (0)
Please sign in to comment.