把三维扫描点云做成一个能点开的网页:从偏差算法到上线部署的完整过程

写于 2026-11-04

先说结果,省得你往下翻找:

两条链接点开就能用,不用登录、不依赖后端。左边一整套是合格件,右边一整套是缺陷件,鼠标拖动可以转,点一下「开始扫描」会从下往上一层一层扫过去,扫过的区域按偏差上色,最后自动标出三处超差并给出判读理由。手机上建议横屏,这页是按大屏设计的。

下面是这东西怎么一步步做出来的,包括我踩的三个技术坑和一个部署坑。

一、起点:一个"你凭什么说它错了"的问题

需求很简单:课堂教学里要对比两件零件的三维扫描点云,一件合格、一件有缺陷,让观众直观看到"AI 能指出哪里错了"。

难点在最后半句。让人看到"这里红了"很容易,任何检测软件都能给一张彩色云图;难的是说清为什么红、红成这个形状代表什么。学生看得懂颜色,说不出性质——这才是要解决的问题。

所以我给自己的目标不是"做一个检测软件",而是"把专家的判读过程演出来"。

二、先做减法:三条路,只选一条当主菜

摆在面前有三条技术路线:

  • 二维切面比对:在三维软件里切一刀,导出两张截面截图,网页上做逐像素比对。半天能做完,最稳,视觉上像教材插图。
  • 三维点云并排:两套点云丢进浏览器,用 WebGL 渲染,可以旋转、可以剖切、可以逐层扫描。两三天,视觉冲击最强。
  • 现场真实扫描:拿扫描仪现场扫一个件。最有说服力,但光线、反光、标定、时间全都不可控。

最后选的是三维当主演示、二维当兜底、真实扫描只写进教学设计当拓展。理由是比赛现场只有一次机会,宁可牺牲一点"真实感",也要保证零失误。

这条判断后来被证明是对的:现场设备、投影、网络任何一环出问题,我只要双击一个本地 HTML 就能继续讲。

三、数据从哪来:不去现场扫,自己"造"一个缺陷件

这里有个观念要先扭过来:不要拿"另一个真实零件"当基准。

真实缺陷不可复现、不可控、讲不清成因。要考三个知识点,就得让三种误差各出现一次,而且幅度刚好可辨——真实件给不了这个。

所以做法是:在三维软件里建理论模型,然后人为注入三类典型误差,生成一版"合格件"和一版"缺陷件"。这在计量教学里叫标准件/缺陷样件,是正规做法,不是造假。

这次设计的零件是"底板 + 凸台 + 沉头锥孔 + 四个安装孔",注入的三处误差刚好对应三条判据:

  • 安装孔孔位偏移 1.44 mm(位置误差):偏差沿圆周按余弦规律变化,表现为"单侧红、对侧蓝"的双色环带,红蓝分界沿孔轴贯穿到底。这是刚性整体偏移的指纹——如果是孔径变小,整圈都会红。
  • 凸台顶面翘曲 0.32 mm(形状误差):四角高、中心低,且对称于零件几何中心。对称性说明不是磕碰,是残余应力释放或夹紧力过大。
  • 沉头锥角 +3°(角度误差):偏差从锥底向锥口线性增大、同一高度整圈一致。沿母线单调增大是角度类误差的典型特征;整条母线一起偏,那才叫位置误差。

三处误差、三种形态、三条判据,讲完正好收口。

四、第一个技术坎:偏差算出来是假的

有了两套点云,下一步是算"每个点偏离理论面多少毫米"。最直觉的做法是点到点最近邻距离:对实测点云里每个点,在理论点云里找最近的点,算欧氏距离。

我照这个做了一版,跑出来是这样的:

偏差   : 均值 0.4020  中位 0.3449  RMS 0.4836  最大 2.8795 (mm)
超差   : 21700 / 22509 点 (96.4%)  判据 0.1 mm

96.4% 的点超差。零件再差也不可能几乎所有点都超差——这不是零件的问题,是算法的问题。

原因很朴素:两套点云是各自独立采样的,两个点之间的"最近距离"里混进了采样的疏密。采样间距本身就有 0.3~0.4 mm,这个量级直接盖过了真实偏差。真实扫描件之间对比也有同样的问题,只是没人细想。

正确的做法是点到局部平面:对每个实测点,先取理论点云里最近的 8 个点,拟合一个局部平面,再算点到这个平面的距离。10 行代码:

from scipy.spatial import cKDTree
import numpy as np

def point_to_plane(tree, nom, mea, k=8):
    _, idx = tree.query(mea, k=k, workers=-1)
    nb  = nom[idx]                       # (n, k, 3) 每个点的 k 个邻居
    c   = nb.mean(axis=1)                # 邻域重心
    q   = nb - c[:, None, :]
    cov = np.einsum("nki,nkj->nij", q, q) / k
    val, vec = np.linalg.eigh(cov)
    nrm = vec[:, :, 0]                   # 最小特征向量 = 局部法向
    return np.abs(np.einsum("ni,ni->n", mea - c, nrm))

同一份数据,换成点到平面:

偏差   : 均值 0.0775  中位 0.0226  RMS 0.1741  最大 1.6868 (mm)
超差   : 5495 / 22509 点 (24.4%)  判据 0.1 mm

均值从 0.40 掉到 0.078,超差比例从 96.4% 掉到 24.4%,最大偏差 1.69 mm 正好对上设计值 1.44 mm 加噪声。这才是真的。

这一条值得单独记住:点云之间算距离,稀疏时点到点是废的,必须点到面(或拟合局部面)。

五、第二个技术坎:棱角和边界会造假

换成点到平面之后,零件边缘冒出一片假红点,位置都在点云的"边界"上——比如底面的边缘、孔口的棱线。

原因是这一处的邻域退化了:边缘上的点,它的 8 个邻居基本都落在同一侧,拟出来的"平面"根本代表不了曲面,法向是错的,算出来的距离自然离谱。

两个过滤,一起上:

  • 面性判据:把邻域协方差矩阵的三个特征值排序,(λ2 − λ1) / λ3 小于 0.25 就说明邻域退化成线或点(棱角),法向不可信,直接掩掉。
  • 边界判据:第 k 个邻居的距离如果超过全局中位数的 2.5 倍,说明这个点孤零零挂在点云边上,同样掩掉。

实测这一轮滤掉 3021 个点,占 13%。滤完之后,假红点基本消失,剩下的全是真缺陷。

顺带说个经验:任何点云处理算法都要先看一眼边界。边界效应是这类算法的通病,不是数据脏。

六、第三个技术坎:把"一堆红点"变成"一处缺陷"

到这一步,你有的是几万个带偏差值的点。给人看的话,这没有意义——"这里有 5495 个超差点"不是一句人话。

人话是"这个零件的安装孔偏了 1.44 毫米"。

中间差的这一步是聚类:把超差点按三维空间连通性聚成区域,每个区域给出中心位置、包围盒尺寸、最大偏差、平均偏差。我用的是最朴素的做法——把超差点铺到一个体素网格上,用 26 邻域连通域标号:

from scipy import ndimage

sel  = dev > thresh
keys = np.floor((pts[sel] - pts[sel].min(0)) / voxel).astype(int)
grid = np.zeros(keys.max(0) + 2, dtype=bool)
grid[keys[:, 0], keys[:, 1], keys[:, 2]] = True
lab, n = ndimage.label(grid, structure=np.ones((3, 3, 3)))

跑出来是 4 个区域,最大的那个 3888 个点、最大偏差 1.50 mm、尺寸 80×12×60 mm——一看就知道是底板顶面加侧壁那一圈。

聚类做完,后面才好接"智能体":把每个区域的数值喂给大模型,让它按固定格式写判读。我用的提示词是五段式——部位与数值、偏差的分布形态、这个形态排除了哪些可能、最可能的工艺成因、一条可执行的处置建议。关键要求是"不写模糊词":不许说"可能""也许",必须给判断依据。这样出来的文字才配得上"判读"两个字。

七、前端:把检测过程演出来,而不是直接给答案

前端是一个 three.js 页面,但没用任何高级特效,全部围绕"让过程可见"来设计。

点云渲染用自定义着色器而不是现成的 PointsMaterial,因为要两个东西:

  • 双色带:公差内走蓝→青(冷色,安全),超差走黄→红(暖色,告警),阈值正好是冷暖分界。学生一眼能看出"这条线就是标准"。
  • 扫描揭示:所有点初始是灰的,一个 uScanY 均匀量从下往上推,被扫过的点才上色,扫描面附近再加一道高亮。这一手让"检测"变成了一个可以暂停、可以讲解的动作,而不是一个黑箱结论。

剖切没有用 three.js 的裁剪平面,而是在着色器里直接 discard:

if (uSlabOn > 0.5 && (vY < uSlabMin || vY > uSlabMax)) discard;

原因很实际:裁剪平面是世界坐标的,镜头一转切割面就跟着歪;而在着色器里按顶点自身的 Y 判断,切面永远"贴在零件上",转镜头时切面不动。讲课时勾一下"剖切观察"、拖一下高度滑块,三维就变成了教材上的剖面图。

编号标注是 HTML 覆盖层:把三处缺陷的三维坐标投影到屏幕坐标,每帧更新一个绝对定位的小圆点。试过直接画成飘在模型上的文字标签,三处挨着的时候会糊在一起,改成编号圆点 + 右侧卡片对应,反而更清楚。

性能上,两件共 84k 个点,实测 60 fps,不需要任何优化技巧。

八、为什么必须打包成一个 627 KB 的 HTML

三个原因,按重要性排:

  1. 现场没网。比赛场地的网络是玄学,不能赌。
  2. 装环境来不及。让评委或学生装 Node、装 Python 是不现实的。
  3. 一个文件最好传。U 盘、微信、邮件都能过去。

做法是把 three.js 的 UMD 版本整个内联进 HTML,得到一个 627 KB 的自包含文件,file:// 双击就能跑。代价是源码不可维护,所以本地留了两份:模板(外链 three.js)+ 打包脚本(把 three.js 内联)。

html = tpl.read_text(encoding="utf-8")
js   = three.read_text(encoding="utf-8")
html = html.replace('<script src="three.min.js"></script>',
                    "<script>\n" + js + "\n</script>")

改功能改模板,跑一次脚本重新打包,几分钟的事。

九、上线到 Django 站点:四步,加一个坑

站点是 Django(DEBUG=False,静态文件走自定义的 static_serve),加两个独立页面的做法很轻:

  1. 文件放静态目录:/app/static/tools/pointcloud3d.html 和 pointcloud2d.html
  2. 加两条路由(插在 urlpatterns 末尾):
path('tools/pointcloud/', serve,
     kwargs={'path': 'tools/pointcloud3d.html',
             'document_root': settings.BASE_DIR / 'static'},
     name='tool_pointcloud'),
path('tools/pointcloud-section/', serve,
     kwargs={'path': 'tools/pointcloud2d.html',
             'document_root': settings.BASE_DIR / 'static'},
     name='tool_pointcloud_section'),

注意 document_root 是显式指定的,所以跟 DEBUG 是 True 还是 False、有没有跑过 collectstatic 都没关系。这一点省掉了整条静态文件部署仪式。

  1. 首页加一张入口卡片(放在「个人开发在线实用工具」区块里)
  2. 重启 + 验证:模板改了必须重启(DEBUG=False 时 Django 用缓存模板加载器),然后 curl 断言 200、浏览器验一遍交互。

踩的坑在这里:首页的工具卡片是一串 <div class="website-item">…</div>,我拿「上一个卡片的闭合标签 + 容器闭合标签 + 下一个标题」当锚点做字符串替换,替换的时候把上一个卡片的 </div> 顺手吃掉了。结果是新卡片被嵌进了上一张卡里——页面上表现为「数据可视化工具」那张卡里莫名其妙多出一段别的工具介绍,而 .website-item 的数量居然没变,光数数量查不出来。

教训是:改 HTML 做字符串替换,替换完必须验证结构而不是验证数量。三个自检一起上:

  • document.querySelectorAll('.website-item').length 对不对
  • 新卡片的 closest('.website-item').textContent 是不是只含本卡片文字
  • <div 与 </div> 计数是否相等

十、可复现的命令清单

没有扫描仪也能把整条链路跑通,两个脚本就够了。

先生成一对教学样件点云(凸台 + 沉头锥孔 + 四个安装孔,含三处人为缺陷):

python tools/make_test_ply.py -o data
#   → data/nominal.ply  (37500 点)
#   → data/defect.ply   (37500 点)

再算偏差、聚类、导出网页要的 JSON:

python tools/build_pointcloud.py \
    --nominal data/nominal.ply \
    --measured data/defect.ply \
    --up y --voxel 0.6 --thresh 0.10 \
    -o data/pointcloud.json \
    --dump-errors data/errors_template.json

几个参数值得记:

  • --up z:扫描仪一般 Z 轴朝上,网页用 Y 轴朝上,脚本会自动转换(默认 z,样例数据已经是 y)
  • --voxel 0.6:体素下采样,把点云压到浏览器扛得住的量级;0 表示不降采样
  • --thresh 0.10:超差判据,单位毫米,页面上的红色阈值滑块跟它对应
  • --dist-mode plane:默认就是点到局部平面;只有点云足够密时才建议用 point(点到点)
  • --dump-errors:把聚类出来的区域导成一份判读模板,交给大模型补文字

十一、复盘:这套东西的价值不在代码

代码量不大,真正花时间的是三件事,而且都不是技术问题:

第一,想清楚要考什么。先确定三条判据(分布形态、方向性、单调性),再倒推需要什么数据、界面要暴露什么交互。顺序反了就会做出一堆炫技但讲不出所以然的效果。

第二,让过程可见,而不是给结论。所以扫描要一层层扫、阈值要能现场调、剖切要能随手拖。评委和学生看到的不是"AI 说它错了",而是"AI 一步步比出来的"。

第三,留一条人推翻 AI 的路。课堂上我故意留了个问题:"AI 说这是装夹偏移,你信吗?你要用什么去验证?"——AI 给假设,人做验证。这一条比任何算法都重要。

如果这篇文章只留一句话:AI 把判读过程显性化,但判据得在你自己脑子里。

页面基于 three.js(threejs.org)渲染,偏差计算与聚类用 SciPy 的 cKDTree 与 ndimage(scipy.spatial.cKDTree)。如果要做更专业的点云配准、色谱分析与报告导出,建议配合 CloudCompare(cloudcompare.org)——它是免费开源的,配准精度和可视化都比自己造轮子省事。