圖形影象處理 - 手寫 QQ 說說圖片處理效果
OpenCv 的基礎學習目前先告一段落了,後面我們要開始手寫一些常用的效果了,且都是基於 Android 平臺的。希望我們有一定的 C++ 和 JNI 基礎,如果我們對這塊知識有所欠缺,大家不妨看看這個: Android進階之旅(JNI基礎實戰)
我們可能會忍不住問,做 android 應用層開發,學習圖形影象處理到底有啥好處?首先不知我們是否有在 Glide 中有看到像這樣的原始碼:
private static final int GIF_HEADER = 0x474946; private static final int PNG_HEADER = 0x89504E47; static final int EXIF_MAGIC_NUMBER = 0xFFD8; @NonNull private ImageType getType(Reader reader) throws IOException { final int firstTwoBytes = reader.getUInt16(); // JPEG. if (firstTwoBytes == EXIF_MAGIC_NUMBER) { return JPEG; } final int firstFourBytes = (firstTwoBytes << 16 & 0xFFFF0000) | (reader.getUInt16() & 0xFFFF); // PNG. if (firstFourBytes == PNG_HEADER) { // See: http://stackoverflow.com/questions/2057923/how-to-check-a-png-for-grayscale-alpha // -color-type reader.skip(25 - 4); int alpha = reader.getByte(); // A RGB indexed PNG can also have transparency. Better safe than sorry! return alpha >= 3 ? PNG_A : PNG; } // GIF from first 3 bytes. if (firstFourBytes >> 8 == GIF_HEADER) { return GIF; } // ....... 省略部分程式碼 return ImageType.WEBP; }
其次學習 opencv 不能只停留在其 api 的呼叫上,我們必須瞭解其內部的實現的原理,最好還要能手寫實現。最後學習影象圖形處理,也有利於我們後面學習音視訊的開發,能夠幫助我們更加熟悉 NDK 開發,包括我們自己去閱讀 android native 層的原始碼等等,總之好處還是有很多的。
接下來我們就以 QQ 發說說處理圖片的效果為例,來手寫實現部分效果。有些效果在之前的文章中已有講到,這裡就不再給程式碼了,我們可以參考: 《圖形影象處理 - Android 濾鏡效果》 搭建 android ndk 開發環境和整合 opencv 大家可以參考: 《NDK開發前奏 - 實現支付寶人臉識別功能》 。
1. 逆世界和映象
againstWorld(JNIEnv *env, jclass type, jobject bitmap) { // bitmap -> mat Mat src; cv_helper::bitmap2mat(env, bitmap, src); // 二分之一的位置 const int middleRows = src.rows >> 1; // 四分之一的位置 const int quarterRows = middleRows >> 1; Mat res(src.size(), src.type()); // 處理下半部分 for (int rows = 0; rows < middleRows; ++rows) { for (int cols = 0; cols < src.cols; ++cols) { res.at<int>(middleRows + rows, cols) = src.at<int>(quarterRows + rows, cols); } } // 處理上半部分 for (int rows = 0; rows < middleRows; ++rows) { for (int cols = 0; cols < src.cols; ++cols) { res.at<int>(rows, cols) = src.at<int>(src.rows - quarterRows - rows, cols); } } // mat -> bitmap cv_helper::mat2bitmap(env, res, bitmap); return bitmap; }
2. remap 重對映
void remap(Mat &src, Mat &dst, Mat &mapX, Mat &mapY) { // 有一系列的檢測 dst.create(src.size(), src.type()); for (int rows = 0; rows < dst.rows; ++rows) { for (int cols = 0; cols < dst.cols; ++cols) { int r_rows = mapY.at<int>(rows, cols); int r_cols = mapX.at<int>(rows, cols); dst.at<Vec4b>(rows, cols) = src.at<Vec4b>(r_rows, r_cols); } } } remap(JNIEnv *env, jclass type, jobject bitmap) { // bitmap -> mat Mat src; cv_helper::bitmap2mat(env, bitmap, src); Mat res; Mat mapX(src.size(), src.type()); Mat mapY(src.size(), src.type()); for (int rows = 0; rows < src.rows; ++rows) { for (int cols = 0; cols < src.cols; ++cols) { mapX.at<int>(rows, cols) = src.cols - cols; mapY.at<int>(rows, cols) = src.rows - rows; } } remap(src, res, mapX, mapY); // mat -> bitmap cv_helper::mat2bitmap(env, res, bitmap); return bitmap; }
3. resize 插值法
我們經常會將某種尺寸的影象轉換為其他尺寸的影象,如果放大或者縮小圖片的尺寸,籠統來說的話,可以使用OpenCV為我們提供的如下兩種方式:
- resize函式。這是最直接的方式,
- pyrUp( )、pyrDown( )函式。即影象金字塔相關的兩個函式,對影象進行向上取樣,向下取樣的操作。
pyrUp、pyrDown 其實和專門用作放大縮小影象尺寸的 resize 在功能上差不多,披著影象金字塔的皮,說白了還是在對影象進行放大和縮小操作。另外需要指出的是,pyrUp、pyrDown 在 OpenCV 的 imgproc 模組中的 Image Filtering 子模組裡。而 resize 在 imgproc 模組的 Geometric Image Transformations 子模組裡。關於 pyrUp、pyrDown 在 opencv 基礎學習中已有詳細介紹,這裡就不再反覆了。
resize( ) 為 OpenCV 中專職調整影象大小的函式。此函式將源影象精確地轉換為指定尺寸的目標影象。如果源影象中設定了 ROI(Region Of Interest ,感興趣區域),那麼 resize( ) 函式會對源影象的 ROI 區域進行調整影象尺寸的操作,來輸出到目標影象中。若目標影象中已經設定 ROI 區域,不難理解 resize( ) 將會對源影象進行尺寸調整並填充到目標影象的 ROI 中。很多時候,我們並不用考慮第二個引數dst的初始影象尺寸和型別(即直接定義一個Mat型別,不用對其初始化),因為其尺寸和型別可以由 src,dsize,fx 和 fy 這其他的幾個引數來確定。可選的方式為:
- INTER_NEAREST - 最近鄰插值
- INTER_LINEAR - 線性插值(預設值)
- INTER_AREA - 區域插值(利用畫素區域關係的重取樣插值)
- INTER_CUBIC –三次樣條插值(超過4×4畫素鄰域內的雙三次插值)
- INTER_LANCZOS4 -Lanczos插值(超過8×8畫素鄰域的Lanczos插值)
-
最近鄰插值
最簡單的影象縮放演算法就是最近鄰插值。顧名思義,就是將目標影象各點的畫素值設為源影象中與其最近的點。演算法優點在與簡單、速度快。
如下圖所示,一個4 4的圖片縮放為8 8的圖片。步驟:
- 生成一張空白的8*8的圖片,然後在縮放位置填充原始圖片值(可以這麼理解)
-
在圖片的未填充區域(黑色部分),填充為原有圖片最近的位置的畫素值。
最近鄰插值
void resize(Mat src, Mat dst, int nH, int nW) { dst.create(nH, nW, src.type()); int oH = src.rows; int oW = src.cols; for (int rows = 0; rows < dst.rows; ++rows) { for (int cols = 0; cols < dst.cols; ++cols) { int nR = rows * (nH / oH); int nC = cols * (nW / oW); dst.at<Vec4b>(rows, cols) = src.at<Vec4b>(nR, nC); } } }
- 雙線性插值法
如果原始影象src的大小是3×3,目標影象dst的大小是4×4,考慮dst中(1,1)點畫素對應原始影象畫素點的位置為(0.75,0.75),如果使用最近鄰演算法來計算,原始影象的位置在浮點數取整後為座標(0,0)。
上面這樣粗暴的計算會丟失很多資訊,考慮(0.75,0.75)這個資訊,它表示在原始影象中的座標位置,相比較取(0,0)點,(0.75,0.75)貌似更接近(1,1)點,那如果將最近鄰演算法中的取整方式改為cvRound(四捨五入)的方式取(1,1)點,同樣會有丟的資訊,即丟失了“0.25”部分的(0,0)點、(1,0)點和(0,1)點。
可以看到,dst影象上(X,Y)對應到src影象上的點,最好是根據計算出的浮點數座標,按照百分比各取四周的畫素點的部分。
如下圖:

雙線性插值的原理相類似,這裡不寫雙線性插值計算點座標的方法,容易把思路帶跑偏,直接就按照比率權重的思想考慮。將 ( wWX , hHY ) ( wWX , hHY ) 寫成 ( x′ + u , y′ + v ) ( x′ + u , y′ + v ) 的形式,表示將 xx 與yy 中的整數和小數分開表示 uvuv 分別代表小數部分。這樣,根據權重比率的思想得到計算公式
(X,Y) = (1 − u) · (1 − v) · (x , y) + (u − 1) · v · ( x , y + 1) + u · (v − 1) · (x + 1 , y) + (u · v) · (x , y)
在實際的程式碼編寫中,會有兩個問題,一個是影象會發生偏移,另一個是效率問題。
幾何中心對齊:
由於計算的影象是離散座標系,如果使用 (wWX , hHY) (wWX , hHY) 公式來計算,得到的 (X , Y) 值是錯誤的比率計算而來的,即 (x + 1 , y)、(x , y + 1)、(x + 1 , y + 1)這三組點中,有可能有幾個沒有參與到比率運算當中,或者這個插值的比率直接是錯誤的。 例如,src 影象大小是 a×aa×a,dst 影象的大小是 0.5a×0.5a0.5a×0.5a。
根據原始公式計算(wW , hH) (wW , hH)得到(2 , 2)(2 , 2)(注意這不是表示點座標,而是 x 和 y 對應的比率)如果要計算 dst 點 (0 , 0) 對應的插值結果,由於 (2 , 2) (2 , 2) 是整數,沒有小數,所以最後得到 dst 點在 (0 , 0) (0 , 0) 點的畫素值就是src影象上在 (0,0)(0,0)點的值。然而,我們想要的 dst 在 (0,0)(0,0)上的結果是應該是有 (0 , 0) (1 , 0) (0 , 1) (1 , 1) 這四個點各自按照 0.5×0.5 的比率加權的結果。 所以我們要將 dst 上面的點,按照比率 (wW , hH) ( wW , hH) 向右下方向平移0.5個單位。
公式如下:
(x , y) = (XwW + 0.5(wW − 1),YhH + 0.5(hH − 1))
(x , y) = (XwW + 0.5(wW − 1),YhH + 0.5(hH − 1))
運算優化:由計算公式可以得知,在計算每一個dst影象中的畫素值時會涉及到大量的浮點數運算,效能不佳。可以考慮將浮點數變換成一個整數,即擴大一定的倍數,運算得到的結果再除以這個倍數。舉一個簡單的例子,計算 0.25×0.75,可以將 0.25 和 0.75 都乘上 8,得到 2×6=12,結果再除以 8282,這樣運算的結果與直接計算浮點數沒有差別。
在程式中,沒有辦法取得一個標準的整數,使得兩個相互運算的浮點數都變成類似“2”和”6“一樣的標準整數,只能取一個適當的值來儘量的減少誤差,在原始碼當中取值為 211211=2048,即 2 的固定冪數,最後結果可以通過用位移來表示除以一個 2 整次冪數,計算速度會有很大的提高。
//雙線性插值 void resize(const Mat &src, Mat &dst, Size &dsize, double fx = 0.0, double fy = 0.0){ //獲取矩陣大小 Size ssize = src.size(); //保證矩陣的長寬都大於0 CV_Assert(ssize.area() > 0); //如果dsize為(0,0) if (!dsize.area()) { //satureate_cast防止資料溢位 dsize = Size(saturate_cast<int>(src.cols * fx), saturate_cast<int>(src.rows * fy)); CV_Assert(dsize.area()); } else { //Size中的寬高和mat中的行列是相反的 fx = (double) dsize.width / src.cols; fy = (double) dsize.height / src.rows; } dst.create(dsize, src.type()); double ifx = 1. / fx; double ify = 1. / fy; uchar *dp = dst.data; uchar *sp = src.data; //寬(列數) int iWidthSrc = src.cols; //高(行數) int iHiehgtSrc = src.rows; int channels = src.channels(); short cbufy[2]; short cbufx[2]; for (int row = 0; row < dst.rows; row++) { float fy = (float) ((row + 0.5) * ify - 0.5); //整數部分 int sy = cvFloor(fy); //小數部分 fy -= sy; sy = std::min(sy, iHiehgtSrc - 2); sy = std::max(0, sy); cbufy[0] = cv::saturate_cast<short>((1.f - fy) * 2048); cbufy[1] = 2048 - cbufy[0]; for (int col = 0; col < dst.cols; col++) { float fx = (float) ((col + 0.5) * ifx - 0.5); int sx = cvFloor(fx); fx -= sx; if (sx < 0) { fx = 0, sx = 0; } if (sx >= iWidthSrc - 1) { fx = 0, sx = iWidthSrc - 2; } cbufx[0] = cv::saturate_cast<short>((1.f - fx) * 2048); cbufx[1] = 2048 - cbufx[0]; for (int k = 0; k < src.channels(); ++k) { dp[(row * dst.cols + col) * channels + k] = ( sp[(sy * src.cols + sx) * channels + k] * cbufx[0] * cbufy[0] + sp[((sy + 1) * src.cols + sx) * channels + k] * cbufx[0] * cbufy[1] + sp[(sy * src.cols + (sx + 1)) * channels + k] * cbufx[1] * cbufy[0] + sp[((sy + 1) * src.cols + (sx + 1)) * channels + k] * cbufx[1] * cbufy[1] ) >> 22; } } } }
視訊連結地址:週六晚八點