做机器视觉的上位机开发最绕不开的一件事就是把 Halcon 里算完的图送到界面显示或者转成标准位图保存。Halcon 的 HObject 在内部是平面分离存储的而 Windows 的 Bitmap24 位格式是 BGR 三通道交错排列两者直接对接根本行不通网上讨论大都是零散代码没讲清楚为什么这么做。我最早做这块时也是东拼西凑才跑通中间还踩过颜色颠倒、图像上下翻转、花屏、内存泄漏各种坑。这篇就专门聊 HObject 转 Bitmap24 位的完整思路和直接能用的实现看懂之后你也能顺手封装一套自己的转换工具。1. 为什么 Halcon 图像不能直接当 Bitmap 用1.1 Halcon 图像内部的真面目Halcon 里的图像对象包括从相机采集回来的图和经过算子处理后的结果本质上是按“平面”存储的。所谓平面存储就是如果有 R、G、B 三个通道内存里先把所有红色像素放一块再把所有绿色像素放一块最后放蓝色像素。每个通道的数据是独立的一块矩形缓冲区互不夹杂。举个例子一张 4x4 的三通道彩色图Halcon 内存布局大概是Red 通道16 个 byte 连续排列Green 通道16 个 byte 连续排列Blue 通道16 个 byte 连续排列这和我们在 Windows 下常见的 DIB/Bitmap 格式正好相反。Bitmap24 位要求内存里从第一个像素开始依次存 B、G、R然后再下一个像素也就是所谓的“交错存储”。两个像素在内存里就是 B1 G1 R1 B2 G2 R2 这样排。所以就算你把 Halcon 的指针直接交给 Bitmap 的 LockBits结果必然是花屏、颜色错乱、显示成三个灰度图叠在一起。转换的核心工作就是把平面数据“打散重排”成交错数据。1.2 24 位 BMP/DIB 到底想要什么Windows 的 24 位位图最常打交道的两个内存场景是 BitmapData 和 BMP 文件。用 C# 的 System.Drawing.Bitmap 时LockBits拿到的Scan0指向一块内存里面存的是一行一行的像素。24 位格式下每个像素占用 3 个字节顺序固定是 B、G、R。这里有个特别容易忽略的细节行与行之间不是紧贴着的每行字节数必须按 4 字节对齐。计算公式是stride ((width * 24 31) / 32) * 4等价写法是stride ((width * 3 3) / 4) * 4比如宽度 100 像素width * 3 300300 除以 4 恰好整除stride 是 300。宽度 101 像素101 * 3 303303 不能被 4 整除stride 向上取整到 304。这一行的最后多出来的 1 个字节是 padding不存任何有效像素。如果忽略这个对齐转换出的图会从某一列开始出现倾斜和彩色条纹而且越靠下偏移越明显。还有一个行序问题。Halcon 图像默认是从上到下排列的第一行是图像顶部。而标准 BMP 文件或者使用正高度的 Bitmap 对象像素数据却是从最后一行开始放的。也就是说如果你想把 Halcon 图像完整存成 BMP需要在写入时把行序反过来。要么在内存复制时用h - 1 - y翻转要么在构造位图头时把biHeight设为负数表示 top-down 扫描顺序。多数上位机场景我习惯在复制时直接翻转这样兼容性最好。1.3 转换前要确认的三件事拿到一个 HObject 后先别急着取指针先想清楚三件事。第一这个 HObject 到底是不是图像对象。Halcon 的 HObject 是个大杂烩容器图像、Region、XLD 都叫 HObject。万一传进来一个 Regionget_image_pointer3会直接报异常。稳健的封装应该先检查类型或者用一个 try-catch 兜底。第二图像的像素类型是什么。get_image_pointer3会返回 Type可能是byte、uint2、int2、real等。Bitmap24 位里每个通道只接受 0 到 255 的 byte 值。如果类型不是 byte必须先转换成 byte否则数据范围对不上显示出来要么全黑要么高光一片。第三通道数是多少。三通道图像用get_image_pointer3灰度图用get_image_pointer1。不要指望灰度图也能拿到三个指针Halcon 不会给单通道图返回三个通道指针。灰度转 24 位时需要把同一个灰度值复制三份分别填到 B、G、R 位置。2. 转换方案的整体设计与取舍2.1 直接内存复制还是用 Halcon 算子有人会问Halcon 自带的write_image不是可以直接写 BMP 吗确实可以如果目标只是存文件write_image(Image, bmp, 0, output.bmp)一行搞定根本不用自己转换。但实际开发中很多需求是转成内存 Bitmap 去刷新 WinForms 或 WPF 界面或者把 HObject 转成 OpenCV 的 Mat 继续处理。这种场景下不能落盘只能在内存里手动重排。Halcon 有没有现成的算子把平面图像转成交错图像有个gen_image_interleaved可以从交错数据创建图像方向是反的不能用来把 HObject 直接转成交错格式。另一些算子像access_channel、compose3虽然能操作通道但内部数据布局依然是平面分离的。所以最终还是要靠最底层的指针操作自己写循环重排。我个人测试下来直接内存复制是性能最优、逻辑最可控的方案。每层只是简单的颜色分量搬移没有算子调用的额外开销也不依赖 Halcon 的运行时状态。2.2 输出目标决定实现细节转换函数写成一个还是多个我建议看输出目标拆成两层。第一层是“HObject 转内存像素缓冲”输出一个标准的 24 位交错 BGR 数据块按 Bitmap 要求完成行对齐。这一层只关心像素本身不依赖 GDI。第二层才是“像素缓冲转 Bitmap 对象”或者“像素缓冲写 BMP 文件”。这一层可以由上层逻辑自由选择。如果要把图给 WinForms就包成Bitmap如果要做网络传输或存档可以直接写文件。这么做的好处是核心交换代码只维护一份其他场景都复用同一份缓冲。比如 C 项目里经常需要把 Halcon 图转给图像采集卡或者 OpenCV直接对接第一层的std::vectoruint8_t就行。2.3 封装思路统一入口避免到处 CopyPixels很多项目里同一份 Halcon 图像会被好几个模块同时转换。一开始我图省事直接在窗体事件里写转换逻辑后来发现代码重复不说改一个通道顺序要全局搜索特别痛苦。后来我固定一个静态工具类提供两个入口public static Bitmap ToBitmap(HObject image) public static byte[] ToBgrBuffer(HObject image)内部统一处理单通道、三通道、类型检查和行序翻转。外部使用者完全不知道 Halcon 内部的存储细节只要传 HObject 进去拿到的就是能直接显示的 Bitmap。这个封装习惯强烈推荐尤其项目里有多个窗体或者还要对接二次开发时能省掉大量重复劳动。3. 核心代码实现HObject 转 24 位 Bitmap3.1 C# 实现从 GetImagePointer3 到 Bitmap下面这段是 C# HalconDotNet 的完整实现。核心是先取三通道指针再通过unsafe把平面数据填充到 Bitmap 的Scan0指向的目标缓冲。using System; using System.Drawing; using System.Drawing.Imaging; using HalconDotNet; public static class HalconBitmapConverter { public static Bitmap ToBitmap(HObject image) { HTuple channels new HTuple(); HOperatorSet.CountChannels(image, out channels); int numChannels channels[0].I; if (numChannels 1) { return ConvertGrayToBitmap24(image); } else if (numChannels 3) { return ConvertColorToBitmap24(image); } else { throw new NotSupportedException(仅支持单通道或三通道图像); } } private static Bitmap ConvertColorToBitmap24(HObject image) { HTuple pointerR, pointerG, pointerB; HTuple type, width, height; HOperatorSet.GetImagePointer3(image, out pointerR, out pointerG, out pointerB, out type, out width, out height); string typeStr type[0].S; if (typeStr ! byte) { throw new NotSupportedException(三通道转换要求图像类型为 byte当前类型 typeStr); } int w width[0].I; int h height[0].I; int stride ((w * 3 3) / 4) * 4; Bitmap bmp new Bitmap(w, h, PixelFormat.Format24bppRgb); BitmapData bmpData bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); unsafe { byte* pR (byte*)pointerR[0].L; byte* pG (byte*)pointerG[0].L; byte* pB (byte*)pointerB[0].L; byte* dst (byte*)bmpData.Scan0; // Bitmap 默认 bottom-up需要把图像最后一行放到目标第一行 for (int y 0; y h; y) { byte* srcR pR (long)y * w; byte* srcG pG (long)y * w; byte* srcB pB (long)y * w; byte* dstRow dst (long)(h - 1 - y) * bmpData.Stride; for (int x 0; x w; x) { dstRow[x * 3] srcB[x]; dstRow[x * 3 1] srcG[x]; dstRow[x * 3 2] srcR[x]; } } } bmp.UnlockBits(bmpData); return bmp; } private static Bitmap ConvertGrayToBitmap24(HObject image) { HTuple pointer; HTuple type, width, height; HOperatorSet.GetImagePointer1(image, out pointer, out type, out width, out height); string typeStr type[0].S; if (typeStr ! byte) { throw new NotSupportedException(灰度转换要求图像类型为 byte当前类型 typeStr); } int w width[0].I; int h height[0].I; Bitmap bmp new Bitmap(w, h, PixelFormat.Format24bppRgb); BitmapData bmpData bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); unsafe { byte* pGray (byte*)pointer[0].L; byte* dst (byte*)bmpData.Scan0; for (int y 0; y h; y) { byte* src pGray (long)y * w; byte* dstRow dst (long)(h - 1 - y) * bmpData.Stride; for (int x 0; x w; x) { byte g src[x]; dstRow[x * 3] g; // B dstRow[x * 3 1] g; // G dstRow[x * 3 2] g; // R } } } bmp.UnlockBits(bmpData); return bmp; } }这段代码有几个地方要特别说。GetImagePointer3返回的三个指针顺序是 Red、Green、Blue而不是 BGR。所以填充目标时我把pB放到dstRow[x*3]把pR放到dstRow[x*32]。很多新手第一次写反结果图像偏蓝或者偏红原理就在这里。pointerR[0].L在 HalconDotNet 里表示取 HTuple 中的 64 位整数指针地址转成byte*后就可以直接按字节访问。要注意的是这里必须在unsafe上下文中并且项目要允许不安全代码。行序上我用的是(h - 1 - y)做翻转原因是new Bitmap(w, h, PixelFormat.Format24bppRgb)默认高度为正对应的 DIB 是 bottom-upScan0指向的是图像最后一行。如果你在复制时没有翻转最终显示或保存的图会是上下颠倒的。3.2 C 实现不依赖托管环境C 环境下使用 HalconCpp思路完全一致区别只是指针类型换成了H5_IntPtr。下面这个函数把 HObject 转换并直接写出一个 24 位 BMP 文件不依赖 Windows GDI纯标准库写位图头适合任何平台。#include vector #include fstream #include cstdint #include cstring #include HalconCpp.h using namespace HalconCpp; void Put16(std::vectorunsigned char data, size_t offset, uint16_t value) { data[offset] static_castunsigned char(value 0xFF); data[offset 1] static_castunsigned char((value 8) 0xFF); } void Put32(std::vectorunsigned char data, size_t offset, uint32_t value) { data[offset] static_castunsigned char(value 0xFF); data[offset 1] static_castunsigned char((value 8) 0xFF); data[offset 2] static_castunsigned char((value 16) 0xFF); data[offset 3] static_castunsigned char((value 24) 0xFF); } void SaveHObjectAsBmp24(const HObject image, const std::string filename) { HTuple type, width, height; HTuple pR, pG, pB; HOperatorSet::GetImagePointer3(image, pR, pG, pB, type, width, height); int w width[0].I(); int h height[0].I(); int rowSize ((w * 3 3) / 4) * 4; size_t headerSize 54; size_t pixelSize static_castsize_t(rowSize) * h; std::vectorunsigned char fileData(headerSize pixelSize, 0); // BITMAPFILEHEADER fileData[0] B; fileData[1] M; Put32(fileData, 2, static_castuint32_t(fileData.size())); Put32(fileData, 10, static_castuint32_t(headerSize)); // BITMAPINFOHEADER Put32(fileData, 14, 40); Put32(fileData, 18, static_castuint32_t(w)); Put32(fileData, 22, static_castuint32_t(h)); Put16(fileData, 26, 1); Put16(fileData, 28, 24); Put32(fileData, 30, 0); // BI_RGB Put32(fileData, 34, static_castuint32_t(pixelSize)); H5_IntPtr ptrR pR[0].L(); H5_IntPtr ptrG pG[0].L(); H5_IntPtr ptrB pB[0].L(); size_t outPos headerSize; for (int y 0; y h; y) { int srcY h - 1 - y; const unsigned char* srcR reinterpret_castconst unsigned char*(ptrR) static_castsize_t(srcY) * w; const unsigned char* srcG reinterpret_castconst unsigned char*(ptrG) static_castsize_t(srcY) * w; const unsigned char* srcB reinterpret_castconst unsigned char*(ptrB) static_castsize_t(srcY) * w; unsigned char* dst fileData.data() outPos; for (int x 0; x w; x) { dst[x * 3] srcB[x]; dst[x * 3 1] srcG[x]; dst[x * 3 2] srcR[x]; } outPos rowSize; } std::ofstream outFile(filename, std::ios::binary); outFile.write(reinterpret_castconst char*(fileData.data()), fileData.size()); }这个实现把位图头写死成 54 字节适用于 24 位非压缩 BMP。rowSize按 4 字节对齐写入像素时已经处理了底部的 padding。由于fileData初始化为 0每行末尾的空余字节天然是 0不需要额外清零。如果只是要内存中的像素缓冲而不是写文件可以仿照 C# 版本把dst换成外部传入的std::vectorunsigned char外面的代码再决定怎么用。注意这段代码假设输入已经是 byte 类型的三通道图。如果拿到的是灰度图需要先转成三通道否则GetImagePointer3本身会抛出异常。3.3 顺手把 BMP 文件写出来直接用上面 C 代码写 BMP 文件好处是不依赖 GDI生成的.bmp文件能被所有看图软件识别。C# 里其实更省事因为我们已经在内存里构建好了Bitmap对象直接调用bmp.Save(output.bmp, ImageFormat.Bmp)就行。但有一点要注意C# 的Bitmap保存时可能会附带一些额外信息不过最终像素布局仍然是标准的 24 位 BMP不用太担心。如果你的项目要求在 Linux 或嵌入式环境导出一个纯 BMP 文件建议用 C 那种手动构造头文件的方式完全可控。4. 避坑指南颜色错乱、花屏、类型不对4.1 图像类型不是 byte 怎么办工业相机经常输出 10 位、12 位、14 位的图像数据Halcon 读进来后类型通常是uint2数值范围可能到 0 到 65535。如果直接把uint2当成 byte 处理要么强制截断低位信息丢失要么数据溢出变成噪点显示出来就是一团黑或者一片花。正确做法是先拉伸再转换。Halcon 里我最常用的组合是scale_image_max(Image, ImageScaled) convert_image_type(ImageScaled, ImageByte, byte)scale_image_max会根据图像实际最大值和最小值做线性拉伸把动态范围映射到 0 到 255显示效果比直接convert_image_type截断好得多。也有场景需要按固定比例拉伸那就用scale_image传入自己的参数。在 C# 封装里如果检测到类型不是 byte不要直接抛异常至少先尝试ScaleImageMax再ConvertImageType。这样外部调用者不会因为相机参数不同而经常改代码。4.2 指针不连续、Region 影响与错位Halcon 图像经过reduce_domain、crop_domain等操作后图像本身可能带一个非全图的 Region。这里有个容易搞混的概念get_image_pointer3返回的指针永远指向原始完整图像缓冲的左上角宽度和高度也是完整图像的宽高而不是 Region 的外接矩形。如果直接拿指针按照 Region 的尺寸去复制结果就是错位、花屏、内容偏移。处理办法是转换前先判断是否需要对 Region 处理。大多数显示场景我们关心的是整幅图像所以拿到指针后按完整宽高复制是正常的。如果确实只需要 ROI先用crop_domain裁剪出一个新的 HObject再对这个新对象做转换这时候返回的宽高就是裁剪后的尺寸。另外Halcon 内部图像行与行之间通常是紧凑排列的但不能绝对保证每行之间不存在额外行间距。常规read_image、grab_image出来的图像没问题但某些算子处理后的中间图像可能带 internal padding。如果转换出现了只偏几列的问题可以考虑用get_image_pointer1_rect获取更精确的步长信息。日常开发中我用基础版get_image_pointer1偏多因为绝大多数采集和标准读图场景都是紧凑的。4.3 颜色通道顺序为什么偏绿、偏红最经典的坑Halcon 的get_image_pointer3返回顺序是 R、G、B而 Bitmap24 位需要 B、G、R。如果你在代码里把pR放到目标第 0 字节图像会明显偏蓝如果把pB放到第 2 字节图像偏红。这里不是背顺序的问题而是要建立“源通道名 → 目标通道名”的映射思维。正确映射表如下源指针目标位置含义PointerReddst[x*32]红色分量放到第 3 字节PointerGreendst[x*31]绿色分量放到第 2 字节PointerBluedst[x*3]蓝色分量放到第 1 字节如果图像还是偏绿常见原因是灰度图转换时误用了GetImagePointer3的某个通道把灰度值只填进了 G 通道。灰度转彩色必须把同一个灰度值复制成三个分量不能只填一个。4.4 stride 对齐歪斜和锯齿的根源Bitmap 的 stride 和图像宽度不一定是同一个数字。当width * 3不能被 4 整除时每行末尾会多出 1 到 3 个 padding 字节。如果你复制的时候整行拷贝用的是width * 3而目标行距用bmpData.Stride最终结果是对的如果目标行距也用了width * 3从第二行开始就会慢慢向左偏形成明显的锯齿。我在 C# 版本里故意让代码一直使用bmpData.Stride定位目标行就是因为这个坑太隐蔽。C 版本则统一用rowSize ((w * 3 3) / 4) * 4确保自己的像素缓冲本身就满足对齐要求后续写文件不会出问题。常见问题速查现象可能原因解决方案图像上下颠倒忘记 bottom-up 行序复制时使用 h-1-y 或设置负高度图像左右颠倒不应该出现需检查 ROI 坐标确认宽高与指针对应关系图像偏蓝/偏红通道顺序填反按 R→第3字节、G→第2字节、B→第1字节图像连续偏移stride 计算错误用公式计算对齐后每行字节数整图全黑uint2 未拉伸直接截断先用 scale_image_max 再转 byte花屏且严重错位Region 尺寸与实际不匹配先 crop_domain 再转换5. 性能优化与实测经验5.1 unsafe 指针与逐行复制的微优化C# 不开启 unsafe直接操作像素只能通过Marshal.Copy配合字节数组每一次都要拷贝整行效率还可以但灵活度差。而unsafe指针写法在LockBits后直接读写内存避免多一次托管数组拷贝实测性能能提升一半以上。对于三通道彩色图最直观的写法是三层for循环for (int y 0; y h; y) for (int x 0; x w; x) dstRow[x * 3] srcB[x];这个写法缓存命中率尚可因为三个源指针分别指向三块连续的通道缓冲目标指针按行交错写入。编译器在 Release 模式下通常会做循环优化不需要人为拆成按 16 字节块处理。如果对性能有极致要求可以增加一个临时交错行缓冲先把三个通道组合成一行 BGR 数据再用memcpy整行搬到目标减少对目标缓冲的随机写。但多数项目里1920x1080 的图三通道转换在普通桌面机上也就 10 到 15 毫秒完全够用。5.2 并行化与缓存复用如果图像分辨率到了 4000x3000 以上逐像素循环的时间会明显增加。这时可以用Parallel.For对行做并行因为每一行的源指针和目标行地址都是独立计算出来的互不干扰。需要注意线程安全不能在所有线程里共享同一个可变的y变量。还有一点经验频繁转换大图时Bitmap对象不要每次new完就丢给 GC。尤其在大分辨率下反复分配非托管内存容易造成内存碎片和句柄泄漏。更好的做法是预分配一个Bitmap每次进来新图时只重新LockBits更新像素用完立即UnlockBits。界面刷新时直接把同一个Bitmap对象赋值给 PictureBox能明显降低 GC 压力。5.3 大图像实测数据与项目建议我曾经在一个项目里测试过不同规格图像的转换耗时环境是普通 i5 台式机Release 模式C# unsafe 实现图像尺寸单通道灰度转24位三通道彩色转24位640x480约 1 ms约 2 ms1280x720约 4 ms约 6 ms1920x1080约 8 ms约 12 ms4000x3000约 35 ms约 55 ms以上数据是连续转换多次后的平均值包含 LockBits 和创建 Bitmap 的开销。如果你的视觉处理周期是 40 毫秒一帧这种转换开销基本可以接受。但如果要做 100 帧以上的实时预览建议把转换次数降到最低最好在采集线程直接转换到目标缓冲界面线程只负责 Blit不要再做二次转换。如果最终目的只是保存图像文件强烈建议直接用write_image算子不要转 Bitmap 再存文件节省的时间和内存完全不是一个量级。转换应该发生在“需要给显示/其他库用”的时候而不是每次处理完都例行公事转一遍。6. 一点个人体会这套 HObject 转 Bitmap24 的方法我前后在不同的项目里用过很多次踩过的坑基本都写在上面了。个人体会是格式转换从来不是把代码拷贝过去就完事最重要的是搞懂内存布局然后再动手写。尤其是 Halcon 内部的平面存储和 Windows 位图的交错存储这两个概念一旦理解了遇到 OpenCV、Python 图像库或者其他需要互转的场景思路其实是完全一样的。最后再分享一个小技巧。如果你的工程里到处都是 HObject 转 Bitmap 的散装代码建议统一收敛到一个静态类里对外暴露一个方法。以后哪怕 Halcon 版本升级或者换成新的图像接口只需要改这一处不用满工程翻代码。这个习惯帮我节省的排障时间远比写转换函数本身多得多。
阅读完成 · 觉得有帮助?