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,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`。

View File

@@ -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()