Newer
Older
Paul McCarthy
committed
# mesh.py - The TriangleMesh class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`Mesh` class, which represents a
Paul McCarthy
committed
3D model made of triangles.
See also the following modules:
Paul McCarthy
committed
fsl.data.vtk
fsl.data.gifti
fsl.data.freesurfer
A handful of standalone functions are provided in this module, for doing
various things with meshes:
.. autosummary::
:nosignatures:
calcFaceNormals
calcVertexNormals
needsFixing
import logging
import collections
import six
import deprecation
import os.path as op
import numpy as np
import fsl.utils.meta as meta
import fsl.utils.notifier as notifier
import fsl.utils.memoize as memoize
import fsl.utils.transform as transform
Paul McCarthy
committed
log = logging.getLogger(__name__)
class Mesh(notifier.Notifier, meta.Meta):
"""The ``Mesh`` class represents a 3D model. A mesh is defined by a
collection of ``N`` vertices, and ``M`` triangles. The triangles are
Paul McCarthy
committed
A ``Mesh`` instance has the following attributes:
Paul McCarthy
committed
Paul McCarthy
committed
============== ====================================================
``name`` A name, typically the file name sans-suffix.
Paul McCarthy
committed
``dataSource`` Full path to the mesh file (or ``None`` if there is
no file associated with this mesh).
``vertices`` A ``(n, 3)`` array containing the currently selected
vertices. You can assign a vertex set key to this
attribute to change the selected vertex set.
``bounds`` The lower and upper bounds
``indices`` A ``(m, 3)`` array containing the vertex indices
for ``m`` triangles
``normals`` A ``(m, 3)`` array containing face normals for the
triangles
``vnormals`` A ``(n, 3)`` array containing vertex normals for the
the current vertices.
Paul McCarthy
committed
============== ====================================================
Paul McCarthy
committed
**Vertex sets**
A ``Mesh`` object can be associated with multiple sets of vertices, but
only one set of triangles. Vertices can be added via the
:meth:`addVertices` method. Each vertex set must be associated with a
unique key - you can then select the current vertex set via the
:meth:`vertices` property. Most ``Mesh`` methods will raise a ``KeyError``
if you have not added any vertex sets, or selected a vertex set. The
following methods are available for managing vertex sets:
.. autosummary::
:nosignatures:
addVertices
selectedVertices
**Vertex data**
A ``Mesh`` object can store vertex-wise data. The following methods can be
used for adding/retrieving vertex data:
Paul McCarthy
committed
.. autosummary::
:nosignatures:
loadVertexData
getVertexData
vertexDataSets
clearVertexData
The ``Mesh`` class inherits from the :class:`Notifier` class. Whenever the
``Mesh`` vertex set is changed, a notification is emitted via the
``Notifier`` interface, with a topic of ``'vertices'``. When this occurs,
the :meth:`vertices`, :meth:`bounds`, :meth:`normals` and :attr:`vnormals`
properties will all change so that they return data specific to the newly
selected vertex set.
The ``Mesh`` class also inherits from the :class:`Meta` class, so
any metadata associated with the ``Mesh`` may be added via those methods.
If the ``trimesh`` library is present, the following methods may be used
to perform geometric queries on a mesh:
.. autosummary::
:nosignatures:
rayIntersection
planeIntersection
nearestVertex
"""
def __new__(cls, *args, **kwargs):
"""Create a ``Mesh``. We must override ``__new__``, otherwise the
:class:`Meta` and :class:`Notifier` ``__new__`` methods will not be
called correctly.
"""
return super(Mesh, cls).__new__(cls, *args, **kwargs)
def __init__(self,
indices,
name='mesh',
dataSource=None,
vertices=None,
fixWinding=False):
"""Create a ``Mesh`` instance.
Before a ``Mesh`` can be used, some vertices must be added via the
:meth:`addVertices` method.
:arg indices: A list of indices into the vertex data, defining the
mesh triangles.
:arg name: A name for this ``Mesh``.

Paul McCarthy
committed
:arg dataSource: The data source for this ``Mesh``.
:arg vertices: Initial vertex set to add - given the key
``'default'``.
:arg fixWinding: Ignored if ``vertices is None``. Passed through to the
:meth:`addVertices` method along with ``vertices``.
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
self.__name = name
self.__dataSource = dataSource
self.__indices = np.asarray(indices).reshape((-1, 3))
# This attribute is used to store
# the currently selected vertex set,
# used as a kety into all of the
# dictionaries below.
self.__selected = None
# Flag used to keep track of whether
# the triangle winding order has been
# "fixed" - see the addVertices method.
self.__fixed = False
# All of these are populated
# in the addVertices method
self.__vertices = collections.OrderedDict()
self.__loBounds = collections.OrderedDict()
self.__hiBounds = collections.OrderedDict()
# These get populated on
# normals/vnormals accesses
self.__faceNormals = collections.OrderedDict()
self.__vertNormals = collections.OrderedDict()
# this gets populated in
# the addVertexData method
self.__vertexData = collections.OrderedDict()
# this gets populated
# in the trimesh method
self.__trimesh = collections.OrderedDict()

Paul McCarthy
committed
# Add initial vertex
# set if provided
if vertices is not None:
self.addVertices(vertices, fixWinding=fixWinding)

Paul McCarthy
committed
def __repr__(self):
"""Returns a string representation of this ``Mesh`` instance. """
return '{}({}, {})'.format(type(self).__name__,
self.name,
self.dataSource)
def __str__(self):
"""Returns a string representation of this ``Mesh`` instance.
"""
return self.__repr__()
@property
def name(self):
"""Returns the name of this ``Mesh``. """
return self.__name
@property
def dataSource(self):
"""Returns the data source of this ``Mesh``. """
return self.__dataSource
@property
def vertices(self):
"""The ``(N, 3)`` vertices of this mesh. """
return self.__vertices[self.__selected]
def vertices(self, key):
"""Select the current vertex set - a ``KeyError`` is raised
if no vertex set with the specified ``key`` has been added.
"""
# Force a key error if
# the key is invalid
self.__vertices[key]
self.__selected = key
@property
def indices(self):
"""The ``(M, 3)`` triangles of this mesh. """
@property
def normals(self):
"""A ``(M, 3)`` array containing surface normals for every
triangle in the mesh, normalised to unit length.
"""
selected = self.__selected
indices = self.__indices
vertices = self.__vertices[selected]
fnormals = self.__faceNormals.get(selected, None)
if fnormals is None:
fnormals = calcFaceNormals(vertices, indices)
self.__faceNormals[selected] = fnormals
@property
def vnormals(self):
"""A ``(N, 3)`` array containing normals for every vertex
in the mesh.
"""
selected = self.__selected
indices = self.__indices
vertices = self.__vertices[selected]
vnormals = self.__vertNormals.get(selected, None)
if vnormals is None:
vnormals = calcVertexNormals(vertices, indices, self.normals)
self.__vertNormals[selected] = vnormals
@property
def bounds(self):
"""Returns a tuple of values which define a minimal bounding box that
will contain all of the currently selected vertices in this
``Mesh`` instance. The bounding box is arranged like so:
``((xlow, ylow, zlow), (xhigh, yhigh, zhigh))``
"""
lo = self.__loBounds[self.__selected]
hi = self.__hiBounds[self.__selected]
return lo, hi

Paul McCarthy
committed
def addVertices(self, vertices, key=None, select=True, fixWinding=False):
"""Adds a set of vertices to this ``Mesh``.
:arg vertices: A `(n, 3)` array containing ``n`` vertices, compatible
with the indices specified in :meth:`__init__`.

Paul McCarthy
committed
:arg key: A key for this vertex set. If ``None`` defaults to
``'default'``.
:arg select: If ``True`` (the default), this vertex set is
made the currently selected vertex set.
:arg fixWinding: Defaults to ``False``. If ``True``, the vertex
winding order of every triangle is is fixed so they
all have outward-facing normal vectors.

Paul McCarthy
committed
if key is None:
key = 'default'
vertices = np.asarray(vertices)
lo = vertices.min(axis=0)
hi = vertices.max(axis=0)
self.__vertices[key] = vertices
self.__loBounds[key] = lo
self.__hiBounds[key] = hi
if select:
self.vertices = key
# indices already fixed?
if fixWinding and (not self.__fixed):
indices = self.indices
normals = self.normals
needsFix = needsFixing(vertices, indices, normals, lo, hi)
self.__fixed = True
# See needsFixing documentation
if needsFix:
indices[:, [1, 2]] = indices[:, [2, 1]]
for k, fn in self.__faceNormals.items():
self.__faceNormals[k] = fn * -1
"""Returns a list containing the keys of all vertex sets. """
return list(self.__vertices.keys())

Paul McCarthy
committed
def selectedVertices(self):
"""Returns the key of the currently selected vertex set. """
return self.__selected
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
def loadVertexData(self, infile, key=None):
"""Loads vertex-wise data from the given ``infile``, and adds it with
the given ``key``. This implementation supports loading data from
whitespace-delimited text files via ``numpy.loadtxt``, but sub-classes
may override this method to support additional file types.
:arg infile: File to load data from.
:arg key: Key to pass to :meth:`addVertexData`. If not provided,
set to ``infile`` (converted to an absolute path)
"""
infile = op.abspath(infile)
if key is None:
key = infile
nvertices = self.vertices.shape[0]
vertexData = np.loadtxt(infile)
vertexData = vertexData.reshape(nvertices, -1)
self.addVertexData(key, vertexData)
return vertexData
def addVertexData(self, key, vdata):
"""Adds a vertex-wise data set to the ``Mesh``. It can be retrieved
by passing the specified ``key`` to the :meth:`getVertexData` method.
"""
nvertices = self.vertices.shape[0]
if vdata.ndim not in (1, 2) or vdata.shape[0] != nvertices:
raise ValueError('{}: incompatible vertex data '
'shape: {}'.format(key, vdata.shape))
self.__vertexData[key] = vdata.reshape(nvertices, -1)
def getVertexData(self, key):
"""Returns the vertex data for the given ``key`` from the
internal vertex data cache. If there is no vertex data iwth the
given key, a ``KeyError`` is raised.
return self.__vertexData[key]
def clearVertexData(self):
"""Clears the internal vertex data cache - see the
:meth:`addVertexData` and :meth:`getVertexData` methods.
self.__vertexData = collections.OrderedDict()
def vertexDataSets(self):
"""Returns a list of keys for all loaded vertex data sets. """
return list(self.__vertexData.keys())
@memoize.Instanceify(memoize.memoize)
def trimesh(self):
"""Reference to a ``trimesh.Trimesh`` object which can be used for
geometric operations on the mesh.
If the ``trimesh`` or ``rtree`` libraries are not available, this
function returns ``None``, and none of the geometric query methods
will do anything.
"""
# trimesh is an optional dependency - rtree
# is a depedendency of trimesh which is a
# wrapper around libspatialindex, without
# which trimesh can't be used for calculating
# ray-mesh intersections.
try:
import trimesh
import rtree # noqa
except ImportError:
log.warning('trimesh is not available')
return None
tm = self.__trimesh.get(self.__selected, None)
if tm is None:
tm = trimesh.Trimesh(self.vertices,
self.indices,
process=False,
validate=False)
self.__trimesh[self.__selected] = tm
def rayIntersection(self, origins, directions, vertices=False):
"""Calculate the intersection between the mesh, and the rays defined by
``origins`` and ``directions``.
:arg origins: Sequence of ray origins
:arg directions: Sequence of ray directions
:returns: A tuple containing:
- A ``(n, 3)`` array containing the coordinates
where the mesh was intersected by each of the
``n`` rays.
- A ``(n,)`` array containing the indices of the
triangles that were intersected by each of the
``n`` rays.
"""
trimesh = self.trimesh()
if trimesh is None:
return np.zeros((0, 3)), np.zeros((0,))
tris, rays, locs = trimesh.ray.intersects_id(
origins,
directions,
return_locations=True,
multiple_hits=False)
return np.zeros((0, 3)), np.zeros((0,))
# sort by ray. I'm Not sure if this is
# needed - does trimesh do it for us?
rayIdxs = np.asarray(np.argsort(rays), np.int)
locs = locs[rayIdxs]
tris = tris[rayIdxs]
return locs, tris
def nearestVertex(self, points):
"""Identifies the nearest vertex to each of the provided points.
:arg points: A ``(n, 3)`` array containing the points to query.
:returns: A tuple containing:
- A ``(n, 3)`` array containing the nearest vertex for
for each of the ``n`` input points.
- A ``(n,)`` array containing the indices of each vertex.
- A ``(n,)`` array containing the distance from each
point to the nearest vertex.
"""
trimesh = self.trimesh()
if trimesh is None:
return np.zeros((0, 3)), np.zeros((0, )), np.zeros((0, ))
dists, idxs = trimesh.nearest.vertex(points)
verts = self.vertices[idxs, :]
return verts, idxs, dists
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
def planeIntersection(self,
normal,
origin,
distances=False):
"""Calculate the intersection of this ``TriangleMesh`` with
the plane defined by ``normal`` and ``origin``.
:arg normal: Vector defining the plane orientation
:arg origin: Point defining the plane location
:arg distances: If ``True``, barycentric coordinates for each
intersection line vertex are calculated and returned,
giving their respective distance from the intersected
triangle vertices.
:returns: A tuple containing
- A ``(m, 2, 3)`` array containing ``m`` vertices:
of a set of lines, defining the plane intersection
- A ``(m,)`` array containing the indices of the
``m`` triangles that were intersected.
- (if ``distances is True``) A ``(m, 2, 3)`` arra
containing the barycentric coordinates of each
line vertex with respect to its intersected
triangle.
"""
trimesh = self.trimesh()
if trimesh is None:
return np.zeros((0, 3)), np.zeros((0, 3))
import trimesh.intersections as tmint
import trimesh.triangles as tmtri
lines, faces = tmint.mesh_plane(
trimesh,
plane_normal=normal,
plane_origin=origin,
return_faces=True)
if not distances:
return lines, faces
# Calculate the barycentric coordinates
# (distance from triangle vertices) for
# each intersection line
triangles = self.vertices[self.indices[faces]].repeat(2, axis=0)
points = lines.reshape((-1, 3))
if triangles.size > 0:
dists = tmtri.points_to_barycentric(triangles, points)
dists = dists.reshape((-1, 2, 3))
else:
dists = np.zeros((0, 2, 3))
return lines, faces, dists
def calcFaceNormals(vertices, indices):
"""Calculates face normals for the mesh described by ``vertices`` and
``indices``.
Paul McCarthy
committed
:arg vertices: A ``(n, 3)`` array containing the mesh vertices.
:arg indices: A ``(m, 3)`` array containing the mesh triangles.
:returns: A ``(m, 3)`` array containing normals for every triangle in
the mesh.
v0 = vertices[indices[:, 0]]
v1 = vertices[indices[:, 1]]
v2 = vertices[indices[:, 2]]
fnormals = np.cross((v1 - v0), (v2 - v0))
fnormals = transform.normalise(fnormals)
def calcVertexNormals(vertices, indices, fnormals):
"""Calculates vertex normals for the mesh described by ``vertices``
and ``indices``.
:arg vertices: A ``(n, 3)`` array containing the mesh vertices.
:arg indices: A ``(m, 3)`` array containing the mesh triangles.
:arg fnormals: A ``(m, 3)`` array containing the face/triangle normals.
:returns: A ``(n, 3)`` array containing normals for every vertex in
the mesh.
"""
vnormals = np.zeros((vertices.shape[0], 3), dtype=np.float)
# TODO make fast. I can't figure
# out how to use np.add.at to
# accumulate the face normals for
# each vertex.
for i in range(indices.shape[0]):
Paul McCarthy
committed
vnormals[v0, :] += fnormals[i]
vnormals[v1, :] += fnormals[i]
vnormals[v2, :] += fnormals[i]
Paul McCarthy
committed
# normalise to unit length
return transform.normalise(vnormals)
Paul McCarthy
committed
def needsFixing(vertices, indices, fnormals, loBounds, hiBounds):
"""Determines whether the triangle winding order, for the mesh described by
``vertices`` and ``indices``, needs to be flipped.
Paul McCarthy
committed
If this function returns ``True``, the given ``indices`` and ``fnormals``
need to be adjusted so that all face normals are facing outwards from the
centre of the mesh. The necessary adjustments are as follows::
Paul McCarthy
committed
indices[:, [1, 2]] = indices[:, [2, 1]]
fnormals = fnormals * -1
Paul McCarthy
committed
:arg vertices: A ``(n, 3)`` array containing the mesh vertices.
:arg indices: A ``(m, 3)`` array containing the mesh triangles.
:arg fnormals: A ``(m, 3)`` array containing the face/triangle normals.
:arg loBounds: A ``(3, )`` array contaning the low vertex bounds.
:arg hiBounds: A ``(3, )`` array contaning the high vertex bounds.
Paul McCarthy
committed
:returns: ``True`` if the ``indices`` and ``fnormals`` need to be
adjusted, ``False`` otherwise.
Paul McCarthy
committed
"""
# Define a viewpoint which is
# far away from the mesh.
camera = loBounds - (hiBounds - loBounds)
# Find the nearest vertex
# to the viewpoint
dists = np.sqrt(np.sum((vertices - camera) ** 2, axis=1))
ivert = np.argmin(dists)
vert = vertices[ivert]
# Pick a triangle that
# this vertex is in and
# ges its face normal
itri = np.where(indices == ivert)[0][0]
n = fnormals[itri, :]
# Make sure the angle between the
# normal, and a vector from the
# vertex to the camera is positive
# If it isn't, we need to flip the
# triangle winding order.
return np.dot(n, transform.normalise(camera - vert)) < 0
"""Deprecated - use :class:`fsl.data.mesh.Mesh`, or one of its sub-classes
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
instead.
"""
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use fsl.data.mesh.Mesh, or one '
'of its sub-classes instead')
def __init__(self, data, indices=None, fixWinding=False):
import fsl.data.vtk as fslvtk
if isinstance(data, six.string_types):
name = op.basename(data)
dataSource = data
mesh = fslvtk.VTKMesh(data, fixWinding=False)
vertices = mesh.vertices
indices = mesh.indices
else:
name = 'TriangleMesh'
dataSource = None
vertices = data
Mesh.__init__(self, indices, name=name, dataSource=dataSource)
self.addVertices(vertices, 'default', fixWinding=fixWinding)
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use the Mesh class instead')
def loadVertexData(self, dataSource, vertexData=None):
nvertices = self.vertices.shape[0]
# Currently only white-space delimited
# text files are supported
if vertexData is None:
vertexData = np.loadtxt(dataSource)
vertexData.reshape(nvertices, -1)
if vertexData.shape[0] != nvertices:
raise ValueError('Incompatible size: {}'.format(dataSource))
self.addVertexData(dataSource, vertexData)
return vertexData
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use bounds instead')
def getBounds(self):
"""Deprecated - use :meth:`bounds` instead. """
return self.bounds
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use the Mesh class instead')
def getVertexData(self, dataSource):
try:
return Mesh.getVertexData(self, dataSource)
except KeyError:
return self.loadVertexData(dataSource)
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use fsl.data.vtk.loadVTKPolydataFile instead')
def loadVTKPolydataFile(*args, **kwargs):
"""Deprecated - use :func:`fsl.data.vtk.loadVTKPolydataFile` instead. """
import fsl.data.vtk as fslvtk
return fslvtk.loadVTKPolydataFile(*args, **kwargs)
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use fsl.data.vtk.getFIRSTPrefix instead')
def getFIRSTPrefix(*args, **kwargs):
"""Deprecated - use :func:`fsl.data.vtk.getFIRSTPrefix` instead. """
import fsl.data.vtk as fslvtk
return fslvtk.getFIRSTPrefix(*args, **kwargs)
@deprecation.deprecated(deprecated_in='1.6.0',
removed_in='2.0.0',
details='Use fsl.data.vtk.findReferenceImage instead')
def findReferenceImage(*args, **kwargs):
"""Deprecated - use :func:`fsl.data.vtk.findReferenceImage` instead. """
import fsl.data.vtk as fslvtk
return fslvtk.findReferenceImage(*args, **kwargs)
ALLOWED_EXTENSIONS = ['.vtk']
"""Deprecated, will be removed in fslpy 2.0.0. Use
:attr:`fsl.data.vtk.ALLOWED_EXTENSIONS` instead."""
EXTENSION_DESCRIPTIONS = ['VTK polygon model file']
"""Deprecated, will be removed in fslpy 2.0.0. Use
:attr:`fsl.data.vtk.EXTENSION_DESCRIPTIONS` instead."""