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:
@@ -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 级公差),
|
||||
用于验证算法对扫描噪声的鲁棒性。
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user