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,364 @@
|
||||
"""End-to-end orchestration for *research / validation* runs.
|
||||
|
||||
Two entry points:
|
||||
|
||||
- :func:`run_coupler_measurement_pipeline` — the integration path:
|
||||
cloud (in CAD frame) + optional CAD STP + ROI → ROI clip → denoise →
|
||||
measure. **No registration.**
|
||||
- :func:`run_from_cad_pipeline` — the research path: STP → synthesise →
|
||||
denoise → 4-hole Kabsch (against oracle deflected GT) → measure → diff
|
||||
GT. Saves every intermediate artifact under a structured run directory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .api import compute_coupler_measurement, ProgressCallback
|
||||
from .config import load_config, resolve_auto
|
||||
from .data_prep import (
|
||||
PreparedCloud,
|
||||
denoise_cloud,
|
||||
parse_cad,
|
||||
prepare_cloud_from_cad,
|
||||
prepare_cloud_from_scan,
|
||||
register_cloud,
|
||||
synthesize_cloud,
|
||||
)
|
||||
from .exceptions import InsufficientPointsError
|
||||
from .io_utils import (
|
||||
Artifacts,
|
||||
crop_ply_bbox,
|
||||
load_point_cloud,
|
||||
write_json,
|
||||
write_point_cloud,
|
||||
write_yaml,
|
||||
)
|
||||
from .metrics import compute_deflection_metrics
|
||||
from .detection.autotune import detection_overlay, relax_denoise_steps
|
||||
from .detection.layer_select import select_panel_layer
|
||||
from .roi_select import ROISpec, apply_roi, default_roi_spec, load_roi_json
|
||||
from .structures import CouplerMeasurement
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_coupler_measurement_pipeline(
|
||||
input_cloud: str | Path,
|
||||
out_dir: str | Path,
|
||||
*,
|
||||
config_path: str | Path | None = None,
|
||||
profile: str = "auto",
|
||||
cad_stp_path: str | Path | None = None,
|
||||
roi_spec: ROISpec | None = None,
|
||||
roi_json: str | Path | None = None,
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> CouplerMeasurement:
|
||||
"""Main measurement entry: cloud (in CAD frame) → ROI → denoise → measure.
|
||||
|
||||
**No registration step.** The input cloud is assumed to already live
|
||||
in the CAD / world frame (scanner extrinsics calibrated upstream).
|
||||
If it doesn't, 4-hole Kabsch against nominal CAD holes would also
|
||||
cancel the panel-deflection signal we want to measure — so this
|
||||
pipeline deliberately does not register. For the research path that
|
||||
*does* register (using oracle deflected GT), see
|
||||
:func:`run_from_cad_pipeline`.
|
||||
|
||||
Inputs:
|
||||
|
||||
- ``cad_stp_path`` — optional STEP file. Parsed for the analytical
|
||||
panel-normal seed and, when no profile/config is given, to
|
||||
auto-tune the scale/density-dependent denoise + detection knobs
|
||||
(see below). The parsed GT is written as ``01_gt.json`` for audit.
|
||||
- ``roi_spec`` / ``roi_json`` — axis-aligned-bbox ROI applied to the
|
||||
cloud *before* denoising. If both omitted, the full cloud is kept.
|
||||
``roi_spec`` wins over ``roi_json``.
|
||||
|
||||
Writes every intermediate to ``out_dir``. Returns a
|
||||
:class:`CouplerMeasurement`.
|
||||
"""
|
||||
art = Artifacts(out_dir)
|
||||
cfg = load_config(config_path, profile=profile)
|
||||
cfg = resolve_auto(cfg)
|
||||
write_yaml(cfg, art.resolved_config)
|
||||
|
||||
# ---- Load + ROI clip --------------------------------------------------
|
||||
# The ROI JSON is the cropping hyper-parameter. When it is a finite
|
||||
# bounding box and the input is a (possibly multi-GB) binary PLY, crop
|
||||
# it by streaming the file so we never hold the whole vehicle cloud in
|
||||
# memory. Otherwise fall back to load-then-filter.
|
||||
if roi_spec is None:
|
||||
roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec()
|
||||
|
||||
finite_bbox = all(
|
||||
math.isfinite(v) for v in (
|
||||
roi_spec.x_min, roi_spec.x_max,
|
||||
roi_spec.y_min, roi_spec.y_max,
|
||||
roi_spec.z_min, roi_spec.z_max,
|
||||
)
|
||||
)
|
||||
is_ply = str(input_cloud).lower().endswith(".ply")
|
||||
if finite_bbox and is_ply:
|
||||
pts, roi_diag = crop_ply_bbox(
|
||||
input_cloud,
|
||||
x_min=roi_spec.x_min, x_max=roi_spec.x_max,
|
||||
y_min=roi_spec.y_min, y_max=roi_spec.y_max,
|
||||
z_min=roi_spec.z_min, z_max=roi_spec.z_max,
|
||||
)
|
||||
roi_diag["kind"] = roi_spec.kind
|
||||
else:
|
||||
raw_pts = load_point_cloud(input_cloud)
|
||||
pts, roi_diag = apply_roi(raw_pts, roi_spec)
|
||||
|
||||
if pts.shape[0] == 0:
|
||||
raise InsufficientPointsError(
|
||||
f"ROI bbox kept 0 points from {input_cloud}; check the ROI JSON "
|
||||
f"bounds match the cloud's coordinate frame"
|
||||
)
|
||||
write_point_cloud(pts, art.path("sampled", ext="ply"))
|
||||
write_json({"spec": roi_spec.to_dict(), "diagnostics": roi_diag}, art.root / "roi.json")
|
||||
|
||||
# ---- Optional normal-direction layer isolation -----------------------
|
||||
# For a ROI cut from a full-vehicle scan, the box can slice through
|
||||
# several structures stacked along the panel normal (base plate +
|
||||
# raised mounting plate + roof truss). Detection and plane fit assume a
|
||||
# single thin plate, so we keep only one layer when the active config
|
||||
# turns this on (layer_select.enabled). Default config leaves it off,
|
||||
# so single-part clouds are unaffected.
|
||||
ls_cfg = cfg.get("layer_select", {})
|
||||
if ls_cfg.get("enabled", False):
|
||||
layer = select_panel_layer(
|
||||
pts,
|
||||
normal_seed=ls_cfg.get("normal_seed", "pca"),
|
||||
half_width_mm=float(ls_cfg.get("half_width_mm", 6.0)),
|
||||
bins=int(ls_cfg.get("bins", 240)),
|
||||
pick=str(ls_cfg.get("pick", "frontmost")),
|
||||
)
|
||||
pts = layer.points
|
||||
write_point_cloud(pts, art.path("transformed", ext="ply"))
|
||||
write_json(layer.diagnostics, art.root / "layer.json")
|
||||
|
||||
# Auto-tune the scale/density-dependent knobs from CAD + cloud when the
|
||||
# caller relies on the generic default config (no explicit config file
|
||||
# and no hand-tuned profile) but provides a CAD STP. This makes the
|
||||
# "CAD + cloud + ROI" contract self-sufficient for an adequately-sampled
|
||||
# scan of a panel of any scale, instead of (a) the default radius filter
|
||||
# deleting a sparse cloud or (b) 4-hole detection failing with the
|
||||
# small-panel defaults. A specific profile (e.g. lu1_af4) or an explicit
|
||||
# config is respected as-is (autotune skipped), so existing setups are
|
||||
# byte-for-byte unchanged.
|
||||
autotune = (
|
||||
cad_stp_path is not None
|
||||
and config_path is None
|
||||
and str(profile) in ("auto", "default", "none", "None")
|
||||
)
|
||||
|
||||
# ---- Optional CAD parse (panel_normal_seed + audit GT JSON) ----------
|
||||
panel_seed: np.ndarray | None = None
|
||||
parsed = None
|
||||
if cad_stp_path is not None:
|
||||
if autotune:
|
||||
# The default locating-hole band ([4,12] mm) selects the small
|
||||
# inner holes on a large panel, from which autotune would derive
|
||||
# too fine a grid. Widen it so parse_cad picks the large corner
|
||||
# locating holes (the reliably-scannable rectangle) and excludes
|
||||
# the big central pass-through hole.
|
||||
cfg["cad"] = {**cfg["cad"], "locating_hole_radius_mm": [6.0, 30.0]}
|
||||
parsed = parse_cad(cad_stp_path, cfg["cad"])
|
||||
write_json(parsed.gt.to_dict(), art.path("gt", ext="json"))
|
||||
panel_seed = np.asarray(parsed.gt.plane.normal, dtype=np.float64)
|
||||
|
||||
# ---- Denoise (no Kabsch, no prepare_cloud_from_scan) -----------------
|
||||
# Stage 1 of autotune (before denoise): drop the density-fragile radius
|
||||
# filter so a sparse cloud is not deleted.
|
||||
if autotune:
|
||||
cfg["denoise"] = relax_denoise_steps(cfg.get("denoise", {}))
|
||||
denoised = denoise_cloud(pts, cfg.get("denoise", {}))
|
||||
write_point_cloud(denoised.points, art.path("denoised", ext="ply"))
|
||||
|
||||
# Stage 2 of autotune (after denoise): size the detection grid / fill /
|
||||
# area band from the CAD locating-hole radius + the DENOISED point
|
||||
# spacing (the cloud detection actually sees).
|
||||
if autotune and parsed is not None:
|
||||
loc_r = float(np.median([h.radius_mm for h in parsed.gt.holes.locating]))
|
||||
cfg["detection"] = {
|
||||
**cfg.get("detection", {}),
|
||||
**detection_overlay(loc_r, denoised.points),
|
||||
}
|
||||
write_yaml(cfg, art.resolved_config)
|
||||
|
||||
# ---- Measurement ------------------------------------------------------
|
||||
result = compute_coupler_measurement(
|
||||
denoised.points,
|
||||
config=cfg,
|
||||
profile="custom",
|
||||
panel_normal_seed=panel_seed,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
write_json(result.to_dict(), art.report_json)
|
||||
write_json(result.to_dict()["report"], art.path("metrics", ext="json"))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CAD-driven research entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_from_cad_pipeline(
|
||||
stp_path: str | Path,
|
||||
out_dir: str | Path,
|
||||
*,
|
||||
config_path: str | Path | None = None,
|
||||
profile: str = "auto",
|
||||
progress: ProgressCallback | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""End-to-end research run: STP → synthesise → prepare → measure → diff GT.
|
||||
|
||||
Returns a summary dict containing the measurement, GT metrics, and
|
||||
measured-minus-GT differences. Writes every intermediate cloud and
|
||||
JSON to ``out_dir``.
|
||||
"""
|
||||
art = Artifacts(out_dir)
|
||||
cfg = load_config(config_path, profile=profile)
|
||||
cfg = resolve_auto(cfg)
|
||||
write_yaml(cfg, art.resolved_config)
|
||||
|
||||
# ---- Phase A.1 — parse + GT -------------------------------------------
|
||||
parsed = parse_cad(stp_path, cfg["cad"])
|
||||
write_point_cloud(parsed.mesh.vertices, art.path("cad_mesh", ext="ply"))
|
||||
write_json(parsed.gt.to_dict(), art.path("gt", ext="json"))
|
||||
|
||||
# ---- Phase A.2 — synthesise --------------------------------------------
|
||||
synth = synthesize_cloud(
|
||||
parsed,
|
||||
sampling_cfg=cfg["sampling"],
|
||||
noise_cfg=cfg.get("noise", {}),
|
||||
transform_cfg=cfg.get("transform", {}),
|
||||
scan_cfg=cfg.get("scan_simulator", {}),
|
||||
deflection_cfg=cfg.get("panel_deflection", {}),
|
||||
)
|
||||
write_point_cloud(synth.sampled, art.path("sampled", ext="ply"))
|
||||
write_point_cloud(synth.noisy, art.path("noisy", ext="ply"))
|
||||
write_point_cloud(synth.transformed, art.path("transformed", ext="ply"))
|
||||
|
||||
# ---- Phase A.3 — denoise ----------------------------------------------
|
||||
denoised = denoise_cloud(synth.transformed, cfg.get("denoise", {}))
|
||||
write_point_cloud(denoised.points, art.path("denoised", ext="ply"))
|
||||
|
||||
# ---- Phase A.4 — register ---------------------------------------------
|
||||
registered = register_cloud(
|
||||
denoised.points,
|
||||
target_holes=synth.gt_holes_after_deflection,
|
||||
detection_cfg=cfg["detection"],
|
||||
cad_cfg=cfg["cad"],
|
||||
registration_cfg=cfg["registration"],
|
||||
panel_normal_seed=None,
|
||||
)
|
||||
write_point_cloud(registered.points, art.path("registered", ext="ply"))
|
||||
|
||||
# ---- Phase B — measurement algorithm ----------------------------------
|
||||
result = compute_coupler_measurement(
|
||||
registered.points,
|
||||
config=cfg,
|
||||
profile="custom",
|
||||
panel_normal_seed=np.asarray(parsed.gt.plane.normal, dtype=np.float64),
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
# ---- GT metrics --------------------------------------------------------
|
||||
gt_normal = synth.gt_normal_after_deflection
|
||||
centers = synth.gt_holes_after_deflection.locating_centers()
|
||||
gt_metrics = compute_deflection_metrics(
|
||||
normal=gt_normal,
|
||||
center_C=tuple(centers.mean(axis=0).tolist()),
|
||||
panel_points=centers,
|
||||
right_axis_sign=int(cfg["metrics"].get("right_axis_sign", 1)),
|
||||
angle_clamp_deg=float(cfg["metrics"].get("angle_clamp_deg", 0.0)),
|
||||
)
|
||||
|
||||
diff = {
|
||||
"theta_right_deg": result.theta_right_deg - gt_metrics.theta_right_deg,
|
||||
"theta_ref_deg": result.theta_ref_deg - gt_metrics.theta_ref_deg,
|
||||
"D_y_max_mm": result.D_y_max_mm - gt_metrics.D_y_max_mm,
|
||||
"D_z_max_mm": result.D_z_max_mm - gt_metrics.D_z_max_mm,
|
||||
"dx_y_max_mm": result.dx_y_max_mm - gt_metrics.dx_y_max_mm,
|
||||
"dx_z_max_mm": result.dx_z_max_mm - gt_metrics.dx_z_max_mm,
|
||||
}
|
||||
|
||||
summary = {
|
||||
"stp": str(stp_path),
|
||||
"panel_deflection_injected": {
|
||||
"theta_right_deg": synth.panel_deflection_theta_right_deg,
|
||||
"theta_ref_deg": synth.panel_deflection_theta_ref_deg,
|
||||
"R": synth.panel_deflection_R.tolist(),
|
||||
},
|
||||
"scan_pose_perturbation": synth.gt_transform_4x4.tolist(),
|
||||
"registration": {
|
||||
"matrix": registered.transform_4x4.tolist(),
|
||||
"rmse_mm": registered.rmse_mm,
|
||||
"max_residual_mm": registered.max_residual_mm,
|
||||
},
|
||||
"measured": result.to_dict(),
|
||||
"gt_cad": parsed.gt.to_dict(),
|
||||
"gt_after_deflection": {
|
||||
"normal": gt_normal.tolist(),
|
||||
"locating_centers": centers.tolist(),
|
||||
},
|
||||
"gt_metrics": gt_metrics.to_dict(),
|
||||
"diff": diff,
|
||||
"config": cfg,
|
||||
}
|
||||
write_json(summary, art.path("metrics", ext="json"))
|
||||
write_json(summary, art.report_json)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_holeset_from_json(path: str | Path):
|
||||
"""Reconstruct a HoleSet from the JSON written by ``GroundTruth.to_dict``."""
|
||||
import json
|
||||
|
||||
from .structures import Hole, HoleSet
|
||||
|
||||
with Path(path).open("r", encoding="utf-8") as fh:
|
||||
d = json.load(fh)
|
||||
holes = d["holes"]
|
||||
locating = tuple(
|
||||
Hole(
|
||||
center=tuple(h["center"]),
|
||||
radius_mm=float(h["radius_mm"]),
|
||||
fit_residual_rms_mm=float(h.get("fit_residual_rms_mm", 0.0)),
|
||||
role=h.get("role", "locating"),
|
||||
)
|
||||
for h in holes["locating"]
|
||||
)
|
||||
central = None
|
||||
if holes.get("central") is not None:
|
||||
c = holes["central"]
|
||||
central = Hole(
|
||||
center=tuple(c["center"]),
|
||||
radius_mm=float(c["radius_mm"]),
|
||||
fit_residual_rms_mm=float(c.get("fit_residual_rms_mm", 0.0)),
|
||||
role="central",
|
||||
)
|
||||
return HoleSet(locating=locating, central=central)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"run_coupler_measurement_pipeline",
|
||||
"run_from_cad_pipeline",
|
||||
]
|
||||
Reference in New Issue
Block a user