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:
2026-06-11 02:30:04 +00:00
parent 16811100d1
commit cec0766e67
143 changed files with 3087 additions and 12604 deletions

4
.gitignore vendored
View File

@@ -143,5 +143,5 @@ alg/点云测距v1.1/train_measurement_system/results/
**/dist-info/ **/dist-info/
# 算法运行中间产物 # 算法运行中间产物
alg/delivery_v1.1/coupler_panel_release/runs/ alg/delivery_v1.2/coupler_panel_release/runs/
alg/delivery_v1.1/underframe_deflection/runs/ alg/delivery_v1.2/underframe_deflection/runs/

View File

@@ -1,61 +0,0 @@
# coupler-panel-validation
车钩面板 CAD-STP 到点云测量验证系统。
从 CAD STP 文件出发,构造 GT、合成模拟点云、跑完整测量流水线,
对比 GT,输出实验报告与可视化。
## 一句话用法
```python
from coupler_panel import compute_coupler_measurement, load_point_cloud
points = load_point_cloud("scan.ply") # (N, 3) ndarray, 单位 mm
result = compute_coupler_measurement(points)
print(result.theta_right_deg, result.theta_ref_deg)
print(result.dx_y_max_mm, result.dx_z_max_mm)
```
完整文档见 [docs/USAGE.md](docs/USAGE.md)。
## 安装
```bash
# 仅核心计算(numpy/scipy)
pip install -e .
# 加上点云 IO + 去噪 + 配准
pip install -e ".[cloud]"
# 加上 STP 解析
pip install -e ".[cad]"
# 加上可视化
pip install -e ".[viz]"
# 全装
pip install -e ".[all]"
```
## 命令行
```bash
# 1) 已有点云,直接测量
coupler-measure --input-cloud scan.ply --out-dir runs/demo
# 2) 从 CAD STP 生成模拟点云并测量
coupler-measure --stp panel.stp --out-dir runs/from_cad
# 3) 跑参数 sweep(采样×密度×噪声×变换矩阵)
coupler-measure --sweep configs/experiments/full_sweep.yaml --out-dir runs/sweep
```
## 坐标系约定
- x = 车体纵向
- y = 车体宽度
- z = 车体高度
- 单位 mm
- 拟合面板法向 `n = (n_x, n_y, n_z)` 强制 `n_x > 0`
详见 USAGE.md 第 5 节。

View File

@@ -1,376 +0,0 @@
# coupler_panel — 集成开发者使用指南
> 高铁车钩面板偏转角与最大偏差测量算法
>
> 主输入:**CAD STP + 点云 PLY + ROI JSON**
>
> 主输出:两个方向偏转角 `θ_right` / `θ_ref`、最大 x 向偏差 `dx_y_max` / `dx_z_max`,以及一份完整可序列化的 `report.json`。
---
## 1. 一句话集成
```python
from coupler_panel.pipeline import run_coupler_measurement_pipeline
result = run_coupler_measurement_pipeline(
input_cloud = "scan.ply", # 你的测量点云(已在 CAD 帧)
cad_stp_path = "panel.stp", # 同款面板的 CAD STP
roi_json = "roi.json", # ROI 描述(不裁剪就传 mode=all)
out_dir = "runs/today", # 中间产物 + report.json 落盘目录
)
print(result.theta_right_deg, result.theta_ref_deg) # 两个偏转角
print(result.dx_y_max_mm, result.dx_z_max_mm) # 最大 x 偏差
```
返回 `CouplerMeasurement`,全字段语义见 §4。`out_dir` 下额外得到 8 个文件(含 `report.json`,便于排查 / 后处理)。
---
## 2. 安装
```bash
# 全量安装(推荐)— core + 点云 IO + STP 解析 + 可视化 + Excel
python -m pip install -r requirements.txt
pip install -e ".[all]"
# 也可以按需取子集(见 pyproject.toml 的 extras):
pip install -e ".[cloud]" # 只要点云 + 去噪
pip install -e ".[cad]" # 需要解析 STP
pip install -e ".[report]" # 需要 Excel 报表
```
`requirements.txt` 锁定了端到端跑通过的版本(Python 3.10 验证)。
---
## 3. 三件输入的准备
### 3.1 CAD STP
- 提供面板的 STEP 文件路径。
- 算法用它做两件事:
1. 从拓扑直接提 4 个定位孔 + 中心大孔 + 面板法向(写入 `01_gt.json`,作审计用)
2. 把 CAD 法向作为 `panel_normal_seed` 喂给 hole-detection 的投影几何,加速并稳定 2D 网格化
- **可以不传**(`cad_stp_path=None`)—— 此时 hole detection 走 PCA 自行估法向;适用于你已经预先把法向算好/扫描坐标系本来就规范的场景。建议生产里**始终传**,便于审计。
### 3.2 点云 PLY
- 支持 `.ply` / `.pcd` / `.xyz` / `.txt` / `.csv`(PCD 与二进制 PLY 需要 open3d)。
- 数组形状 `(N, 3)`,单位 **mm**。
- **必须在 CAD 坐标系**:
- x = 车体纵向,y = 车体宽度,z = 车体高度
- 算法默认面板法向 `n_x > 0`
- **不再做配准**(参见 §6 "为什么不配准")。这意味着上游扫描端外参必须已经标定 —— 把点云对齐到 CAD 帧是扫描软件的职责,不是本算法的职责。
- 可以带颜色 / 法向等额外列;算法只读前 3 列。
### 3.3 ROI JSON
定义"PLY 里哪些点要纳入后续算法",**轴对齐 bounding box(AABB)**格式:
```json
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
```
六个 key 全部必填,单位 mm,坐标系 CAD 帧。点 `(x, y, z)` 被保留的条件是:
```
x_min <= x <= x_max ∧ y_min <= y <= y_max ∧ z_min <= z <= z_max
```
- **想保留全部点云**:把 6 个边界写得比点云自身 bbox 还宽即可(不需要任何 mode 切换)。
- **想不写文件**:传 `roi_json=None`,算法走"±∞" 默认(pass-through)。
- **想程序内构造**:传 `roi_spec=ROISpec(x_min=..., ...)`。
---
## 4. 公共 API 详解
### 4.1 主入口 `run_coupler_measurement_pipeline`
```python
def run_coupler_measurement_pipeline(
input_cloud : str | Path, # 必填:点云路径
out_dir : str | Path, # 必填:输出目录(自动创建)
*,
config_path : str | Path | None = None, # 可选:自定义 YAML
profile : str = "auto", # 可选:preset profile
cad_stp_path: str | Path | None = None, # 推荐:CAD STP 路径
roi_spec : ROISpec | None = None, # 二选一:内存 ROI 对象
roi_json : str | Path | None = None, # 二选一:ROI JSON 路径
progress : Callable[[str, float], None] | None = None,
) -> CouplerMeasurement
```
**算法链路(固定不可绕过)**:
```
load_point_cloud → apply_roi → [optional CAD parse → panel_normal_seed]
→ denoise_cloud → compute_coupler_measurement
├─ validate
├─ detect 4 holes
├─ build reference frame
├─ extract panel ROI
├─ fit panel plane (RANSAC + SVD)
└─ compute metrics
```
**Profile**:传 `"auto"` 即可;若你的面板型号对应到一个特定 profile,可指定 `profile="lu1_af4"` 等(详见 `configs/profiles/`)。
**配置覆盖**:
```python
result = run_coupler_measurement_pipeline(
input_cloud="scan.ply",
out_dir="runs/x",
config_path={
# 任何在 configs/default.yaml 里的字段都可以这样覆盖
"denoise": {"steps": [{"type": "voxel", "voxel_mm": 0.3}]},
"plane_fit": {"ransac_inlier_thresh_mm": 1.0},
"metrics": {"right_axis_sign": -1},
},
)
```
### 4.2 返回值 `CouplerMeasurement`
```python
@dataclass
class CouplerMeasurement:
# 版本号
schema_version: str # 字段语义稳定性 → SCHEMA_VERSION
algorithm_version: str # 数值结果稳定性 → ALGORITHM_VERSION
# === 6 个主测量量 ===
theta_right_deg: float # 右偏转角 atan2(n_y, n_x),applied right_axis_sign
theta_ref_deg: float # 上偏转角 atan2(n_z, n_x)
D_y_max_mm: float # max |y_i - C_y|
D_z_max_mm: float # max |z_i - C_z|
dx_y_max_mm: float # |A| · D_y_max, A = -n_y/n_x
dx_z_max_mm: float # |B| · D_z_max, B = -n_z/n_x
# === 几何 ===
center_C: tuple[float, float, float] # 面板中心 (4 孔均值)
normal: tuple[float, float, float] # 拟合面板法向,n_x > 0
# === 详细子结构 ===
holes: HoleSet # 检测到的 4 个定位孔 + 可选中心孔
plane: PanelPlane # normal + offset + inlier_ratio + RMS
frame: ReferenceFrame # C + 参考面 z=C_z + 纵向中面 y=C_y
# === 诊断 ===
quality_flags: list[str] # 例如 'plane_inlier_ratio_low'
timings: list[StageTiming] # 每阶段秒数 + 峰值内存
report: dict # 全量可序列化,与 report.json 一致
```
```python
result.to_dict() # 完全 JSON 化的 dict(落 DB / 网络传输用)
```
### 4.3 异常
```
CouplerError # 所有可预见异常的基类
├── InvalidPointCloudError # 输入形状/dtype/NaN 非法
│ └── UnsupportedFormatError # 文件后缀不支持
├── InsufficientPointsError # 点数 < 200 或 ROI 后 < 100
├── CADParseError # STP 解析失败 / 找不到 4 孔
├── HoleDetectionError # 点云上 4 孔检测失败
└── PlaneFitError # RANSAC 找不到平面
```
非致命问题通过 `result.quality_flags` 暴露:
| Flag | 含义 |
|---|---|
| `plane_inlier_ratio_low` | 平面拟合内点率低于 `plane_fit.min_inlier_ratio` |
### 4.4 进度回调
```python
def on_progress(stage: str, fraction: float) -> None:
print(f"[{fraction:5.1%}] {stage}")
result = run_coupler_measurement_pipeline(..., progress=on_progress)
```
阶段顺序:`validate → detect_holes → build_frame → extract_roi → fit_plane → metrics`。
---
## 5. ROI JSON schema 详细
**完整字段**(6 个 key,全部必填,单位 mm,坐标系 CAD 帧):
```json
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
```
**规则**:
- 必须满足 `x_min < x_max` 且 `y_min < y_max` 且 `z_min < z_max`,否则 `load_roi_json` 抛 `ValueError`
- 不在 [min, max] 闭区间内的点被丢弃(用 `>=` 与 `<=`,包含边界)
- 多余的 key(例如 `comment`、`source`)会被忽略,方便挂元数据
**手动构造 ROI(不读文件)**:
```python
from coupler_panel.roi_select import ROISpec, default_roi_spec
# 全保留默认(六个轴都是 ±∞)
spec = default_roi_spec()
# 自定义 bbox
spec = ROISpec(
x_min=-12000, x_max= 100,
y_min= -300, y_max= 1200,
z_min= -1000, z_max= 800,
)
result = run_coupler_measurement_pipeline(..., roi_spec=spec)
```
**诊断输出**:
每次运行后 `out_dir/roi.json` 写出实际生效的 spec + 应用诊断:
```jsonc
{
"spec": { "x_min": -12000.0, ... },
"diagnostics": {
"kind": "aabb",
"n_in": 96662,
"n_out": 96662,
"kept_ratio": 1.0,
"bounds": { "x_min": -12000.0, ... }
}
}
```
---
## 6. 为什么主路径**不做配准**
车钩面板上的 4 个定位孔与面板一起刚体旋转。如果用"扫到的 4 孔 → CAD 原始 4 孔"做 Kabsch 配准,数学上会**精确解出**"扫描器位姿误差 · 面板偏转"的复合刚体变换并反向作用 —— 把面板偏转随同位姿误差**一起消掉**,下游平面拟合再读不到偏转信号。
这是几何性质,不是实现 bug。
工程上,主算法假设扫描端外参标定让点云已经在 CAD 帧。如果你的设备做不到位姿标定,要么:
1. 在面板**外**装基准(不动)做 Kabsch
2. 用 ICP 或其他不依赖 4 孔的方法
研究模式(`coupler_panel.pipeline.run_from_cad_pipeline`)保留了 4 孔 Kabsch + 注入偏转 oracle 的能力,用于上限对照实验;生产集成请用 `run_coupler_measurement_pipeline`。
---
## 7. 落盘产物(`out_dir/`)
| 文件 | 内容 |
|---|---|
| `config.resolved.yaml` | 解析合并后实际生效的完整配置 |
| `roi.json` | ROI spec + 应用诊断(n_in / n_out / kept_ratio) |
| `01_gt.json` | (传了 cad_stp_path 时)从 STP 提的审计用 GT |
| `02_sampled.ply` | ROI 裁剪后的点云 |
| `05_denoised.ply` | 去噪后点云(喂给测量算法的最终点云) |
| `10_metrics.json` | 等价于 `report.json` 的内部 report 部分 |
| `report.json` | **完整结果**,等于 `result.to_dict()`;schema 见 §4.2 |
| `images/` | 占位空目录(仅 `viz` 模块写图时会用) |
---
## 8. 集成模板
```python
import logging
from pathlib import Path
from coupler_panel.pipeline import run_coupler_measurement_pipeline
from coupler_panel.exceptions import CouplerError
log = logging.getLogger(__name__)
def measure_coupler(
cloud_path: Path,
cad_stp: Path,
roi_json: Path,
out_dir: Path,
) -> dict | None:
"""主集成入口。失败时返回 None;成功时返回 6 个测量量 + flags。"""
try:
result = run_coupler_measurement_pipeline(
input_cloud = cloud_path,
cad_stp_path = cad_stp,
roi_json = roi_json,
out_dir = out_dir,
progress = lambda s, f: log.debug("%s %.0f%%", s, f * 100),
)
except CouplerError as exc:
log.error("coupler measurement failed: %s", exc)
return None
if result.quality_flags:
log.warning("quality flags: %s", result.quality_flags)
return {
"schema_version": result.schema_version,
"algorithm_version": result.algorithm_version,
"theta_right_deg": result.theta_right_deg,
"theta_ref_deg": result.theta_ref_deg,
"dx_y_max_mm": result.dx_y_max_mm,
"dx_z_max_mm": result.dx_z_max_mm,
"center_C_mm": list(result.center_C),
"normal": list(result.normal),
"flags": result.quality_flags,
}
```
---
## 9. 命令行(备用,非主集成路径)
```bash
# 等价于 run_coupler_measurement_pipeline(input_cloud=..., out_dir=...)
coupler-measure --input-cloud scan.ply --out-dir runs/today
```
> 当前 CLI 暂未把 `--cad` 与 `--roi` 暴露出来(设计上 CLI 主要给研究流程用);集成方请直接用 Python API。
---
## 10. 版本兼容性
| 标识 | 在哪 | 含义 |
|---|---|---|
| `coupler_panel.__version__` | `coupler_panel._version` | 包版本,遵循语义化版本 |
| `coupler_panel.ALGORITHM_VERSION` | 同上 | 算法版本;升级算法时递增,**数值结果可能变化** |
| `coupler_panel.SCHEMA_VERSION` | 同上 | `CouplerMeasurement` / `report.json` 字段结构;只在破坏性变更时递增;**新增可选字段不递增** |
集成建议:
1. 落库时把 `algorithm_version` 写进每条记录,便于历史复现
2. 反序列化 `report.json` 时校验 `schema_version` 主版本号
---
## 11. 坐标 & 单位速查
| 量 | 单位 | 约定 |
|---|---|---|
| 点云坐标 | mm | x=纵向、y=宽度、z=高度 |
| 角度 | 度 (deg) | `θ_right` = atan2(n_y, n_x)、`θ_ref` = atan2(n_z, n_x) |
| 法向 | 单位向量 | 强制 `n_x > 0` |
| `right_axis_sign` | ±1 | 如果车体"右"是 +y 留 1;如果是 -y 则配置 -1 |

View File

@@ -1,13 +0,0 @@
# Parameter sweep over point cloud density.
matrix:
sampling.spacing_mm:
- 0.25
- 0.5
- 1.0
- 2.0
- 5.0
fixed:
sampling.method: poisson_disk
noise.sigma_mm: 0.5
transform.rotation_deg: 3.0
transform.translation_mm: 5.0

View File

@@ -1,34 +0,0 @@
# Full cartesian product. WARNING: 4 * 5 * 8 * 4 * 4 = 2560 runs.
# Use only when you have time and disk.
matrix:
sampling.method:
- uniform_area
- poisson_disk
- adaptive
- grid_spacing
sampling.spacing_mm:
- 0.25
- 0.5
- 1.0
- 2.0
- 5.0
noise.sigma_mm:
- 0.0
- 0.25
- 0.5
- 1.0
- 2.0
- 4.0
- 10.0
- 20.0
transform.rotation_deg:
- 1.0
- 3.0
- 5.0
- 10.0
transform.translation_mm:
- 0.5
- 1.0
- 5.0
- 10.0
fixed: {}

View File

@@ -1,47 +0,0 @@
# Sweep matrix for the lu1-AF4 coupler panel STP. Total 9 runs.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
noise.sigma_mm: [0.0, 0.5, 2.0]
transform.rotation_deg: [1.0, 3.0, 10.0]
fixed:
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.seed: 43
transform.translation_mm: 5.0
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 1.0
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -1,74 +0,0 @@
# Deflection-recovery sweep: vary the INJECTED panel-deflection angle and
# verify the algorithm reads it back. Single-axis at a time so the
# response of theta_right and theta_ref can be analysed independently.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
panel_deflection.theta_right_deg: [0.0, 0.1, 0.3, 0.5, 1.0, 2.0]
fixed:
panel_deflection.enabled: true
panel_deflection.theta_ref_deg: 0.0
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.sigma_mm: 0.05
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -1,73 +0,0 @@
# Point-cloud density sweep: fix deflection + noise, vary num_points to
# show how many points the algorithm needs.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
sampling.num_points: [200000, 350000, 500000, 750000, 1000000]
fixed:
panel_deflection.enabled: true
panel_deflection.theta_right_deg: 0.5
panel_deflection.theta_ref_deg: 0.3
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.sigma_mm: 0.05
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 28.0] # wider window for low-density tests
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -1,73 +0,0 @@
# Noise sweep: fix the panel deflection at (theta_right=0.5°, theta_ref=0.3°)
# and vary Gaussian noise sigma to characterise robustness.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
noise.sigma_mm: [0.0, 0.025, 0.05, 0.1, 0.2, 0.5]
fixed:
panel_deflection.enabled: true
panel_deflection.theta_right_deg: 0.5
panel_deflection.theta_ref_deg: 0.3
sampling.method: poisson_disk
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -1,78 +0,0 @@
# Sampling-strategy sweep: 4 methods × same other settings, so the saved
# intermediates per run/<method>/02_sampled.ply, 03_noisy.ply, ... can be
# compared apples-to-apples.
stp_path: D:/myDocuments/PKU/high_speed_train_proj/lu1-M000004835653_AF4_ASMP072708187_N_AF4.stp
matrix:
sampling.method:
- uniform_area
- poisson_disk
- adaptive
- grid_spacing
fixed:
panel_deflection.enabled: true
panel_deflection.theta_right_deg: 0.5
panel_deflection.theta_ref_deg: 0.3
sampling.spacing_mm: null
sampling.num_points: 500000
sampling.seed: 42
scan_simulator.enabled: true
scan_simulator.view_origin: [10000.0, 0.0, 0.0]
scan_simulator.hpr_radius_mm: 200000.0
noise.enabled: true
noise.sigma_mm: 0.05
noise.mode: isotropic
noise.seed: 43
transform.enabled: true
transform.rotation_deg: 0.5
transform.translation_mm: 2.0
transform.rotation_axis: random
transform.translation_dir: random
transform.seed: 44
cad.locating_hole_radius_mm: [14.0, 18.0]
cad.central_hole_radius_min_mm: 80.0
cad.linear_deflection_mm: 0.5
cad.angular_deflection_rad: 0.1
detection.panel_normal_seed: pca
detection.grid_pixel_mm: 2.0
detection.fill_bg_below_mm2: 100.0
detection.hole_min_area_mm2: 600.0
detection.hole_max_area_mm2: 5000.0
detection.locating_hole_radius_mm: [14.0, 22.0]
detection.panel_min_area_ratio: 0.15
detection.rectangle_aspect_tol: 0.40
detection.circle_fit.method: algebraic_then_lsq
detection.circle_fit.inlier_residual_mm: 1.5
roi.hole_dilate_mm: 5.0
roi.central_hole_dilate_mm: 10.0
roi.edge_buffer_mm: 8.0
roi.chamfer_normal_dot_min: 0.80
roi.outlier_z_score: 3.0
plane_fit.ransac_iterations: 300
plane_fit.ransac_inlier_thresh_mm: auto
plane_fit.min_inlier_ratio: 0.5
plane_fit.enforce_nx_positive: true
metrics.right_axis_sign: 1
metrics.angle_clamp_deg: 0.0
registration.method: kabsch_four_holes
registration.refuse_if_residual_above_mm: 5.0
denoise.steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5

View File

@@ -1,16 +0,0 @@
# Parameter sweep over Gaussian noise sigma.
matrix:
noise.sigma_mm:
- 0.0
- 0.25
- 0.5
- 1.0
- 2.0
- 4.0
- 10.0
- 20.0
fixed:
sampling.method: poisson_disk
sampling.spacing_mm: 1.0
transform.rotation_deg: 3.0
transform.translation_mm: 5.0

View File

@@ -1,12 +0,0 @@
# Parameter sweep over sampling methods (other knobs fixed at default).
matrix:
sampling.method:
- uniform_area
- poisson_disk
- adaptive
- grid_spacing
fixed:
sampling.spacing_mm: 1.0
noise.sigma_mm: 0.5
transform.rotation_deg: 3.0
transform.translation_mm: 5.0

View File

@@ -1,16 +0,0 @@
# Parameter sweep over GT rigid transform magnitude.
matrix:
transform.rotation_deg:
- 1.0
- 3.0
- 5.0
- 10.0
transform.translation_mm:
- 0.5
- 1.0
- 5.0
- 10.0
fixed:
sampling.method: poisson_disk
sampling.spacing_mm: 1.0
noise.sigma_mm: 0.5

View File

@@ -1,52 +0,0 @@
# coupler-panel-validation — locked dependency list.
#
# Versions in this file are the ones the package has been *end-to-end
# verified* against on Python 3.10 (Windows 11). For a clean install use:
#
# python -m pip install -r requirements.txt
# pip install -e .
#
# If you only need a subset of the functionality, use the optional
# extras declared in pyproject.toml instead:
#
# pip install -e ".[cloud]" # core + point-cloud IO + denoise
# pip install -e ".[cad]" # core + STP parsing + GT extraction
# pip install -e ".[viz]" # core + matplotlib diagnostics
# pip install -e ".[report]" # core + Excel batch reporting
# pip install -e ".[all]" # everything above
#
# ---------------------------------------------------------------------------
# Core compute — required even for the thinnest integration deployment.
# ---------------------------------------------------------------------------
numpy==1.26.4
scipy==1.15.3
pyyaml==6.0.2
tqdm==4.65.0
# ---------------------------------------------------------------------------
# Point-cloud IO + denoise helpers (PLY/PCD read & write, voxel/SOR/ROR,
# normal-via-PCA). Required for the main pipeline; the package will fall
# back to ASCII-only PLY reading without open3d, which is not enough for
# the binary PLYs produced by the data generator.
# ---------------------------------------------------------------------------
open3d==0.19.0
trimesh==4.12.2
# ---------------------------------------------------------------------------
# STP / STEP parsing — required if you pass cad_stp_path. (Skip this group
# if you don't have CAD STP at integration time and instead pre-compute
# the panel-normal seed yourself.)
# ---------------------------------------------------------------------------
cadquery-ocp==7.8.1.1.post1
# ---------------------------------------------------------------------------
# Diagnostic PNG renders + offscreen 3D screenshots — only used by
# coupler_panel.viz.render_all (optional).
# ---------------------------------------------------------------------------
matplotlib==3.10.8
# ---------------------------------------------------------------------------
# Excel batch-eval reporting — required if you use
# coupler_panel.reporting.write_excel_report or examples/run_batch_eval.py.
# ---------------------------------------------------------------------------
openpyxl==3.1.2

File diff suppressed because it is too large Load Diff

View File

@@ -1,94 +0,0 @@
schema_version: '1.0'
cad:
linear_deflection_mm: 0.5
angular_deflection_rad: 0.1
locating_hole_radius_mm:
- 14.0
- 18.0
central_hole_radius_min_mm: 80.0
rectangle_aspect_tol: 0.15
sampling:
method: grid_spacing
spacing_mm: null
num_points: 500000
seed: 42
adaptive:
curvature_weight: 1.0
min_spacing_factor: 0.25
max_spacing_factor: 4.0
scan_simulator:
enabled: true
view_origin:
- 10000.0
- 0.0
- 0.0
hpr_radius_mm: 200000.0
panel_deflection:
enabled: true
theta_right_deg: 0.5
theta_ref_deg: 0.3
noise:
enabled: true
sigma_mm: 0.05
mode: isotropic
seed: 43
transform:
enabled: true
rotation_deg: 0.5
rotation_axis: random
translation_mm: 2.0
translation_dir: random
seed: 44
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5
smoothing:
enabled: false
search_radius_mm: 2.0
polynomial_order: 2
registration:
method: kabsch_four_holes
pre_align: pca_centroid
refuse_if_residual_above_mm: 5.0
detection:
panel_normal_seed: pca
panel_normal_pca_kth: 1
grid_pixel_mm: 2.0
panel_min_area_ratio: 0.15
hole_min_area_mm2: 600.0
hole_max_area_mm2: 5000.0
circle_fit:
method: algebraic_then_lsq
inlier_residual_mm: 1.5
select_top_k_by_radius_match: 4
rectangle_aspect_tol: 0.4
fill_bg_below_mm2: 100.0
locating_hole_radius_mm:
- 14.0
- 22.0
roi:
hole_dilate_mm: 5.0
central_hole_dilate_mm: 10.0
edge_buffer_mm: 8.0
chamfer_normal_dot_min: 0.8
outlier_z_score: 3.0
plane_fit:
method: ransac_svd
ransac_iterations: 300
ransac_inlier_thresh_mm: 0.5
min_inlier_ratio: 0.5
enforce_nx_positive: true
metrics:
right_axis_sign: 1
angle_clamp_deg: 0.0
artifacts:
save_intermediate: true
ply_format: binary
image_dpi: 150
heatmap_colormap: viridis

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,70 +0,0 @@
{
"stp": "test_data\\lu1.stp",
"profile": "lu1_af4",
"gt": "gt.json",
"samples": [
{
"sample_id": "rp000_zp000",
"ply": "cloud_rp000_zp000.ply",
"theta_right_deg": 0.0,
"theta_ref_deg": 0.0,
"n_points": 96662
},
{
"sample_id": "rp010_zp000",
"ply": "cloud_rp010_zp000.ply",
"theta_right_deg": 0.1,
"theta_ref_deg": 0.0,
"n_points": 96459
},
{
"sample_id": "rp030_zp000",
"ply": "cloud_rp030_zp000.ply",
"theta_right_deg": 0.3,
"theta_ref_deg": 0.0,
"n_points": 96605
},
{
"sample_id": "rp050_zp000",
"ply": "cloud_rp050_zp000.ply",
"theta_right_deg": 0.5,
"theta_ref_deg": 0.0,
"n_points": 96706
},
{
"sample_id": "rp100_zp000",
"ply": "cloud_rp100_zp000.ply",
"theta_right_deg": 1.0,
"theta_ref_deg": 0.0,
"n_points": 96664
},
{
"sample_id": "rp200_zp000",
"ply": "cloud_rp200_zp000.ply",
"theta_right_deg": 2.0,
"theta_ref_deg": 0.0,
"n_points": 96713
},
{
"sample_id": "rp000_zp050",
"ply": "cloud_rp000_zp050.ply",
"theta_right_deg": 0.0,
"theta_ref_deg": 0.5,
"n_points": 96666
},
{
"sample_id": "rp050_zp050",
"ply": "cloud_rp050_zp050.ply",
"theta_right_deg": 0.5,
"theta_ref_deg": 0.5,
"n_points": 96614
},
{
"sample_id": "rp100_zp100",
"ply": "cloud_rp100_zp100.ply",
"theta_right_deg": 1.0,
"theta_ref_deg": 1.0,
"n_points": 96601
}
]
}

View File

@@ -1,200 +0,0 @@
# underframe-deflection
**从扫描点云测量高铁底架纵梁挠度的 Python 算法包:接受"CAD 基准 + 扫描点云 + ROI JSON"三件套输入,自动配准、去噪、识别梁、出 Excel 误差报表。**
[![python](https://img.shields.io/badge/python-3.10%2B-blue)]() [![version](https://img.shields.io/badge/version-1.1.0-green)]() [![status](https://img.shields.io/badge/status-beta-yellow)]()
---
## 这是什么
输入一份现场扫描点云(`.ply / .xyz`),一份 CAD 基准(`.ply / .stl / .obj`),一份描述测量区域的 ROI JSON,本包会:
1. **去噪**:passthrough → 统计离群 → 半径离群 → 双边滤波 → 体素降采样
2. **配准**:用 ICP 把扫描点云刚性对齐到 CAD 坐标系
3. **识别梁**:沿车长切片、Beam-search 跟踪左右纵梁
4. **拟合**:鲁棒样条拟合代表线
5. **找参考**:基于车体孔特征或线端点定参考线
6. **算挠度**:沿 X 轴逐点计算并选出绝对值最大的测量点
输出:完整 JSON 报告、Excel 误差报表(5 sheet)、沿 X 的挠度 CSV、候选切片诊断 CSV、5 张诊断 PNG、4 个着色 PLY 叠加图。
---
## 安装
```bash
# 进入本目录后
pip install -e .[all] # 推荐:core + matplotlib + openpyxl
# 或仅安装核心 + 自己选 extra
pip install -e . # 仅 numpy + scipy(只能调用 compute_deflection)
pip install -e .[viz] # +matplotlib(PNG 诊断图)
pip install -e .[excel] # +openpyxl(.xlsx 报表)
# 或直接用 requirements 锁版本
pip install -r requirements.txt
```
安装后会注册命令行 `underframe-measure`,Python 内可直接 `import underframe_deflection`。
---
## 上手
```bash
underframe-measure \
--input-cloud scan.ply \
--cad reference.ply \
--roi roi.json \
--out-dir out/
```
或者 Python 调用:
```python
from underframe_deflection import run_measurement_pipeline
report = run_measurement_pipeline(
input_cloud="scan.ply",
cad_path="reference.ply",
roi_path="roi.json",
out_dir="out/",
)
print(report["deflection"]["left"]["selected_measurement"]["signed_deflection_mm"])
print(report["deflection"]["right"]["selected_measurement"]["signed_deflection_mm"])
```
`test_data/` 自带可直接运行的样例:
```bash
underframe-measure \
--input-cloud test_data/m000004802409t02_asm_100000pts.ply \
--cad test_data/m000004802409t02_asm_100000pts.ply \
--roi test_data/sample_roi.json \
--out-dir test_data/runs/demo
```
---
## 文档导览
| 文件 | 用途 |
| --- | --- |
| **`README.md`** (本文件) | 仓库概览,30 秒上手 |
| **`USAGE.md`** | 集成开发者完整指南:三种调用方式、输入格式、读结果、异常、配置覆盖、模板 |
| `requirements.txt` | 锁定版本的依赖清单 |
| `pyproject.toml` | 打包描述(pip install 的入口) |
---
## 仓库结构
```
underframe_deflection/
├── README.md ← 本文件
├── USAGE.md ← 集成开发者文档
├── requirements.txt
├── pyproject.toml ← pip 打包描述(无需 setup.py)
├── _version.py ← __version__ / ALGORITHM_VERSION / SCHEMA_VERSION
├── __init__.py ← 公开 API 出口
│
├── api.py ← compute_deflection() 主入口
├── pipeline.py ← run_measurement_pipeline() 端到端编排
├── cli.py ← 命令行入口(underframe-measure)
│
├── config.py ← DEFAULT_CONFIG 与档位
├── preprocess.py ← SOR / radius / voxel / normals / bilateral
├── cad_io.py ← CAD 读取 (PLY/XYZ/STL/OBJ)
├── registration.py ← PCA 粗对齐 + trimmed ICP
├── roi.py ← ROI JSON 读取 + AABB passthrough
│
├── frame.py ← 车体坐标系推断 + ROI mask
├── beams.py ← 切片 + 候选条带 + Beam-search 跟踪
├── fitting.py ← 代表点 + 鲁棒样条拟合
├── reference.py ← 参考线检测(孔 / 端点)
│
├── artifacts.py ← JSON / CSV / PNG / PLY writer
├── excel_report.py ← Excel 写入器 (5 sheet)
├── ply_overlay.py ← 着色 PLY 叠加图
├── visualization.py ← 诊断 PNG 绘图
│
├── io.py ← 文件 I/O 工具
├── exceptions.py ← 公开异常层级
├── structures.py ← 内部数据类
│
└── test_data/
├── m000004802409t02_asm.stp ← 原始 STEP(仅参考,不被解析)
├── m000004802409t02_asm_100000pts.ply ← STEP 采样得到的 CAD 基准点云
└── sample_roi.json ← 样例 ROI(AABB)
```
---
## 输入约定
所有点云的坐标轴必须:
- **X**:纵向(车长)
- **Y**:横向(车宽)
- **Z**:高度,**向上为正**
- 单位:**毫米**
CAD 基准也必须遵循同样的轴向;ROI JSON 中的 bbox 字段在 CAD 坐标系下。
如果你的扫描设备轴向不同,请在调用前自行做旋转/置换。
---
## 公开 API
```python
from underframe_deflection import (
# 主入口
compute_deflection, # 纯内存计算
run_measurement_pipeline, # 文件读 + 算 + 文件写
# 单步工具(高级用法)
load_point_cloud, # 读扫描点云
read_cad_reference, # 读 CAD
load_roi_aabb, # 读 ROI JSON
register_to_cad, # ICP 配准
bilateral_filter_points, # 双边滤波
apply_passthrough, # AABB 过滤
# 结果数据类
DeflectionResult,
SideDeflection,
DeflectionCurves,
RegistrationResult,
# 异常
DeflectionError,
InvalidPointCloudError,
InsufficientPointsError,
UnsupportedFormatError,
FrameInferenceError,
# 版本
__version__,
ALGORITHM_VERSION,
SCHEMA_VERSION,
)
```
详见 [`USAGE.md`](./USAGE.md)。
---
## 状态与版本
- **当前版本**:1.1.0
- **算法版本**:0.1.0(v1.1 没有动算法主流程,仅扩展 I/O / 预处理 / 配准)
- **Schema 版本**:1.0(v1.1 仅新增可选字段,向后兼容)
- **Python**:≥ 3.10(Windows / Linux / macOS 通用)
---
## License
Proprietary — PKU High-Speed Train Project.

View File

@@ -1,363 +0,0 @@
# underframe_deflection 集成调用指南 (v1.1)
> 高铁底架纵梁挠度测量算法 —— 基于点云的计算。
> 本文档面向把本算法集成进上层软件的开发者:怎么准备 **CAD 基准 + 扫描点云 + ROI JSON** 三件套输入、怎么调用、怎么读结果。
---
## 0. TL;DR
```python
from underframe_deflection import run_measurement_pipeline
report = run_measurement_pipeline(
input_cloud="scan.ply",
cad_path="reference.ply",
roi_path="roi.json",
out_dir="out/",
)
# 关心的两个核心数值
print(report["deflection"]["left"]["selected_measurement"]["signed_deflection_mm"])
print(report["deflection"]["right"]["selected_measurement"]["signed_deflection_mm"])
```
落盘后会得到:
```
out/
├── report.json # 完整结果
├── report.xlsx # 误差报表(Summary + 4 张中间表)
├── deflection_curves.csv # 沿 X 的挠度曲线
├── beam_tracks.csv # 候选梁切片诊断
├── images/ # 5 张诊断 PNG
└── ply/ # 4 个着色 PLY 叠加图
```
---
## 1. 算法的三个输入
| 输入 | 类型 | 必需 | 说明 |
| --- | --- | --- | --- |
| **扫描点云** | `.ply / .xyz / .txt / .csv` | ✅ 必须 | 现场采集得到的 (N, 3) XYZ 点云,单位 mm |
| **CAD 基准** | `.ply / .xyz / .stl / .obj` | 可选但强烈建议 | 预先 tessellate / 采样过的 CAD 几何,作为配准基准坐标系;不直接吃 STEP/IGES |
| **ROI JSON** | `.json` | 可选但强烈建议 | 描述车底测量区域的轴对齐 bbox,坐标基于 CAD 坐标系 |
> 三者**全给**时走 v1.1 完整流程:去噪 → 配准到 CAD 系 → 按 ROI JSON 截区 → 算挠度 → 出 Excel。
> 只给扫描点云时走 legacy 流程,与本算法 v1.0 行为完全一致(仅多出 bilateral 这一步可用 `--no-bilateral` 关掉)。
### 1.1 ROI JSON 文件格式
固定 6 个 float 字段,单位 mm,CAD 坐标系下:
```json
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}
```
约束:每个轴 `min < max`,且都是有限实数;任一字段缺失或非法 → `InvalidPointCloudError`。
`test_data/sample_roi.json` 是一个可直接使用的样例。
### 1.2 CAD 基准
- **不解析 STEP / IGES**。如果你只有 `.stp`,请用 CAD 软件或离线脚本先把它转成 `.stl` / `.ply` / `.obj`,再喂给本算法。
- 三角网格的话只读顶点位置,face 信息忽略 —— 算法内部把 CAD 当作"参考点集"用 ICP 对齐。
- `test_data/m000004802409t02_asm_100000pts.ply` 是从原始 STEP 采样得到的 10 万点 PLY,可直接当 CAD 基准。
---
## 2. 三种调用方式
### 2.1 命令行(最简单的集成方式)
```bash
underframe-measure \
--input-cloud scan.ply \
--cad reference.ply \
--roi roi.json \
--out-dir out/
```
可选开关:
| 开关 | 含义 |
| --- | --- |
| `--config CONFIG.json` | 覆盖部分内部超参数(结构同 `DEFAULT_CONFIG`) |
| `--profile {auto,M,TC,TP,custom}` | 调参档位 |
| `--skip-registration` | 给了 `--cad` 但不跑 ICP(适合调试或扫描已经预对齐) |
| `--no-bilateral` | 关闭双边滤波(v1.0 行为) |
| `--skip-excel` | 不写 `.xlsx` |
未安装本包时,等价命令:
```bash
python -m underframe_deflection.cli --input-cloud scan.ply ...
```
### 2.2 一次性 Python 函数(推荐)
```python
from underframe_deflection import run_measurement_pipeline
report = run_measurement_pipeline(
input_cloud="scan.ply",
cad_path="reference.ply", # 可选
roi_path="roi.json", # 可选
out_dir="out/",
config_path=None, # 可选 JSON 配置覆盖
profile="auto", # 可选档位
skip_registration=False,
enable_bilateral=True,
skip_excel=False,
)
```
**返回**:`report` 是一个 `dict`,结构与 `out/report.json` 完全一致。
### 2.3 纯内存计算(不读写文件)
如果你已经从其它来源拿到了点云和 ROI(例如直接从相机驱动 / 数据库取数据),可以避开文件 I/O:
```python
import numpy as np
from underframe_deflection import compute_deflection, load_point_cloud
points = load_point_cloud("scan.ply") # 或自己构造 (N, 3) ndarray
roi_aabb = { # 与 ROI JSON 同结构的 dict
"x_min": -12500, "x_max": 12500,
"y_min": 150, "y_max": 4100,
"z_min": -1680, "z_max": 200,
}
result = compute_deflection(points, roi_aabb=roi_aabb)
print(result.left.selected_signed_mm, result.right.selected_signed_mm)
```
> 注意:`compute_deflection` 本身**不做 CAD 配准**。如果你想自己控制配准过程,先调用 `register_to_cad`,把变换应用到点云后再喂给 `compute_deflection`,配合 `config["frame"]["use_input_axes"]=True`(这本来就是默认值)即可:
```python
from underframe_deflection import (
read_cad_reference, register_to_cad, compute_deflection,
)
from underframe_deflection.registration import apply_transform
scan = load_point_cloud("scan.ply")
cad = read_cad_reference("reference.ply")
# 你需要一份预解算好的 config(spacing 之类)。最简单的办法:
from underframe_deflection.config import load_config, resolve_numeric_parameters, cloud_geometry
from underframe_deflection.preprocess import estimate_spacing
cfg = load_config(None, "auto")
cfg = resolve_numeric_parameters(cfg, cloud_geometry(scan), estimate_spacing(scan))
reg = register_to_cad(scan, cad, cfg)
scan_aligned = apply_transform(scan, reg.R, reg.t)
result = compute_deflection(scan_aligned, roi_aabb=roi_aabb)
```
---
## 3. 读取输出
### 3.1 核心数值(最常用)
```python
left = report["deflection"]["left"]["selected_measurement"]
right = report["deflection"]["right"]["selected_measurement"]
if left["available"]:
x_mm = left["x_mm"]
signed = left["signed_deflection_mm"] # 带符号,向下为负
absolute = left["absolute_deflection_mm"]
type_ = left["selected_type"] # "downward" / "upward"
beam_pt = left["beam_point"] # [x, y, z]
ref_pt = left["reference_point"] # [x, y, z]
```
如果你用的是 `compute_deflection`,公开数据类更直接:
```python
result.left.selected_signed_mm
result.left.max_downward_mm
result.left.max_downward_x_mm
result.left.max_upward_mm
result.left.reference_confidence
result.curves.x_mm # ndarray
result.curves.left_deflection_mm # ndarray, NaN 表示该 X 处无效
```
### 3.2 质量警告 `quality_flags`
**非致命**问题列在这里,调用方应判断是否要重测:
| Flag | 含义 |
| --- | --- |
| `left_track_missing` / `right_track_missing` | 单侧梁追踪失败 |
| `left_representative_line_missing` / `right_...` | 单侧代表线拟合失败 |
| `left_reference_unavailable` / `right_...` | 单侧参考线不可用 |
| `left_reference_low_confidence` / `right_...` | 参考线置信度低 |
| `left_track_low_coverage` / `right_...` | 单侧追踪覆盖率 < 45% |
| `left_right_symmetry_warning` | 左右对称性偏差 > 18% |
| **`registration_not_converged`** | ICP 未收敛到容差内 |
| **`registration_poor_quality`** | ICP 最终 RMS > max(10 mm, 2×点间距) |
| **`registration_low_inlier_ratio`** | ICP 内点比例 < 50% |
| **`passthrough_empty`** | ROI passthrough 后点过少,已自动回退 |
| **`bilateral_degraded`** | bilateral k 邻域被裁剪 |
加粗的几项是 v1.1 新增。
### 3.3 配准信息
```python
reg = report["registration"]
if reg["performed"]:
reg["rms_mm"] # 最终对齐残差
reg["initial_rms_mm"] # 粗对齐后的初始残差
reg["iterations"]
reg["inlier_ratio"]
reg["converged"] # bool
reg["R"] # 3x3 list
reg["t"] # length-3 list
```
### 3.4 Excel 误差报表
`out/report.xlsx` 5 个 sheet:
| Sheet | 内容 |
| --- | --- |
| **Summary** | key/value 形式:版本、输入路径、配准统计、预处理各阶段计数、左右挠度核心数值、质量警告 |
| **Deflection_Curves** | 沿 X 的逐点表:x / 左右 beam_y, beam_z / 左右 ref_y, ref_z / 左右 deflection / 左右 confidence |
| **Beam_Tracks** | 每片候选条带:slice_index、y_center、z_low、width、score、selected_rank 等 |
| **Reference_Lines** | 左右参考线 front/back 端点、direction、residual、confidence |
| **Representative_Points** | 左右每个代表点的 x/y/z/width/residual/confidence |
> 用 `openpyxl`、`pandas.read_excel`、Excel/LibreOffice 都可以直接读。
### 3.5 异常
| 异常类 | 触发条件 |
| --- | --- |
| `InvalidPointCloudError` | 点云 / CAD / ROI JSON 文件格式非法、字段缺失、AABB 反转 |
| `UnsupportedFormatError` | 文件后缀不在支持列表 |
| `InsufficientPointsError` | 点数过少(点云 < 256)或 ROI 过滤后无重叠 |
| `FrameInferenceError` | 自动坐标系推断失败(仅 legacy 路径) |
集成方应在外层 `try / except DeflectionError` 兜底,并把 `quality_flags` 落入日志或上层告警。
---
## 4. 配置覆盖(高级)
绝大多数场景不需要碰配置。需要微调时,通过 `--config my.json` 或 `config=` 传入嵌套 dict,与 `DEFAULT_CONFIG` 深度合并:
```python
config = {
"preprocess": {
"bilateral": {"enabled": True, "k": 32, "sigma_n_deg": 10.0},
# 也可以从外部传 passthrough,但通常用 ROI JSON 即可
},
"registration": {
"icp_max_iter": 80,
"trim_ratio": 0.15,
},
"reference": {
"low_confidence_threshold": 0.5,
},
"deflection": {
"evaluation_grid_factor": 0.5, # 更密的挠度采样
},
}
```
完整字段定义见 `underframe_deflection/config.py: DEFAULT_CONFIG`。
---
## 5. 进度回调
`compute_deflection` 支持 `progress` 回调:
```python
def on_progress(stage: str, fraction: float) -> None:
print(f"[{fraction:5.1%}] {stage}")
result = compute_deflection(points, roi_aabb=roi_aabb, progress=on_progress)
```
阶段顺序:`preprocess → frame → beam_tracking → line_fitting → reference → deflection`。
`run_measurement_pipeline` 当前未透传 progress 回调,需要时请直接调 `compute_deflection`。
---
## 6. 坐标系约定
输入点云**必须**满足以下约定,否则结果无意义:
- **X 轴**:纵向(车长方向)
- **Y 轴**:横向(车宽方向)
- **Z 轴**:高度方向,**向上为正**
- 单位:毫米
CAD 基准也必须遵循同样的轴向;ROI JSON 中的 bbox 字段在 CAD 坐标系下。
扫描点云轴向不同 → 调用前自行做置换 / 旋转。
---
## 7. 版本兼容性
- `__version__` —— 包版本
- `ALGORITHM_VERSION` —— 算法版本(升级会改变数值结果)
- `SCHEMA_VERSION` —— `report.json` 结构版本(仅在破坏性变更时递增;新字段不递增)
v1.1 新增了非破坏性的可选字段(`cad_reference / roi_source / roi_aabb / registration / preprocess.passthrough / preprocess.bilateral / roi.source / quality_flags 新值`),`SCHEMA_VERSION` 保持 `"1.0"` 不变。
---
## 8. 集成模板
```python
import logging
from underframe_deflection import (
run_measurement_pipeline,
DeflectionError,
)
log = logging.getLogger(__name__)
def measure(scan_path: str, cad_path: str, roi_path: str, out_dir: str) -> dict | None:
try:
report = run_measurement_pipeline(
input_cloud=scan_path,
cad_path=cad_path,
roi_path=roi_path,
out_dir=out_dir,
)
except DeflectionError as exc:
log.error("deflection failed: %s", exc)
return None
flags = report.get("quality_flags") or []
if flags:
log.warning("quality flags: %s", flags)
return {
"schema_version": report["schema_version"],
"algorithm_version": report["algorithm_version"],
"left_signed_mm": report["deflection"]["left"].get("selected_measurement", {}).get("signed_deflection_mm"),
"right_signed_mm": report["deflection"]["right"].get("selected_measurement", {}).get("signed_deflection_mm"),
"registration_rms_mm": report["registration"].get("rms_mm"),
"flags": flags,
}
```

View File

@@ -1,83 +0,0 @@
"""Command-line entry point installed by ``pyproject.toml``.
This mirrors the project-root ``run_underframe_measurement.py`` script so
that ``pip install`` exposes a console command (``underframe-measure``).
"""
from __future__ import annotations
import argparse
from .pipeline import run_measurement_pipeline
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Measure underframe longitudinal beam deflection from a point cloud.",
)
parser.add_argument(
"--input-cloud",
required=True,
help="Path to input scan point cloud (.ply or .xyz).",
)
parser.add_argument(
"--out-dir",
required=True,
help="Directory for report.json/.xlsx, CSV files, PLY overlays, and PNG plots.",
)
parser.add_argument(
"--cad",
default=None,
help="Optional CAD reference (.ply/.xyz/.stl/.obj) used as the registration baseline.",
)
parser.add_argument(
"--roi",
default=None,
help="Optional ROI JSON file holding an axis-aligned bbox in the CAD frame.",
)
parser.add_argument(
"--config",
default=None,
help="Optional JSON config override file.",
)
parser.add_argument(
"--profile",
default="auto",
choices=["auto", "M", "TC", "TP", "custom"],
help="Optional profile for scale-related tuning.",
)
parser.add_argument(
"--skip-registration",
action="store_true",
help="Bypass ICP even when --cad is supplied (useful for debugging).",
)
parser.add_argument(
"--no-bilateral",
action="store_true",
help="Disable the bilateral denoising step.",
)
parser.add_argument(
"--skip-excel",
action="store_true",
help="Skip writing the .xlsx error report.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
run_measurement_pipeline(
input_cloud=args.input_cloud,
out_dir=args.out_dir,
config_path=args.config,
profile=args.profile,
cad_path=args.cad,
roi_path=args.roi,
skip_registration=args.skip_registration,
enable_bilateral=not args.no_bilateral,
skip_excel=args.skip_excel,
)
if __name__ == "__main__":
main()

View File

@@ -1,88 +0,0 @@
from __future__ import annotations
import csv
import json
from pathlib import Path
from typing import Iterable
import numpy as np
def read_point_cloud(path: str | Path) -> np.ndarray:
path = Path(path)
suffix = path.suffix.lower()
if suffix == ".ply":
return _read_ascii_ply(path)
if suffix in {".xyz", ".txt", ".csv"}:
return np.loadtxt(path, dtype=np.float64)
raise ValueError(f"Unsupported point cloud format: {path.suffix}")
def _read_ascii_ply(path: Path) -> np.ndarray:
header_lines = 0
vertex_count = None
with path.open("r", encoding="ascii", errors="strict") as fh:
for line in fh:
header_lines += 1
stripped = line.strip()
if stripped.startswith("element vertex"):
vertex_count = int(stripped.split()[-1])
if stripped == "end_header":
break
points = np.loadtxt(path, skiprows=header_lines, dtype=np.float64)
if points.ndim == 1:
points = points[None, :]
if points.shape[1] < 3:
raise ValueError(f"PLY file does not contain XYZ coordinates: {path}")
points = points[:, :3]
if vertex_count is not None and len(points) != vertex_count:
raise ValueError(
f"PLY vertex count mismatch: header={vertex_count}, data={len(points)}"
)
return points
def ensure_output_dir(path: str | Path) -> Path:
out_dir = Path(path)
out_dir.mkdir(parents=True, exist_ok=True)
return out_dir
def write_json(path: str | Path, payload: dict) -> None:
Path(path).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def write_csv(path: str | Path, fieldnames: list[str], rows: Iterable[dict]) -> None:
with Path(path).open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(row)
def write_colored_ply(
path: str | Path,
points: np.ndarray,
colors: np.ndarray,
) -> None:
path = Path(path)
if len(points) != len(colors):
raise ValueError("Point/color length mismatch for PLY export.")
points = np.asarray(points, dtype=np.float64)
colors = np.asarray(colors, dtype=np.uint8)
with path.open("w", encoding="ascii", newline="\n") as fh:
fh.write("ply\n")
fh.write("format ascii 1.0\n")
fh.write(f"element vertex {len(points)}\n")
fh.write("property float x\n")
fh.write("property float y\n")
fh.write("property float z\n")
fh.write("property uchar red\n")
fh.write("property uchar green\n")
fh.write("property uchar blue\n")
fh.write("end_header\n")
for (x, y, z), (r, g, b) in zip(points, colors, strict=True):
fh.write(f"{x:.6f} {y:.6f} {z:.6f} {int(r)} {int(g)} {int(b)}\n")

View File

@@ -1,8 +0,0 @@
{
"x_min": -12500.0,
"x_max": 12500.0,
"y_min": 150.0,
"y_max": 4100.0,
"z_min": -1680.0,
"z_max": 200.0
}

View File

@@ -0,0 +1,68 @@
# coupler-panel-validation
高铁车钩面板**偏转角测量**算法。输入待测点云 + ROI + 理论 CAD,输出面板相对车体纵轴的两个方向偏转角及最大偏差,并生成 Excel 报表。
## 这个项目做什么
给定一块车钩安装面板的扫描点云,算法测量它相对理论安装姿态的**偏转**:
- `θ_right` —— 面板法向在一个方向上的偏转角(度)
- `θ_ref` —— 面板法向在另一个方向上的偏转角(度)
- `dx_y_max` / `dx_z_max` —— 由偏转引起的最大 x 向偏差(mm)
支持两种输入规模:
- **单零件点云**(对零件 STP 采样得到的稠密点云)
- **整车扫描点云**(GB 级 PLY,用 ROI 框出车钩区域后内部流式裁剪)
## 三件套输入(集成主路径)
| 输入 | 说明 |
|---|---|
| **ROI JSON** | 6 个数的轴对齐包围盒,框出待测面板区域(即裁剪超参数) |
| **点云 PLY** | 待测点云,可为整车(多 GB),算法内部按 ROI 流式裁剪 |
| **CAD STP** | 理论模型,作辅助 / 审计 |
输出:`eval_report.xlsx`(含两个偏转角的 GT / 预测 / 误差 + 中间量 + 运行时间)。
## 快速开始
```bash
# 安装(锁定版本见 requirements.txt)
python -m pip install -r requirements.txt
pip install -e ".[all]"
# 三件套集成入口
python examples/run_integration.py \
--roi examples/roi_train2_coupler.json \
--cloud /path/to/train_2_1T.ply \
--stp /path/to/asm.stp \
--out runs/train2 \
--profile train2_coupler
```
库调用:
```python
from coupler_panel.pipeline import run_coupler_measurement_pipeline
from coupler_panel.roi_select import load_roi_json
result = run_coupler_measurement_pipeline(
input_cloud = "train_2_1T.ply",
out_dir = "runs/train2",
profile = "train2_coupler",
roi_spec = load_roi_json("roi_train2_coupler.json"),
)
print(result.theta_right_deg, result.theta_ref_deg)
```
## 文档
| 文档 | 内容 |
|---|---|
| [USAGE.md](USAGE.md) | **集成方使用指南** —— 三件套输入、API、ROI JSON、profile、输出、坐标系 |
| [CHANGES.md](CHANGES.md) | **修改记录** —— 每轮改了什么、为什么、遇到的问题与定位解决 |
## 坐标系约定
- 算法以面板法向 `n=(n_x,n_y,n_z)` 相对 x 轴的夹角定义偏转,强制 `n_x>0`。
- **坐标轴含义由数据集决定**:项目标称约定为 x=纵向、y=宽度、z=高度;但某些整车数据集为 x=车长、y=车高、z=车宽(y/z 对调)。偏转角公式对称、理论 0 不受影响,但 `D_y_max`/`D_z_max` 对应的物理轴随之对调。ROI JSON 的六个边界必须与**点云自身坐标系**一致。详见 USAGE.md。

View File

@@ -0,0 +1,256 @@
# coupler_panel — 集成开发者使用指南
> 高铁车钩面板偏转角测量算法
>
> **主输入:ROI JSON + 点云 PLY + CAD STP(三件套)**
> **主输出:含两个方向偏转角的 Excel 报表**
---
## 1. 一句话集成
```python
from coupler_panel.pipeline import run_coupler_measurement_pipeline
from coupler_panel.roi_select import load_roi_json
result = run_coupler_measurement_pipeline(
input_cloud = "train_2_1T.ply", # 待测点云(可为整车,内部按 ROI 流式裁剪)
out_dir = "runs/today", # 中间产物 + report.json 落盘
profile = "train2_coupler", # 该零件 profile
roi_spec = load_roi_json("roi.json"), # ROI = 裁剪超参数 ./examples/roi_train2_coupler.json
)
print(result.theta_right_deg, result.theta_ref_deg) # 两个偏转角
print(result.dx_y_max_mm, result.dx_z_max_mm) # 最大 x 偏差
```
要直接产出 Excel,用集成入口脚本(§3)。
---
## 2. 安装
```bash
python -m pip install -r requirements.txt # 锁定版本(Python 3.10 验证)
pip install -e ".[all]" # core + cloud + cad + viz + report
```
按需子集见 `pyproject.toml` 的 extras:`[cloud]` `[cad]` `[viz]` `[report]`。
---
## 3. 集成入口(三文件 → Excel)
`examples/run_integration.py` 是给集成方的标准入口:
```bash
python examples/run_integration.py \
--roi examples/roi_train2_coupler.json \ # ROI JSON
--cloud /path/to/train_2_1T.ply \ # 整车点云(可多 GB)
--stp /path/to/asm.stp \ # CAD STP(审计)
--out runs/train2 \ # 输出目录
--profile train2_coupler
```
产出 `runs/train2/eval_report.xlsx`(30 列,见 §7)+ 全部中间产物。
可选参数:`--gt-theta-right` / `--gt-theta-ref`(已知理论偏转,用于误差列,默认 0)、`--sample-id`、`--config`。
---
## 4. 三件输入说明
### 4.1 ROI JSON(裁剪超参数)
6-key 轴对齐包围盒,**坐标系必须与点云一致**,单位 mm:
```json
{
"x_min": 11526.0, "x_max": 11733.0,
"y_min": 588.0, "y_max": 1192.0,
"z_min": -950.0, "z_max": 950.0
}
```
- 点 `(x,y,z)` 保留条件:`x_min<=x<=x_max ∧ y_min<=y<=y_max ∧ z_min<=z<=z_max`
- 必须 `min<max`,否则报 `ValueError`;多余 key(如 `comment`)忽略
- **算法直接拿这六个数裁剪**。当输入是二进制 PLY 且 ROI 是有限 bbox 时,走**流式裁剪**(memmap 分块,不整体加载),可处理 GB 级整车点云
- 想全保留:传 `roi_json=None` 或 `roi_spec=default_roi_spec()`(±∞)
- 裁剪诊断写到 `out_dir/roi.json`(`mode`、`n_in`、`n_out`、`bbox`)
> **坐标系坑(必读)**:ROI 的六个边界写在**点云自己的坐标系**里。若点云是 x=车长 / y=车高 / z=车宽(部分整车数据集如此),ROI 也按此填。ROI 裁出 0 点时算法抛 `InsufficientPointsError` 并提示检查坐标系。
### 4.2 点云 PLY
- 支持 `.ply`(二进制/ASCII)/ `.pcd` / `.xyz` / `.txt` / `.csv`
- 单位 **mm**;算法只读 x/y/z 前三列(颜色/法向等额外列容忍)
- **可为整车扫描**(多 GB)——ROI 流式裁剪后才进入算法
- **算法不做配准**:假设上游扫描端外参已标定、点云已在 CAD/车体帧(原因见 §8)
### 4.3 CAD STP
当前 STP 的角色取决于规模:
- **单零件 STP**:可被 `parse_cad` 解析,用于提供面板法向 seed + 自动整定检测参数(见 §6)。传 `cad_stp_path=...`。
- **整车装配 STP**(数百 MB、数千实例):项目的 `parse_cad`(单零件设计)无法处理,此时 STP 仅作**审计/可追溯**输入(路径记入报表),不参与计算——检测参数由 profile 提供、法向用 PCA。
> 若将来需要从整车 STP 提取目标几何,需另写装配级 XCAF 解析(不能复用单零件 `parse_cad`)。
---
## 5. 主入口 API
```python
def run_coupler_measurement_pipeline(
input_cloud : str | Path, # 必填:点云路径(可整车)
out_dir : str | Path, # 必填:输出目录
*,
config_path : str | Path | None = None, # 可选:自定义 YAML / dict
profile : str = "auto", # 可选:preset profile
cad_stp_path: str | Path | None = None, # 可选:单零件 STP(法向 + autotune)
roi_spec : ROISpec | None = None, # 二选一:内存 ROI 对象
roi_json : str | Path | None = None, # 二选一:ROI JSON 路径
progress : Callable[[str, float], None] | None = None,
) -> CouplerMeasurement
```
**算法链路**:
```
crop_ply_bbox(流式 ROI 裁剪)
→ [layer_select] 沿法向隔离前层(profile 开关,去多层重叠)
→ [autotune] 从 CAD 孔径 + 点距自整定(仅默认 profile + 单零件 CAD 时)
→ denoise_cloud
→ compute_coupler_measurement
validate → detect 4 holes → build frame
→ extract ROI → fit plane(RANSAC+SVD) → metrics
```
**profile 选择**:
- 有匹配 profile(如 `train2_coupler` / `lu1_af4`)→ 直接指定,最稳。
- 无 profile 但传了单零件 `cad_stp_path` → `profile="auto"`,走自动整定(§6)。
**配置覆盖**:`config_path` 可传 dict 覆盖任意 `configs/default.yaml` 字段。
### 5.1 返回值 `CouplerMeasurement`
```python
theta_right_deg, theta_ref_deg # 两个偏转角(度)
D_y_max_mm, D_z_max_mm # 两方向最大距中心偏差
dx_y_max_mm, dx_z_max_mm # 最大 x 向偏差
center_C, normal # 面板中心、拟合法向(n_x>0)
holes, plane, frame # 4 孔 / 平面 / 参考系子结构
quality_flags, timings, report # 诊断;report == out_dir/report.json
result.to_dict() # 完全 JSON 化
```
### 5.2 异常
```
CouplerError
├── InvalidPointCloudError / UnsupportedFormatError 输入非法 / 格式不支持
├── InsufficientPointsError 点数过少 / ROI 裁出 0 点
├── CADParseError STP 解析失败
├── HoleDetectionError 4 孔检测失败
└── PlaneFitError 平面拟合失败
```
非致命问题在 `result.quality_flags`(如 `plane_inlier_ratio_low`)。
---
## 6. 检测参数:profile vs 自动整定
不同零件的定位孔半径、是否有中心孔、点云密度都不同,检测参数必须匹配,否则报 `HoleDetectionError: only N hole-shaped cavities found`。两种适配方式:
1. **per-part profile(推荐用于已知零件)**:`configs/profiles/<name>.yaml` 写死该零件的孔径带、面积窗、是否前层隔离等。已有 `train2_coupler`(整车车钩面板)、`lu1_af4`(单零件车钩面板)。
2. **autotune(单零件 + 默认 profile 时)**:传单零件 `cad_stp_path` 且 `profile=auto`、无 `config`,算法从 CAD 定位孔半径 + 去噪后点距自动推导检测参数(`coupler_panel/detection/autotune.py`)。指定具体 profile 或 config 时**完全跳过**。
> 整车场景用 profile(`train2_coupler`),因为整车 STP 不能 parse、autotune 不可用。
---
## 7. 输出
### 7.1 Excel 报表(`out_dir/eval_report.xlsx`,30 列)
样本标识、输入 PLY、ROI kind、**θ_right / θ_ref 的 GT/预测/误差**、D_y/D_z_max、dx_y/dx_z_max、中心 C(x/y/z)、法向 n(x/y/z)、平面内点率/残差、配准 RMSE/最大残差(主路径恒空)、ROI 前后点数、quality_flags、**算法运行时间 `Algorithm runtime (s)`**(输入→返回整段 wall-clock)、内核阶段累计、算法/schema 版本。
多组结果用 `coupler_panel.reporting.write_excel_report(rows, path)` 合并成一张表。
### 7.2 落盘中间产物(`out_dir/`)
| 文件 | 内容 |
|---|---|
| `config.resolved.yaml` | 实际生效的合并配置 |
| `roi.json` | ROI spec + 裁剪诊断 |
| `01_gt.json` | (单零件 CAD parse 时)审计 GT |
| `02_sampled.ply` | ROI 裁剪后的点云 |
| `transformed.ply` | (前层隔离开启时)隔离后的层 |
| `05_denoised.ply` | 去噪后点云(喂给测量算法的最终点云) |
| `report.json` | 完整结果,等于 `result.to_dict()` |
---
## 8. 为什么不做配准
车钩面板的 4 个定位孔与面板一起刚体旋转。用「扫到的 4 孔 → CAD 原始 4 孔」做 Kabsch,会精确解出「扫描器位姿 · 面板偏转」的复合刚体变换并反向作用,把偏转随位姿误差**一起消掉**,下游再读不到偏转——这是几何性质,不是 bug。
故主算法假设上游扫描端外参标定、点云已在车体帧。研究路径 `run_from_cad_pipeline`(含 Kabsch + 注入 oracle)完整保留作上限对照。
---
## 9. 集成模板
```python
import logging, time, json
from pathlib import Path
from coupler_panel.pipeline import run_coupler_measurement_pipeline
from coupler_panel.roi_select import load_roi_json
from coupler_panel.reporting import build_row_from_summary, write_excel_report
from coupler_panel.exceptions import CouplerError
log = logging.getLogger(__name__)
def measure(cloud, roi_json, stp, out_dir, profile="train2_coupler"):
t0 = time.perf_counter()
try:
r = run_coupler_measurement_pipeline(
input_cloud=cloud, out_dir=out_dir,
profile=profile, roi_spec=load_roi_json(roi_json))
except CouplerError as e:
log.error("measurement failed: %s", e)
return None
dt = time.perf_counter() - t0
diag = json.loads((Path(out_dir)/"roi.json").read_text(encoding="utf-8"))["diagnostics"]
row = build_row_from_summary(
sample_id=Path(cloud).stem, ply_path=str(cloud), roi_kind="aabb",
measured=r.to_dict(), gt_angles=(0.0, 0.0), registration=None,
n_points_in=diag.get("n_in", 0), n_points_after_roi=diag.get("n_out", 0),
pipeline_seconds=dt)
write_excel_report([row], Path(out_dir)/"eval_report.xlsx")
return r
```
---
## 10. 坐标 & 单位速查
| 量 | 单位 | 说明 |
|---|---|---|
| 点云坐标 | mm | 轴含义由数据集决定(见 README / §4.1) |
| 偏转角 | 度 | `θ_right=atan2(n_y,n_x)`、`θ_ref=atan2(n_z,n_x)` |
| 法向 | 单位向量 | 强制 `n_x>0` |
| `right_axis_sign` | ±1 | 右方向符号(profile `metrics` 配置) |
---
## 11. 版本兼容
| 标识 | 含义 |
|---|---|
| `coupler_panel.__version__` | 包版本(语义化) |
| `coupler_panel.ALGORITHM_VERSION` | 算法版本,升级时**数值可能变化** |
| `coupler_panel.SCHEMA_VERSION` | 结果结构版本,仅破坏性变更递增 |
建议:落库时记录 `algorithm_version`;反序列化 `report.json` 时校验 `schema_version` 主版本。

View File

@@ -101,12 +101,10 @@ detection:
panel_normal_seed: from_cad # from_cad | pca | given panel_normal_seed: from_cad # from_cad | pca | given
panel_normal_pca_kth: 1 # use k-th smallest PCA eigenvector as panel normal panel_normal_pca_kth: 1 # use k-th smallest PCA eigenvector as panel normal
# 2D occupancy grid. # 2D occupancy grid.
grid_pixel_mm: 0.3 grid_pixel_mm: 0.5
panel_min_area_ratio: 0.4 # largest connected component must be ≥ this fraction of bbox panel_min_area_ratio: 0.4 # largest connected component must be ≥ this fraction of bbox
hole_min_area_mm2: 10.0 hole_min_area_mm2: 30.0
hole_max_area_mm2: 1500.0 hole_max_area_mm2: 800.0
closing_iters: 2 # 原来没有,加形态学闭运算填补边界断裂
fill_bg_below_mm2: 5.0 # 原来没有,填充小碎片
# Circle fitting on hole boundary. # Circle fitting on hole boundary.
circle_fit: circle_fit:
method: algebraic_then_lsq # algebraic_then_lsq | hyper | taubin method: algebraic_then_lsq # algebraic_then_lsq | hyper | taubin

View File

@@ -0,0 +1,94 @@
# Profile for the train_2 coupler mounting panel measured from a full-vehicle
# scan (train_2_1T.ply, cropped to the coupler ROI).
#
# Part topology (measured from the 1T cloud, dataset frame x=length,
# y=height, z=width):
# - a wide base plate with a RAISED central square mounting plate
# - the central plate has 4 corner bolt holes, r≈15.6 mm,
# rectangle 360 mm (z) × 200 mm (y), at z=±180, y=835/1035
# - NO central pass-through hole
# - the ROI box also slices a roof truss + the base plate behind the
# raised plate, so a normal-direction layer isolation is required
#
# Use:
# run_coupler_measurement_pipeline(input_cloud=roi.ply,
# profile="train2_coupler", roi_spec=<bbox>) # no CAD, no Kabsch
# --- CAD block (not used for STP parse here; kept for radius thresholds) ---
cad:
linear_deflection_mm: 0.5
angular_deflection_rad: 0.1
locating_hole_radius_mm: [11.0, 19.0] # r≈15.6 corner bolt holes
central_hole_radius_min_mm: 999.0 # this part has NO central hole
rectangle_aspect_tol: 0.40
# --- Normal-direction layer isolation (the key pre-processing step) -------
# Isolate the frontmost dominant layer = the raised central plate, so the
# back plate + truss don't fill the holes / pollute the plane fit.
layer_select:
enabled: true
normal_seed: pca # pca | from_cad
half_width_mm: 8.0 # slab half-thickness around the chosen layer
pick: frontmost # frontmost (toward scanner) | densest
bins: 240
# --- Denoise: drop the density-fragile radius filter (sparse-ish scan) ----
denoise:
steps:
- type: passthrough
bbox_padding_mm: 50.0
- type: voxel
voxel_mm: 0.5
- type: statistical
nb_neighbors: 20
std_ratio: 2.5
# --- 4-hole detection tuned for r≈15.6 mm bolt holes ----------------------
detection:
panel_normal_seed: pca
grid_pixel_mm: 2.0
closing_iters: 1
fill_bg_below_mm2: 60.0
panel_min_area_ratio: 0.15
hole_min_area_mm2: 300.0 # r=15.6 -> ~765 mm²; floor well below
hole_max_area_mm2: 1400.0 # ceil above; excludes larger back-layer holes
locating_hole_radius_mm: [11.0, 19.0]
rectangle_aspect_tol: 0.40
circle_fit:
method: algebraic_then_lsq
inlier_residual_mm: 1.5
# --- Panel ROI extraction -------------------------------------------------
roi:
hole_dilate_mm: 5.0
central_hole_dilate_mm: 10.0
edge_buffer_mm: 8.0
chamfer_normal_dot_min: 0.80
outlier_z_score: 3.0
# --- Plane fit ------------------------------------------------------------
plane_fit:
ransac_iterations: 400
ransac_inlier_thresh_mm: 1.0
min_inlier_ratio: 0.3
enforce_nx_positive: true
# --- Metrics --------------------------------------------------------------
# NOTE: this dataset's frame is x=length, y=height, z=width — i.e. y and z
# are swapped relative to the package's nominal (y=width, z=height). The
# angle formulas are symmetric in form; here theta_right = atan2(n_y,n_x)
# is the tilt toward car-height and theta_ref = atan2(n_z,n_x) is the tilt
# toward car-width. Both are ~0 for an undeflected panel.
metrics:
right_axis_sign: 1
angle_clamp_deg: 0.0
registration:
method: kabsch_four_holes
refuse_if_residual_above_mm: 5.0
artifacts:
save_intermediate: true
ply_format: binary
image_dpi: 150
heatmap_colormap: viridis

View File

@@ -0,0 +1,114 @@
"""Derive scale/density-dependent config from CAD geometry + cloud spacing.
This is a **configuration helper**, not part of the measurement algorithm.
The denoise, detection, ROI and plane-fit algorithms are all unchanged;
this module only fills in the knobs whose *correct value depends on the
panel size and the point spacing*, so that the "CAD STP + cloud + ROI"
input contract is self-sufficient and does not need a hand-tuned profile
per panel.
Why it is needed
----------------
The shipped ``default.yaml`` is tuned for a small, densely-sampled panel.
Two groups of knobs break on a large, sparsely-scanned panel (e.g. the
lu1 coupler panel: r≈16 mm locating holes, ~3-4 mm point spacing over a
~1.5 m face):
1. **Denoise radius filter** ``{type: radius, nb_points: 8, radius_mm: 2}``
demands 8 neighbours within 2 mm. At 3.7 mm spacing *every* point fails
that test, so the whole cloud is deleted → ``too few points: 0``.
2. **Detection grid / area band** (``grid_pixel_mm=0.5``,
``hole_*_area_mm2=[30,800]``, ``locating_hole_radius_mm=[4,12]``) make
the occupancy grid too fine and the cavity-area window too small, so the
locating holes never form detectable cavities →
``only N hole-shaped cavities found``.
Two-stage application (around denoise)
--------------------------------------
The two groups must be tuned at *different* moments because the point
spacing changes across denoising:
- :func:`relax_denoise_steps` runs **before** denoising. It drops the
density-fragile radius filter (the statistical filter still removes
outliers), so a sparse cloud survives.
- :func:`detection_overlay` runs **after** denoising, measuring the point
spacing on the *denoised* cloud — the same cloud detection will see —
and sizes the grid / fill / area band from the CAD locating-hole radius
plus that spacing.
Together they reproduce, to within a few percent, the values previously
hand-tuned in ``configs/profiles/lu1_af4.yaml``.
"""
from __future__ import annotations
import copy
from typing import Any
import numpy as np
def median_nn_spacing(points: np.ndarray, *, max_samples: int = 5000) -> float:
"""Median nearest-neighbour distance, sub-sampled for speed (deterministic)."""
pts = np.asarray(points, dtype=np.float64)
n = pts.shape[0]
if n < 2:
return 1.0
try:
from scipy.spatial import cKDTree
except ImportError:
span = float(np.linalg.norm(pts.max(axis=0) - pts.min(axis=0)))
return max(span / max(n ** (1.0 / 3.0), 1.0), 1e-6)
step = max(1, n // max_samples)
probe = pts[::step]
tree = cKDTree(pts)
d, _ = tree.query(probe, k=2)
return float(np.median(d[:, 1]))
def relax_denoise_steps(denoise_cfg: dict[str, Any]) -> dict[str, Any]:
"""Return a denoise cfg with the density-fragile ``radius`` filter removed.
The radius outlier filter deletes everything on a cloud sparser than its
``radius_mm``; the statistical filter already handles outliers, so for an
auto-tuned run we simply drop the radius step (this matches the proven
lu1_af4 denoise recipe). Other steps are kept as-is.
"""
out = copy.deepcopy(denoise_cfg) if denoise_cfg else {}
steps = out.get("steps", [])
out["steps"] = [s for s in steps if s.get("type") != "radius"]
return out
def detection_overlay(locating_hole_radius_mm: float, points: np.ndarray) -> dict[str, Any]:
"""Detection sub-config derived from locating-hole radius + DENOISED spacing.
``points`` must be the cloud detection will actually run on (i.e. the
denoised cloud), because the fill-gap threshold is sized from its point
spacing.
"""
r = float(locating_hole_radius_mm)
nn = median_nn_spacing(points)
# Grid fine enough to resolve a locating hole (~16 px across its
# diameter), but never finer than 0.5 mm.
pixel = round(max(r / 8.0, 0.5), 2)
# Fill background gaps up to a few point-spacings so sub-sampled panel
# regions read as solid rather than as spurious cavities.
fill_bg = round((2.5 * nn) ** 2, 1)
a_loc = np.pi * r * r
return {
"grid_pixel_mm": pixel,
"fill_bg_below_mm2": fill_bg,
"panel_min_area_ratio": 0.15,
"hole_min_area_mm2": round(0.30 * a_loc, 1),
"hole_max_area_mm2": round(2.2 * a_loc, 1),
"locating_hole_radius_mm": [round(r * 0.80, 2), round(r * 1.45, 2)],
"panel_normal_seed": "pca",
}
__all__ = ["median_nn_spacing", "relax_denoise_steps", "detection_overlay"]

View File

@@ -0,0 +1,129 @@
"""Isolate a single panel layer from a multi-layer ROI cloud.
A ROI box cut out of a real vehicle scan can slice through several
structures stacked along the panel normal — e.g. the big base plate, a
raised central mounting plate, and a roof truss behind them. The 4-hole
detector and the plane fit both assume a *single* thin plate; feeding
them the multi-layer cloud makes holes fill in (back-layer points project
into the front holes) and pollutes the plane fit.
This module keeps only the points belonging to one layer along the panel
normal, selected by a 1-D histogram of signed distance to a robust plane
seed (PCA normal by default, or a supplied CAD normal).
Pure geometry — it does not touch the detection / plane-fit algorithms.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
@dataclass
class LayerResult:
points: np.ndarray # kept layer points
normal: np.ndarray # (3,) unit normal used, n_x > 0
center_signed: float # signed-distance center of the kept layer
half_width_mm: float
diagnostics: dict[str, Any]
def _pca_normal(points: np.ndarray) -> np.ndarray:
c = points - points.mean(axis=0)
cov = (c.T @ c) / max(points.shape[0] - 1, 1)
_, V = np.linalg.eigh(cov)
n = V[:, 0]
if n[0] < 0:
n = -n
return n / max(np.linalg.norm(n), 1e-12)
def select_panel_layer(
points: np.ndarray,
*,
normal_seed: np.ndarray | str = "pca",
half_width_mm: float = 6.0,
bins: int = 240,
pick: str = "frontmost",
) -> LayerResult:
"""Keep one layer of ``points`` along the panel normal.
Parameters
----------
normal_seed : (3,) or "pca"
Plane normal used to compute signed distances. ``"pca"`` estimates
it from the cloud (smallest-variance axis).
half_width_mm : float
Half-thickness of the kept slab around the chosen layer center.
bins : int
Histogram resolution for finding layer peaks.
pick : "frontmost" | "densest"
``"frontmost"`` keeps the layer with the largest signed distance
(the face toward the scanner, +normal side) among the dominant
peaks — this is the raised plate the user wants. ``"densest"``
keeps the single most-populated layer.
"""
pts = np.asarray(points, dtype=np.float64)
if pts.shape[0] == 0:
raise ValueError("empty cloud")
if isinstance(normal_seed, str):
if normal_seed != "pca":
raise ValueError(f"unknown normal_seed '{normal_seed}'")
normal = _pca_normal(pts)
else:
normal = np.asarray(normal_seed, dtype=np.float64).reshape(3)
normal = normal / max(np.linalg.norm(normal), 1e-12)
if normal[0] < 0:
normal = -normal
sd = pts @ normal
hist, edges = np.histogram(sd, bins=bins)
centers = 0.5 * (edges[:-1] + edges[1:])
# Candidate layers = histogram peaks holding a meaningful share of points.
thresh = 0.25 * hist.max()
peak_idx = [i for i in range(len(hist)) if hist[i] >= thresh]
# group contiguous bins into peaks; take each group's argmax center
groups: list[list[int]] = []
for i in peak_idx:
if groups and i == groups[-1][-1] + 1:
groups[-1].append(i)
else:
groups.append([i])
peak_centers = []
for g in groups:
gi = max(g, key=lambda i: hist[i])
peak_centers.append((centers[gi], int(hist[gi])))
if not peak_centers:
peak_centers = [(centers[int(np.argmax(hist))], int(hist.max()))]
if pick == "densest":
chosen = max(peak_centers, key=lambda c: c[1])[0]
else: # frontmost = max signed distance (toward +normal / scanner)
chosen = max(peak_centers, key=lambda c: c[0])[0]
mask = np.abs(sd - chosen) <= half_width_mm
kept = pts[mask]
return LayerResult(
points=kept,
normal=normal,
center_signed=float(chosen),
half_width_mm=float(half_width_mm),
diagnostics={
"n_in": int(pts.shape[0]),
"n_out": int(kept.shape[0]),
"normal": [float(v) for v in normal],
"layer_center_signed": float(chosen),
"peak_centers": [[float(c), int(n)] for c, n in peak_centers],
"pick": pick,
},
)
__all__ = ["LayerResult", "select_panel_layer"]

View File

@@ -5,6 +5,7 @@ from .pointcloud_io import (
write_point_cloud, write_point_cloud,
write_metadata_json, write_metadata_json,
) )
from .ply_stream import crop_ply_bbox
from .artifacts import ( from .artifacts import (
ensure_dir, ensure_dir,
write_json, write_json,
@@ -17,6 +18,7 @@ __all__ = [
"load_point_cloud", "load_point_cloud",
"write_point_cloud", "write_point_cloud",
"write_metadata_json", "write_metadata_json",
"crop_ply_bbox",
"ensure_dir", "ensure_dir",
"write_json", "write_json",
"write_yaml", "write_yaml",

View File

@@ -0,0 +1,165 @@
"""Streaming AABB crop for large binary PLY point clouds.
A full-vehicle scan PLY can be 1-12 GB. Loading it whole to apply a
region-of-interest box would blow up memory, so this module parses the
PLY header, memory-maps the vertex block, and filters it in chunks,
keeping only points inside the ROI bounding box.
Supports binary little-/big-endian PLY with a single ``element vertex``
block whose first three float properties are x, y, z (extra properties
such as colours or normals are tolerated — the per-vertex stride is read
from the header). ASCII PLY and other layouts fall back to the normal
loader in :mod:`coupler_panel.io_utils.pointcloud_io`.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from ..exceptions import InvalidPointCloudError
# PLY property type -> (numpy dtype char, byte size)
_PLY_TYPES = {
"char": ("i1", 1), "int8": ("i1", 1),
"uchar": ("u1", 1), "uint8": ("u1", 1),
"short": ("i2", 2), "int16": ("i2", 2),
"ushort": ("u2", 2), "uint16": ("u2", 2),
"int": ("i4", 4), "int32": ("i4", 4),
"uint": ("u4", 4), "uint32": ("u4", 4),
"float": ("f4", 4), "float32": ("f4", 4),
"double": ("f8", 8), "float64": ("f8", 8),
}
@dataclass
class _PlyHeader:
fmt: str # "binary_little_endian" | "binary_big_endian" | "ascii"
n_vertices: int
data_offset: int # byte offset where vertex data starts
props: list[tuple[str, str]] # ordered (name, type) for the vertex element
def _parse_header(path: Path) -> _PlyHeader:
fmt = None
n_vertices = 0
props: list[tuple[str, str]] = []
in_vertex = False
# Read a generous prefix; PLY headers are tiny (well under 64 KB).
with path.open("rb") as f:
prefix = f.read(65536)
marker = b"end_header"
mpos = prefix.find(marker)
if mpos < 0:
raise InvalidPointCloudError(f"{path}: no end_header in first 64KB")
# data starts right after the newline(s) following 'end_header'
after = prefix[mpos + len(marker):]
if after.startswith(b"\r\n"):
nl_len = 2
elif after.startswith(b"\n") or after.startswith(b"\r"):
nl_len = 1
else:
nl_len = 0
data_offset = mpos + len(marker) + nl_len
text = prefix[:mpos].decode("ascii", errors="replace")
lines = text.splitlines()
for ln in lines:
parts = ln.split()
if not parts:
continue
if parts[0] == "format":
fmt = parts[1]
elif parts[0] == "element":
in_vertex = parts[1] == "vertex"
if in_vertex:
n_vertices = int(parts[2])
elif parts[0] == "property" and in_vertex:
# property <type> <name> (lists not supported for vertex)
if parts[1] == "list":
raise InvalidPointCloudError(f"{path}: list property in vertex unsupported")
props.append((parts[-1], parts[1]))
elif parts[0] == "element" and not in_vertex:
in_vertex = False
if fmt is None or n_vertices <= 0 or not props:
raise InvalidPointCloudError(f"{path}: malformed PLY header")
return _PlyHeader(fmt=fmt, n_vertices=n_vertices, data_offset=data_offset, props=props)
def crop_ply_bbox(
path: str | Path,
*,
x_min: float, x_max: float,
y_min: float, y_max: float,
z_min: float, z_max: float,
chunk: int = 10_000_000,
) -> tuple[np.ndarray, dict[str, Any]]:
"""Stream-crop a binary PLY to an AABB. Returns ``(points (M,3), diag)``.
Falls back to a full load (via the standard reader) for ASCII PLY or
headers this fast path can't handle.
"""
path = Path(path)
if not path.exists():
raise InvalidPointCloudError(f"point cloud not found: {path}")
try:
hdr = _parse_header(path)
except InvalidPointCloudError:
hdr = None
if hdr is None or not hdr.fmt.startswith("binary"):
# Fallback: load whole then filter (ASCII / unknown layout).
from .pointcloud_io import load_point_cloud
pts = load_point_cloud(path)
m = ((pts[:, 0] >= x_min) & (pts[:, 0] <= x_max)
& (pts[:, 1] >= y_min) & (pts[:, 1] <= y_max)
& (pts[:, 2] >= z_min) & (pts[:, 2] <= z_max))
out = pts[m]
return out, {"mode": "fallback_full_load", "n_in": int(pts.shape[0]),
"n_out": int(out.shape[0])}
# Build a structured dtype matching the vertex layout.
byteorder = "<" if "little" in hdr.fmt else ">"
names = [p[0] for p in hdr.props]
formats = []
for _, t in hdr.props:
if t not in _PLY_TYPES:
raise InvalidPointCloudError(f"{path}: unsupported property type {t}")
formats.append(byteorder + _PLY_TYPES[t][0])
dt = np.dtype({"names": names, "formats": formats})
if not all(c in names for c in ("x", "y", "z")):
raise InvalidPointCloudError(f"{path}: vertex has no x/y/z properties")
mm = np.memmap(path, dtype=dt, mode="r", offset=hdr.data_offset, shape=(hdr.n_vertices,))
kept = []
n_in = hdr.n_vertices
for start in range(0, n_in, chunk):
end = min(start + chunk, n_in)
blk = mm[start:end]
x = np.asarray(blk["x"], dtype=np.float64)
y = np.asarray(blk["y"], dtype=np.float64)
z = np.asarray(blk["z"], dtype=np.float64)
m = ((x >= x_min) & (x <= x_max)
& (y >= y_min) & (y <= y_max)
& (z >= z_min) & (z <= z_max))
if m.any():
kept.append(np.column_stack([x[m], y[m], z[m]]))
out = np.concatenate(kept, axis=0) if kept else np.zeros((0, 3))
return out, {
"mode": "streaming",
"format": hdr.fmt,
"n_in": int(n_in),
"n_out": int(out.shape[0]),
"bbox": {"x_min": x_min, "x_max": x_max, "y_min": y_min,
"y_max": y_max, "z_min": z_min, "z_max": z_max},
}
__all__ = ["crop_ply_bbox"]

View File

@@ -1,23 +1,18 @@
"""End-to-end orchestration for *research / validation* runs. """End-to-end orchestration for *research / validation* runs.
Composition:: Two entry points:
data_prep.prepare_cloud_from_cad / from_scan (Phase A) - :func:`run_coupler_measurement_pipeline` — the integration path:
api.compute_coupler_measurement (Phase B) cloud (in CAD frame) + optional CAD STP + ROI → ROI clip → denoise →
measure. **No registration.**
Phase A is the validation / data-preparation part: STP → mesh → sample - :func:`run_from_cad_pipeline` — the research path: STP → synthesise →
→ noise → transform → denoise → register. Phase B is the public denoise → 4-hole Kabsch (against oracle deflected GT) → measure → diff
measurement algorithm. This module saves every intermediate artifact GT. Saves every intermediate artifact under a structured run directory.
under a structured run directory so engineers can debug and so the
sweep runner can compare runs.
Production callers should *not* use this module — they should call
:func:`coupler_panel.api.compute_coupler_measurement` directly with
their own clean point cloud.
""" """
from __future__ import annotations from __future__ import annotations
import math
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -34,20 +29,24 @@ from .data_prep import (
register_cloud, register_cloud,
synthesize_cloud, synthesize_cloud,
) )
from .exceptions import InsufficientPointsError
from .io_utils import ( from .io_utils import (
Artifacts, Artifacts,
crop_ply_bbox,
load_point_cloud, load_point_cloud,
write_json, write_json,
write_point_cloud, write_point_cloud,
write_yaml, write_yaml,
) )
from .metrics import compute_deflection_metrics 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 .roi_select import ROISpec, apply_roi, default_roi_spec, load_roi_json
from .structures import CouplerMeasurement from .structures import CouplerMeasurement
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Cloud-only research entry point # Integration entry point
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -75,12 +74,12 @@ def run_coupler_measurement_pipeline(
Inputs: Inputs:
- ``cad_stp_path`` — optional STEP file. Parsed for the analytical - ``cad_stp_path`` — optional STEP file. Parsed for the analytical
panel-normal seed (speeds up + stabilises hole detection's panel-normal seed and, when no profile/config is given, to
in-plane projection); the parsed GT is written as ``01_gt.json`` auto-tune the scale/density-dependent denoise + detection knobs
to ``out_dir`` for auditing. (see below). The parsed GT is written as ``01_gt.json`` for audit.
- ``roi_spec`` / ``roi_json`` — ROI selector applied to the cloud - ``roi_spec`` / ``roi_json`` — axis-aligned-bbox ROI applied to the
*before* denoising. If both omitted, the default ``mode='all'`` cloud *before* denoising. If both omitted, the full cloud is kept.
spec (pass-through) is used. ``roi_spec`` wins over ``roi_json``. ``roi_spec`` wins over ``roi_json``.
Writes every intermediate to ``out_dir``. Returns a Writes every intermediate to ``out_dir``. Returns a
:class:`CouplerMeasurement`. :class:`CouplerMeasurement`.
@@ -91,24 +90,110 @@ def run_coupler_measurement_pipeline(
write_yaml(cfg, art.resolved_config) write_yaml(cfg, art.resolved_config)
# ---- Load + ROI clip -------------------------------------------------- # ---- Load + ROI clip --------------------------------------------------
raw_pts = load_point_cloud(input_cloud) # 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: if roi_spec is None:
roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec() roi_spec = load_roi_json(roi_json) if roi_json is not None else default_roi_spec()
pts, roi_diag = apply_roi(raw_pts, 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_point_cloud(pts, art.path("sampled", ext="ply"))
write_json({"spec": roi_spec.to_dict(), "diagnostics": roi_diag}, art.root / "roi.json") 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) ---------- # ---- Optional CAD parse (panel_normal_seed + audit GT JSON) ----------
panel_seed: np.ndarray | None = None panel_seed: np.ndarray | None = None
parsed = None
if cad_stp_path is not 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"]) parsed = parse_cad(cad_stp_path, cfg["cad"])
write_json(parsed.gt.to_dict(), art.path("gt", ext="json")) write_json(parsed.gt.to_dict(), art.path("gt", ext="json"))
panel_seed = np.asarray(parsed.gt.plane.normal, dtype=np.float64) panel_seed = np.asarray(parsed.gt.plane.normal, dtype=np.float64)
# ---- Denoise (no Kabsch, no prepare_cloud_from_scan) ----------------- # ---- 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", {})) denoised = denoise_cloud(pts, cfg.get("denoise", {}))
write_point_cloud(denoised.points, art.path("denoised", ext="ply")) 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 ------------------------------------------------------ # ---- Measurement ------------------------------------------------------
result = compute_coupler_measurement( result = compute_coupler_measurement(
denoised.points, denoised.points,
@@ -170,11 +255,6 @@ def run_from_cad_pipeline(
write_point_cloud(denoised.points, art.path("denoised", ext="ply")) write_point_cloud(denoised.points, art.path("denoised", ext="ply"))
# ---- Phase A.4 — register --------------------------------------------- # ---- Phase A.4 — register ---------------------------------------------
# Use PCA on the cloud itself for the projection seed (the cloud is
# still in the pre-Kabsch frame here; using the CAD normal would
# cause foreshortening bias). Register against the *deflected* GT
# holes so Kabsch only cancels scanner-pose jitter, not the panel
# deflection signal we want to measure.
registered = register_cloud( registered = register_cloud(
denoised.points, denoised.points,
target_holes=synth.gt_holes_after_deflection, target_holes=synth.gt_holes_after_deflection,
@@ -195,10 +275,6 @@ def run_from_cad_pipeline(
) )
# ---- GT metrics -------------------------------------------------------- # ---- GT metrics --------------------------------------------------------
# GT for the metrics is the *deflected* panel: angles equal the
# injection, and the maximum-distance / maximum-x-deviation values are
# computed over the deflected 4-hole rectangle (the same primitive the
# algorithm later sees through the noise + jitter chain).
gt_normal = synth.gt_normal_after_deflection gt_normal = synth.gt_normal_after_deflection
centers = synth.gt_holes_after_deflection.locating_centers() centers = synth.gt_holes_after_deflection.locating_centers()
gt_metrics = compute_deflection_metrics( gt_metrics = compute_deflection_metrics(
@@ -252,25 +328,6 @@ def run_from_cad_pipeline(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _try_load_normal_from_gt_json(path: str | Path) -> np.ndarray | None:
"""Read ``plane.normal`` from a GT JSON if present. Returns ``None`` otherwise."""
import json
try:
with Path(path).open("r", encoding="utf-8") as fh:
d = json.load(fh)
except Exception:
return None
n = d.get("plane", {}).get("normal")
if n is None or len(n) != 3:
return None
arr = np.asarray(n, dtype=np.float64)
norm = float(np.linalg.norm(arr))
if norm < 1e-9:
return None
return arr / norm
def _load_holeset_from_json(path: str | Path): def _load_holeset_from_json(path: str | Path):
"""Reconstruct a HoleSet from the JSON written by ``GroundTruth.to_dict``.""" """Reconstruct a HoleSet from the JSON written by ``GroundTruth.to_dict``."""
import json import json

View File

@@ -0,0 +1,9 @@
{
"x_min": 11526.0,
"x_max": 11733.0,
"y_min": 588.0,
"y_max": 1192.0,
"z_min": -950.0,
"z_max": 950.0,
"comment": "Coupler mounting panel ROI on train_2 full-vehicle scan. Dataset frame: x=length, y=height, z=width. These six numbers are the cropping hyper-parameters consumed directly by the algorithm."
}

Some files were not shown because too many files have changed in this diff Show More