搞视觉的人一定都被HObject转Bitmap这事折磨过。相机采集回来的临时对象就是Halcon的HObject,想在WinForm或WPF里显示、发给OCR、塞进深度学习框架,都逃不掉把它变成System.Drawing.Bitmap,或者至少变成一段24位BMP像素流。网上多数教程是拿HImage.ToBitmap()一把梭,一帧两帧无所谓,到了工业现场连续推流就变成卡顿源。这篇文章只讲一件事:HObject高效转Bitmap24,把HObject的Planar通道布局、BMP的BGR交织规则、C#和C++的指针级转换方案一次说清楚,适合希望每一帧都能稳定跑在几十毫秒内的视觉开发。
1. 先看HObject的内存长什么样:Planar通道与Bitmap24的天然冲突
1.1 HObject的图像数据不是RGB交织的
Halcon里HObject是万金油容器,图像、区域、XLD都是它。当它装的是图像时,底层数据通常是byte类型的多通道矩阵。问题在于,Halcon内部把一个三通道彩图拆成R、G、B三块独立内存,每块是一个宽度×高度的一维矩阵。所以你在内存里看到的顺序是:一整片R平面、一整片G平面、一整片B平面;而不是像BMP那样每个像素点连续放B、G、R三个字节。这种“平面式(Planar)”存储对Halcon自己的算子特别友好,做通道变换、形态学都不需要重新排列数据,但一旦要交给普通窗口系统,就成了最头痛的适配点。
举个直观例子:一张宽度为4、高度为2的RGB小图,在Halcon里是三块各8字节的连续内存。而我们想要的24位BMP数据是8个BGR三字节组合。两者之间就差了一次“三块平面交织成一块像素流”的重排,而这个重排恰恰是转换效率的关键。
1.2 BMP24在内存里的真实要求
BMP24说的是每个像素24bit,也就是3字节。它的通道顺序固定为BGR——最低字节是蓝色,中间是绿色,最高是红色。对宽度为W的行来说,一行需要W×3字节,但BMP要求每一行的字节数对齐到4的倍数,所以实际的行步长是((W*3+3)/4)*4;如果没有对齐,行尾会有补零字节。更麻烦的是,标准BMP文件是bottom-up的,文件里第一行数据其实是图像的最后一行;不过GDI+的Bitmap对象在内存里可以做成top-down,创建后LockBits返回的Scan0通常就是第一行,我们要按top-down顺序去填。
所以说,HObject的内存布局和BMP的要求几乎是反着的:一个Planar、一个Interleaved,一个行对齐可忽略、一个强制4字节对齐。转换的核心不是“拷过去”,而是“边拷贝边重排”。
1.3 一张表理清两边差异
| 维度 | HObject(byte图像) | 24位Bitmap/DIB |
|---|---|---|
| 多通道布局 | R/G/B三块平面 | BGR三字节交织 |
| 字节顺序 | 通常R、G、B平面顺序 | B、G、R像素顺序 |
| 行对齐 | 全图情况下常用width连续 | 每行4字节对齐 |
| 行序 | 第一行是图像顶部 | top-down或bottom-up均有 |
| 内存归属 | Halcon内部非托管内存 | GDI+或者我们自己分配 |
看到这里你应该明白:无论用什么语言写,转换都要做两件事——把三个平面交织成一个像素流,并把通道序翻成BGR。下面的高效方案,本质就是把这两件事压到一次指针遍历里完成。
2. 自带转换工具的账要算清楚:ToBitmap和WriteImage并没有那么香
2.1 Halcon .NET的ToBitmap:省事但不可控
如果你用的是Halcon .NET,一定见过HImage.ToBitmap()这个现成方法。第一眼确实诱人,一行代码就有Bitmap。但这里有几个问题:一是它内部要创建一个新的Bitmap对象,每次都分配一块新的内存;二是它的像素格式不是固定的,可能根据图像通道数和平台给你返回24bpp、32bpp、8bpp等格式,想统一走24bpp反而要先做格式检查;三是它的转换路径经过了若干层封装,并不是为每帧高频调用设计的。我做过一次粗测,1920×1080的byte图像直接ToBitmap(),一次调用大概在几毫秒到十几毫秒不等,看起来还能接受,但一旦和相机采集一起跑,GC压力和GDI+的资源碎片会让你在复杂界面下明显感觉到掉帧。
如果只是做离线测试,ToBitmap()没问题。可你要在采集回调里每一帧都调用它,很快就会发现垃圾回收的停顿越来越频繁,界面刷新也开始忽快忽慢。这其实不是ToBitmap()本身做了多少数学计算,而是对象分配和跨组件边界调用被放大了。
2.2 把WriteImage当BMP中转属于“运输机送外卖”
另一个常见套路是把HObject用WriteImage写成一个BMP临时文件,再用Bitmap.FromFile读回来。这个方案的好处是代码极短、不用碰指针,适合调试脚本。但在需要高性能的场景下,它做了太多无用功:先在内存里把HObject拼成BMP文件格式,再交给文件系统,随后GDI+去读文件、解析文件头、解压像素、生成Bitmap对象。中间至少发生了两三次完整内存拷贝,还有磁盘IO的锁和缓存抖动。工业现场里一秒30帧,这么折腾基本上就把CPU和磁盘都点了。
我理解有些新人这么写是因为不清楚HObject内部布局,想绕开手动内存操作。但“避重就轻”的结果是引入文件系统这个完全不需要的中间层。如果你的目标是“高效”,第一步就应该是关掉文件IO的思路。
2.3 还有比ToBitmap更慢的:GetPixel长路
最不推荐的是拿到HObject指针后,先转成数组,再用Bitmap.SetPixel逐个像素赋值。SetPixel每次调用都要做参数检查、颜色空间转换和GDI+内部锁定,百万像素就是百万次调用。哪怕是原型验证,也别这么写。真正能做实时显示的,只有“拿到Halcon指针→自己按BGR交织拷进Bitmap内存”这一条路。
下面这张表是我心里的排序,供你选型参考:
| 方案 | 速度 | 适用场景 | 问题 |
|---|---|---|---|
HImage.ToBitmap() | 中 | 原型、低频显示 | 格式不可控、分配频繁 |
WriteImage临时文件 | 慢 | 保存文件顺便转换 | IO抖动、格式二次处理 |
SetPixel逐点 | 极慢 | 不可接受 | 百万次调用 |
指针+LockBits | 快 | 实时帧处理 | 需要自己处理细节 |
3. C#指针转换实现:一次GetImagePointer3搞定灰度与彩色
3.1 先按通道数分流
拿到HObject后先确认它真的是图像,并且是byte类型。调CountChannels看通道数,1通道走灰度分支,3通道走彩色分支。如果图像类型是uint2、int2这类16位数据,先ConvertImageType转成byte,因为Bitmap24只支持8位每通道。转换本身会有一次内存拷贝,但这是必要的代价;如果你上游可以设置相机输出8位灰度或RGB,就省掉这一步。
3.2 灰度图复制成BGR三份
下面是完整的灰度HObject转24位Bitmap方法。注意方法用了unsafe,所以工程里要AllowUnsafeBlocks,并且建议以64位进程运行,因为Halcon指针在64位下是8字节地址。
using System; using System.Drawing; using System.Drawing.Imaging; using HalconDotNet; public static Bitmap GrayToBitmap24(HObject image) { HOperatorSet.GetImagePointer1(image, out HTuple ptr, out HTuple type, out HTuple width, out HTuple height); if (type.S != "byte") throw new InvalidOperationException("请先转成byte类型,再转Bitmap24"); int w = width.I, h = height.I; Bitmap bmp = new Bitmap(w, h, PixelFormat.Format24bppRgb); BitmapData bd = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); try { unsafe { byte* pSrc = (byte*)new IntPtr(ptr.L).ToPointer(); byte* pDst = (byte*)bd.Scan0.ToPointer(); // Halcon全图byte图,默认行步长就是width int srcStride = w; int dstStride = bd.Stride; for (int y = 0; y < h; y++) { byte* srcRow = pSrc + y * srcStride; byte* dstRow = pDst + y * dstStride; for (int x = 0; x < w; x++) { byte v = srcRow[x]; int off = x * 3; dstRow[off] = v; // B dstRow[off + 1] = v; // G dstRow[off + 2] = v; // R } } } } finally { bmp.UnlockBits(bd); } return bmp; }这段代码里最关键的两步是GetImagePointer1拿到HObject内部的首地址,以及LockBits拿到Bitmap内存的写入地址。中间的循环是纯字节操作,没有任何函数调用,所以速度很快。灰度图转24位本质就是“一个灰度值复制成三份”,属于最简情况。
3.3 彩色图三通道交织
对于真正的RGB三通道HObject,用GetImagePointer3一次拿三个通道的地址,然后做BGR交织。同样避开Decompose3,不要为了转Bitmap先拆通道,那是纯浪费。
public static Bitmap RgbToBitmap24(HObject image) { HOperatorSet.GetImagePointer3(image, out HTuple r, out HTuple g, out HTuple b, out HTuple type, out HTuple width, out HTuple height); if (type.S != "byte") throw new InvalidOperationException("请先转成byte类型,再转Bitmap24"); int w = width.I, h = height.I; Bitmap bmp = new Bitmap(w, h, PixelFormat.Format24bppRgb); BitmapData bd = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); try { unsafe { byte* pR = (byte*)new IntPtr(r.L).ToPointer(); byte* pG = (byte*)new IntPtr(g.L).ToPointer(); byte* pB = (byte*)new IntPtr(b.L).ToPointer(); byte* pDst = (byte*)bd.Scan0.ToPointer(); for (int y = 0; y < h; y++) { byte* rRow = pR + y * w; byte* gRow = pG + y * w; byte* bRow = pB + y * w; byte* dstRow = pDst + y * bd.Stride; for (int x = 0; x < w; x++) { int off = x * 3; dstRow[off] = bRow[x]; // B dstRow[off + 1] = gRow[x]; // G dstRow[off + 2] = rRow[x]; // R } } } } finally { bmp.UnlockBits(bd); } return bmp; }上面两个分支可以合成一个入口:
public static Bitmap HObjectToBitmap24(HObject image) { HOperatorSet.CountChannels(image, out HTuple channels); return channels.I == 1 ? GrayToBitmap24(image) : RgbToBitmap24(image); }这里有个细节:GetImagePointer3返回的指针顺序,一般对应Halcon内部认为的R、G、B三通道。但如果你从相机插件拿到的图像本身是BGR输出,或者某些情况下通道顺序被Halcon自动翻转,直接套用代码会看到红蓝互换。解决办法后面第6章会专门讲。
3.4 LockBits负Stride的兜底
上面LockBits得到的Bitmap是从new Bitmap创建的,绝大多数情况下bd.Stride是正数,按行从上往下填就对了。如果你从别的渠道拿到一个bottom-up的Bitmap,Stride会是负数,Scan0指向最后一行,此时行指针要写成:
byte* dstRow = pDst + (height - 1 - y) * (-bd.Stride);写转换函数时最好把这个分支也包进去,避免某天换了一张怪图直接内存越界。
4. C++里直接用DIBSection,省掉中间Bitmap对象
4.1 为什么DIBSection比CImage更合适
C++环境里最常见的转换目标是用MFC或Win32显示图像。有人习惯用CImage加载和操作位图,但CImage内部还是GDI+那套东西,做一个转换问题不大,真要每帧都做,还是直接操作DIB Section更干净。CreateDIBSection会返回一个可写入的内存指针,我们拿着这个指针把Halcon的三通道像素交织进去,之后直接交给GDI绘制或者封装成CBitmap都行。
DIB Section的好处在于:不经过文件系统,不经过Bitmap对象包装,创建一次后可以反复写入。只要图像尺寸不变,CreateDIBSection只需要调用一次,后面每帧都往同一个bits指针里填数据。
4.2 核心代码
下面用Halcon C接口的形式写,C++/C#里取指针的方式会稍有差异,但原理通用。
#include <windows.h> #include "halconcpp/HOperatorSet.h" HBITMAP HImageToDib24(const HObject& hImg) { Hlong pR = 0, pG = 0, pB = 0; Hlong width = 0, height = 0; HTuple type; // 如果用HImage,下面就是 GetImagePointer3 get_image_pointer3(hImg, &pR, &pG, &pB, &type, &width, &height); // 这里最好再检查 type == "byte" if (strcmp(type.S(), "byte") != 0) return nullptr; BITMAPINFOHEADER bi = { 0 }; bi.biSize = sizeof(BITMAPINFOHEADER); bi.biWidth = (LONG)width; bi.biHeight = -(LONG)height; // 负数表示top-down,省去行翻转 bi.biPlanes = 1; bi.biBitCount = 24; bi.biCompression = BI_RGB; void* bits = nullptr; HBITMAP hbmp = CreateDIBSection(nullptr, (BITMAPINFO*)&bi, DIB_RGB_COLORS, &bits, nullptr, 0); if (!hbmp) return nullptr; BYTE* pDst = (BYTE*)bits; size_t dstStride = ((width * 24 + 31) / 32) * 4; // 4字节对齐 size_t srcStride = (size_t)width; // Halcon全图byte图 BYTE* pRed = (BYTE*)pR; BYTE* pGreen = (BYTE*)pG; BYTE* pBlue = (BYTE*)pB; for (long y = 0; y < height; y++) { const BYTE* rc = pRed + y * srcStride; const BYTE* gc = pGreen + y * srcStride; const BYTE* bc = pBlue + y * srcStride; BYTE* dc = pDst + y * dstStride; for (long x = 0; x < width; x++) { dc[x * 3] = bc[x]; // B dc[x * 3 + 1] = gc[x]; // G dc[x * 3 + 2] = rc[x]; // R } } return hbmp; }这里要特别说明biHeight为什么用负数。Halcon的图像坐标是第0行在顶部,而DIB如果biHeight为正数,内存里第一行是图像的底部。设成负数后,DIB数据就是top-down,直接按y * dstStride往里填即可,不用再倒着写一遍。
灰度图在C++里同理,拿get_image_pointer1得到一个通道指针,然后让B、G、R三个位置写同一个灰度值就行。彩色分支不变。
4.3 内存管理与显示
CreateDIBSection创建的HBITMAP,用完后需要DeleteObject释放。如果你希望在C#里继续用,可以用Bitmap.FromHbitmap封装一次,但那个操作会产生新Bitmap,高频调用时同样要注意释放旧句柄。最好的做法是:图像处理管线在C++侧,显示也在C++侧,全程不跨语言边界,DIB Section一帧帧复用,性能最稳。
5. 把转换速度逼近memcpy:缓存复用、并行与预处理
5.1 Bitmap创建一次就够了,别每帧new
C#里最容易被忽视的问题是每帧new Bitmap(w, h)。新建Bitmap会在非托管侧分配GDI+对象,还需要Finalizer去清理,高频下GC压力很大。正确做法是初始化阶段创建一块,之后每帧只LockBits、写入、UnlockBits。
// 初始化一次 Bitmap _frameBmp = new Bitmap(width, height, PixelFormat.Format24bppRgb); // 每帧写入 BitmapData bd = _frameBmp.LockBits( new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); // 用前面示例代码填充bd.Scan0 _frameBmp.UnlockBits(bd);注意:如果同一个Bitmap已经被PictureBox.Image引用,写入和绘制之间需要做好线程同步,否则可能看到撕裂画面。可以再准备第二帧做双缓冲,写完再整体切换引用。
5.2 交织是内存带宽操作,并行和SIMD值得上
彩色通道交织这个循环看起来是CPU运算,实际上它主要消耗的是内存带宽。1920×1080的24位图写入约6MB,加上读三块平面约6MB,总内存搬运量十几MB,单核串行也能在几毫秒内完成。如果你的图更大或者帧率更高,可以用Parallel.For按行并行,因为每一行的写入位置完全独立,不会产生竞争:
Parallel.For(0, h, y => { // 上面的行内循环 });并行适合高分辨率图像,图很小时反而因为线程调度开销变慢。我自己一般以高度1024为门槛,大于这个值才开并行。
更进一步,C++里可以用SSE/AVX做灰度到BGR的批量展开,C#里也有System.Runtime.Intrinsics可用。但说实话,绝大多数视觉项目的瓶颈在图像采集和算法,不在这个转换上。先把缓存复用做好,收益比搞SIMD高得多。
5.3 预处理:能转byte就不拖到最后一刻
如果你的HObject是uint2或者int2,转24位Bitmap之前必须先ConvertImageType转成byte。这个转换本身是一次全图拷贝,拖到帧循环里做会额外消耗时间。如果相机输出可以直接配成8位,一定在相机端就配好,不要在Halcon里补转。
另一个常见浪费是Decompose3。有些人习惯先把三通道拆成三个HObject,再分别GetImagePointer1,最后手动交织。这等于把原始图像完整复制了三份,然后再读出来拼一份,内存带宽直接翻倍。用GetImagePointer3一次拿三个地址,才是高效的做法。
5.4 缩图之后再转换
如果最终只是显示到界面,而图像是5000万像素的相机大图,先不要直接转成全尺寸Bitmap。用ZoomImageFactor或MagnifyImage在HObject里先缩到合适的显示尺寸,再做24位转换。缩图计算在Halcon内部是高度优化的,比你转换后再用GDI去缩放要快得多。
6. 绕开这三个老坑:通道序、行对齐和子图指针
6.1 通道序:一张纯红图验证最靠谱
工业相机输出可能是RGB也可能是BGR,Halcon的ReadImage对PNG/JPG按RGB语义分解,但相机采集插件就不一定。不要赌,转出来用一张纯红图验证:BGR三个字节应为0,0,255;如果显示成255,0,0,交换红蓝指针即可。换句话说,问题不在转换逻辑,而在Halcon图像本身各通道的语义。调试时生成一张纯红图最方便:
HObject red; HOperatorSet.GenImageConst(out red, "byte", 128, 128, 3, 128); HOperatorSet.SetGrayval(red, 64, 64, out HTuple val); // 辅助手段实际生产中,我会在初始化阶段主动让相机拍一张标准色卡,然后检查第一像素的BGR值,再决定后续是否要交换通道。这个配置只做一次,之后所有帧都用同一个分支。
6.2 行对齐:Halcon也不是永远width连续
全图domain的byte图,GetImagePointer1返回的行步长可以认为等于width。但如果你用了ReduceDomain或者ChangeDomain,指针仍然指向原始图像的数据矩阵,而不是你ROI区域的左上角。这时候你用y * width去跳行,出来的画面是斜的。
解决办法是在转换前把domain裁剪掉:
HObject cropped; HOperatorSet.CropDomain(image, out cropped);CropDomain会把domain对应的矩形区域提取成一张新的连续图像,之后行步长才等于新图的width。凡是做ROI检测后还想在界面里“只看ROI”的场景,都要先CropDomain再转换,直接拿原图指针去填ROI是典型的翻车写法。
6.3 检查HObject到底是图像还是Region
HObject本身是通用类型,它可能是Region、XLD或者图像。如果拿到的是Region,调用GetImagePointer1会直接报异常。转换前最好先用CountChannels或者类型判断兜底。Region可以用HOperatorSet.RegionToBin转成byte图像,但那和本文讨论的Bitmap24是两回事,别混在一起。
6.4 别忘了UnlockBits和DeleteObject
C#里LockBits之后无论转换有没有成功,都要在finally中UnlockBits,否则Bitmap会一直处于锁定状态,后续显示和保存都会异常。C++里CreateDIBSection创建的内存块要在DeleteObject释放,复用DIB Section时也要妥善管理。
我在实际项目中见过因为漏了UnlockBits导致界面越跑越卡,重启才恢复。这种问题排错很花时间,建议把所有转换函数统一封装成公共工具类,把内存释放逻辑集中管理,不要在业务代码里到处手写。
最后说一个我自己的习惯:原型阶段我直接用HImage.ToBitmap()看效果,因为代码短;框架阶段一律换成上面的指针版,再配一个预创建的Bitmap缓存。这个组合帮我解决过很多“显示拖慢采集”的抱怨。你也别嫌手写循环啰嗦,真把产线帧率卡进来的时候,你会感谢这份可控性。