feat: initial commit - massive-cloud point cloud processing system

This commit is contained in:
2026-05-27 09:51:42 +00:00
commit 16811100d1
391 changed files with 78338 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
-- MySQL:为 task_analysis_results.type 增加 smooth_heatmap(平滑热力异步分析)
-- 在部署或本地 drizzle push 未覆盖 enum 时手动执行。
ALTER TABLE task_analysis_results
MODIFY COLUMN type ENUM(
'error_analysis',
'measurement',
'compression',
'smooth_heatmap',
'assembly_inspection'
) NOT NULL;

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
服务端 GPU:PyTorch3D point_face_distance(CUDA)计算点到各零件 STL 三角面的最近距离,
与 three-mesh-bvh 语义一致(点到三角形)。无 CUDA / 无依赖时退出码 2,由 Node 回退 CPU。
依赖(需与 CUDA 版本匹配自行安装):
pip install torch trimesh numpy
pip install "git+https://github.com/facebookresearch/pytorch3d.git@stable"
# 或官方 wheel: https://github.com/facebookresearch/pytorch3d/blob/main/INSTALL.md
"""
from __future__ import annotations
import argparse
import json
import os
import sys
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--points", required=True, help="raw float32 little-endian, N*3 bytes")
ap.add_argument("--manifest-dir", required=True, help="含 manifest.json 与各 .stl")
ap.add_argument("--out-dir", required=True, help="写入 assignments.int32 / distances.f32 / result.json")
ap.add_argument("--chunk", type=int, default=65536, help="每批点数,显存不足时调小")
args = ap.parse_args()
try:
import numpy as np
import torch
import trimesh
from pytorch3d.loss.point_mesh_distance import point_face_distance
except ImportError as e: # noqa: PERF203
print(json.dumps({"ok": False, "error": f"import: {e}", "fallback": True}), flush=True)
sys.exit(2)
if not torch.cuda.is_available():
print(
json.dumps({"ok": False, "error": "no CUDA device", "fallback": True}),
flush=True,
)
sys.exit(2)
device = torch.device("cuda:0")
pts_path = args.points
man_dir = args.manifest_dir.rstrip(os.sep)
out_dir = args.out_dir
os.makedirs(out_dir, exist_ok=True)
with open(pts_path, "rb") as f:
raw = f.read()
n_float = len(raw) // 4
if n_float % 3 != 0:
print(json.dumps({"ok": False, "error": "points file size not multiple of 12"}), flush=True)
sys.exit(1)
pts_np = np.frombuffer(raw, dtype=np.float32).reshape(-1, 3)
n_pt = int(pts_np.shape[0])
man_path = os.path.join(man_dir, "manifest.json")
with open(man_path, encoding="utf-8") as f:
man = json.load(f)
parts = man.get("parts") or []
if not parts:
print(json.dumps({"ok": False, "error": "empty manifest.parts"}), flush=True)
sys.exit(1)
stl_infos: list[tuple[torch.Tensor, torch.Tensor]] = []
for p in parts:
rel = p.get("stlRelative") or p.get("stl_relative")
if not rel:
print(json.dumps({"ok": False, "error": "part missing stlRelative"}), flush=True)
sys.exit(1)
stl_path = os.path.join(man_dir, rel)
if not os.path.isfile(stl_path):
print(json.dumps({"ok": False, "error": f"missing {stl_path}"}), flush=True)
sys.exit(1)
mesh = trimesh.load(stl_path, force="mesh")
if hasattr(mesh, "geometry"):
mesh = trimesh.util.concatenate(tuple(mesh.geometry.values()))
verts = torch.tensor(mesh.vertices, dtype=torch.float32, device=device)
faces = torch.tensor(mesh.faces, dtype=torch.int64, device=device)
if faces.numel() == 0:
print(json.dumps({"ok": False, "error": f"empty mesh {rel}"}), flush=True)
sys.exit(1)
stl_infos.append((verts, faces))
n_parts = len(stl_infos)
chunk = max(1024, int(args.chunk))
# 增量 argmin,避免 (N × 零件数) 距离矩阵占满显存
best_d = torch.full((n_pt,), float("inf"), dtype=torch.float32, device=device)
best_i = torch.zeros((n_pt,), dtype=torch.int64, device=device)
p_first = torch.tensor([0], dtype=torch.int64, device=device)
t_first = torch.tensor([0], dtype=torch.int64, device=device)
for pi in range(n_parts):
verts, faces = stl_infos[pi]
tris = verts[faces] # (F, 3, 3)
for s in range(0, n_pt, chunk):
end = min(s + chunk, n_pt)
block = torch.tensor(pts_np[s:end], dtype=torch.float32, device=device)
mp = end - s
d_sq = point_face_distance(
block,
p_first,
tris,
t_first,
mp,
5e-3,
)
d = torch.sqrt(torch.clamp(d_sq, min=0.0))
sl = slice(s, end)
improve = d < best_d[sl]
best_d[sl] = torch.where(improve, d, best_d[sl])
best_i[sl] = torch.where(
improve,
torch.full((mp,), pi, dtype=torch.int64, device=device),
best_i[sl],
)
assign = best_i.to(torch.int32).cpu().numpy()
min_d = best_d.to(torch.float32).cpu().numpy()
a_path = os.path.join(out_dir, "assignments.int32")
d_path = os.path.join(out_dir, "distances.f32")
assign.tofile(a_path)
min_d.tofile(d_path)
meta = {
"ok": True,
"n": int(n_pt),
"parts": n_parts,
"assignmentsFile": a_path,
"distancesFile": d_path,
"device": str(device),
}
with open(os.path.join(out_dir, "result.json"), "w", encoding="utf-8") as f:
json.dump(meta, f)
print(json.dumps(meta), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
GPU 偏差热力:PyTorch CUDA 计算网格顶点到最近点云点的距离并着色。
支持千万~亿级点云:点云不全部 load 进显存,分块流式处理。
无 CUDA / 无依赖时退出码 2,由 Node 回退 CPU。
用法:
python3 deviation_heatmap_gpu.py \
--cad /path/to/model.obj \
--points /tmp/points.bin \
--out-dir /tmp/dev-heatmap \
[--chunk-v 2048] [--chunk-p 100000]
输入:
--cad STL 或 OBJ 文件路径
--points 采样点 float32 little-endian,N*3 个 float(x y z 交替)
输出(写入 --out-dir):
colors.bin float32 little-endian,V*3(每顶点 RGB [0,1])
result.json { ok, vertexCount, dispMin, dispMax }
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import time
def normalize_like_viewer(pts):
"""居中 + 缩放到约 10 单位"""
mn = pts.min(dim=0).values
mx = pts.max(dim=0).values
center = (mn + mx) / 2
max_dim = (mx - mn).max().item()
scale = 10.0 / max_dim if max_dim > 0 else 1.0
return (pts - center) * scale, scale
def sequential_ramp_rgb(t):
"""顺序色标 t∈[0,1]:蓝→绿→黄→红"""
import torch
stops_t = [0.0, 0.33, 0.66, 1.0]
stops_rgb = [
(0x21 / 255, 0x66 / 255, 0xAC / 255),
(0x4D / 255, 0xAF / 255, 0x4A / 255),
(0xFE / 255, 0xE0 / 255, 0x8B / 255),
(0xD7 / 255, 0x30 / 255, 0x27 / 255),
]
t = t.clamp(0.0, 1.0)
r = torch.zeros_like(t)
g = torch.zeros_like(t)
b = torch.zeros_like(t)
for i in range(len(stops_t) - 1):
t0, t1 = stops_t[i], stops_t[i + 1]
r0, g0, b0 = stops_rgb[i]
r1, g1, b1 = stops_rgb[i + 1]
mask = (t >= t0) & (t <= t1) if i < len(stops_t) - 2 else (t >= t0)
s = ((t - t0) / (t1 - t0)).clamp(0, 1)
r = torch.where(mask, r0 + (r1 - r0) * s, r)
g = torch.where(mask, g0 + (g1 - g0) * s, g)
b = torch.where(mask, b0 + (b1 - b0) * s, b)
return torch.stack([r, g, b], dim=-1)
def auto_tune_chunks(vram_bytes: int, n_verts: int, n_pts: int):
"""根据可用显存自动决定分块大小,预留 1GB 给网格/点云/开销"""
usable = max(vram_bytes - 1024 * 1024 * 1024, 512 * 1024 * 1024)
# cdist 输出 float32: chunk_v * chunk_p * 4 bytes
# 目标:每个子矩阵 ≤ usable 的 50%
budget = usable * 0.5
chunk_v = min(4096, n_verts)
chunk_p = int(budget / (chunk_v * 4))
chunk_p = max(10000, min(chunk_p, n_pts))
return chunk_v, chunk_p
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--cad", required=True, help="STL 或 OBJ 文件")
ap.add_argument("--points", required=True, help="float32 little-endian N*3")
ap.add_argument("--out-dir", required=True)
ap.add_argument("--chunk-v", type=int, default=0, help="顶点分块,0=自动")
ap.add_argument("--chunk-p", type=int, default=0, help="点云分块,0=自动")
ap.add_argument("--smooth", type=int, default=40,
help="拉普拉斯平滑迭代次数,0=不平滑,越大区域越均匀")
ap.add_argument("--smooth-alpha", type=float, default=0.7,
help="平滑权重 0~1,越大越平滑")
args = ap.parse_args()
try:
import numpy as np
import torch
import trimesh
except ImportError as e:
print(json.dumps({"ok": False, "error": f"import: {e}", "fallback": True}), flush=True)
sys.exit(2)
if not torch.cuda.is_available():
print(json.dumps({"ok": False, "error": "no CUDA device", "fallback": True}), flush=True)
sys.exit(2)
device = torch.device("cuda:0")
vram = torch.cuda.get_device_properties(device).total_memory
print(f"[DeviationGPU] VRAM={vram / 1e9:.1f}GB device={torch.cuda.get_device_name(device)}", flush=True)
t0 = time.time()
# ── 加载 CAD 网格 ──
cad_path = args.cad
ext = os.path.splitext(cad_path)[1].lower()
mesh = trimesh.load(cad_path, force="mesh")
if hasattr(mesh, "geometry"):
mesh = trimesh.util.concatenate(tuple(mesh.geometry.values()))
verts_np = np.array(mesh.vertices, dtype=np.float32)
n_verts = verts_np.shape[0]
if n_verts == 0:
print(json.dumps({"ok": False, "error": "empty mesh"}), flush=True)
sys.exit(1)
print(f"[DeviationGPU] mesh {ext} verts={n_verts:,}", flush=True)
# ── 加载点云(mmap,不占全部内存)──
pts_path = args.points
file_size = os.path.getsize(pts_path)
n_floats = file_size // 4
if n_floats % 3 != 0:
print(json.dumps({"ok": False, "error": "points size not multiple of 12"}), flush=True)
sys.exit(1)
n_pts = n_floats // 3
pts_mmap = np.memmap(pts_path, dtype=np.float32, mode="r", shape=(n_pts, 3))
print(f"[DeviationGPU] points={n_pts:,} ({file_size / 1e9:.2f}GB)", flush=True)
faces_np = np.array(mesh.faces, dtype=np.int64)
n_faces = faces_np.shape[0]
print(f"[DeviationGPU] faces={n_faces:,} face_verts={n_faces * 3:,} (Three.js layout)", flush=True)
# ── 归一化 mesh(放 GPU)──
verts_t = torch.tensor(verts_np, dtype=torch.float32, device=device)
verts_t, _ = normalize_like_viewer(verts_t)
# ── 点云归一化参数(在 CPU 上扫一遍 mmap 求 bbox)──
SCAN_CAP = min(n_pts, 2_000_000)
scan_idx = np.linspace(0, n_pts - 1, SCAN_CAP, dtype=int)
scan_pts = pts_mmap[scan_idx]
pc_min = scan_pts.min(axis=0)
pc_max = scan_pts.max(axis=0)
pc_center = (pc_min + pc_max) / 2
pc_max_dim = float((pc_max - pc_min).max())
pc_scale = 10.0 / pc_max_dim if pc_max_dim > 0 else 1.0
# ── 分块大小 ──
chunk_v = args.chunk_v if args.chunk_v > 0 else 0
chunk_p = args.chunk_p if args.chunk_p > 0 else 0
if chunk_v == 0 or chunk_p == 0:
auto_v, auto_p = auto_tune_chunks(vram, n_verts, n_pts)
if chunk_v == 0:
chunk_v = auto_v
if chunk_p == 0:
chunk_p = auto_p
sub_mem = chunk_v * chunk_p * 4 / 1024 / 1024
total_iters = math.ceil(n_verts / chunk_v) * math.ceil(n_pts / chunk_p)
print(f"[DeviationGPU] chunk_v={chunk_v:,} chunk_p={chunk_p:,} sub-matrix≈{sub_mem:.0f}MB iters={total_iters:,}", flush=True)
# ── 双重分块 KNN ──
best_dist = torch.full((n_verts,), float("inf"), dtype=torch.float32, device=device)
done_iters = 0
last_log = time.time()
for sv in range(0, n_verts, chunk_v):
ev = min(sv + chunk_v, n_verts)
block_v = verts_t[sv:ev] # (Bv, 3)
for sp in range(0, n_pts, chunk_p):
ep = min(sp + chunk_p, n_pts)
# 从 mmap 读一块点云 → GPU,归一化
block_p_np = np.array(pts_mmap[sp:ep]) # copy from mmap
block_p = torch.tensor(block_p_np, dtype=torch.float32, device=device)
block_p = (block_p - torch.tensor(pc_center, dtype=torch.float32, device=device)) * pc_scale
d = torch.cdist(block_v, block_p) # (Bv, Bp)
min_d, _ = d.min(dim=1)
best_dist[sv:ev] = torch.minimum(best_dist[sv:ev], min_d)
done_iters += 1
now = time.time()
if now - last_log >= 2.0 or sv == 0:
pct = done_iters / total_iters * 100
elapsed = now - t0
eta = elapsed / max(done_iters, 1) * (total_iters - done_iters)
print(f"[DeviationGPU] {pct:.0f}% verts={sv:,}/{n_verts:,} elapsed={elapsed:.1f}s ETA={eta:.0f}s", flush=True)
last_log = now
best_dist_cpu = best_dist.cpu()
elapsed = time.time() - t0
print(f"[DeviationGPU] KNN done in {elapsed:.1f}s", flush=True)
# ── 拉普拉斯平滑:相邻顶点距离互相平均,消除逐顶点噪声,形成区域级着色 ──
smooth_iters = args.smooth
smooth_alpha = args.smooth_alpha
if smooth_iters > 0:
t_smooth = time.time()
edges = mesh.edges_unique # (E, 2) 唯一边
e0 = edges[:, 0]
e1 = edges[:, 1]
# 每个顶点的邻居数(度)
degree = np.zeros(n_verts, dtype=np.float32)
np.add.at(degree, e0, 1)
np.add.at(degree, e1, 1)
degree[degree == 0] = 1.0
dist = best_dist_cpu.numpy().copy()
alpha = smooth_alpha
for it in range(smooth_iters):
# 对每个顶点,累加所有邻居的距离值
neighbor_sum = np.zeros(n_verts, dtype=np.float64)
np.add.at(neighbor_sum, e0, dist[e1])
np.add.at(neighbor_sum, e1, dist[e0])
neighbor_avg = (neighbor_sum / degree).astype(np.float32)
dist = alpha * neighbor_avg + (1 - alpha) * dist
best_dist_cpu = torch.from_numpy(dist)
print(f"[DeviationGPU] smoothed {smooth_iters} iters (alpha={smooth_alpha}) in {time.time() - t_smooth:.1f}s", flush=True)
# ── 色标范围(4%–96% 分位数)──
sorted_d, _ = best_dist_cpu.sort()
m = sorted_d.numel()
lo = sorted_d[max(0, int(m * 0.04))].item()
hi = sorted_d[min(m - 1, int(math.ceil(m * 0.96)) - 1)].item()
if hi <= lo:
hi = lo + 1e-9
disp_min = float(lo)
disp_max = float(hi)
# ── 展开为 per-face-vertex(与 Three.js OBJLoader 非索引布局一致)──
face_vert_idx = torch.from_numpy(faces_np.flatten().astype(np.int64))
best_dist_face = best_dist_cpu[face_vert_idx] # (F*3,)
n_face_verts = best_dist_face.numel()
print(f"[DeviationGPU] expanded unique→face verts: {n_verts:,} → {n_face_verts:,}", flush=True)
# ── 着色 ──
t_norm = ((best_dist_face - disp_min) / (disp_max - disp_min)).clamp(0, 1)
colors = sequential_ramp_rgb(t_norm) # (F*3, 3)
# ── 写出 ──
os.makedirs(args.out_dir, exist_ok=True)
colors_np = colors.numpy().astype(np.float32)
colors_path = os.path.join(args.out_dir, "colors.bin")
colors_np.tofile(colors_path)
meta = {
"ok": True,
"vertexCount": int(n_face_verts),
"dispMin": disp_min,
"dispMax": disp_max,
"pointsUsed": int(n_pts),
"elapsedSec": round(time.time() - t0, 1),
}
with open(os.path.join(args.out_dir, "result.json"), "w", encoding="utf-8") as f:
json.dump(meta, f)
print(json.dumps(meta), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# 将当前运行的 massive-point-cloud-app-dev 容器固化为镜像,避免下次从 Dockerfile 全量重建。
#
# 限制(必读):
# - 绑定挂载 .:/app 下的代码改动在 commit 时不会打进镜像(运行时仍用宿主机目录)。
# - app-node-modules 是命名卷,commit 不会包含 node_modules;勿对 app 执行 docker compose down -v,
# 否则需重新 pnpm install(卷在则依赖仍在)。
# - 在容器内装到 /usr、/usr/local 等的 apt/pip 等会随 commit 保留。
#
# 用法:
# chmod +x scripts/snapshot-app-dev-image.sh
# ./scripts/snapshot-app-dev-image.sh # 标签 massive-point-cloud-app-dev:frozen-YYYYMMDD
# ./scripts/snapshot-app-dev-image.sh mytag # 标签 massive-point-cloud-app-dev:mytag
# ./scripts/snapshot-app-dev-image.sh mytag ./app-dev-snapshot.tar # 同时 docker save 到文件便于备份/迁移
set -euo pipefail
CONTAINER_NAME="${CONTAINER_NAME:-massive-point-cloud-app-dev}"
IMAGE_BASENAME="${IMAGE_BASENAME:-massive-point-cloud-app-dev}"
TAG="${1:-frozen-$(date +%Y%m%d)}"
SAVE_PATH="${2:-}"
if ! docker inspect "$CONTAINER_NAME" &>/dev/null; then
echo "错误: 找不到容器 $CONTAINER_NAME ,请先启动: docker compose -f docker-compose.dev.yml up -d app" >&2
exit 1
fi
FULL_IMAGE="${IMAGE_BASENAME}:${TAG}"
echo "正在提交容器 $CONTAINER_NAME -> $FULL_IMAGE"
docker commit "$CONTAINER_NAME" "$FULL_IMAGE"
echo "已生成镜像: $FULL_IMAGE"
if [[ -n "$SAVE_PATH" ]]; then
echo "正在导出: docker save -o $SAVE_PATH $FULL_IMAGE"
docker save -o "$SAVE_PATH" "$FULL_IMAGE"
echo "已保存: $SAVE_PATH"
fi
cat <<EOF
后续用固化镜像启动(跳过 Dockerfile 构建):
export MASSIVE_APP_DEV_IMAGE=$FULL_IMAGE
docker compose -f docker-compose.dev.yml -f docker-compose.gpu.yml up -d --no-build
若不用 GPU 覆盖文件,可去掉 -f docker-compose.gpu.yml 。
勿使用 --build ,否则会按 Dockerfile 重新构建并覆盖本地 tag 的构建结果。
如需长期默认使用该镜像,可在项目根目录 .env 中写入一行:
MASSIVE_APP_DEV_IMAGE=$FULL_IMAGE
EOF

94
scripts/step_parse_mvp.py Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
STEP → 每 solid 一个 STL + manifest.json(需 cadquery:pip install cadquery)
用法: python3 step_parse_mvp.py <step_path> <out_dir>
stdout 打印一行 JSON: {"ok":true} 或 {"ok":false,"error":"..."}
"""
from __future__ import annotations
import json
import os
import sys
def main() -> None:
if len(sys.argv) < 3:
err = {"ok": False, "error": "usage: step_parse_mvp.py <step_path> <out_dir>"}
print(json.dumps(err), flush=True)
sys.exit(2)
step_path = sys.argv[1]
out_dir = sys.argv[2]
try:
import cadquery as cq # type: ignore
except ImportError as e:
# pip3 与 python3 可能不是同一解释器,须用「当前解释器」安装:见 sys.executable
exe = sys.executable
pip_cmd = (
f'"{exe}" -m pip install --break-system-packages '
f"-i https://pypi.tuna.tsinghua.edu.cn/simple cadquery"
)
detail = f"{type(e).__name__}: {e}"
err = {
"ok": False,
"error": (
f"无法 import cadquery({detail})。当前 Python: {exe}。"
f"请用**同一解释器**安装(勿单独用 pip3,除非确认与 python3 一致): {pip_cmd}"
),
}
print(json.dumps(err, ensure_ascii=False), flush=True)
sys.exit(1)
if not os.path.isfile(step_path):
err = {"ok": False, "error": f"文件不存在: {step_path}"}
print(json.dumps(err), flush=True)
sys.exit(1)
os.makedirs(out_dir, exist_ok=True)
try:
wp = cq.importers.importStep(step_path)
except Exception as e:
err = {"ok": False, "error": f"importStep: {e}"}
print(json.dumps(err), flush=True)
sys.exit(1)
solids: list = []
try:
solids = list(wp.solids().vals())
except Exception:
try:
solids = [wp.val()]
except Exception as e:
err = {"ok": False, "error": f"无法取得实体: {e}"}
print(json.dumps(err), flush=True)
sys.exit(1)
if not solids:
err = {"ok": False, "error": "STEP 中未解析出任何 Solid"}
print(json.dumps(err), flush=True)
sys.exit(1)
parts: list[dict] = []
for i, solid in enumerate(solids):
name = f"part_{i:04d}"
stl_name = f"{name}.stl"
stl_path = os.path.join(out_dir, stl_name)
cq.exporters.export(solid, stl_path)
parts.append({"id": name, "name": name, "stlRelative": stl_name})
manifest = {"ok": True, "parts": parts, "sourceStep": os.path.abspath(step_path)}
man_path = os.path.join(out_dir, "manifest.json")
with open(man_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
result_line = json.dumps({"ok": True, "parts": parts, "manifestPath": man_path})
# Node 端若 stdout 含 OCP 杂讯,仍可从该文件读取结果
with open(os.path.join(out_dir, "_parse_result.json"), "w", encoding="utf-8") as f:
f.write(result_line)
print(result_line, flush=True)
if __name__ == "__main__":
main()