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,54 @@
|
||||
# CAD 采样工具(测试数据生成 · 与主算法隔离)
|
||||
|
||||
> **这是独立工具,不是主算法的一部分。** 它只用来把 CAD 的 STEP 模型采样成点云,
|
||||
> 作为**模拟测试数据**。主测量算法 (`underframe_deflection`) **不会 import 本目录任何代码**,
|
||||
> 也不依赖 `cadquery`。将来用真实扫描点云时,本工具完全可以不用。
|
||||
|
||||
## 作用
|
||||
|
||||
```
|
||||
STEP (.stp) ──► step_to_pointcloud.py ──► 整车点云 (.ply / .xyz) + metadata (.json)
|
||||
(tessellate + 按三角面积加权采样)
|
||||
```
|
||||
|
||||
输出坐标轴约定与主算法一致:`x=纵向, y=横向, z=高度`(脚本内部做了 `[0,2,1]` 轴重排,
|
||||
因为 CAD 原始是 `x=纵向, y=高度, z=横向`)。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
# 仅生成测试数据时才需要;主算法运行不需要它
|
||||
pip install -e .[sampling] # 安装 cadquery
|
||||
```
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
python tools/cad_sampling/step_to_pointcloud.py <模型.stp> \
|
||||
-n 800000 \ # 采样点数(越大越密)
|
||||
--bottom-boost 4.0 \ # 对车底区域加密的权重(>1 时车底点更多)
|
||||
--bottom-band-height-ratio 0.24 \
|
||||
--output-prefix test_data/dense_800k
|
||||
```
|
||||
|
||||
常用参数:
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `-n / --num-points` | 100000 | 采样点总数 |
|
||||
| `--bottom-boost` | 1.0 | 车底加密倍数(挠度测量建议 ≥ 3) |
|
||||
| `--bottom-band-height-ratio` | 0.24 | 车底带的高度占比 |
|
||||
| `--linear-deflection` | 1.0 | tessellation 线性精度(越小三角越细,越慢) |
|
||||
| `--angular-deflection` | 0.1 | tessellation 角度精度 |
|
||||
| `--seed` | 42 | 随机种子(可复现) |
|
||||
|
||||
## 与主算法的关系
|
||||
|
||||
```
|
||||
[本工具] STEP → 点云 [主算法] 点云 + CAD + ROI(json) → 挠度报告
|
||||
(cadquery,仅测试) (numpy+scipy,生产)
|
||||
└── 解耦 ──────────────────────┘
|
||||
```
|
||||
|
||||
挠度测量对点云密度敏感:**车底纵梁区域过稀会导致样条拟合在端点不稳定**。
|
||||
生成模拟测试数据时建议 `--bottom-boost ≥ 3` 且 `-n ≥ 8e5`。
|
||||
@@ -0,0 +1,332 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cadquery as cq
|
||||
import numpy as np
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Sample a STEP model surface into a point cloud."
|
||||
)
|
||||
parser.add_argument("input_step", help="Path to the STEP file.")
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--num-points",
|
||||
type=int,
|
||||
default=100_000,
|
||||
help="Number of points to sample from the surface.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--linear-deflection",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Linear deflection for tessellation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--angular-deflection",
|
||||
type=float,
|
||||
default=0.1,
|
||||
help="Angular deflection for tessellation.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bottom-boost",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Extra sampling weight applied to the bottom band during triangle selection.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bottom-band-height-ratio",
|
||||
type=float,
|
||||
default=0.24,
|
||||
help="Height ratio used to define the bottom band for boosted sampling.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=42,
|
||||
help="Random seed for reproducible sampling.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--chunk-size",
|
||||
type=int,
|
||||
default=250_000,
|
||||
help="Triangle chunk size used during weighted sampling.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-prefix",
|
||||
default=None,
|
||||
help="Output prefix. Defaults to <input stem>_<num-points>pts.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_shape(step_path: Path):
|
||||
original_cwd = Path.cwd()
|
||||
step_dir = step_path.resolve().parent
|
||||
try:
|
||||
os.chdir(step_dir)
|
||||
return cq.importers.importStep(step_path.name).val()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
def vectors_to_array(vectors) -> np.ndarray:
|
||||
return np.asarray([v.toTuple() for v in vectors], dtype=np.float64)
|
||||
|
||||
|
||||
def triangle_areas(vertices: np.ndarray, tri_chunk: np.ndarray) -> np.ndarray:
|
||||
p0 = vertices[tri_chunk[:, 0]]
|
||||
p1 = vertices[tri_chunk[:, 1]]
|
||||
p2 = vertices[tri_chunk[:, 2]]
|
||||
cross = np.cross(p1 - p0, p2 - p0)
|
||||
return 0.5 * np.linalg.norm(cross, axis=1)
|
||||
|
||||
|
||||
def triangle_effective_weights(
|
||||
vertices: np.ndarray,
|
||||
tri_chunk: np.ndarray,
|
||||
bottom_boost: float,
|
||||
bottom_height_cut: float | None,
|
||||
) -> np.ndarray:
|
||||
areas = triangle_areas(vertices, tri_chunk)
|
||||
if bottom_boost <= 1.0 or bottom_height_cut is None:
|
||||
return areas
|
||||
centroids = vertices[tri_chunk].mean(axis=1)
|
||||
boost_mask = centroids[:, 1] <= bottom_height_cut
|
||||
weights = areas.copy()
|
||||
weights[boost_mask] *= bottom_boost
|
||||
return weights
|
||||
|
||||
|
||||
def assign_samples_per_chunk(
|
||||
triangles,
|
||||
vertices: np.ndarray,
|
||||
num_points: int,
|
||||
chunk_size: int,
|
||||
seed: int,
|
||||
bottom_boost: float,
|
||||
bottom_height_cut: float | None,
|
||||
) -> tuple[list[int], np.ndarray]:
|
||||
chunk_area_sums = []
|
||||
non_empty_chunk_indices = []
|
||||
|
||||
for start in range(0, len(triangles), chunk_size):
|
||||
tri_chunk = np.asarray(triangles[start : start + chunk_size], dtype=np.int32)
|
||||
if len(tri_chunk) == 0:
|
||||
continue
|
||||
weights = triangle_effective_weights(
|
||||
vertices,
|
||||
tri_chunk,
|
||||
bottom_boost=bottom_boost,
|
||||
bottom_height_cut=bottom_height_cut,
|
||||
)
|
||||
area_sum = float(weights.sum())
|
||||
chunk_area_sums.append(area_sum)
|
||||
if area_sum > 0:
|
||||
non_empty_chunk_indices.append(len(chunk_area_sums) - 1)
|
||||
|
||||
if not chunk_area_sums:
|
||||
raise ValueError("No tessellated triangles were produced from the STEP model.")
|
||||
|
||||
chunk_area_sums = np.asarray(chunk_area_sums, dtype=np.float64)
|
||||
total_area = float(chunk_area_sums.sum())
|
||||
if total_area <= 0:
|
||||
raise ValueError("All tessellated triangle areas are zero.")
|
||||
|
||||
probabilities = chunk_area_sums / total_area
|
||||
rng = np.random.default_rng(seed)
|
||||
chunk_counts = rng.multinomial(num_points, probabilities)
|
||||
return chunk_counts.tolist(), chunk_area_sums
|
||||
|
||||
|
||||
def sample_points(
|
||||
triangles,
|
||||
vertices: np.ndarray,
|
||||
chunk_counts: list[int],
|
||||
chunk_size: int,
|
||||
seed: int,
|
||||
bottom_boost: float,
|
||||
bottom_height_cut: float | None,
|
||||
) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
sampled_chunks = []
|
||||
|
||||
for chunk_index, start in enumerate(range(0, len(triangles), chunk_size)):
|
||||
requested = chunk_counts[chunk_index]
|
||||
if requested <= 0:
|
||||
continue
|
||||
|
||||
tri_chunk = np.asarray(triangles[start : start + chunk_size], dtype=np.int32)
|
||||
weights = triangle_effective_weights(
|
||||
vertices,
|
||||
tri_chunk,
|
||||
bottom_boost=bottom_boost,
|
||||
bottom_height_cut=bottom_height_cut,
|
||||
)
|
||||
positive_mask = weights > 0
|
||||
if not np.any(positive_mask):
|
||||
continue
|
||||
|
||||
tri_chunk = tri_chunk[positive_mask]
|
||||
weights = weights[positive_mask]
|
||||
probabilities = weights / weights.sum()
|
||||
|
||||
chosen = rng.choice(len(tri_chunk), size=requested, p=probabilities)
|
||||
chosen_triangles = tri_chunk[chosen]
|
||||
|
||||
p0 = vertices[chosen_triangles[:, 0]]
|
||||
p1 = vertices[chosen_triangles[:, 1]]
|
||||
p2 = vertices[chosen_triangles[:, 2]]
|
||||
|
||||
u = rng.random(requested)
|
||||
v = rng.random(requested)
|
||||
sqrt_u = np.sqrt(u)
|
||||
w0 = 1.0 - sqrt_u
|
||||
w1 = sqrt_u * (1.0 - v)
|
||||
w2 = sqrt_u * v
|
||||
points = (
|
||||
p0 * w0[:, None]
|
||||
+ p1 * w1[:, None]
|
||||
+ p2 * w2[:, None]
|
||||
)
|
||||
sampled_chunks.append(points)
|
||||
|
||||
if not sampled_chunks:
|
||||
raise ValueError("Sampling produced no points.")
|
||||
|
||||
points = np.vstack(sampled_chunks)
|
||||
if len(points) != sum(chunk_counts):
|
||||
raise RuntimeError("Point count mismatch after sampling.")
|
||||
return points
|
||||
|
||||
|
||||
def write_xyz(points: np.ndarray, output_path: Path) -> None:
|
||||
np.savetxt(output_path, points, fmt="%.6f")
|
||||
|
||||
|
||||
def write_ply(points: np.ndarray, output_path: Path) -> None:
|
||||
with output_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("end_header\n")
|
||||
for x, y, z in points:
|
||||
fh.write(f"{x:.6f} {y:.6f} {z:.6f}\n")
|
||||
|
||||
|
||||
def write_metadata(
|
||||
output_path: Path,
|
||||
input_step: Path,
|
||||
num_points: int,
|
||||
linear_deflection: float,
|
||||
angular_deflection: float,
|
||||
seed: int,
|
||||
num_vertices: int,
|
||||
num_triangles: int,
|
||||
bottom_boost: float,
|
||||
bottom_band_height_ratio: float,
|
||||
output_axis_convention: dict[str, str],
|
||||
) -> None:
|
||||
metadata = {
|
||||
"input_step": str(input_step.resolve()),
|
||||
"num_points": num_points,
|
||||
"linear_deflection": linear_deflection,
|
||||
"angular_deflection": angular_deflection,
|
||||
"seed": seed,
|
||||
"num_tessellated_vertices": num_vertices,
|
||||
"num_tessellated_triangles": num_triangles,
|
||||
"bottom_boost": bottom_boost,
|
||||
"bottom_band_height_ratio": bottom_band_height_ratio,
|
||||
"output_axis_convention": output_axis_convention,
|
||||
}
|
||||
output_path.write_text(
|
||||
json.dumps(metadata, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
input_step = Path(args.input_step)
|
||||
if not input_step.exists():
|
||||
raise FileNotFoundError(f"Input STEP file not found: {input_step}")
|
||||
|
||||
if args.output_prefix:
|
||||
output_prefix = Path(args.output_prefix)
|
||||
else:
|
||||
output_prefix = input_step.with_name(
|
||||
f"{input_step.stem}_{args.num_points}pts"
|
||||
)
|
||||
|
||||
shape = load_shape(input_step)
|
||||
vertices, triangles = shape.tessellate(
|
||||
args.linear_deflection,
|
||||
args.angular_deflection,
|
||||
)
|
||||
vertices_np = vectors_to_array(vertices)
|
||||
height_min = float(vertices_np[:, 1].min())
|
||||
height_max = float(vertices_np[:, 1].max())
|
||||
bottom_height_cut = height_min + args.bottom_band_height_ratio * (
|
||||
height_max - height_min
|
||||
)
|
||||
chunk_counts, _ = assign_samples_per_chunk(
|
||||
triangles,
|
||||
vertices_np,
|
||||
args.num_points,
|
||||
args.chunk_size,
|
||||
args.seed,
|
||||
args.bottom_boost,
|
||||
bottom_height_cut,
|
||||
)
|
||||
points = sample_points(
|
||||
triangles,
|
||||
vertices_np,
|
||||
chunk_counts,
|
||||
args.chunk_size,
|
||||
args.seed,
|
||||
args.bottom_boost,
|
||||
bottom_height_cut,
|
||||
)
|
||||
# CAD model coordinates are x=longitudinal, y=height, z=lateral.
|
||||
# We deliberately KEEP the CAD-native axis order here (no y/z swap), so the
|
||||
# sampled cloud matches the real scan/CAD coordinate convention. Any axis
|
||||
# alignment the measurement algorithm needs is handled downstream, not here.
|
||||
|
||||
xyz_path = output_prefix.with_suffix(".xyz")
|
||||
ply_path = output_prefix.with_suffix(".ply")
|
||||
metadata_path = output_prefix.with_suffix(".json")
|
||||
|
||||
write_xyz(points, xyz_path)
|
||||
write_ply(points, ply_path)
|
||||
write_metadata(
|
||||
metadata_path,
|
||||
input_step,
|
||||
len(points),
|
||||
args.linear_deflection,
|
||||
args.angular_deflection,
|
||||
args.seed,
|
||||
len(vertices),
|
||||
len(triangles),
|
||||
args.bottom_boost,
|
||||
args.bottom_band_height_ratio,
|
||||
{
|
||||
"x": "longitudinal",
|
||||
"y": "lateral",
|
||||
"z": "height",
|
||||
},
|
||||
)
|
||||
|
||||
print(f"Sampled {len(points)} points.")
|
||||
print(f"PLY: {ply_path}")
|
||||
print(f"XYZ: {xyz_path}")
|
||||
print(f"Metadata: {metadata_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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