☰
Bmp2RgnFix源码解析:调色板位图转HRGN与异形窗口实现
2026/10/9 3:04:53 网站建设 项目流程

简介:这是一份面向图形编程初学者与Windows开发者的位图与调色板源码包,源自商业编程实战,围绕Bmp2RgnFix修复工具,重点解决位图文件解析、调色板颜色映射以及由位图创建Region区域对象时常见的颜色失真与坐标偏移问题。压缩包共11个文件,体积仅167KB,包含C++源文件与头文件(.cpp/.h)、Visual C++工程配置(.dsp/.dsw)、资源描述(.rc)、可执行示例(.exe)以及一张测试位图(.bmp),结构清晰便于直接编译学习。目前已有102人学习下载,对理解GDI编程中的位图与调色板机制颇有帮助。源码详细展示了BMP文件头信息读取、256色调色板到RGB颜色的转换、像素位运算处理以及区域生成逻辑,并附有错误捕获与修复流程,可帮助读者快速掌握从位图到区域对象的转换步骤,是课外自学图形编程的短小而完整的参考资料。

1. 拿到 Bmp2RgnFix 源码包之前,先搞清楚它在解决什么

做 Win32 异形窗口、透明按钮、自定义光标点选,都会碰到同一件事:把一张位图里不想显示的部分挖掉,只保留真正可见的形状。看名字就知道,Bmp2RgnFix 就是干这个的——Bmp 到 Rgn(Bitmap to Region),后面那个 Fix 点明了它的价值:修正版。它重点是处理带调色板的 8bpp/4bpp 老式位图,这类格式在源码里最容易翻车:你以为读出的是颜色,实际读出的是调色板索引;你以为图像是正立的,实际是自底向上存储。成熟的做法不是逐像素调用 GetPixel,而是一次性取回 DIB 数据,按调色板查表,再用矩形区域拼出最终 HRGN。这篇笔记先把原理讲透,再给出可以直接抄的最小实现,最后把内存、方向、DPI 这些坑一个个排掉。

2. 先立住原理:调色板索引、颜色键与 HRGN 三者怎么配合

2.1 位图在内存里的真实样子:调色板位图存的是索引不是颜色

很多刚接触位图转区域的同事,第一次看到 8bpp 位图的原始数据都会懵:里面全是 0~255 的字节,根本找不到 RGB 颜色。这不是数据坏了,而是调色板位图的定义如此——像素数组里每个字节是调色板索引,真正的颜色在文件头后面的调色板表里。

调色板表是一个 RGBQUAD 数组,每个表项 4 字节,前三个字节分别是蓝、绿、红,第四个字节一般保留为 0。对 8bpp 位图,默认表项数量是 256 个,也就是 1 << 8;对 4bpp 位图,默认 16 个。文件头里的 BITMAPINFOHEADER.biClrUsed 字段如果为零,就按位深推导:biClrUsed = 1 << biBitCount。如果非零,说明作者手动指定了用到的颜色数,读取调色板时必须以这个字段为准,否则多读或漏读都会让后面颜色全部错位。

于是转换区域时就有两条路。一条路是把位图强制转成 32bpp 的 DIBSection,让 GDI 替你做调色板到 RGB 的换算,这种方案代码最省事,后面第 3 章的核心实现就是走这条路。另一条路是保留 8bpp 原格式,自己把索引读出来,再手动取调色板表项拼颜色。后者的优点是可以精细控制透明色判定,缺点是调色板表的读取时机、大小、对 DIB_RGB_COLORS 标志的理解一旦出错,整个区域就画歪。因此我一般建议:如果不是源码里明确要求保留 8bpp 路径,就统一转 32bpp。

2.2 HRGN 的构造单元:CreateRectRgn 与 CombineRgn 的组合规则

Windows 的区域(Region)不是一个像素数组,而是一组矩形的并集。HRGN 是这套矩形的句柄,构造一个异形区域最常见的方式,是创建一堆小矩形,再通过 CombineRgn 把它们按“并集”方式合并。

  • CreateRectRgn(left, top, right, bottom) 创建单个矩形区域,注意 right 和 bottom 是开区间,也就是说矩形包含的像素 x 范围是 [left, right),y 范围是 [top, bottom)。
  • CombineRgn(hDest, hSrc1, hSrc2, RGN_OR) 把 hSrc1 和 hSrc2 做布尔运算,结果放进 hDest,返回值是 ERROR 表示失败,SIMPLEREGION 表示结果只含一个矩形,COMPLEXREGION 表示多个矩形。
  • 临时创建的 hSeg 在合并进 hRgn 之后可以立即 DeleteObject,因为它已经不再是区域的一部分。

把位图转成区域,本质就是把每一行不透明的连续像素段看成一个矩形,然后不断 RGN_OR 到总区域里。这样做简单,但矩形数量会非常多。一张 512x512 的锯齿形位图,最坏情况会产生几千个矩形。区域对象内部有大小限制,超过限度的区域会被 GDI 降级甚至创建失败,所以第 4 章会专门讲合并策略,把同一形状的相邻行合并成一个更高的矩形。

2.3 Bmp2RgnFix 这类修正源码通常补的是哪几个洞

老代码里最容易出问题的不是算法本身,而是对位图格式的假设。常见修正点有这么几处:

第一,透明色判定。老代码常用一个写死的 RGB(255, 0, 255) 当透明键,但实际素材里由于压缩或抗锯齿,这个颜色已经被改成了 RGB(254, 0, 254),导致边缘一圈挖不干净。修正源码里通常会增加容差参数,或者默认取左上角像素作为透明键。

第二,自底向上的位图方向。很多美术工具导出的 BMP,biHeight 是正值,像素数据从最后一行开始。如果源码默认按自顶向下遍历,转换出来的区域会整体上下颠倒,窗口显示位置完全错乱。修正版本会用负的 biHeight 申请输出缓冲区,或者手动翻转行序。

第三,调色板表读取时机。GetDIBits 第一次调用时如果传入空的 image buffer,系统会填充调色板表;第二次调用才真正取像素数据。不少旧代码只调用了一次,拿到的像素全是索引,然后直接把索引当成 RGB 用,区域自然一塌糊涂。修正源码的逻辑是“先取表、再取数”,或者干脆像 3.2 节那样直接转 32bpp 绕开这张表。

3. 拿到源码包之后:从解压到跑通最小用例

3.1 先认文件:头文件、主循环、转换函数的识别顺序

源码包解压后,先别急着点开某个大文件。找一个典型的 Bmp2RgnFix 工程,里面通常按这样的结构组织:

  • stdafx.h 或 common.h:包含 windows.h,可能声明了转换函数的接口。
  • Bmp2Rgn.cpp / RegionUtil.c:核心转换代码,函数名通常叫 Bmp2Rgn、BitmapToRegion、ImageToRgn。
  • main.cpp / WinMain.c:示例窗口,负责 LoadImage、调用转换、SetWindowRgn。
  • 一个 sample.bmp 或多个调色板位图,用来演示效果。

我拿到这类源码的第一件事,不是读主函数,而是搜 “SetWindowRgn”。这个调用所在的位置,就是源码把区域挂到窗口上的地方,往前追能找到转换函数入口,往后追能看到作者有没有正确地处理区域所有权。接着搜 “GetDIBits” 和 “CreateRectRgn”,一个判断取数据的方式,一个判断区域的构建粒度。如果源码里找不到 GetDIBits 而是到处 GetPixel,那基本可以断定这段代码性能不太行,也不推荐直接用到生产环境,参考思路就好。

3.2 核心转换函数:把位图像素扫成区域的最小 C 实现

下面这段代码是 BmpToRgn 的最小实现,覆盖位图加载、像素读取、透明判定和区域合并的完整路径。它不依赖具体源码包,任何 Windows 编译器都能编译通过。

// bmp2rgn_min.c // 把 HBITMAP 转为 HRGN。crKey 是透明颜色,tol 是容差(0~255) #include <windows.h> #include <stdlib.h> static int IsTransparent(DWORD pixel, int rk, int gk, int bk, int tol) { int r = pixel & 0xFF; int g = (pixel >> 8) & 0xFF; int b = (pixel >> 16) & 0xFF; if (abs(r - rk) > tol) return 0; if (abs(g - gk) > tol) return 0; if (abs(b - bk) > tol) return 0; return 1; } HRGN BmpToRgn(HBITMAP hBmp, COLORREF crKey, int tol) { BITMAP bm = {0}; GetObject(hBmp, sizeof(bm), &bm); // 取位图宽高和格式信息 // 统一按 32bpp 自顶向下取数据,调色板由 GDI 自动转换 BITMAPINFO bi = {0}; bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth = bm.bmWidth; bi.bmiHeader.biHeight = -bm.bmHeight; // 负高度 = 自顶向下 bi.bmiHeader.biPlanes = 1; bi.bmiHeader.biBitCount = 32; bi.bmiHeader.biCompression = BI_RGB; HDC hdc = GetDC(NULL); DWORD *pix = (DWORD*)malloc(bm.bmWidth * bm.bmHeight * 4); int got = GetDIBits(hdc, hBmp, 0, bm.bmHeight, (BYTE*)pix, &bi, DIB_RGB_COLORS); ReleaseDC(NULL, hdc); if (got != bm.bmHeight) { free(pix); return NULL; // 取数据失败,常见原因是位图句柄无效或 hdc 不匹配 } int rk = GetRValue(crKey); int gk = GetGValue(crKey); int bk = GetBValue(crKey); HRGN hRgn = CreateRectRgn(0, 0, 0, 0); // 初始空区域 for (int y = 0; y < bm.bmHeight; y++) { DWORD *line = pix + (DWORD)y * bm.bmWidth; int x = 0; while (x < bm.bmWidth) { // 跳过透明像素,找到一段不透明连续段 while (x < bm.bmWidth && IsTransparent(line[x], rk, gk, bk, tol)) x++; if (x >= bm.bmWidth) break; int xStart = x; while (x < bm.bmWidth && !IsTransparent(line[x], rk, gk, bk, tol)) x++; HRGN hSeg = CreateRectRgn(xStart, y, x, y + 1); CombineRgn(hRgn, hRgn, hSeg, RGN_OR); // 并入总区域 DeleteObject(hSeg); // 合并完即可释放 } } free(pix); return hRgn; }

这段代码的逻辑说明如下:GetObject 先拿到位图宽度和高度,GetDIBits 以 32bpp 格式一次性读出所有像素,这样每像素占用 DWORD,R 在最低字节,G 在第二字节,B 在第三字节,和 COLORREF 的字节序相反,需要手动拆位。透明判定用三个通道的绝对值差,只要三个通道差值都不超过 tol 就认为是透明,这个容差对带压缩噪点的 PNG 转 BMP 素材特别关键。

参数说明里,tol 一般设在 0 到 32 之间。tol 取 0 是最严格匹配,素材边缘如果有渐变或压缩痕迹,区域会出现大量零散小点;tol 取 16 左右能吸收大部分噪点,同时不会误伤相近颜色。治标的办法是素材端直接避免在透明边缘产生渐变色,治本的办法是使用 alpha 通道而不是颜色键,但 BMP 的 alpha 支持相当不统一,所以颜色键加容差仍是兼容性最好的方案。

3.3 挂到窗口上:用 SetWindowRgn 让异形窗口立刻可见

转换函数只生成区域,真正的效果要在窗口初始化时看到。下面是最小主程序片段,它加载一张 BMP,生成区域,再交给窗口。

// 窗口过程中只保留关键分支 case WM_CREATE: { HBITMAP hBmp = (HBITMAP)LoadImage(NULL, L"shape.bmp", IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION); if (hBmp) { HRGN hRgn = BmpToRgn(hBmp, RGB(255, 0, 255), 12); SetWindowRgn(hWnd, hRgn, TRUE); // 注意:SetWindowRgn 成功后,区域所有权归系统,不要再 DeleteObject DeleteObject(hBmp); } break; } case WM_LBUTTONDOWN: PostMessage(hWnd, WM_CLOSE, 0, 0); break;

这段代码里最关键的是一个容易被忽略的规则:SetWindowRgn 成功返回后,HRGN 的所有权移交给了系统,窗口销毁时系统会释放它。如果在调用后立刻又调用 DeleteObject(hRgn),轻则区域消失,重则造成二次释放崩溃。这是很多老源码的隐患,也是 Bmp2RgnFix 这类修正包存在的意义之一。

LoadImage 指定了 LR_CREATEDIBSECTION,这能让 HBITMAP 是 DIB section,GetDIBits 读取时更稳。如果不加这个标志,部分 DDB 位图在内存中可能是按设备格式存储的,GetDIBits 行为会有差异,导致取到的像素颜色偏移。

4. 调色板位图的处理与三处参数调整

4.1 从 8bpp 索引到 RGB:读调色板表的两个时机

虽然第 3 章的代码用 32bpp 绕开了手动查表,但如果你被要求修改源码并保留 8bpp 路径,就必须学会怎么正确读调色板表。标准做法是分两次调用 GetDIBits。

第一次调用,传空的像素缓存,系统会返回位图信息并填充调色板表。这时需要用 BITMAPINFO 结构体,并且结构体后面要预留 256 个 RGBQUAD 的空间,否则调色板没有地方写。代码框架如下:

BITMAPINFO *pbmi = (BITMAPINFO*)malloc(sizeof(BITMAPINFO) + 256 * sizeof(RGBQUAD)); pbmi->bmiHeader.biSize = sizeof(BITMAPINFOHEADER); pbmi->bmiHeader.biWidth = width; pbmi->bmiHeader.biHeight = -height; // 自顶向下 pbmi->bmiHeader.biPlanes = 1; pbmi->bmiHeader.biBitCount = 8; pbmi->bmiHeader.biCompression = BI_RGB; HDC hdc = GetDC(NULL); // 第一次:拿调色板和实际颜色数 GetDIBits(hdc, hBmp, 0, 0, NULL, pbmi, DIB_RGB_COLORS); // 第二次:真正读取像素 GetDIBits(hdc, hBmp, 0, height, pixelBuffer, pbmi, DIB_RGB_COLORS); ReleaseDC(NULL, hdc);

这里的第一个时机要注意:第一次调用后,pbmi->bmiHeader.biClrUsed 会被填上实际使用的颜色数,调色板表项也写入了 pbmi->bmiColors。第二次调用必须复用同一个 pbmi,因为 GDI 需要知道调色板已经取走,否则第二次调用返回的仍是索引。第二个时机是释放时机,pixelBuffer 必须在第二次调用完成之后才能释放,任何提前释放都会让系统读取到野指针。

手动查表时,像素字节的值就是调色板索引,颜色从 pbmi->bmiColors[index] 取,顺序是 RGBQUAD.b 是蓝、.g 是绿、.r 是红。这个顺序和很多人直觉相反,源码里如果直接拿字节值当 RGB,区域就会呈现出奇怪的偏色,但透明判定依然可能“碰巧”工作,所以这种 bug 不容易一眼发现。

4.2 透明色选定与容差:别让锯齿边缘扎手

透明色的来源有三种,源码里需要明确支持其中至少两种。

第一种是固定颜色键,比如 RGB(255, 0, 255),美术素材里约定好用这个颜色画透明部分。优点是简单,缺点是只要图像里真实存在这种颜色,就会被错误挖空。第二种是取左上角像素作为透明键,这种方案对按钮类素材很友好,因为按钮背景通常就是角落颜色。第三种是基于调色板透明索引,老式 8bpp 位图里可能约定第 0 号或第 255 号调色板索引作为透明位,这种情况查表判定,不要对颜色做容差。

容差参数放在 BmpToRgn 的最后一个入参。需要强调的是,容差只会影响颜色键路径,索引路径则不应该加容差,因为索引是离散的,相邻索引对应的颜色可能差很远,按数值近似会让半张图都变透明。第 3 章代码里的 IsTransparent 已经展示了实现方式,比较三个通道的绝对值,而不是计算欧氏距离,因为欧氏距离涉及乘法和开方,在逐像素循环里性能影响明显。

4.3 合并策略:单像素段与连续段怎么取舍

一个区域里矩形数量越多,GDI 内部的数据量就越大,窗口移动和重绘越慢。最基本的优化是把水平方向连续的不透明像素合成一个矩形,第 3 章的代码已经这么做了。接下来可以做的优化是垂直方向合并。

垂直合并的思路是:上一行与当前行在同一个 x 区间上都是不透明,而且起点终点完全相同,那么就可以把这两个矩形上下拼成一个更高的矩形,而不是分别创建两个。实现时维护一个“当前活动矩形”列表,每处理完一行,就试图把本行的连续段与上一行同坐标的段合并。

// 伪代码:记录上一行的线段数组,尝试与当前行合并 typedef struct { int left, right, top, bottom; } Seg; // 如果 current.left == prev.left && current.right == prev.right // 就把 prev.bottom++,而不是新建矩形

取舍在于:如果位图特别碎,比如一张花哨的书法字,每行的段都不同,垂直合并几乎没有效果,反而徒增比较开销。此时更值得做的是把水平方向的小段和一个超大容差结合,直接消除 1~2 像素宽的孤立噪点。反过来,如果是按钮、圆角矩形这类简单图形,垂直合并能把几百个矩形压到几十个,性能收益非常明显。一般我按这个标准决定:区域矩形数超过 2000 时,必须做垂直合并,否则 SetWindowRgn 之后的拖拽体验会很差。

5. 避坑手册:这批源码最容易翻车的五个地方

5.1 现象:窗口全黑或区域直接为空

有同事拿源码编译后,运行发现窗口位置显示了一个黑色大矩形,或者干脆什么都看不见。查了半天,问题出在透明色的判定上——素材边缘经过压缩后,原本 RGB(255,0,255) 的透明位置变成了 RGB(254,0,254),容差设为 0 时这些边缘像素被判为不透明,于是区域不仅没变小,反而把整张图片的边界全包了进去。另一种情况恰恰相反,透明键选错成图片主体颜色,区域直接被掏空,窗口变成一道细线或看不见。

解决办法分两手:一手把容差提高到 8 到 16,另一手改透明键来源,优先取左上角像素而不是写死颜色。如果取左上角像素,要保证素材的左上角一定落在透明区域,否则会把主体颜色当透明色,整体区域全都错乱。这个约束建议写进代码注释,避免美术同事换素材时踩坑。

5.2 现象:图像上下颠倒,点击位置对不上

窗口显示出来了,但内容上下颠倒,或者点击测试的区域位置和图像不匹配。这个问题的根子几乎都在 biHeight 的正负号上。BMP 规范里,biHeight 为正表示自底向上存储,第一行数据在文件里是图像的最底部;为负表示自顶向下。

如果源码遍历像素时用的是 y 从 0 到 height,但对一个自底向上的位图,y=0 那一行实际上是图像的最后一行,于是整个区域被垂直镜像。修正方法有两个:一是把申请 biHeight 时改成负值,这在第 3 章代码里已经演示过;二是保持原方向,但遍历时把 y 映射为实际行号,即 srcY = height - 1 - y。我建议优先用负 biHeight,因为 GDI 在转换时会帮你把方向理直,后续代码对素材来源的假设最少。

5.3 现象:GDI 对象泄露,跑几小时崩溃

程序刚跑起来一切正常,但连续运行几个小时后,Windows 弹出“没有足够内存”或者窗口重绘异常。打开任务管理器切到详细信息,看到 GDI 对象数量持续上涨,几乎可以确定是区域对象或 HDC 没有释放。

这类问题出现的位置很固定。第一个是 CombineRgn 后忘记 DeleteObject 临时区域,循环几万次之后 GDI 对象数直接拉满。第二个是 GetDC 之后缺少 ReleaseDC。第三个也是最隐蔽的,SetWindowRgn 成功后又把 hRgn 删了一次,导致系统手里的区域变成一个悬空句柄,表面上不崩,但内部对象计数混乱。排查时可以写一个日志,在每条路径里打印 GDI 对象数的增量,重点看 BmpToRgn 循环内和 SetWindowRgn 分支之后。

5.4 现象:区域矩形段太多,移动窗口卡顿

拖拽窗口时明显感觉慢半拍,尤其在高分辨率屏幕上,整个异形窗口像在拖着一块很重的幕布。用工具查看区域复杂度,发现矩形数量超过几千甚至上万。

原因很简单,位图转区域的实现里只做了水平方向的连续段合并,没有做垂直合并,多行之间大量相同形状的矩形被重复创建。解决方法是补上 4.3 节的垂直合并逻辑,把相同 x 区间的上下两行合并成一个矩形。还有一种更激进的做法:在容差允许的情况下,先把位图缩小到一半再生成区域,得到区域后用 StretchBlt 拉伸回原尺寸。区域是矢量结构,拉伸后依然光滑,但矩形数量会因为源图像素减少而显著下降。

5.5 现象:高分屏下区域整体偏移

Windows 显示缩放设为 125% 或 150% 时,异形窗口的边缘和鼠标点击热区与位图对应不上,整体往右下角偏移。原因是 SDI/普通窗口在 DPI 缩放时,系统会把客户区坐标和物理像素坐标做换算,而 SetWindowRgn 使用的是物理像素坐标。

解决办法是在程序初始化时调用 SetProcessDPIAware,让进程以自己的方式处理缩放,不再让系统自动拉伸。同时窗口尺寸在使用 GetSystemMetrics 时也要考虑缩放系数,或者在收到 WM_DPICHANGED 时重新生成区域。代码里并不需要复杂的自适应,只需要在初始化时把 DPI 感知打开,并确保素材是按原始像素尺寸设计的即可。

6. 最后做验证:确认生成的区域真的可用

区域和位图表面上看着一致并不够,还要用两个方法验证。第一个方法是可视化验证:在窗口上画一层半透明调试色,把区域当作 clip,然后在区域外画一个明显的边框。具体实现是创建两个 HRGN,一个由 BmpToRgn 生成,另一个是窗口矩形区域,然后用 RGN_XOR 求差集,在差集部分填充亮色。如果差集范围里有非透明像素,说明区域边界和位图不贴合,需要回头调容差或检查调色板。

// 调试:把区域之外的部分画成半透明红 HRGN hWinRgn = CreateRectRgn(0, 0, width, height); HRGN hDiff = CreateRectRgn(0, 0, 0, 0); // 求差集:窗口减去区域,剩下的应该都是透明位置 CombineRgn(hDiff, hWinRgn, hRgn, RGN_DIFF); HBRUSH hBrush = CreateSolidBrush(RGB(255, 0, 0)); FillRgn(hdc, hDiff, hBrush); DeleteObject(hBrush); DeleteObject(hDiff); DeleteObject(hWinRgn);

第二个方法是命中测试验证:在 WM_NCHITTEST 或 WM_LBUTTONDOWN 里调用 PtInRegion,把鼠标坐标和区域进行点包含测试。注意这里要用窗口坐标而不是屏幕坐标,否则点击热点会整体偏移。我习惯把 PtInRegion 的结果打印到调试窗口,每点一次鼠标输出一次坐标和结果,对比位图实际可见形状,很快就能确认区域方向是否正确。

最后留一个我自己踩过的坑作为收尾:早期我为了省事,把容差调得很大,结果一张看图软件导出的 BMP 里,阴影部分被当成透明挖掉了,客户看到窗口只剩一个轮廓。后来我把透明键从写死的品红改成取左上角像素,同时容差固定为 12,这才稳定下来。这个参数组合我沿用到现在,也希望帮你少走这一步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询