利用谷歌提供的Tesseract开源OCR工具 来实现一个准确率一般的、简易的身份证信息提取工具。
了解Tesseract
Tesseract是谷歌提供的一个开源OCR(Optical Character Recognition, 光学字符识别)工具,可以从图片中提取字符。支持英语、中文(简体/繁体以及横式/竖式)、数学公式以及上百种语言。其对于英文和数字的识别准确率比较高,但是对中文字符的识别准确率相对较低。
可以从Tesseract的Github仓库直接下载使用,也可以安装python的模块pytesseract
,一种更加方便的方式是直接下载自动安装程序。本文使用tesseract-ocr-w64-setup-v4.0.0.exe
,资源可以很容易在网络上找到并下载,这里就不再给出。
安装时可以根据需要在Additional script data
和Additional language data
中安装对应的语言包,或者使用其他地方下载得到的语言包。安装完成后,使用前可能需要配置环境变量:
然后在安装路径(如上图,默认为C:\Program Files (x86)\Tesseract-OCR\
)中运行命令行,使用tesseract [-l package] xxx.jpg yyy
来进行一次图片识别,其中-l package
表示使用哪个语言包,不写则默认为-l eng
;xxx.jpg
是图片名称,.png
等其他图片类型也可;yyy
是输出结果文件,输出到yyy.txt
。
数据格式说明
为了方便扩展,支持更多种类证件识别,可以对每种证件提前做好配置文件。本文用.json
文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { "sf" : { "ratio" : 1.5851851851851851851851851851852 , "info" : { "Eid" : [0.3253 ,0.8185 ,0.8842 ,0.9185 ], "Zname" : [0.1700 ,0.1200 ,0.3276 ,0.2241 ], "Zaddress" : [0.1723 ,0.5148 ,0.6065 ,0.7583 ], "Mpicture" : [0.5982 ,0.1481 ,0.9400 ,0.7593 ] } }, ...... }
以上述文件为例,"sf"
就是一类证件种类(这里是身份证),每种证件需要有信息"ratio"
表示这种证件的长宽比,"info"
表示证件中要识别的信息所在的相对位置。"info"
这一项大大降低了识别难度,提高了识别准确率,不过也成为一项局限。需要人手动输入所有信息在图片中的相对位置[x1,y1,x2,y2]
,图片左上角视为( 0 , 0 ) (0,0) ( 0 , 0 ) ,右下角视为( 1 , 1 ) (1,1) ( 1 , 1 ) 。信息名称第一个字母表示种类,E
为英文,Z
为中文,N
为数字(身份证号不用数字因为最后一位可能是X
),M
表示只切割照片不识别文字。
图片的命名格式为sfxxxxx.jpg
或.png
,保证图片的前两位为证件种类名称。
规整身份证照片OCR
首先尝试对规整的身份证照片 (完全对正并且身份证边界就是照片边界)进行识别。首先只提取身份证号以及家庭住址 。
工具
首先定义一些有用的工具例如基本文件操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import numpy as npimport shutilimport stringimport jsonimport mathimport osdef clear (PATH) : if os.path.exists(PATH): shutil.rmtree(PATH) os.mkdir(PATH) def read_json (FILE) : with open(FILE,encoding='utf-8' ) as f: data = json.loads(f.read()) return data def save_json (object,FILE) : with open(FILE,"w" ) as f: json.dump(object,f,ensure_ascii=False ,sort_keys=True ,indent=4 )
识别
首先做好环境的准备: src
为图片存放目录,tmp
为工作用临时目录,rst
为输出结果所在目录,为方便起见可以选择dst
目录,使得src
中识别完成的图片会移动到dst
中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 from copy import deepcopyimport subprocessimport requestsimport imutilsimport timefrom preprocess import *src = "images/" tmp = "temp/" dst = "processed/" rst = "results/" CHINESE = ( "chi_sim_43327" ) LAN = {'Z' :"-l " +CHINESE,'N' :"" ,'E' :"" } config = {} def make_environment () : global config if not os.path.exists(src): os.mkdir(src) clear(tmp) if not os.path.exists(dst): os.mkdir(dst) if not os.path.exists(rst): os.mkdir(rst) config = read_json("config.json" ) def tesseract (FILE,TYPE,RESULT) : command = 'tesseract {0} {1} {2}' .format(LAN[TYPE],FILE,RESULT) return subprocess.Popen(command,shell=True )
chi_sim
为中文语言包名称,本文尝试了多个语言包,用其文件大小作为区分。
接下来定义和实现图像识别的函数:
1 2 def OCR (PIC_NAME,move=False,delete=False)
PIC_NAME
为照片名称(带后缀),move=True
表示识别完成后将图片复制 到dst
目录,delete=True
表示识别完成后将src
目录的图片删除。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 def OCR (PIC_NAME,move=False,delete=False) : PATH = src+PIC_NAME category = (deepcopy(config[PIC_NAME[:2 ]]) if PIC_NAME[:2 ] in config else {'info' :{'Z0' :[0. ,0. ,1. ,1. ]}})['info' ] result = {} thread = {} with Image.open(PATH) as img: w,h = img.size for key,box in category.items(): TEMP = tmp+key+"_" +PIC_NAME RESULT = tmp+key+"_" +PIC_NAME.split('.' )[0 ] box[0 ] *= w; box[1 ] *= h; box[2 ] *= w; box[3 ] *= h img_cr = img.crop([int(round(j)) for j in box]) img_cr = normalized(img_cr) img_cr.save(TEMP,dpi=(300.0 ,300.0 )) thread[key] = tesseract(TEMP,key[0 ],RESULT) for key,box in category.items(): TEMP = tmp+key+"_" +PIC_NAME RESULT = tmp+key+"_" +PIC_NAME.split('.' )[0 ]+".txt" thread[key].wait() with open(RESULT,"r" ,encoding='UTF-8' ) as f: result[key] = clean(f.read()) if move: with Image.open(PATH) as img: img.save(dst+PIC_NAME) if delete: os.remove(PATH) clear(tmp) return result
category
表示证件类别,如果证件类别不存在,那么就默认识别整张图。因为身份证照片已经是规整的,因此"ratio"
没有作用。经过实践,图像的预处理只需要进行转为灰度图并归一化即可。
转换为灰度图可以使用PIL
中的Image.convert('L')
。然后线性归一化就是将图片中每个像素放缩到0 ∼ 255 0 \sim 255 0 ∼ 2 5 5 范围:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from itertools import permutationsfrom scipy import ndimagefrom PIL import Imageimport PIL.ImageOpsimport numpy as npimport cv2from utils import *def normalized (img) : arr = np.array(img.copy().convert('L' )) arr_min = arr.min() arr_max = arr.max() arr = ((arr-arr_min)/(arr_max-arr_min)*255 ).astype(np.uint8) return Image.fromarray(arr)
由于Tesseract的要求,图片的分辨率要足够高,不过似乎一般图片中不含dpi信息,因此手动设置dpi为(300,300)
。
因为subprocess
调用命令行可以形成新的进程,因此不需要每次调用Tesseract的时候等待其运行完,而可以先保存下来,等到真正需要读取数据时再等待,提高并行程度。
清理识别后数据,去除空格:
1 2 3 4 5 6 7 8 def clean (s) : return (s.replace(" " ,"" ) .replace("\r" ,"" ) .replace("\n" ,"" ) .replace("\t" ,"" ) .replace("\f" ,"" ) .replace("\v" ,"" ))
这样就可以对身份证号和身份证地址进行基本的识别了。身份证号识别(测试了几张身份证以及不同角度)基本不会出错,而住址识别一般会出现0 ∼ 2 0 \sim 2 0 ∼ 2 个汉字的错误,关键信息基本不会出错。
在保证光照均匀的情况下准确率都很高 ,有一些(我刻意测试)的光照情况下即使人看清上面某个特定位置的字也很困难,除此以外表现基本是可以接受的。
了解身份证
已经识别了身份证号和家庭地址,那么接下来就可以推广到识别整个身份证上的信息了。需要对所有信息分别找出其在身份证上的相对位置,写入配置文件中。实践证明,如果信息片面积过小,识别准确率会很低 。性别、民族、出生年、出生月、出生日,识别框很小,只有一个汉字或几个数字,几乎很难识别正确。而姓名对于常见字识别准确率较高,相对不常见、复杂的字识别准确率则较低。比如"醉"和"醇"有时傻傻分不清楚。
然而,身份证是非常特殊的。了解身份证可以对识别有帮助!这就需要了解身份证号的组成了: 二代居民身份证号码为18 18 1 8 位,其中最后一位是校验码,可能出现X
。计算方法为(来自百度百科):
将前面的身份证号码17位数分别乘以不同的系数。从第一位到第十七位的系数分别为:7-9-10-5-8-4-2-1-6-3-7-9-10-5-8-4-2
将这17位数字和系数相乘的结果相加
用加出来和除以11,看余数是多少
余数只可能有0-1-2-3-4-5-6-7-8-9-10这11个数字。其分别对应的最后一位身份证的号码为1-0-X -9-8-7-6-5-4-3-2。(即余数0对应1,余数1对应0,余数2对应X…)
因此我们可以按照上述过程实现对身份证号识别是否正确的校验 (虽然一般都是正确的,不过还是有用的):
1 2 3 4 5 ID_verify = [7 ,9 ,10 ,5 ,8 ,4 ,2 ,1 ,6 ,3 ,7 ,9 ,10 ,5 ,8 ,4 ,2 ] ID_table = "10X98765432" def check_ID_simple (s) : return ID_table[np.dot(ID_verify,[int(j) for j in s[:17 ]])%11 ]==s[17 ] if len(s)==18 and s[:17 ].isdigit() and (s[17 ] in ID_table) else False
当然真正合格的身份证号还需要更加严格检验: 比如出生日期是否是合法日期,身份证号前两位的出生地区码是否合法等。不过对于一个原本合格的身份证号,由于识别导致出现这些错误并且还能通过校验码验证的概率不大。
另外,针对身份证号可以在校验之前优化识别 ,去除识别过程中产生的杂乱符号而只保留数字以及最后一位的X
:
1 2 3 4 5 6 7 def clean_ID (s) : s = '' .join(list(filter(lambda x: x in ID_table,s))) if s=="" : return s n = len(s)-1 return '' .join(list(filter(str.isdigit,s[:n])))+s[n]
注意这里需要防止出现身份证号也没识别出来的情况。
类似地,对于数字以及信息的识别可以进行优化,分别去除非数字字符和标点符号:
1 2 3 4 5 6 def clean_num (s) : return '' .join(list(filter(str.isdigit,s))) def clean_info (s) : return '' .join(list(filter(lambda x: x not in string.punctuation,s)))
由于身份证内含出生年月日 ,因此不需要识别出生年月日,直接从身份证号中提取并合并为"{0}年{1}月{2}日".format(ID[6:10],ID[10:12],ID[12:14])
即可。
另外,对于身份证的第17位,满足男性为奇数,女性为偶数 。因此,身份证信息不需要去识别性别,只需要把性别设置为"男" if int(ID[16])%2 else "女"
即可。
对于照片的"M"
类需要进行单独处理: 在发现key
的第一位是"M"
以后直接跳过识别和图像的灰度化和归一化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 def OCR (PIC_NAME,move=False,delete=False) : PATH = src+PIC_NAME category = (deepcopy(config[PIC_NAME[:2 ]]) if PIC_NAME[:2 ] in config else {'info' :{'Z0' :[0. ,0. ,1. ,1. ]}})['info' ] result = {} thread = {} with Image.open(PATH) as img: w,h = img.size for key,box in category.items(): TEMP = tmp+key+"_" +PIC_NAME RESULT = tmp+key+"_" +PIC_NAME.split('.' )[0 ] box[0 ] *= w; box[1 ] *= h; box[2 ] *= w; box[3 ] *= h img_cr = img.crop([int(round(j)) for j in box]) if key[0 ]=='M' : img_cr.save(rst+key[1 :]+"_" +PIC_NAME) result[key] = key[1 :]+"_" +PIC_NAME else : img_cr = normalized(img_cr) img_cr.save(TEMP,dpi=(300.0 ,300.0 )) thread[key] = tesseract(TEMP,key[0 ],RESULT) for key,box in category.items(): if key[0 ]=='M' : continue TEMP = tmp+key+"_" +PIC_NAME RESULT = tmp+key+"_" +PIC_NAME.split('.' )[0 ]+".txt" thread[key].wait() with open(RESULT,"r" ,encoding='UTF-8' ) as f: result[key] = clean(f.read()) if key[0 ]=='N' : result[key] = clean_num(result[key]) if move: with Image.open(PATH) as img: img.save(dst+PIC_NAME) if delete: os.remove(PATH) clear(tmp) return result
对于民族这一项,目前就没有什么好办法了…
其他角度身份证照片OCR
然而很难要求身份证总是拍的正正好好并且裁剪得完美。这样如果稍微有一点歪斜,原来手动标注的识别框就会完全失效。作为下一阶段的检测样例,刻意选取一个很过分的角度:
即使是角度不对,也不能太过分。为了简单起见,对图片进行下列假定:
整个身份证都出现在图片内
图片光照均匀,无强光,无严重阴影
背景最好为均匀纯色,减少线条和明显差异
身份证倾角不会过大,最好在45以内
虽然由于桌子的木纹和边缘,上述照片其实不满足条件3 3 3 。不过最后效果还是不错的。
主要思路是,在背景均匀、身份证完全位于图片内的条件下,利用身份证的边缘作为从图像中识别出身份证位置的依据。核心问题就是寻找身份证的四个角点,只要找到四个角点,进行一次仿射变换即可提取出规整的身份证照片 。
进一步预处理
首先就要让身份证的轮廓更加明显。因此采用常用的图像预处理方式: 灰度化-归一化-去噪声-二值化-腐蚀和膨胀/闭运算和开运算。灰度化和归一化前面已经进行过了。
代码方面,OpenCV的cv2
和PIL
的Image
是两个完全不同的系统,需要注意区分。代码中用img
表示PIL.Image
变量,用arr
表示OpenCV的图像即numpy.ndarray
变量。
高斯模糊对图像做平滑处理,可以去噪。其中(9,9)
为高斯卷积核大小(必须为奇数或0
表示自动),0
为X方向标准差(Y方向标准差自动等于X方向):
1 blurred = cv2.GaussianBlur(arr,(9 ,9 ),0 )
二值化,将图像变为黑白两色。其中阈值THRESH
处填入一个自己设置的玄学的 常数:
1 _,binary = cv2.threshold(blurred,THRESH,255 ,cv2.THRESH_BINARY)
对图像先后进行闭运算和开运算(似乎没有看出图片有很大区别,可以观察文字,图像的零散部分被一定程度上抹平)。kernel
为卷积核,其类型为矩形(cv2.MORPH_RECT
),大小为(5,5)
。腐蚀就是图像每个点求局部最小值(因为黑色灰度为0
,这使图像变黑),膨胀就是对图像每个点求局部最大值(因为白色灰度为255
,这使图像变白);闭运算就是先膨胀再腐蚀,开运算就是先腐蚀再膨胀:
1 2 3 kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(5 ,5 )) closed = cv2.morphologyEx(binary,cv2.MORPH_CLOSE,kernel) result = cv2.morphologyEx(closed,cv2.MORPH_OPEN,kernel)
~~实践表明,一般情况下身份证颜色相对环境都很浅,~~将图像反色一下效果更好:
完整的图像处理函数(不包括之前已经做了的灰度化和二值化):
1 2 3 4 5 6 7 8 def opened (arr,invert=True) : blurred = cv2.GaussianBlur(arr,(9 ,9 ),0 ) _,binary = cv2.threshold(blurred,THRESH,255 ,cv2.THRESH_BINARY) kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(5 ,5 )) closed = cv2.morphologyEx(binary,cv2.MORPH_CLOSE,kernel) result = cv2.morphologyEx(closed,cv2.MORPH_OPEN,kernel) return 255 -result if invert else result
边缘检测
尝试利用边缘检测来寻找身份证的四个角点。
使用OpenCV提供的cv2.Canny
方法先对已经二值化过的图像进行边缘检测。其中CANNY_LOW
和CANNY_HIGH
处也要填入两个玄学的 常数,高于第二个阈值的像素会被直接保留,低于第一个阈值的像素会被直接抛弃,在两个阈值之间的像素会被进行进一步的检测(比较周围像素)。
1 2 opened_arr = opened(arr) edges = cv2.Canny(opened_arr,CANNY_LOW,CANNY_HIGH,apertureSize=3 )
接下来从这些边缘中提取线段,使用OpenCV提供的霍夫变换。使用cv2.HoughLines
会得到直线 到原点距离线段的极坐标 表示,使用cv2.HoughLinesP
会得到线段 的直角坐标 表示。rho
和theta
顾名思义分别规定了距离和角度的精度要求,这两项一般不动。接下来又是喜闻乐见的 常数环节,HOUGH_THRESH
是阈值,只保留大于阈值的线段;HOUGH_MIN_LEN
为线段长度,只保留大于该长度的线段;HOUGH_MAX_GAP
表示最大间隔,如果两个平行共线的线段之间距离不超过HOUGH_MAX_GAP
就会被连接起来被认为是一条线段。
1 lines = cv2.HoughLinesP(edges, rho=1 , theta=np.pi/180 , threshold=HOUGH_THRESH, minLineLength=HOUGH_MIN_LEN, maxLineGap=HOUGH_MAX_GAP)
这一过程可能会找出成千上万条线段来,返回的list
基本会按照可信度排序。因此将前20 20 2 0 条线段在图上做出来观察一下识别效果。到Canny边缘检测以后,图像基本不再进行处理和修改了,从此以后使用原来的灰度化归一化图像作为基底做图方便观察。使用下述代码进行可视化: 不知道为什么cv2.HoughLinesP
返回值要包装成两层列表,因此会比较别扭;用cv2.line
在图像上绘制线条,首先输入图像,再输入两个点坐标,0
表示颜色为黑色,10
表示线条粗细。
1 2 3 4 5 6 blank = arr for j in lines[:20 ]: for x1,y1,x2,y2 in j: cv2.line(blank,(x1,y1),(x2,y2),0 ,10 ) cv2.imshow("lines" ,blank) cv2.waitKey(0 )
虽然看起来没有20 20 2 0 条线,但是霍夫变换返回的有很多线条可能是接近重合的。同样,它们的顺序也不清楚。边缘检测在这张图片中效果不错,但在其他图片中效果并没有这么好。在一些其他图片中,由于不能保证背景均匀和光照均匀,可能会出现不想要的线条。因此直接用这些线条来计算身份证的四个角是不太好的。而且由于视角的变化,这些线段甚至不一定成90 90 9 0 度,因此一个很大的难点就是 如何保证找到的线段一定是身份证的边界而不是背景中的线条呢?
需要思考进一步优化识别、区分线段的方法。
矩形包围盒
除了边缘检测,另一个思路是寻找身份证的矩形包围盒。
身份证的性状是一个规则的四边形,可以考虑通过寻找包围盒来获得身份证的位置。使用cv2.findContours
首先将图像的轮廓描出来,然后利用轮廓计算包围盒cv2.minAreaRect
。注意到cv2.findContours
也会找出很多个轮廓。因此假定身份证图片是图片中最大的轮廓 ,这个假设在背景均匀时一般比较合理。
1 2 3 4 5 def bounding_box (opened_arr) : contours,_ = cv2.findContours(opened_arr,cv2.RETR_LIST,cv2.CHAIN_APPROX_SIMPLE) bounding_rect = cv2.minAreaRect(sorted(contours,key=cv2.contourArea,reverse=True )[1 ]) return np.int0(cv2.boxPoints(bounding_rect))
分别可视化绘制一下轮廓和包围盒。使用cv2.drawContours
,-1
表示绘制所有轮廓;0
表示颜色为黑色;10
为线条粗细:
1 2 3 blank = cv2.drawContours(arr,contours,-1 ,0 ,10 ) cv2.imshow("contour" ,blank) cv2.waitKey(0 )
1 2 3 4 box = bounding_box(opened_arr) blank = cv2.drawContours(arr,[box],-1 ,0 ,10 ) cv2.imshow("bounding_box" ,blank) cv2.waitKey(0 )
由于视角的变换,原本的身份证并不是矩形了,因此这样一个矩形包围盒可以作为一个良好的近似 ,但并不可能直接使用。
Mix!
边缘检测的方法也行不通,矩形包围盒的方法也行不通,那… Why not try BOTH?
既然矩形包围盒已经是身份证一个良好的近似了,那么可以利用这个近似的包围盒来筛选线段!只保留和矩形包围盒边界比较近似的线段就可以获得只属于身份证的线段了!
这里要先做好一些图形计算的辅助函数来展示OI选手计算几何的基本功底 方便以后的操作,主要包括求距离、叉积、角度、判平行、求交(常数DIFF_ANGLE
可视情况自己取):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 DIFF_ANGLE = 10 def sqrdis (a,b) : return (a[0 ]-b[0 ])**2 +(a[1 ]-b[1 ])**2 def product (a,b) : return a[0 ]*b[1 ]-a[1 ]*b[0 ] def angle (a) : r = math.degrees(math.atan2(a[1 ],a[0 ])) if r > 180 : r -= 180 if r < 0 : r += 180 return r def parallel (line1,line2) : a = np.array(line1[:2 ],dtype=np.float32) - np.array(line1[2 :],dtype=np.float32) b = np.array(line2[:2 ],dtype=np.float32) - np.array(line2[2 :],dtype=np.float32) diff = math.fabs(angle(a) - angle(b)) return diff <= DIFF_ANGLE or diff >= 180 -DIFF_ANGLE def cross (line1,line2) : a = np.array(line1[:2 ],dtype=np.float32); b = np.array(line1[2 :],dtype=np.float32); c = np.array(line2[:2 ],dtype=np.float32); d = np.array(line2[2 :],dtype=np.float32) return a+(product(d-c,a-c)/product(b-a,d-c))*(b-a)
接下来用每个线段去匹配包围盒的每条边,枚举哪两对点匹配,以两对点的距离平方和作为排序依据,取误差最小的前20 20 2 0 条线段。顺手把线段的二层列表去掉一层。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 opened_arr = opened(arr) box = bounding_box(opened_arr) edges = cv2.Canny(opened_arr,CANNY_LOW,CANNY_HIGH,apertureSize=3 ) lines = cv2.HoughLinesP(edges, rho=1 , theta=np.pi/180 , threshold=HOUGH_THRESH, minLineLength=HOUGH_MIN_LEN, maxLineGap=HOUGH_MAX_GAP) lines = sorted(lines, key=lambda x:min([sqrdis(x[0 ][:2 ],box[0 ])+sqrdis(x[0 ][2 :],box[1 ]), sqrdis(x[0 ][2 :],box[0 ])+sqrdis(x[0 ][:2 ],box[1 ]), sqrdis(x[0 ][:2 ],box[1 ])+sqrdis(x[0 ][2 :],box[2 ]), sqrdis(x[0 ][2 :],box[1 ])+sqrdis(x[0 ][:2 ],box[2 ]), sqrdis(x[0 ][:2 ],box[2 ])+sqrdis(x[0 ][2 :],box[3 ]), sqrdis(x[0 ][2 :],box[2 ])+sqrdis(x[0 ][:2 ],box[3 ]), sqrdis(x[0 ][:2 ],box[3 ])+sqrdis(x[0 ][2 :],box[0 ]), sqrdis(x[0 ][2 :],box[3 ])+sqrdis(x[0 ][:2 ],box[0 ]) ]), reverse=False ) lines = [np.array(x[0 ],dtype=np.float32) for x in lines[:LINES]]
然后对线段求交,正如上文所说,霍夫变换会得到很多重复的线段,因此求交点之前需要判断平行,求出交点后需要去重(常数DIFF_SQRDIS
自己取,注意是平方距离所以这个值要比较大)。另外,要将一些落在图像外的明显是噪音的点去掉,对于恰好落在图像边缘附近的点宽容一些空间(常数EDGE
自己取)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def crosses (lines,w,h) : points = [] unique_points = [] for a in lines: for b in lines: if not parallel(a,b): p = cross(a,b) if -EDGE <= p[0 ] <= w+EDGE and -EDGE <= p[1 ] <= h+EDGE: points.append(p) for i in range(len(points)): flag = True for j in range(i): if sqrdis(points[i],points[j]) < DIFF_SQRDIS: flag = False break if flag: unique_points.append(points[i]) return unique_points
求出的线段交点不出意外的话应该是四个。可视化一下,在图像上画点显然是没啥效果的,用画圆的方式来可视化。
1 2 3 4 5 6 7 blank = arr for j in lines: cv2.line(blank,(j[0 ],j[1 ]),(j[2 ],j[3 ]),0 ,10 ) for j in points: cv2.circle(blank,(j[0 ],j[1 ]),10 ,0 ,-1 ) cv2.imshow("img" ,blank) cv2.waitKey(0 )
很棒!终于找到了身份证的四个角点 。
仿射变换和透视变换
现在已经知道身份证的四个角点了,要得到一个正规的身份证照片,需要将四个角点通过视角的变换运算到( 0 , 0 ) , ( 0 , h ) , ( w , h ) , ( w , 0 ) (0,0),(0,h),(w,h),(w,0) ( 0 , 0 ) , ( 0 , h ) , ( w , h ) , ( w , 0 ) 。
首先注意到这四个点是乱序的,按逆时针顺序匹配一下。假设存在一条竖直中线可以将身份证分为左右两部分 ,这个假设也是合理的,否则根据身份证的长宽比1.5 8 ˙ 5 1 ˙ 1.5\dot 85 \dot 1 1 . 5 8 ˙ 5 1 ˙ ,身份证倾角会达到57 ° 57° 5 7 ° 。那么就认为0 0 0 号点是左侧高度较高的点,1 1 1 号点是左侧高度较低的点,2 2 2 号点是右侧高度较低的点,3 3 3 号点是右侧高度较高的点。
1 2 3 4 5 6 7 8 9 def matched (points) : assert (len(points)==4 ) points = sorted(points,key=lambda x:x[0 ]) if points[0 ][1 ] > points[1 ][1 ]: points[0 ],points[1 ] = points[1 ],points[0 ] if points[3 ][1 ] > points[2 ][1 ]: points[2 ],points[3 ] = points[3 ],points[2 ] return np.array(points,dtype=np.float32)
~~众所周知,~~一个空间中的变换可以用一个3 × 3 3 \times 3 3 × 3 矩阵来表示,三个点就可以确定一个变换。因此只需要使用身份证四个角点中的三个,利用cv2.getAffineTransform
来寻找0 , 1 , 2 0,1,2 0 , 1 , 2 号点变为( 0 , 0 ) , ( 0 , h ) , ( w , h ) (0,0),(0,h),(w,h) ( 0 , 0 ) , ( 0 , h ) , ( w , h ) 的变换矩阵即可。不过,OpenCV还恰好提供了一个利用四个点来寻找变换的cv2.getPerspectiveTransform
,那为什么不用呢?于是就可以完整实现下面的函数来求出变换矩阵:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def affine (arr) : h,w = arr.shape opened_arr = opened(arr) box = bounding_box(opened_arr) edges = cv2.Canny(opened_arr,CANNY_LOW,CANNY_HIGH,apertureSize=3 ) lines = cv2.HoughLinesP(edges, rho=1 , theta=np.pi/180 , threshold=HOUGH_THRESH, minLineLength=HOUGH_MIN_LEN, maxLineGap=HOUGH_MAX_GAP) lines = sorted(lines, key=lambda x:min([sqrdis(x[0 ][:2 ],box[0 ])+sqrdis(x[0 ][2 :],box[1 ]), sqrdis(x[0 ][2 :],box[0 ])+sqrdis(x[0 ][:2 ],box[1 ]), sqrdis(x[0 ][:2 ],box[1 ])+sqrdis(x[0 ][2 :],box[2 ]), sqrdis(x[0 ][2 :],box[1 ])+sqrdis(x[0 ][:2 ],box[2 ]), sqrdis(x[0 ][:2 ],box[2 ])+sqrdis(x[0 ][2 :],box[3 ]), sqrdis(x[0 ][2 :],box[2 ])+sqrdis(x[0 ][:2 ],box[3 ]), sqrdis(x[0 ][:2 ],box[3 ])+sqrdis(x[0 ][2 :],box[0 ]), sqrdis(x[0 ][2 :],box[3 ])+sqrdis(x[0 ][:2 ],box[0 ]) ]), reverse=False ) lines = [np.array(x[0 ],dtype=np.float32) for x in lines[:LINES]] points = crosses(lines,w,h) return cv2.getPerspectiveTransform(matched(points),np.array([[0 ,0 ],[0 ,h],[w,h],[w,0 ]],dtype=np.float32))
注意一下OpenCV格式(numpy.ndarray
)存储的图片的性状是h × w h \times w h × w ,与PIL.Image.size
的w × h w \times h w × h 不同。
接下来在没有处理过的原图上进行仿射变换就可以得到正规的身份证照片了。
1 2 3 4 5 6 7 def align (img) : arr = np.array(img.copy().convert('L' )) h,w = arr.shape transform = affine(arr) arr = cv2.warpPerspective(np.array(img),transform,(w,h)) return Image.fromarray(arr)
注意正规化以后的身份证长宽比w : h w:h w : h 是图像的长宽比。因此利用"ratio"
对图片resize一下:
1 img = img.resize((w,h),Image.ANTIALIAS)
看一看正规化后、比例正确的照片(代码中直接用正规化后的照片替换了原照片):
真的是从之前那张照片变换回来的!!!
了解身份证 · 2
然而怎么判断身份证需不需要变换呢,如果本来就是一张正规的身份证照片怎么办?
这里用了一个取巧的办法,牺牲一点性能: 定义一个"id"
类只识别身份证号。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "sf" : { "ratio" : 1.5851851851851851851851851851852 , "info" : { "Eid" : [0.3253 ,0.8185 ,0.8842 ,0.9185 ], "Zname" : [0.1700 ,0.1200 ,0.3276 ,0.2241 ], "Zaddress" : [0.1723 ,0.5148 ,0.6065 ,0.7583 ], "Mpicture" : [0.5982 ,0.1481 ,0.9400 ,0.7593 ] } }, "id" : { "ratio" : 1.5851851851851851851851851851852 , "info" : { "Eid" : [0.3253 ,0.8185 ,0.8842 ,0.9185 ] } } }
因为身份证号的识别成功率是很高的,因此只要正规的身份证切的位置对,可以先识别一下身份证号,用之前的check_ID_simple
函数检验是否正确!如果正确说明本来就正规了,直接识别即可,否则需要进行变换。
可以再添加一些针对身份证识别的优化(例如如果用手机照,可能身份证会竖过来,根据长宽旋转下),写成一个单独的身份证识别函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 def ID_Card_OCR (PIC_NAME) : assert (PIC_NAME[:2 ]=="sf" and "sf" in config) PATH = src+PIC_NAME ID_PIC = "id" +PIC_NAME[2 :] with Image.open(PATH) as img: w,h = img.size if w < h: img = img.transpose(Image.ROTATE_90); w,h = h,w img.save(PATH,dpi=(300.0 ,300.0 )) img.save(src+ID_PIC,dpi=(300.0 ,300.0 )) ID = clean_ID(OCR(ID_PIC,delete=True )['Eid' ]) if check_ID_simple(ID): result = OCR(PIC_NAME) else : with Image.open(PATH) as img: img = align(img) w,h = img.size w = int(h * config[PIC_NAME[:2 ]]['ratio' ]) img = img.resize((w,h),Image.ANTIALIAS) img.save(PATH,dpi=(300.0 ,300.0 )) result = OCR(PIC_NAME) ID = clean_ID(result['Eid' ]) if not check_ID_simple(ID): return None result['id' ] = ID result['picture' ] = result['Mpicture' ] result['name' ] = clean_info(result['Zname' ]) result['address' ] = clean_info(result['Zaddress' ]) del result['Eid' ],result['Mpicture' ],result['Zname' ],result['Zaddress' ] result['sex' ] = "男" if int(ID[16 ])%2 else "女" result['birthdate' ] = "{0}年{1}月{2}日" .format(ID[6 :10 ],ID[10 :12 ],ID[12 :14 ]) return result
完整代码
于是一个简易的身份证OCR工具就完成了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 import numpy as npimport shutilimport stringimport jsonimport mathimport osDIFF_ANGLE = 10 def clear (PATH) : if os.path.exists(PATH): shutil.rmtree(PATH) os.mkdir(PATH) def read_json (FILE) : with open(FILE,encoding='utf-8' ) as f: data = json.loads(f.read()) return data def save_json (object,FILE) : with open(FILE,"w" ) as f: json.dump(object,f,ensure_ascii=False ,sort_keys=True ,indent=4 ) def sqrdis (a,b) : return (a[0 ]-b[0 ])**2 +(a[1 ]-b[1 ])**2 def product (a,b) : return a[0 ]*b[1 ]-a[1 ]*b[0 ] def angle (a) : r = math.degrees(math.atan2(a[1 ],a[0 ])) if r > 180 : r -= 180 if r < 0 : r += 180 return r def parallel (line1,line2) : a = np.array(line1[:2 ],dtype=np.float32) - np.array(line1[2 :],dtype=np.float32) b = np.array(line2[:2 ],dtype=np.float32) - np.array(line2[2 :],dtype=np.float32) diff = math.fabs(angle(a) - angle(b)) return diff <= DIFF_ANGLE or diff >= 180 -DIFF_ANGLE def cross (line1,line2) : a = np.array(line1[:2 ],dtype=np.float32); b = np.array(line1[2 :],dtype=np.float32); c = np.array(line2[:2 ],dtype=np.float32); d = np.array(line2[2 :],dtype=np.float32) return a+(product(d-c,a-c)/product(b-a,d-c))*(b-a) def rgb2grey (R,G,B) : return int(round(0.299 *R + 0.587 *G + 0.114 *B)) def clean (s) : return (s.replace(" " ,"" ) .replace("\r" ,"" ) .replace("\n" ,"" ) .replace("\t" ,"" ) .replace("\f" ,"" ) .replace("\v" ,"" )) def clean_num (s) : return '' .join(list(filter(str.isdigit,s))) ID_verify = [7 ,9 ,10 ,5 ,8 ,4 ,2 ,1 ,6 ,3 ,7 ,9 ,10 ,5 ,8 ,4 ,2 ] ID_table = "10X98765432" def check_ID_simple (s) : return ID_table[np.dot(ID_verify,[int(j) for j in s[:17 ]])%11 ]==s[17 ] if len(s)==18 and s[:17 ].isdigit() and (s[17 ] in ID_table) else False def clean_ID (s) : s = '' .join(list(filter(lambda x: x in ID_table,s))) if s=="" : return s n = len(s)-1 return '' .join(list(filter(str.isdigit,s[:n])))+s[n] def clean_info (s) : return '' .join(list(filter(lambda x: x not in string.punctuation,s)))
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 from itertools import permutationsfrom PIL import Imageimport PIL.ImageOpsimport numpy as npimport cv2from utils import *THRESH = 165 EDGE = 10 DIFF_SQRDIS = 1000 CANNY_LOW = 150 CANNY_HIGH = 200 HOUGH_THRESH = 50 HOUGH_MIN_LEN = 100 HOUGH_MAX_GAP = 10 LINES = 20 def opened (arr,invert=True) : blurred = cv2.GaussianBlur(arr,(9 ,9 ),0 ) _,binary = cv2.threshold(blurred,THRESH,255 ,cv2.THRESH_BINARY) kernel = cv2.getStructuringElement(cv2.MORPH_RECT,(5 ,5 )) closed = cv2.morphologyEx(binary,cv2.MORPH_CLOSE,kernel) result = cv2.morphologyEx(closed,cv2.MORPH_OPEN,kernel) return 255 -result if invert else result def bounding_box (opened_arr) : contours,_ = cv2.findContours(opened_arr,cv2.RETR_LIST,cv2.CHAIN_APPROX_SIMPLE) bounding_rect = cv2.minAreaRect(sorted(contours,key=cv2.contourArea,reverse=True )[1 ]) return np.int0(cv2.boxPoints(bounding_rect)) def crosses (lines,w,h) : points = [] unique_points = [] for a in lines: for b in lines: if not parallel(a,b): p = cross(a,b) if -EDGE <= p[0 ] <= w+EDGE and -EDGE <= p[1 ] <= h+EDGE: points.append(p) for i in range(len(points)): flag = True for j in range(i): if sqrdis(points[i],points[j]) < DIFF_SQRDIS: flag = False break if flag: unique_points.append(points[i]) return unique_points def matched (points) : assert (len(points)==4 ) points = sorted(points,key=lambda x:x[0 ]) if points[0 ][1 ] > points[1 ][1 ]: points[0 ],points[1 ] = points[1 ],points[0 ] if points[3 ][1 ] > points[2 ][1 ]: points[2 ],points[3 ] = points[3 ],points[2 ] return np.array(points,dtype=np.float32) def affine (arr) : h,w = arr.shape opened_arr = opened(arr) box = bounding_box(opened_arr) edges = cv2.Canny(opened_arr,CANNY_LOW,CANNY_HIGH,apertureSize=3 ) lines = cv2.HoughLinesP(edges, rho=1 , theta=np.pi/180 , threshold=HOUGH_THRESH, minLineLength=HOUGH_MIN_LEN, maxLineGap=HOUGH_MAX_GAP) lines = sorted(lines, key=lambda x:min([sqrdis(x[0 ][:2 ],box[0 ])+sqrdis(x[0 ][2 :],box[1 ]), sqrdis(x[0 ][2 :],box[0 ])+sqrdis(x[0 ][:2 ],box[1 ]), sqrdis(x[0 ][:2 ],box[1 ])+sqrdis(x[0 ][2 :],box[2 ]), sqrdis(x[0 ][2 :],box[1 ])+sqrdis(x[0 ][:2 ],box[2 ]), sqrdis(x[0 ][:2 ],box[2 ])+sqrdis(x[0 ][2 :],box[3 ]), sqrdis(x[0 ][2 :],box[2 ])+sqrdis(x[0 ][:2 ],box[3 ]), sqrdis(x[0 ][:2 ],box[3 ])+sqrdis(x[0 ][2 :],box[0 ]), sqrdis(x[0 ][2 :],box[3 ])+sqrdis(x[0 ][:2 ],box[0 ]) ]), reverse=False ) lines = [np.array(x[0 ],dtype=np.float32) for x in lines[:LINES]] points = crosses(lines,w,h) return cv2.getPerspectiveTransform(matched(points),np.array([[0 ,0 ],[0 ,h],[w,h],[w,0 ]],dtype=np.float32)) def align (img) : arr = np.array(img.copy().convert('L' )) h,w = arr.shape transform = affine(arr) arr = cv2.warpPerspective(np.array(img),transform,(w,h)) return Image.fromarray(arr) def normalized (img) : arr = np.array(img.copy().convert('L' )) arr_min = arr.min() arr_max = arr.max() arr = ((arr-arr_min)/(arr_max-arr_min)*255 ).astype(np.uint8) return Image.fromarray(arr)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 from copy import deepcopyimport subprocessimport imutilsimport timefrom preprocess import *src = "images/" tmp = "temp/" dst = "processed/" rst = "results/" CHINESE = ( "chi_sim_43327" ) LAN = {'Z' :"-l " +CHINESE,'N' :"" ,'E' :"" } config = {} def make_environment () : global config if not os.path.exists(src): os.mkdir(src) clear(tmp) if not os.path.exists(dst): os.mkdir(dst) if not os.path.exists(rst): os.mkdir(rst) config = read_json("config.json" ) def tesseract (FILE,TYPE,RESULT) : command = 'tesseract {0} {1} {2}' .format(LAN[TYPE],FILE,RESULT) return subprocess.Popen(command,shell=True ) def OCR (PIC_NAME,move=False,delete=False) : PATH = src+PIC_NAME category = (deepcopy(config[PIC_NAME[:2 ]]) if PIC_NAME[:2 ] in config else {'info' :{'Z0' :[0. ,0. ,1. ,1. ]}})['info' ] result = {} thread = {} with Image.open(PATH) as img: w,h = img.size for key,box in category.items(): TEMP = tmp+key+"_" +PIC_NAME RESULT = tmp+key+"_" +PIC_NAME.split('.' )[0 ] box[0 ] *= w; box[1 ] *= h; box[2 ] *= w; box[3 ] *= h img_cr = img.crop([int(round(j)) for j in box]) if key[0 ]=='M' : img_cr.save(rst+key[1 :]+"_" +PIC_NAME) result[key] = key[1 :]+"_" +PIC_NAME else : img_cr = normalized(img_cr) img_cr.save(TEMP,dpi=(300.0 ,300.0 )) thread[key] = tesseract(TEMP,key[0 ],RESULT) for key,box in category.items(): if key[0 ]=='M' : continue TEMP = tmp+key+"_" +PIC_NAME RESULT = tmp+key+"_" +PIC_NAME.split('.' )[0 ]+".txt" thread[key].wait() with open(RESULT,"r" ,encoding='UTF-8' ) as f: result[key] = clean(f.read()) if key[0 ]=='N' : result[key] = clean_num(result[key]) if move: with Image.open(PATH) as img: img.save(dst+PIC_NAME) if delete: os.remove(PATH) clear(tmp) return result def ID_Card_OCR (PIC_NAME) : assert (PIC_NAME[:2 ]=="sf" and "sf" in config) PATH = src+PIC_NAME ID_PIC = "id" +PIC_NAME[2 :] with Image.open(PATH) as img: w,h = img.size if w < h: img = img.transpose(Image.ROTATE_90); w,h = h,w img.save(PATH,dpi=(300.0 ,300.0 )) img.save(src+ID_PIC,dpi=(300.0 ,300.0 )) ID = clean_ID(OCR(ID_PIC,delete=True )['Eid' ]) if check_ID_simple(ID): result = OCR(PIC_NAME) else : with Image.open(PATH) as img: img = align(img) w,h = img.size w = int(h * config[PIC_NAME[:2 ]]['ratio' ]) img = img.resize((w,h),Image.ANTIALIAS) img.save(PATH,dpi=(300.0 ,300.0 )) result = OCR(PIC_NAME) ID = clean_ID(result['Eid' ]) if not check_ID_simple(ID): return None result['id' ] = ID result['picture' ] = result['Mpicture' ] result['name' ] = clean_info(result['Zname' ]) result['address' ] = clean_info(result['Zaddress' ]) del result['Eid' ],result['Mpicture' ],result['Zname' ],result['Zaddress' ] result['sex' ] = "男" if int(ID[16 ])%2 else "女" result['birthdate' ] = "{0}年{1}月{2}日" .format(ID[6 :10 ],ID[10 :12 ],ID[12 :14 ]) return result make_environment() print(ID_Card_OCR("sf1.jpg" ))
识别结果(已和谐
的识别完全正确,需要提醒一点,这个识别工具准确率很依赖于照片环境,不能保证一直高正确率):
1 {'id': 已和谐, 'picture': 'picture_sf8.jpg', 'name': '陈醉', 'address': 已和谐, 'sex': '男', 'birthdate': '2001年08月18日'}
完结撒花!!!
扫描二维码即可在手机上查看这篇文章,或者转发二维码来分享这篇文章: