reverse3d 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reverse3d/__init__.py +39 -0
- reverse3d/__main__.py +4 -0
- reverse3d/_about.py +15 -0
- reverse3d/_version.py +1 -0
- reverse3d/agent/__init__.py +5 -0
- reverse3d/agent/compact.py +110 -0
- reverse3d/agent/mcp_server.py +171 -0
- reverse3d/agent/prompts.py +173 -0
- reverse3d/agent/skill/SKILL.md +53 -0
- reverse3d/agent/tools.py +811 -0
- reverse3d/autopilot.py +355 -0
- reverse3d/cli.py +1054 -0
- reverse3d/config.py +167 -0
- reverse3d/doctor.py +113 -0
- reverse3d/errors.py +83 -0
- reverse3d/hardware.py +115 -0
- reverse3d/imaging.py +79 -0
- reverse3d/logging_utils.py +41 -0
- reverse3d/mesh_io.py +152 -0
- reverse3d/offline.py +313 -0
- reverse3d/profiles.py +33 -0
- reverse3d/py.typed +0 -0
- reverse3d/recipes.py +394 -0
- reverse3d/reconstruction/__init__.py +22 -0
- reverse3d/reconstruction/depth_estimation.py +263 -0
- reverse3d/reconstruction/photogrammetry.py +460 -0
- reverse3d/reconstruction/pointcloud.py +120 -0
- reverse3d/rendering/__init__.py +10 -0
- reverse3d/rendering/_blender_job.py +284 -0
- reverse3d/rendering/blender_render.py +215 -0
- reverse3d/rendering/preview.py +80 -0
- reverse3d/reverse_eng/__init__.py +33 -0
- reverse3d/reverse_eng/geometry_fit.py +478 -0
- reverse3d/reverse_eng/measure.py +87 -0
- reverse3d/reverse_eng/mesh_cleaner.py +304 -0
- reverse3d/reverse_eng/parametric.py +154 -0
- reverse3d/security.py +294 -0
- reverse3d/setup_agents.py +517 -0
- reverse3d/userconfig.py +156 -0
- reverse3d/vision/__init__.py +12 -0
- reverse3d/vision/feature_matcher.py +272 -0
- reverse3d/vision/mask_extractor.py +352 -0
- reverse3d/watch.py +238 -0
- reverse3d-0.4.0.dist-info/METADATA +650 -0
- reverse3d-0.4.0.dist-info/RECORD +50 -0
- reverse3d-0.4.0.dist-info/WHEEL +5 -0
- reverse3d-0.4.0.dist-info/entry_points.txt +2 -0
- reverse3d-0.4.0.dist-info/licenses/LICENSE +202 -0
- reverse3d-0.4.0.dist-info/licenses/NOTICE +27 -0
- reverse3d-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Primitive fitting (planes, spheres, cylinders) on point clouds with RANSAC.
|
|
2
|
+
|
|
3
|
+
Pure NumPy implementation — no Open3D required — so it is deterministic (seeded),
|
|
4
|
+
portable and testable. Each fit is a RANSAC hypothesis search followed by a
|
|
5
|
+
least-squares refinement on the inliers. :func:`detect_primitives` extracts
|
|
6
|
+
several primitives sequentially, removing the inliers of each accepted one.
|
|
7
|
+
|
|
8
|
+
Normals, when available (sampled from a mesh), are used both to generate
|
|
9
|
+
cylinder hypotheses and to reject inliers whose orientation disagrees.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from reverse3d.config import Settings
|
|
22
|
+
from reverse3d.errors import InputValidationError
|
|
23
|
+
from reverse3d.mesh_io import load_geometry, sample_points
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
PrimitiveKind = Literal["plane", "sphere", "cylinder"]
|
|
28
|
+
ALL_KINDS: tuple[PrimitiveKind, ...] = ("plane", "cylinder", "sphere")
|
|
29
|
+
_EPS = 1e-12
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PrimitiveFit:
|
|
34
|
+
"""A fitted primitive. ``params`` depend on ``kind``:
|
|
35
|
+
|
|
36
|
+
* plane: ``normal``, ``d`` (n·x + d = 0), ``centroid``, ``extent_2d``
|
|
37
|
+
* sphere: ``center``, ``radius``
|
|
38
|
+
* cylinder: ``axis_point``, ``axis_direction``, ``radius``, ``height``
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
kind: PrimitiveKind
|
|
42
|
+
params: dict[str, object]
|
|
43
|
+
inliers: int
|
|
44
|
+
inlier_ratio: float
|
|
45
|
+
rmse: float
|
|
46
|
+
inlier_indices: np.ndarray = field(repr=False, default_factory=lambda: np.empty(0, int))
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, object]:
|
|
49
|
+
data = asdict(self)
|
|
50
|
+
data.pop("inlier_indices", None)
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class FitReport:
|
|
56
|
+
"""Result of :func:`detect_primitives` / :func:`fit_primitives_file`."""
|
|
57
|
+
|
|
58
|
+
point_count: int
|
|
59
|
+
threshold: float
|
|
60
|
+
primitives: list[PrimitiveFit]
|
|
61
|
+
unexplained_ratio: float
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, object]:
|
|
64
|
+
return {
|
|
65
|
+
"point_count": self.point_count,
|
|
66
|
+
"threshold": self.threshold,
|
|
67
|
+
"primitives": [p.to_dict() for p in self.primitives],
|
|
68
|
+
"unexplained_ratio": self.unexplained_ratio,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _r(v: np.ndarray | float, nd: int = 6) -> list[float] | float:
|
|
73
|
+
if np.ndim(v) == 0:
|
|
74
|
+
return round(float(v), nd)
|
|
75
|
+
return [round(float(x), nd) for x in np.ravel(v)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unit(v: np.ndarray) -> np.ndarray:
|
|
79
|
+
n = np.linalg.norm(v)
|
|
80
|
+
return v / n if n > _EPS else v
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _normal_ok(
|
|
84
|
+
normals: np.ndarray | None, expected: np.ndarray, cos_min: float
|
|
85
|
+
) -> np.ndarray | bool:
|
|
86
|
+
if normals is None:
|
|
87
|
+
return True
|
|
88
|
+
return np.abs(np.einsum("ij,ij->i", normals, expected)) >= cos_min
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ─── plane ────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _refine_plane(points: np.ndarray) -> tuple[np.ndarray, float, np.ndarray]:
|
|
95
|
+
centroid = points.mean(axis=0)
|
|
96
|
+
_, _, vt = np.linalg.svd(points - centroid, full_matrices=False)
|
|
97
|
+
normal = vt[2]
|
|
98
|
+
return normal, -float(normal @ centroid), centroid
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fit_plane(
|
|
102
|
+
points: np.ndarray,
|
|
103
|
+
normals: np.ndarray | None = None,
|
|
104
|
+
*,
|
|
105
|
+
threshold: float,
|
|
106
|
+
iterations: int = 500,
|
|
107
|
+
normal_angle_deg: float = 30.0,
|
|
108
|
+
rng: np.random.Generator | None = None,
|
|
109
|
+
) -> PrimitiveFit | None:
|
|
110
|
+
"""RANSAC plane fit. Returns ``None`` if fewer than 3 points."""
|
|
111
|
+
n = len(points)
|
|
112
|
+
if n < 3:
|
|
113
|
+
return None
|
|
114
|
+
rng = rng or np.random.default_rng(0)
|
|
115
|
+
cos_min = float(np.cos(np.radians(normal_angle_deg)))
|
|
116
|
+
best_mask: np.ndarray | None = None
|
|
117
|
+
for _ in range(iterations):
|
|
118
|
+
p0, p1, p2 = points[rng.choice(n, 3, replace=False)]
|
|
119
|
+
normal = np.cross(p1 - p0, p2 - p0)
|
|
120
|
+
if np.linalg.norm(normal) < _EPS:
|
|
121
|
+
continue
|
|
122
|
+
normal = _unit(normal)
|
|
123
|
+
dist = np.abs((points - p0) @ normal)
|
|
124
|
+
mask = dist < threshold
|
|
125
|
+
mask &= _normal_ok(normals, np.broadcast_to(normal, points.shape), cos_min)
|
|
126
|
+
if best_mask is None or mask.sum() > best_mask.sum():
|
|
127
|
+
best_mask = mask
|
|
128
|
+
if best_mask is None or best_mask.sum() < 3:
|
|
129
|
+
return None
|
|
130
|
+
normal, d, centroid = _refine_plane(points[best_mask])
|
|
131
|
+
dist = np.abs(points @ normal + d)
|
|
132
|
+
mask = dist < threshold
|
|
133
|
+
mask &= _normal_ok(normals, np.broadcast_to(normal, points.shape), cos_min)
|
|
134
|
+
if mask.sum() < 3:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
inl = points[mask]
|
|
138
|
+
# In-plane extent: project on two orthonormal in-plane axes.
|
|
139
|
+
u, v = plane_basis(normal)
|
|
140
|
+
local = np.stack([(inl - centroid) @ u, (inl - centroid) @ v], axis=1)
|
|
141
|
+
extent = local.max(axis=0) - local.min(axis=0)
|
|
142
|
+
return PrimitiveFit(
|
|
143
|
+
kind="plane",
|
|
144
|
+
params={
|
|
145
|
+
"normal": _r(normal),
|
|
146
|
+
"d": _r(d),
|
|
147
|
+
"centroid": _r(centroid),
|
|
148
|
+
"extent_2d": _r(extent),
|
|
149
|
+
},
|
|
150
|
+
inliers=int(mask.sum()),
|
|
151
|
+
inlier_ratio=round(float(mask.mean()), 4),
|
|
152
|
+
rmse=round(float(np.sqrt(np.mean(dist[mask] ** 2))), 6),
|
|
153
|
+
inlier_indices=np.flatnonzero(mask),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ─── sphere ───────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _algebraic_sphere(points: np.ndarray) -> tuple[np.ndarray, float] | None:
|
|
161
|
+
"""Least-squares sphere: x²+y²+z² + Dx + Ey + Fz + G = 0."""
|
|
162
|
+
a = np.hstack([points, np.ones((len(points), 1))])
|
|
163
|
+
b = -(points**2).sum(axis=1)
|
|
164
|
+
try:
|
|
165
|
+
sol, *_ = np.linalg.lstsq(a, b, rcond=None)
|
|
166
|
+
except np.linalg.LinAlgError:
|
|
167
|
+
return None
|
|
168
|
+
center = -0.5 * sol[:3]
|
|
169
|
+
r2 = float(center @ center - sol[3])
|
|
170
|
+
if r2 <= 0:
|
|
171
|
+
return None
|
|
172
|
+
return center, float(np.sqrt(r2))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def fit_sphere(
|
|
176
|
+
points: np.ndarray,
|
|
177
|
+
normals: np.ndarray | None = None,
|
|
178
|
+
*,
|
|
179
|
+
threshold: float,
|
|
180
|
+
iterations: int = 500,
|
|
181
|
+
max_radius: float | None = None,
|
|
182
|
+
normal_angle_deg: float = 30.0,
|
|
183
|
+
rng: np.random.Generator | None = None,
|
|
184
|
+
) -> PrimitiveFit | None:
|
|
185
|
+
"""RANSAC sphere fit (4-point minimal sample)."""
|
|
186
|
+
n = len(points)
|
|
187
|
+
if n < 4:
|
|
188
|
+
return None
|
|
189
|
+
rng = rng or np.random.default_rng(0)
|
|
190
|
+
cos_min = float(np.cos(np.radians(normal_angle_deg)))
|
|
191
|
+
|
|
192
|
+
def evaluate(center: np.ndarray, radius: float) -> tuple[np.ndarray, np.ndarray]:
|
|
193
|
+
offsets = points - center
|
|
194
|
+
norms = np.linalg.norm(offsets, axis=1)
|
|
195
|
+
dist = np.abs(norms - radius)
|
|
196
|
+
mask = dist < threshold
|
|
197
|
+
if normals is not None:
|
|
198
|
+
radial = offsets / np.maximum(norms, _EPS)[:, None]
|
|
199
|
+
mask &= _normal_ok(normals, radial, cos_min)
|
|
200
|
+
return mask, dist
|
|
201
|
+
|
|
202
|
+
best: tuple[np.ndarray, float] | None = None
|
|
203
|
+
best_count = 0
|
|
204
|
+
for _ in range(iterations):
|
|
205
|
+
sample = _algebraic_sphere(points[rng.choice(n, 4, replace=False)])
|
|
206
|
+
if sample is None:
|
|
207
|
+
continue
|
|
208
|
+
center, radius = sample
|
|
209
|
+
if max_radius is not None and radius > max_radius:
|
|
210
|
+
continue
|
|
211
|
+
mask, _ = evaluate(center, radius)
|
|
212
|
+
if mask.sum() > best_count:
|
|
213
|
+
best, best_count = sample, int(mask.sum())
|
|
214
|
+
if best is None or best_count < 4:
|
|
215
|
+
return None
|
|
216
|
+
mask, _ = evaluate(*best)
|
|
217
|
+
refined = _algebraic_sphere(points[mask])
|
|
218
|
+
if refined is not None:
|
|
219
|
+
mask, dist = evaluate(*refined)
|
|
220
|
+
best = refined
|
|
221
|
+
else:
|
|
222
|
+
_, dist = evaluate(*best)
|
|
223
|
+
if mask.sum() < 4:
|
|
224
|
+
return None
|
|
225
|
+
center, radius = best
|
|
226
|
+
return PrimitiveFit(
|
|
227
|
+
kind="sphere",
|
|
228
|
+
params={"center": _r(center), "radius": _r(radius)},
|
|
229
|
+
inliers=int(mask.sum()),
|
|
230
|
+
inlier_ratio=round(float(mask.mean()), 4),
|
|
231
|
+
rmse=round(float(np.sqrt(np.mean(dist[mask] ** 2))), 6),
|
|
232
|
+
inlier_indices=np.flatnonzero(mask),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─── cylinder ─────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def plane_basis(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
240
|
+
"""Deterministic orthonormal ``(u, v)`` spanning the plane orthogonal to ``axis``."""
|
|
241
|
+
axis = np.asarray(axis, dtype=np.float64)
|
|
242
|
+
helper = np.array([1.0, 0.0, 0.0]) if abs(axis[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
|
243
|
+
u = _unit(np.cross(axis, helper))
|
|
244
|
+
return u, np.cross(axis, u)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _fit_circle_2d(xy: np.ndarray) -> tuple[np.ndarray, float] | None:
|
|
248
|
+
"""Kåsa algebraic circle fit."""
|
|
249
|
+
a = np.hstack([xy, np.ones((len(xy), 1))])
|
|
250
|
+
b = -(xy**2).sum(axis=1)
|
|
251
|
+
try:
|
|
252
|
+
sol, *_ = np.linalg.lstsq(a, b, rcond=None)
|
|
253
|
+
except np.linalg.LinAlgError:
|
|
254
|
+
return None
|
|
255
|
+
center = -0.5 * sol[:2]
|
|
256
|
+
r2 = float(center @ center - sol[2])
|
|
257
|
+
return (center, float(np.sqrt(r2))) if r2 > 0 else None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def fit_cylinder(
|
|
261
|
+
points: np.ndarray,
|
|
262
|
+
normals: np.ndarray | None,
|
|
263
|
+
*,
|
|
264
|
+
threshold: float,
|
|
265
|
+
iterations: int = 500,
|
|
266
|
+
max_radius: float | None = None,
|
|
267
|
+
normal_angle_deg: float = 30.0,
|
|
268
|
+
rng: np.random.Generator | None = None,
|
|
269
|
+
) -> PrimitiveFit | None:
|
|
270
|
+
"""RANSAC cylinder fit. Requires normals (axis = n1 × n2 hypothesis)."""
|
|
271
|
+
if normals is None:
|
|
272
|
+
return None
|
|
273
|
+
n = len(points)
|
|
274
|
+
if n < 6:
|
|
275
|
+
return None
|
|
276
|
+
rng = rng or np.random.default_rng(0)
|
|
277
|
+
cos_min = float(np.cos(np.radians(normal_angle_deg)))
|
|
278
|
+
|
|
279
|
+
def evaluate(
|
|
280
|
+
axis: np.ndarray, point: np.ndarray, radius: float
|
|
281
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
282
|
+
rel = points - point
|
|
283
|
+
along = rel @ axis
|
|
284
|
+
radial_vec = rel - np.outer(along, axis)
|
|
285
|
+
radial = np.linalg.norm(radial_vec, axis=1)
|
|
286
|
+
dist = np.abs(radial - radius)
|
|
287
|
+
mask = dist < threshold
|
|
288
|
+
mask &= _normal_ok(normals, radial_vec / np.maximum(radial, _EPS)[:, None], cos_min)
|
|
289
|
+
return mask, dist
|
|
290
|
+
|
|
291
|
+
def hypothesis(idx: np.ndarray, axis: np.ndarray) -> tuple[np.ndarray, float] | None:
|
|
292
|
+
u, v = plane_basis(axis)
|
|
293
|
+
xy = np.stack([points[idx] @ u, points[idx] @ v], axis=1)
|
|
294
|
+
circle = _fit_circle_2d(xy)
|
|
295
|
+
if circle is None:
|
|
296
|
+
return None
|
|
297
|
+
(cx, cy), radius = circle
|
|
298
|
+
return cx * u + cy * v, radius
|
|
299
|
+
|
|
300
|
+
best: tuple[np.ndarray, np.ndarray, float] | None = None
|
|
301
|
+
best_count = 0
|
|
302
|
+
for _ in range(iterations):
|
|
303
|
+
i, j, k = rng.choice(n, 3, replace=False)
|
|
304
|
+
axis = np.cross(normals[i], normals[j])
|
|
305
|
+
if np.linalg.norm(axis) < 0.05: # nearly parallel normals: no axis information
|
|
306
|
+
continue
|
|
307
|
+
axis = _unit(axis)
|
|
308
|
+
hyp = hypothesis(np.array([i, j, k]), axis)
|
|
309
|
+
if hyp is None:
|
|
310
|
+
continue
|
|
311
|
+
point, radius = hyp
|
|
312
|
+
if max_radius is not None and radius > max_radius:
|
|
313
|
+
continue
|
|
314
|
+
mask, _ = evaluate(axis, point, radius)
|
|
315
|
+
if mask.sum() > best_count:
|
|
316
|
+
best, best_count = (axis, point, radius), int(mask.sum())
|
|
317
|
+
if best is None or best_count < 6:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
# Refinement: axis = direction least represented in the inlier normals.
|
|
321
|
+
axis, point, radius = best
|
|
322
|
+
mask, _ = evaluate(axis, point, radius)
|
|
323
|
+
scatter = normals[mask].T @ normals[mask]
|
|
324
|
+
_, eigvecs = np.linalg.eigh(scatter)
|
|
325
|
+
refined_axis = _unit(eigvecs[:, 0])
|
|
326
|
+
hyp = hypothesis(np.flatnonzero(mask), refined_axis)
|
|
327
|
+
if hyp is not None:
|
|
328
|
+
new_mask, _ = evaluate(refined_axis, *hyp)
|
|
329
|
+
if new_mask.sum() >= mask.sum():
|
|
330
|
+
axis, (point, radius) = refined_axis, hyp
|
|
331
|
+
mask, dist = evaluate(axis, point, radius)
|
|
332
|
+
if mask.sum() < 6:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
along = (points[mask] - point) @ axis
|
|
336
|
+
lo, hi = float(along.min()), float(along.max())
|
|
337
|
+
base = point + lo * axis
|
|
338
|
+
return PrimitiveFit(
|
|
339
|
+
kind="cylinder",
|
|
340
|
+
params={
|
|
341
|
+
"axis_point": _r(base),
|
|
342
|
+
"axis_direction": _r(axis),
|
|
343
|
+
"radius": _r(radius),
|
|
344
|
+
"height": _r(hi - lo),
|
|
345
|
+
},
|
|
346
|
+
inliers=int(mask.sum()),
|
|
347
|
+
inlier_ratio=round(float(mask.mean()), 4),
|
|
348
|
+
rmse=round(float(np.sqrt(np.mean(dist[mask] ** 2))), 6),
|
|
349
|
+
inlier_indices=np.flatnonzero(mask),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ─── sequential extraction ────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def detect_primitives(
|
|
357
|
+
points: np.ndarray,
|
|
358
|
+
normals: np.ndarray | None = None,
|
|
359
|
+
*,
|
|
360
|
+
kinds: tuple[PrimitiveKind, ...] = ALL_KINDS,
|
|
361
|
+
threshold: float | None = None,
|
|
362
|
+
min_inlier_ratio: float = 0.05,
|
|
363
|
+
max_primitives: int = 8,
|
|
364
|
+
iterations: int = 500,
|
|
365
|
+
seed: int = 0,
|
|
366
|
+
) -> FitReport:
|
|
367
|
+
"""Extract up to ``max_primitives`` primitives, best-first.
|
|
368
|
+
|
|
369
|
+
Each round fits every requested kind on the remaining points and keeps the
|
|
370
|
+
one explaining the most points (ties favour the simpler model: plane <
|
|
371
|
+
cylinder < sphere). Its inliers are then removed.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
threshold: inlier distance; defaults to 1% of the cloud's bbox diagonal.
|
|
375
|
+
min_inlier_ratio: stop when the best candidate explains less than this
|
|
376
|
+
fraction of the *original* points.
|
|
377
|
+
"""
|
|
378
|
+
points = np.asarray(points, dtype=np.float64)
|
|
379
|
+
if points.ndim != 2 or points.shape[1] != 3 or len(points) < 10:
|
|
380
|
+
raise InputValidationError("points must be an (N>=10, 3) array.")
|
|
381
|
+
if normals is not None:
|
|
382
|
+
normals = np.asarray(normals, dtype=np.float64)
|
|
383
|
+
if normals.shape != points.shape:
|
|
384
|
+
raise InputValidationError("normals must have the same shape as points.")
|
|
385
|
+
unknown = set(kinds) - set(ALL_KINDS)
|
|
386
|
+
if unknown or not kinds:
|
|
387
|
+
raise InputValidationError(f"kinds must be a non-empty subset of {ALL_KINDS}.")
|
|
388
|
+
if not 1 <= max_primitives <= 50:
|
|
389
|
+
raise InputValidationError("max_primitives must be in [1, 50].")
|
|
390
|
+
if not 10 <= iterations <= 20_000:
|
|
391
|
+
raise InputValidationError("iterations must be in [10, 20000].")
|
|
392
|
+
|
|
393
|
+
diagonal = float(np.linalg.norm(points.max(axis=0) - points.min(axis=0)))
|
|
394
|
+
threshold = threshold if threshold and threshold > 0 else max(diagonal * 0.01, 1e-9)
|
|
395
|
+
max_radius = diagonal * 2
|
|
396
|
+
rng = np.random.default_rng(seed)
|
|
397
|
+
total = len(points)
|
|
398
|
+
remaining = np.arange(total)
|
|
399
|
+
found: list[PrimitiveFit] = []
|
|
400
|
+
preference = {"plane": 0, "cylinder": 1, "sphere": 2}
|
|
401
|
+
|
|
402
|
+
while len(found) < max_primitives and len(remaining) >= 10:
|
|
403
|
+
pts = points[remaining]
|
|
404
|
+
nrm = normals[remaining] if normals is not None else None
|
|
405
|
+
candidates: list[PrimitiveFit] = []
|
|
406
|
+
for kind in kinds:
|
|
407
|
+
fit: PrimitiveFit | None
|
|
408
|
+
if kind == "plane":
|
|
409
|
+
fit = fit_plane(pts, nrm, threshold=threshold, iterations=iterations, rng=rng)
|
|
410
|
+
elif kind == "sphere":
|
|
411
|
+
fit = fit_sphere(
|
|
412
|
+
pts,
|
|
413
|
+
nrm,
|
|
414
|
+
threshold=threshold,
|
|
415
|
+
iterations=iterations,
|
|
416
|
+
max_radius=max_radius,
|
|
417
|
+
rng=rng,
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
fit = fit_cylinder(
|
|
421
|
+
pts,
|
|
422
|
+
nrm,
|
|
423
|
+
threshold=threshold,
|
|
424
|
+
iterations=iterations,
|
|
425
|
+
max_radius=max_radius,
|
|
426
|
+
rng=rng,
|
|
427
|
+
)
|
|
428
|
+
if fit is not None:
|
|
429
|
+
candidates.append(fit)
|
|
430
|
+
if not candidates:
|
|
431
|
+
break
|
|
432
|
+
best = max(candidates, key=lambda f: (f.inliers, -preference[f.kind]))
|
|
433
|
+
if best.inliers < min_inlier_ratio * total:
|
|
434
|
+
break
|
|
435
|
+
# Report inliers relative to the full cloud and map indices back.
|
|
436
|
+
best.inlier_indices = remaining[best.inlier_indices]
|
|
437
|
+
best.inlier_ratio = round(best.inliers / total, 4)
|
|
438
|
+
found.append(best)
|
|
439
|
+
keep = np.ones(len(remaining), dtype=bool)
|
|
440
|
+
keep[np.searchsorted(remaining, best.inlier_indices)] = False
|
|
441
|
+
remaining = remaining[keep]
|
|
442
|
+
|
|
443
|
+
explained = total - len(remaining)
|
|
444
|
+
return FitReport(
|
|
445
|
+
point_count=total,
|
|
446
|
+
threshold=round(threshold, 9),
|
|
447
|
+
primitives=found,
|
|
448
|
+
unexplained_ratio=round(1 - explained / total, 4),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def fit_primitives_file(
|
|
453
|
+
path: str | Path,
|
|
454
|
+
settings: Settings,
|
|
455
|
+
*,
|
|
456
|
+
kinds: tuple[PrimitiveKind, ...] = ALL_KINDS,
|
|
457
|
+
sample_count: int = 20_000,
|
|
458
|
+
threshold: float | None = None,
|
|
459
|
+
max_primitives: int = 8,
|
|
460
|
+
min_inlier_ratio: float = 0.05,
|
|
461
|
+
seed: int = 0,
|
|
462
|
+
) -> FitReport:
|
|
463
|
+
"""Load a mesh / point cloud, sample points and run :func:`detect_primitives`."""
|
|
464
|
+
if not 100 <= sample_count <= 500_000:
|
|
465
|
+
raise InputValidationError("sample_count must be in [100, 500000].")
|
|
466
|
+
geometry = load_geometry(path, settings)
|
|
467
|
+
points, normals = sample_points(geometry, sample_count, seed=seed)
|
|
468
|
+
report = detect_primitives(
|
|
469
|
+
points,
|
|
470
|
+
normals,
|
|
471
|
+
kinds=kinds,
|
|
472
|
+
threshold=threshold,
|
|
473
|
+
max_primitives=max_primitives,
|
|
474
|
+
min_inlier_ratio=min_inlier_ratio,
|
|
475
|
+
seed=seed,
|
|
476
|
+
)
|
|
477
|
+
logger.info("Detected %d primitive(s) in %s", len(report.primitives), Path(path).name)
|
|
478
|
+
return report
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Mesh statistics and principal dimensions (cotas)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import trimesh
|
|
10
|
+
|
|
11
|
+
from reverse3d.config import Settings
|
|
12
|
+
from reverse3d.mesh_io import load_mesh
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _r(values: np.ndarray | list[float], nd: int = 6) -> list[float]:
|
|
16
|
+
return [round(float(v), nd) for v in values]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def boundary_edge_count(mesh: trimesh.Trimesh) -> int:
|
|
20
|
+
"""Number of edges used by a single face (open boundary edges)."""
|
|
21
|
+
if len(mesh.faces) == 0:
|
|
22
|
+
return 0
|
|
23
|
+
groups = trimesh.grouping.group_rows(mesh.edges_sorted, require_count=1)
|
|
24
|
+
return len(groups)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class MeshStats:
|
|
29
|
+
"""Topology and dimension summary of a triangle mesh.
|
|
30
|
+
|
|
31
|
+
Lengths are in the mesh's own units (whatever the source file used).
|
|
32
|
+
``obb_extents`` are the sorted (largest first) side lengths of the minimum
|
|
33
|
+
oriented bounding box: the most robust "overall dimensions" of a part.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
vertices: int
|
|
37
|
+
faces: int
|
|
38
|
+
components: int
|
|
39
|
+
watertight: bool
|
|
40
|
+
winding_consistent: bool
|
|
41
|
+
boundary_edges: int
|
|
42
|
+
euler_number: int
|
|
43
|
+
aabb_min: list[float]
|
|
44
|
+
aabb_max: list[float]
|
|
45
|
+
aabb_extents: list[float]
|
|
46
|
+
obb_extents: list[float]
|
|
47
|
+
surface_area: float
|
|
48
|
+
volume: float | None
|
|
49
|
+
center_mass: list[float] | None
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, object]:
|
|
52
|
+
return asdict(self)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compute_stats(mesh: trimesh.Trimesh) -> MeshStats:
|
|
56
|
+
"""Compute :class:`MeshStats` for ``mesh`` (no file I/O)."""
|
|
57
|
+
if len(mesh.faces) == 0:
|
|
58
|
+
raise ValueError("Mesh has no faces.")
|
|
59
|
+
watertight = bool(mesh.is_watertight)
|
|
60
|
+
try:
|
|
61
|
+
_, obb_extents = trimesh.bounds.oriented_bounds(mesh)
|
|
62
|
+
except Exception: # degenerate (flat/collinear) input
|
|
63
|
+
obb_extents = mesh.extents
|
|
64
|
+
components = len(
|
|
65
|
+
trimesh.graph.connected_components(mesh.face_adjacency, nodes=np.arange(len(mesh.faces)))
|
|
66
|
+
)
|
|
67
|
+
return MeshStats(
|
|
68
|
+
vertices=len(mesh.vertices),
|
|
69
|
+
faces=len(mesh.faces),
|
|
70
|
+
components=components,
|
|
71
|
+
watertight=watertight,
|
|
72
|
+
winding_consistent=bool(mesh.is_winding_consistent),
|
|
73
|
+
boundary_edges=boundary_edge_count(mesh),
|
|
74
|
+
euler_number=int(mesh.euler_number),
|
|
75
|
+
aabb_min=_r(mesh.bounds[0]),
|
|
76
|
+
aabb_max=_r(mesh.bounds[1]),
|
|
77
|
+
aabb_extents=_r(mesh.extents),
|
|
78
|
+
obb_extents=_r(sorted(obb_extents, reverse=True)),
|
|
79
|
+
surface_area=round(float(mesh.area), 6),
|
|
80
|
+
volume=round(abs(float(mesh.volume)), 6) if watertight else None,
|
|
81
|
+
center_mass=_r(mesh.center_mass) if watertight else None,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def measure_mesh_file(path: str | Path, settings: Settings) -> MeshStats:
|
|
86
|
+
"""Load a mesh file and return its :class:`MeshStats`."""
|
|
87
|
+
return compute_stats(load_mesh(path, settings))
|