feat: 升级算法包 delivery_v1.1 → v1.2,新增任务级 CAD 与测距面板
- 算法包替换为 delivery_v1.2(coupler_panel 新增 autotune/layer_select,underframe_deflection 新增 single_beam 管线),删除 delivery_v1.1 - manifests/adapters 的 PYTHONPATH 与版本说明同步更新到 v1.2 - 新增任务级 CAD 文件支持(task_cad_files 表、上传/读取路由、TaskManager 全选与加载显示) - 新增 train-measurement 自定义前端面板(YAML 编辑器 + 可视化 ROI 配置 + 3D 取点) - PointCloudViewer 支持 PLY 叠加层渲染与 ROI 拾取 - Dockerfile 改用 Miniforge 安装 pythonocc-core,修复 libgomp/libstdc++ 依赖
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user