Source code for fsl.data.mesh

#!/usr/bin/env python
#
# mesh.py - The TriangleMesh class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`Mesh` class, which represents a
3D model made of triangles.

See also the following modules:

  .. autosummary::

     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 os.path as op
import numpy   as np

import fsl.utils.meta       as meta
import fsl.utils.notifier   as notifier
import fsl.transform.affine as affine


log = logging.getLogger(__name__)


[docs] class IncompatibleVerticesError(ValueError): """``ValueError`` raised by the :meth:`Mesh.addVertices` method if an attempt is made to add a vertex set with the wrong number of vertices. """
[docs] 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 defined by ``(M, 3)`` indices into the list of vertices. A ``Mesh`` instance has the following attributes: ============== ====================================================== ``name`` A name, typically the file name sans-suffix. ``dataSource`` Full path to the mesh file (or ``None`` if there is no file associated with this mesh). ``nvertices`` The number of vertices in the 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. ``trimesh`` (if the `trimesh <https://github.com/mikedh/trimesh>`_ library is present) A ``trimesh.Trimesh`` object which can be used for geometric queries on the mesh. ============== ====================================================== **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: loadVertices addVertices selectedVertices vertexSets .. note:: Internally the ``Mesh`` class may store two versions of the triangles, with opposite unwinding orders. It keeps track of the required triangle unwinding order for each vertex set, so that the :meth:`indices` method will return the appropriate copy for the currently selected vertex set. **Vertex data** A ``Mesh`` object can store vertex-wise data. The following methods can be used for adding/retrieving vertex data: .. autosummary:: :nosignatures: loadVertexData addVertexData getVertexData vertexDataSets clearVertexData **Notification** 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. **Metadata** The ``Mesh`` class also inherits from the :class:`Meta` class, so any metadata associated with the ``Mesh`` may be added via those methods. **Geometric queries** If the ``trimesh`` library is present, the following methods may be used to perform geometric queries on a mesh: .. autosummary:: :nosignatures: rayIntersection planeIntersection nearestVertex """
[docs] def __init__(self, indices=None, 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. If not provided, must be provided after creation via the :meth:`indices` setter method. :arg name: A name for this ``Mesh``. :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``. """ if indices is None and vertices is not None: raise ValueError('Indices must be provided ' 'if vertices are provided') self.__name = name self.__dataSource = dataSource # nvertices/indices are assigned in the # indices setter method. # We potentially store two copies of # the indices, - one set (__indices) # as provided, and the other # (__fixedIndices) with opposite # unwinding orders. The vindices dict # stores refs to one or the other for # each vertex set. self.__nvertices = None self.__indices = None self.__fixedIndices = None self.__vindices = collections.OrderedDict() # All of these are populated # in the addVertices method self.__selected = None 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() # Add initial indices/vertices if provided if indices is not None: self.indices = indices if vertices is not None: self.addVertices(vertices, fixWinding=fixWinding)
[docs] def __repr__(self): """Returns a string representation of this ``Mesh`` instance. """ return '{}({}, {})'.format(type(self).__name__, self.name, self.dataSource)
[docs] 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 @name.setter def name(self, name): """Set the name of this ``Mesh``. """ self.__name = name @property def dataSource(self): """Returns the data source of this ``Mesh``. """ return self.__dataSource @property def nvertices(self): """Returns the number of vertices in the mesh. """ return self.__nvertices @property def vertices(self): """The ``(N, 3)`` vertices of this mesh. """ return self.__vertices[self.__selected] @vertices.setter def vertices(self, key): """Select the current vertex set - a ``KeyError`` is raised if no vertex set with the specified ``key`` has been added. When the current vertex set is changed, a notification is emitted through the :class:`.Notifier` interface, with the topic ``'vertices'``. """ # Force a key error if # the key is invalid self.__vertices[key] if self.__selected != key: self.__selected = key self.notify(topic='vertices') @property def indices(self): """The ``(M, 3)`` triangles of this mesh. Returns ``None`` if indices have not yet been assigned. """ if self.__indices is None: return None return self.__vindices[self.__selected] @indices.setter def indices(self, indices): """Set the indices for this mesh. """ if self.__indices is not None: raise ValueError('Indices are already set') indices = np.asarray(indices, dtype=np.int32) self.__nvertices = int(indices.max()) + 1 self.__indices = indices.reshape((-1, 3)) @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.__vindices[selected] vertices = self.__vertices[selected] fnormals = self.__faceNormals.get(selected, None) if fnormals is None: fnormals = calcFaceNormals(vertices, indices) self.__faceNormals[selected] = fnormals return fnormals @property def vnormals(self): """A ``(N, 3)`` array containing normals for every vertex in the mesh. """ selected = self.__selected indices = self.__vindices[selected] vertices = self.__vertices[selected] vnormals = self.__vertNormals.get(selected, None) if vnormals is None: vnormals = calcVertexNormals(vertices, indices, self.normals) self.__vertNormals[selected] = vnormals return 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))`` Returns ``None`` if indices or vertices have not yet been assigned. """ if self.__indices is None or len(self.__vertices) == 0: return None lo = self.__loBounds[self.__selected] hi = self.__hiBounds[self.__selected] return lo, hi
[docs] def loadVertices(self, infile, key=None, **kwargs): """Loads vertex data from the given ``infile``, and adds it as a vertex set with the given ``key``. This implementation supports loading vertex data from white-space 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:`addVertices`. If not provided, set to ``infile`` (converted to an absolute path) All of the other arguments are passed through to :meth:`addVertices`. :returns: The loaded vertices. """ infile = op.abspath(infile) if key is None: key = infile vertices = np.loadtxt(infile) return self.addVertices(vertices, key, **kwargs)
[docs] 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__`. :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. :returns: The vertices, possibly reshaped :raises: ``IncompatibleVerticesError`` if the provided ``vertices`` array has the wrong number of vertices. """ if self.__indices is None: raise ValueError('Mesh indices have not yet been set') if key is None: key = 'default' vertices = np.asarray(vertices) lo = vertices.min(axis=0) hi = vertices.max(axis=0) # Don't allow vertices of # different size to be added try: vertices = vertices.reshape(self.nvertices, 3) # reshape raised an error - # wrong number of vertices except ValueError: raise IncompatibleVerticesError( f'{key}: invalid number of vertices: ' f'{vertices.shape} != ({self.nvertices}, 3)') self.__vertices[key] = vertices self.__vindices[key] = self.__indices self.__loBounds[key] = lo self.__hiBounds[key] = hi if select: self.vertices = key if fixWinding: indices = self.__indices normals = calcFaceNormals(vertices, indices) needsFix = needsFixing(vertices, indices, normals, lo, hi) # See needsFixing documentation if needsFix: if self.__fixedIndices is None: self.__fixedIndices = indices[:, [0, 2, 1]] self.__vindices[ key] = self.__fixedIndices self.__faceNormals[key] = normals * -1 else: self.__faceNormals[key] = normals return vertices
[docs] def vertexSets(self): """Returns a list containing the keys of all vertex sets. """ return list(self.__vertices.keys())
[docs] def selectedVertices(self): """Returns the key of the currently selected vertex set. """ return self.__selected
[docs] 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) :returns: The loaded vertex data. """ infile = op.abspath(infile) if key is None: key = infile vertexData = np.loadtxt(infile) return self.addVertexData(key, vertexData)
[docs] 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. :returns: The vertex data, possibly reshaped. """ nvertices = self.nvertices if vdata.ndim not in (1, 2) or vdata.shape[0] != nvertices: raise ValueError('{}: incompatible vertex data ' 'shape: {}'.format(key, vdata.shape)) vdata = vdata.reshape(nvertices, -1) self.__vertexData[key] = vdata return vdata
[docs] 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]
[docs] def clearVertexData(self): """Clears the internal vertex data cache - see the :meth:`addVertexData` and :meth:`getVertexData` methods. """ self.__vertexData = collections.OrderedDict()
[docs] def vertexDataSets(self): """Returns a list of keys for all loaded vertex data sets. """ return list(self.__vertexData.keys())
@property 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 return tm
[docs] 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) if len(tris) == 0: 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)) locs = locs[rayIdxs] tris = tris[rayIdxs] return locs, tris
[docs] 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
[docs] 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)`` array 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
[docs] def calcFaceNormals(vertices, indices): """Calculates face 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. :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 = affine.normalise(fnormals) return np.atleast_2d(fnormals)
[docs] 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=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]): v0, v1, v2 = indices[i] vnormals[v0, :] += fnormals[i] vnormals[v1, :] += fnormals[i] vnormals[v2, :] += fnormals[i] # normalise to unit length return affine.normalise(vnormals)
[docs] 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. 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:: indices[:, [1, 2]] = indices[:, [2, 1]] fnormals = fnormals * -1 :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. :returns: ``True`` if the ``indices`` and ``fnormals`` need to be adjusted, ``False`` otherwise. """ # 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] # Get all the triangles # that this vertex is in # and their face normals itris = np.where(indices == ivert)[0] norms = fnormals[itris, :] # Calculate the angle between each # normal, and a vector from the # vertex to the camera. If more than # 50% of the angles are negative # (== more than 90 degrees == the # face is facing away from the # camera), assume that we need to # flip the triangle winding order. angles = np.dot(norms, affine.normalise(camera - vert)) return ((angles >= 0).sum() / len(itris)) < 0.5