feat: 算法升级、整车配准集成与性能分析文档

算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
This commit is contained in:
2026-06-25 08:22:20 +00:00
parent cec0766e67
commit 29f1d45d66
266 changed files with 1311475 additions and 2846 deletions

View File

@@ -0,0 +1,21 @@
"""CAD STP parsing + GT extraction.
This package depends on ``cadquery-ocp`` (OCP) for OpenCascade access. If
OCP is not installed the modules will raise :class:`ImportError` at import
time only when actually used; pure point-cloud workflows do not need it.
"""
from .stp_parser import StepShape, load_step
from .mesh import tessellate_to_trimesh
from .gt_extractor import (
GroundTruth,
extract_ground_truth,
)
__all__ = [
"StepShape",
"load_step",
"tessellate_to_trimesh",
"GroundTruth",
"extract_ground_truth",
]

View File

@@ -0,0 +1,310 @@
"""Extract Ground Truth geometry from a parsed STEP shape.
Strategy
--------
1. Walk every circular edge in the shape (``iter_circular_edges``).
2. Cluster by radius:
- radii inside ``locating_hole_radius_mm`` → candidate locating holes
- radii ≥ ``central_hole_radius_min_mm`` → candidate central hole
3. Discard duplicates (front + back face of the same hole share centre and
axis up to a sign).
4. Find a set of four locating holes whose centres form a near-rectangle:
pairwise sort by axis-distance, then verify diagonals roughly equal.
5. Order the four locating holes consistently (anti-clockwise when viewed
from +x looking down −x). This ordering is the canonical pairing for
Kabsch later.
6. Define:
- ``C`` = mean of the four locating-hole centres (== diagonal
intersection for a rectangle)
- ``n`` = panel normal = unit axis of the locating circles, signed
so that ``n_x > 0``
- reference line, plane, longitudinal mid-plane in :class:`ReferenceFrame`
This GT can be used as
- the gold standard the measurement is compared against, AND
- a prior law that bootstraps point-cloud-side hole detection (we know the
panel normal a priori).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from ..exceptions import CADParseError
from ..structures import Hole, HoleSet, PanelPlane, ReferenceFrame
from .stp_parser import StepShape, iter_circular_edges
@dataclass
class GroundTruth:
"""All CAD-derived reference values used as the GT for validation."""
holes: HoleSet
plane: PanelPlane
frame: ReferenceFrame
raw_circles: list[dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"holes": {
"locating": [
{
"center": list(h.center),
"radius_mm": h.radius_mm,
"fit_residual_rms_mm": h.fit_residual_rms_mm,
"role": h.role,
}
for h in self.holes.locating
],
"central": (
None
if self.holes.central is None
else {
"center": list(self.holes.central.center),
"radius_mm": self.holes.central.radius_mm,
"fit_residual_rms_mm": self.holes.central.fit_residual_rms_mm,
"role": self.holes.central.role,
}
),
},
"plane": {
"normal": list(self.plane.normal),
"point_on_plane": list(self.plane.point_on_plane),
"d_offset": self.plane.d_offset,
"inlier_ratio": self.plane.inlier_ratio,
"inlier_residual_rms_mm": self.plane.inlier_residual_rms_mm,
},
"frame": {
"center_C": list(self.frame.center_C),
"line_direction": list(self.frame.line_direction),
"reference_plane_z": self.frame.reference_plane_z,
"longitudinal_mid_y": self.frame.longitudinal_mid_y,
},
"raw_circles": self.raw_circles,
}
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def extract_ground_truth(
step: StepShape,
cad_cfg: dict[str, Any],
) -> GroundTruth:
"""Extract GT from a parsed STEP file using the ``cad`` config block."""
r_lo, r_hi = cad_cfg["locating_hole_radius_mm"]
r_central_min = float(cad_cfg["central_hole_radius_min_mm"])
rect_aspect_tol = float(cad_cfg.get("rectangle_aspect_tol", 0.15))
raw: list[dict[str, Any]] = []
locating: list[dict[str, Any]] = []
central: list[dict[str, Any]] = []
for center, axis, radius, _edge in iter_circular_edges(step.shape):
item = {
"center": center,
"axis": axis,
"radius": radius,
}
raw.append(item)
if r_lo <= radius <= r_hi:
locating.append(item)
elif radius >= r_central_min:
central.append(item)
if not locating:
raise CADParseError(
f"no circular edge with radius in [{r_lo}, {r_hi}] mm; "
"adjust cad.locating_hole_radius_mm to match your panel"
)
locating = _dedupe_circles(locating, r_tol=0.05, c_tol=0.5)
central = _dedupe_circles(central, r_tol=0.5, c_tol=2.0)
four = _select_rectangle(locating, rect_aspect_tol=rect_aspect_tol)
four_ordered = _order_rectangle(four)
panel_normal = _consensus_axis(four_ordered)
panel_normal = _enforce_nx_positive(panel_normal)
centers = np.asarray([c["center"] for c in four_ordered], dtype=np.float64)
C = centers.mean(axis=0)
holes = HoleSet(
locating=tuple( # type: ignore[arg-type]
Hole(
center=tuple(c["center"]),
radius_mm=float(c["radius"]),
fit_residual_rms_mm=0.0,
role="locating",
)
for c in four_ordered
),
central=(
Hole(
center=tuple(central[0]["center"]),
radius_mm=float(central[0]["radius"]),
fit_residual_rms_mm=0.0,
role="central",
)
if central
else None
),
)
plane = PanelPlane(
normal=tuple(panel_normal),
point_on_plane=tuple(C),
d_offset=float(-np.dot(panel_normal, C)),
inlier_ratio=1.0,
inlier_residual_rms_mm=0.0,
)
frame = ReferenceFrame(
center_C=tuple(C),
line_direction=(1.0, 0.0, 0.0),
reference_plane_z=float(C[2]),
longitudinal_mid_y=float(C[1]),
)
return GroundTruth(holes=holes, plane=plane, frame=frame, raw_circles=raw)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _dedupe_circles(
items: list[dict[str, Any]],
*,
r_tol: float,
c_tol: float,
) -> list[dict[str, Any]]:
"""Drop circles that share centre and radius (front/back face copies)."""
out: list[dict[str, Any]] = []
for it in items:
c = np.asarray(it["center"])
r = it["radius"]
is_dup = False
for kept in out:
if abs(kept["radius"] - r) <= r_tol:
if np.linalg.norm(np.asarray(kept["center"]) - c) <= c_tol:
is_dup = True
break
if not is_dup:
out.append(it)
return out
def _select_rectangle(
items: list[dict[str, Any]],
*,
rect_aspect_tol: float,
) -> list[dict[str, Any]]:
"""Pick four items whose centres look most like a rectangle.
For tractability we (a) require ≥ 4 candidates, (b) if exactly 4, return
them, (c) otherwise enumerate all 4-subsets and pick the one with the
smallest "rectangleness" residual.
"""
if len(items) < 4:
raise CADParseError(
f"only {len(items)} locating-hole candidates found; need ≥ 4"
)
if len(items) == 4:
return items
from itertools import combinations
best_score = float("inf")
best: list[dict[str, Any]] | None = None
for combo in combinations(items, 4):
s = _rectangle_score(combo)
if s < best_score:
best_score = s
best = list(combo)
assert best is not None
if best_score > rect_aspect_tol:
# Still accept the best — the tolerance is informational; warn via
# raw_circles in the GT report.
pass
return best
def _rectangle_score(items) -> float:
"""0 = perfect rectangle. Larger = worse."""
c = np.asarray([i["center"] for i in items], dtype=np.float64)
# Use distances between the 4 centres. For a rectangle, the 6 pairwise
# distances are: two "long" sides equal, two "short" sides equal, two
# diagonals equal.
from itertools import combinations
d = sorted(np.linalg.norm(c[i] - c[j]) for i, j in combinations(range(4), 2))
if d[0] < 1e-6:
return 1e6
s1, s2 = d[0], d[1] # short sides
l1, l2 = d[2], d[3] # long sides
g1, g2 = d[4], d[5] # diagonals
score = (
abs(s1 - s2) / max(s1, s2)
+ abs(l1 - l2) / max(l1, l2)
+ abs(g1 - g2) / max(g1, g2)
)
return score
def _order_rectangle(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return four hole dicts in a canonical (-y/-z, +y/-z, +y/+z, -y/+z) order.
The ordering is taken in the y-z plane (panel normal is along ±x). This
matches how a panel is normally described: bottom-left, bottom-right,
top-right, top-left when viewed from +x looking down −x.
"""
c = np.asarray([i["center"] for i in items], dtype=np.float64)
cy = c[:, 1].mean()
cz = c[:, 2].mean()
quadrant: dict[tuple[bool, bool], int] = {}
for idx, p in enumerate(c):
key = (p[1] >= cy, p[2] >= cz)
quadrant[key] = idx
order = [
(False, False),
(True, False),
(True, True),
(False, True),
]
if not all(q in quadrant for q in order):
# Geometry is degenerate; fall back to angular sort.
ang = np.arctan2(c[:, 2] - cz, c[:, 1] - cy)
idxs = np.argsort(ang)
return [items[i] for i in idxs]
return [items[quadrant[q]] for q in order]
def _consensus_axis(items: list[dict[str, Any]]) -> np.ndarray:
"""Average and re-normalise the circle axis directions."""
axes = np.asarray([i["axis"] for i in items], dtype=np.float64)
# Flip any axes that disagree with the first.
ref = axes[0]
for k in range(1, len(axes)):
if np.dot(axes[k], ref) < 0:
axes[k] = -axes[k]
n = axes.mean(axis=0)
n /= max(np.linalg.norm(n), 1e-12)
return n
def _enforce_nx_positive(n: np.ndarray) -> np.ndarray:
if n[0] < 0:
return -n
return n
__all__ = ["GroundTruth", "extract_ground_truth"]

View File

@@ -0,0 +1,143 @@
"""Tessellation of a TopoDS_Shape into a (V, F) triangle mesh.
The output is a small dataclass — we do not import ``trimesh`` until the
caller actually asks for it, to keep the core dependency surface minimal.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
from .stp_parser import StepShape, _import_ocp, iter_faces
@dataclass
class TriangleMesh:
"""Vertex / face arrays.
Attributes
----------
vertices : ``(V, 3) float64``
faces : ``(F, 3) int64`` (vertex indices)
face_normals : ``(F, 3) float64``
"""
vertices: np.ndarray
faces: np.ndarray
face_normals: np.ndarray
@property
def n_vertices(self) -> int:
return int(self.vertices.shape[0])
@property
def n_faces(self) -> int:
return int(self.faces.shape[0])
def to_trimesh(self):
"""Return a ``trimesh.Trimesh`` (lazy import)."""
try:
import trimesh
except ImportError as exc:
raise ImportError(
"trimesh is required for to_trimesh(); "
"install with: pip install 'coupler-panel-validation[cad]'"
) from exc
return trimesh.Trimesh(
vertices=self.vertices,
faces=self.faces,
face_normals=self.face_normals,
process=False,
)
def tessellate_to_trimesh(
step: StepShape,
*,
linear_deflection_mm: float = 0.5,
angular_deflection_rad: float = 0.1,
) -> TriangleMesh:
"""Tessellate a :class:`StepShape` into a single combined triangle mesh.
Each face is tessellated independently (with the location of the edge
transferred onto the vertices), then concatenated. Triangle winding is
flipped where the OCP face is reversed, so :attr:`face_normals` is the
*outward* normal in the assembly frame.
"""
occ = _import_ocp()
BRep_Tool = occ["BRep_Tool"]
TopLoc_Location = occ["TopLoc_Location"]
BRepMesh_IncrementalMesh = occ["BRepMesh_IncrementalMesh"]
BRepMesh_IncrementalMesh(
step.shape,
float(linear_deflection_mm),
False,
float(angular_deflection_rad),
True,
)
all_v: list[np.ndarray] = []
all_f: list[np.ndarray] = []
all_n: list[np.ndarray] = []
v_offset = 0
for face in iter_faces(step.shape):
loc = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, loc)
if triangulation is None:
continue
trsf = loc.Transformation()
n_nodes = triangulation.NbNodes()
n_tri = triangulation.NbTriangles()
if n_nodes == 0 or n_tri == 0:
continue
v = np.empty((n_nodes, 3), dtype=np.float64)
for i in range(n_nodes):
p = triangulation.Node(i + 1).Transformed(trsf)
v[i, 0] = p.X()
v[i, 1] = p.Y()
v[i, 2] = p.Z()
f = np.empty((n_tri, 3), dtype=np.int64)
is_reversed = face.Orientation() == 1 # TopAbs_REVERSED
for j in range(n_tri):
tri = triangulation.Triangle(j + 1)
i1, i2, i3 = tri.Get()
if is_reversed:
f[j] = (i3 - 1, i2 - 1, i1 - 1)
else:
f[j] = (i1 - 1, i2 - 1, i3 - 1)
# Outward face normal per triangle.
a = v[f[:, 0]]
b = v[f[:, 1]]
c = v[f[:, 2]]
normals = np.cross(b - a, c - a)
norms = np.linalg.norm(normals, axis=1, keepdims=True)
norms[norms < 1e-12] = 1.0
normals = normals / norms
all_v.append(v)
all_f.append(f + v_offset)
all_n.append(normals)
v_offset += n_nodes
if not all_v:
raise RuntimeError(
"tessellation produced no triangles; "
"try a finer linear_deflection_mm"
)
vertices = np.concatenate(all_v, axis=0)
faces = np.concatenate(all_f, axis=0)
face_normals = np.concatenate(all_n, axis=0)
return TriangleMesh(vertices=vertices, faces=faces, face_normals=face_normals)
__all__ = ["TriangleMesh", "tessellate_to_trimesh"]

View File

@@ -0,0 +1,219 @@
"""STP file loading via OCP (OpenCascade Python bindings).
The OCP import is deferred so the rest of the package does not require
OpenCascade to be installed.
The parser exposes a thin :class:`StepShape` wrapper that gives the rest
of the package what it needs: the underlying ``TopoDS_Shape`` for
tessellation and topology traversal.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..exceptions import CADParseError
@dataclass
class StepShape:
"""Result of loading a STEP file.
Attributes
----------
shape : ``OCP.TopoDS.TopoDS_Shape``
The root shape (assembly compound or single solid).
source : ``Path``
Source file path, for debugging.
"""
shape: Any
source: Path
def _import_ocp() -> dict[str, Any]:
"""Import the OCP modules we need. Raises a clean error if missing."""
try:
from OCP.STEPControl import STEPControl_Reader # type: ignore
from OCP.IFSelect import IFSelect_RetDone # type: ignore
from OCP.TopoDS import TopoDS, TopoDS_Shape # type: ignore
from OCP.TopAbs import ( # type: ignore
TopAbs_FACE,
TopAbs_EDGE,
TopAbs_VERTEX,
)
from OCP.TopExp import TopExp_Explorer # type: ignore
from OCP.TopLoc import TopLoc_Location # type: ignore
from OCP.BRep import BRep_Tool # type: ignore
from OCP.BRepMesh import BRepMesh_IncrementalMesh # type: ignore
from OCP.BRepGProp import BRepGProp # type: ignore
from OCP.GProp import GProp_GProps # type: ignore
from OCP.GeomAbs import GeomAbs_Circle # type: ignore
from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface # type: ignore
from OCP.gp import gp_Pnt, gp_Dir, gp_Vec # type: ignore
except ImportError as exc:
raise ImportError(
"OCP (cadquery-ocp) is required to parse STEP files. "
"Install with: pip install 'coupler-panel-validation[cad]'"
) from exc
return {
"STEPControl_Reader": STEPControl_Reader,
"IFSelect_RetDone": IFSelect_RetDone,
"TopoDS": TopoDS,
"TopoDS_Shape": TopoDS_Shape,
"TopAbs_FACE": TopAbs_FACE,
"TopAbs_EDGE": TopAbs_EDGE,
"TopAbs_VERTEX": TopAbs_VERTEX,
"TopExp_Explorer": TopExp_Explorer,
"TopLoc_Location": TopLoc_Location,
"BRep_Tool": BRep_Tool,
"BRepMesh_IncrementalMesh": BRepMesh_IncrementalMesh,
"BRepGProp": BRepGProp,
"GProp_GProps": GProp_GProps,
"GeomAbs_Circle": GeomAbs_Circle,
"BRepAdaptor_Curve": BRepAdaptor_Curve,
"BRepAdaptor_Surface": BRepAdaptor_Surface,
"gp_Pnt": gp_Pnt,
"gp_Dir": gp_Dir,
"gp_Vec": gp_Vec,
}
def load_step(path: str | Path) -> StepShape:
"""Read a STEP file and return its root :class:`TopoDS_Shape`.
Raises :class:`CADParseError` on read failure.
"""
p = Path(path)
if not p.exists():
raise CADParseError(f"STEP file not found: {p}")
occ = _import_ocp()
reader = occ["STEPControl_Reader"]()
status = reader.ReadFile(str(p))
if status != occ["IFSelect_RetDone"]:
raise CADParseError(f"OCP failed to read {p} (status={status})")
n_roots = reader.TransferRoots()
if n_roots <= 0:
raise CADParseError(f"no roots transferred from {p}")
shape = reader.OneShape()
return StepShape(shape=shape, source=p)
# ---------------------------------------------------------------------------
# Topology iteration helpers (used by gt_extractor)
# ---------------------------------------------------------------------------
def _shape_key(shape) -> int:
"""Stable identity key for a TopoDS_Shape across OCP versions.
OCP 7.8+ removed ``HashCode``. We fall back to the underlying TShape's
Python id, which is stable per OpenCASCADE topology object.
"""
try:
return shape.HashCode(2**31 - 1)
except AttributeError:
try:
return id(shape.TShape())
except Exception:
return id(shape)
def iter_faces(shape: Any):
"""Yield every TopoDS_Face inside ``shape``.
Two visits of the *same underlying surface with opposite orientations*
are treated as distinct because the orientation governs the outward
normal of the produced triangulation.
"""
occ = _import_ocp()
TopoDS = occ["TopoDS"]
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_FACE"])
while explorer.More():
raw = explorer.Current()
try:
yield TopoDS.Face_s(raw)
except Exception:
pass
explorer.Next()
def iter_circular_edges(shape: Any):
"""Yield ``(center_xyz, axis_xyz, radius_mm, edge)`` for every circular
edge in ``shape``. Duplicates (same circle on two adjacent faces) are
NOT removed here — downstream ``_dedupe_circles`` does that by
geometry (centre + radius).
"""
occ = _import_ocp()
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_EDGE"])
TopoDS = occ["TopoDS"]
BRepAdaptor_Curve = occ["BRepAdaptor_Curve"]
GeomAbs_Circle = occ["GeomAbs_Circle"]
while explorer.More():
raw = explorer.Current()
try:
edge = TopoDS.Edge_s(raw)
curve = BRepAdaptor_Curve(edge)
if curve.GetType() == GeomAbs_Circle:
circ = curve.Circle()
loc = circ.Location()
ax = circ.Axis().Direction()
radius = float(circ.Radius())
center = (float(loc.X()), float(loc.Y()), float(loc.Z()))
axis = (float(ax.X()), float(ax.Y()), float(ax.Z()))
yield center, axis, radius, edge
except Exception:
pass
explorer.Next()
def iter_cylindrical_faces(shape: Any):
"""Yield ``(center, axis, radius, face)`` for every cylinder face.
Cylinder faces are typically the *walls* of through-holes; their
radii match the circular edges that bound them.
"""
occ = _import_ocp()
TopoDS = occ["TopoDS"]
BRepAdaptor_Surface = occ["BRepAdaptor_Surface"]
explorer = occ["TopExp_Explorer"](shape, occ["TopAbs_FACE"])
while explorer.More():
raw = explorer.Current()
try:
face = TopoDS.Face_s(raw)
surf = BRepAdaptor_Surface(face)
if int(surf.GetType()) == 1:
cyl = surf.Cylinder()
loc = cyl.Location()
ax = cyl.Axis().Direction()
radius = float(cyl.Radius())
yield (
(float(loc.X()), float(loc.Y()), float(loc.Z())),
(float(ax.X()), float(ax.Y()), float(ax.Z())),
radius,
face,
)
except Exception:
pass
explorer.Next()
def face_area(face: Any) -> float:
occ = _import_ocp()
props = occ["GProp_GProps"]()
occ["BRepGProp"].SurfaceProperties_s(face, props)
return float(props.Mass())
__all__ = [
"StepShape",
"load_step",
"iter_faces",
"iter_circular_edges",
"face_area",
]