Qt4图像处理实战:从零实现Photoshop图层混合与滤镜 简介这是一份基于Qt4框架复现Photoshop部分核心功能的C图像处理项目源码面向具备一定Qt基础、希望深入理解绘图工具与图像编辑器实现原理的开发者。项目围绕QPainter展开涵盖画笔、橡皮擦、填充、选区、图层管理、滤镜效果、项目保存加载以及仿Photoshop风格界面等模块适合作为课程设计、毕业设计或图像处理入门练手参考。压缩包共32个文件约102KB包含10个cpp源文件、9个ui界面文件、9个头文件及pro工程文件另附可执行程序与说明文档结构完整、便于直接编译运行与二次修改。目前已有356人学习下载。通过阅读源码读者可掌握QPainter绘制路径、QImage像素级操作、QStackedWidget模拟图层、QDataStream序列化等实用技巧并理解双缓冲防闪烁与线程管理的优化思路为开发基础图像编辑软件打下扎实基础。1. Qt4 写 Photoshop 部分功能从零搭一个能跑的图像处理壳子很多人第一次听到“用 Qt4 写 Photoshop 部分功能”脑子里浮现的是要复刻一个完整的设计软件其实完全不是。真实场景往往是你手上有个老项目跑在 Qt4 环境里客户突然要求加个“打开图片、调亮度、加个滤镜、另存为”的小工具或者你自己想搞明白 Photoshop 里那些图层、选区、滤镜到底是怎么在代码层面落地的。Qt4 的 QImage、QPainter、QGraphicsView 这套东西足够撑起一个轻量级图像编辑器的骨架而且不用引入 OpenCV 那种重依赖。这篇文章就是把我自己踩过的路重新走一遍从环境搭建、画布渲染、图层混合到滤镜实现和性能避坑每一步都给能直接抄的代码和参数说明。适合两类人一是被 Qt4 老项目绑住、需要快速加图像功能的工程师二是想通过造轮子理解 Photoshop 核心机制的学习者。下面所有代码都在 Qt4.8 qmake 环境下验证过Qt5 需要微调头文件我会在对应位置标注。2. 环境搭建与最小可运行画布QImage 和 QGraphicsView 怎么选2.1 为什么不用 QLabel 直接显示图片新手最容易掉进的坑是拿 QLabel 的 setPixmap 来显示图片然后发现缩放卡顿、鼠标坐标对不上、画不了选区。QLabel 适合展示静态图标不适合做编辑器画布。正确的做法是用 QGraphicsView QGraphicsScene QGraphicsPixmapItem 这套组合它自带坐标变换、缩放、滚动和 item 拾取后面加选区、图层、滤镜预览都靠它。先看工程文件Qt4 的 .pro 写法很固定# imageeditor.pro QT core gui TARGET ImageEditor TEMPLATE app SOURCES main.cpp \ mainwindow.cpp \ canvasview.cpp HEADERS mainwindow.h \ canvasview.h这里只依赖 QtGui 模块不需要 QtOpenGL除非你要做 GPU 加速滤镜。TEMPLATE app 表示生成可执行程序不是库。2.2 画布类的核心代码与坐标映射CanvasView 继承 QGraphicsView重写 wheelEvent 实现以鼠标为中心的缩放这是 Photoshop 里最基础的手感。// canvasview.cpp #include canvasview.h #include QWheelEvent #include QGraphicsPixmapItem CanvasView::CanvasView(QWidget *parent) : QGraphicsView(parent) { scene new QGraphicsScene(this); this-setScene(scene); this-setRenderHint(QPainter::SmoothPixmapTransform, true); this-setDragMode(QGraphicsView::NoDrag); pixmapItem NULL; zoomLevel 1.0; } void CanvasView::loadImage(const QString path) { QImage img(path); if (img.isNull()) return; scene-clear(); pixmapItem scene-addPixmap(QPixmap::fromImage(img)); scene-setSceneRect(img.rect()); zoomLevel 1.0; this-resetTransform(); } void CanvasView::wheelEvent(QWheelEvent *event) { if (!pixmapItem) return; double factor 1.15; if (event-delta() 0) factor 1.0 / factor; // 以鼠标位置为中心缩放 this-setTransformationAnchor(QGraphicsView::AnchorUnderMouse); this-scale(factor, factor); zoomLevel * factor; event-accept(); }逻辑说明setSceneRect 必须设置成图片实际矩形否则滚动条范围不对。AnchorUnderMouse 是 Qt 自带的锚点策略省去手动算偏移。zoomLevel 变量留着后面做“100% 显示”按钮用。参数上factor 取 1.15 是手感比较顺的值取 1.2 会显得跳取 1.1 又太肉。2.3 主窗口里把菜单和画布串起来MainWindow 里用 QAction 挂菜单打开文件用 QFileDialog注意 Qt4 的静态函数签名和 Qt5 不同。// mainwindow.cpp #include mainwindow.h #include canvasview.h #include QMenuBar #include QFileDialog #include QMessageBox MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { canvas new CanvasView(this); this-setCentralWidget(canvas); this-resize(1024, 768); QMenu *fileMenu menuBar()-addMenu(tr(文件)); QAction *openAct fileMenu-addAction(tr(打开)); connect(openAct, SIGNAL(triggered()), this, SLOT(openImage())); } void MainWindow::openImage() { QString path QFileDialog::getOpenFileName(this, tr(选择图片), , tr(图片文件 (*.png *.jpg *.bmp))); if (path.isEmpty()) return; canvas-loadImage(path); }这里有个 Qt4 特有的坑QFileDialog::getOpenFileName 的过滤器字符串格式是 “描述 (*.ext)”多个用两个分号隔开写错了会弹不出文件。另外 connect 用的是老式 SIGNAL/SLOT 宏Qt4 不支持 C11 的 lambda 连接方式别照抄 Qt5 教程。3. 图层混合与选区把 Photoshop 的“图层”概念落到 QImage 上3.1 图层数据结构怎么设计Photoshop 的图层本质是带位置、透明度、混合模式的独立像素缓冲。在 Qt4 里我用一个结构体加 QList 来管不搞复杂继承。// layer.h struct Layer { QImage image; // 图层像素ARGB32 格式 QString name; QPoint offset; // 相对画布左上角偏移 qreal opacity; // 0.0 ~ 1.0 int blendMode; // 0 正常1 正片叠底2 滤色 bool visible; };image 必须用 QImage::Format_ARGB32否则混合时 alpha 通道会出问题。offset 用 QPoint 而不是 QRect因为图层尺寸可能小于画布。opacity 用 qreal 是为了后面做动画过渡。3.2 正片叠底和滤色的像素级实现混合模式不能靠 QPainter 的 CompositionMode 直接搞定因为 Qt4 的合成模式里没有“正片叠底”这种命名得自己遍历像素算。// blend.cpp QImage blendImages(const QImage base, const QImage top, const QPoint offset, qreal opacity, int mode) { QImage result base.convertToFormat(QImage::Format_ARGB32); QImage src top.convertToFormat(QImage::Format_ARGB32); int w qMin(src.width(), result.width() - offset.x()); int h qMin(src.height(), result.height() - offset.y()); for (int y 0; y h; y) { QRgb *baseLine (QRgb*)result.scanLine(y offset.y()); QRgb *srcLine (QRgb*)src.scanLine(y); for (int x 0; x w; x) { QRgb b baseLine[x offset.x()]; QRgb s srcLine[x]; int sa qAlpha(s); if (sa 0) continue; qreal alpha (sa / 255.0) * opacity; int br qRed(b), bg qGreen(b), bb qBlue(b); int sr qRed(s), sg qGreen(s), sb qBlue(s); int rr, rg, rb; if (mode 1) { // 正片叠底 rr br * sr / 255; rg bg * sg / 255; rb bb * sb / 255; } else if (mode 2) { // 滤色 rr 255 - (255 - br) * (255 - sr) / 255; rg 255 - (255 - bg) * (255 - sg) / 255; rb 255 - (255 - bb) * (255 - sb) / 255; } else { // 正常 rr sr; rg sg; rb sb; } // 按 alpha 插值 baseLine[x offset.x()] qRgba( br (rr - br) * alpha, bg (rg - bg) * alpha, bb (rb - bb) * alpha, qAlpha(b)); } } return result; }逻辑说明scanLine 返回的是行指针比 pixel() 逐点访问快一个数量级。alpha 插值那一步是混合的核心opacity 乘在图层自身 alpha 上。参数 mode 用整数枚举避免 Qt4 里 enum 跨文件传递的麻烦。注意 qRgba 的 alpha 我保留了底图 alpha因为这里假设底图是不透明的画布。3.3 矩形选区和蚂蚁线的绘制选区用 QGraphicsRectItem 做可视化蚂蚁线靠 QPen 的 dash pattern 加定时器偏移。// 在 CanvasView 里加选区 void CanvasView::startRectSelect(const QPointF scenePos) { selectStart scenePos; if (rubberBand) scene-removeItem(rubberBand); rubberBand new QGraphicsRectItem(QRectF(scenePos, QSizeF(0, 0))); QPen pen(Qt::white, 1, Qt::DashLine); pen.setDashPattern(QVectorqreal() 4 4); rubberBand-setPen(pen); scene-addItem(rubberBand); } void CanvasView::updateRectSelect(const QPointF scenePos) { if (!rubberBand) return; QRectF rect QRectF(selectStart, scenePos).normalized(); rubberBand-setRect(rect); }蚂蚁线动画需要 QTimer 每 200ms 改一次 pen 的 dashOffsetQt4 的 QPen 没有直接 dashOffset 接口得重建 QPen。这个细节很多人卡住以为 Qt4 不支持其实是接口不同。4. 滤镜实现亮度对比度、高斯模糊和卷积核的 Qt4 写法4.1 亮度对比度调节的查表优化逐像素算浮点乘加在 4000x3000 图上会卡到怀疑人生。正确做法是建 256 项查找表。// filter.cpp QImage adjustBrightnessContrast(const QImage src, int brightness, qreal contrast) { QImage dst src.convertToFormat(QImage::Format_ARGB32); int lut[256]; qreal c contrast; // 范围建议 0.5 ~ 2.0 for (int i 0; i 256; i) { qreal v i brightness; // brightness 范围 -100 ~ 100 v (v - 128) * c 128; // 以 128 为中心拉伸 lut[i] qBound(0, (int)v, 255); } for (int y 0; y dst.height(); y) { QRgb *line (QRgb*)dst.scanLine(y); for (int x 0; x dst.width(); x) { QRgb p line[x]; line[x] qRgba(lut[qRed(p)], lut[qGreen(p)], lut[qBlue(p)], qAlpha(p)); } } return dst; }参数说明brightness 直接加到像素值上contrast 以 128 为轴心做线性拉伸。qBound 是 Qt 自带的钳位函数比手写 if 简洁。这个函数在 1920x1080 图上耗时约 15ms可以接受实时预览。4.2 高斯模糊的分离卷积实现二维高斯核直接卷积是 O(n²)改成先横后竖两次一维卷积复杂度降到 O(n)。Qt4 里没有现成的卷积函数全手写。// 生成一维高斯核 QVectorqreal gaussianKernel(int radius, qreal sigma) { QVectorqreal kernel(2 * radius 1); qreal sum 0.0; for (int i -radius; i radius; i) { qreal v exp(-(i * i) / (2 * sigma * sigma)); kernel[i radius] v; sum v; } for (int i 0; i kernel.size(); i) kernel[i] / sum; return kernel; } QImage gaussianBlur(const QImage src, int radius, qreal sigma) { QImage tmp src.convertToFormat(QImage::Format_ARGB32); QImage dst tmp; QVectorqreal kernel gaussianKernel(radius, sigma); int w src.width(), h src.height(); // 横向 for (int y 0; y h; y) { QRgb *srcLine (QRgb*)tmp.scanLine(y); QRgb *dstLine (QRgb*)dst.scanLine(y); for (int x 0; x w; x) { qreal r 0, g 0, b 0, a 0; for (int k -radius; k radius; k) { int xx qBound(0, x k, w - 1); QRgb p srcLine[xx]; qreal wgt kernel[k radius]; r qRed(p) * wgt; g qGreen(p) * wgt; b qBlue(p) * wgt; a qAlpha(p) * wgt; } dstLine[x] qRgba((int)r, (int)g, (int)b, (int)a); } } // 纵向交换 tmp 和 dst 再跑一遍 tmp dst; for (int x 0; x w; x) { for (int y 0; y h; y) { qreal r 0, g 0, b 0, a 0; for (int k -radius; k radius; k) { int yy qBound(0, y k, h - 1); QRgb p ((QRgb*)tmp.scanLine(yy))[x]; qreal wgt kernel[k radius]; r qRed(p) * wgt; g qGreen(p) * wgt; b qBlue(p) * wgt; a qAlpha(p) * wgt; } ((QRgb*)dst.scanLine(y))[x] qRgba((int)r, (int)g, (int)b, (int)a); } } return dst; }参数上radius 取 3 到 10 比较实用sigma 一般取 radius / 3.0。边界处理用 qBound 钳位到边缘像素这叫“夹取”模式比补零自然。纵向那层循环里 scanLine 调用频繁可以先把行指针缓存到数组但 Qt4 环境下编译器优化后差距不大。4.3 锐化滤镜与卷积核通用框架锐化就是拉普拉斯核我把它写成一个通用 3x3 卷积函数方便后面加浮雕、边缘检测。QImage convolve3x3(const QImage src, const qreal kernel[9], qreal factor, qreal bias) { QImage dst src.convertToFormat(QImage::Format_ARGB32); int w src.width(), h src.height(); for (int y 0; y h; y) { for (int x 0; x w; x) { qreal r 0, g 0, b 0; for (int ky -1; ky 1; ky) { for (int kx -1; kx 1; kx) { int xx qBound(0, x kx, w - 1); int yy qBound(0, y ky, h - 1); QRgb p ((QRgb*)src.scanLine(yy))[xx]; qreal k kernel[(ky 1) * 3 (kx 1)]; r qRed(p) * k; g qGreen(p) * k; b qBlue(p) * k; } } QRgb old ((QRgb*)src.scanLine(y))[x]; ((QRgb*)dst.scanLine(y))[x] qRgba( qBound(0, (int)(r * factor bias), 255), qBound(0, (int)(g * factor bias), 255), qBound(0, (int)(b * factor bias), 255), qAlpha(old)); } } return dst; }锐化核是 {0,-1,0, -1,5,-1, 0,-1,0}factor 取 1.0bias 取 0。浮雕核是 {-2,-1,0, -1,1,1, 0,1,2}factor 取 1.0bias 取 128。这个框架在 Qt4 下跑 800x600 图约 40ms做预览够用。5. 避坑与排查Qt4 图像处理里最容易翻车的 5 个点5.1 图片打开后颜色发暗或偏色现象用 QImage 加载 JPG 后显示正常但混合或滤镜后颜色变暗。原因QImage 默认可能加载成 Format_RGB32没有 alpha 通道混合时 qAlpha 返回 255 但实际语义不对。解决所有参与混合的图强制 convertToFormat(QImage::Format_ARGB32)加载后立刻转别等到混合时再转。5.2 缩放后画质糊成一片现象QGraphicsView 缩放后图片边缘锯齿严重。原因没开 SmoothPixmapTransform 渲染提示或者开了但 pixmapItem 的 transformationMode 没设。解决view 上 setRenderHint(QPainter::SmoothPixmapTransform, true)同时 item 上 setTransformationMode(Qt::SmoothTransformation)。注意 Qt4 的 QGraphicsPixmapItem 默认是 FastTransformation。5.3 滤镜处理大图时界面卡死现象点一下高斯模糊窗口白屏好几秒。原因滤镜跑在主线程事件循环被阻塞。解决用 QThread 把滤镜计算丢到后台算完发信号回主线程更新 QImage。Qt4 的 QThread 用法是继承 QThread 重写 run()别用 moveToThreadQt4 对那个支持不完整。5.4 选区坐标和图片坐标对不上现象鼠标拖出来的选区和实际想选的位置有偏移。原因QGraphicsView 的 scenePos 和 viewport 坐标混用或者没考虑滚动条偏移。解决统一用 mapToScene(event-pos()) 拿场景坐标所有选区、绘制操作都在场景坐标系里做。滚动条偏移 QGraphicsView 会自动处理别自己减。5.5 内存泄漏QImage 隐式共享的陷阱现象反复打开关闭图片内存只涨不降。原因QImage 是隐式共享的但 scanLine 返回的指针如果被缓存到全局会阻止共享释放。解决别把 scanLine 指针存成成员变量每次用每次取。另外 QGraphicsScene::clear() 会删除 item但 QImage 数据要确认没有其他引用。6. 进阶技巧用 QPainter 合成路径和文字图层6.1 把选区转成 QPainterPath 做裁剪选区不只是矩形用 QPainterPath 可以支持套索和魔棒。下面是把一个多边形选区应用到图层上的写法。QImage applySelectionMask(const QImage layer, const QPainterPath path) { QImage result layer.convertToFormat(QImage::Format_ARGB32); QPainter painter(result); painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); painter.setRenderHint(QPainter::Antialiasing, true); painter.fillPath(path, Qt::black); painter.end(); return result; }CompositionMode_DestinationIn 的意思是“只保留目标中与源重叠的部分”fillPath 用黑色填充路径alpha 为 1 的区域保留路径外 alpha 变 0。这个技巧比逐像素判断快得多而且自带抗锯齿。参数上path 的填充规则用 Qt::WindingFill 还是 OddEvenFill 会影响自相交路径的结果套索一般用 WindingFill。6.2 文字图层的渲染与描边Photoshop 的文字图层可以加描边Qt4 里用 QPainterPath 加 QPen 实现。QImage renderTextLayer(const QString text, const QFont font, const QColor fill, const QColor stroke, int strokeWidth) { QFontMetrics fm(font); QRect rect fm.boundingRect(text); QImage img(rect.width() strokeWidth * 2, rect.height() strokeWidth * 2, QImage::Format_ARGB32); img.fill(Qt::transparent); QPainter painter(img); painter.setRenderHint(QPainter::Antialiasing, true); QPainterPath path; path.addText(strokeWidth, fm.ascent() strokeWidth, font, text); if (strokeWidth 0) { QPen pen(stroke, strokeWidth * 2); pen.setJoinStyle(Qt::RoundJoin); painter.setPen(pen); painter.drawPath(path); } painter.fillPath(path, fill); painter.end(); return img; }逻辑说明先画描边再填内部描边宽度乘 2 是因为 QPen 的宽度是内外各一半。RoundJoin 让转角圆滑模拟 Photoshop 的圆角描边。参数 strokeWidth 取 2 到 6 比较自然再大就糊了。6.3 用 QSettings 记住上次打开的目录和窗口布局这个不算图像处理但做工具类软件很实用。Qt4 的 QSettings 在 Windows 下写注册表Linux 下写 ini跨平台不用改代码。// 保存 QSettings settings(MyCompany, ImageEditor); settings.setValue(lastDir, lastOpenDir); settings.setValue(windowGeometry, saveGeometry()); // 读取 QSettings settings(MyCompany, ImageEditor); lastOpenDir settings.value(lastDir, QDir::homePath()).toString(); restoreGeometry(settings.value(windowGeometry).toByteArray());注意 QSettings 的构造函数参数是组织名和应用名别写反。value 的第二个参数是默认值类型要匹配toByteArray 不能省。6.4 性能验证用 QTime 给每个滤镜打点别凭感觉猜哪个滤镜慢加几行计时代码。QTime timer; timer.start(); QImage result gaussianBlur(src, 5, 1.6); qDebug() gaussianBlur cost: timer.elapsed() ms;在 1920x1080 的 ARGB32 图上radius5 的高斯模糊大约 180msradius10 约 350ms。如果超过 500ms检查是不是没转 ARGB32 或者 scanLine 用成了 pixel。这个习惯我保持了多年每次加新滤镜先打点心里有数再决定要不要上多线程。最后说个血泪教训Qt4 的 QImage 在 Format_ARGB32 和 Format_ARGB32_Premultiplied 之间转换时如果直接混合会出半透明边缘发黑的问题。我一般统一用 Format_ARGB32只在最终显示前转一次 Premultiplied。这个坑我翻过两次希望帮到你。本文还有配套的精品资源点击获取