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 @@
"""Diagnostic visualisation utilities (matplotlib + open3d offscreen).
All renderers degrade gracefully if matplotlib / open3d are missing —
they raise :class:`ImportError` only when actually called. The pipeline
calls them via :func:`render_all`, which silently skips on missing deps.
"""
from .cloud_view import render_cloud, render_cloud_compare
from .holes_view import render_holes
from .plane_view import render_plane_with_refs
from .heatmap import render_panel_heatmap
from .render_all import render_all
__all__ = [
"render_cloud",
"render_cloud_compare",
"render_holes",
"render_plane_with_refs",
"render_panel_heatmap",
"render_all",
]

View File

@@ -0,0 +1,27 @@
"""Open3D offscreen renderer factory.
Wraps the shifting open3d offscreen API so callers don't have to. Returns
``None`` if open3d is not available — callers should fall back to
matplotlib in that case.
"""
from __future__ import annotations
from typing import Any
def make_offscreen_renderer(width: int = 1024, height: int = 768) -> Any | None:
try:
import open3d as o3d # type: ignore
except ImportError:
return None
# open3d's offscreen path moved between 0.16 / 0.18.
try:
renderer = o3d.visualization.rendering.OffscreenRenderer(width, height)
return renderer
except Exception:
return None
__all__ = ["make_offscreen_renderer"]

View File

@@ -0,0 +1,134 @@
"""Point cloud rendering using matplotlib.
By default we render a 2D (y, z) projection — the coupler panel is a thin
sheet whose normal is along x, so a top-down y-z view is more readable
than a 3D scatter. Pass ``mode="3d"`` to force a 3D scatter (slower and
broken on some matplotlib versions, kept for completeness).
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: WPS433
return plt
def _decimate(points: np.ndarray, max_points: int) -> np.ndarray:
n = points.shape[0]
if n <= max_points:
return points
rng = np.random.default_rng(0)
idx = rng.choice(n, size=max_points, replace=False)
return points[idx]
def render_cloud(
points: np.ndarray,
out_path: str | Path,
*,
title: str = "",
color: str | np.ndarray = "tab:blue",
max_points: int = 80_000,
dpi: int = 150,
mode: str = "2d",
) -> Path:
plt = _import_plt()
pts = _decimate(np.asarray(points, dtype=np.float64), max_points)
if mode == "3d":
return _render_3d(pts, out_path, title=title, color=color, dpi=dpi)
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
axes[0].scatter(pts[:, 1], pts[:, 2], s=0.5, c=color)
axes[0].set_xlabel("y (mm)")
axes[0].set_ylabel("z (mm)")
axes[0].set_aspect("equal")
axes[0].set_title("y-z projection (panel face)")
axes[1].scatter(pts[:, 0], pts[:, 2], s=0.5, c=color)
axes[1].set_xlabel("x (mm)")
axes[1].set_ylabel("z (mm)")
axes[1].set_aspect("equal")
axes[1].set_title("x-z projection (side view)")
if title:
fig.suptitle(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
def render_cloud_compare(
clouds: dict[str, np.ndarray],
out_path: str | Path,
*,
title: str = "",
max_points: int = 50_000,
dpi: int = 150,
) -> Path:
"""Side-by-side y-z projections of several clouds."""
plt = _import_plt()
n = len(clouds)
fig, axes = plt.subplots(1, n, figsize=(6 * n, 6), squeeze=False)
cmap = plt.get_cmap("tab10")
for i, (name, pts) in enumerate(clouds.items()):
ax = axes[0, i]
sub = _decimate(np.asarray(pts, dtype=np.float64), max_points)
ax.scatter(sub[:, 1], sub[:, 2], s=0.5, c=[cmap(i)])
ax.set_title(name)
ax.set_xlabel("y (mm)")
ax.set_ylabel("z (mm)")
ax.set_aspect("equal")
if title:
fig.suptitle(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
def _render_3d(pts, out_path, *, title, color, dpi):
plt = _import_plt()
fig = plt.figure(figsize=(8, 6))
try:
ax = fig.add_subplot(111, projection="3d")
ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], s=0.5, c=color)
ax.set_xlabel("x (mm)")
ax.set_ylabel("y (mm)")
ax.set_zlabel("z (mm)")
if title:
ax.set_title(title)
except Exception:
# Fallback to 2D when matplotlib's 3D backend is broken (e.g. mpl 3.10
# has a known incompatibility with mpl_toolkits.mplot3d).
ax = fig.add_subplot(111)
ax.scatter(pts[:, 1], pts[:, 2], s=0.5, c=color)
ax.set_aspect("equal")
ax.set_xlabel("y (mm)")
ax.set_ylabel("z (mm)")
if title:
ax.set_title(f"{title} (3D backend unavailable)")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_cloud", "render_cloud_compare"]

View File

@@ -0,0 +1,73 @@
"""Panel deviation heatmap.
Colours each panel-ROI point by its signed perpendicular distance to
the fitted plane, projected onto a 2D image of the panel face.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
return plt
def render_panel_heatmap(
panel_points: np.ndarray,
*,
plane_normal: tuple[float, float, float],
plane_d: float,
center_C: tuple[float, float, float],
out_path: str | Path,
title: str = "",
cmap: str = "viridis",
dpi: int = 150,
) -> Path:
plt = _import_plt()
pts = np.asarray(panel_points, dtype=np.float64)
n = np.asarray(plane_normal, dtype=np.float64)
n = n / max(np.linalg.norm(n), 1e-12)
C = np.asarray(center_C, dtype=np.float64)
# Local 2D frame on the panel.
y_world = np.array([0.0, 1.0, 0.0])
u = y_world - np.dot(y_world, n) * n
if np.linalg.norm(u) < 1e-6:
u = np.array([0.0, 0.0, 1.0]) - np.dot(np.array([0.0, 0.0, 1.0]), n) * n
u /= np.linalg.norm(u)
v = np.cross(n, u)
rel = pts - C
uu = rel @ u
vv = rel @ v
signed_dist = pts @ n + float(plane_d)
fig, ax = plt.subplots(figsize=(8, 7))
sc = ax.scatter(uu, vv, c=signed_dist, cmap=cmap, s=2)
cb = plt.colorbar(sc, ax=ax)
cb.set_label("signed distance to fitted plane (mm)")
ax.set_aspect("equal")
ax.set_xlabel("panel u (≈ y axis, mm)")
ax.set_ylabel("panel v (≈ z axis, mm)")
if title:
ax.set_title(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_panel_heatmap"]

View File

@@ -0,0 +1,70 @@
"""4-hole detection diagnostic plot.
Shows the panel cloud projected to its local 2D frame, with each
detected circle drawn on top and the four selected locating holes
highlighted.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Circle # noqa: WPS433
return plt, Circle
def render_holes(
uv_points: np.ndarray,
candidates: list,
selected_indices: list[int],
out_path: str | Path,
*,
title: str = "",
dpi: int = 150,
) -> Path:
plt, Circle = _import_plt()
fig, ax = plt.subplots(figsize=(8, 8))
if uv_points.shape[0] > 50_000:
idx = np.random.default_rng(0).choice(uv_points.shape[0], 50_000, replace=False)
sub = uv_points[idx]
else:
sub = uv_points
ax.scatter(sub[:, 0], sub[:, 1], s=0.3, c="lightgray")
for i, cand in enumerate(candidates):
is_sel = i in set(selected_indices)
col = "tab:red" if is_sel else "tab:blue"
ax.add_patch(
Circle(
(cand.fit.cx, cand.fit.cy),
cand.fit.radius,
fill=False,
edgecolor=col,
lw=2 if is_sel else 1,
)
)
ax.plot(cand.fit.cx, cand.fit.cy, "+", color=col)
ax.set_aspect("equal")
ax.set_xlabel("u (mm)")
ax.set_ylabel("v (mm)")
if title:
ax.set_title(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_holes"]

View File

@@ -0,0 +1,105 @@
"""Plane + reference geometry overlay (2D projection).
Renders the y-z view of the panel ROI with the fitted plane normal,
reference line L, reference plane z=C_z, and longitudinal mid y=C_y
overlaid as annotations.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
def _import_plt():
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
return plt
def render_plane_with_refs(
panel_points: np.ndarray,
*,
plane_normal: tuple[float, float, float],
center_C: tuple[float, float, float],
out_path: str | Path,
title: str = "",
line_length_mm: float = 200.0,
dpi: int = 150,
) -> Path:
plt = _import_plt()
pts = np.asarray(panel_points, dtype=np.float64)
n = np.asarray(plane_normal, dtype=np.float64)
n = n / max(np.linalg.norm(n), 1e-12)
C = np.asarray(center_C, dtype=np.float64)
if pts.shape[0] > 80_000:
idx = np.random.default_rng(0).choice(pts.shape[0], 80_000, replace=False)
pts = pts[idx]
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Panel face view: y-z, with a small arrow for the normal projected
# into y-z (the in-plane part of n).
ax = axes[0]
ax.scatter(pts[:, 1], pts[:, 2], s=0.5, c="lightgray")
# Reference plane z = C_z
ax.axhline(C[2], color="tab:green", lw=1, alpha=0.7, label=f"ref plane z = {C[2]:.2f}")
# Longitudinal mid y = C_y
ax.axvline(C[1], color="tab:orange", lw=1, alpha=0.7, label=f"long. mid y = {C[1]:.2f}")
ax.scatter([C[1]], [C[2]], color="black", s=40, zorder=5, label=f"C = ({C[1]:.1f}, {C[2]:.1f})")
# Project normal into y-z
n_yz = np.array([n[1], n[2]])
if np.linalg.norm(n_yz) > 1e-3:
scale = line_length_mm * 0.5
ax.annotate(
"",
xy=(C[1] + n_yz[0] * scale, C[2] + n_yz[1] * scale),
xytext=(C[1], C[2]),
arrowprops=dict(arrowstyle="->", color="tab:red", lw=2),
)
ax.set_aspect("equal")
ax.set_xlabel("y (mm)")
ax.set_ylabel("z (mm)")
ax.set_title("y-z view: ROI + reference geometry + n's y-z projection")
ax.legend(loc="upper right", fontsize="x-small")
# x-y side view: shows the panel tilt out of x.
ax = axes[1]
ax.scatter(pts[:, 0], pts[:, 1], s=0.5, c="lightgray")
# Reference line L (along +x at y=C_y, z=C_z, projected to x-y → horizontal at y=C_y)
L = float(line_length_mm)
ax.plot([C[0] - L, C[0] + L], [C[1], C[1]], color="tab:blue", lw=2, label="ref line L")
# Plane normal (x, y components)
n_xy = np.array([n[0], n[1]])
scale = line_length_mm * 0.5
ax.annotate(
"",
xy=(C[0] + n_xy[0] * scale, C[1] + n_xy[1] * scale),
xytext=(C[0], C[1]),
arrowprops=dict(arrowstyle="->", color="tab:red", lw=2),
)
ax.scatter([C[0]], [C[1]], color="black", s=40, zorder=5)
ax.set_aspect("equal")
ax.set_xlabel("x (mm)")
ax.set_ylabel("y (mm)")
ax.set_title(f"x-y view: n=({n[0]:.4f},{n[1]:.4f},{n[2]:.4f})")
ax.legend(loc="upper right", fontsize="x-small")
if title:
fig.suptitle(title)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out_path, dpi=dpi)
plt.close(fig)
return out_path
__all__ = ["render_plane_with_refs"]

View File

@@ -0,0 +1,112 @@
"""One-shot rendering of every diagnostic image for a single run.
Reads the artifact directory written by :mod:`coupler_panel.pipeline` and
fills in ``images/`` with PNG plots. Silently skips any stage that
cannot be loaded (e.g. ``denoised.ply`` if denoise was disabled).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import numpy as np
from ..io_utils import load_point_cloud, Artifacts
def _safe_load(path: Path) -> np.ndarray | None:
try:
return load_point_cloud(path) if path.exists() else None
except Exception:
return None
def render_all(run_dir: str | Path, *, dpi: int = 150) -> dict[str, Path]:
"""Render every diagnostic image we know how to make for this run."""
art = Artifacts(run_dir)
out: dict[str, Path] = {}
# Lazy imports — matplotlib not strictly required to run pipeline.
try:
from .cloud_view import render_cloud, render_cloud_compare
from .plane_view import render_plane_with_refs
from .heatmap import render_panel_heatmap
except ImportError:
return out
stages = ("cad_mesh", "sampled", "noisy", "transformed", "denoised", "registered")
clouds: dict[str, np.ndarray] = {}
for st in stages:
pts = _safe_load(art.path(st, ext="ply"))
if pts is not None and pts.shape[0] > 0:
clouds[st] = pts
try:
out[st] = render_cloud(
pts, art.image(f"{st}.png"), title=st, dpi=dpi,
)
except Exception:
pass
# Per-stage 2-up comparisons of selected pairs.
if "sampled" in clouds and "noisy" in clouds:
try:
out["sampled_vs_noisy"] = render_cloud_compare(
{"sampled": clouds["sampled"], "noisy": clouds["noisy"]},
art.image("sampled_vs_noisy.png"),
dpi=dpi,
)
except Exception:
pass
if "transformed" in clouds and "registered" in clouds:
try:
out["transformed_vs_registered"] = render_cloud_compare(
{"transformed": clouds["transformed"], "registered": clouds["registered"]},
art.image("transformed_vs_registered.png"),
dpi=dpi,
)
except Exception:
pass
# Plane + reference geometry (read from report.json).
report_path = art.report_json
if report_path.exists():
try:
import json
with report_path.open("r", encoding="utf-8") as fh:
report: dict[str, Any] = json.load(fh)
measured = report.get("measured", report)
plane = measured.get("plane")
frame = measured.get("frame")
roi_pts_path = art.path("panel_roi", ext="ply")
roi_pts = _safe_load(roi_pts_path)
if roi_pts is None and "registered" in clouds:
roi_pts = clouds["registered"]
if plane and frame and roi_pts is not None and roi_pts.shape[0] > 0:
out["plane_with_refs"] = render_plane_with_refs(
roi_pts,
plane_normal=tuple(plane["normal"]),
center_C=tuple(frame["center_C"]),
out_path=art.image("plane_with_refs.png"),
title="fitted plane + reference geometry",
dpi=dpi,
)
out["heatmap"] = render_panel_heatmap(
roi_pts,
plane_normal=tuple(plane["normal"]),
plane_d=float(plane["d_offset"]),
center_C=tuple(frame["center_C"]),
out_path=art.image("deviation_heatmap.png"),
title="panel deviation heatmap",
dpi=dpi,
)
except Exception:
pass
return out
__all__ = ["render_all"]