├── .github └── workflows │ └── pythonpublish.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── depth_to_mesh ├── __init__.py └── __main__.py └── setup.py /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 📦 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.x" 18 | - name: Install pypa/build 19 | run: >- 20 | python3 -m 21 | pip install 22 | build 23 | --user 24 | - name: Build a binary wheel and a source tarball 25 | run: python3 -m build 26 | - name: Store the distribution packages 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: python-package-distributions 30 | path: dist/ 31 | 32 | publish-to-pypi: 33 | name: >- 34 | Publish Python 🐍 distribution 📦 to PyPI 35 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 36 | needs: 37 | - build 38 | runs-on: ubuntu-latest 39 | environment: 40 | name: pypi 41 | url: https://pypi.org/p/depth-to-mesh/ # Replace with your PyPI project name 42 | permissions: 43 | id-token: write # IMPORTANT: mandatory for trusted publishing 44 | 45 | steps: 46 | - name: Download all the dists 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: python-package-distributions 50 | path: dist/ 51 | - name: Publish distribution 📦 to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | 54 | github-release: 55 | name: >- 56 | Sign the Python 🐍 distribution 📦 with Sigstore 57 | and upload them to GitHub Release 58 | needs: 59 | - publish-to-pypi 60 | runs-on: ubuntu-latest 61 | 62 | permissions: 63 | contents: write # IMPORTANT: mandatory for making GitHub Releases 64 | id-token: write # IMPORTANT: mandatory for sigstore 65 | 66 | steps: 67 | - name: Download all the dists 68 | uses: actions/download-artifact@v4 69 | with: 70 | name: python-package-distributions 71 | path: dist/ 72 | - name: Sign the dists with Sigstore 73 | uses: sigstore/gh-action-sigstore-python@v3.0.0 74 | with: 75 | inputs: >- 76 | ./dist/*.tar.gz 77 | ./dist/*.whl 78 | - name: Create GitHub Release 79 | env: 80 | GITHUB_TOKEN: ${{ github.token }} 81 | run: >- 82 | gh release create 83 | "$GITHUB_REF_NAME" 84 | --repo "$GITHUB_REPOSITORY" 85 | --notes "" 86 | - name: Upload artifact signatures to GitHub Release 87 | env: 88 | GITHUB_TOKEN: ${{ github.token }} 89 | # Upload to GitHub Release using the `gh` CLI. 90 | # `dist/` contains the built packages, and the 91 | # sigstore-produced signatures and certificates. 92 | run: >- 93 | gh release upload 94 | "$GITHUB_REF_NAME" dist/** 95 | --repo "$GITHUB_REPOSITORY" 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | build 3 | bin 4 | example.* 5 | 6 | *.egg-info 7 | __pycache__ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Hendrik Sommerhoff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # depth-to-mesh 2 | depth-to-mesh is a python library / command line tool to convert depth maps into meshes. It uses the uniform structure of depth maps for its triangulation. Degenerate triangles and triangles that are likely wrong, i.e. connecting foreground and background surfaces) are filtered out. 3 | 4 | ## Installation 5 | Simply running ```pip install depth-to-mesh``` installs all requirements 6 | 7 | ## Getting Started 8 | 9 | To use the command line tool, execute ```python -m depth_to_mesh``` and see the output for instructions on arguments. 10 | 11 | You will likely need a different intrinsic camera matrix than the default one. The matrix can be passed with the `--camera ` command line parameter, where `` is the path to a text file containing the matrix. 12 | The text file should simply have the usual shape of intrinsic matrices, i.e. 13 | ``` 14 | fx 0 cx 15 | 0 fy cy 16 | 0 0 1 17 | ``` 18 | The default matrix in text file form would be 19 | ``` 20 | 528 0 319.5 21 | 0 528 239.5 22 | 0 0 1 23 | ``` 24 | 25 | The python library converts depth files into Open3D TriangleMesh objects. See the [Open3D documentation](http://www.open3d.org/docs/release/) for instructions on how to use them. 26 | 27 | ## License 28 | 29 | This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details 30 | -------------------------------------------------------------------------------- /depth_to_mesh/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pathlib 4 | import itertools 5 | import open3d as o3d 6 | import numpy as np 7 | import math 8 | import logging 9 | from skimage.io import imread 10 | from tqdm import tqdm 11 | 12 | DEFAULT_CAMERA = o3d.camera.PinholeCameraIntrinsic( 13 | width=640, height=480, 14 | fx=528.0, fy=528.0, 15 | cx=319.5, cy=239.5 16 | ) 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | def _pixel_coord_np(width, height): 21 | """ 22 | Pixel in homogenous coordinate 23 | Returns: 24 | Pixel coordinate: [3, width * height] 25 | """ 26 | x = np.linspace(0, width - 1, width).astype(np.int32) 27 | y = np.linspace(0, height - 1, height).astype(np.int32) 28 | [x, y] = np.meshgrid(x, y) 29 | return np.vstack((x.flatten(), y.flatten(), np.ones_like(x.flatten()))) 30 | 31 | def depth_file_to_mesh(image, cameraMatrix=DEFAULT_CAMERA, minAngle=3.0, sun3d=False, depthScale=1000.0): 32 | """ 33 | Converts a depth image file into a open3d TriangleMesh object 34 | 35 | :param image: path to the depth image file 36 | :param cameraMatrix: numpy array of the intrinsic camera matrix 37 | :param minAngle: Minimum angle between viewing rays and triangles in degrees 38 | :param sun3d: Specify if the depth file is in the special SUN3D format 39 | :returns: an open3d.geometry.TriangleMesh containing the converted mesh 40 | """ 41 | depth_raw = imread(image).astype('uint16') 42 | width = depth_raw.shape[1] 43 | height = depth_raw.shape[0] 44 | 45 | if sun3d: 46 | depth_raw = np.bitwise_or(depth_raw>>3, depth_raw<<13) 47 | 48 | depth_raw = depth_raw.astype('float32') 49 | depth_raw /= depthScale 50 | 51 | logger.debug('Image dimensions:%s x %s', width, height) 52 | logger.debug('Camera Matrix:%s', cameraMatrix) 53 | 54 | if cameraMatrix is None: 55 | camera = DEFAULT_CAMERA 56 | else: 57 | camera = o3d.camera.PinholeCameraIntrinsic( 58 | width=width, height=height, 59 | fx=cameraMatrix[0,0], fy=cameraMatrix[1,1], 60 | cx=cameraMatrix[0,2], cy=cameraMatrix[1,2] 61 | ) 62 | return depth_to_mesh(depth_raw.astype('float32'), camera, minAngle) 63 | 64 | def depth_to_mesh(depth, camera=DEFAULT_CAMERA, minAngle=3.0): 65 | """ 66 | Vectorized version of converting a depth image to a mesh, filtering out invalid triangles. 67 | 68 | :param depth: np.array of type float32 containing the depth image 69 | :param camera: open3d.camera.PinholeCameraIntrinsic 70 | :param minAngle: Minimum angle between viewing rays and triangles in degrees 71 | :returns: an open3d.geometry.TriangleMesh containing the converted mesh 72 | """ 73 | logger.info('Reprojecting points...') 74 | 75 | K = camera.intrinsic_matrix 76 | K_inv = np.linalg.inv(K) 77 | 78 | # Reproject all points 79 | pixel_coords = _pixel_coord_np(depth.shape[1], depth.shape[0]) 80 | cam_coords = K_inv @ pixel_coords * depth.flatten() 81 | 82 | # Generate indices for triangles 83 | h, w = depth.shape 84 | i, j = np.meshgrid(np.arange(h - 1), np.arange(w - 1), indexing='ij') 85 | i, j = i.flatten(), j.flatten() 86 | 87 | # Form two triangles for each quad 88 | idx_t1 = np.stack([i * w + j, (i + 1) * w + j, i * w + (j + 1)], axis=-1) 89 | idx_t2 = np.stack([i * w + (j + 1), (i + 1) * w + j, (i + 1) * w + (j + 1)], axis=-1) 90 | 91 | # Combine triangle indices 92 | indices = np.vstack([idx_t1, idx_t2]) 93 | 94 | # Extract vertices for each triangle 95 | verts = cam_coords[:, indices] 96 | 97 | # Calculate normals 98 | v1 = verts[:, :, 1] - verts[:, :, 0] 99 | v2 = verts[:, :, 2] - verts[:, :, 0] 100 | normals = np.cross(v1, v2, axis=0) 101 | normal_lengths = np.linalg.norm(normals, axis=0) 102 | 103 | # Calculate angles 104 | centers = verts.mean(axis=2) 105 | center_lengths = np.linalg.norm(centers, axis=0) 106 | valid_normals = normal_lengths > 0 107 | valid_centers = center_lengths > 0 108 | 109 | # Filter invalid triangles (zero-length normals or centers) 110 | valid = valid_normals & valid_centers 111 | 112 | # Recalculate angles only for valid triangles 113 | normals = normals[:, valid] / normal_lengths[valid] 114 | centers = centers[:, valid] / center_lengths[valid] 115 | angles = np.degrees(np.arcsin(np.abs(np.einsum('ij,ij->j', normals, centers)))) 116 | 117 | # Further filter by angle 118 | angle_valid = angles > minAngle 119 | indices = indices[valid][angle_valid].astype(np.int32) 120 | 121 | # Create Open3D mesh 122 | indices = o3d.utility.Vector3iVector(np.ascontiguousarray(indices)) 123 | points = o3d.utility.Vector3dVector(np.ascontiguousarray(cam_coords.transpose())) 124 | mesh = o3d.geometry.TriangleMesh(points, indices) 125 | 126 | mesh.compute_vertex_normals() 127 | mesh.compute_triangle_normals() 128 | 129 | return mesh -------------------------------------------------------------------------------- /depth_to_mesh/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | from depth_to_mesh import * 5 | import open3d as o3d 6 | 7 | if __name__ == '__main__': 8 | parser = argparse.ArgumentParser(description='Converts a depth image to a triangle mesh') 9 | parser.add_argument('image', type=str, help='path to the image file') 10 | parser.add_argument('output', type=str, help='name of output file') 11 | parser.add_argument('--camera', '-c', type=str, help='path to camera matrix', default=None) 12 | parser.add_argument('--sun3d', '-s', dest='sun3d', action='store_true', help='set if image is in SUN3D format') 13 | parser.add_argument('--log', '-l', type=str, help='specify logging level', default='INFO') 14 | parser.add_argument('--min-angle', '-e', type=float, help='specify the minimum angle in degrees between viewing ray and triangles', default=3) 15 | parser.add_argument('--depth-scale', '-d', type=float, help='specify the depth scale', default=1000.0) 16 | args = parser.parse_args() 17 | 18 | numeric_level = getattr(logging, args.log.upper(), None) 19 | if not isinstance(numeric_level, int): 20 | raise ValueError('Invalid log level: %s' % args.log) 21 | 22 | logging.basicConfig(level=numeric_level) 23 | 24 | if args.camera is not None: 25 | cameraMatrix = np.loadtxt(args.camera) 26 | else: 27 | cameraMatrix = None 28 | 29 | mesh = depth_file_to_mesh(args.image, cameraMatrix, args.min_angle, args.sun3d, args.depth_scale) 30 | 31 | o3d.io.write_triangle_mesh(args.output, mesh) 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='depth-to-mesh', 5 | version='0.1.6', 6 | description='Converts depth maps into triangle meshes', 7 | url='https://github.com/hesom/depth_to_mesh', 8 | author='Hendrik Sommerhoff', 9 | author_email='sommerhoff.hendrik@gmail.com', 10 | license='MIT', 11 | packages=['depth_to_mesh'], 12 | install_requires=[ 13 | 'open3d>=0.18,<0.19', 14 | 'numpy', 15 | 'scikit-image', 16 | 'tqdm' 17 | ], 18 | zip_safe=False 19 | ) 20 | --------------------------------------------------------------------------------