基于Tesseract的简易身份证OCR

利用谷歌提供的Tesseract开源OCR工具来实现一个准确率一般的、简易的身份证信息提取工具。

了解Tesseract

Tesseract是谷歌提供的一个开源OCR(Optical Character Recognition, 光学字符识别)工具,可以从图片中提取字符。支持英语、中文(简体/繁体以及横式/竖式)、数学公式以及上百种语言。其对于英文和数字的识别准确率比较高,但是对中文字符的识别准确率相对较低。

可以从Tesseract的Github仓库直接下载使用,也可以安装python的模块pytesseract,一种更加方便的方式是直接下载自动安装程序。本文使用tesseract-ocr-w64-setup-v4.0.0.exe,资源可以很容易在网络上找到并下载,这里就不再给出。

tesseract-ocr-w64-setup-v4.0.0.exe 安装界面

安装时可以根据需要在Additional script dataAdditional language data中安装对应的语言包,或者使用其他地方下载得到的语言包。安装完成后,使用前可能需要配置环境变量:

配置环境变量TESSDATA_PREFIX

然后在安装路径(如上图,默认为C:\Program Files (x86)\Tesseract-OCR\)中运行命令行,使用tesseract [-l package] xxx.jpg yyy来进行一次图片识别,其中-l package表示使用哪个语言包,不写则默认为-l engxxx.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)$,右下角视为$(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
# utils.py
import numpy as np
import shutil
import string
import json
import math
import os

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)


识别

首先做好环境的准备: 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
# OCR.py
from copy import deepcopy
import subprocess
import requests
import imutils
import time
from preprocess import *

src = "images/"
tmp = "temp/"
dst = "processed/"
rst = "results/"
CHINESE = (
# "chi_sim_2412"
# "chi_sim_12771"
"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
# OCR.py
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
# OCR.py
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.show()
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 \sim 255$范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# preprocess.py
from itertools import permutations
from scipy import ndimage
from PIL import Image
import PIL.ImageOps
import numpy as np
import cv2
from 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
# utils.py
def clean(s):
return(s.replace(" ","")
.replace("\r","")
.replace("\n","")
.replace("\t","")
.replace("\f","")
.replace("\v",""))

这样就可以对身份证号和身份证地址进行基本的识别了。身份证号识别(测试了几张身份证以及不同角度)基本不会出错,而住址识别一般会出现$0 \sim 2$个汉字的错误,关键信息基本不会出错。

在保证光照均匀的情况下准确率都很高,有一些(我刻意测试)的光照情况下即使人看清上面某个特定位置的字也很困难,除此以外表现基本是可以接受的。


了解身份证

已经识别了身份证号和家庭地址,那么接下来就可以推广到识别整个身份证上的信息了。需要对所有信息分别找出其在身份证上的相对位置,写入配置文件中。实践证明,如果信息片面积过小,识别准确率会很低。性别、民族、出生年、出生月、出生日,识别框很小,只有一个汉字或几个数字,几乎很难识别正确。而姓名对于常见字识别准确率较高,相对不常见、复杂的字识别准确率则较低。比如”醉”和”醇”有时傻傻分不清楚。

然而,身份证是非常特殊的。了解身份证可以对识别有帮助!这就需要了解身份证号的组成了: 二代居民身份证号码为$18$位,其中最后一位是校验码,可能出现X。计算方法为(来自百度百科):

  1. 将前面的身份证号码17位数分别乘以不同的系数。从第一位到第十七位的系数分别为:7-9-10-5-8-4-2-1-6-3-7-9-10-5-8-4-2

  2. 将这17位数字和系数相乘的结果相加

  3. 用加出来和除以11,看余数是多少

  4. 余数只可能有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
# utils.py
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
# utils.py
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
# utils.py
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
# OCR.py
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.show()
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

然而很难要求身份证总是拍的正正好好并且裁剪得完美。这样如果稍微有一点歪斜,原来手动标注的识别框就会完全失效。作为下一阶段的检测样例,刻意选取一个很过分的角度:

手动标注一时爽,换个角度火葬场

即使是角度不对,也不能太过分。为了简单起见,对图片进行下列假定:

  1. 整个身份证都出现在图片内

  2. 图片光照均匀,无强光,无严重阴影

  3. 背景最好为均匀纯色,减少线条和明显差异

  4. 身份证倾角不会过大,最好在45以内

虽然由于桌子的木纹和边缘,上述照片其实不满足条件$3$。不过最后效果还是不错的。

主要思路是,在背景均匀、身份证完全位于图片内的条件下,利用身份证的边缘作为从图像中识别出身份证位置的依据。核心问题就是寻找身份证的四个角点,只要找到四个角点,进行一次仿射变换即可将提取出规整的身份证照片


进一步预处理

首先就要让身份证的轮廓更加明显。因此采用常用的图像预处理方式: 灰度化-归一化-去噪声-二值化-腐蚀和膨胀/闭运算和开运算。灰度化和归一化前面已经进行过了。

代码方面,OpenCV的cv2PILImage是两个完全不同的系统,需要注意区分。代码中用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
result = 255-result

反色图像


完整的图像处理函数(不包括之前已经做了的灰度化和二值化):

1
2
3
4
5
6
7
8
# preprocess.py
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_LOWCANNY_HIGH处也要填入两个玄学的常数,高于第二个阈值的像素会被直接保留,低于第一个阈值的像素会被直接抛弃,在两个阈值之间的像素会被进行进一步的检测(比较周围像素)。

1
2
opened_arr = opened(arr)
edges = cv2.Canny(opened_arr,CANNY_LOW,CANNY_HIGH,apertureSize=3)

Canny边缘检测结果,你看马赛克都不用打了(雾)


接下来从这些边缘中提取线段,使用OpenCV提供的霍夫变换。使用cv2.HoughLines会得到直线到原点距离线段的极坐标表示,使用cv2.HoughLinesP会得到线段直角坐标表示。rhotheta顾名思义分别规定了距离和角度的精度要求,这两项一般不动。接下来又是喜闻乐见的常数环节,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$条线段在图上做出来观察一下识别效果。到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$条线,但是霍夫变换返回的有很多线条可能是接近重合的。同样,它们的顺序也不清楚。边缘检测在这张图片中效果不错,但在其他图片中效果并没有这么好。在一些其他图片中,由于不能保证背景均匀和光照均匀,可能会出现不想要的线条。因此直接用这些线条来计算身份证的四个角是不太好的。而且由于视角的变化,这些线段甚至不一定成$90$度,因此一个很大的难点就是如何保证找到的线段一定是身份证的边界而不是背景中的线条呢?

需要思考进一步优化识别、区分线段的方法。


矩形包围盒

除了边缘检测,另一个思路是寻找身份证的矩形包围盒。

身份证的性状是一个规则的四边形,可以考虑通过寻找包围盒来获得身份证的位置。使用cv2.findContours首先将图像的轮廓描出来,然后利用轮廓计算包围盒cv2.minAreaRect。注意到cv2.findContours也会找出很多个轮廓。因此假定身份证图片是图片中最大的轮廓,这个假设在背景均匀时一般比较合理。

1
2
3
4
5
# preprocess.py
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
# utils.py

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$条线段。顺手把线段的二层列表去掉一层。

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]]

重新排序的前20线段,这张图恰巧没什么区别...其他一些照片还是有区别的


然后对线段求交,正如上文所说,霍夫变换会得到很多重复的线段,因此求交点之前需要判断平行,求出交点后需要去重(常数DIFF_SQRDIS自己取,注意是平方距离所以这个值要比较大)。另外,要将一些落在图像外的明显是噪音的点去掉,对于恰好落在图像边缘附近的点宽容一些空间(常数EDGE自己取)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# preprocess.py
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)$。

首先注意到这四个点是乱序的,按逆时针顺序匹配一下。假设存在一条竖直中线可以将身份证分为左右两部分,这个假设也是合理的,否则根据身份证的长宽比$1.5\dot 85 \dot 1$,身份证倾角会达到$57°$。那么就认为$0$号点是左侧高度较高的点,$1$号点是左侧高度较低的点,$2$号点是右侧高度较低的点,$3$号点是右侧高度较高的点。

1
2
3
4
5
6
7
8
9
# preprocess.py
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 \times 3$矩阵来表示,三个点就可以确定一个变换。因此只需要使用身份证四个角点中的三个,利用cv2.getAffineTransform来寻找$0,1,2$号点变为$(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
# preprocess.py
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)
# 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)
return cv2.getPerspectiveTransform(matched(points),np.array([[0,0],[0,h],[w,h],[w,0]],dtype=np.float32))

注意一下OpenCV格式(numpy.ndarray)存储的图片的性状是$h \times w$,与PIL.Image.size的$w \times h$不同。


接下来在没有处理过的原图上进行仿射变换就可以得到正规的身份证照片了。

1
2
3
4
5
6
7
# preprocess.py
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$是图像的长宽比。因此利用"ratio"对图片resize一下:

1
img = img.resize((w,h),Image.ANTIALIAS)

看一看正规化后、比例正确的照片(代码中直接用正规化后的照片替换了原照片):

OHHHHHHHHHHHHHHHHHHHHHHHHH!

真的是从之前那张照片变换回来的!!!


了解身份证 · 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
# OCR.py
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.show()
# exit(0)
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
# utils.py
import numpy as np
import shutil
import string
import json
import math
import os

DIFF_ANGLE = 10

# ==================== File Operations ====================

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)

# ==================== Geometry ====================

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)

# ==================== OCR ====================

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 Card ====================

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
# preprocess.py
from itertools import permutations
from PIL import Image
import PIL.ImageOps
import numpy as np
import cv2
from 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)
# 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)
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
# OCR.py
from copy import deepcopy
import subprocess
import imutils
import time
from preprocess import *

src = "images/"
tmp = "temp/"
dst = "processed/"
rst = "results/"
CHINESE = (
# "chi_sim_2412"
# "chi_sim_12771"
"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])
# img_cr.show()
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.show()
# exit(0)
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日'}

完结撒花!!!


扫描二维码即可在手机上查看这篇文章,或者转发二维码来分享这篇文章:


文章作者: Magolor
文章链接: https://magolor.cn/2020/02/13/2020-02-13-blog-01/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Magolor
扫描二维码在手机上查看或转发二维码以分享Magolor的博客