Qt Quick 影象處理例項之美圖秀秀 附原始碼下載
在《Qt Quick 之 QML 與 C++ 混合程式設計詳解》一文中我們講解了 QML 與 C++ 混合程式設計的方方面面的內容,這次我們通過一個影象處理應用,再來看一下 QML 與 C++ 混合程式設計的威力,同時也為諸君揭開美圖秀秀、魔拍之類的相片美化應用的底層原理。
專案的建立過程請參考《Qt Quick 之 Hello World 圖文詳解》,專案名稱為 imageProcessor ,建立完成後需要新增兩個檔案: imageProcessor.h 和 imageProcessor.cpp 。
本文是作者 Qt Quick 系列文章中的一篇,其它文章在這裡:
例項效果
先看一下示例的實際執行效果,然後我們再來展開。
圖 1 是在電腦上開啟一個圖片後的初始效果:
圖 1 初始效果
圖 2 是應用柔化特效後的效果:
圖 2 柔化特效
圖 3 是應用灰度特效後的截圖:
圖 3 灰度特效
圖 4 是浮雕特效:
圖 4 浮雕特效
圖 5 是黑白特效:
圖 5 黑白特效
圖 6 是應用底片特效後的截圖:
圖 6 底片特效
如果你注意到我部落格的頭像……嗯,木錯,它就是我使用本文例項的底片特效做出來的。
圖 7 是應用銳化特效後的截圖:
圖 7 銳化特效
特效展示完畢,那麼它們是怎麼實現的呢?這就要說到影象處理演算法了。
影象處理演算法
imageProcessor 例項提供了"柔化"、"灰度"、"浮雕"、"黑白"、"底片"、"銳化"六種影象效果。演算法的實現在 imageProcessor.h / imageProcessor.cpp 兩個檔案中,我們先簡介每種效果對應的演算法,然後看程式碼實現。
柔化
柔化又稱模糊,影象模糊演算法有很多種,我們最常見的就是均值模糊,即取一定半徑內的畫素值之平均值作為當前點的新的畫素值。
為了提高計算速度,我們取 3 為半徑,就是針對每一個畫素,將周圍 8 個點加上自身的 RGB 值的平均值作為畫素新的顏色值置。程式碼如下:
static void _soften(QString sourceFile, QString destFile){ QImage image(sourceFile); if(image.isNull()) { qDebug() << "load " << sourceFile << " failed! "; return; } int width = image.width(); int height = image.height(); int r, g, b; QRgb color; int xLimit = width - 1; int yLimit = height - 1; for(int i = 1; i < xLimit; i++) { for(int j = 1; j < yLimit; j++) { r = 0; g = 0; b = 0; for(int m = 0; m < 9; m++) { int s = 0; int p = 0; switch(m) { case 0: s = i - 1; p = j - 1; break; case 1: s = i; p = j - 1; break; case 2: s = i + 1; p = j - 1; break; case 3: s = i + 1; p = j; break; case 4: s = i + 1; p = j + 1; break; case 5: s = i; p = j + 1; break; case 6: s = i - 1; p = j + 1; break; case 7: s = i - 1; p = j; break; case 8: s = i; p = j; } color = image.pixel(s, p); r += qRed(color); g += qGreen(color); b += qBlue(color); } r = (int) (r / 9.0); g = (int) (g / 9.0); b = (int) (b / 9.0); r = qMin(255, qMax(0, r)); g = qMin(255, qMax(0, g)); b = qMin(255, qMax(0, b)); image.setPixel(i, j, qRgb(r, g, b)); } } image.save(destFile);}
這樣處理的效果不是特別明顯,採用高斯模糊演算法可以獲取更好的效果。灰度
把影象變灰,大概有這麼三種方法:
- 最大值法,即 R = G = B = max(R , G , B),這種方法處理過的圖片亮度偏高
- 平均值法,即 R = G = B = (R + G + B) / 3 ,這種方法處理過的圖片比較柔和
- 加權平均值法,即 R = G = B = R*Wr + G*Wg + B*Wb ,因為人眼對不同顏色的敏感度不一樣,三種顏色權重也不一樣,一般來說綠色最高,紅色次之,藍色最低。這種方法最合理的取值,紅、綠、藍的權重依次是 0.299 、0.587 、 0.114 。為了避免浮點運算,可以用移位替代。
Qt 框架有一個 qGray() 函式,採取加權平均值法計算灰度。 qGray() 將浮點運算轉為整型的乘法和除法,公式是 (r * 11 + g * 16 + b * 5)/32 ,沒有使用移位運算。
我使用 qGray() 函式計算灰度,下面是程式碼:
static void _gray(QString sourceFile, QString destFile){ QImage image(sourceFile); if(image.isNull()) { qDebug() << "load " << sourceFile << " failed! "; return; } qDebug() << "depth - " << image.depth(); int width = image.width(); int height = image.height(); QRgb color; int gray; for(int i = 0; i < width; i++) { for(int j= 0; j < height; j++) { color = image.pixel(i, j); gray = qGray(color); image.setPixel(i, j, qRgba(gray, gray, gray, qAlpha(color))); } } image.save(destFile);}
qGray() 計算灰度時忽略了 Alpha 值,我在實現時保留原有的 Alpha 值。浮雕
"浮雕" 圖象效果是指影象的前景前向凸出背景。
浮雕的演算法相對複雜一些,用當前點的 RGB 值減去相鄰點的 RGB 值並加上 128 作為新的 RGB 值。由於圖片中相鄰點的顏色值是比較接近的,因此這樣的演算法處理之後,只有顏色的邊沿區域,也就是相鄰顏色差異較大的部分的結果才會比較明顯,而其他平滑區域則值都接近128左右,也就是灰色,這樣就具有了浮雕效果。
看程式碼:
static void _emboss(QString sourceFile, QString destFile){ QImage image(sourceFile); if(image.isNull()) { qDebug() << "load " << sourceFile << " failed! "; return; } int width = image.width(); int height = image.height(); QRgb color; QRgb preColor = 0; QRgb newColor; int gray, r, g, b, a; for(int i = 0; i < width; i++) { for(int j= 0; j < height; j++) { color = image.pixel(i, j); r = qRed(color) - qRed(preColor) + 128; g = qGreen(color) - qGreen(preColor) + 128; b = qBlue(color) - qBlue(preColor) + 128; a = qAlpha(color); gray = qGray(r, g, b); newColor = qRgba(gray, gray, gray, a); image.setPixel(i, j, newColor); preColor = newColor; } } image.save(destFile);}
在實現 _emboss() 函式時 ,為避免有些區域殘留“彩色”雜點或者條狀痕跡,我對新的 RGB 值又做了一次灰度處理。黑白
黑白圖片的處理演算法比較簡單:對一個畫素的 R 、G 、B 求平均值,average = (R + G + B) / 3 ,如果 average 大於等於選定的閾值則將該畫素置為白色,小於閾值就把畫素置為黑色。
示例中我選擇的閾值是 128 ,也可以是其它值,根據效果調整即可。比如你媳婦兒高圓圓嫌給她拍的照片黑白處理後黑多白少,那可以把閾值調低一些,取 80 ,效果肯定就變了。下面是程式碼:
static void _binarize(QString sourceFile, QString destFile){ QImage image(sourceFile); if(image.isNull()) { qDebug() << "load " << sourceFile << " failed! "; return; } int width = image.width(); int height = image.height(); QRgb color; QRgb avg; QRgb black = qRgb(0, 0, 0); QRgb white = qRgb(255, 255, 255); for(int i = 0; i < width; i++) { for(int j= 0; j < height; j++) { color = image.pixel(i, j); avg = (qRed(color) + qGreen(color) + qBlue(color))/3; image.setPixel(i, j, avg >= 128 ? white : black); } } image.save(destFile);}
程式碼的邏輯簡單,從檔案載入圖片,生成一個 QImage 例項,然後應用演算法,處理後的圖片儲存到指定位置。底片
早些年的相機使用膠捲記錄拍攝結果,洗照片比較麻煩,不過如果你拿到底片,逆光去看,效果就很特別。
底片演算法其實很簡單,取 255 與畫素的 R 、 G、 B 分量之差作為新的 R、 G、 B 值。實現程式碼:
static void _negative(QString sourceFile, QString destFile){ QImage image(sourceFile); if(image.isNull()) { qDebug() << "load " << sourceFile << " failed! "; return; } int width = image.width(); int height = image.height(); QRgb color; QRgb negative; for(int i = 0; i < width; i++) { for(int j= 0; j < height; j++) { color = image.pixel(i, j); negative = qRgba(255 - qRed(color), 255 - qGreen(color), 255 - qBlue(color), qAlpha(color)); image.setPixel(i, j, negative); } } image.save(destFile);}
銳化
影象銳化的主要目的是增強影象邊緣,使模糊的影象變得更加清晰,顏色變得鮮明突出,影象的質量有所改善,產生更適合人眼觀察和識別的影象。
常見的銳化演算法有微分法和高通濾波法。微分法又以梯度銳化和拉普拉斯銳化較為常用。本示例採用微分法中的梯度銳化,用差分近似微分,則影象在點(i,j)處的梯度幅度計算公式如下:
G[f(i,j)] = abs(f(i,j) - f(i+1,j)) + abs(f(i,j) - f(i,j+1))
為了更好的增強影象邊緣,我們引入一個閾值,只有畫素點的梯度值大於閾值時才對該畫素點進行銳化,將畫素點的 R 、 G、 B 值設定為對應的梯度值與一個常數之和。常數值的選取應當參考影象的具體特點。我們的示例為簡單起見,將常數設定為 100 ,梯度閾值取 80 ,寫死在演算法函式中,更好的做法是通過引數傳入,以便客戶程式可以調整這些變數來觀察效果。好啦,看程式碼:
static void _sharpen(QString sourceFile, QString destFile){ QImage image(sourceFile); if(image.isNull()) { qDebug() << "load " << sourceFile << " failed! "; return; } int width = image.width(); int height = image.height(); int threshold = 80; QImage sharpen(width, height, QImage::Format_ARGB32); int r, g, b, gradientR, gradientG, gradientB; QRgb rgb00, rgb01, rgb10; for(int i = 0; i < width; i++) { for(int j= 0; j < height; j++) { if(image.valid(i, j) && image.valid(i+1, j) && image.valid(i, j+1)) { rgb00 = image.pixel(i, j); rgb01 = image.pixel(i, j+1); rgb10 = image.pixel(i+1, j); r = qRed(rgb00); g = qGreen(rgb00); b = qBlue(rgb00); gradientR = abs(r - qRed(rgb01)) + abs(r - qRed(rgb10)); gradientG = abs(g - qGreen(rgb01)) + abs(g - qGreen(rgb10)); gradientB = abs(b - qBlue(rgb01)) + abs(b - qBlue(rgb10)); if(gradientR > threshold) { r = qMin(gradientR + 100, 255); } if(gradientG > threshold) { g = qMin( gradientG + 100, 255); } if(gradientB > threshold) { b = qMin( gradientB + 100, 255); } sharpen.setPixel(i, j, qRgb(r, g, b)); } } } sharpen.save(destFile);}
示例用到的影象處理演算法和 Qt 程式碼實現已經介紹完畢,您看得累嗎?累就對了,舒服是留給死人的。擦,睡著了,我……
原始碼情景分析
上一節介紹了影象特效演算法,現在我們先看應用與管理這些特效的 C++ 類 ImageProcessor ,然後再來看 QML 程式碼。
ImageProcessor
在設計 ImageProcessor 類時,我希望它能夠在 QML 環境中使用,因此實用了訊號、槽、 Q_ENUMS 、 Q_PROPERTY 等特性,感興趣的話請參考《Qt Quick 之 QML 與 C++ 混合程式設計詳解》進一步熟悉。
先看 imageProcessor.h :
#ifndef IMAGEPROCESSOR_H#define IMAGEPROCESSOR_H#include <QObject>#include <QString>class ImageProcessorPrivate;class ImageProcessor : public QObject{ Q_OBJECT Q_ENUMS(ImageAlgorithm) Q_PROPERTY(QString sourceFile READ sourceFile) Q_PROPERTY(ImageAlgorithm algorithm READ algorithm)public: ImageProcessor(QObject *parent = 0); ~ImageProcessor(); enum ImageAlgorithm{ Gray = 0, Binarize, Negative, Emboss, Sharpen, Soften, AlgorithmCount }; QString sourceFile() const; ImageAlgorithm algorithm() const; void setTempPath(QString tempPath);signals: void finished(QString newFile); void progress(int value);public slots: void process(QString file, ImageAlgorithm algorithm); void abort(QString file, ImageAlgorithm algorithm); void abortAll();private: ImageProcessorPrivate *m_d;};#endif
ImageProcessor 類的宣告比較簡單,它通過 finished() 訊號通知關注者影象處理完畢,提供 process() 方法供客戶程式呼叫,還有 setTempPath() 設定臨時目錄,也允許你取消待執行的任務……下面是實現檔案 imageProcessor.cpp :
#include "imageProcessor.h"#include <QThreadPool>#include <QList>#include <QFile>#include <QFileInfo>#include <QRunnable>#include <QEvent>#include <QCoreApplication>#include <QPointer>#include <QUrl>#include <QImage>#include <QDebug>#include <QDir>typedef void (*AlgorithmFunction)(QString sourceFile, QString destFile);class AlgorithmRunnable;class ExcutedEvent : public QEvent{public: ExcutedEvent(AlgorithmRunnable *r) : QEvent(evType()), m_runnable(r) { } AlgorithmRunnable *m_runnable; static QEvent::Type evType() { if(s_evType == QEvent::None) { s_evType = (QEvent::Type)registerEventType(); } return s_evType; }private: static QEvent::Type s_evType;};QEvent::Type ExcutedEvent::s_evType = QEvent::None;static void _gray(QString sourceFile, QString destFile);static void _binarize(QString sourceFile, QString destFile);static void _negative(QString sourceFile, QString destFile);static void _emboss(QString sourceFile, QString destFile);static void _sharpen(QString sourceFile, QString destFile);static void _soften(QString sourceFile, QString destFile);static AlgorithmFunction g_functions[ImageProcessor::AlgorithmCount] = { _gray, _binarize, _negative, _emboss, _sharpen, _soften};class AlgorithmRunnable : public QRunnable{public: AlgorithmRunnable( QString sourceFile, QString destFile, ImageProcessor::ImageAlgorithm algorithm, QObject * observer) : m_observer(observer) , m_sourceFilePath(sourceFile) , m_destFilePath(destFile) , m_algorithm(algorithm) { } ~AlgorithmRunnable(){} void run() { g_functions[m_algorithm](m_sourceFilePath, m_destFilePath); QCoreApplication::postEvent(m_observer, new ExcutedEvent(this)); } QPointer<QObject> m_observer; QString m_sourceFilePath; QString m_destFilePath; ImageProcessor::ImageAlgorithm m_algorithm;};class ImageProcessorPrivate : public QObject{public: ImageProcessorPrivate(ImageProcessor *processor) : QObject(processor), m_processor(processor), m_tempPath(QDir::currentPath()) { ExcutedEvent::evType(); } ~ImageProcessorPrivate() { } bool event(QEvent * e) { if(e->type() == ExcutedEvent::evType()) { ExcutedEvent *ee = (ExcutedEvent*)e; if(m_runnables.contains(ee->m_runnable)) { m_notifiedAlgorithm = ee->m_runnable->m_algorithm; m_notifiedSourceFile = ee->m_runnable->m_sourceFilePath; emit m_processor->finished(ee->m_runnable->m_destFilePath); m_runnables.removeOne(ee->m_runnable); } delete ee->m_runnable; return true; } return QObject::event(e); } void process(QString sourceFile, ImageProcessor::ImageAlgorithm algorithm) { QFileInfo fi(sourceFile); QString destFile = QString("%1/%2_%3").arg(m_tempPath) .arg((int)algorithm).arg(fi.fileName()); AlgorithmRunnable *r = new AlgorithmRunnable(sourceFile, destFile, algorithm, this); m_runnables.append(r); r->setAutoDelete(false); QThreadPool::globalInstance()->start(r); } ImageProcessor * m_processor; QList<AlgorithmRunnable*> m_runnables; QString m_notifiedSourceFile; ImageProcessor::ImageAlgorithm m_notifiedAlgorithm; QString m_tempPath;};ImageProcessor::ImageProcessor(QObject *parent) : QObject(parent) , m_d(new ImageProcessorPrivate(this)){}ImageProcessor::~ImageProcessor(){ delete m_d;}QString ImageProcessor::sourceFile() const{ return m_d->m_notifiedSourceFile;}ImageProcessor::ImageAlgorithm ImageProcessor::algorithm() const{ return m_d->m_notifiedAlgorithm;}void ImageProcessor::setTempPath(QString tempPath){ m_d->m_tempPath = tempPath;}void ImageProcessor::process(QString file, ImageAlgorithm algorithm){ m_d->process(file, algorithm);}void ImageProcessor::abort(QString file, ImageAlgorithm algorithm){ int size = m_d->m_runnables.size(); AlgorithmRunnable *r; for(int i = 0; i < size; i++) { r = m_d->m_runnables.at(i); if(r->m_sourceFilePath == file && r->m_algorithm == algorithm) { m_d->m_runnables.removeAt(i); break; } }}
為避免阻塞 UI 執行緒,我把影象處理部分放到執行緒池內完成,根據 QThreadPool 的要求,從 QRunnable 繼承,實現了 AlgorithmRunnable ,當 run() 函式執行完時傳送自定義的 ExecutedEvent 給 ImageProcessor ,而 ImageProcessor 就在處理事件時發出 finished() 訊號。關於 QThreadPool 和自定義事件,請參考 Qt 幫助瞭解詳情。演算法函式放在一個全域性的函式指標陣列中, AlgorithmRunnable 則根據演算法列舉值從陣列中取出相應的函式來處理影象。
其它的程式碼一看即可明白,不再多說。
要想在 QML 中實用 ImageProcessor 類,需要匯出一個 QML 型別。這個工作是在 main() 函式中完成的。
main() 函式
main() 函式就在 main.cpp 中,下面是 main.cpp 的全部程式碼:
#include <QApplication>#include "qtquick2applicationviewer.h"#include <QtQml>#include "imageProcessor.h"#include <QQuickItem>#include <QDebug>int main(int argc, char *argv[]){ QApplication app(argc, argv); qmlRegisterType<ImageProcessor>("an.qt.ImageProcessor", 1, 0,"ImageProcessor"); QtQuick2ApplicationViewer viewer; viewer.rootContext()->setContextProperty("imageProcessor", new ImageProcessor); viewer.setMainQmlFile(QStringLiteral("qml/imageProcessor/main.qml")); viewer.showExpanded(); return app.exec();}
我使用 qmlRegisterType() 註冊了 ImageProcessor 類,包名是 an.qt.ImageProcessor ,版本是 1.0 ,所以你在稍後的 main.qml 文件中可以看到下面的匯入語句:import an.qt.ImageProcessor 1.0
上了賊船,就跟賊走,是時候看看 main.qml 了 。main.qml
main.qml 還是比較長的哈,有 194 行程式碼:
import QtQuick 2.2import QtQuick.Controls 1.1import QtQuick.Dialogs 1.1import an.qt.ImageProcessor 1.0import QtQuick.Controls.Styles 1.1Rectangle { width: 640; height: 480; color: "#121212"; BusyIndicator { id: busy; running: false; anchors.centerIn: parent; z: 2; } Label { id: stateLabel; visible: false; anchors.centerIn: parent; } Image { objectName: "imageViewer"; id: imageViewer; asynchronous: true; anchors.fill: parent; fillMode: Image.PreserveAspectFit; onStatusChanged: { if (imageViewer.status === Image.Loading) { busy.running = true; stateLabel.visible = false; } else if(imageViewer.status === Image.Ready){ busy.running = false; } else if(imageViewer.status === Image.Error){ busy.running = false; stateLabel.visible = true; stateLabel.text = "ERROR"; } } } ImageProcessor { id: processor; onFinished: { imageViewer.source = "file:///" +newFile; } } FileDialog { id: fileDialog; title: "Please choose a file"; nameFilters: ["Image Files (*.jpg *.png *.gif)"]; onAccepted: { console.log(fileDialog.fileUrl); imageViewer.source = fileDialog.fileUrl; } } Component{ id: btnStyle; ButtonStyle { background: Rectangle { implicitWidth: 70 implicitHeight: 25 border.width: control.pressed ? 2 : 1 border.color: (control.pressed || control.hovered) ? "#00A060" : "#888888" radius: 6 gradient: Gradient { GradientStop { position: 0 ; color: control.pressed ? "#cccccc" : "#e0e0e0" } GradientStop { position: 1 ; color: control.pressed ? "#aaa" : "#ccc" } } } } } Button { id: openFile; text: "開啟"; anchors.left: parent.left; anchors.leftMargin: 6; anchors.top: parent.top; anchors.topMargin: 6; onClicked: { fileDialog.visible = true; } style: btnStyle; z: 1; } Button { id: quit; text: "退出"; anchors.left: openFile.right; anchors.leftMargin: 4; anchors.bottom: openFile.bottom; onClicked: { Qt.quit() } style: btnStyle; z: 1; } Rectangle { anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: openFile.bottom; anchors.bottomMargin: -6; anchors.right: quit.right; anchors.rightMargin: -6; color: "#404040"; opacity: 0.7; } Grid { id: op; anchors.left: parent.left; anchors.leftMargin: 4; anchors.bottom: parent.bottom; anchors.bottomMargin: 4; rows: 2; columns: 3; rowSpacing: 4; columnSpacing: 4; z: 1; Button { text: "柔化"; style: btnStyle; onClicked: { busy.running = true; processor.process(fileDialog.fileUrl, ImageProcessor.Soften); } } Button { text: "灰度"; style: btnStyle; onClicked: { busy.running = true; processor.process(fileDialog.fileUrl, ImageProcessor.Gray); } } Button { text: "浮雕"; style: btnStyle; onClicked: { busy.running = true; processor.process(fileDialog.fileUrl, ImageProcessor.Emboss); } } Button { text: "黑白"; style: btnStyle; onClicked: { busy.running = true; processor.process(fileDialog.fileUrl, ImageProcessor.Binarize); } } Button { text: "底片"; style: btnStyle; onClicked: { busy.running = true; processor.process(fileDialog.fileUrl, ImageProcessor.Negative); } } Button { text: "銳化"; style: btnStyle; onClicked: { busy.running = true; processor.process(fileDialog.fileUrl, ImageProcessor.Sharpen); } } } Rectangle { anchors.left: parent.left; anchors.top: op.top; anchors.topMargin: -4; anchors.bottom: parent.bottom; anchors.right: op.right; anchors.rightMargin: -4; color: "#404040"; opacity: 0.7; }}
圖片的顯示使用一個充滿視窗的 Image 物件,在 onStatusChanged 訊號處理器中控制載入提示物件 BusyIndicator 是否顯示。我通過 Z 序來保證 busy 總是在 imageViewer 上面。你看到了,我像使用 QML 內建物件那樣使用了 ImageProcessor 物件,為它的 finished 訊號定義了 onFinished 訊號處理器,在訊號處理器中把應用影象特效後的中間檔案傳遞給 imageViewer 來顯示。
介面佈局比較簡陋,開啟和退出兩個按鈕放在左上角,使用錨佈局。關於錨佈局,請參考《Qt Quick 佈局介紹》或《Qt Quick 簡單教程》。影象處理的 6 個按鈕使用 Grid 定位器來管理, 2 行 3 列,放在介面左下角。 Grid 定位器的使用請參考《Qt Quick 佈局介紹》。
關於影象處理按鈕,以黑白特效做下說明,在 onClicked 訊號處理器中,呼叫 processor 的 process() 方法,傳入本地圖片路徑和特效演算法。當特效運算非同步完成後,就會觸發 finished 訊號,進而 imageViewer 會更新……
好啦好啦,我們的影象處理例項就到此為止了。秒懂?
例項專案及原始碼下載:點這裡點這裡。需要一點積分啊親。
回顧一下吧:
本文寫作過程中參考了下列文章,特此感謝:
- winorlose2000 部落格( http://vaero.blog.51cto.com/ )中關於影象處理演算法的博文
- ian 的個人部落格( http://www.icodelogic.com/ )中關於影象處理演算法的博文