相信很多人手機里都裝了個“掃描全能王”APP,平時可以用它來可以掃描一些證件、文本,確實很好用,第一次用的時候確實感覺功能很強大啊算法很牛逼啊。但是仔細一想,其實這些實現起來也是很簡單的,我想了下,實現的步驟應該就只有下面三個:
- 將證件輪廓找到
- 提取證件矩形輪廓四點進行透視變換
- 二值化
知道原理之后,我馬上利用強大的opencv開發一個類似“全能掃描王”掃描工具。
整理一下我們要制作的這個掃描工具有哪些功能:
- 圖像的信息區域的提取與矯正
- 圖像的二值化
- 銳化和增強
第二第三點都非常簡單,那么制作這個工具的難點完全落在了第一點“ 圖像的信息區域的提取與矯正”上了。在編碼實現的過程中,確實有很多坑需要踩一踩。
我們先展示一下效果,我們有這么一個用手機拍攝的圖片
經過掃描工具一番處理后變成這樣子。也就是說,我們將原圖中的那個文件摳了了出來,並且完成矯正。
實現過程查閱了大量資料,也看了網上很多類似的博客,前輩們實現過相類似的透視變換的代碼,但是他們的代碼實現的都不理想,很多圖片根本沒法檢測。不過還是可以從前人的經驗中獲取到很多好想法的,所以先列出一些有借鑒的博客:
http://www.pyimagesearch.com/2014/09/01/build-kick-ass-mobile-document-scanner-just-5-minutes/
http://www.pyimagesearch.com/2014/09/01/build-kick-ass-mobile-document-scanner-just-5-minutes/
正式實現
第一步,二值化+高斯濾波+膨脹+canny邊緣提取
一開始我是沒有采取形態學處理的,僅僅是二值化+高斯濾波+canny邊緣提取的策略,但是實際運行下效果並不好,原因在於有一些圖片的信息區域輪廓沒法閉合,這就導致了信息區域輪廓沒法提取。但是加入適當的膨脹后,效果就好多了。
Mat src = imread("1.png");
imshow("src img", src);
Mat source = src.clone();
Mat bkup = src.clone();
Mat img = src.clone();
cvtColor(img, img, CV_RGB2GRAY); //二值化
imshow("gray", img);
//equalizeHist(img, img);
//imshow("equal", img);
GaussianBlur(img, img, Size(5, 5), 0, 0); //高斯濾波
//獲取自定義核
Mat element = getStructuringElement(MORPH_RECT, Size(3, 3)); //第一個參數MORPH_RECT表示矩形的卷積核,當然還可以選擇橢圓形的、交叉型的
//膨脹操作
dilate(img, img, element); //實現過程中發現,適當的膨脹很重要
imshow("dilate", img);
Canny(img, img, 30, 120, 3); //邊緣提取
imshow("get contour", img);
}
輪廓提取效果如下:
第二步,輪廓查找並篩選
一般情況下,我們提取到的輪廓不會像上圖那樣的干凈,而是帶有很多干擾項輪廓,如果我們不能很好的剔除這些輪廓,我們根本沒法找出我們想要的信息區域。我篩選輪廓的方法很簡單,就是找出一張圖片中面積最大的那個輪廓作為我們的信息區域輪廓,這招真是屢試不爽,因為根據我們日常經驗,我們對一張證件或者文件性掃描拍攝,證件區域占整張圖片的面積肯定是最大的。
vector<vector<Point> > contours;
vector<vector<Point> > f_contours;
std::vector<cv::Point> approx2;
//注意第5個參數為CV_RETR_EXTERNAL,只檢索外框
findContours(img, f_contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE); //找輪廓
//求出面積最大的輪廓
int max_area = 0;
int index;
for (int i = 0; i < f_contours.size(); i++)
{
double tmparea = fabs(contourArea(f_contours[i]));
if (tmparea > max_area)
{
index = i;
max_area = tmparea;
}
}
contours.push_back(f_contours[index]);
篩選出我們所需的輪廓
第三步,找出這個四邊形輪廓的四個頂點
因為我們需要輪廓的四個頂點坐標來實現透視變換,現在的問題來了:我們怎么利用這個四邊形輪廓的點集來找出初四邊形的四個頂點?
這真是一個大難題!
一開始我的想法是這樣子的:直接從四邊形點集中篩選出四個定點(比如x坐標最大的那個坐標肯定是四邊形右上角坐標或者右下角坐標,x坐標最小的那個坐標肯定是左上角或者下角的那個坐標,如此類推),但是這種想法實現起來是很有問題的而因為它很受限於四邊形的姿態,所以一個思路一直沒法進行下去。如果大家有僅依賴四邊形點集就能找出四邊形的四個頂點坐標的方法,請告訴我,我們一同探討。
所以我切換了另外一個思路:基於直線交點的思路。我們首先使用霍夫變換找出四邊形的邊,然后求兩兩直線的交點不就是四邊形的定點嗎?的確是這樣子的,但是實際操作起來也是問題多多啊。
最大的問題就是,我們怎么保證我們使用霍夫變換找到的直線剛好就是形成四邊形的四條直線?
所以我們就必須不斷地去改變霍夫變換的參數,不斷迭代,來求出一個可以形成四邊形的直線情況。
那什么情況的直線我們不能接受?
- 兩兩直線過於接近我們排除
- 兩兩直線沒有交點我們排除
- 檢測出來的直線數目不是4條我們排除
如果找到了滿足條件的四條直線,我們就可以去計算他們的交點了。算法如下:
cv::Point2f computeIntersect(cv::Vec4i a, cv::Vec4i b)
{
int x1 = a[0], y1 = a[1], x2 = a[2], y2 = a[3];
int x3 = b[0], y3 = b[1], x4 = b[2], y4 = b[3];
if (float d = ((float)(x1 - x2) * (y3 - y4)) - ((y1 - y2) * (x3 - x4)))
{
cv::Point2f pt;
pt.x = ((x1*y2 - y1*x2) * (x3 - x4) - (x1 - x2) * (x3*y4 - y3*x4)) / d;
pt.y = ((x1*y2 - y1*x2) * (y3 - y4) - (y1 - y2) * (x3*y4 - y3*x4)) / d;
return pt;
}
else
return cv::Point2f(-1, -1);
}
計算出四個交點后,我們不能完全信任他們就是我們要找的四個頂點,所以繼續篩選:
- 如果兩兩定點的距離過近,我們排除
bool IsGoodPoints = true;
//保證點與點的距離足夠大以排除錯誤點
for (int i = 0; i < corners.size(); i++)
{
for (int j = i + 1; j < corners.size(); j++)
{
int distance = sqrt((corners[i].x - corners[j].x)*(corners[i].x - corners[j].x) + (corners[i].y - corners[j].y)*(corners[i].y - corners[j].y));
if (distance < 5)
{
IsGoodPoints = false;
}
}
}
if (!IsGoodPoints) continue;
- 如果這四個點構成不了四邊形我們排除
cv::approxPolyDP(cv::Mat(corners), approx, cv::arcLength(cv::Mat(corners), true) * 0.02, true);
if (lines.size() == 4 && corners.size() == 4 && approx.size() == 4)
{
flag = 1;
break;
}
如果都通過以上篩選條件的,我們就可以認為他們就是我們找的那四個頂點,這時我們就可以停止迭代,進行頂點排序,即確定這四個頂點哪個是左上角點,哪個又是右下點。
算法如下:
bool x_sort(const Point2f & m1, const Point2f & m2)
{
return m1.x < m2.x;
}
//確定四個點的中心線
void sortCorners(std::vector<cv::Point2f>& corners,
cv::Point2f center)
{
std::vector<cv::Point2f> top, bot;
vector<Point2f> backup = corners;
sort(corners, x_sort); //注意先按x的大小給4個點排序
for (int i = 0; i < corners.size(); i++)
{
if (corners[i].y < center.y && top.size() < 2) //這里的小於2是為了避免三個頂點都在top的情況
top.push_back(corners[i]);
else
bot.push_back(corners[i]);
}
corners.clear();
if (top.size() == 2 && bot.size() == 2)
{
//cout << "log" << endl;
cv::Point2f tl = top[0].x > top[1].x ? top[1] : top[0];
cv::Point2f tr = top[0].x > top[1].x ? top[0] : top[1];
cv::Point2f bl = bot[0].x > bot[1].x ? bot[1] : bot[0];
cv::Point2f br = bot[0].x > bot[1].x ? bot[0] : bot[1];
corners.push_back(tl);
corners.push_back(tr);
corners.push_back(br);
corners.push_back(bl);
}
else
{
corners = backup;
}
}
第四步,四點法透射變換
我們拿到原圖信息區域四邊形的四個頂點,現在我們還需要變換后圖像的四個頂點才可以實現投射變換。
求變換后四個頂點坐標前我們還需要做的一件事就是,確定變換后的圖像尺寸。第一種方法就是人工指定,比如我直接規定好變換后的圖片大小是bbb*aaa。第二種方法就是,通過計算確定信息區域的尺寸,也就是說,信息區域有多大,我們變換后的圖像就有多大。
既然我們知道了四邊形的四個頂點了,那么我們可以直接求兩點的距離來確定四邊形的長寬。變換后的圖像高度寬度可以這么確定:
int g_dst_hight; //最終圖像的高度
int g_dst_width; //最終圖像的寬度
void CalcDstSize(const vector<cv::Point2f>& corners)
{
int h1 = sqrt((corners[0].x - corners[3].x)*(corners[0].x - corners[3].x) + (corners[0].y - corners[3].y)*(corners[0].y - corners[3].y));
int h2 = sqrt((corners[1].x - corners[2].x)*(corners[1].x - corners[2].x) + (corners[1].y - corners[2].y)*(corners[1].y - corners[2].y));
g_dst_hight = MAX(h1, h2);
int w1 = sqrt((corners[0].x - corners[1].x)*(corners[0].x - corners[1].x) + (corners[0].y - corners[1].y)*(corners[0].y - corners[1].y));
int w2 = sqrt((corners[2].x - corners[3].x)*(corners[2].x - corners[3].x) + (corners[2].y - corners[3].y)*(corners[2].y - corners[3].y));
g_dst_width = MAX(w1, w2);
}
透射變換:
cv::Mat quad = cv::Mat::zeros(g_dst_hight, g_dst_width, CV_8UC3);
std::vector<cv::Point2f> quad_pts;
quad_pts.push_back(cv::Point2f(0, 0));
quad_pts.push_back(cv::Point2f(quad.cols, 0));
quad_pts.push_back(cv::Point2f(quad.cols, quad.rows));
quad_pts.push_back(cv::Point2f(0, quad.rows));
cv::Mat transmtx = cv::getPerspectiveTransform(corners, quad_pts);
cv::warpPerspective(source, quad, transmtx, quad.size());
所有關鍵步驟都已經說明完畢,運行一下代碼,看看效果。
再拍一些其他圖片,看看處理效果
試一下帶有干擾背景的圖像,效果還是不錯的
額外效果:二值化
有些時候還需要將一些文本或者證件弄成掃描模樣,那我們就加入二值化實現該效果。
Mat local,gray;
cvtColor(quad, gray, CV_RGB2GRAY);
int blockSize = 25;
int constValue = 10;
adaptiveThreshold(gray, local, 255, CV_ADAPTIVE_THRESH_MEAN_C, CV_THRESH_BINARY, blockSize, constValue);
imshow("二值化", local);
二值化效果挺好的
完整代碼以及測試圖像可以在我的github上獲取。歡迎大家一起探討~