Skip to content

Commit 82ff662

Browse files
committed
ENH: Start restoring triangular meshes
1 parent 590b226 commit 82ff662

File tree

2 files changed

+226
-0
lines changed

2 files changed

+226
-0
lines changed

nibabel/pointset.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ def __array__(self, dtype: _DType, /) -> np.ndarray[ty.Any, _DType]:
4848
... # pragma: no cover
4949

5050

51+
class HasMeshAttrs(ty.Protocol):
52+
coordinates: CoordinateArray
53+
triangles: CoordinateArray
54+
55+
5156
@dataclass
5257
class Pointset:
5358
"""A collection of points described by coordinates.
@@ -144,6 +149,71 @@ def get_coords(self, *, as_homogeneous: bool = False):
144149
return coords
145150

146151

152+
class TriangularMesh(Pointset):
153+
triangles: CoordinateArray
154+
155+
def __init__(
156+
self,
157+
coordinates: CoordinateArray,
158+
triangles: CoordinateArray,
159+
affine: np.ndarray | None = None,
160+
homogeneous: bool = False,
161+
):
162+
super().__init__(coordinates, affine=affine, homogeneous=homogeneous)
163+
self.triangles = triangles
164+
165+
@classmethod
166+
def from_tuple(
167+
cls,
168+
mesh: tuple[CoordinateArray, CoordinateArray],
169+
affine: np.ndarray | None = None,
170+
homogeneous: bool = False,
171+
) -> Self:
172+
return cls(mesh[0], mesh[1], affine=affine, homogeneous=homogeneous)
173+
174+
@classmethod
175+
def from_object(
176+
cls,
177+
mesh: HasMeshAttrs,
178+
affine: np.ndarray | None = None,
179+
homogeneous: bool = False,
180+
) -> Self:
181+
return cls(mesh.coordinates, mesh.triangles, affine=affine, homogeneous=homogeneous)
182+
183+
@property
184+
def n_triangles(self):
185+
"""Number of faces
186+
187+
Subclasses should override with more efficient implementations.
188+
"""
189+
return self.triangles.shape[0]
190+
191+
def get_triangles(self):
192+
"""Mx3 array of indices into coordinate table"""
193+
return np.asanyarray(self.triangles)
194+
195+
def get_mesh(self, *, as_homogeneous: bool = False):
196+
return self.get_coords(as_homogeneous=as_homogeneous), self.get_triangles()
197+
198+
199+
class CoordinateFamilyMixin(Pointset):
200+
def __init__(self, *args, **kwargs):
201+
self._coords = {}
202+
super().__init__(*args, **kwargs)
203+
204+
def get_names(self):
205+
"""List of surface names that can be passed to :meth:`with_name`"""
206+
return list(self._coords)
207+
208+
def with_name(self, name: str) -> Self:
209+
new = replace(self, coordinates=self._coords[name])
210+
new._coords = self._coords
211+
return new
212+
213+
def add_coordinates(self, name, coordinates):
214+
self._coords[name] = coordinates
215+
216+
147217
class Grid(Pointset):
148218
r"""A regularly-spaced collection of coordinates
149219

nibabel/tests/test_pointset.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,159 @@ def test_to_mask(self):
182182
],
183183
)
184184
assert np.array_equal(mask_img.affine, np.eye(4))
185+
186+
187+
class TestTriangularMeshes(TestPointsets):
188+
...
189+
190+
191+
class H5ArrayProxy:
192+
def __init__(self, file_like, dataset_name):
193+
self.file_like = file_like
194+
self.dataset_name = dataset_name
195+
with h5.File(file_like, 'r') as h5f:
196+
arr = h5f[dataset_name]
197+
self._shape = arr.shape
198+
self._dtype = arr.dtype
199+
200+
@property
201+
def is_proxy(self):
202+
return True
203+
204+
@property
205+
def shape(self):
206+
return self._shape
207+
208+
@property
209+
def ndim(self):
210+
return len(self.shape)
211+
212+
@property
213+
def dtype(self):
214+
return self._dtype
215+
216+
def __array__(self, dtype=None):
217+
with h5.File(self.file_like, 'r') as h5f:
218+
return np.asanyarray(h5f[self.dataset_name], dtype)
219+
220+
def __getitem__(self, slicer):
221+
with h5.File(self.file_like, 'r') as h5f:
222+
return h5f[self.dataset_name][slicer]
223+
224+
225+
class H5Geometry(ps.TriMeshFamily):
226+
"""Simple Geometry file structure that combines a single topology
227+
with one or more coordinate sets
228+
"""
229+
230+
@classmethod
231+
def from_filename(klass, pathlike):
232+
meshes = {}
233+
with h5.File(pathlike, 'r') as h5f:
234+
triangles = H5ArrayProxy(pathlike, '/topology')
235+
for name in h5f['coordinates']:
236+
meshes[name] = (H5ArrayProxy(pathlike, f'/coordinates/{name}'), triangles)
237+
return klass(meshes)
238+
239+
def to_filename(self, pathlike):
240+
with h5.File(pathlike, 'w') as h5f:
241+
h5f.create_dataset('/topology', data=self.get_triangles())
242+
for name, coord in self._coords.items():
243+
h5f.create_dataset(f'/coordinates/{name}', data=coord)
244+
245+
246+
class FSGeometryProxy:
247+
def __init__(self, pathlike):
248+
self._file_like = str(Path(pathlike))
249+
self._offset = None
250+
self._vnum = None
251+
self._fnum = None
252+
253+
def _peek(self):
254+
from nibabel.freesurfer.io import _fread3
255+
256+
with open(self._file_like, 'rb') as fobj:
257+
magic = _fread3(fobj)
258+
if magic != 16777214:
259+
raise NotImplementedError('Triangle files only!')
260+
fobj.readline()
261+
fobj.readline()
262+
self._vnum = np.fromfile(fobj, '>i4', 1)[0]
263+
self._fnum = np.fromfile(fobj, '>i4', 1)[0]
264+
self._offset = fobj.tell()
265+
266+
@property
267+
def vnum(self):
268+
if self._vnum is None:
269+
self._peek()
270+
return self._vnum
271+
272+
@property
273+
def fnum(self):
274+
if self._fnum is None:
275+
self._peek()
276+
return self._fnum
277+
278+
@property
279+
def offset(self):
280+
if self._offset is None:
281+
self._peek()
282+
return self._offset
283+
284+
@auto_attr
285+
def coords(self):
286+
ap = ArrayProxy(self._file_like, ((self.vnum, 3), '>f4', self.offset))
287+
ap.order = 'C'
288+
return ap
289+
290+
@auto_attr
291+
def triangles(self):
292+
offset = self.offset + 12 * self.vnum
293+
ap = ArrayProxy(self._file_like, ((self.fnum, 3), '>i4', offset))
294+
ap.order = 'C'
295+
return ap
296+
297+
298+
class FreeSurferHemisphere(ps.TriMeshFamily):
299+
@classmethod
300+
def from_filename(klass, pathlike):
301+
path = Path(pathlike)
302+
hemi, default = path.name.split('.')
303+
mesh_names = (
304+
'orig',
305+
'white',
306+
'smoothwm',
307+
'pial',
308+
'inflated',
309+
'sphere',
310+
'midthickness',
311+
'graymid',
312+
) # Often created
313+
if default not in mesh_names:
314+
mesh_names.append(default)
315+
meshes = {}
316+
for mesh in mesh_names:
317+
fpath = path.parent / f'{hemi}.{mesh}'
318+
if fpath.exists():
319+
meshes[mesh] = FSGeometryProxy(fpath)
320+
hemi = klass(meshes)
321+
hemi._default = default
322+
return hemi
323+
324+
325+
def test_FreeSurferHemisphere():
326+
lh = FreeSurferHemisphere.from_filename(FS_DATA / 'fsaverage/surf/lh.white')
327+
assert lh.n_coords == 163842
328+
assert lh.n_triangles == 327680
329+
330+
331+
@skipUnless(has_h5py, reason='Test requires h5py')
332+
def test_make_H5Geometry(tmp_path):
333+
lh = FreeSurferHemisphere.from_filename(FS_DATA / 'fsaverage/surf/lh.white')
334+
h5geo = H5Geometry({name: lh.get_mesh(name) for name in ('white', 'pial')})
335+
h5geo.to_filename(tmp_path / 'geometry.h5')
336+
337+
rt_h5geo = H5Geometry.from_filename(tmp_path / 'geometry.h5')
338+
assert set(h5geo._coords) == set(rt_h5geo._coords)
339+
assert np.array_equal(lh.get_coords('white'), rt_h5geo.get_coords('white'))
340+
assert np.array_equal(lh.get_triangles(), rt_h5geo.get_triangles())

0 commit comments

Comments
 (0)