Skip to content

Commit f5dec3b

Browse files
Mesh smoothing (#42)
* Add mesh smoothing reference * Implement mesh smoothing
1 parent a6210cc commit f5dec3b

6 files changed

Lines changed: 858 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Tuple
2+
3+
import nifty
4+
import numpy as np
5+
6+
7+
def smooth_mesh(
8+
verts: np.ndarray, normals: np.ndarray, faces: np.ndarray, iterations: int
9+
) -> Tuple[np.ndarray, np.ndarray]:
10+
"""Smooth mesh surface via laplacian smoothing.
11+
12+
Args:
13+
verts: The mesh vertices.
14+
normals: The mesh normals.
15+
faces: The mesh faces.
16+
iterations: The number of smoothing iterations.
17+
18+
Returns:
19+
The vertices after smoothing.
20+
The normals after smoothing.
21+
"""
22+
n_verts = len(verts)
23+
g = nifty.graph.undirectedGraph(n_verts)
24+
25+
edges = np.concatenate([faces[:, :2], faces[:, 1:], faces[:, ::2]], axis=0)
26+
g.insertEdges(edges)
27+
28+
current_verts = verts
29+
current_normals = normals
30+
new_verts = np.zeros_like(verts, dtype=verts.dtype)
31+
new_normals = np.zeros_like(normals, dtype=normals.dtype)
32+
33+
# Implement this directly in nifty for speed up?
34+
for it in range(iterations):
35+
for vert in range(n_verts):
36+
nbrs = np.array([vert] + [nbr[0] for nbr in g.nodeAdjacency(vert)], dtype="int")
37+
new_verts[vert] = np.mean(current_verts[nbrs], axis=0)
38+
new_normals[vert] = np.mean(current_normals[nbrs], axis=0)
39+
current_verts = new_verts
40+
current_normals = new_normals
41+
42+
return new_verts, new_normals
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Benchmark Laplacian mesh smoothing: bioimage_cpp vs the nifty-based reference.
2+
3+
The mesh is built by triangulating ``n`` random points sampled on the unit
4+
sphere via ``scipy.spatial.ConvexHull`` — a closed manifold of ``2n - 4``
5+
triangles is a fair workload for the algorithm. The Python reference is
6+
imported from ``_mesh_smoothing_reference.py`` and uses ``nifty`` for graph
7+
adjacency; if either library is missing, the corresponding column is skipped.
8+
9+
Run::
10+
11+
python development/utils/benchmark_mesh_smoothing.py --n-vertices 5000 --iterations 5 --repeats 3
12+
python development/utils/benchmark_mesh_smoothing.py --n-vertices 20000 --threads 1,2,4,0
13+
14+
Notes
15+
-----
16+
The reference does in-place Gauss-Seidel smoothing after the first iteration
17+
(an aliasing accident in the Python source); bioimage_cpp does textbook Jacobi
18+
smoothing. Correctness against the reference is only checked for
19+
``iterations=1``, which is where the two implementations agree exactly.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import argparse
25+
import csv
26+
import os
27+
import sys
28+
from dataclasses import dataclass
29+
from statistics import median
30+
from time import perf_counter
31+
32+
import numpy as np
33+
34+
import bioimage_cpp as bic
35+
36+
37+
def parse_args() -> argparse.Namespace:
38+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
39+
parser.add_argument("--n-vertices", type=int, default=5000)
40+
parser.add_argument("--iterations", type=int, default=5)
41+
parser.add_argument("--repeats", type=int, default=3)
42+
parser.add_argument("--warmup", type=int, default=1)
43+
parser.add_argument("--seed", type=int, default=2026)
44+
parser.add_argument(
45+
"--threads",
46+
type=str,
47+
default="1,0",
48+
help="Comma-separated list of n_threads values to benchmark (0=auto).",
49+
)
50+
parser.add_argument("--csv", type=str, default=None, help="Optional CSV output path.")
51+
return parser.parse_args()
52+
53+
54+
@dataclass(frozen=True)
55+
class Mesh:
56+
verts: np.ndarray
57+
normals: np.ndarray
58+
faces: np.ndarray
59+
60+
61+
def build_sphere_mesh(n_vertices: int, seed: int) -> Mesh:
62+
from scipy.spatial import ConvexHull
63+
64+
rng = np.random.default_rng(seed)
65+
raw = rng.standard_normal((n_vertices, 3))
66+
points = (raw / np.linalg.norm(raw, axis=1, keepdims=True)).astype(np.float64)
67+
hull = ConvexHull(points)
68+
return Mesh(verts=points, normals=points.copy(), faces=hull.simplices.astype(np.int64))
69+
70+
71+
def time_call(fn, repeats: int, warmup: int) -> list[float]:
72+
for _ in range(warmup):
73+
fn()
74+
times = []
75+
for _ in range(repeats):
76+
t0 = perf_counter()
77+
fn()
78+
times.append(perf_counter() - t0)
79+
return times
80+
81+
82+
def import_reference():
83+
here = os.path.dirname(os.path.abspath(__file__))
84+
if here not in sys.path:
85+
sys.path.insert(0, here)
86+
try:
87+
import nifty # noqa: F401
88+
except ImportError:
89+
return None
90+
try:
91+
from _mesh_smoothing_reference import smooth_mesh as ref
92+
except ImportError:
93+
return None
94+
return ref
95+
96+
97+
def main() -> int:
98+
args = parse_args()
99+
thread_values = [int(t) for t in args.threads.split(",") if t.strip()]
100+
101+
try:
102+
mesh = build_sphere_mesh(args.n_vertices, args.seed)
103+
except ImportError:
104+
print("scipy is required to build the benchmark mesh.")
105+
return 1
106+
107+
n_faces = mesh.faces.shape[0]
108+
print(
109+
f"Mesh: {args.n_vertices} vertices, {n_faces} faces "
110+
f"({args.iterations} smoothing iterations, {args.repeats} repeats)"
111+
)
112+
113+
rows: list[dict] = []
114+
115+
# bioimage_cpp variants (one per --threads value).
116+
for n_threads in thread_values:
117+
label = f"bioimage_cpp[n_threads={n_threads}]"
118+
119+
def run(verts=mesh.verts, normals=mesh.normals, faces=mesh.faces, n=n_threads):
120+
bic.utils.smooth_mesh(verts, normals, faces, iterations=args.iterations, n_threads=n)
121+
122+
times = time_call(run, args.repeats, args.warmup)
123+
rows.append(
124+
{"impl": label, "median_s": median(times), "best_s": min(times)}
125+
)
126+
127+
# Reference (single-threaded by definition).
128+
reference = import_reference()
129+
if reference is not None:
130+
def run_ref():
131+
reference(mesh.verts, mesh.normals, mesh.faces, args.iterations)
132+
133+
times = time_call(run_ref, args.repeats, args.warmup)
134+
rows.append(
135+
{"impl": "reference[nifty]", "median_s": median(times), "best_s": min(times)}
136+
)
137+
else:
138+
print("(reference skipped: nifty not installed)")
139+
140+
# Correctness check against the reference at iterations=1 (only point of
141+
# exact agreement; see module docstring).
142+
if reference is not None:
143+
ref_v, ref_n = reference(mesh.verts, mesh.normals, mesh.faces, 1)
144+
ours_v, ours_n = bic.utils.smooth_mesh(mesh.verts, mesh.normals, mesh.faces, iterations=1)
145+
v_max_diff = float(np.max(np.abs(ours_v - ref_v)))
146+
n_max_diff = float(np.max(np.abs(ours_n - ref_n)))
147+
print(f"Correctness vs reference (iterations=1): "
148+
f"max|Δverts|={v_max_diff:.2e}, max|Δnormals|={n_max_diff:.2e}")
149+
150+
# Print result table.
151+
print()
152+
print(f"{'impl':<32} {'median (s)':>12} {'best (s)':>12} {'speedup':>10}")
153+
baseline = None
154+
for row in rows:
155+
if row["impl"] == "reference[nifty]":
156+
baseline = row["median_s"]
157+
break
158+
for row in rows:
159+
speedup = baseline / row["median_s"] if baseline else float("nan")
160+
print(
161+
f"{row['impl']:<32} {row['median_s']:>12.4f} {row['best_s']:>12.4f} "
162+
f"{speedup:>10.2f}x"
163+
)
164+
165+
if args.csv:
166+
with open(args.csv, "w", newline="") as f:
167+
writer = csv.DictWriter(f, fieldnames=["impl", "median_s", "best_s"])
168+
writer.writeheader()
169+
for row in rows:
170+
writer.writerow(row)
171+
print(f"\nWrote {args.csv}")
172+
173+
return 0
174+
175+
176+
if __name__ == "__main__":
177+
raise SystemExit(main())

0 commit comments

Comments
 (0)