diff --git a/fsl/data/mesh.py b/fsl/data/mesh.py index a979c01cc9a0d25d55acbefbdbb2be66590bc099..3017b5db02ef5dffae76cd49cd824e01a2585393 100644 --- a/fsl/data/mesh.py +++ b/fsl/data/mesh.py @@ -4,30 +4,40 @@ # # Author: Paul McCarthy <pauldmccarthy@gmail.com> # -"""This module provides the :class:`TriangleMesh` class, which represents a +"""This module provides the :class:`Mesh` class, which represents a 3D model made of triangles. -.. note:: I/O support is very limited - currently, the only supported file - type is the VTK legacy file format, containing the ``POLYDATA`` - dataset. the :class:`TriangleMesh` class assumes that every polygon - defined in an input file is a triangle (i.e. refers to three - vertices). +See also the following modules: - See http://www.vtk.org/wp-content/uploads/2015/04/file-formats.pdf - for an overview of the VTK legacy file format. + .. autosummary:: - In the future, I may or may not add support for more complex meshes. + 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 six - import fsl.utils.meta as meta +import fsl.utils.notifier as notifier import fsl.utils.memoize as memoize import fsl.utils.transform as transform @@ -37,13 +47,13 @@ from . import image as fslimage log = logging.getLogger(__name__) -class TriangleMesh(meta.Meta): - """The ``TriangleMesh`` class represents a 3D model. A mesh is defined by a +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 ``TriangleMesh`` instance has the following attributes: + A ``Mesh`` instance has the following attributes: ============== ==================================================== @@ -52,134 +62,164 @@ class TriangleMesh(meta.Meta): ``dataSource`` Full path to the mesh file (or ``None`` if there is no file associated with this mesh). - ``vertices`` A :math:`N\times 3` ``numpy`` array containing - the vertices. + ``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 :meth:`M\times 3` ``numpy`` array containing - the vertex indices for :math:`M` triangles + ``indices`` A ``(m, 3)`` array containing the vertex indices + for ``m`` triangles - ``normals`` A :math:`M\times 3` ``numpy`` array containing - face normals. + ``normals`` A ``(m, 3)`` array containing face normals for the + triangles - ``vnormals`` A :math:`N\times 3` ``numpy`` array containing - vertex normals. + ``vnormals`` A ``(n, 3)`` array containing vertex normals for the + the current vertices. ============== ==================================================== - And the following methods: + **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. + + + **Vertex data** + + + A ``Mesh`` object can store vertex-wise data. The following methods can be + used for adding/retrieving vertex data: .. autosummary:: :nosignatures: - getBounds - loadVertexData + addVertexData getVertexData clearVertexData - """ - def __init__(self, data, indices=None, fixWinding=False): - """Create a ``TriangleMesh`` instance. + **Notification** - :arg data: Can either be a file name, or a :math:`N\\times 3` - ``numpy`` array containing vertex data. If ``data`` - is a file name, it is passed to the - :func:`loadVTKPolydataFile` function. - :arg indices: A list of indices into the vertex data, defining - the triangles. + 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. - :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. - """ - if isinstance(data, six.string_types): - infile = data - data, lengths, indices = loadVTKPolydataFile(infile) + **Metadata* - if np.any(lengths != 3): - raise RuntimeError('All polygons in VTK file must be ' - 'triangles ({})'.format(infile)) - self.name = op.basename(infile) - self.dataSource = infile - else: - self.name = 'TriangleMesh' - self.dataSource = None + The ``Mesh`` class also inherits from the :class:`Meta` class, so + any metadata associated with the ``Mesh`` may be added via those methods. - if indices is None: - indices = np.arange(data.shape[0]) - self.__vertices = np.array(data) - self.__indices = np.array(indices).reshape((-1, 3)) + **Geometric queries** - self.__vertexData = {} - self.__faceNormals = None - self.__vertNormals = None - self.__loBounds = self.vertices.min(axis=0) - self.__hiBounds = self.vertices.max(axis=0) - if fixWinding: - self.__fixWindingOrder() + If the ``trimesh`` library is present, the following methods may be used + to perform geometric queries on a mesh: + .. autosummary:: + :nosignatures: - def __repr__(self): - """Returns a string representation of this ``TriangleMesh`` instance. - """ - return '{}({}, {})'.format(type(self).__name__, - self.name, - self.dataSource) + rayIntersection + planeIntersection + nearestVertex + """ - def __str__(self): - """Returns a string representation of this ``TriangleMesh`` instance. - """ - return self.__repr__() + def __init__(self, indices, name='mesh', dataSource=None): + """Create a ``Mesh`` instance. + + Before a ``Mesh`` can be used, some vertices must be added via the + :meth:`addVertices` method. - def __fixWindingOrder(self): - """Called by :meth:`__init__` if ``fixWinding is True``. Fixes the - mesh triangle winding order so that all face normals are facing - outwards from the centre of the mesh. + :arg indices: A list of indices into the vertex data, defining the + mesh triangles. + + :arg name: A name for this ``Mesh``. + + :arg dataSource: the data source for this ``Mesh``. """ - # Define a viewpoint which is - # far away from the mesh. - fnormals = self.normals - camera = self.__loBounds - (self.__hiBounds - self.__loBounds) - - # Find the nearest vertex - # to the viewpoint - dists = np.sqrt(np.sum((self.vertices - camera) ** 2, axis=1)) - ivert = np.argmin(dists) - vert = self.vertices[ivert] - - # Pick a triangle that - # this vertex in and - # ges its face normal - itri = np.where(self.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, flip the triangle - # winding order. - if np.dot(n, transform.normalise(camera - vert)) < 0: - self.indices[:, [1, 2]] = self.indices[:, [2, 1]] - self.__faceNormals *= -1 + 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() + + + @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 + return self.__vertices[self.__selected] + + + @property.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. + """ + + # 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. """ - return self.__indices + return self.__indices[self.__selected] @property @@ -188,18 +228,16 @@ class TriangleMesh(meta.Meta): triangle in the mesh, normalised to unit length. """ - if self.__faceNormals is not None: - return self.__faceNormals - - v0 = self.vertices[self.indices[:, 0]] - v1 = self.vertices[self.indices[:, 1]] - v2 = self.vertices[self.indices[:, 2]] - - n = np.cross((v1 - v0), (v2 - v0)) + selected = self.__selected + indices = self.__indices + vertices = self.__vertices[selected] + fnormals = self.__faceNormals.get(selected, None) - self.__faceNormals = transform.normalise(n) + if fnormals is None: + fnormals = calcFaceNormals(vertices, indices) + self.__faceNormals[selected] = fnormals - return self.__faceNormals + return fnormals @property @@ -207,89 +245,98 @@ class TriangleMesh(meta.Meta): """A ``(N, 3)`` array containing normals for every vertex in the mesh. """ - if self.__vertNormals is not None: - return self.__vertNormals - # per-face normals - fnormals = self.normals - vnormals = np.zeros((self.vertices.shape[0], 3), dtype=np.float) + selected = self.__selected + indices = self.__indices + vertices = self.__vertices[selected] + vnormals = self.__vertNormals.get(selected, None) - # 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(self.indices.shape[0]): + if vnormals is None: + vnormals = calcVertexNormals(vertices, indices, self.normals) + self.__vertNormals[selected] = vnormals - v0, v1, v2 = self.indices[i] + return vnormals - vnormals[v0, :] += fnormals[i] - vnormals[v1, :] += fnormals[i] - vnormals[v2, :] += fnormals[i] - # normalise to unit length - self.__vertNormals = transform.normalise(vnormals) - - return self.__vertNormals - - - def getBounds(self): + @property + def bounds(self): """Returns a tuple of values which define a minimal bounding box that - will contain all vertices in this ``TriangleMesh`` instance. The - bounding box is arranged like so: + 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))`` """ - return (self.__loBounds, self.__hiBounds) + lo = self.__loBounds[self.__selected] + hi = self.__hiBounds[self.__selected] + + return lo, hi - def loadVertexData(self, dataSource, vertexData=None): - """Attempts to load scalar data associated with each vertex of this - ``TriangleMesh`` from the given ``dataSource``. The data is returned, - and also stored in an internal cache so it can be retrieved later - via the :meth:`getVertexData` method. - This method may be overridden by sub-classes. + def addVertices(self, vertices, key, select=True, fixWinding=False): + """Adds a set of vertices to this ``Mesh``. - :arg dataSource: Path to the vertex data to load - :arg vertexData: The vertex data itself, if it has already been - loaded. + :arg vertices: A `(n, 3)` array containing ``n`` vertices, compatible + with the indices specified in :meth:`__init__`. - :returns: A ``(M, N)``) array, which contains ``N`` data points - for ``M`` vertices. + :arg key: A key for this vertex set. + + :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. """ - nvertices = self.vertices.shape[0] + vertices = np.asarray(vertices) + lo = vertices.min(axis=0) + hi = vertices.max(axis=0) - # Currently only white-space delimited - # text files are supported - if vertexData is None: - vertexData = np.loadtxt(dataSource) - vertexData.reshape(nvertices, -1) + self.__vertices[key] = vertices + self.__loBounds[key] = lo + self.__hiBounds[key] = hi - if vertexData.shape[0] != nvertices: - raise ValueError('Incompatible size: {}'.format(dataSource)) + if select: + self.vertices = key - self.__vertexData[dataSource] = vertexData + # 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 - return vertexData + # See needsFixing documentation + if needsFix: + + indices[:, [1, 2]] = indices[:, [2, 1]] + + for k, fn in self.__faceNormals.items(): + self.__faceNormals[k] = fn * -1 + + + 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. + """ + self.__vertexData[key] = vdata - def getVertexData(self, dataSource): - """Returns the vertex data for the given ``dataSource`` from the - internal vertex data cache. If the given ``dataSource`` is not - in the cache, it is loaded via :meth:`loadVertexData`. + 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. """ - try: return self.__vertexData[dataSource] - except KeyError: return self.loadVertexData(dataSource) + return self.__vertexData[key] def clearVertexData(self): """Clears the internal vertex data cache - see the - :meth:`loadVertexData` and :meth:`getVertexData` methods. + :meth:`addVertexData` and :meth:`getVertexData` methods. """ - - self.__vertexData = {} + self.__vertexData = collections.OrderedDict() @memoize.Instanceify(memoize.memoize) @@ -298,7 +345,8 @@ class TriangleMesh(meta.Meta): geometric operations on the mesh. If the ``trimesh`` or ``rtree`` libraries are not available, this - function returns ``None`` + function returns ``None``, and none of the geometric query methods + will do anything. """ # trimesh is an optional dependency - rtree @@ -313,15 +361,17 @@ class TriangleMesh(meta.Meta): log.warning('trimesh is not available') return None - if hasattr(self, '__trimesh'): - return self.__trimesh + tm = self.__trimesh.get(self.__selected, None) + + if tm is None: + tm = trimesh.Trimesh(self.vertices, + self.indices, + process=False, + validate=False) - self.__trimesh = trimesh.Trimesh(self.__vertices, - self.__indices, - process=False, - validate=False) + self.__trimesh[self.__selected] = tm - return self.__trimesh + return tm def rayIntersection(self, origins, directions, vertices=False): @@ -453,107 +503,95 @@ class TriangleMesh(meta.Meta): return lines, faces, dists +def calcFaceNormals(vertices, indices): + """Calculates face normals for the mesh described by ``vertices`` and + ``indices``. -ALLOWED_EXTENSIONS = ['.vtk'] -"""A list of file extensions which could contain :class:`TriangleMesh` data. -""" - - -EXTENSION_DESCRIPTIONS = ['VTK polygon model file'] -"""A description for each of the extensions in :data:`ALLOWED_EXTENSIONS`.""" - - -def loadVTKPolydataFile(infile): - """Loads a vtk legacy file containing a ``POLYDATA`` data set. - - :arg infile: Name of a file to load from. - - :returns: a tuple containing three values: - - - A :math:`N\\times 3` ``numpy`` array containing :math:`N` - vertices. - - A 1D ``numpy`` array containing the lengths of each polygon. - - A 1D ``numpy`` array containing the vertex indices for all - polygons. + :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. """ - lines = None + v0 = vertices[indices[:, 0]] + v1 = vertices[indices[:, 1]] + v2 = vertices[indices[:, 2]] - with open(infile, 'rt') as f: - lines = f.readlines() + fnormals = np.cross((v1 - v0), (v2 - v0)) + fnormals = transform.normalise(fnormals) - lines = [l.strip() for l in lines] + return fnormals - if lines[3] != 'DATASET POLYDATA': - raise ValueError('Only the POLYDATA data type is supported') - nVertices = int(lines[4].split()[1]) - nPolygons = int(lines[5 + nVertices].split()[1]) - nIndices = int(lines[5 + nVertices].split()[2]) - nPolygons +def calcVertexNormals(vertices, indices, fnormals): + """Calculates vertex normals for the mesh described by ``vertices`` + and ``indices``. - vertices = np.zeros((nVertices, 3), dtype=np.float32) - polygonLengths = np.zeros( nPolygons, dtype=np.uint32) - indices = np.zeros( nIndices, dtype=np.uint32) - - for i in range(nVertices): - vertLine = lines[i + 5] - vertices[i, :] = [float(w) for w in vertLine.split()] - - indexOffset = 0 - for i in range(nPolygons): - - polyLine = lines[6 + nVertices + i].split() - polygonLengths[i] = int(polyLine[0]) + :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. + """ - start = indexOffset - end = indexOffset + polygonLengths[i] - indices[start:end] = [int(w) for w in polyLine[1:]] + vnormals = np.zeros((vertices.shape[0], 3), dtype=np.float) - indexOffset += polygonLengths[i] + # 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]): - return vertices, polygonLengths, indices + v0, v1, v2 = indices[i] + vnormals[v0, :] += fnormals[i] + vnormals[v1, :] += fnormals[i] + vnormals[v2, :] += fnormals[i] -def getFIRSTPrefix(modelfile): - """If the given ``vtk`` file was generated by `FIRST - <https://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FIRST>`_, this function - will return the file prefix. Otherwise a ``ValueError`` will be - raised. - """ + # normalise to unit length + return transform.normalise(vnormals) - if not modelfile.endswith('first.vtk'): - raise ValueError('Not a first vtk file: {}'.format(modelfile)) - modelfile = op.basename(modelfile) - prefix = modelfile.split('-') - prefix = '-'.join(prefix[:-1]) +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. - return prefix + 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 -def findReferenceImage(modelfile): - """Given a ``vtk`` file, attempts to find a corresponding ``NIFTI`` - image file. Return the path to the image, or ``None`` if no image was - found. + :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. - Currently this function will only return an image for ``vtk`` files - generated by `FIRST <https://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FIRST>`_. + :returns: ``True`` if the ``indices`` and ``fnormals`` need to be + adjusted, ``False`` otherwise. """ - try: - - dirname = op.dirname(modelfile) - prefixes = [getFIRSTPrefix(modelfile)] - except ValueError: - return None - - if prefixes[0].endswith('_first'): - prefixes.append(prefixes[0][:-6]) - - for p in prefixes: - try: - return fslimage.addExt(op.join(dirname, p), mustExist=True) - except fslimage.PathError: - continue - - return None + # 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