Files
massive-cloud/scripts/deviation_heatmap_gpu.py

268 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()