From ebac66daeb4ea226291f6bd6c1516e690657c4c8 Mon Sep 17 00:00:00 2001 From: Nikhila Ravi Date: Fri, 18 Dec 2020 07:25:18 -0800 Subject: [PATCH] Classic Marching Cubes algorithm implementation Summary: Defines a function to run marching cubes algorithm on a single or batch of 3D scalar fields. Returns a mesh's faces and vertices. UPDATES (12/18) - Input data is now specified as a (B, D, H, W) tensor as opposed to a (B, W, H, D) tensor. This will now be compatible with the Volumes datastructure. - Add an option to return output vertices in local coordinates instead of world coordinates. Also added a small fix to remove the dype for device in Transforms3D - if passing in a torch.device instead of str it causes a pyre error. Reviewed By: jcjohnson Differential Revision: D24599019 fbshipit-source-id: 90554a200319fed8736a12371cc349e7108aacd0 --- pytorch3d/ops/marching_cubes.py | 347 ++++++++ pytorch3d/ops/marching_cubes_data.py | 545 +++++++++++++ pytorch3d/transforms/transform3d.py | 10 +- tests/bm_marching_cubes.py | 25 + .../double_ellipsoid.pickle | Bin 0 -> 233319 bytes .../sphere_level64.pickle | Bin 0 -> 70119 bytes tests/test_marching_cubes.py | 771 ++++++++++++++++++ 7 files changed, 1693 insertions(+), 5 deletions(-) create mode 100644 pytorch3d/ops/marching_cubes.py create mode 100644 pytorch3d/ops/marching_cubes_data.py create mode 100644 tests/bm_marching_cubes.py create mode 100644 tests/data/test_marching_cubes_data/double_ellipsoid.pickle create mode 100644 tests/data/test_marching_cubes_data/sphere_level64.pickle create mode 100644 tests/test_marching_cubes.py diff --git a/pytorch3d/ops/marching_cubes.py b/pytorch3d/ops/marching_cubes.py new file mode 100644 index 00000000..e1d6bca8 --- /dev/null +++ b/pytorch3d/ops/marching_cubes.py @@ -0,0 +1,347 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. + +from typing import Dict, List, Optional, Tuple + +import torch +from pytorch3d.ops.marching_cubes_data import EDGE_TABLE, EDGE_TO_VERTICES, FACE_TABLE +from pytorch3d.transforms import Translate + + +EPS = 0.00001 + + +class Cube: + def __init__(self, bfl_vertex: Tuple[int, int, int], spacing: int = 1): + """ + Initializes a cube given the bottom front left vertex coordinate + and the cube spacing + + Edge and vertex convention: + + v4_______e4____________v5 + /| /| + / | / | + e7/ | e5/ | + /___|______e6_________/ | + v7| | |v6 |e9 + | | | | + | |e8 |e10| + e11| | | | + | |_________________|___| + | / v0 e0 | /v1 + | / | / + | /e3 | /e1 + |/_____________________|/ + v3 e2 v2 + + Args: + bfl_vertex: a tuple of size 3 corresponding to the bottom front left vertex + of the cube in (x, y, z) format + spacing: the length of each edge of the cube + """ + # match corner orders to algorithm convention + if len(bfl_vertex) != 3: + msg = "The vertex {} is size {} instead of size 3".format( + bfl_vertex, len(bfl_vertex) + ) + raise ValueError(msg) + + x, y, z = bfl_vertex + self.vertices = torch.tensor( + [ + [x, y, z + spacing], + [x + spacing, y, z + spacing], + [x + spacing, y, z], + [x, y, z], + [x, y + spacing, z + spacing], + [x + spacing, y + spacing, z + spacing], + [x + spacing, y + spacing, z], + [x, y + spacing, z], + ] + ) + + def get_index(self, volume_data: torch.Tensor, isolevel: float) -> int: + """ + Calculates the cube_index in the range 0-255 to index + into EDGE_TABLE and FACE_TABLE + Args: + volume_data: the 3D scalar data + isolevel: the isosurface value used as a threshold + for determining whether a point is inside/outside + the volume + """ + cube_index = 0 + bit = 1 + for index in range(len(self.vertices)): + vertex = self.vertices[index] + value = _get_value(vertex, volume_data) + if value < isolevel: + cube_index |= bit + bit *= 2 + return cube_index + + +def marching_cubes_naive( + volume_data_batch: torch.Tensor, + isolevel: Optional[float] = None, + spacing: int = 1, + return_local_coords: bool = True, +) -> Tuple[List[torch.Tensor], List[torch.Tensor]]: + """ + Runs the classic marching cubes algorithm, iterating over + the coordinates of the volume_data and using a given isolevel + for determining intersected edges of cubes of size `spacing`. + Returns vertices and faces of the obtained mesh. + This operation is non-differentiable. + + This is a naive implementation, and is not optimized for efficiency. + + Args: + volume_data_batch: a Tensor of size (N, D, H, W) corresponding to + a batch of 3D scalar fields + isolevel: the isosurface value to use as the threshold to determine + whether points are within a volume. If None, then the average of the + maximum and minimum value of the scalar field will be used. + spacing: an integer specifying the cube size to use + return_local_coords: bool. If True the output vertices will be in local coordinates in + the range [-1, 1] x [-1, 1] x [-1, 1]. If False they will be in the range + [0, W-1] x [0, H-1] x [0, D-1] + Returns: + verts: [(V_0, 3), (V_1, 3), ...] List of N FloatTensors of vertices. + faces: [(F_0, 3), (F_1, 3), ...] List of N LongTensors of faces. + """ + volume_data_batch = volume_data_batch.detach().cpu() + batched_verts, batched_faces = [], [] + D, H, W = volume_data_batch.shape[1:] + # pyre-ignore [16] + volume_size_xyz = volume_data_batch.new_tensor([W, H, D])[None] + + if return_local_coords: + # Convert from local coordinates in the range [-1, 1] range to + # world coordinates in the range [0, D-1], [0, H-1], [0, W-1] + local_to_world_transform = Translate( + x=+1.0, y=+1.0, z=+1.0, device=volume_data_batch.device + ).scale((volume_size_xyz - 1) * spacing * 0.5) + # Perform the inverse to go from world to local + world_to_local_transform = local_to_world_transform.inverse() + + for i in range(len(volume_data_batch)): + volume_data = volume_data_batch[i] + curr_isolevel = ( + ((volume_data.max() + volume_data.min()) / 2).item() + if isolevel is None + else isolevel + ) + edge_vertices_to_index = {} + vertex_coords_to_index = {} + verts, faces = [], [] + # Use length - spacing for the bounds since we are using + # cubes of size spacing, with the lowest x,y,z values + # (bottom front left) + for x in range(0, W - spacing, spacing): + for y in range(0, H - spacing, spacing): + for z in range(0, D - spacing, spacing): + cube = Cube((x, y, z), spacing) + new_verts, new_faces = polygonise( + cube, + curr_isolevel, + volume_data, + edge_vertices_to_index, + vertex_coords_to_index, + ) + verts.extend(new_verts) + faces.extend(new_faces) + if len(faces) > 0 and len(verts) > 0: + verts = torch.tensor(verts, dtype=torch.float32) + # Convert vertices from world to local coords + if return_local_coords: + verts = world_to_local_transform.transform_points(verts[None, ...]) + verts = verts.squeeze() + batched_verts.append(verts) + batched_faces.append(torch.tensor(faces, dtype=torch.int64)) + return batched_verts, batched_faces + + +def polygonise( + cube: Cube, + isolevel: float, + volume_data: torch.Tensor, + edge_vertices_to_index: Dict[Tuple[Tuple, Tuple], int], + vertex_coords_to_index: Dict[Tuple[float, float, float], int], +) -> Tuple[list, list]: + """ + Runs the classic marching cubes algorithm for one Cube in the volume. + Returns the vertices and faces for the given cube. + + Args: + cube: a Cube indicating the cube being examined for edges that intersect + the volume data. + isolevel: the isosurface value to use as the threshold to determine + whether points are within a volume. + volume_data: a Tensor of shape (D, H, W) corresponding to + a 3D scalar field + edge_vertices_to_index: A dictionary which maps an edge's two coordinates + to the index of its interpolated point, if that interpolated point + has already been used by a previous point + vertex_coords_to_index: A dictionary mapping a point (x, y, z) to the corresponding + index of that vertex, if that point has already been marked as a vertex. + Returns: + verts: List of triangle vertices for the given cube in the volume + faces: List of triangle faces for the given cube in the volume + """ + num_existing_verts = max(edge_vertices_to_index.values(), default=-1) + 1 + verts, faces = [], [] + cube_index = cube.get_index(volume_data, isolevel) + edges = EDGE_TABLE[cube_index] + edge_indices = _get_edge_indices(edges) + if len(edge_indices) == 0: + return [], [] + + new_verts, edge_index_to_point_index = _calculate_interp_vertices( + edge_indices, + volume_data, + cube, + isolevel, + edge_vertices_to_index, + vertex_coords_to_index, + num_existing_verts, + ) + + # Create faces + face_triangles = FACE_TABLE[cube_index] + for i in range(0, len(face_triangles), 3): + tri1 = edge_index_to_point_index[face_triangles[i]] + tri2 = edge_index_to_point_index[face_triangles[i + 1]] + tri3 = edge_index_to_point_index[face_triangles[i + 2]] + if tri1 != tri2 and tri2 != tri3 and tri1 != tri3: + faces.append([tri1, tri2, tri3]) + + verts += new_verts + return verts, faces + + +def _get_edge_indices(edges: int) -> List[int]: + """ + Finds which edge numbers are intersected given the bit representation + detailed in marching_cubes_data.EDGE_TABLE. + + Args: + edges: an integer corresponding to the value at cube_index + from the EDGE_TABLE in marching_cubes_data.py + + Returns: + edge_indices: A list of edge indices + """ + if edges == 0: + return [] + + edge_indices = [] + for i in range(12): + if edges & (2 ** i): + edge_indices.append(i) + return edge_indices + + +def _calculate_interp_vertices( + edge_indices: List[int], + volume_data: torch.Tensor, + cube: Cube, + isolevel: float, + edge_vertices_to_index: Dict[Tuple[Tuple, Tuple], int], + vertex_coords_to_index: Dict[Tuple[float, float, float], int], + num_existing_verts: int, +) -> Tuple[List, Dict[int, int]]: + """ + Finds the interpolated vertices for the intersected edges, either referencing + previous calculations or newly calculating and storing the new interpolated + points. + + Args: + edge_indices: the numbers of the edges which are intersected. See the + Cube class for more detail on the edge numbering convention. + volume_data: a Tensor of size (D, H, W) corresponding to + a 3D scalar field + cube: a Cube indicating the cube being examined for edges that intersect + the volume + isolevel: the isosurface value to use as the threshold to determine + whether points are within a volume. + edge_vertices_to_index: A dictionary which maps an edge's two coordinates + to the index of its interpolated point, if that interpolated point + has already been used by a previous point + vertex_coords_to_index: A dictionary mapping a point (x, y, z) to the corresponding + index of that vertex, if that point has already been marked as a vertex. + num_existing_verts: the number of vertices that have been found in previous + calls to polygonise for the given volume_data in the above function, marching_cubes. + This is equal to the 1 + the maximum value in edge_vertices_to_index. + Returns: + interp_points: a list of new interpolated points + edge_index_to_point_index: a dictionary mapping an edge number to the index in the + marching cubes' vertices list of the interpolated point on that edge. To be precise, + it refers to the index within the vertices list after interp_points + has been appended to the verts list constructed in the marching_cubes_naive + function. + """ + interp_points = [] + edge_index_to_point_index = {} + for edge_index in edge_indices: + v1, v2 = EDGE_TO_VERTICES[edge_index] + point1, point2 = cube.vertices[v1], cube.vertices[v2] + p_tuple1, p_tuple2 = tuple(point1.tolist()), tuple(point2.tolist()) + if (p_tuple1, p_tuple2) in edge_vertices_to_index: + edge_index_to_point_index[edge_index] = edge_vertices_to_index[ + (p_tuple1, p_tuple2) + ] + else: + val1, val2 = _get_value(point1, volume_data), _get_value( + point2, volume_data + ) + + point = None + if abs(isolevel - val1) < EPS: + point = point1 + + if abs(isolevel - val2) < EPS: + point = point2 + + if abs(val1 - val2) < EPS: + point = point1 + + if point is None: + mu = (isolevel - val1) / (val2 - val1) + x1, y1, z1 = point1 + x2, y2, z2 = point2 + x = x1 + mu * (x2 - x1) + y = y1 + mu * (y2 - y1) + z = z1 + mu * (z2 - z1) + else: + x, y, z = point + + x, y, z = x.item(), y.item(), z.item() # for dictionary keys + + vert_index = None + if (x, y, z) in vertex_coords_to_index: + vert_index = vertex_coords_to_index[(x, y, z)] + else: + vert_index = num_existing_verts + len(interp_points) + interp_points.append([x, y, z]) + vertex_coords_to_index[(x, y, z)] = vert_index + + edge_vertices_to_index[(p_tuple1, p_tuple2)] = vert_index + edge_index_to_point_index[edge_index] = vert_index + + return interp_points, edge_index_to_point_index + + +def _get_value(point: Tuple[int, int, int], volume_data: torch.Tensor) -> float: + """ + Gets the value at a given coordinate point in the scalar field. + + Args: + point: data of shape (3) corresponding to an xyz coordinate. + volume_data: a Tensor of size (D, H, W) corresponding to + a 3D scalar field + Returns: + data: scalar value in the volume at the given point + """ + x, y, z = point + return volume_data[z][y][x] diff --git a/pytorch3d/ops/marching_cubes_data.py b/pytorch3d/ops/marching_cubes_data.py new file mode 100644 index 00000000..8ad92825 --- /dev/null +++ b/pytorch3d/ops/marching_cubes_data.py @@ -0,0 +1,545 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. + +# A length 256 list which maps a cubeindex to a number +# with the intersected edges' bits set to 1. +# Each cubeindex corresponds to a given cube configuration, where +# it is composed of a bitstring where the 0th bit is flipped if vertex 0 +# is below the isosurface (i.e. 0x01), for each of the 8 vertices. +EDGE_TABLE = [ + 0x0, + 0x109, + 0x203, + 0x30A, + 0x406, + 0x50F, + 0x605, + 0x70C, + 0x80C, + 0x905, + 0xA0F, + 0xB06, + 0xC0A, + 0xD03, + 0xE09, + 0xF00, + 0x190, + 0x99, + 0x393, + 0x29A, + 0x596, + 0x49F, + 0x795, + 0x69C, + 0x99C, + 0x895, + 0xB9F, + 0xA96, + 0xD9A, + 0xC93, + 0xF99, + 0xE90, + 0x230, + 0x339, + 0x33, + 0x13A, + 0x636, + 0x73F, + 0x435, + 0x53C, + 0xA3C, + 0xB35, + 0x83F, + 0x936, + 0xE3A, + 0xF33, + 0xC39, + 0xD30, + 0x3A0, + 0x2A9, + 0x1A3, + 0xAA, + 0x7A6, + 0x6AF, + 0x5A5, + 0x4AC, + 0xBAC, + 0xAA5, + 0x9AF, + 0x8A6, + 0xFAA, + 0xEA3, + 0xDA9, + 0xCA0, + 0x460, + 0x569, + 0x663, + 0x76A, + 0x66, + 0x16F, + 0x265, + 0x36C, + 0xC6C, + 0xD65, + 0xE6F, + 0xF66, + 0x86A, + 0x963, + 0xA69, + 0xB60, + 0x5F0, + 0x4F9, + 0x7F3, + 0x6FA, + 0x1F6, + 0xFF, + 0x3F5, + 0x2FC, + 0xDFC, + 0xCF5, + 0xFFF, + 0xEF6, + 0x9FA, + 0x8F3, + 0xBF9, + 0xAF0, + 0x650, + 0x759, + 0x453, + 0x55A, + 0x256, + 0x35F, + 0x55, + 0x15C, + 0xE5C, + 0xF55, + 0xC5F, + 0xD56, + 0xA5A, + 0xB53, + 0x859, + 0x950, + 0x7C0, + 0x6C9, + 0x5C3, + 0x4CA, + 0x3C6, + 0x2CF, + 0x1C5, + 0xCC, + 0xFCC, + 0xEC5, + 0xDCF, + 0xCC6, + 0xBCA, + 0xAC3, + 0x9C9, + 0x8C0, + 0x8C0, + 0x9C9, + 0xAC3, + 0xBCA, + 0xCC6, + 0xDCF, + 0xEC5, + 0xFCC, + 0xCC, + 0x1C5, + 0x2CF, + 0x3C6, + 0x4CA, + 0x5C3, + 0x6C9, + 0x7C0, + 0x950, + 0x859, + 0xB53, + 0xA5A, + 0xD56, + 0xC5F, + 0xF55, + 0xE5C, + 0x15C, + 0x55, + 0x35F, + 0x256, + 0x55A, + 0x453, + 0x759, + 0x650, + 0xAF0, + 0xBF9, + 0x8F3, + 0x9FA, + 0xEF6, + 0xFFF, + 0xCF5, + 0xDFC, + 0x2FC, + 0x3F5, + 0xFF, + 0x1F6, + 0x6FA, + 0x7F3, + 0x4F9, + 0x5F0, + 0xB60, + 0xA69, + 0x963, + 0x86A, + 0xF66, + 0xE6F, + 0xD65, + 0xC6C, + 0x36C, + 0x265, + 0x16F, + 0x66, + 0x76A, + 0x663, + 0x569, + 0x460, + 0xCA0, + 0xDA9, + 0xEA3, + 0xFAA, + 0x8A6, + 0x9AF, + 0xAA5, + 0xBAC, + 0x4AC, + 0x5A5, + 0x6AF, + 0x7A6, + 0xAA, + 0x1A3, + 0x2A9, + 0x3A0, + 0xD30, + 0xC39, + 0xF33, + 0xE3A, + 0x936, + 0x83F, + 0xB35, + 0xA3C, + 0x53C, + 0x435, + 0x73F, + 0x636, + 0x13A, + 0x33, + 0x339, + 0x230, + 0xE90, + 0xF99, + 0xC93, + 0xD9A, + 0xA96, + 0xB9F, + 0x895, + 0x99C, + 0x69C, + 0x795, + 0x49F, + 0x596, + 0x29A, + 0x393, + 0x99, + 0x190, + 0xF00, + 0xE09, + 0xD03, + 0xC0A, + 0xB06, + 0xA0F, + 0x905, + 0x80C, + 0x70C, + 0x605, + 0x50F, + 0x406, + 0x30A, + 0x203, + 0x109, + 0x0, +] + +# Maps each edge (by index) to the corresponding cube vertices +EDGE_TO_VERTICES = [ + [0, 1], + [1, 2], + [3, 2], + [0, 3], + [4, 5], + [5, 6], + [7, 6], + [4, 7], + [0, 4], + [1, 5], + [2, 6], + [3, 7], +] + +# A list of lists mapping a cube_index (a given configuration) +# to a list of faces corresponding to that configuration. Each face is represented +# by 3 consecutive numbers. A configuration will at most have 5 faces. +# +# Table taken from http://paulbourke.net/geometry/polygonise/ +FACE_TABLE = [ + [], + [0, 8, 3], + [0, 1, 9], + [1, 8, 3, 9, 8, 1], + [1, 2, 10], + [0, 8, 3, 1, 2, 10], + [9, 2, 10, 0, 2, 9], + [2, 8, 3, 2, 10, 8, 10, 9, 8], + [3, 11, 2], + [0, 11, 2, 8, 11, 0], + [1, 9, 0, 2, 3, 11], + [1, 11, 2, 1, 9, 11, 9, 8, 11], + [3, 10, 1, 11, 10, 3], + [0, 10, 1, 0, 8, 10, 8, 11, 10], + [3, 9, 0, 3, 11, 9, 11, 10, 9], + [9, 8, 10, 10, 8, 11], + [4, 7, 8], + [4, 3, 0, 7, 3, 4], + [0, 1, 9, 8, 4, 7], + [4, 1, 9, 4, 7, 1, 7, 3, 1], + [1, 2, 10, 8, 4, 7], + [3, 4, 7, 3, 0, 4, 1, 2, 10], + [9, 2, 10, 9, 0, 2, 8, 4, 7], + [2, 10, 9, 2, 9, 7, 2, 7, 3, 7, 9, 4], + [8, 4, 7, 3, 11, 2], + [11, 4, 7, 11, 2, 4, 2, 0, 4], + [9, 0, 1, 8, 4, 7, 2, 3, 11], + [4, 7, 11, 9, 4, 11, 9, 11, 2, 9, 2, 1], + [3, 10, 1, 3, 11, 10, 7, 8, 4], + [1, 11, 10, 1, 4, 11, 1, 0, 4, 7, 11, 4], + [4, 7, 8, 9, 0, 11, 9, 11, 10, 11, 0, 3], + [4, 7, 11, 4, 11, 9, 9, 11, 10], + [9, 5, 4], + [9, 5, 4, 0, 8, 3], + [0, 5, 4, 1, 5, 0], + [8, 5, 4, 8, 3, 5, 3, 1, 5], + [1, 2, 10, 9, 5, 4], + [3, 0, 8, 1, 2, 10, 4, 9, 5], + [5, 2, 10, 5, 4, 2, 4, 0, 2], + [2, 10, 5, 3, 2, 5, 3, 5, 4, 3, 4, 8], + [9, 5, 4, 2, 3, 11], + [0, 11, 2, 0, 8, 11, 4, 9, 5], + [0, 5, 4, 0, 1, 5, 2, 3, 11], + [2, 1, 5, 2, 5, 8, 2, 8, 11, 4, 8, 5], + [10, 3, 11, 10, 1, 3, 9, 5, 4], + [4, 9, 5, 0, 8, 1, 8, 10, 1, 8, 11, 10], + [5, 4, 0, 5, 0, 11, 5, 11, 10, 11, 0, 3], + [5, 4, 8, 5, 8, 10, 10, 8, 11], + [9, 7, 8, 5, 7, 9], + [9, 3, 0, 9, 5, 3, 5, 7, 3], + [0, 7, 8, 0, 1, 7, 1, 5, 7], + [1, 5, 3, 3, 5, 7], + [9, 7, 8, 9, 5, 7, 10, 1, 2], + [10, 1, 2, 9, 5, 0, 5, 3, 0, 5, 7, 3], + [8, 0, 2, 8, 2, 5, 8, 5, 7, 10, 5, 2], + [2, 10, 5, 2, 5, 3, 3, 5, 7], + [7, 9, 5, 7, 8, 9, 3, 11, 2], + [9, 5, 7, 9, 7, 2, 9, 2, 0, 2, 7, 11], + [2, 3, 11, 0, 1, 8, 1, 7, 8, 1, 5, 7], + [11, 2, 1, 11, 1, 7, 7, 1, 5], + [9, 5, 8, 8, 5, 7, 10, 1, 3, 10, 3, 11], + [5, 7, 0, 5, 0, 9, 7, 11, 0, 1, 0, 10, 11, 10, 0], + [11, 10, 0, 11, 0, 3, 10, 5, 0, 8, 0, 7, 5, 7, 0], + [11, 10, 5, 7, 11, 5], + [10, 6, 5], + [0, 8, 3, 5, 10, 6], + [9, 0, 1, 5, 10, 6], + [1, 8, 3, 1, 9, 8, 5, 10, 6], + [1, 6, 5, 2, 6, 1], + [1, 6, 5, 1, 2, 6, 3, 0, 8], + [9, 6, 5, 9, 0, 6, 0, 2, 6], + [5, 9, 8, 5, 8, 2, 5, 2, 6, 3, 2, 8], + [2, 3, 11, 10, 6, 5], + [11, 0, 8, 11, 2, 0, 10, 6, 5], + [0, 1, 9, 2, 3, 11, 5, 10, 6], + [5, 10, 6, 1, 9, 2, 9, 11, 2, 9, 8, 11], + [6, 3, 11, 6, 5, 3, 5, 1, 3], + [0, 8, 11, 0, 11, 5, 0, 5, 1, 5, 11, 6], + [3, 11, 6, 0, 3, 6, 0, 6, 5, 0, 5, 9], + [6, 5, 9, 6, 9, 11, 11, 9, 8], + [5, 10, 6, 4, 7, 8], + [4, 3, 0, 4, 7, 3, 6, 5, 10], + [1, 9, 0, 5, 10, 6, 8, 4, 7], + [10, 6, 5, 1, 9, 7, 1, 7, 3, 7, 9, 4], + [6, 1, 2, 6, 5, 1, 4, 7, 8], + [1, 2, 5, 5, 2, 6, 3, 0, 4, 3, 4, 7], + [8, 4, 7, 9, 0, 5, 0, 6, 5, 0, 2, 6], + [7, 3, 9, 7, 9, 4, 3, 2, 9, 5, 9, 6, 2, 6, 9], + [3, 11, 2, 7, 8, 4, 10, 6, 5], + [5, 10, 6, 4, 7, 2, 4, 2, 0, 2, 7, 11], + [0, 1, 9, 4, 7, 8, 2, 3, 11, 5, 10, 6], + [9, 2, 1, 9, 11, 2, 9, 4, 11, 7, 11, 4, 5, 10, 6], + [8, 4, 7, 3, 11, 5, 3, 5, 1, 5, 11, 6], + [5, 1, 11, 5, 11, 6, 1, 0, 11, 7, 11, 4, 0, 4, 11], + [0, 5, 9, 0, 6, 5, 0, 3, 6, 11, 6, 3, 8, 4, 7], + [6, 5, 9, 6, 9, 11, 4, 7, 9, 7, 11, 9], + [10, 4, 9, 6, 4, 10], + [4, 10, 6, 4, 9, 10, 0, 8, 3], + [10, 0, 1, 10, 6, 0, 6, 4, 0], + [8, 3, 1, 8, 1, 6, 8, 6, 4, 6, 1, 10], + [1, 4, 9, 1, 2, 4, 2, 6, 4], + [3, 0, 8, 1, 2, 9, 2, 4, 9, 2, 6, 4], + [0, 2, 4, 4, 2, 6], + [8, 3, 2, 8, 2, 4, 4, 2, 6], + [10, 4, 9, 10, 6, 4, 11, 2, 3], + [0, 8, 2, 2, 8, 11, 4, 9, 10, 4, 10, 6], + [3, 11, 2, 0, 1, 6, 0, 6, 4, 6, 1, 10], + [6, 4, 1, 6, 1, 10, 4, 8, 1, 2, 1, 11, 8, 11, 1], + [9, 6, 4, 9, 3, 6, 9, 1, 3, 11, 6, 3], + [8, 11, 1, 8, 1, 0, 11, 6, 1, 9, 1, 4, 6, 4, 1], + [3, 11, 6, 3, 6, 0, 0, 6, 4], + [6, 4, 8, 11, 6, 8], + [7, 10, 6, 7, 8, 10, 8, 9, 10], + [0, 7, 3, 0, 10, 7, 0, 9, 10, 6, 7, 10], + [10, 6, 7, 1, 10, 7, 1, 7, 8, 1, 8, 0], + [10, 6, 7, 10, 7, 1, 1, 7, 3], + [1, 2, 6, 1, 6, 8, 1, 8, 9, 8, 6, 7], + [2, 6, 9, 2, 9, 1, 6, 7, 9, 0, 9, 3, 7, 3, 9], + [7, 8, 0, 7, 0, 6, 6, 0, 2], + [7, 3, 2, 6, 7, 2], + [2, 3, 11, 10, 6, 8, 10, 8, 9, 8, 6, 7], + [2, 0, 7, 2, 7, 11, 0, 9, 7, 6, 7, 10, 9, 10, 7], + [1, 8, 0, 1, 7, 8, 1, 10, 7, 6, 7, 10, 2, 3, 11], + [11, 2, 1, 11, 1, 7, 10, 6, 1, 6, 7, 1], + [8, 9, 6, 8, 6, 7, 9, 1, 6, 11, 6, 3, 1, 3, 6], + [0, 9, 1, 11, 6, 7], + [7, 8, 0, 7, 0, 6, 3, 11, 0, 11, 6, 0], + [7, 11, 6], + [7, 6, 11], + [3, 0, 8, 11, 7, 6], + [0, 1, 9, 11, 7, 6], + [8, 1, 9, 8, 3, 1, 11, 7, 6], + [10, 1, 2, 6, 11, 7], + [1, 2, 10, 3, 0, 8, 6, 11, 7], + [2, 9, 0, 2, 10, 9, 6, 11, 7], + [6, 11, 7, 2, 10, 3, 10, 8, 3, 10, 9, 8], + [7, 2, 3, 6, 2, 7], + [7, 0, 8, 7, 6, 0, 6, 2, 0], + [2, 7, 6, 2, 3, 7, 0, 1, 9], + [1, 6, 2, 1, 8, 6, 1, 9, 8, 8, 7, 6], + [10, 7, 6, 10, 1, 7, 1, 3, 7], + [10, 7, 6, 1, 7, 10, 1, 8, 7, 1, 0, 8], + [0, 3, 7, 0, 7, 10, 0, 10, 9, 6, 10, 7], + [7, 6, 10, 7, 10, 8, 8, 10, 9], + [6, 8, 4, 11, 8, 6], + [3, 6, 11, 3, 0, 6, 0, 4, 6], + [8, 6, 11, 8, 4, 6, 9, 0, 1], + [9, 4, 6, 9, 6, 3, 9, 3, 1, 11, 3, 6], + [6, 8, 4, 6, 11, 8, 2, 10, 1], + [1, 2, 10, 3, 0, 11, 0, 6, 11, 0, 4, 6], + [4, 11, 8, 4, 6, 11, 0, 2, 9, 2, 10, 9], + [10, 9, 3, 10, 3, 2, 9, 4, 3, 11, 3, 6, 4, 6, 3], + [8, 2, 3, 8, 4, 2, 4, 6, 2], + [0, 4, 2, 4, 6, 2], + [1, 9, 0, 2, 3, 4, 2, 4, 6, 4, 3, 8], + [1, 9, 4, 1, 4, 2, 2, 4, 6], + [8, 1, 3, 8, 6, 1, 8, 4, 6, 6, 10, 1], + [10, 1, 0, 10, 0, 6, 6, 0, 4], + [4, 6, 3, 4, 3, 8, 6, 10, 3, 0, 3, 9, 10, 9, 3], + [10, 9, 4, 6, 10, 4], + [4, 9, 5, 7, 6, 11], + [0, 8, 3, 4, 9, 5, 11, 7, 6], + [5, 0, 1, 5, 4, 0, 7, 6, 11], + [11, 7, 6, 8, 3, 4, 3, 5, 4, 3, 1, 5], + [9, 5, 4, 10, 1, 2, 7, 6, 11], + [6, 11, 7, 1, 2, 10, 0, 8, 3, 4, 9, 5], + [7, 6, 11, 5, 4, 10, 4, 2, 10, 4, 0, 2], + [3, 4, 8, 3, 5, 4, 3, 2, 5, 10, 5, 2, 11, 7, 6], + [7, 2, 3, 7, 6, 2, 5, 4, 9], + [9, 5, 4, 0, 8, 6, 0, 6, 2, 6, 8, 7], + [3, 6, 2, 3, 7, 6, 1, 5, 0, 5, 4, 0], + [6, 2, 8, 6, 8, 7, 2, 1, 8, 4, 8, 5, 1, 5, 8], + [9, 5, 4, 10, 1, 6, 1, 7, 6, 1, 3, 7], + [1, 6, 10, 1, 7, 6, 1, 0, 7, 8, 7, 0, 9, 5, 4], + [4, 0, 10, 4, 10, 5, 0, 3, 10, 6, 10, 7, 3, 7, 10], + [7, 6, 10, 7, 10, 8, 5, 4, 10, 4, 8, 10], + [6, 9, 5, 6, 11, 9, 11, 8, 9], + [3, 6, 11, 0, 6, 3, 0, 5, 6, 0, 9, 5], + [0, 11, 8, 0, 5, 11, 0, 1, 5, 5, 6, 11], + [6, 11, 3, 6, 3, 5, 5, 3, 1], + [1, 2, 10, 9, 5, 11, 9, 11, 8, 11, 5, 6], + [0, 11, 3, 0, 6, 11, 0, 9, 6, 5, 6, 9, 1, 2, 10], + [11, 8, 5, 11, 5, 6, 8, 0, 5, 10, 5, 2, 0, 2, 5], + [6, 11, 3, 6, 3, 5, 2, 10, 3, 10, 5, 3], + [5, 8, 9, 5, 2, 8, 5, 6, 2, 3, 8, 2], + [9, 5, 6, 9, 6, 0, 0, 6, 2], + [1, 5, 8, 1, 8, 0, 5, 6, 8, 3, 8, 2, 6, 2, 8], + [1, 5, 6, 2, 1, 6], + [1, 3, 6, 1, 6, 10, 3, 8, 6, 5, 6, 9, 8, 9, 6], + [10, 1, 0, 10, 0, 6, 9, 5, 0, 5, 6, 0], + [0, 3, 8, 5, 6, 10], + [10, 5, 6], + [11, 5, 10, 7, 5, 11], + [11, 5, 10, 11, 7, 5, 8, 3, 0], + [5, 11, 7, 5, 10, 11, 1, 9, 0], + [10, 7, 5, 10, 11, 7, 9, 8, 1, 8, 3, 1], + [11, 1, 2, 11, 7, 1, 7, 5, 1], + [0, 8, 3, 1, 2, 7, 1, 7, 5, 7, 2, 11], + [9, 7, 5, 9, 2, 7, 9, 0, 2, 2, 11, 7], + [7, 5, 2, 7, 2, 11, 5, 9, 2, 3, 2, 8, 9, 8, 2], + [2, 5, 10, 2, 3, 5, 3, 7, 5], + [8, 2, 0, 8, 5, 2, 8, 7, 5, 10, 2, 5], + [9, 0, 1, 5, 10, 3, 5, 3, 7, 3, 10, 2], + [9, 8, 2, 9, 2, 1, 8, 7, 2, 10, 2, 5, 7, 5, 2], + [1, 3, 5, 3, 7, 5], + [0, 8, 7, 0, 7, 1, 1, 7, 5], + [9, 0, 3, 9, 3, 5, 5, 3, 7], + [9, 8, 7, 5, 9, 7], + [5, 8, 4, 5, 10, 8, 10, 11, 8], + [5, 0, 4, 5, 11, 0, 5, 10, 11, 11, 3, 0], + [0, 1, 9, 8, 4, 10, 8, 10, 11, 10, 4, 5], + [10, 11, 4, 10, 4, 5, 11, 3, 4, 9, 4, 1, 3, 1, 4], + [2, 5, 1, 2, 8, 5, 2, 11, 8, 4, 5, 8], + [0, 4, 11, 0, 11, 3, 4, 5, 11, 2, 11, 1, 5, 1, 11], + [0, 2, 5, 0, 5, 9, 2, 11, 5, 4, 5, 8, 11, 8, 5], + [9, 4, 5, 2, 11, 3], + [2, 5, 10, 3, 5, 2, 3, 4, 5, 3, 8, 4], + [5, 10, 2, 5, 2, 4, 4, 2, 0], + [3, 10, 2, 3, 5, 10, 3, 8, 5, 4, 5, 8, 0, 1, 9], + [5, 10, 2, 5, 2, 4, 1, 9, 2, 9, 4, 2], + [8, 4, 5, 8, 5, 3, 3, 5, 1], + [0, 4, 5, 1, 0, 5], + [8, 4, 5, 8, 5, 3, 9, 0, 5, 0, 3, 5], + [9, 4, 5], + [4, 11, 7, 4, 9, 11, 9, 10, 11], + [0, 8, 3, 4, 9, 7, 9, 11, 7, 9, 10, 11], + [1, 10, 11, 1, 11, 4, 1, 4, 0, 7, 4, 11], + [3, 1, 4, 3, 4, 8, 1, 10, 4, 7, 4, 11, 10, 11, 4], + [4, 11, 7, 9, 11, 4, 9, 2, 11, 9, 1, 2], + [9, 7, 4, 9, 11, 7, 9, 1, 11, 2, 11, 1, 0, 8, 3], + [11, 7, 4, 11, 4, 2, 2, 4, 0], + [11, 7, 4, 11, 4, 2, 8, 3, 4, 3, 2, 4], + [2, 9, 10, 2, 7, 9, 2, 3, 7, 7, 4, 9], + [9, 10, 7, 9, 7, 4, 10, 2, 7, 8, 7, 0, 2, 0, 7], + [3, 7, 10, 3, 10, 2, 7, 4, 10, 1, 10, 0, 4, 0, 10], + [1, 10, 2, 8, 7, 4], + [4, 9, 1, 4, 1, 7, 7, 1, 3], + [4, 9, 1, 4, 1, 7, 0, 8, 1, 8, 7, 1], + [4, 0, 3, 7, 4, 3], + [4, 8, 7], + [9, 10, 8, 10, 11, 8], + [3, 0, 9, 3, 9, 11, 11, 9, 10], + [0, 1, 10, 0, 10, 8, 8, 10, 11], + [3, 1, 10, 11, 3, 10], + [1, 2, 11, 1, 11, 9, 9, 11, 8], + [3, 0, 9, 3, 9, 11, 1, 2, 9, 2, 11, 9], + [0, 2, 11, 8, 0, 11], + [3, 2, 11], + [2, 3, 8, 2, 8, 10, 10, 8, 9], + [9, 10, 2, 0, 9, 2], + [2, 3, 8, 2, 8, 10, 0, 1, 8, 1, 10, 8], + [1, 10, 2], + [1, 3, 8, 9, 1, 8], + [0, 9, 1], + [0, 3, 8], + [], +] diff --git a/pytorch3d/transforms/transform3d.py b/pytorch3d/transforms/transform3d.py index 695af174..0d2e330c 100644 --- a/pytorch3d/transforms/transform3d.py +++ b/pytorch3d/transforms/transform3d.py @@ -414,7 +414,7 @@ class Transform3d: class Translate(Transform3d): - def __init__(self, x, y=None, z=None, dtype=torch.float32, device: str = "cpu"): + def __init__(self, x, y=None, z=None, dtype=torch.float32, device="cpu"): """ Create a new Transform3d representing 3D translations. @@ -448,7 +448,7 @@ class Translate(Transform3d): class Scale(Transform3d): - def __init__(self, x, y=None, z=None, dtype=torch.float32, device: str = "cpu"): + def __init__(self, x, y=None, z=None, dtype=torch.float32, device="cpu"): """ A Transform3d representing a scaling operation, with different scale factors along each coordinate axis. @@ -489,7 +489,7 @@ class Scale(Transform3d): class Rotate(Transform3d): def __init__( - self, R, dtype=torch.float32, device: str = "cpu", orthogonal_tol: float = 1e-5 + self, R, dtype=torch.float32, device="cpu", orthogonal_tol: float = 1e-5 ): """ Create a new Transform3d representing 3D rotation using a rotation @@ -528,7 +528,7 @@ class RotateAxisAngle(Rotate): axis: str = "X", degrees: bool = True, dtype=torch.float64, - device: str = "cpu", + device="cpu", ): """ Create a new Transform3d representing 3D rotation about an axis @@ -635,7 +635,7 @@ def _handle_input(x, y, z, dtype, device, name: str, allow_singleton: bool = Fal return xyz -def _handle_angle_input(x, dtype, device: str, name: str): +def _handle_angle_input(x, dtype, device, name: str): """ Helper function for building a rotation function using angles. The output is always of shape (N,). diff --git a/tests/bm_marching_cubes.py b/tests/bm_marching_cubes.py new file mode 100644 index 00000000..288b8345 --- /dev/null +++ b/tests/bm_marching_cubes.py @@ -0,0 +1,25 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. + +from fvcore.common.benchmark import benchmark +from test_marching_cubes import TestMarchingCubes + + +def bm_marching_cubes() -> None: + kwargs_list = [ + {"batch_size": 1, "V": 5}, + {"batch_size": 1, "V": 10}, + {"batch_size": 1, "V": 20}, + {"batch_size": 1, "V": 40}, + {"batch_size": 5, "V": 5}, + {"batch_size": 20, "V": 20}, + ] + benchmark( + TestMarchingCubes.marching_cubes_with_init, + "MARCHING_CUBES", + kwargs_list, + warmup_iters=1, + ) + + +if __name__ == "__main__": + bm_marching_cubes() diff --git a/tests/data/test_marching_cubes_data/double_ellipsoid.pickle b/tests/data/test_marching_cubes_data/double_ellipsoid.pickle new file mode 100644 index 0000000000000000000000000000000000000000..3642a73d3955bbcf1e8e4ccab9b1fa7171a30932 GIT binary patch literal 233319 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 sm5GFTIrCndN!8{EjTo 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 literal 0 HcmV?d00001 diff --git a/tests/test_marching_cubes.py b/tests/test_marching_cubes.py new file mode 100644 index 00000000..182b01b4 --- /dev/null +++ b/tests/test_marching_cubes.py @@ -0,0 +1,771 @@ +# Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. +import os +import pickle +import unittest +from pathlib import Path + +import torch +from common_testing import TestCaseMixin +from pytorch3d.ops.marching_cubes import marching_cubes_naive + + +USE_SCIKIT = False + + +def convert_to_local(verts, volume_dim): + return (2 * verts) / (volume_dim - 1) - 1 + + +class TestCubeConfiguration(TestCaseMixin, unittest.TestCase): + + # Test single cubes. Each case corresponds to the corresponding + # cube vertex configuration in each case here (0-indexed): + # https://en.wikipedia.org/wiki/Marching_cubes#/media/File:MarchingCubes.svg + + def test_empty_volume(self): # case 0 + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor([]) + expected_faces = torch.tensor([], dtype=torch.int64) + self.assertClose(verts, expected_verts) + self.assertClose(faces, expected_faces) + + def test_case1(self): # case 1 + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5, 0, 0], + [0, 0, 0.5], + [0, 0.5, 0], + ] + ) + + expected_faces = torch.tensor([[1, 2, 0]]) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case2(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0:2, 0, 0] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [1.0000, 0.0000, 0.5000], + [0.0000, 0.0000, 0.5000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + expected_faces = torch.tensor([[1, 2, 0], [3, 2, 1]]) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case3(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 1, 1, 0] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [1.0000, 1.0000, 0.5000], + [0.5000, 1.0000, 0.0000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + expected_faces = torch.tensor([[0, 1, 5], [4, 3, 2]]) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case4(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 1, 0, 0] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 0, 0, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [0.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + ] + ) + expected_faces = torch.tensor([[0, 2, 1], [0, 4, 2], [4, 3, 2]]) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case5(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0:2, 0, 0:2] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + + expected_faces = torch.tensor([[1, 0, 2], [2, 0, 3]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case6(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 1, 0, 0] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 0, 0, 1] = 0 + volume_data[0, 0, 1, 0] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [0.5000, 1.0000, 0.0000], + [0.0000, 1.0000, 0.5000], + [0.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + expected_faces = torch.tensor([[2, 7, 3], [0, 6, 1], [6, 4, 1], [6, 5, 4]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case7(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 1, 1, 0] = 0 + volume_data[0, 0, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 1.0000], + [1.0000, 0.0000, 0.5000], + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [0.5000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.5000], + [0.5000, 1.0000, 0.0000], + [0.0000, 1.0000, 0.5000], + [0.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + + expected_faces = torch.tensor([[0, 1, 9], [4, 7, 8], [2, 3, 11], [5, 10, 6]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case8(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 0, 0, 1] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 0, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [1.0000, 0.0000, 0.5000], + [0.5000, 0.0000, 0.0000], + [0.5000, 1.0000, 1.0000], + [0.0000, 1.0000, 0.5000], + [1.0000, 0.5000, 1.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + expected_faces = torch.tensor([[2, 3, 5], [4, 2, 5], [4, 5, 1], [4, 1, 0]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case9(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 1, 0, 0] = 0 + volume_data[0, 0, 0, 1] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 0, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [0.5000, 1.0000, 1.0000], + [0.0000, 1.0000, 0.5000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + ] + ) + expected_faces = torch.tensor([[0, 5, 4], [0, 4, 3], [0, 3, 1], [3, 4, 2]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case10(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 1, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [0.5000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.5000], + [1.0000, 0.5000, 1.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + + expected_faces = torch.tensor([[4, 3, 2], [0, 1, 5]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case11(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 1, 0, 0] = 0 + volume_data[0, 1, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [1.0000, 0.0000, 0.5000], + [0.0000, 0.0000, 0.5000], + [0.5000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.5000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + + expected_faces = torch.tensor([[5, 1, 6], [5, 0, 1], [4, 3, 2]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case12(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 1, 0, 0] = 0 + volume_data[0, 0, 1, 0] = 0 + volume_data[0, 1, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [1.0000, 0.0000, 0.5000], + [0.5000, 0.0000, 0.0000], + [0.5000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.5000], + [0.5000, 1.0000, 0.0000], + [0.0000, 1.0000, 0.5000], + [1.0000, 0.5000, 1.0000], + [1.0000, 0.5000, 0.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + + expected_faces = torch.tensor([[6, 3, 2], [7, 0, 1], [5, 4, 8]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case13(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 0, 1, 0] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 1, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5000, 0.0000, 1.0000], + [1.0000, 0.0000, 0.5000], + [0.5000, 0.0000, 0.0000], + [0.0000, 0.0000, 0.5000], + [0.5000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.5000], + [0.5000, 1.0000, 0.0000], + [0.0000, 1.0000, 0.5000], + ] + ) + + expected_faces = torch.tensor([[3, 6, 2], [3, 7, 6], [1, 5, 0], [5, 4, 0]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + def test_case14(self): + volume_data = torch.ones(1, 2, 2, 2) # (B, W, H, D) + volume_data[0, 0, 0, 0] = 0 + volume_data[0, 0, 0, 1] = 0 + volume_data[0, 1, 0, 1] = 0 + volume_data[0, 1, 1, 1] = 0 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [1.0000, 0.0000, 0.5000], + [0.5000, 0.0000, 0.0000], + [0.5000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.5000], + [0.0000, 0.5000, 1.0000], + [0.0000, 0.5000, 0.0000], + ] + ) + + expected_faces = torch.tensor([[1, 0, 3], [1, 3, 4], [1, 4, 5], [2, 4, 3]]) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 2) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + +class TestMarchingCubes(TestCaseMixin, unittest.TestCase): + def test_single_point(self): + volume_data = torch.zeros(1, 3, 3, 3) # (B, W, H, D) + volume_data[0, 1, 1, 1] = 1 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.5, 1, 1], + [1, 1, 0.5], + [1, 0.5, 1], + [1, 1, 1.5], + [1, 1.5, 1], + [1.5, 1, 1], + ] + ) + expected_faces = torch.tensor( + [ + [2, 0, 1], + [2, 3, 0], + [0, 4, 1], + [3, 4, 0], + [5, 2, 1], + [3, 2, 5], + [5, 1, 4], + [3, 5, 4], + ] + ) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 3) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + self.assertTrue(verts[0].ge(-1).all() and verts[0].le(1).all()) + + def test_cube(self): + volume_data = torch.zeros(1, 5, 5, 5) # (B, W, H, D) + volume_data[0, 1, 1, 1] = 1 + volume_data[0, 1, 1, 2] = 1 + volume_data[0, 2, 1, 1] = 1 + volume_data[0, 2, 1, 2] = 1 + volume_data[0, 1, 2, 1] = 1 + volume_data[0, 1, 2, 2] = 1 + volume_data[0, 2, 2, 1] = 1 + volume_data[0, 2, 2, 2] = 1 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, 0.9, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [0.9000, 1.0000, 1.0000], + [1.0000, 1.0000, 0.9000], + [1.0000, 0.9000, 1.0000], + [0.9000, 1.0000, 2.0000], + [1.0000, 0.9000, 2.0000], + [1.0000, 1.0000, 2.1000], + [0.9000, 2.0000, 1.0000], + [1.0000, 2.0000, 0.9000], + [0.9000, 2.0000, 2.0000], + [1.0000, 2.0000, 2.1000], + [1.0000, 2.1000, 1.0000], + [1.0000, 2.1000, 2.0000], + [2.0000, 1.0000, 0.9000], + [2.0000, 0.9000, 1.0000], + [2.0000, 0.9000, 2.0000], + [2.0000, 1.0000, 2.1000], + [2.0000, 2.0000, 0.9000], + [2.0000, 2.0000, 2.1000], + [2.0000, 2.1000, 1.0000], + [2.0000, 2.1000, 2.0000], + [2.1000, 1.0000, 1.0000], + [2.1000, 1.0000, 2.0000], + [2.1000, 2.0000, 1.0000], + [2.1000, 2.0000, 2.0000], + ] + ) + + expected_faces = torch.tensor( + [ + [2, 0, 1], + [2, 4, 3], + [0, 2, 3], + [4, 5, 3], + [0, 6, 7], + [1, 0, 7], + [3, 8, 0], + [8, 6, 0], + [5, 9, 8], + [3, 5, 8], + [6, 10, 7], + [11, 10, 6], + [8, 11, 6], + [9, 11, 8], + [13, 2, 1], + [12, 13, 1], + [14, 4, 13], + [13, 4, 2], + [4, 14, 15], + [5, 4, 15], + [12, 1, 16], + [1, 7, 16], + [15, 17, 5], + [5, 17, 9], + [16, 7, 10], + [18, 16, 10], + [19, 18, 11], + [18, 10, 11], + [9, 17, 19], + [11, 9, 19], + [20, 13, 12], + [20, 21, 14], + [13, 20, 14], + [15, 14, 21], + [22, 20, 12], + [16, 22, 12], + [21, 20, 23], + [23, 20, 22], + [17, 15, 21], + [23, 17, 21], + [22, 16, 18], + [23, 22, 18], + [19, 23, 18], + [17, 23, 19], + ] + ) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + verts, faces = marching_cubes_naive(volume_data, 0.9, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 5) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + # Check all values are in the range [-1, 1] + self.assertTrue(verts[0].ge(-1).all() and verts[0].le(1).all()) + + def test_cube_no_duplicate_verts(self): + volume_data = torch.zeros(1, 5, 5, 5) # (B, W, H, D) + volume_data[0, 1, 1, 1] = 1 + volume_data[0, 1, 1, 2] = 1 + volume_data[0, 2, 1, 1] = 1 + volume_data[0, 2, 1, 2] = 1 + volume_data[0, 1, 2, 1] = 1 + volume_data[0, 1, 2, 2] = 1 + volume_data[0, 2, 2, 1] = 1 + volume_data[0, 2, 2, 2] = 1 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, 1, return_local_coords=False) + + expected_verts = torch.tensor( + [ + [1.0, 1.0, 1.0], + [1.0, 1.0, 2.0], + [1.0, 2.0, 1.0], + [1.0, 2.0, 2.0], + [2.0, 1.0, 1.0], + [2.0, 1.0, 2.0], + [2.0, 2.0, 1.0], + [2.0, 2.0, 2.0], + ] + ) + + expected_faces = torch.tensor( + [ + [1, 3, 0], + [3, 2, 0], + [5, 1, 4], + [4, 1, 0], + [4, 0, 6], + [0, 2, 6], + [5, 7, 1], + [1, 7, 3], + [7, 6, 3], + [6, 2, 3], + [5, 4, 7], + [7, 4, 6], + ] + ) + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive(volume_data, 1, return_local_coords=True) + expected_verts = convert_to_local(expected_verts, 5) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + # Check all values are in the range [-1, 1] + self.assertTrue(verts[0].ge(-1).all() and verts[0].le(1).all()) + + def test_sphere(self): + # (B, W, H, D) + volume = torch.Tensor( + [ + [ + [(x - 10) ** 2 + (y - 10) ** 2 + (z - 10) ** 2 for z in range(20)] + for y in range(20) + ] + for x in range(20) + ] + ).unsqueeze(0) + volume = volume.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive( + volume, isolevel=64, return_local_coords=False + ) + + DATA_DIR = Path(__file__).resolve().parent / "data" + data_filename = "test_marching_cubes_data/sphere_level64.pickle" + filename = os.path.join(DATA_DIR, data_filename) + with open(filename, "rb") as file: + verts_and_faces = pickle.load(file) + expected_verts = verts_and_faces["verts"].squeeze() + expected_faces = verts_and_faces["faces"].squeeze() + + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + verts, faces = marching_cubes_naive( + volume, isolevel=64, return_local_coords=True + ) + + expected_verts = convert_to_local(expected_verts, 20) + self.assertClose(verts[0], expected_verts) + self.assertClose(faces[0], expected_faces) + + # Check all values are in the range [-1, 1] + self.assertTrue(verts[0].ge(-1).all() and verts[0].le(1).all()) + + # Uses skimage.draw.ellipsoid + def test_double_ellipsoid(self): + if USE_SCIKIT: + import numpy as np + from skimage.draw import ellipsoid + + ellip_base = ellipsoid(6, 10, 16, levelset=True) + ellip_double = np.concatenate( + (ellip_base[:-1, ...], ellip_base[2:, ...]), axis=0 + ) + volume = torch.Tensor(ellip_double).unsqueeze(0) + volume = volume.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume, isolevel=0.001) + + DATA_DIR = Path(__file__).resolve().parent / "data" + data_filename = "test_marching_cubes_data/double_ellipsoid.pickle" + filename = os.path.join(DATA_DIR, data_filename) + with open(filename, "rb") as file: + verts_and_faces = pickle.load(file) + expected_verts = verts_and_faces["verts"] + expected_faces = verts_and_faces["faces"] + + self.assertClose(verts[0], expected_verts[0]) + self.assertClose(faces[0], expected_faces[0]) + + def test_cube_surface_area(self): + if USE_SCIKIT: + from skimage.measure import marching_cubes_classic, mesh_surface_area + + volume_data = torch.zeros(1, 5, 5, 5) + volume_data[0, 1, 1, 1] = 1 + volume_data[0, 1, 1, 2] = 1 + volume_data[0, 2, 1, 1] = 1 + volume_data[0, 2, 1, 2] = 1 + volume_data[0, 1, 2, 1] = 1 + volume_data[0, 1, 2, 2] = 1 + volume_data[0, 2, 2, 1] = 1 + volume_data[0, 2, 2, 2] = 1 + volume_data = volume_data.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume_data, return_local_coords=False) + verts_sci, faces_sci = marching_cubes_classic(volume_data[0]) + + surf = mesh_surface_area(verts[0], faces[0]) + surf_sci = mesh_surface_area(verts_sci, faces_sci) + + self.assertClose(surf, surf_sci) + + def test_sphere_surface_area(self): + if USE_SCIKIT: + from skimage.measure import marching_cubes_classic, mesh_surface_area + + # (B, W, H, D) + volume = torch.Tensor( + [ + [ + [ + (x - 10) ** 2 + (y - 10) ** 2 + (z - 10) ** 2 + for z in range(20) + ] + for y in range(20) + ] + for x in range(20) + ] + ).unsqueeze(0) + volume = volume.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume, isolevel=64) + verts_sci, faces_sci = marching_cubes_classic(volume[0], level=64) + + surf = mesh_surface_area(verts[0], faces[0]) + surf_sci = mesh_surface_area(verts_sci, faces_sci) + + self.assertClose(surf, surf_sci) + + def test_double_ellipsoid_surface_area(self): + if USE_SCIKIT: + import numpy as np + from skimage.draw import ellipsoid + from skimage.measure import marching_cubes_classic, mesh_surface_area + + ellip_base = ellipsoid(6, 10, 16, levelset=True) + ellip_double = np.concatenate( + (ellip_base[:-1, ...], ellip_base[2:, ...]), axis=0 + ) + volume = torch.Tensor(ellip_double).unsqueeze(0) + volume = volume.permute(0, 3, 2, 1) # (B, D, H, W) + verts, faces = marching_cubes_naive(volume, isolevel=0) + verts_sci, faces_sci = marching_cubes_classic(volume[0], level=0) + + surf = mesh_surface_area(verts[0], faces[0]) + surf_sci = mesh_surface_area(verts_sci, faces_sci) + + self.assertClose(surf, surf_sci) + + @staticmethod + def marching_cubes_with_init(batch_size: int, V: int): + device = torch.device("cuda:0") + volume_data = torch.rand( + (batch_size, V, V, V), dtype=torch.float32, device=device + ) + torch.cuda.synchronize() + + def convert(): + marching_cubes_naive(volume_data, return_local_coords=False) + torch.cuda.synchronize() + + return convert