feat: 算法升级、整车配准集成与性能分析文档

算法更新: delivery v1.2.1、平整度v1.5、测距v2.2、整车配准v1.3
适配器: vehicle-registration 新增、各算法 manifest 和 adapter 更新
后端: taskCadFiles/source 字段迁移、derivedPointClouds 注册、取消任务联动
前端: ElapsedTime 时长显示、两步算法流程、canRun 逻辑修复
文档: 新增5份算法性能分析报告
This commit is contained in:
2026-06-25 08:22:20 +00:00
parent cec0766e67
commit 29f1d45d66
266 changed files with 1311475 additions and 2846 deletions

View File

@@ -0,0 +1,48 @@
# 点云加噪模拟工具(独立,与主算法无关)
> 给点云每个点加小幅随机高斯噪声,模拟真实扫描仪的抖动。
> **与挠度测量算法完全解耦**,仅用于生成测试数据 / 评估算法对噪声的鲁棒性。
## 噪声量级
参考:约 2m × 2m 的扫描区域,真实噪声约 **50–80 微米**(0.05–0.08 mm)每坐标。
噪声沿 x/y/z 三个轴独立施加(各向同性高斯)。
## 用法
```bash
# 随机 σ ∈ [50, 80] um(默认)
python tools/noise_simulation/add_noise.py input.ply output_noisy.ply
# 固定 σ = 65 um
python tools/noise_simulation/add_noise.py input.ply output_noisy.ply --sigma-um 65
# 指定范围 + 固定种子(可复现)
python tools/noise_simulation/add_noise.py input.ply output_noisy.ply \
--sigma-um-min 50 --sigma-um-max 80 --seed 42
```
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `--sigma-um` | 无 | 固定噪声标准差(微米)。不给则在区间内随机取一个值 |
| `--sigma-um-min` / `--sigma-um-max` | 50 / 80 | 随机 σ 的区间(微米) |
| `--seed` | 0 | 随机种子(可复现) |
| `--chunk` | 4000000 | 流式分块大小(顶点数),支持超大文件 |
输入支持 ASCII / binary PLY;输出统一为 binary little-endian PLY(xyz float32)。
12GB 级大文件流式处理,内存占用恒定。
## 典型流程(评估噪声对挠度的影响)
```bash
# 1) 给整车点云加噪
python tools/noise_simulation/add_noise.py train.ply train_noisy.ply --sigma-um 65
# 2) 把加噪后的点云喂给算法
underframe-measure --input-cloud train_noisy.ply \
--left-roi roi_left_beam.json --right-roi roi_right_beam.json \
--out-dir out_noisy/
```
理论挠度为 0 的样本,加 ~65um 噪声后挠度测量结果应仍很小(远小于 mm 级公差),
用于验证算法对扫描噪声的鲁棒性。

View File

@@ -0,0 +1,168 @@
"""Independent point-cloud noise simulator (NOT part of the measurement algorithm).
Adds small random Gaussian noise to every point to emulate real-scanner jitter.
For a ~2m x 2m scan patch the realistic per-coordinate noise is roughly
50-80 micrometres (0.05-0.08 mm). The noise is applied along all three axes.
Streams the input PLY in chunks so multi-GB clouds (binary little/big-endian)
can be processed without loading the whole file into memory. Output is written
as binary little-endian PLY (same xyz float32 layout as the inputs in this
project).
Usage:
python add_noise.py INPUT.ply OUTPUT.ply [--sigma-um 65] [--seed 0]
python add_noise.py INPUT.ply OUTPUT.ply --sigma-um-min 50 --sigma-um-max 80
"""
from __future__ import annotations
import argparse
import struct
import sys
from pathlib import Path
import numpy as np
# Map PLY scalar property type -> numpy dtype code (subset used by xyz clouds).
_PLY_TYPE_TO_NP = {
"char": "i1", "int8": "i1", "uchar": "u1", "uint8": "u1",
"short": "i2", "int16": "i2", "ushort": "u2", "uint16": "u2",
"int": "i4", "int32": "i4", "uint": "u4", "uint32": "u4",
"float": "f4", "float32": "f4", "double": "f8", "float64": "f8",
}
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Add small random noise to a point cloud (independent tool).")
p.add_argument("input", help="Input point cloud (.ply binary/ascii).")
p.add_argument("output", help="Output .ply (binary little-endian).")
p.add_argument("--sigma-um", type=float, default=None,
help="Fixed noise std-dev in micrometres (per coordinate). "
"Default: random per run in [sigma-um-min, sigma-um-max].")
p.add_argument("--sigma-um-min", type=float, default=50.0,
help="Min noise std-dev (um) when --sigma-um is not given. Default 50.")
p.add_argument("--sigma-um-max", type=float, default=80.0,
help="Max noise std-dev (um) when --sigma-um is not given. Default 80.")
p.add_argument("--seed", type=int, default=0, help="RNG seed (reproducible).")
p.add_argument("--chunk", type=int, default=4_000_000, help="Vertices per chunk.")
return p.parse_args()
def _parse_ply_header(path: Path):
header_bytes = 0
vertex_count = None
vertex_props: list[tuple[str, str]] = []
in_vertex = False
fmt = "ascii"
with path.open("rb") as fh:
first = fh.readline(); header_bytes += len(first)
if first.strip().lower() != b"ply":
raise ValueError(f"Not a PLY file: {path}")
while True:
raw = fh.readline()
if not raw:
raise ValueError(f"PLY header missing end_header: {path}")
header_bytes += len(raw)
tok = raw.decode("ascii", errors="replace").strip().split()
if not tok:
continue
kw = tok[0].lower()
if kw == "format":
fmt = tok[1].lower()
elif kw == "element":
in_vertex = tok[1].lower() == "vertex"
if in_vertex:
vertex_count = int(tok[2])
elif kw == "property" and in_vertex:
vertex_props.append(("list", tok[-1]) if tok[1].lower() == "list"
else (tok[1].lower(), tok[-1]))
elif kw == "end_header":
break
if vertex_count is None:
raise ValueError(f"PLY has no vertex element: {path}")
return fmt, header_bytes, vertex_count, vertex_props
def _write_ply_header(fh, n: int) -> None:
fh.write(b"ply\n")
fh.write(b"format binary_little_endian 1.0\n")
fh.write(f"element vertex {n}\n".encode("ascii"))
fh.write(b"property float x\nproperty float y\nproperty float z\n")
fh.write(b"end_header\n")
def main() -> None:
args = parse_args()
in_path, out_path = Path(args.input), Path(args.output)
rng = np.random.default_rng(args.seed)
if args.sigma_um is not None:
sigma_um = float(args.sigma_um)
else:
sigma_um = float(rng.uniform(args.sigma_um_min, args.sigma_um_max))
sigma_mm = sigma_um / 1000.0 # micrometres -> millimetres
fmt, header_bytes, vertex_count, vertex_props = _parse_ply_header(in_path)
if fmt == "ascii":
# small/text path: load, perturb, write
pts = np.loadtxt(in_path, skiprows=_count_header_lines(in_path), dtype=np.float64)[:, :3]
pts = pts + rng.normal(0.0, sigma_mm, size=pts.shape)
with out_path.open("wb") as fh:
_write_ply_header(fh, len(pts))
fh.write(pts.astype("<f4").tobytes())
_report(in_path, out_path, len(pts), sigma_um)
return
if fmt not in {"binary_little_endian", "binary_big_endian"}:
raise ValueError(f"Unsupported PLY format: {fmt}")
if any(t == "list" for t, _ in vertex_props):
raise ValueError("List vertex properties unsupported.")
names = [n for _, n in vertex_props]
for ax in ("x", "y", "z"):
if ax not in names:
raise ValueError(f"PLY missing coordinate '{ax}'.")
endian = "<" if fmt == "binary_little_endian" else ">"
rec_dtype = np.dtype([(n, endian + _PLY_TYPE_TO_NP[t]) for t, n in vertex_props])
rec_bytes = rec_dtype.itemsize
with in_path.open("rb") as fin, out_path.open("wb") as fout:
_write_ply_header(fout, vertex_count)
fin.seek(header_bytes)
remaining = vertex_count
while remaining > 0:
n = min(args.chunk, remaining)
buf = fin.read(n * rec_bytes)
if len(buf) < n * rec_bytes:
n = len(buf) // rec_bytes
if n == 0:
break
buf = buf[: n * rec_bytes]
recs = np.frombuffer(buf, dtype=rec_dtype, count=n)
xyz = np.column_stack([recs["x"], recs["y"], recs["z"]]).astype(np.float64)
xyz += rng.normal(0.0, sigma_mm, size=xyz.shape)
fout.write(xyz.astype("<f4").tobytes())
remaining -= n
_report(in_path, out_path, vertex_count, sigma_um)
def _count_header_lines(path: Path) -> int:
n = 0
with path.open("rb") as fh:
for line in fh:
n += 1
if line.strip() == b"end_header":
break
return n
def _report(in_path, out_path, n, sigma_um) -> None:
print(f"input : {in_path}")
print(f"output: {out_path}")
print(f"points: {n:,}")
print(f"noise : Gaussian sigma = {sigma_um:.1f} um ({sigma_um/1000:.5f} mm) per coordinate")
if __name__ == "__main__":
sys.exit(main())