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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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 节。
|
|
||||||
@@ -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 |
|
|
||||||
@@ -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
|
|
||||||
@@ -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: {}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# underframe-deflection
|
|
||||||
|
|
||||||
**从扫描点云测量高铁底架纵梁挠度的 Python 算法包:接受"CAD 基准 + 扫描点云 + ROI JSON"三件套输入,自动配准、去噪、识别梁、出 Excel 误差报表。**
|
|
||||||
|
|
||||||
[]() []() []()
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 这是什么
|
|
||||||
|
|
||||||
输入一份现场扫描点云(`.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.
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -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()
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
68
alg/delivery_v1.2/coupler_panel_release/README.md
Normal file
68
alg/delivery_v1.2/coupler_panel_release/README.md
Normal 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。
|
||||||
256
alg/delivery_v1.2/coupler_panel_release/USAGE.md
Normal file
256
alg/delivery_v1.2/coupler_panel_release/USAGE.md
Normal 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` 主版本。
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user