必须调用 LockBits 才能高效操作像素;直接使用 GetPixel/SetPixel 效率极低;需根据 PixelFormat 区分 BGR/ARGB 布局、正确计算 Stride 和灰度公式;操作后务必调用 UnlockBits 防止资源泄漏。

用 Bitmap 锁定像素前必须调用 LockBits
直接读写 Bitmap.GetPixel / SetPixel 看似简单,但实际是逐像素触发 GDI+ 内部拷贝,1000×1000 图片可能慢 100 倍以上。灰度化这种密集像素操作,必须走指针路径。
关键点:调用 LockBits 后拿到的是内存起始地址(Scan0),不是图像左上角坐标;行字节数(Stride)往往大于宽度 × 字节/像素,因为 Windows 要求每行按 4 字节对齐。
-
PixelFormat.Format24bppRgb最常用,但Stride可能是 width×3 + 补零字节,不能直接用width * 3计算下一行地址 - 务必检查
BitmapData.PixelFormat,有些图片加载后是Format32bppArgb(带 Alpha),灰度公式得适配四通道 - 操作完必须调用
UnlockBits,否则资源泄漏,多次调用后程序可能卡死或抛出OutOfMemoryException
灰度公式别硬背,按输入格式选 Y = 0.299R + 0.587G + 0.114B 还是平均值
人眼对绿色最敏感,标准加权灰度公式是 Y = 0.299R + 0.587G + 0.114B,但前提是 R/G/B 是 0–255 的整数且无 Alpha。如果图像是 Format32bppArgb,首字节是 Alpha,RGB 顺序仍是 BGR(Windows 小端排布),取值时容易错位。
- 对
Format24bppRgb:每行从Scan0 + y * Stride开始,每像素 3 字节,顺序为 B、G、R - 对
Format32bppArgb:每像素 4 字节,顺序为 B、G、R、A;Alpha 不参与灰度计算,但影响Stride(通常是 width×4) - 若只要快速预览,用
(B + G + R) / 3也行,但暗部细节会发灰,尤其在低光照区域
unsafe 代码里指针偏移别越界,Stride 和 Height 要一起校验
用 byte* 遍历像素时,常见错误是只判断 y ,却忽略 <code>Stride 可能比逻辑宽度大。比如 3 像素宽的图,Stride 是 12(补了 3 字节对齐),第 0 行末尾地址是 scan0 + 12,第 1 行起点是 scan0 + 12,但如果误用 scan0 + y * width * 3,第 1 行就直接跳到 scan0 + 9——访问非法内存,Debug 模式下可能崩,Release 下行为不可预测。
- 安全遍历方式:
for (int y = 0; y - 不要假设
Stride == Width * BytesPerPixel,打印bmpData.Stride和bmpData.Width看一眼更稳 - 启用
unsafe需在项目文件加<AllowUnsafeBlocks>true</AllowUnsafeBlocks>,否则编译不过
输出灰度图时,PixelFormat 要匹配,别让 GDI+ 自动转成彩色
即使你把每个像素都设成了 R=G=B,如果保存时用的是 Format24bppRgb,它还是彩色图(只是看起来灰)。真正节省体积、符合语义的做法是输出为 Format8bppIndexed —— 但 .NET 的 Bitmap 对索引色支持有限,构造时需手动建调色板,稍麻烦。
- 最简方案:保持原格式(如
Format24bppRgb),仅把 R/G/B 设为相同值,兼容性最好 - 想真正存为 8 位灰度:必须用
Format8bppIndexed,并调用bitmap.Palette设置 256 级灰阶调色板,否则保存后仍是 24 位 - 用
Image.Save保存 PNG 时,Format8bppIndexed能被正确识别;但 JPEG 不支持索引色,强行保存会自动转回 24 位
灰度化本身不难,难的是 Bitmap 内存布局和 GDI+ 的隐式转换规则——很多人卡在 Stride 和字节序上,反复调试半天才发现 BGR 当成了 RGB。










