
做图像处理界面的时候绕不开的一个东西就是 QImage。它不是 PySide6 里最花哨的类却是几乎所有图像功能的地基。不管是打开图片预览、截取区域、逐像素处理还是把一张图转换成界面能直接显示的格式最后都要落到 QImage 这层来。这篇笔记我会从一个实际做界面的人的角度把 QImage 的构造、像素读写、几何变换、绘制上屏以及和 numpy 结合的高效用法完整过一遍适合刚好学到图像处理这一块、想把手里的图片数据真正跑起来的同学。先说清楚一个常见误区QImage 不是给你直接贴到窗口上用的它是离屏的图片数据。真正的显示工具是 QPixmap而 QImage 更像是一个存放在内存里、可以随意修改分析的图像对象。理解了这个分工后面很多东西就顺了。1. QImage在PySide6里的定位一张与界面脱钩的图片1.1 为什么图像处理要用 QImage 而不是 QPixmapQPixmap 的设计目标是快它是为屏幕绘制优化的底层依赖 QPainter 和平台图形后端。在 Windows 上它可能走 DIB在 Linux 上可能走 X11 或者 Wayland 的共享内存在 macOS 上又会被 CoreGraphics 接管。这意味着 QPixmap 的内容未必能保证你拿到连续的像素数据也不能保证像素格式和你期望的一模一样。QImage 的设计目标则是数据。它不依赖任何窗口系统自己管理一块连续的内存里面是一个个像素点。你可以直接访问、修改、重新解释这块数据甚至把内存地址交给 C 扩展来处理。所以一旦要做图像分析、图像变换、机器学习预处理这类事情先让图片变成 QImage然后在此基础上操作是正确的路径。我用一个类比来帮助理解QPixmap 像拍立得照片拍出来什么样就是什么样适合拿给人看QImage 像底片你可以在暗房里随意调整放大、裁剪、调色最终要展示的时候再去冲印成照片QImage 转 QPixmap。1.2 图像数据本质它到底在内存里长什么样QImage 的核心成员是 width、height、format 和一条指向像素数据的引用。format 决定了每个像素用多少位、怎么排列比较常见的几个Format_RGB888每个像素 3 字节顺序是 R、G、B没有透明度。适合做通用图像数据的过渡格式。Format_ARGB32每个像素 4 字节顺序是蓝、绿、红、alpha注意这里是小端序下的内存排列实际用 QRgb 取值的时候是按 alpha、red、green、blue 的位组合来读的。这是老项目里最常见的格式。Format_RGBA8888每个像素 4 字节内存顺序就是 R、G、B、A和 numpy 里常见的 RGBA 布局一致。新代码里我更推荐这种因为它的内存布局更直观也方便和 OpenCV 之间互相倒腾。Format_Grayscale8每个像素 1 字节0 到 255。做灰度图时很省内存。一张 1920x1080 的 Format_ARGB32 图片占用的内存大约是 1920×1080×4等于 8294400 字节约为 8MB。有些同学会惊讶一张图怎么占这么多内存其实再加上缩放副本、QImage 和 QPixmap 两套拷贝一个界面撑到几百 MB 一点也不奇怪。所以在做批量图像处理时要时刻注意你持有的是数据的引用还是拷贝这个我在后面性能那部分会详细讲。2. 构造QImage的几条路从文件、画布到numpy数组2.1 从文件加载代码只有一行但容易忽略细节从文件加载 QImage 是最常见的用法from PySide6.QtGui import QImage img QImage(path/to/photo.jpg) if img.isNull(): print(加载失败可能文件不存在或格式不支持)这里我需要强调两个点。isNull() 一定要检查。因为构造失败时 QImage 不会抛异常而是返回一个宽度为 0、高度为 0 的空对象。你不检查的话后面一调用 width() 之类的就是无声的错误界面上白茫茫一片却不知道问题出在哪。第二个点是格式支持。PySide6 官方 wheels 通常会包含常用的 imageformats 插件比如 jpg、png、bmp、webp但如果你想加载 tiff、ico 等更偏门的格式取决于你的环境有没有带上对应插件。以前我接到过一个需求客户给的素材是 tiff 格式本地开发环境能打开部署到另一台机器上就打开了后来查明就是 imageformats 插件没有带上。稳妥的做法是尽量在项目里固化一套输入格式或者用 QImageReader 来显式地查询支持的格式列表而不要假设什么都能读。2.2 构造空白画布Format选择直接影响后面的性能有时候我们需要的是一张从零开始的图比如做画板、生成验证码背景、创建蒙版。可以用img QImage(800, 600, QImage.Format.Format_RGBA8888) img.fill(0xFFFFFFFF) # 白色不透明这里有个小坑QImage 构造出来之后像素数据并不保证是零。它只是分配了一块内存里面可能是之前其他进程留下的残留数据。所以一定要调用 fill() 或者用 painter 把内容画上去之后再用否则画布上会出现莫名其妙的噪点。Format 的选择也要提前想好。如果只是做黑白蒙版Format_Grayscale8 就足够了比 ARGB32 省四倍内存而且逐像素遍历时更快如果要做带透明效果的图像合成那就用 Format_RGBA8888 或者 Format_ARGB32。后面当你调用 convertToFormat() 去转换格式时会多一次像素级拷贝几百毫秒对一张大图来说很常见所以一开始就选对格式能省很多事。2.3 用 numpy 直接构造 QImage图像处理接口的关键桥梁PySide6 里没有直接从 numpy 数组构造 QImage 的构造函数最常用的办法是共享内存import numpy as np from PySide6.QtGui import QImage height, width, channel 480, 640, 3 data np.zeros((height, width, channel), dtypenp.uint8) data[:, :, 0] 200 # R data[:, :, 1] 100 # G data[:, :, 2] 50 # B img QImage(data.data, width, height, data.strides[0], QImage.Format.Format_RGB888)这里构造函数里第 4 个参数是 bytesPerLine很多资料直接写 width * channel也就是 1920。但当你手里是一个连续的 C 数组时没问题一旦 data 来自 numpy 的切片操作比如 data[:, ::2]那么它的行字节数就不再是简单的 width × channel 了必须用 strides[0]。如果传错你会看到图像变成扭曲的斜纹排查起来非常头疼。还有一个很隐蔽的点这样构造出来的 QImage 和 numpy 数组是共享同一块内存的而且 QImage 默认不会去拷贝这份数据。这意味着如果 numpy 数组被释放了或者被重新 resize 了QImage 就会变成悬空引用继续使用会得到随机内容甚至崩溃。解决方法有两种一是确保 numpy 数组的生命周期覆盖 QImage 的使用二是立刻调用 img.copy() 做一次拷贝让 QImage 独立持有数据。如果这张图要长期保留在界面里我通常无脑 copy()。原图可能带有 alpha 通道所以如果你手里是 RGBA 的 numpy 数组就对应 Format_RGBA8888。如果通道顺序是 BGRA用 Format_BGR888 也能适配。关于通道顺序的问题实操里非常常见后面再细说。3. 读写像素背后从pixel()到内存级的bits()访问3.1 直观但极慢的 pixel() 和 setPixel()QImage 提供了最简单直观的像素读写接口color img.pixel(100, 200) # 返回 QRgb一个整数 qcolor img.pixelColor(100, 200) # 返回 QColor img.setPixel(100, 200, QColor(255, 0, 0).rgb())这个方法在学习阶段用来验证想法非常方便。比如你想测试第 300 行第 400 列那个像素是不是偏红写三行代码就跑通了。但我要给你一个实际的数据在 Python 里循环逐像素调用 pixelColor()处理一张 1000×1000 的图可能要好几秒。因为每一次调用都要经过 Python 到 C 的类型转换、方法调用、再返回一个 QColor 对象而这个对象本身的构造又有额外开销。如果再加一层嵌套循环性能会迅速变得不可接受。结论就是pixel() 和 setPixel() 适合调试小图、验证思路真正上规模的处理得走 bits()。3.2 bits()、扫描线和 numpy 向量化这才是高效的正确姿势QImage 最底层的访问方式是 bits()它返回一个 memoryview 对象指向这块图像数据。借助它我们可以把 QImage 包装成一个 numpy ndarray然后所有操作都变成向量化运算速度会快几个数量级from PySide6.QtGui import QImage import numpy as np def qimage_to_numpy(img): # 复制图片并转换到已知格式避免出现不可预测的字节垫 padding img img.convertToFormat(QImage.Format.Format_RGBA8888) width img.width() height img.height() # 注意QImage 的扫描线可能有额外对齐这里使用 bytesPerLine() ptr img.bits() ptr.setsize(img.sizeInBytes()) arr np.ndarray((height, width, 4), dtypenp.uint8, bufferptr, strides(img.bytesPerLine(), 4, 1)) return arr.copy() # 拷贝一份避免引用 dangling这里为什么用 strides(img.bytesPerLine(), 4, 1) 而不是 (width*4, 4, 1)因为 Qt 在某些格式和硬件加速组合下每一行像素后面可能补了额外的字节让每行起始地址对齐到 4 或 16 字节。如果直接用简单的 width × 4对某些图片你会看到每行有个细微的错位越往下越歪。拿到 numpy 数组之后所有处理都变得很舒服。比如把整张图变暗一半arr[:, :, 0] arr[:, :, 0] // 2 arr[:, :, 1] arr[:, :, 1] // 2 arr[:, :, 2] arr[:, :, 2] // 2这里只谈访问方式的话还可以用 ndarray 的切片、布尔索引、np.where 做局部处理几乎能做到实时响应。当你把图转成 numpy 之后处理逻辑就不再是 Qt 的边界而是 numpy 的性能边界了。这也是我处理大图时的默认路径。4. 缩放、裁剪与旋转QImage的几何变换操作4.1 缩放是要质量的FastTransformation 和 SmoothTransformation差别不小缩放一个 QImage 直接用 scaled()scaled_img img.scaled(320, 240, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)参数里有两点值得解释。KeepAspectRatio 表示保持宽高比也就是说 640×480 的图缩到 320 宽时高会自动变成 240不会变形如果你用 IgnoreAspectRatio那图会被拉伸到指定的长宽常见于做头像裁剪这种需要严格占满的场景。第二个是 SmoothTransformation。这个选项表示使用平滑插值缩放后的边缘不容易出现锯齿FastTransformation 则是简单快速采样适合做缩略图预览这种不要求质量的场合。一个容易忽略的细节是SmoothTransformation 在缩放到很小尺寸时计算量明显更大。如果你的列表项里要同时生成几十张缩略图先用 FastTransformation 出一版交到界面上防止卡顿等空闲了再平滑重算是更稳的做法。还有一个我踩过的坑scaled() 缩放之后如果原图是灰度图并且保持原有格式不变出来的结果可能还是灰度图但如果原图是 Format_RGB888缩放结果在某些 Qt 版本里会变成 Format_ARGB32。如果你的代码依赖像素格式做判断缩完之后最好确认一下 img.format()必要时再 convertToFormat()。4.2 copy()、mirrored() 和 transformed()裁剪、镜像和旋转的日常用法裁剪本质上就是取子区域。QImage.copy(x, y, w, h) 会返回一个独立的新图像和原来的数据不再共享内存。注意复制区域不能超出原图范围否则越界部分会被填充为空数据。如果你需要的是保留原区域、在原图上画框而不是裁剪出子图用 QPainter 往原图上画矩形就行那是另一种思路。镜像最直接mirrored img.mirrored(horizontalTrue, verticalFalse)水平镜像相当于把图左右翻转你拍的带字照片翻转后会变成镜像字这是常见应用。旋转的话用 transformed() 配合 QTransformfrom PySide6.QtGui import QTransform transform QTransform().rotate(90) rotated img.transformed(transform, Qt.TransformationMode.SmoothTransformation)需要注意 rotation 方向是顺时针还是逆时针。QTransform().rotate(90) 是顺时针旋转 90 度如果你要逆时针对应 rotate(-90)。旋转之后图像的宽高会交换如果你想把它放回原来的显示区域记得处理布局。4.3 一个容易被忽略的操作rgbSwapped()QImage 还有一个很贴心的内置方法 rgbSwapped()它把红和蓝通道互换。为什么需要这个因为很多相机和图像库默认输出 BGR 顺序但你期望看到正常的 RGB。比如你从一个外部相机 SDK 拿到一张 BGR 的 buffer转成 QImage 后显示出来发现蓝色和红色全反了这时调用一下img img.rgbSwapped()颜色就恢复正常了。这个方法比你自己遍历像素交换快得多因为它内部是逐行按格式优化过的。我实际项目中经常因为摄像头 SDK 的通道顺序头大这个方法救了我不少时间。5. 让图片上屏QImage与QPixmap、QPainter的配合5.1 一次转换后续重复使用img 转 QPixmap 的正确姿势QImage 本身不直接参与界面的 paintEvent 绘制也行但 QLabel 和大多数控件倾向于接受 QPixmap因为 QPixmap 是和设备相关的底层已经尽可能做了针对当前显示硬件的优化。所以标准流程是from PySide6.QtGui import QPixmap pixmap QPixmap.fromImage(img) label.setPixmap(pixmap)如果你用的是 QPainter 自主绘制那可以不用转 QPixmap直接 drawImagepainter.drawImage(0, 0, img)这里有几个需要提醒的。QPixmap 是对 QImage 做了一次拷贝吗不一定。Qt 内部有隐式共享机制这个转换不会立刻复制所有像素但它确实会经过一次平台相关处理。如果你的主循环里每帧都从 QImage 转 QPixmap几百次下来会有明显 CPU 开销所以对于不变的内容尽量在 pEvent 之外只转一次缓存起来。另外一个细节是高分屏。现在的笔记本很多是 2K 甚至更高分辨率Qt 默认支持 devicePixelRatio。如果你把一张正好 100×100 的图片塞到 QLabel 里显示而标签的实际物理像素尺寸在高分屏上是 200×200图片会被放大出现模糊。解决办法是设置图片设备的像素比pixmap.setDevicePixelRatio(devicePixelRatioF())或者在 QImage 阶段就把图缩放到需要的像素尺寸让它在高分屏上清晰显示。很多初学者做出来的界面在自己机器上清晰换到高分屏笔记本上变糊根因往往就在这里。5.2 用 QPainter 把 QImage 画到界面上sourceRect 和 targetRect当你需要把一张大图的一部分画到一个固定区域时drawImage 有两个矩形参数很好用targetRect目标区域内要绘制的范围sourceRect源图像里取哪一块painter.drawImage(QRect(10, 10, 200, 150), img, QRect(50, 50, 200, 150))这个语法意思是把源图里从 (50, 50) 开始、宽 200 高 150 的那块区域画到目标矩形 (10, 10, 200, 150)。配合鼠标事件可以实现图片查看器里的拖拽裁切预览效果。我一般会在 paintEvent 里加一个绘制数量保护比如先判断当前图片是否为空再判断目标矩形和源矩形是否在合法范围内。否则一旦鼠标拖出的区域超过图片边界绘制的内容就会失控。5.3 给 QImage 加透明度setOpacity 不是图像数据的一部分QPainter 的透明度控制通常是在绘制时传入painter.setOpacity(0.5) painter.drawImage(0, 0, img)这样画出来的图片是半透明的但 QImage 本身的像素数据没有被修改。如果你想让图片本身带上透明度信息那就得操作 alpha 通道比如把某个区域设置成全透明arr qimage_to_numpy(img) arr[:, :, 3] 0这两种方式的使用场景完全不同前者适合做动画淡入淡出后者适合做真正的图像蒙版抠图。写代码前先分清你要的是显示层的透明还是数据层的透明能省掉不少弯路。6. 一个能跑到界面上的图像处理小例子灰度化与二值化6.1 从理论到代码灰度公式你为什么这么取值在界面上做一个一键变灰度按钮是最典型的图像处理入门例子。灰度化的核心就是判断一个彩色像素的亮度。公式有很多种Qt 官方计算灰度时采用的系数大致是gray 0.299 R 0.587 G 0.114 B这是给偏色系数做了加权因为人眼对绿色最敏感、对蓝色最不敏感。如果你直接取三个通道的平均值出来的灰度会偏亮尤其蓝色占比较高的图效果会失真。用 numpy 实现非常直观import numpy as np from PySide6.QtGui import QImage def image_to_grayscale(img: QImage) - QImage: arr qimage_to_numpy(img) # RGBA gray (arr[:, :, 0] * 0.299 arr[:, :, 1] * 0.587 arr[:, :, 2] * 0.114).astype(np.uint8) h, w gray.shape gray_qimage QImage(gray.data, w, h, gray.strides[0], QImage.Format.Format_Grayscale8) return gray_qimage.copy()注意这里我用了 QImage.format.Format_Grayscale8这样内存占用降低到原来的四分之一后续如果要做阈值、形态学处理也更快。显示的时候 QPixmap 对灰度图也能正常处理不需要再转换回 RGB。6.2 阈值二值化与滑块调参把处理参数暴露到界面上二值化是灰度图的下游操作把灰度分成黑和白。最简单的固定阈值模式是threshold 128 binary np.where(gray threshold, 255, 0).astype(np.uint8)但如果所有图片都用一个固定阈值边界一复杂就会出现大片错误。我在工具界面里通常会加一个 QSlider 让使用者实时调阈值slider QSlider(Qt.Orientation.Horizontal) slider.setRange(0, 255) slider.valueChanged.connect(on_threshold_change) def on_threshold_change(value): binary np.where(gray value, 255, 0).astype(np.uint8) show_image(binary)滑块拖动时每秒会产生很多次 valueChanged而大图做 numpy 运算也要时间所以磨轮的时候要考虑性能。两个实用的优化小图就直接算大图先在低分辨率缩放图上预览效果松开滑块条再对原图应用或者使用 QTimer 做 100ms 聚合只在最后一次变化时重算。这一步实操下来你会同时对 QImage、numpy 和 Qt 的交互回调有一个完整的串联理解。6.3 保存处理结果扩展名决定格式参数控制质量处理完的图片要落盘QImage.save() 是最简单的ok img.save(output.png, PNG, 90) if not ok: print(保存失败)注意第二个参数其实是显式指定格式。如果省略Qt 会根据文件扩展名推断但是扩展名写错或者为空时很容易保存失败。另外质量参数对 PNG 影响不大对 JPEG 影响很大JPEG 质量 95 和 75 的文件大小能差几倍同时肉眼看不出明显区别。如果你做的是批量导出任务建议 PNG 无损、JPEG 质量控制在 85 左右平衡体积和观感。save() 还有一个细节需要确保目标目录存在否则返回 False。不要以为传了绝对路径就一定成功目录权限和路径中文字符也会导致失败。正确姿势是保存前判断 os.path.exists(dirname)。7. 性能、边界和常见翻车点我实际用过之后的几个体会7.1 为什么逐像素循环那么慢一场 Python 和 C 之间的翻译前面提到 pixel() 循环慢这里展开说原因。Python 每调用一次 pixel()实际上至少做三件事构造参数、调用 C 绑定接口、构造返回值对象。如果你在一个 200 万像素的图上做两层循环那就是 200 万次翻译每次翻译的固定开销大约在几微秒到几十微秒乘起来自然不可接受。而 numpy 向量化操作是把整个数组的运算交给 C 语言级别的循环执行Python 层只发一条指令然后等结果。所以同样做灰度化从几秒降到几十毫秒并不稀奇。这条经验对 QImage 同样成立凡是涉及逐像素、大面积、重复性运算就先把图像转成 numpy 数组把所有算术逻辑丢给 numpy 解决最后只保留一个 QImage 转换回界面。如果你确实需要在 Python 里严格按坐标访问一行数据可以考虑把 QImage 先转成 numpy 之后再索引而不是直接用 pixel。7.2 我踩过的三个坑copy、格式、内存归属第一个坑是忽略了 QImage 和 numpy 数组之间的共享内存。用 QImage(data.data, ...) 构造出来后如果你马上把 data 数组删除或者重新赋值QImage 还是会指向那块已经释放的内存。结果就是图像时好时坏有时候显示正常有时候花屏程序还可能在退出时崩溃。实际项目里排查这种问题很费时间我现在统一规则凡是进入 QImage 的数据都调用 img.copy()虽然多一次拷贝但换来稳定性。第二个坑是格式没对齐。QImage 的 Format_ARGB32 在内存里是 B、G、R、A 的顺序而 Format_RGBA8888 是 R、G、B、A。很多从摄像头一路透传过来的缓冲是 BGR 顺序的如果你按 RGB888 构造 QImage出来的颜色会和原画面红蓝颠倒。遇到这种情况先在文档里确认你这个 SDK 输出的通道顺序到底是什么再决定用哪个 format。不要反复试错查一次文档比瞎试十次都快。第三个坑是 convertToFormat 的默认转换策略。某些情况下 Qt 会对图像做 alpha 预乘输出结果和你期望的数值有细微差别。如果你只用它做格式统一最好在转换后及时做一次像素级抽查防止后续计算里残留意外。7.3 关于字节对齐的一点点深入在处理 QImage 时bytesPerLine 这个字段比你想象的重要。Qt 为了保证每行起始地址对齐可能会在行尾追加填充字节。比如一张 3 字节每像素的图宽度是 100按理说一行 300 字节但 bytesPerLine 可能是 304 或者 320。此时如果你直接用 width*channel 当作 strides 去套 numpy图像会整体歪斜每隔几行会出现一条错位线。这个问题在图片宽度恰好是某些值时不容易暴露一旦宽度变了歪斜立刻出现。所以我写了一个统一函数 qimage_to_numpy 和 numpy_to_qimage每次都用 bytesPerLine 动态计算不写死任何常量。这样跨平台、跨格式跑起来都稳定。7.4 进阶方向把处理放进 QGraphicsView 做缩放平移如果只是单个 QLabel 显示图片交互能力比较有限。当你需要做图像浏览器的连动放大、缩放下可以考虑内置 QGraphicsView 和 QGraphicsScene然后把 QImage 转变为一个 QPixmapItem 放进场景里。QGraphicsView 自带缩放、拖拽、惯性滑动比自己手动处理鼠标事件省太多。当初我把显示控件从 QLabel 升级成 QGraphicsView 之后图片浏览体验上了一个台阶。前面说的 qimage_to_numpy 在这套模式下同样适用你可以处理完 QImage 后把它转成 QPixmap 再塞回 item更新内容时只需调用 item.setPixmap() 并触发一次 update性能完全可控。最后再分享一点自己的习惯每当我写图像相关的功能都会在项目里放一个 image_utils.py专门封装这几个核心转换函数包含 bytesPerLine 的处理、copy 逻辑和格式转换。后续所有模块都从这里面取函数用。这样既能保证格式一致又能避免在十几个文件里重复踩同一类坑。如果你也走到图像处理这一步我建议你像我一样尽早把 QImage 和 numpy 之间的桥梁函数固化下来后面能省下很多时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。