转载自蒙特卡洛家的树
视频讲解地址:https://www.bilibili.com/video/BV1yz411e7kQ
opencv从4代之后推出了二维码识别接口.调用方法是这样的.
import cv2 img = cv2.imread('data/qrcode.jpg') img = cv2.imread('qrcode/11.jpg') qrcode = cv2.QRCodeDetector() result, points, code = qrcode.detectAndDecode(img)返回值有三个,第一个result就是解码后的内容,例如我这个二维码的结果是"https://space.bilibili.com/37270391",当然也可以是个纯数字.第二个points是二维码轮廓的四个角,从左上角顺时针转的.第三个code是二维码的原始排列,也就是每个点是0还是255的一个矩阵.白色是255,黑色是0. 调用起来十分方便,而且如果不需要解码,只是想定位的话可以调用detect函数,返回结果就只有四个角点了.
它的源码放在"modules/objdetect/src/qrcode.cpp"文件里面,很容易就能找到.
直接看detect函数
bool QRCodeDetector::detect(InputArray in, OutputArray points) const { Mat inarr; if (!checkQRInputImage(in, inarr)) return false; QRDetect qrdet; qrdet.init(inarr, p->epsX, p->epsY); if (!qrdet.localization()) { return false; } if (!qrdet.computeTransformationPoints()) { return false; } vector<Point2f> pnts2f = qrdet.getTransformationPoints(); updatePointsResult(points, pnts2f); return true; }它第一步调用的是QRDetect类的localization函数 然后我们再看这个localization函数
我提取了一些关键部分,它首先调用了searchHorizontalLines这个函数,用来寻找符合角点轮廓要求的水平线段.然后利用这些线段,调用separateVerticalLines寻找垂直的线段.
这个函数先按行去找横线,所以这里是y从0到height_bin_barcode
pixels_position.clear(); const uint8_t *bin_barcode_row = bin_barcode.ptr<uint8_t>(y); int pos = 0; for (; pos < width_bin_barcode; pos++) { if (bin_barcode_row[pos] == 0) break; } if (pos == width_bin_barcode) { continue; } pixels_position.push_back(pos); pixels_position.push_back(pos); pixels_position.push_back(pos);然后初始化了接下来要用的pixels_position 接下来就是重点了,从左到右一个点一个点的判断
uint8_t future_pixel = 255; for (int x = pos; x < width_bin_barcode; x++) { if (bin_barcode_row[x] == future_pixel) { future_pixel = static_cast<uint8_t>(~future_pixel); pixels_position.push_back(x); } }首先设定下一个待搜索的像素是白色(255),然后从左到右找,一直到白色了就把这个位置记录下来,作为白线的起点,然后吧下一个待寻找的点改成黑色future_pixel = static_cast<uint8_t>(~future_pixel);,这样循环下来就能找到这一行所有的线段了. 然后判断一下这些线段长度是不是满足二维码角点轮廓的比例
pixels_position.push_back(width_bin_barcode - 1); for (size_t i = 2; i < pixels_position.size() - 4; i+=2) { test_lines[0] = static_cast<double>(pixels_position[i - 1] - pixels_position[i - 2]); test_lines[1] = static_cast<double>(pixels_position[i ] - pixels_position[i - 1]); test_lines[2] = static_cast<double>(pixels_position[i + 1] - pixels_position[i ]); test_lines[3] = static_cast<double>(pixels_position[i + 2] - pixels_position[i + 1]); test_lines[4] = static_cast<double>(pixels_position[i + 3] - pixels_position[i + 2]); double length = 0.0, weight = 0.0; // TODO avoid 'double' calculations for (size_t j = 0; j < test_lines_size; j++) { length += test_lines[j]; } if (length == 0) { continue; } for (size_t j = 0; j < test_lines_size; j++) { if (j != 2) { weight += fabs((test_lines[j] / length) - 1.0/7.0); } else { weight += fabs((test_lines[j] / length) - 3.0/7.0); } } if (weight < eps_vertical) { Vec3d line; line[0] = static_cast<double>(pixels_position[i - 2]); line[1] = y; line[2] = length; result.push_back(line); } }这里这个pixels_position为什么敢直接取到4呢,我们在回到上面看
int pos = 0; for (; pos < width_bin_barcode; pos++) { if (bin_barcode_row[pos] == 0) break; } if (pos == width_bin_barcode) { continue; } pixels_position.push_back(pos); pixels_position.push_back(pos); pixels_position.push_back(pos);他这里初始化的时候就直接推进去了3个0,最后又把右边界推进去了pixels_position.push_back(width_bin_barcode - 1); 所以他就可以从左往右把连续的5个线段拿出来判断了,滑动的方法就和一维的卷积一样,步长是1,宽度是5.
test_lines[0] = static_cast<double>(pixels_position[i - 1] - pixels_position[i - 2]); test_lines[1] = static_cast<double>(pixels_position[i ] - pixels_position[i - 1]); test_lines[2] = static_cast<double>(pixels_position[i + 1] - pixels_position[i ]); test_lines[3] = static_cast<double>(pixels_position[i + 2] - pixels_position[i + 1]); test_lines[4] = static_cast<double>(pixels_position[i + 3] - pixels_position[i + 2]);接下来就是判断这5条线的长度比例是不是满足,比例如下图 看这个图就很明显了,如果是中间位置的话,这个比例应该是1:1:3:1:1这5条线段,那么除了第3条,其他都是1,总长度为7,所以我们按照1/7,1/7,3/7,1/7,1/7这个比例来搜索,就是下面这个代码
double length = 0.0, weight = 0.0; // TODO avoid 'double' calculations for (size_t j = 0; j < test_lines_size; j++) { length += test_lines[j]; } if (length == 0) { continue; } for (size_t j = 0; j < test_lines_size; j++) { if (j != 2) { weight += fabs((test_lines[j] / length) - 1.0/7.0); } else { weight += fabs((test_lines[j] / length) - 3.0/7.0); } } if (weight < eps_vertical) { Vec3d line; line[0] = static_cast<double>(pixels_position[i - 2]); line[1] = y; line[2] = length; result.push_back(line); }这里就是我说的,判断了一下j是不是2,2的话就代表是中间那根,就只有它是3/7,其他都是1/7,然后用这个weight去+这个数是什么意思呢,我们可以看到,weight初始化的时候是0,然后每次加的是这个比例和1/7或者3/7的差,也就是加上了和我们期望想要的值的差,所以这个weight记录的就是误差和,那么我们最后判断一下累积的这个误差weight和我们设置的误差阈值eps_vertical比较,如果实际误差小于我们这个精度要求,那么就把这一段给记录下来,从起点的x,起点的y,到总长度都记录在了一个Vec3d类型的变量里面,然后添加到result这个数组里了. 这样,我们这个searchHorizontalLines函数就收集了所有满足这个比例要求的横线序列. 然后我们从这些横线里面找竖直方向也是这样的,把他们挑出来作为二维码角点的候选. 那就是调用separateVerticalLines这个函数了
这个函数就有意思了,先调用extractVerticalLines函数找到所有竖着符合要求的线,直接算出了横竖交叉的中心点,然后把这些中心点聚类成了3类,但是聚类结果没有用,只是判断了一下聚类成功了没有.
核心在extractVerticalLines这个函数里,我们直接去这里看
这个函数直接取了横线的中点,也就是想从正中心开始找
const int x = cvRound(list_lines[pnt][0] + list_lines[pnt][2] * 0.5); const int y = cvRound(list_lines[pnt][1]);如果从正中心出发,那么就是分为了上下两段,我们先从上往下找
test_lines.clear(); uint8_t future_pixel_up = 255; int temp_length_up = 0; for (int j = y; j < bin_barcode.rows - 1; j++) { uint8_t next_pixel = bin_barcode.ptr<uint8_t>(j + 1)[x]; temp_length_up++; if (next_pixel == future_pixel_up) { future_pixel_up = static_cast<uint8_t>(~future_pixel_up); test_lines.push_back(temp_length_up); temp_length_up = 0; if (test_lines.size() == 3) break; } }方法也是一样的,找到3段连续的截止,然后从下往上找
// --------------- Search vertical down-lines --------------- // int temp_length_down = 0; uint8_t future_pixel_down = 255; for (int j = y; j >= 1; j--) { uint8_t next_pixel = bin_barcode.ptr<uint8_t>(j - 1)[x]; temp_length_down++; if (next_pixel == future_pixel_down) { future_pixel_down = static_cast<uint8_t>(~future_pixel_down); test_lines.push_back(temp_length_down); temp_length_down = 0; if (test_lines.size() == 6) break; } }再找3段,加起来就是6段了就截止. 然后也是判断是不是符合那个比例
// --------------- Compute vertical lines --------------- // if (test_lines.size() == 6) { double length = 0.0, weight = 0.0; // TODO avoid 'double' calculations for (size_t i = 0; i < test_lines.size(); i++) length += test_lines[i]; CV_Assert(length > 0); for (size_t i = 0; i < test_lines.size(); i++) { if (i % 3 != 0) { weight += fabs((test_lines[i] / length) - 1.0/ 7.0); } else { weight += fabs((test_lines[i] / length) - 3.0/14.0); } } if (weight < eps) { result.push_back(list_lines[pnt]); } } }因为这次是从中间向上下两边发展,所以中间那一段就不是3/7了,而应该是两个3/14,因为是从中间出发的嘛,原本一条线被分成了两截. 最后把符合要求的中点都保存下来返回出去
vector<Point2f> point2f_result; if (result.size() > 2) { for (size_t i = 0; i < result.size(); i++) { point2f_result.push_back( Point2f(static_cast<float>(result[i][0] + result[i][2] * 0.5), static_cast<float>(result[i][1]))); } }然后我们再回到localization函数,接着往下看
vector<Point2f> list_lines_y = separateVerticalLines(list_lines_x); if( list_lines_y.empty() ) { return false; } vector<Point2f> centers; Mat labels; kmeans(list_lines_y, 3, labels, TermCriteria( TermCriteria::EPS + TermCriteria::COUNT, 10, 0.1), 3, KMEANS_PP_CENTERS, localization_points); fixationPoints(localization_points);他在separateVerticalLines这个函数找到了所有疑似中点的点,然后还聚类判断了一下,把那些聚类后的方差大于一定值的点保存下来,我不是很明白为什么要这样,可能是担心方差太小了说明点少了,误差较大吧还是什么.反正到这边又聚类了一次,数据是挑选的那些方差大的点,最终找出3个中心.然后调了fixationPoints找到旋转角度.
这个函数先计算了三个点两两之间的距离
CV_TRACE_FUNCTION(); double cos_angles[3], norm_triangl[3]; norm_triangl[0] = norm(local_point[1] - local_point[2]); norm_triangl[1] = norm(local_point[0] - local_point[2]); norm_triangl[2] = norm(local_point[1] - local_point[0]);然后算了一下三个夹角的余弦值
cos_angles[0] = (norm_triangl[1] * norm_triangl[1] + norm_triangl[2] * norm_triangl[2] - norm_triangl[0] * norm_triangl[0]) / (2 * norm_triangl[1] * norm_triangl[2]); cos_angles[1] = (norm_triangl[0] * norm_triangl[0] + norm_triangl[2] * norm_triangl[2] - norm_triangl[1] * norm_triangl[1]) / (2 * norm_triangl[0] * norm_triangl[2]); cos_angles[2] = (norm_triangl[0] * norm_triangl[0] + norm_triangl[1] * norm_triangl[1] - norm_triangl[2] * norm_triangl[2]) / (2 * norm_triangl[0] * norm_triangl[1]);如果出现了余弦大于0.85的就直接不考虑了,我算了一下,余弦0.85差不多就是30度,也就是说要是有夹角小于30度的说明找错点了,直接就返回了
const double angle_barrier = 0.85; if (fabs(cos_angles[0]) > angle_barrier || fabs(cos_angles[1]) > angle_barrier || fabs(cos_angles[2]) > angle_barrier) { local_point.clear(); return; }接下来根据夹角确定了三个点的方位
size_t i_min_cos = (cos_angles[0] < cos_angles[1] && cos_angles[0] < cos_angles[2]) ? 0 : (cos_angles[1] < cos_angles[0] && cos_angles[1] < cos_angles[2]) ? 1 : 2;之后做了更细的一些筛选,主要是剔除了一些不该有的东西.保证挑选出来的二维码一定是准确的.
opencv的识别用的是第三方的接口,用的quirc这个库,他放在"3rdparty/quirc"这个目录里了 可以看一下opencv的这个decode函数
std::string QRCodeDetector::decode(InputArray in, InputArray points, OutputArray straight_qrcode) { Mat inarr; if (!checkQRInputImage(in, inarr)) return std::string(); vector<Point2f> src_points; points.copyTo(src_points); CV_Assert(src_points.size() == 4); CV_CheckGT(contourArea(src_points), 0.0, "Invalid QR code source points"); QRDecode qrdec; qrdec.init(inarr, src_points); bool ok = qrdec.fullDecodingProcess(); std::string decoded_info = qrdec.getDecodeInformation(); if (ok && straight_qrcode.needed()) { qrdec.getStraightBarcode().convertTo(straight_qrcode, straight_qrcode.fixedType() ? straight_qrcode.type() : CV_32FC2); } return ok ? decoded_info : std::string(); }其实就是调用了qrdec.getStraightBarcode()函数,返回识别结果
opencv这个检测,主要是利用竖直方向和水平方向线段长度比例来确定的,但这样一点旋转不变形都没有做,虽然旋转了之后可能会有部分保留这个比例,也能找到,但如果是拍照的,除了宣传还有一些透视的话,那么opencv很容易就检测不到,就像这张图 因为它有透视的弯折,导致了这个比例不一定正确,或者图像不清晰的时候,严格的去数点很容易就找不到了,所以opencv的这个方法检出率极低. 所以我自己开发的时候没有使用他的这个方法.
我用的是找轮廓的方法,根据轮廓层级,找到3层的轮廓,然后作为候选,就不去数点确认比例了.
看我的步骤
path = 'data/qrcode.jpg' img = cv2.imread(path) thresholdImage, contours, hierarchy = getContours(img)首先把轮廓提取出来
def convert_img_to_binary(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) binary_img = cv2.adaptiveThreshold( gray, 255, # Value to assign cv2.ADAPTIVE_THRESH_MEAN_C,# Mean threshold cv2.THRESH_BINARY, 11, # Block size of small area 2, # Const to substract ) return binary_img def getContours(img): binary_img = convert_img_to_binary(img) thresholdImage = cv2.Canny(binary_img, 100, 200) #Edges by canny edge detection _, contours, hierarchy = cv2.findContours( thresholdImage, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) return thresholdImage, contours, hierarchy把图片预处理了一下,首先二值化,然后做一下canny提取边缘信息,就成了这样 ,然后调opencv的findContours函数,找到轮廓, 结果发现全是轮廓,哈哈,不急,我们慢慢挑,最重要的是最后那个返回值,也就是层级关系.这个是我们算法的核心信息.
然后我们就要开始利用这个层级关系了,因为二维码轮廓是黑白黑三层的,所以我们搜索所有三层的轮廓
# qrcode corner has 3 levels levelsNum = 3 patterns, patternsIndices = getContourWithinLevel(levelsNum, contours, hierarchy)看一下这个怎么搜索的
也就是找到有3层子轮廓的轮廓,把它返回回来,其中hierarchy这个对象,固定的shape是1,n,4其中n是轮廓的数量,例如我们这个例子就是(1, 1253, 4),最后那个4个维度代表下一个轮廓id,上一个轮廓id,子轮廓id,父轮廓id如果没有的话统统都是-1,所以我们这里获得hierarchy[0][contourIndex][2]就是获取了子轮廓的id,然后就数连着3级都有子轮廓就存下来,但是也不是都存下来了,因为最后还有一个checkRatioOfContours函数,这个是防止子轮廓大小比例异常
def checkRatioOfContours(index, contours, hierarchy): firstChildIndex = hierarchy[0][index][2] secondChildIndex = hierarchy[0][firstChildIndex][2] firstArea = cv2.contourArea(contours[index]) / ( cv2.contourArea(contours[firstChildIndex]) + 1e-5) secondArea = cv2.contourArea(contours[firstChildIndex]) / ( cv2.contourArea(contours[secondChildIndex]) + 1e-5) return ((firstArea / (secondArea+ 1e-5)) > 1 and \ ((firstArea / (secondArea+ 1e-5)) < 10))所以我比较了一下,父轮廓的大小要在子轮廓的1-10倍之间,不然就算噪声排除掉了. 现在看看还剩下多少轮廓 这样一挑,就只剩下8个轮廓了. 如果图片特别模糊,看不清是3个层级怎么办,我们后续也加了一步
#in case not all the picture has clear pattern while len(patterns) < 3 and levelsNum > 0: levelsNum -= 1 patterns, patternsIndices = getContourWithinLevel(levelsNum, contours, hierarchy)如果找到的关键点还不够3个,那么我们就减小层级,这样假设太小了的,三层只能看见两层的我们也能找到,所以我们逐步缩小了层级,直到找够3个为止 找到之后就开始处理了,如果此时还不3个就直接宣告GG吧,也不浪费时间了
interstingPatternList = [] if len(patterns) < 3 : print('no enough pattern') return False, [] # return False如果刚好找到了3个,那就把这3个都作为感兴趣的轮廓加进去
elif len(patterns) == 3: for patternIndex in range(len(patterns)): x, y, w, h = cv2.boundingRect(patterns[patternIndex]) interstingPatternList.append(patterns[patternIndex]) cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) show(img, 'qrcode') # return patterns如果比3个多,我们就要把父轮廓提取出来,刚才那8个都是一个父轮廓带着一个子轮廓的,现在我们要把这个父轮廓提取出来,子轮廓就不要了. 我们来挨个判断一下,把那些没有爸爸的加到感兴趣的里面,但是这样有个问题,如果有的有爸爸,但是他的爸爸在早期就被淘汰了,例如有的图整张图片有个超大的圈,所有图案都是外面那个圈的子轮廓,这时候我们就不能从全局去找爸爸了,那该怎么办呢?我们就从这8个待选的轮廓中找爸爸.
elif len(patterns) > 3: # sort from small to large patternAreaList = np.array( [cv2.contourArea(pattern) for pattern in patterns]) areaIdList = np.argsort(patternAreaList) # get patterns without parents intrestingPatternIdList = [] for i in range(len(areaIdList) - 1, 0, -1): index = patternsIndices[areaIdList[i]] if hierarchy[0][index][3] == -1: intrestingPatternIdList.append(index) else: # We can make sure the parent must appear before chirld because we sorted the list by area if not isParentInList(intrestingPatternIdList, index, hierarchy): intrestingPatternIdList.append(index) for intrestingPatternId in intrestingPatternIdList: x, y, w, h = cv2.boundingRect(contours[intrestingPatternId]) cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) interstingPatternList.append(contours[intrestingPatternId]) show(img, 'qrcode')我们先给这些轮廓按照大小排个序,这样的话从大到小来判断,就可以优先提取出最大的了,剩下的绝对不可能是前一个的父轮廓,所以我们每个轮廓就判断一下它有没有爸爸已经被我们选中了即可. 这下我们搜集的所有轮廓都不互为父子了. 我们再看现在剩下的轮廓,有一个在小电视上,那是我们不希望存在的,现在就要想办法把它除掉. 首先使用距离肯定是不合适的,因为小电视明显和最下面的那个最近,用全部距离的话会把右上角那个点去掉,把小电视保留. 那么我们就用角度好了,每三个点都找一遍,选出两条垂直并且长度也差不多的线 现在是轮廓,我们要把它变成点 首先每个轮廓找到他自己的重心
centerOfMassList = getCenterOfMass(interstingPatternList) for centerOfMass in centerOfMassList: cv2.circle(img_show, tuple(centerOfMass), 3, (0, 255, 0))结果如下 找重心的函数是
def getCenterOfMass(contours): pointList = [] for i in range(len(contours)): moment = cv2.moments(contours[i]) centreOfMassX = int(moment['m10'] / moment['m00']) centreOfMassY = int(moment['m01'] / moment['m00']) pointList.append([centreOfMassX, centreOfMassY]) return pointList我是通过计算图像的矩来找的重心,说白了也就是哪边点多就往哪边偏移,这符合重心的原理.当然直接用轮廓找个重心也是可以的,但是这样噪声影响比较大. 找到重心之后我们要挑出最终的三个点
id1, id2, id3 = 0, 1, 2 if len(patterns) > 3: result = selectPatterns(centerOfMassList) if result is None: print('no correct pattern') return False, [] id1, id2, id3 = result我的做法是每3个点做两条线段,然后判断线段如果差不多90度,我这里的范围比较宽松,可以浮动30度,也就是说你夹角只要在60-120度之间的都可以进入下一步,下一步就是从所有夹角符合的情况中选出线段长度最接近的,也就是minLengthDiff这一组,恭喜这三个点两条线获胜,就是我们想要的轮廓.
最后就是来计算旋转角度了,也就是看这三个点哪个是左下,哪个是左上,哪个是右上
interstingPatternList = np.array(interstingPatternList)[[id1, id2, id3]] centerOfMassList = np.array(centerOfMassList)[[id1, id2, id3]] pointList = getOrientation(interstingPatternList, centerOfMassList)我这里其实就是先用距离找出离两个距离最远的点,剩下一个就是直角点了,然后算出斜边的斜率,再算出直角点在斜边的哪一侧,就可以得出这个图形的旋转方式. 最终就可以得到我们想要的三个点了,我返回时候是按照左上角,左下角,右上角的这个顺序返回的. 看看识别结果
项目我是开源了,放在了github上 https://github.com/keefeWu/QRCode