urdf-mesh-optimizer 0.2.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.
- optimizer/__init__.py +3 -0
- optimizer/cli.py +404 -0
- optimizer/core.py +111 -0
- optimizer/metrics.py +122 -0
- optimizer/moveit_check.py +153 -0
- optimizer/urdf_export.py +117 -0
- optimizer/urdf_robot.py +331 -0
- urdf_mesh_optimizer-0.2.0.dist-info/METADATA +323 -0
- urdf_mesh_optimizer-0.2.0.dist-info/RECORD +13 -0
- urdf_mesh_optimizer-0.2.0.dist-info/WHEEL +5 -0
- urdf_mesh_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
- urdf_mesh_optimizer-0.2.0.dist-info/licenses/LICENSE +21 -0
- urdf_mesh_optimizer-0.2.0.dist-info/top_level.txt +1 -0
optimizer/__init__.py
ADDED
optimizer/cli.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py - Command-line interface for urdf-mesh-optimizer.
|
|
3
|
+
|
|
4
|
+
Single file:
|
|
5
|
+
urdf-mesh-optimizer --input model.stl --output model_small.obj --triangles 5000
|
|
6
|
+
urdf-mesh-optimizer --input model.stl --output model_small.obj --reduction 80
|
|
7
|
+
|
|
8
|
+
URDF pair (visual + collision):
|
|
9
|
+
urdf-mesh-optimizer --input cell.stl --urdf-output ./output \
|
|
10
|
+
--visual 20000 --collision 3000
|
|
11
|
+
|
|
12
|
+
Batch (folder):
|
|
13
|
+
urdf-mesh-optimizer --folder ./cell_assets --output ./output --collision 5000
|
|
14
|
+
|
|
15
|
+
Whole robot (every mesh in a URDF, URIs rewritten):
|
|
16
|
+
urdf-mesh-optimizer --urdf my_robot.urdf --output ./optimized --collision 3000
|
|
17
|
+
|
|
18
|
+
Exit codes:
|
|
19
|
+
0 success
|
|
20
|
+
1 a known, reported error (bad input, bad mesh, bad arguments combination
|
|
21
|
+
caught at runtime rather than by argparse)
|
|
22
|
+
2 argument parsing error (raised by argparse itself, e.g. missing a
|
|
23
|
+
required flag)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import logging
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from optimizer import __version__
|
|
32
|
+
from optimizer.core import (
|
|
33
|
+
SUPPORTED_FORMATS,
|
|
34
|
+
clean_mesh,
|
|
35
|
+
load_mesh,
|
|
36
|
+
resolve_output_path,
|
|
37
|
+
resolve_target_triangles,
|
|
38
|
+
save_mesh,
|
|
39
|
+
simplify_mesh,
|
|
40
|
+
)
|
|
41
|
+
from optimizer.metrics import build_report, print_report, save_report
|
|
42
|
+
from optimizer.urdf_export import export_urdf_pair
|
|
43
|
+
from optimizer.urdf_robot import UrdfError, optimize_urdf, print_urdf_summary
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger("optimizer")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CliError(Exception):
|
|
49
|
+
"""A known, user-facing error. Caught in main() and reported with exit code 1."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _process_single(args: argparse.Namespace) -> None:
|
|
58
|
+
log.info("Loading '%s' …", args.input)
|
|
59
|
+
mesh = load_mesh(args.input)
|
|
60
|
+
mesh = clean_mesh(mesh)
|
|
61
|
+
|
|
62
|
+
target = resolve_target_triangles(mesh, args.triangles, args.reduction)
|
|
63
|
+
output_path = resolve_output_path(args.output, args.format)
|
|
64
|
+
|
|
65
|
+
if args.dry_run:
|
|
66
|
+
print(
|
|
67
|
+
f"[dry-run] Would simplify '{args.input}' ({len(mesh.triangles):,} triangles) "
|
|
68
|
+
f"→ {target:,} triangles"
|
|
69
|
+
)
|
|
70
|
+
print(f"[dry-run] Would write → '{output_path}'")
|
|
71
|
+
if args.save_report:
|
|
72
|
+
report_path = str(Path(output_path).with_suffix(".report.json"))
|
|
73
|
+
print(f"[dry-run] Would save report → '{report_path}'")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
log.info("Simplifying to %s triangles …", f"{target:,}")
|
|
77
|
+
result, elapsed = simplify_mesh(mesh, target)
|
|
78
|
+
|
|
79
|
+
save_mesh(result, output_path)
|
|
80
|
+
log.info("Saved → '%s'", output_path)
|
|
81
|
+
|
|
82
|
+
report = build_report(
|
|
83
|
+
args.input,
|
|
84
|
+
output_path,
|
|
85
|
+
mesh,
|
|
86
|
+
result,
|
|
87
|
+
elapsed,
|
|
88
|
+
compute_hausdorff=not args.skip_hausdorff,
|
|
89
|
+
)
|
|
90
|
+
print_report(report)
|
|
91
|
+
|
|
92
|
+
if args.save_report:
|
|
93
|
+
report_path = str(Path(output_path).with_suffix(".report.json"))
|
|
94
|
+
save_report(report, report_path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _process_urdf(args: argparse.Namespace) -> None:
|
|
98
|
+
if args.visual is None or args.collision is None:
|
|
99
|
+
raise CliError("--urdf-output requires both --visual and --collision triangle counts.")
|
|
100
|
+
|
|
101
|
+
if args.dry_run:
|
|
102
|
+
mesh = load_mesh(args.input)
|
|
103
|
+
stem = Path(args.input).stem
|
|
104
|
+
out = Path(args.urdf_output)
|
|
105
|
+
visual_path = out / f"{stem}_visual.stl"
|
|
106
|
+
collision_path = out / f"{stem}_collision.stl"
|
|
107
|
+
snippet_path = out / f"{stem}_urdf_snippet.xml"
|
|
108
|
+
print(f"[dry-run] Would simplify '{args.input}' ({len(mesh.triangles):,} triangles)")
|
|
109
|
+
print(f"[dry-run] Would write → '{visual_path}' ({args.visual:,} triangles)")
|
|
110
|
+
print(f"[dry-run] Would write → '{collision_path}' ({args.collision:,} triangles)")
|
|
111
|
+
print(f"[dry-run] Would write → '{snippet_path}'")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
export_urdf_pair(
|
|
115
|
+
input_path=args.input,
|
|
116
|
+
output_dir=args.urdf_output,
|
|
117
|
+
visual_triangles=args.visual,
|
|
118
|
+
collision_triangles=args.collision,
|
|
119
|
+
compute_hausdorff=not args.skip_hausdorff,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _process_batch(args: argparse.Namespace) -> None:
|
|
124
|
+
folder = Path(args.folder)
|
|
125
|
+
if not folder.is_dir():
|
|
126
|
+
raise CliError(f"'{folder}' is not a directory.")
|
|
127
|
+
|
|
128
|
+
out_dir = Path(args.output) if args.output else folder / "optimized"
|
|
129
|
+
files = sorted(f for f in folder.iterdir() if f.suffix.lower() in SUPPORTED_FORMATS)
|
|
130
|
+
|
|
131
|
+
if not files:
|
|
132
|
+
raise CliError(f"No supported mesh files found in '{folder}'.")
|
|
133
|
+
|
|
134
|
+
if args.dry_run:
|
|
135
|
+
print(f"[dry-run] Would process {len(files)} mesh(es) in '{folder}' → '{out_dir}'")
|
|
136
|
+
for f in files:
|
|
137
|
+
print(f"[dry-run] {f.name}")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
log.info("Found %d mesh(es) in '%s'. Processing …", len(files), folder)
|
|
142
|
+
all_reports = []
|
|
143
|
+
failures = 0
|
|
144
|
+
|
|
145
|
+
for f in files:
|
|
146
|
+
try:
|
|
147
|
+
mesh = load_mesh(str(f))
|
|
148
|
+
mesh = clean_mesh(mesh)
|
|
149
|
+
target = resolve_target_triangles(mesh, args.triangles, args.reduction)
|
|
150
|
+
result, elapsed = simplify_mesh(mesh, target)
|
|
151
|
+
out_path = resolve_output_path(str(out_dir / f.name), args.format)
|
|
152
|
+
save_mesh(result, out_path)
|
|
153
|
+
report = build_report(
|
|
154
|
+
str(f),
|
|
155
|
+
out_path,
|
|
156
|
+
mesh,
|
|
157
|
+
result,
|
|
158
|
+
elapsed,
|
|
159
|
+
compute_hausdorff=not args.skip_hausdorff,
|
|
160
|
+
)
|
|
161
|
+
print_report(report)
|
|
162
|
+
all_reports.append(report)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
log.warning("Skipping '%s': %s", f.name, exc)
|
|
165
|
+
failures += 1
|
|
166
|
+
|
|
167
|
+
if args.save_report:
|
|
168
|
+
import json
|
|
169
|
+
|
|
170
|
+
report_path = out_dir / "batch_report.json"
|
|
171
|
+
with open(report_path, "w") as fh:
|
|
172
|
+
json.dump(all_reports, fh, indent=2)
|
|
173
|
+
log.info("Batch report saved → %s", report_path)
|
|
174
|
+
|
|
175
|
+
if failures and not all_reports:
|
|
176
|
+
raise CliError(f"All {failures} file(s) in the batch failed to process.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parse_package_paths(entries: list[str] | None) -> dict[str, str]:
|
|
180
|
+
paths = {}
|
|
181
|
+
for entry in entries or []:
|
|
182
|
+
name, sep, path = entry.partition("=")
|
|
183
|
+
if not sep or not name or not path:
|
|
184
|
+
raise CliError(f"--package-path expects NAME=DIR, got '{entry}'.")
|
|
185
|
+
paths[name] = path
|
|
186
|
+
return paths
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _process_robot_urdf(args: argparse.Namespace) -> None:
|
|
190
|
+
if args.collision is None:
|
|
191
|
+
raise CliError("--urdf requires --collision (per-mesh collision triangle budget).")
|
|
192
|
+
if args.output is None:
|
|
193
|
+
raise CliError("--urdf requires --output (directory for the rewritten URDF + meshes).")
|
|
194
|
+
try:
|
|
195
|
+
summary = optimize_urdf(
|
|
196
|
+
args.urdf,
|
|
197
|
+
args.output,
|
|
198
|
+
collision_triangles=args.collision,
|
|
199
|
+
visual_triangles=args.visual,
|
|
200
|
+
package_paths=_parse_package_paths(args.package_path),
|
|
201
|
+
mesh_uri_prefix=args.mesh_uri_prefix,
|
|
202
|
+
compute_hausdorff=not args.skip_hausdorff,
|
|
203
|
+
dry_run=args.dry_run,
|
|
204
|
+
)
|
|
205
|
+
except UrdfError as exc:
|
|
206
|
+
raise CliError(str(exc)) from exc
|
|
207
|
+
print_urdf_summary(summary, dry_run=args.dry_run)
|
|
208
|
+
if not args.dry_run and not args.mesh_uri_prefix:
|
|
209
|
+
log.info(
|
|
210
|
+
"Mesh URIs are relative to the output URDF. For RViz / MoveIt, install the output "
|
|
211
|
+
"into a package and pass --mesh-uri-prefix package://<pkg>/<dir>."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Parser
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
221
|
+
p = argparse.ArgumentParser(
|
|
222
|
+
prog="urdf-mesh-optimizer",
|
|
223
|
+
description=(
|
|
224
|
+
"Mesh optimization toolkit for ROS2, MoveIt, and digital twins: "
|
|
225
|
+
"decimation, URDF export, and geometric validation."
|
|
226
|
+
),
|
|
227
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
228
|
+
epilog="""
|
|
229
|
+
Examples:
|
|
230
|
+
# Reduce a single file to 5 000 triangles
|
|
231
|
+
urdf-mesh-optimizer --input gear.stl --output gear_small.obj --triangles 5000
|
|
232
|
+
|
|
233
|
+
# Reduce by percentage
|
|
234
|
+
urdf-mesh-optimizer --input env.ply --output env_small.ply --reduction 80
|
|
235
|
+
|
|
236
|
+
# Preview what a run would do without writing anything
|
|
237
|
+
urdf-mesh-optimizer --input gear.stl --output gear_small.obj --triangles 5000 --dry-run
|
|
238
|
+
|
|
239
|
+
# Generate URDF-ready visual + collision pair
|
|
240
|
+
urdf-mesh-optimizer --input robot_cell.stl --urdf-output ./output \\
|
|
241
|
+
--visual 20000 --collision 3000
|
|
242
|
+
|
|
243
|
+
# Batch-process a whole folder, forcing OBJ output regardless of source format
|
|
244
|
+
urdf-mesh-optimizer --folder ./cell_assets --output ./optimized \\
|
|
245
|
+
--collision 5000 --format obj
|
|
246
|
+
|
|
247
|
+
# Optimize every mesh in a robot description, rewriting the URDF to match
|
|
248
|
+
urdf-mesh-optimizer --urdf ur5e.urdf --output ./ur5e_optimized --collision 2000 \\
|
|
249
|
+
--mesh-uri-prefix package://ur5e_description/meshes_optimized
|
|
250
|
+
|
|
251
|
+
# Verbose logging for debugging a large batch job
|
|
252
|
+
urdf-mesh-optimizer --folder ./cell_assets --collision 5000 -v
|
|
253
|
+
""",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
p.add_argument("--version", action="version", version=f"urdf-mesh-optimizer {__version__}")
|
|
257
|
+
|
|
258
|
+
# --- Input modes ---
|
|
259
|
+
input_group = p.add_mutually_exclusive_group(required=True)
|
|
260
|
+
input_group.add_argument("--input", metavar="FILE", help="Single input mesh file.")
|
|
261
|
+
input_group.add_argument("--folder", metavar="DIR", help="Folder of mesh files (batch mode).")
|
|
262
|
+
input_group.add_argument(
|
|
263
|
+
"--urdf",
|
|
264
|
+
metavar="FILE",
|
|
265
|
+
help="Robot URDF: optimize every mesh it references and write a rewritten copy "
|
|
266
|
+
"(robot mode). Requires --output DIR and --collision N; --visual N is optional.",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# --- Output ---
|
|
270
|
+
p.add_argument(
|
|
271
|
+
"--output",
|
|
272
|
+
metavar="PATH",
|
|
273
|
+
help="Output file (single mode) or output directory (batch mode).",
|
|
274
|
+
)
|
|
275
|
+
p.add_argument(
|
|
276
|
+
"--urdf-output",
|
|
277
|
+
metavar="DIR",
|
|
278
|
+
help="Output directory for URDF pair (activates URDF mode).",
|
|
279
|
+
)
|
|
280
|
+
p.add_argument(
|
|
281
|
+
"--format",
|
|
282
|
+
metavar="EXT",
|
|
283
|
+
help="Force output format (e.g. 'obj', 'stl'), overriding the output "
|
|
284
|
+
"path's extension. Applies to single and batch modes.",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# --- Target size ---
|
|
288
|
+
size_group = p.add_mutually_exclusive_group()
|
|
289
|
+
size_group.add_argument(
|
|
290
|
+
"--triangles", type=int, metavar="N", help="Target triangle count (absolute)."
|
|
291
|
+
)
|
|
292
|
+
size_group.add_argument(
|
|
293
|
+
"--reduction", type=float, metavar="PCT", help="Reduction percentage, e.g. 80 → keep 20%%."
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# --- URDF pair sizes ---
|
|
297
|
+
p.add_argument(
|
|
298
|
+
"--visual",
|
|
299
|
+
type=int,
|
|
300
|
+
metavar="N",
|
|
301
|
+
help="Triangle count for visual mesh (URDF pair mode; optional per-mesh budget in "
|
|
302
|
+
"robot mode).",
|
|
303
|
+
)
|
|
304
|
+
p.add_argument(
|
|
305
|
+
"--collision",
|
|
306
|
+
type=int,
|
|
307
|
+
metavar="N",
|
|
308
|
+
help="Triangle count for collision mesh (URDF pair / batch mode; per-mesh budget "
|
|
309
|
+
"in robot mode).",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# --- Robot (URDF) mode ---
|
|
313
|
+
p.add_argument(
|
|
314
|
+
"--package-path",
|
|
315
|
+
action="append",
|
|
316
|
+
metavar="NAME=DIR",
|
|
317
|
+
help="Where to find a ROS package referenced as package://NAME/... (repeatable). "
|
|
318
|
+
"Otherwise found next to the URDF or via AMENT_PREFIX_PATH.",
|
|
319
|
+
)
|
|
320
|
+
p.add_argument(
|
|
321
|
+
"--mesh-uri-prefix",
|
|
322
|
+
metavar="URI",
|
|
323
|
+
help="Prefix for rewritten mesh URIs in robot mode, e.g. "
|
|
324
|
+
"package://my_robot_description/meshes_optimized (default: relative paths).",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# --- Extras ---
|
|
328
|
+
p.add_argument(
|
|
329
|
+
"--save-report",
|
|
330
|
+
action="store_true",
|
|
331
|
+
help="Persist a JSON performance/quality report alongside output.",
|
|
332
|
+
)
|
|
333
|
+
p.add_argument(
|
|
334
|
+
"--skip-hausdorff",
|
|
335
|
+
action="store_true",
|
|
336
|
+
help="Skip Hausdorff distance computation (faster for large meshes).",
|
|
337
|
+
)
|
|
338
|
+
p.add_argument(
|
|
339
|
+
"--dry-run",
|
|
340
|
+
action="store_true",
|
|
341
|
+
help="Print what would happen without reading targets or writing any files.",
|
|
342
|
+
)
|
|
343
|
+
p.add_argument(
|
|
344
|
+
"-v",
|
|
345
|
+
"--verbose",
|
|
346
|
+
action="count",
|
|
347
|
+
default=0,
|
|
348
|
+
help="Increase logging verbosity. Repeat for more detail (-v, -vv).",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
return p
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _configure_logging(verbosity: int) -> None:
|
|
355
|
+
"""Default shows progress (INFO); -v adds debug detail from dependencies too."""
|
|
356
|
+
level = logging.DEBUG if verbosity >= 1 else logging.INFO
|
|
357
|
+
logging.basicConfig(level=level, format="[%(name)s] %(message)s")
|
|
358
|
+
if verbosity == 0:
|
|
359
|
+
logging.getLogger("open3d").setLevel(logging.WARNING)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Entry point
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def main() -> None:
|
|
368
|
+
parser = build_parser()
|
|
369
|
+
args = parser.parse_args()
|
|
370
|
+
_configure_logging(args.verbose)
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
# ---- Routing ----
|
|
374
|
+
if args.folder:
|
|
375
|
+
if args.triangles is None and args.reduction is None and args.collision is None:
|
|
376
|
+
parser.error("Batch mode requires --triangles, --reduction, or --collision.")
|
|
377
|
+
# In batch mode --collision is a shorthand for --triangles
|
|
378
|
+
if args.collision and args.triangles is None and args.reduction is None:
|
|
379
|
+
args.triangles = args.collision
|
|
380
|
+
_process_batch(args)
|
|
381
|
+
|
|
382
|
+
elif args.urdf:
|
|
383
|
+
_process_robot_urdf(args)
|
|
384
|
+
|
|
385
|
+
elif args.urdf_output:
|
|
386
|
+
_process_urdf(args)
|
|
387
|
+
|
|
388
|
+
else:
|
|
389
|
+
if args.output is None:
|
|
390
|
+
parser.error("--output is required in single-file mode.")
|
|
391
|
+
if args.triangles is None and args.reduction is None:
|
|
392
|
+
parser.error("Single-file mode requires --triangles or --reduction.")
|
|
393
|
+
_process_single(args)
|
|
394
|
+
|
|
395
|
+
except CliError as exc:
|
|
396
|
+
log.error("%s", exc)
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
399
|
+
log.error("%s", exc)
|
|
400
|
+
sys.exit(1)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
if __name__ == "__main__":
|
|
404
|
+
main()
|
optimizer/core.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core.py - Mesh loading, simplification, and cleaning logic.
|
|
3
|
+
Supports: STL, OBJ, PLY, OFF, GLB/GLTF
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import open3d as o3d
|
|
10
|
+
|
|
11
|
+
SUPPORTED_FORMATS = {".stl", ".obj", ".ply", ".off", ".glb", ".gltf"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_mesh(path: str) -> o3d.geometry.TriangleMesh:
|
|
15
|
+
"""Load a mesh from any supported format."""
|
|
16
|
+
p = Path(path)
|
|
17
|
+
if not p.exists():
|
|
18
|
+
raise FileNotFoundError(f"Input file not found: '{path}'.")
|
|
19
|
+
if p.suffix.lower() not in SUPPORTED_FORMATS:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Unsupported format: '{p.suffix}'. " f"Supported: {', '.join(SUPPORTED_FORMATS)}"
|
|
22
|
+
)
|
|
23
|
+
mesh = o3d.io.read_triangle_mesh(str(p))
|
|
24
|
+
if len(mesh.triangles) == 0:
|
|
25
|
+
raise RuntimeError(f"No triangles found in '{path}'. File may be empty or corrupt.")
|
|
26
|
+
return mesh
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clean_mesh(mesh: o3d.geometry.TriangleMesh) -> o3d.geometry.TriangleMesh:
|
|
30
|
+
"""Remove duplicated vertices, degenerate triangles, and unreferenced geometry."""
|
|
31
|
+
mesh.remove_duplicated_vertices()
|
|
32
|
+
mesh.remove_duplicated_triangles()
|
|
33
|
+
mesh.remove_degenerate_triangles()
|
|
34
|
+
mesh.remove_unreferenced_vertices()
|
|
35
|
+
return mesh
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def simplify_mesh(
|
|
39
|
+
mesh: o3d.geometry.TriangleMesh,
|
|
40
|
+
target_triangles: int,
|
|
41
|
+
) -> tuple[o3d.geometry.TriangleMesh, float]:
|
|
42
|
+
"""
|
|
43
|
+
Simplify a mesh using Quadric Error Metrics (QEM).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
(simplified_mesh, processing_time_sec)
|
|
47
|
+
"""
|
|
48
|
+
t0 = time.perf_counter()
|
|
49
|
+
# open3d renamed this method to simplify_quadric_decimation in 0.19;
|
|
50
|
+
# support both so the tool works across the >=0.18 range we advertise.
|
|
51
|
+
if hasattr(mesh, "simplify_quadric_decimation"):
|
|
52
|
+
simplified = mesh.simplify_quadric_decimation(target_number_of_triangles=target_triangles)
|
|
53
|
+
else:
|
|
54
|
+
simplified = mesh.simplify_quadric_error_metrics(
|
|
55
|
+
target_number_of_triangles=target_triangles
|
|
56
|
+
)
|
|
57
|
+
elapsed = time.perf_counter() - t0
|
|
58
|
+
simplified = clean_mesh(simplified)
|
|
59
|
+
return simplified, elapsed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_mesh(mesh: o3d.geometry.TriangleMesh, path: str) -> None:
|
|
63
|
+
"""Save a mesh; output format is inferred from the file extension."""
|
|
64
|
+
p = Path(path)
|
|
65
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
# STL export silently fails (writes a 0-byte file) without triangle
|
|
67
|
+
# normals. simplify_quadric_decimation doesn't carry normals through,
|
|
68
|
+
# so recompute them unconditionally before every write.
|
|
69
|
+
mesh.compute_triangle_normals()
|
|
70
|
+
success = o3d.io.write_triangle_mesh(str(p), mesh)
|
|
71
|
+
if not success:
|
|
72
|
+
raise RuntimeError(f"Failed to write mesh to '{path}'.")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_output_path(path: str, format_override: str | None) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Apply an explicit --format override to an output path, replacing its
|
|
78
|
+
extension. Validates the requested format is supported.
|
|
79
|
+
"""
|
|
80
|
+
if format_override is None:
|
|
81
|
+
return path
|
|
82
|
+
fmt = format_override.lower().lstrip(".")
|
|
83
|
+
suffix = f".{fmt}"
|
|
84
|
+
if suffix not in SUPPORTED_FORMATS:
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"Unsupported --format '{format_override}'. "
|
|
87
|
+
f"Supported: {', '.join(sorted(s.lstrip('.') for s in SUPPORTED_FORMATS))}"
|
|
88
|
+
)
|
|
89
|
+
return str(Path(path).with_suffix(suffix))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_target_triangles(
|
|
93
|
+
mesh: o3d.geometry.TriangleMesh,
|
|
94
|
+
target_triangles: int | None,
|
|
95
|
+
reduction_percent: float | None,
|
|
96
|
+
) -> int:
|
|
97
|
+
"""
|
|
98
|
+
Resolve target triangle count from either an absolute value or a percentage.
|
|
99
|
+
Exactly one of the two arguments must be provided.
|
|
100
|
+
"""
|
|
101
|
+
original = len(mesh.triangles)
|
|
102
|
+
if target_triangles is not None and reduction_percent is not None:
|
|
103
|
+
raise ValueError("Provide --triangles OR --reduction, not both.")
|
|
104
|
+
if target_triangles is not None:
|
|
105
|
+
return int(target_triangles)
|
|
106
|
+
if reduction_percent is not None:
|
|
107
|
+
if not (0 < reduction_percent < 100):
|
|
108
|
+
raise ValueError("--reduction must be between 0 and 100 (exclusive).")
|
|
109
|
+
keep = 1.0 - (reduction_percent / 100.0)
|
|
110
|
+
return max(1, int(original * keep))
|
|
111
|
+
raise ValueError("Provide --triangles or --reduction.")
|
optimizer/metrics.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
metrics.py - Geometry validation and performance reporting.
|
|
3
|
+
|
|
4
|
+
Computes:
|
|
5
|
+
- Triangle counts (before / after)
|
|
6
|
+
- File size delta
|
|
7
|
+
- Processing time
|
|
8
|
+
- Hausdorff distance (geometric deviation estimate)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import open3d as o3d
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("optimizer")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Hausdorff distance
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hausdorff_distance(
|
|
27
|
+
original: o3d.geometry.TriangleMesh,
|
|
28
|
+
simplified: o3d.geometry.TriangleMesh,
|
|
29
|
+
n_samples: int = 10_000,
|
|
30
|
+
) -> dict[str, float]:
|
|
31
|
+
"""
|
|
32
|
+
Estimate one-sided and symmetric Hausdorff distances by sampling
|
|
33
|
+
points on both surfaces and querying nearest-neighbour distances.
|
|
34
|
+
|
|
35
|
+
Returns a dict with keys: max_mm, mean_mm, rms_mm
|
|
36
|
+
(assumes mesh units are millimetres; adjust label if metres).
|
|
37
|
+
"""
|
|
38
|
+
pcd_orig = original.sample_points_uniformly(number_of_points=n_samples)
|
|
39
|
+
pcd_simp = simplified.sample_points_uniformly(number_of_points=n_samples)
|
|
40
|
+
|
|
41
|
+
dists_orig_to_simp = np.asarray(pcd_orig.compute_point_cloud_distance(pcd_simp))
|
|
42
|
+
dists_simp_to_orig = np.asarray(pcd_simp.compute_point_cloud_distance(pcd_orig))
|
|
43
|
+
|
|
44
|
+
all_dists = np.concatenate([dists_orig_to_simp, dists_simp_to_orig])
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"hausdorff_max": float(np.max(all_dists)),
|
|
48
|
+
"hausdorff_mean": float(np.mean(all_dists)),
|
|
49
|
+
"hausdorff_rms": float(np.sqrt(np.mean(all_dists**2))),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Performance report
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_report(
|
|
59
|
+
input_path: str,
|
|
60
|
+
output_path: str,
|
|
61
|
+
original_mesh: o3d.geometry.TriangleMesh,
|
|
62
|
+
result_mesh: o3d.geometry.TriangleMesh,
|
|
63
|
+
processing_time_sec: float,
|
|
64
|
+
compute_hausdorff: bool = True,
|
|
65
|
+
hausdorff_samples: int = 10_000,
|
|
66
|
+
) -> dict:
|
|
67
|
+
"""Build a structured performance/quality report dict."""
|
|
68
|
+
orig_tri = len(original_mesh.triangles)
|
|
69
|
+
red_tri = len(result_mesh.triangles)
|
|
70
|
+
reduction_pct = round((1 - red_tri / orig_tri) * 100, 2) if orig_tri > 0 else 0.0
|
|
71
|
+
|
|
72
|
+
in_size = Path(input_path).stat().st_size / (1024 * 1024)
|
|
73
|
+
out_size = (
|
|
74
|
+
Path(output_path).stat().st_size / (1024 * 1024) if Path(output_path).exists() else 0.0
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
report = {
|
|
78
|
+
"input_file": str(input_path),
|
|
79
|
+
"output_file": str(output_path),
|
|
80
|
+
"original_triangles": orig_tri,
|
|
81
|
+
"reduced_triangles": red_tri,
|
|
82
|
+
"reduction_percent": reduction_pct,
|
|
83
|
+
"file_size_before_mb": round(in_size, 3),
|
|
84
|
+
"file_size_after_mb": round(out_size, 3),
|
|
85
|
+
"processing_time_sec": round(processing_time_sec, 4),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if compute_hausdorff:
|
|
89
|
+
h = hausdorff_distance(original_mesh, result_mesh, n_samples=hausdorff_samples)
|
|
90
|
+
report.update(h)
|
|
91
|
+
|
|
92
|
+
return report
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def print_report(report: dict) -> None:
|
|
96
|
+
"""Pretty-print the report to stdout."""
|
|
97
|
+
sep = "─" * 52
|
|
98
|
+
print(f"\n{sep}")
|
|
99
|
+
print(" Mesh Preprocessing Report")
|
|
100
|
+
print(sep)
|
|
101
|
+
print(f" Input : {report['input_file']}")
|
|
102
|
+
print(f" Output : {report['output_file']}")
|
|
103
|
+
print(
|
|
104
|
+
f" Triangles : {report['original_triangles']:,} → {report['reduced_triangles']:,}"
|
|
105
|
+
)
|
|
106
|
+
print(f" Reduction : {report['reduction_percent']}%")
|
|
107
|
+
before, after = report["file_size_before_mb"], report["file_size_after_mb"]
|
|
108
|
+
print(f" File size : {before} MB → {after} MB")
|
|
109
|
+
print(f" Time : {report['processing_time_sec']} s")
|
|
110
|
+
if "hausdorff_max" in report:
|
|
111
|
+
print(f" Hausdorff max : {report['hausdorff_max']:.6f} (mesh units)")
|
|
112
|
+
print(f" Hausdorff RMS : {report['hausdorff_rms']:.6f} (mesh units)")
|
|
113
|
+
print(sep + "\n")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def save_report(report: dict, path: str) -> None:
|
|
117
|
+
"""Persist the report as JSON."""
|
|
118
|
+
p = Path(path)
|
|
119
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
with open(p, "w") as fh:
|
|
121
|
+
json.dump(report, fh, indent=2)
|
|
122
|
+
log.info("Report saved → %s", p)
|