Geometry & 3D Vision

TSDF Fusion

Fusing many depth maps with known camera poses into one voxel grid of truncated signed distances by a per-voxel weighted average, then extracting the surface at the zero level set.

intermediate

TSDF fusion merges many depth maps, each from a known camera pose, into one voxel grid that stores a truncated signed distance to the surface and a confidence weight. Each depth map updates the voxels it sees by a weighted running average, and the surface is the zero level set of the result. Curless and Levoy (1996) developed the method for laser range scans; on a GPU with a depth camera it became the mapping step of KinectFusion (Newcombe et al., 2011) and a common map in dense RGB-D SLAM and 3D reconstruction.

Problem

One depth map covers one viewpoint and is noisy. Merging many calls for a representation that averages redundant measurements, accepts maps one at a time in any order and assumes nothing about topology, goals Curless and Levoy list (§1). A distance field on a grid meets them: averaging happens per voxel, and any topology comes out of the level set.

Inputs and Outputs

  • Input: depth maps Zk(u,v)Z_k(u, v) with intrinsics KK and world-to-camera poses (Rk,tk)(R_k, \mathbf{t}_k), so that a world point x\mathbf{x} has camera coordinates Xc=Rkx+tkX_c = R_k \mathbf{x} + \mathbf{t}_k; a voxel size hh, a truncation distance μ\mu and a weighting rule.
  • Output: per voxel a fused signed distance D(x)D(\mathbf{x}) and a cumulative weight W(x)W(\mathbf{x}), zero for voxels never observed; from these, a triangle mesh, a point cloud or rendered depth maps.

Intuition

A depth pixel says that a surface lies at the measured distance along its ray and that the space in front is empty: along the ray, a ramp crossing zero at the measurement. Averaging the ramps of several noisy views moves the crossing to the average position (Curless and Levoy, Fig. 2). Far from the surface a ramp may contradict another surface, so it is clipped at ±μ\pm\mu.

Algorithm

  1. Set D=0D = 0 and W=0W = 0 in every voxel.
  2. For each depth map kk, for each voxel:
    1. transform its centre into the camera, Xc=Rkx+tkX_c = R_k \mathbf{x} + \mathbf{t}_k, and project it to a pixel;
    2. compute the projective signed distance η\eta along the line of sight;
    3. skip the voxel if the pixel has no depth or η<−μ\eta < -\mu; otherwise clip η\eta at μ\mu and choose a weight ww;
    4. update DD and WW by the running average.
  3. Extract the surface D=0D = 0 between observed voxels with marching cubes (Lorensen and Cline, 1987), or ray-cast it.

Voxel updates are independent, so the loop parallelizes. Curless and Levoy cast a ray from the sensor through each voxel near the surface to the range image triangulated into a mesh, interpolating the weight from the triangle’s vertices (§3, Fig. 5). KinectFusion visits every voxel and takes the nearest pixel, which avoids smearing measurements across depth discontinuities (§3.3).

Mathematical Formulation

Projective signed distance. With u~\tilde{\mathbf{u}} the voxel’s pixel in homogeneous coordinates and λ=∥K−1u~∥\lambda = \lVert K^{-1}\tilde{\mathbf{u}} \rVert converting depth to distance along the ray,

ηk(x)=λ Zk(u)−∥Xc∥,\eta_k(\mathbf{x}) = \lambda\, Z_k(\mathbf{u}) - \lVert X_c \rVert ,

positive in front of the measured surface and negative behind it, so negative inside. Curless and Levoy use the opposite sign: empty space in front of the surface holds Dmin⁡<0D_{\min} < 0 and unseen space Dmax⁡>0D_{\max} > 0 (§4, Fig. 6b). KinectFusion puts positive values in free space (§3.3).

Truncation. The stored value is dk=min⁡(ηk,μ)d_k = \min(\eta_k, \mu) for ηk≥−μ\eta_k \ge -\mu, with no update otherwise, as KinectFusion describes (§3.3), dividing also by μ\mu. Curless and Levoy truncate distance ramps and weights near the measurements (§3, Fig. 3), tapering the weights behind the surface so that opposite sides of an object do not interfere.

Running average. Curless and Levoy’s Eqs. 1–2 define D=∑iwidi/∑iwiD = \sum_i w_i d_i / \sum_i w_i and W=∑iwiW = \sum_i w_i; incrementally (Eqs. 3–4),

Dk(x)=Wk−1(x) Dk−1(x)+wk(x) dk(x)Wk−1(x)+wk(x),Wk(x)=Wk−1(x)+wk(x).D_{k}(\mathbf{x}) = \frac{W_{k-1}(\mathbf{x})\, D_{k-1}(\mathbf{x}) + w_k(\mathbf{x})\, d_k(\mathbf{x})}{W_{k-1}(\mathbf{x}) + w_k(\mathbf{x})}, \qquad W_k(\mathbf{x}) = W_{k-1}(\mathbf{x}) + w_k(\mathbf{x}).

The order of the maps does not matter; in one dimension, the zero crossing is the weighted mean ∑iwiri/∑iwi\sum_i w_i r_i / \sum_i w_i of the measured ranges rir_i (Eq. 5).

Weights. Curless and Levoy weight each range vertex by the dot product of its normal with the viewing direction and down-weight mesh boundaries; behind the surface the weight falls off at half the maximum uncertainty interval (§3). KinectFusion makes the weight proportional to cos⁡θ/R\cos\theta / R, with θ\theta the angle between the ray and the measured normal and RR the measured depth, but reports that a constant w=1w = 1 gave good results (§3.3).

Least squares. Curless and Levoy’s Appendix A sketches why the zero set is optimal. For an orthographic sensor with range errors independent along the lines of sight, the surface minimizing the weighted squared distances to the range surfaces along those lines (Eq. 6) satisfies ∑iwidi=0\sum_i w_i d_i = 0 (Eq. 9), using that each weight is constant along its line of sight. Since ∑iwi>0\sum_i w_i > 0, this is the zero set of DD.

Extraction. Curless and Levoy meshed only between voxels of non-zero weight (§3), by marching cubes (§5.2.3). KinectFusion ray-casts the field instead, marching each pixel’s ray to the first positive-to-negative crossing (§3.4).

Parameters

  • Voxel size hh: resolution, and memory O(n3)O(n^3) for a dense grid of n3n^3 voxels.
  • Truncation μ\mu: large enough for non-truncated voxels on both sides of each surface, as KinectFusion scales it, widening it with distance (§3.3); larger than the depth noise; smaller than the thinnest structure.
  • Weight cap: KinectFusion caps WW at WηW_\eta (Eq. 13), turning the average into a moving average that follows moving objects.

Complexity

Each depth map costs O(N)O(N) for NN voxels, and a dense grid wastes most of its memory, since a surface crosses only O(n2)O(n^2) of n3n^3 voxels. KinectFusion’s GPU implementation updated over 65 gigavoxels per second, about 2 ms for a 5123512^3 volume (§3.3). Curless and Levoy run-length-encoded theirs, saving 10:1 to 20:1 (§5.2.1).

Sparse structures store only the band. Voxel hashing (Nießner et al., 2013) divides space into blocks of 838^3 voxels of 8 bytes (distance, colour, weight), allocates only blocks within the truncation region of each depth sample, finds them through a hash table on integer block coordinates, and streams blocks between GPU and host as the camera moves (§4–§8). The authors reported 4.6 ms per frame for integration on an NVIDIA GTX Titan, averaged over test scenes up to about 30 m long (§9). They review octrees as the alternative, which need deep trees whose traversal causes thread divergence on GPUs (§2).

Implementation

The NumPy code builds a scene with known geometry: a ground plane, a sphere of radius 10 cm, a box and a plate 12 mm thick, in 5 mm voxels. It renders exact depth maps from 32 cameras on a ring 0.8 m away, adds Gaussian noise (σ\sigma = 2 mm), and fuses them with constant weights, writing +μ+\mu into observed free space as KinectFusion does; marking free space lets later views remove outliers (Nießner et al., 2013, §3). Surface points are zero crossings along grid lines, the vertices of marching cubes; their error is the magnitude of the analytic signed distance (exact except where objects meet the ground), and completeness is the fraction of visible surface samples with a crossing within one voxel. It runs in about 20 seconds.

import numpy as np
from scipy.spatial import cKDTree

# Scene (metres, z up): ground z = 0, a sphere, a box and a 12 mm thin plate.
# Signed distance: negative inside, positive outside.
SPH_C, SPH_R = np.array([0.12, 0.08, 0.10]), 0.10
BOXES = [(np.array([-0.10, 0.05, 0.06]), np.array([0.07, 0.06, 0.06])),    # box
         (np.array([0.00, -0.14, 0.07]), np.array([0.10, 0.006, 0.07]))]   # plate

def box_sdf(p, c, h):
    q = np.abs(p - c) - h
    return np.linalg.norm(np.maximum(q, 0), axis=-1) + np.minimum(q.max(-1), 0)

def scene_sdf(p):
    f = np.minimum(p[..., 2], np.linalg.norm(p - SPH_C, axis=-1) - SPH_R)
    for c, h in BOXES:
        f = np.minimum(f, box_sdf(p, c, h))
    return f

# Pinhole cameras on a ring, world-to-camera X_c = R X_w + t.
W, H, F = 240, 180, 220.0
K = np.array([[F, 0, (W - 1) / 2], [0, F, (H - 1) / 2], [0, 0, 1]])
uu, vv = np.meshgrid(np.arange(W), np.arange(H))
RAYS = np.stack([uu, vv, np.ones_like(uu)], -1).reshape(-1, 3) @ np.linalg.inv(K).T
LAM = np.linalg.norm(RAYS, axis=1)                    # ray length per unit depth

def camera(k, target=np.array([0.0, 0.0, 0.05]), dist=0.8):
    az, el = np.radians(137.5 * k), np.radians(30 + 15 * (k % 2))   # golden-angle ring
    C = target + dist * np.array([np.cos(el) * np.cos(az), np.cos(el) * np.sin(az), np.sin(el)])
    z = (target - C) / np.linalg.norm(target - C)
    x = np.cross(z, [0, 0, 1.0]); x /= np.linalg.norm(x)
    R = np.stack([x, np.cross(z, x), z])               # rows: camera axes in world
    return R, -R @ C

def render_depth(R, t):
    """Exact depth Z per pixel by ray casting the analytic primitives (0 = no data)."""
    C, D = -R.T @ t, RAYS @ R                          # centre, ray directions (Z = 1)
    s = np.full(len(D), np.inf)
    with np.errstate(divide="ignore", invalid="ignore"):
        s = np.minimum(s, np.where(D[:, 2] < 0, -C[2] / D[:, 2], np.inf))   # ground
        oc = C - SPH_C
        a, b, c = (D * D).sum(1), (D @ oc), oc @ oc - SPH_R ** 2
        disc = b * b - a * c
        s = np.minimum(s, np.where(disc >= 0, (-b - np.sqrt(np.maximum(disc, 0))) / a, np.inf))
        for c0, h0 in BOXES:                           # slab test
            t0, t1 = (c0 - h0 - C) / D, (c0 + h0 - C) / D
            near, far = np.minimum(t0, t1).max(1), np.maximum(t0, t1).min(1)
            s = np.minimum(s, np.where((near <= far) & (near > 0), near, np.inf))
    return np.where(s < 1.5, s, 0.0).reshape(H, W)     # D has unit depth, so s = Z

# Voxel grid with h = 5 mm.
h = 0.005
gx, gz = np.arange(-0.25, 0.25 + 1e-9, h), np.arange(-0.04, 0.26 + 1e-9, h)
P = np.stack(np.meshgrid(gx, gx, gz, indexing="ij"), -1).reshape(-1, 3)
SHAPE = (len(gx), len(gx), len(gz))

def integrate(Dsum, Wsum, depth, R, t, mu):
    """Fuse one depth map: projective signed distance, truncation, running average."""
    Xc = P @ R.T + t
    Z = Xc[:, 2]
    u = np.rint(F * Xc[:, 0] / Z + (W - 1) / 2).astype(int)
    v = np.rint(F * Xc[:, 1] / Z + (H - 1) / 2).astype(int)
    ok = (Z > 0) & (u >= 0) & (u < W) & (v >= 0) & (v < H)
    idx = np.flatnonzero(ok)
    pix = v[idx] * W + u[idx]
    zm = depth.ravel()[pix]
    eta = LAM[pix] * zm - np.linalg.norm(Xc[idx], axis=1)   # + in front, - behind
    keep = (zm > 0) & (eta >= -mu)                     # far behind: unseen, no update
    idx, d = idx[keep], np.minimum(eta[keep], mu)      # in front: clipped to +mu
    Dsum[idx] = (Wsum[idx] * Dsum[idx] + d) / (Wsum[idx] + 1)   # constant weight w = 1
    Wsum[idx] += 1

def zero_crossings(Dsum, Wsum):
    """Surface points: sign changes between observed neighbours along x, y and z."""
    D, Wt, G = Dsum.reshape(SHAPE), Wsum.reshape(SHAPE), P.reshape(*SHAPE, 3)
    pts = []
    for ax in range(3):
        a, b = [np.take(D, np.arange(n0, SHAPE[ax] - 1 + n0), ax) for n0 in (0, 1)]
        wa, wb = [np.take(Wt, np.arange(n0, SHAPE[ax] - 1 + n0), ax) for n0 in (0, 1)]
        m = (np.sign(a) != np.sign(b)) & (wa > 0) & (wb > 0)
        frac = a[m] / (a[m] - b[m])
        p = np.take(G, np.arange(SHAPE[ax] - 1), ax)[m]
        p[:, ax] += h * frac
        pts.append(p)
    return np.concatenate(pts)

# Reference: area-uniform samples of the visible surface (seed 0), for completeness.
rng = np.random.default_rng(0)
u3 = rng.normal(size=(62800, 3)); ref = [SPH_C + SPH_R * u3 / np.linalg.norm(u3, axis=1)[:, None]]
for c, hb in BOXES:
    for ax in range(3):
        for sgn in (-1, 1):
            q = rng.uniform(-1, 1, (int(2e6 * np.prod(np.delete(hb, ax))), 3)) * hb
            q[:, ax] = sgn * hb[ax]; ref.append(c + q)
ref.append(np.c_[rng.uniform(-0.25, 0.25, (125000, 2)), np.zeros(125000)])
ref = np.concatenate(ref)
ref = ref[(np.abs(scene_sdf(ref)) < 1e-9) & (np.abs(ref[:, :2]) <= 0.25).all(1)]

CAMS = [camera(k) for k in range(32)]
EXACT = [render_depth(R, t) for R, t in CAMS]

def visible(R, t, depth):
    Xc = ref @ R.T + t
    u = np.rint(F * Xc[:, 0] / Xc[:, 2] + (W - 1) / 2).astype(int)
    v = np.rint(F * Xc[:, 1] / Xc[:, 2] + (H - 1) / 2).astype(int)
    ok = (u >= 0) & (u < W) & (v >= 0) & (v < H)
    vis = np.zeros(len(ref), bool)
    vis[ok] = np.abs(depth[v[ok], u[ok]] - Xc[ok, 2]) < 0.003
    return vis

VIS = np.array([visible(R, t, d) for (R, t), d in zip(CAMS, EXACT)])
on_plate = box_sdf(ref, *BOXES[1]) < 1e-9
REF_TREE = cKDTree(ref)

def evaluate(Dsum, Wsum, n_views):
    pts = zero_crossings(Dsum, Wsum)
    err = np.abs(scene_sdf(pts)) * 1000                # mm from the analytic surface
    seen = VIS[:n_views].any(0)
    found = cKDTree(pts).query(ref, distance_upper_bound=h)[0] < np.inf
    near = REF_TREE.query(pts)[1]                      # nearest reference sample
    plate = on_plate[near]                             # signed offset: + thicker, - thinner
    return (err.mean(), np.percentile(err, 95), found[seen].mean(), found[seen & on_plate].mean(),
            err[VIS[0][near]].mean(), 1000 * box_sdf(pts[plate], *BOXES[1]).mean())

def fuse(n_views, mu, sigma=0.002, seed=0, pose_err_deg=0.0, report=()):
    rng = np.random.default_rng(seed)
    Dsum, Wsum, out = np.zeros(len(P), np.float32), np.zeros(len(P), np.float32), {}
    for k in range(n_views):
        R, t = CAMS[k]
        depth = EXACT[k] + rng.normal(0, sigma, EXACT[k].shape) * (EXACT[k] > 0)
        if pose_err_deg:                               # integrate with a wrong pose
            w = rng.normal(size=3); w *= np.radians(pose_err_deg) / np.linalg.norm(w)
            Wx = np.array([[0, -w[2], w[1]], [w[2], 0, -w[0]], [-w[1], w[0], 0]])
            th = np.linalg.norm(w)
            dR = np.eye(3) + np.sin(th) / th * Wx + (1 - np.cos(th)) / th**2 * Wx @ Wx
            R, t = dR @ R, t + rng.normal(size=3) * 0.8 * np.radians(pose_err_deg) / np.sqrt(3)
        integrate(Dsum, Wsum, depth, R, t, mu)
        if k + 1 in report:
            out[k + 1] = evaluate(Dsum, Wsum, k + 1)
    return out if report else evaluate(Dsum, Wsum, n_views)

# 1. Projective versus true distance, one exact view, binned by incidence angle.
R, t = CAMS[0]
Xc = P @ R.T + t
u = np.rint(F * Xc[:, 0] / Xc[:, 2] + (W - 1) / 2).astype(int)
v = np.rint(F * Xc[:, 1] / Xc[:, 2] + (H - 1) / 2).astype(int)
ok = (u >= 0) & (u < W) & (v >= 0) & (v < H) & (Xc[:, 2] > 0)
pix = v[ok] * W + u[ok]
zm = EXACT[0].ravel()[pix]
eta = LAM[pix] * zm - np.linalg.norm(Xc[ok], axis=1)
f = scene_sdf(P[ok])
hit = (-R.T @ t) + (RAYS[pix] @ R) * zm[:, None]       # surface point seen by the pixel
e = 1e-5
nrm = np.stack([scene_sdf(hit + e * np.eye(3)[i]) - scene_sdf(hit - e * np.eye(3)[i]) for i in range(3)], 1)
nrm /= np.linalg.norm(nrm, axis=1, keepdims=True)
cos = np.abs((RAYS[pix] @ R / LAM[pix, None] * nrm).sum(1))
band = (zm > 0) & (np.abs(f) > h / 2) & (np.abs(f) < 2 * h)
print("cos(incidence)  voxels  median eta/f  1/cos")
for lo, hi in [(0.9, 1.0), (0.7, 0.9), (0.5, 0.7), (0.3, 0.5), (0.1, 0.3)]:
    m = band & (cos >= lo) & (cos < hi)
    print(f"{lo:.1f}-{hi:.1f}  {m.sum():12d}  {np.median(eta[m] / f[m]):12.2f}  {2 / (lo + hi):5.2f}")

# 2. More views: zero-crossing error and completeness, over seeds 0-4.
print("\nviews  mean err (mm)  95th pct (mm)  complete  view-1 surfaces (mm)  seeds 0-4: mean err range")
runs = [fuse(32, mu=0.02, seed=s, report=(1, 2, 4, 8, 16, 32)) for s in range(5)]
for n in (1, 2, 4, 8, 16, 32):
    r = np.array([run[n] for run in runs])
    print(f"{n:5d}  {r[0, 0]:13.2f}  {r[0, 1]:13.2f}  {r[0, 2]:8.1%}  {r[0, 4]:20.2f}  "
          f"{r[:, 0].min():.2f}-{r[:, 0].max():.2f}")

# 3. Truncation distance mu (16 views, sigma = 2 mm, voxel 5 mm).
print("\nmu (mm)  mean err (mm)  95th pct (mm)  complete  plate complete  plate offset (mm)")
for mu in (0.0025, 0.005, 0.01, 0.02, 0.04, 0.08):
    r = fuse(16, mu)
    print(f"{mu * 1000:7.1f}  {r[0]:13.2f}  {r[1]:13.2f}  {r[2]:8.1%}  {r[3]:14.1%}  {r[5]:17.2f}")

# 4. Pose errors: each camera rotated by the angle about a random axis, and
#    shifted by about the same angle times the 0.8 m viewing distance.
print("\npose error (deg)  mean err (mm)  95th pct (mm)  complete")
for deg in (0.0, 0.25, 0.5, 1.0, 2.0):
    r = fuse(16, mu=0.02, pose_err_deg=deg)
    print(f"{deg:16.2f}  {r[0]:13.2f}  {r[1]:13.2f}  {r[2]:8.1%}")

Output:

cos(incidence)  voxels  median eta/f  1/cos
0.9-1.0          4889          1.16   1.05
0.7-0.9          9548          1.29   1.25
0.5-0.7         20862          1.64   1.67
0.3-0.5         16434          2.72   2.50
0.1-0.3          6630          4.38   5.00

views  mean err (mm)  95th pct (mm)  complete  view-1 surfaces (mm)  seeds 0-4: mean err range
    1           0.69           2.02     99.9%                  0.58  0.69-0.70
    2           0.70           2.24     99.8%                  0.55  0.70-0.71
    4           0.72           2.65     99.5%                  0.43  0.72-0.74
    8           0.51           1.80     99.5%                  0.36  0.51-0.51
   16           0.40           1.47     99.5%                  0.28  0.40-0.40
   32           0.35           1.33     99.4%                  0.23  0.35-0.35

mu (mm)  mean err (mm)  95th pct (mm)  complete  plate complete  plate offset (mm)
    2.5           0.58           1.95     82.4%           94.8%              -0.64
    5.0           0.42           1.35     98.5%           99.2%              -0.43
   10.0           0.33           1.10     99.6%           98.7%              -0.27
   20.0           0.40           1.47     99.5%           97.4%               1.18
   40.0           0.81           4.64     92.8%           63.1%               3.46
   80.0           2.41          11.18     83.0%           63.6%               5.21

pose error (deg)  mean err (mm)  95th pct (mm)  complete
            0.00           0.40           1.47     99.5%
            0.25           0.57           2.09     99.2%
            0.50           0.95           3.62     98.3%
            1.00           2.58           8.97     80.9%
            2.00          12.61          30.21     42.2%

Projective distance. The projective distance exceeds the true one by roughly 1/cos⁡θ1/\cos\theta, from 1.16 near normal incidence to 4.38 at grazing angles. For a plane this is exact: a voxel at distance ff lies f/cos⁡θf/\cos\theta from it along a ray at angle θ\theta to the normal (elementary geometry); deviations come from voxels whose nearest surface is not the one the ray hits (our reading). The zero crossing is unaffected, but views disagree on slope. KinectFusion’s authors cite a cos⁡θ\cos\theta correction near the surface (Frisken and Perry) but found averaging hundreds of views good enough without it (§3.3).

More views. The mean error falls from 0.69 mm for one view to 0.35 mm for 32, within 0.02 mm across seeds 0–4. It rises slightly up to four views, in our reading because new views add surfaces seen obliquely or near silhouettes; on the surfaces the first view sees, it falls at every step, from 0.58 to 0.23 mm.

Truncation. With μ\mu at half a voxel, 82.4% of the surface is found: in our reading, many voxels next to it get no non-truncated value. Errors are lowest at μ\mu = 10 mm, two voxels and five noise standard deviations. Once μ\mu exceeds the 12 mm plate, the plate thickens by 1.2 to 5.2 mm per face, and from 40 mm only 63% of it is recovered. In our reading, the negative ramp behind one face reaches the free space beyond the other, where opposite views write positive values, and the average pushes the crossing outward: the thickening Curless and Levoy describe (§7).

Pose error. Perturbing each camera by 0.25° raises the error from 0.40 to 0.57 mm; at 1° it is 2.58 mm and 19% of the surface is lost; at 2°, most of it. At 0.8 m, a 1° rotation alone moves a surface by up to about 14 mm, comparable to μ\mu, so views disagree on where the surface is and the average smears or splits it.

Libraries. Open3D’s ScalableTSDFVolume, recommended by its tutorial for larger scenes, takes a voxel length and a truncation sdf_trunc, integrates each frame with the inverse of its trajectory pose as the extrinsic, and meshes by marching cubes. Open3D’s tensor-based VoxelBlockGrid stores a sparse grid of dense voxel blocks (block_resolution 16 by default) and integrates depth with a trunc_voxel_multiplier (default 8.0). This is from the 0.19.0 documentation, unchanged in 0.20.0, the latest release as of October 2026.

Limitations

  • Poses must be known. Fusion averages in the world frame, so pose errors smear surfaces, as measured above. Curless and Levoy took aligned range images as input (abstract); KinectFusion tracks the camera against the model with ICP.
  • Thin structures. Thin surfaces thicken and sharp corners round off (Curless and Levoy, §7); above, the plate thickened once μ\mu exceeded its thickness.
  • Resolution. Voxel size, μ\mu and sensor noise are coupled: μ\mu must exceed the voxel size and the noise, so voxels much finer than the noise gain little; Nießner et al. saw no visible improvement below 2 mm with their depth cameras (§9).
  • Static scenes. Moving objects leave traces until overwritten (our reading); the weight cap trades noise reduction for faster forgetting.

Variants

  • Space carving and hole filling. Curless and Levoy classify voxels as unseen, empty or near the surface (§4). Voxels near the surface are updated as above, and those along lines of sight in front of it are marked empty. Unseen and empty voxels have zero weight but values Dmax⁡D_{\max} and Dmin⁡D_{\min}, so the same extraction also places surfaces between empty and unseen regions, plugging holes; a backdrop behind the object carves more space. Their dragon from 61 scans had 324 holes; with hole filling and 10 backdrop scans, none (Figs. 8 and 11).
  • Real-time fusion. KinectFusion runs the loop on a GPU for every depth-camera frame; the KinectFusion article covers the system.

Related

  • 3D Reconstruction

    Recovering the 3D shape of objects and scenes from images, depth maps, or range scans, as point clouds, meshes, volumes, or implicit and radiance fields, and measuring how accurate and complete the result is.

  • 3D Representations

    The main ways to store 3D shape and scenes, from depth maps, point clouds and meshes to voxel grids, distance and occupancy fields, radiance fields and 3D Gaussians, and what each makes easy or hard.

  • Camera Pose

    The position and orientation of a camera as a rigid transform in SE(3), the two directions it is stored in (world-to-camera and camera-to-world), the axis conventions of OpenCV, COLMAP, OpenGL and NeRF, and how pose errors are measured.

  • A Volumetric Method for Building Complex Models from Range Images

    Brian Curless and Marc Levoy's SIGGRAPH 1996 paper that merged aligned laser range scans into a cumulative weighted signed distance function on a voxel grid, filled holes by carving empty space, and built the Stanford dragon and Happy Buddha models.

  • Multi-View Stereo

    Recovering dense 3D geometry, as depth maps, point clouds, meshes, or volumes, from many images whose camera poses and intrinsics are known.

  • Simultaneous Localization and Mapping (SLAM)

    Building a map of an unknown environment while estimating the trajectory of the sensor moving through it, and using recognized revisits to keep both globally consistent.

References

  1. Curless, B. & Levoy, M. (1996). A Volumetric Method for Building Complex Models from Range Images. Proceedings of the 23rd Annual Conference on Computer Graphics and Interactive Techniques (SIGGRAPH), 303–312.
  2. Newcombe, R. A., Izadi, S., Hilliges, O., Molyneaux, D., Kim, D., Davison, A. J., Kohli, P., Shotton, J., Hodges, S. & Fitzgibbon, A. (2011). KinectFusion: Real-Time Dense Surface Mapping and Tracking. 10th IEEE International Symposium on Mixed and Augmented Reality (ISMAR), 127–136.
  3. Nießner, M., Zollhöfer, M., Izadi, S. & Stamminger, M. (2013). Real-time 3D Reconstruction at Scale using Voxel Hashing. ACM Transactions on Graphics, 32(6), Article 169.
  4. Lorensen, W. E. & Cline, H. E. (1987). Marching Cubes: A High Resolution 3D Surface Construction Algorithm. Computer Graphics (Proceedings of SIGGRAPH '87), 21(4), 163–169.
  5. Open3D. open3d.t.geometry.VoxelBlockGrid. Open3D 0.19.0 documentation.
  6. Open3D. RGBD integration. Open3D 0.19.0 documentation.