File size: 3,907 Bytes
8f1f637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
"""Quantitative mesh quality / similarity metrics (all verified on this venv).

Two modes:
  - vs known target dimensions (always available): OBB-based dimension match.
  - vs a reference mesh (GSO etc.): Chamfer distance + volumetric IoU after
    normalization and point-to-point ICP alignment.

Pip-only (trimesh + numpy + scipy). No GL, no sudo, no rtree.
Key correctness notes (verified):
  - Use the ORIENTED bounding box (OBB) for dimensions; the axis-aligned box
    inflates under rotation.
  - ICP must be point-to-point (mesh-based ICP needs rtree, which isn't installed).
"""
from __future__ import annotations

import numpy as np
import trimesh
from scipy.spatial import cKDTree


def _load(mesh) -> trimesh.Trimesh:
    return trimesh.load(mesh, force="mesh") if isinstance(mesh, str) else mesh


def _obb_extents(m: trimesh.Trimesh) -> np.ndarray:
    """Orientation-invariant real-world dimensions (sorted ascending)."""
    return np.sort(m.bounding_box_oriented.primitive.extents)


# --------------------------------------------------------------------------
# vs target dimensions (mm) — cheap, always available
# --------------------------------------------------------------------------
def dimension_score(mesh, target_dims_mm: list[float]) -> dict:
    """Compare mesh OBB to a target [x,y,z] in mm (order-insensitive)."""
    m = _load(mesh)
    got = _obb_extents(m)
    tgt = np.sort(np.array(target_dims_mm, dtype=float))
    tgt[tgt == 0] = 1e-6
    rel_err = np.abs(got - tgt) / tgt
    score = float(np.clip(1 - rel_err.mean(), 0, 1))
    return {
        "dims_got_mm": [round(float(x), 1) for x in got],
        "dims_target_mm": [round(float(x), 1) for x in tgt],
        "axis_ratio": [round(float(x), 2) for x in (got / tgt)],
        "dimension_score": round(score, 3),
    }


# --------------------------------------------------------------------------
# vs reference mesh — Chamfer + voxel IoU after normalize + ICP
# --------------------------------------------------------------------------
def _normalize(m: trimesh.Trimesh) -> trimesh.Trimesh:
    m = m.copy()
    m.apply_translation(-m.bounding_box.centroid)
    s = m.extents.max()
    if s > 0:
        m.apply_scale(1.0 / s)
    return m


def _icp_align(target: trimesh.Trimesh, moving: trimesh.Trimesh, n: int = 4000) -> trimesh.Trimesh:
    """Point-to-point ICP (no rtree): align `moving` onto `target`."""
    T, _, _ = trimesh.registration.icp(moving.sample(n), target.sample(n), max_iterations=50)
    out = moving.copy()
    out.apply_transform(T)
    return out


def chamfer_distance(a, b, n: int = 5000) -> float:
    """Symmetric mean Chamfer distance (normalized scale). Lower = better."""
    pa, pb = a.sample(n), b.sample(n)
    d_ab = cKDTree(pb).query(pa)[0]
    d_ba = cKDTree(pa).query(pb)[0]
    return float(d_ab.mean() + d_ba.mean())


def voxel_iou(a, b, pitch: float = 0.02) -> float:
    """Volumetric IoU on filled voxel grids in a shared frame. Higher = better."""
    pa = a.voxelized(pitch).fill().points
    pb = b.voxelized(pitch).fill().points
    sa = {tuple(p) for p in np.round(pa / pitch).astype(int)}
    sb = {tuple(p) for p in np.round(pb / pitch).astype(int)}
    u = len(sa | sb)
    return float(len(sa & sb) / u) if u else 0.0


def compare(mesh, reference=None, target_dims_mm=None) -> dict:
    """Run whichever metrics are applicable; return a compact report."""
    out: dict = {}
    if target_dims_mm is not None:
        out.update(dimension_score(mesh, target_dims_mm))
    if reference is not None:
        try:
            ref = _normalize(_load(reference))
            gen = _icp_align(ref, _normalize(_load(mesh)))
            out["chamfer"] = round(chamfer_distance(ref, gen), 4)
            out["voxel_iou"] = round(voxel_iou(ref, gen), 3)
        except Exception as e:  # noqa: BLE001
            out["reference_error"] = str(e)
    return out