Skip to content

Commit b27ea71

Browse files
Implement mesh smoothing
1 parent 2a921f0 commit b27ea71

5 files changed

Lines changed: 816 additions & 0 deletions

File tree

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())
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
#include "bioimage_cpp/detail/threading.hxx"
5+
6+
#include <algorithm>
7+
#include <cstddef>
8+
#include <cstdint>
9+
#include <numeric>
10+
#include <stdexcept>
11+
#include <string>
12+
#include <utility>
13+
#include <vector>
14+
15+
namespace bioimage_cpp {
16+
17+
namespace detail::mesh_smoothing {
18+
19+
template <class I>
20+
struct Adjacency {
21+
std::vector<std::size_t> offsets;
22+
std::vector<I> neighbours;
23+
};
24+
25+
template <class I>
26+
Adjacency<I> build_adjacency(const ConstArrayView<I> &faces, std::ptrdiff_t n_verts) {
27+
const std::ptrdiff_t n_faces = faces.shape[0];
28+
29+
std::vector<std::pair<I, I>> edges;
30+
edges.reserve(static_cast<std::size_t>(n_faces) * 3);
31+
32+
const auto signed_n_verts = static_cast<std::int64_t>(n_verts);
33+
auto check_index = [&](const I value) {
34+
const auto signed_value = static_cast<std::int64_t>(value);
35+
if (signed_value < 0 || signed_value >= signed_n_verts) {
36+
throw std::invalid_argument(
37+
"faces contains index " + std::to_string(signed_value)
38+
+ " out of range [0, " + std::to_string(n_verts) + ")"
39+
);
40+
}
41+
};
42+
43+
for (std::ptrdiff_t f = 0; f < n_faces; ++f) {
44+
const I a = faces.data[f * 3 + 0];
45+
const I b = faces.data[f * 3 + 1];
46+
const I c = faces.data[f * 3 + 2];
47+
check_index(a);
48+
check_index(b);
49+
check_index(c);
50+
edges.emplace_back(std::min(a, b), std::max(a, b));
51+
edges.emplace_back(std::min(b, c), std::max(b, c));
52+
edges.emplace_back(std::min(a, c), std::max(a, c));
53+
}
54+
55+
std::sort(edges.begin(), edges.end());
56+
edges.erase(std::unique(edges.begin(), edges.end()), edges.end());
57+
58+
std::vector<std::size_t> offsets(static_cast<std::size_t>(n_verts) + 1, 0);
59+
for (const auto &edge : edges) {
60+
++offsets[static_cast<std::size_t>(edge.first) + 1];
61+
++offsets[static_cast<std::size_t>(edge.second) + 1];
62+
}
63+
for (std::size_t i = 1; i < offsets.size(); ++i) {
64+
offsets[i] += offsets[i - 1];
65+
}
66+
67+
std::vector<I> neighbours(offsets.back());
68+
std::vector<std::size_t> insert_pos(offsets.begin(), offsets.end() - 1);
69+
for (const auto &edge : edges) {
70+
neighbours[insert_pos[static_cast<std::size_t>(edge.first)]++] = edge.second;
71+
neighbours[insert_pos[static_cast<std::size_t>(edge.second)]++] = edge.first;
72+
}
73+
74+
return Adjacency<I>{std::move(offsets), std::move(neighbours)};
75+
}
76+
77+
} // namespace detail::mesh_smoothing
78+
79+
// Laplacian smoothing of a triangular mesh: each vertex (and corresponding
80+
// normal) is replaced by the mean of itself and its 1-ring neighbours,
81+
// repeated for `iterations` passes. `verts` and `normals` are (n_verts, dim);
82+
// `faces` is (n_faces, 3) with values in [0, n_verts). Adjacency is built once
83+
// and reused across iterations. For `iterations == 0`, inputs are copied to
84+
// outputs unchanged.
85+
template <class V, class I>
86+
void smooth_mesh(
87+
const ConstArrayView<V> &verts,
88+
const ConstArrayView<V> &normals,
89+
const ConstArrayView<I> &faces,
90+
std::size_t iterations,
91+
int n_threads,
92+
const ArrayView<V> &out_verts,
93+
const ArrayView<V> &out_normals
94+
) {
95+
if (verts.shape.size() != 2) {
96+
throw std::invalid_argument(
97+
"verts must have ndim=2, got ndim=" + std::to_string(verts.shape.size())
98+
);
99+
}
100+
if (normals.shape != verts.shape) {
101+
throw std::invalid_argument("normals shape must match verts shape");
102+
}
103+
if (faces.shape.size() != 2 || faces.shape[1] != 3) {
104+
throw std::invalid_argument("faces must have shape (n_faces, 3)");
105+
}
106+
if (out_verts.shape != verts.shape) {
107+
throw std::invalid_argument("out_verts shape must match verts shape");
108+
}
109+
if (out_normals.shape != verts.shape) {
110+
throw std::invalid_argument("out_normals shape must match verts shape");
111+
}
112+
113+
const std::ptrdiff_t n_verts = verts.shape[0];
114+
const std::ptrdiff_t dim = verts.shape[1];
115+
const std::size_t n_total = static_cast<std::size_t>(n_verts) * static_cast<std::size_t>(dim);
116+
117+
if (iterations == 0) {
118+
std::copy(verts.data, verts.data + n_total, out_verts.data);
119+
std::copy(normals.data, normals.data + n_total, out_normals.data);
120+
return;
121+
}
122+
123+
const auto adjacency = detail::mesh_smoothing::build_adjacency<I>(faces, n_verts);
124+
125+
std::vector<V> scratch_verts(n_total);
126+
std::vector<V> scratch_normals(n_total);
127+
128+
// Choose initial write target so the final write lands in out_*.
129+
// iter 0 reads from `verts`/`normals` (input); subsequent iters alternate
130+
// between out_* and scratch. The last write must be to out_*, so when
131+
// iterations is even, iter 0 writes scratch (and iter 1 writes out).
132+
V *buf_a_verts = out_verts.data;
133+
V *buf_a_normals = out_normals.data;
134+
V *buf_b_verts = scratch_verts.data();
135+
V *buf_b_normals = scratch_normals.data();
136+
if (iterations % 2 == 0) {
137+
std::swap(buf_a_verts, buf_b_verts);
138+
std::swap(buf_a_normals, buf_b_normals);
139+
}
140+
141+
const auto &offsets = adjacency.offsets;
142+
const auto &neighbours = adjacency.neighbours;
143+
const std::size_t threads = detail::normalize_thread_count(
144+
n_threads < 0 ? 0 : static_cast<std::size_t>(n_threads),
145+
static_cast<std::size_t>(n_verts)
146+
);
147+
148+
auto smooth_pass = [&](const V *src_verts, const V *src_normals, V *dst_verts, V *dst_normals) {
149+
detail::parallel_for_chunks(
150+
threads,
151+
static_cast<std::size_t>(n_verts),
152+
[&](std::size_t, std::size_t begin, std::size_t end) {
153+
for (std::size_t v = begin; v < end; ++v) {
154+
const std::size_t beg = offsets[v];
155+
const std::size_t fin = offsets[v + 1];
156+
const auto count = static_cast<V>(fin - beg + 1);
157+
const std::size_t row = v * static_cast<std::size_t>(dim);
158+
for (std::ptrdiff_t d = 0; d < dim; ++d) {
159+
V sum_v = src_verts[row + static_cast<std::size_t>(d)];
160+
V sum_n = src_normals[row + static_cast<std::size_t>(d)];
161+
for (std::size_t k = beg; k < fin; ++k) {
162+
const std::size_t nbr_row =
163+
static_cast<std::size_t>(neighbours[k]) * static_cast<std::size_t>(dim);
164+
sum_v += src_verts[nbr_row + static_cast<std::size_t>(d)];
165+
sum_n += src_normals[nbr_row + static_cast<std::size_t>(d)];
166+
}
167+
dst_verts[row + static_cast<std::size_t>(d)] = sum_v / count;
168+
dst_normals[row + static_cast<std::size_t>(d)] = sum_n / count;
169+
}
170+
}
171+
}
172+
);
173+
};
174+
175+
// First iteration: read from inputs, write to buf_a.
176+
smooth_pass(verts.data, normals.data, buf_a_verts, buf_a_normals);
177+
178+
for (std::size_t it = 1; it < iterations; ++it) {
179+
const V *src_verts = (it % 2 == 1) ? buf_a_verts : buf_b_verts;
180+
const V *src_normals = (it % 2 == 1) ? buf_a_normals : buf_b_normals;
181+
V *dst_verts = (it % 2 == 1) ? buf_b_verts : buf_a_verts;
182+
V *dst_normals = (it % 2 == 1) ? buf_b_normals : buf_a_normals;
183+
smooth_pass(src_verts, src_normals, dst_verts, dst_normals);
184+
}
185+
}
186+
187+
} // namespace bioimage_cpp

0 commit comments

Comments
 (0)