做视觉项目最绕不开的一类场景就是传感器端给出来的数据明明够用可一到算法软件里就不知道该从哪里下手。我最近接手一个线激光3D视觉项目客户给的是线激光传感器扫描出来的高度矩阵要求转成Halcon能直接做缺陷检测和尺寸测量的深度图与点云。一开始我直接在Halcon里读图、顺手转点云结果点云方向全乱飞点一堆深度图也不知道怎么保存成可分析的格式。前前后后折腾了几天最终整理出一套稳定流程一共5步。这篇博文把这个过程完整写出来配合可直接复制的Halcon代码给同样被线激光数据转换折磨过的朋友做个参考。1. 先搞清楚三样东西高度图、深度图、点云分别是什么很多人一上来就喊深度图转点云但线激光传感器给出来的原始数据严格说叫高度图。这两者有差别但在Halcon的处理逻辑里基本可以当成一回事前提是你得知道每个像素的灰度值到底代表什么物理含义。线激光传感器的工作方式其实不复杂激光线打在被测物表面相机拍下一条光条通过三角测量算出这条线上每个点的高度然后被测物在运动轴上移动传感器按固定间距连续采集轮廓线。把所有轮廓线按运动方向堆叠起来就得到一张二维图——行号对应运动位置列号对应扫描宽度方向像素值对应高度。这张图就是高度图也是后续所有处理的源头。Halcon里没有单独的高度图类型它把这种数据当作普通单通道图像处理常用数据类型是uint2或real。和普通灰度图的本质区别在于普通灰度图的灰度值只是亮度没有物理单位高度图的灰度值却代表空间中的高度需要配合标定参数才能换算成毫米。深度图这个词在Halcon文档里经常出现严格说深度图通常指从相机坐标系度量出来的Z值而高度图是从参考平面度量但在线激光场景里Z就是高度混用没有太大问题。你只需要记住一个关键点灰度值等于高度换算关系取决于标定。点云则是另一种数据结构。深度图是一张规整网格每个像素对应一个高度本质是2.5D数据它没法描述悬空、遮挡这类复杂形状。点云是一串带三维坐标的离散点没有网格约束可以表达任意表面。Halcon里点云对应ObjectModel3D句柄后续的平面拟合、3D匹配、点云缺陷检测都基于这个数据结构。三者之间的转换关系可以浓缩成下面这个公式这也是整个转换流程的核心X col * x_scale x_offset Y row * y_scale y_offset Z gray * z_scale z_offset也就是说把每个像素的行列号代入X/Y公式把灰度值代入Z公式就得到了该点的三维坐标所有点收集起来就是点云。反向操作也一样把点云投影回规则网格就得到深度图。数据形态维度物理含义Halcon里的表示适合做什么高度图/深度图H×W每像素灰度代表高度HImage高度差、截面分析、平整度评价点云N×3离散三维坐标点集ObjectModel3D平面拟合、3D匹配、缺陷定位2. 转换前的准备工作拿到原始数据先确认四件事很多坑都是因为数据源头没确认清楚等转完点云才发现方向反了或者全是飞点再回头排查浪费大量时间。我建议拿到数据后先别急着写转换代码把下面四件事一项项确认掉。2.1 确认数据形态和读取方式不同厂家、不同导出方式拿到的东西差别很大。最常见的是16位整型TIFFHalcon直接read_image就能读。也有传感器SDK直接把float数组给到内存这种情况在C#或Qt里调用Halcon时很常见用gen_image1转成HObject再处理。还有少数场景给的是CSV文本一行一条扫描线那就得先读矩阵再matrix_to_image转成图像。代码如下* 方式1直接读TIFF read_image (HeightImage, height_map.tiff) get_image_size (HeightImage, Width, Height) get_image_type (HeightImage, ImageType) * 方式2从内存float数组生成C#/C里调用时常用 * gen_image1 (HeightImage, real, Width, Height, Pointer)我的建议是无论在哪个环节先把数据统一成real类型的HObject图像后面所有算子都按这个类型处理能省掉大量类型转换引发的麻烦。如果是16位TIFF读进来先转成realconvert_image_type (HeightImage, HeightImage, real)2.2 确认物理标定参数线激光系统的标定通常不在Halcon这边做而是由传感器厂商或运动平台标定完成。你要做的只是问清楚三件事X方向相邻像素对应多少毫米x_scale运动方向相邻轮廓间隔是多少毫米y_scale高度方向上灰度值变化1对应多少毫米z_scale有些传感器输出的是编码器脉冲索引不是物理位移那就需要再查运动平台的脉冲当量换算后才能使用。如果传感器已经做好了高度标定Z方向就只剩一个零点偏移。举个例子某传感器16位量程对应40mm高度范围那么z_scale 40 / 65535 ≈ 0.00061 mm/LSB。如果量程是60000个灰度级对应42mm那就是z_scale 42 / 60000 0.0007 mm/LSB。这个值一定要用标定块或已知高度差验证过再写死进代码。2.3 确认坐标系方向和运动方向坐标系方向是最容易踩坑的地方。Halcon图像里row向下增长col向右增长而传感器厂商的坐标系五花八门有的X正方向和图像列方向相反有的Y正方向和运动方向相反。如果是标定后的数据还好最怕的是直接用原始编码器帧号堆叠出来的图。帧号越大在图像里越靠下但如果运动方向是反着走的Y方向整个就反了。怎么确认扫一个已知形状比如高台阶或斜块转成点云后旋转观察朝向和实物一对比就清楚了。如果发现方向反了在第3步构建Y坐标图时把行号翻转即可代码后面会写。2.4 确认无效值编码线激光在边缘、反光表面、低反射率表面经常丢点返回值可能是0、-32768、65535也可能是NaN。这些无效点如果不处理转出来的点云会有一圈一圈的飞点极其难看还会直接影响后续测量精度。务必先用min_max_gray和灰度直方图统计一下看有没有大量集中在极值附近的孤岛区域确认无效值编码后再写剔除逻辑。3. 五步转换从高度数据到可分析的深度图与点云流程整理成5步之后代码维护和排错都清晰多了。每一步做什么、为什么这么做下面都拆开讲。3.1 第1步读入高度数据并转成Halcon图像这一步的目标就是拿到一张类型统一、尺寸明确的HObject图像。* 读入TIFF read_image (HeightImage, height_map.tiff) get_image_size (HeightImage, Width, Height) get_image_type (HeightImage, ImageType) * 统一转成real类型避免后续运算精度问题 if (ImageType ! real) convert_image_type (HeightImage, HeightImage, real) endif * 观察灰度范围确认有效数据区间 min_max_gray (HeightImage, HeightImage, 0, MinGray, MaxGray, Range)这里有个细节如果图像里有无效值MinGray和MaxGray往往会被极值污染。所以看到范围特别离谱时不要慌先看直方图确认哪些是有效数据再做下一步剔除。3.2 第2步无效高度值剔除与滤波这一步的目的是让原始高度图变得干净为后面的点云质量打底。* 根据实际直方图设置有效高度范围 MinValid : 1000 MaxValid : 60000 threshold (HeightImage, ValidRegion, MinValid, MaxValid) * 把有效区域外的高度值置为NANxyz_to_object_model_3d会自动跳过NAN像素 paint_gray (HeightImage, ValidRegion, 0, TmpImage) * 先生成一个有效区域内的临时图再对无效区域填NAN * 更直接的做法先复制原图再把无效区域填NAN copy_image (HeightImage, HeightImageClean) threshold (HeightImage, InvalidRegion, 0, MinValid - 1) paint_gray (HeightImageClean, InvalidRegion, NAN, HeightImageClean) threshold (HeightImage, InvalidRegion2, MaxValid 1, 65535) paint_gray (HeightImageClean, InvalidRegion2, NAN, HeightImageClean)我实际项目里更常用的一招是先按阈值范围把无效点填成NAN再做中值滤波。中值滤波的好处是既能压制孤立噪点又比均值滤波更能保留台阶边缘。* 中值滤波3x3或5x5即可过大会抹掉细节 median_image (HeightImageClean, HeightImageFiltered, 5, 5)滤波核大小的选择原则很简单如果物体表面本身粗糙需要保留纹理细节用3x3如果表面比较光滑想重点去除飞点用5x5。不要一上来就搞大核边缘会糊掉。3.3 第3步标定参数换算构建X/Y/Z三通道物理坐标图这是整个流程最核心的一步。xyz_to_object_model_3d需要三张单通道图X图、Y图、Z图三张图中同一像素位置的数值分别代表该点的X、Y、Z物理坐标。Z图最容易得到把滤波后的高度图按标定参数缩放偏移即可* 标定参数务必根据实际系统标定结果替换 XScale : 0.025 * mm/pixel YScale : 0.025 * mm/pixel ZScale : 0.0007 * mm/gray ZOffset : 0.0 * Z坐标图 灰度值 * z_scale z_offset scale_image (HeightImageFiltered, ZImg, ZScale, ZOffset) convert_image_type (ZImg, ZImg, real)X和Y图需要根据图像尺寸构建坐标网格。在HDevelop里最直观的方式是用矩阵* 构建X坐标图每个像素的灰度值 该像素的列号 create_matrix (Height, Width, 0, MX) for C : 0 to Width - 1 by 1 create_matrix (Height, 1, C, ColVec) set_sub_matrix (MX, ColVec, 0, C) endfor scale_matrix (MX, XScale, MX) matrix_to_image (MX, XImg) * 构建Y坐标图每个像素的灰度值 该像素的行号 * 如果运动方向和图像行方向相反把行号改成 Height - 1 - R 即可 create_matrix (Height, Width, 0, MY) for R : 0 to Height - 1 by 1 create_matrix (1, Width, R, RowVec) set_sub_matrix (MY, RowVec, R, 0) endfor scale_matrix (MY, YScale, MY) matrix_to_image (MY, YImg)这里要解释一下为什么用矩阵而不是set_grayval逐像素赋值一幅2048×1024的高度图有超过200万个像素如果用双重循环逐像素赋值在HDevelop里跑起来非常慢矩阵操作是C底层实现的速度快一个量级。另外如果你是在C#或C里通过外部程序调用Halcon还可以更暴力直接创建两个float数组填充好X和Y坐标值用gen_image1一次性生成X图和Y图这样连HDevelop里的循环都省了。这个方案更适合生产环境尤其是采集和转换要实时跑的时候。3.4 第4步XYZ图转成3D点云模型并可视化三张坐标图准备好之后生成点云就是一句话的事* 由X/Y/Z三张坐标图生成3D点云模型 xyz_to_object_model_3d (XImg, YImg, ZImg, ObjectModel3D) * 检查实际生成的点数 get_object_model_3d_params (ObjectModel3D, num_points, NumPoints) * 可视化 dev_open_window (0, 0, 1000, 800, black, WindowHandle) disp_object_model_3d (WindowHandle, ObjectModel3D, [], [], [], [], [], [], [])这里有几个容易出问题的点。第一xyz_to_object_model_3d对输入图像类型有要求通常需要real类型。所以第3步里convert_image_type那步不能省否则有些版本会直接报错有些版本生成的点云数值是错的。第二前面把无效点填成NAN这一步就会自动跳过。如果你在点云里看到大量坐标为0或极大值的点回去检查第2步是否真的把无效点清干净了。第三可视化时如果点云太大旋转会卡。可以先对点云做下采样再显示方便查看方向是否正常。下采样方法在第5章会讲到。3.5 第5步深度图导出与点云保存转换完成后的输出也要讲究不同的下游工具需要的文件格式不一样。* 保存原始深度图float用于后续计算 write_image (ZImg, tiff, 0, depth_raw.tiff) * 保存可视化深度图0-255灰度方便人工快速查看 min_max_gray (ZImg, ZImg, 0, MinZ, MaxZ, RangeZ) scale_image_range (ZImg, ZVis, MinZ, MaxZ) write_image (ZVis, png, 0, depth_visual.png) * 保存点云为PLYPCL/CloudCompare可以直接查看 write_object_model_3d (ObjectModel3D, ply, points, pointcloud.ply) * 保存为Halcon原生格式方便后续继续在Halcon里处理 write_object_model_3d (ObjectModel3D, om3, [], [], pointcloud.om3)深度图的保存有个容易踩的坑如果直接用write_image写16位TIFF很多版本会把浮点深度图截断成整数毫米级精度全丢了。所以真正要用于计算的深度图我一般用write_object保存成Halcon的.hobj格式或者直接保存浮点TIFF并在文件名里注明float。点云保存时write_object_model_3d的第二个参数是格式第三个参数是输出内容。只有坐标点就选points如果你后续做了三角化保存网格才用points_faces或faces。没做网格化就强行保存faces会在CloudCompare里打开一个空模型。4. 转换过程常见的坑以及我的排查思路这套流程我跑了不下十个项目每个项目都能遇到新问题。下面这几个坑是出现频率最高、也最容易被忽视的。4.1 点云方向反了一眼看上去就是倒着的第一次转完点云我一打开3D窗口就懵了——物体的凸台位置全部凹陷斜面方向完全反了。排查了很久才发现是Y方向反了。线激光传感器数据堆叠时行号对应运动方向但传感器的运动正方向可能和Halcon图像的行号增长方向相反。排查思路很简单扫描一个已知几何形状比如一个带台阶的铝块台阶面朝上。转出点云后如果台阶的朝向下反了把Y坐标改成(Height - 1 - R) * YScale重新生成基本就正过来了。X方向同理如果左右镜像用(Width - 1 - C) * XScale。4.2 uint2灰度图直接缩放表面纹理全丢有一回客户给的是12位灰度TIFFHalcon读进来是uint2类型我图省事直接用scale_image_range做了0-255的归一化结果深度图表面纹理全没了看起来就是一块平板。原因很简单传感器原始高度数据的有效灰度范围可能只占整个灰度范围的很小一段比如灰度集中在10000到12000之间归一化到0-255后真正的高度变化只占几个灰度级细节自然全丢。正确做法是先转real再按物理标定参数换算成实际高度值最后才考虑可视化。千万不要在原始灰度阶段做归一化。4.3 飞点成群出现点云里飘着一层噪声飞点的根源是无效值没处理干净。线激光在黑皮、反光、大角度倾斜表面很容易丢点丢点位置传感器会返回一个异常值。如果直接把原始高度图转点云这些异常值就变成一个个悬浮在空间中的孤立点。处理飞点要分两步走第一步按阈值把明显不合理的极值填成NAN第二步对高度图做中值滤波。中值滤波对孤立噪点非常有效因为它是取邻域排序后的中值不会被个别极端值带偏。我之前在黑色橡胶表面扫描时飞点特别严重。把阈值范围收紧、加上5x5中值滤波之后点云立刻干净了很多。但要注意黑色吸光表面本身高度值就可能偏低阈值不能一刀切得根据实际直方图来。4.4 大图生成坐标图慢到怀疑人生这个坑我前面提过。最早我图省事用set_grayval双重循环生成X图和Y图2048×1024的图像跑了将近10秒。后来改成矩阵方式一秒不到就完成了。如果你是在外部语言里调用Halcon还有个更快的办法直接在C#里构造float[,]数组填充X和Y坐标值然后gen_image1一次性传给Halcon。这样连矩阵到图像的转换都省了适合需要实时处理的场景。4.5 PLY文件在CloudCompare里打开是空的保存PLY时很容易忽略第三个参数。如果你只保存了点云没有网格却写了points_facesCloudCompare打开后模型是空的因为里面根本没有面片数据。保存纯点云用points保存三角网格用faces或points_faces。另外PLY坐标单位要和下游工具统一Halcon里如果用毫米CloudCompare里也要按毫米处理不然尺寸会差一个量级。5. 深度图和点云到手后怎么用才不算白转转换不是目的目的是让下游分析能跑起来。深度图和点云的用法侧重点完全不同。5.1 深度图适合快速测量高度差和截面深度图保留了图像的规则网格结构做区域统计非常方便。比如测一个胶路的高度差只需要在深度图上画一个ROI然后统计ROI内的最大最小高度reduce_domain (ZImg, RoiRegion, ZRoi) min_max_gray (ZRoi, ZRoi, 0, MinH, MaxH, RangeH) HeightDiff : MaxH - MinH这里ZImg已经换算成物理高度所以HeightDiff直接就是毫米值。如果要看某个剖面的形状可以用get_grayval沿着一条线采样画出来就是截面轮廓对判断胶路塌边、焊道凸起特别直观。5.2 点云适合做平面拟合、3D匹配和缺陷定位点云的优势在于不受网格约束适合计算空间几何关系。我经常用的几个算子* 平面拟合获取参考平面 fit_primitives_object_model_3d (ObjectModel3D, primitive, plane, RefPlane) * 计算点云到参考平面的距离分布偏差大的位置就是缺陷候选区 distance_object_model_3d (ObjectModel3D, RefPlane, MinDist, MaxDist, MeanDist, SigmaDist)点云太大时先下采样再计算* 采样距离0.1mm具体值根据点云密度和精度要求调整 sample_object_model_3d (ObjectModel3D, fast, 0.1, ObjectModel3DSampled)如果要做3D定位或模板匹配Halcon的create_surface_model和find_surface_model是成熟方案不过那套系统标定要求更高一般需要相机内外参都标定好才玩得转。5.3 和PCL、CloudCompare协同工作很多项目是多语言混合开发的Halcon做完深度图分析后点云往往要交给PCL做配准或者用CloudCompare查看和比对。文件交换注意两点第一统一用PLY或PCD格式write_object_model_3d都支持第二统一单位全用毫米。CloudCompare里如果发现模型尺寸大了10倍多半是单位没对齐。还有个小技巧CloudCompare的C2C距离计算非常适合快速对比两个点云之间的差异比如测量点云和设计CAD模型的偏差能在几分钟内给出整体偏差分布比肉眼翻3D视图高效得多。我个人在项目里的习惯是每次拿到新的线激光数据先不做任何转换打开图像看直方图确认灰度有效区间和飞点分布数据干净了再走5步流程。这个习惯帮我省掉了大量返工时间也推荐你试试。