0. why
最近考研刷题,刷数学660,好多错题,感觉手抄太费事。平时刷题有注意到660的排版非常有规律性,题目和答题区域在图形上有很好的区分,我就寻思着能不能用opencv把题目从图像中剪裁出来并按顺序导出,整理错题的时候就能单独打印出指定的题目而不用自己去手动找到题目并剪裁。
1. 准备
opencv什么的只是听说过,从来没用过,于是找了找相关的网课学习了下,估摸着学了些完成本需求所需的知识,现在整理如下。
1.1 图像的基本操作
import cv2
def cv_show(img, name=''):
cv2.imshow(name,img)
cv2.waitKey(0)
cv2.destroyAllWindows()
### 读写 ###
img = cv2.imread('test.png') # 读取的图像颜色格式是BGR
cv_show(img)
cv2.imwrite('test_write.png',img) # 数据写入
### 图像的宽高和颜色通道 ###
img.shape # (h,w,c) c=3 说明RGB彩色图 三个颜色通道
### 截取部分图像数据 ###
img = cv2.imread('test.png')
test = img[0:200,0:200]
cv_show(test)
### 颜色通道提取 ###
b,g,r = cv2.split(img) # 分解 变成三个shape为(h,w,1)的矩阵
img = cv2.merge((b,g,r)) # 合并
1.2 图像阈值
ret, dst = cv2.threshold(src, thresh, maxval, type)
- src: 输入图,只能输入单通道图像,通常来说是灰度图
- dst: 输出图
- thresh: 阈值
- maxval: 当像素超过了阈值(或小于阈值,根据type来决定),所赋予的值
- type: 二值化操作了类型,包括以下五种类型
- cv2.THRESH_BINARY 超过阈值部分取maxval(最大值),否则取0
- cv2.THRESH_BINARY_INV THRESH_BINARY的反转
- cv2.THRESH_TRUNC 大于阈值部分设为阈值,否则不变
- cv2.THRESH_TOZERO 大于阈值部分不改变,否则设为0
- cv2.THRESH_TOZERO_INV THRESH_TOZERO的反转
1.3 图像平滑
import cv2
import random
def PepperandSalt(src,percetage):
''' 图像加椒盐噪声 '''
NoiseImg=src
NoiseNum=int(percetage*src.shape[0]*src.shape[1])
for i in range(NoiseNum):
randX=random.randint(0,src.shape[0]-1)
randY=random.randint(0,src.shape[1]-1)
if random.randint(0,1)<=0.5:
NoiseImg[randX,randY]=0
else:
NoiseImg[randX,randY]=255
return NoiseImg
lena_origin = cv2.imread('lena_std.tif')
cv_show(lena_origin)
lena = PepperandSalt(lena_origin, 0.01)
cv_show(lena)
### 均值滤波 ###
# 简单的平均卷积操作
blur = cv2.blur(lena,(3,3))
cv_show(blur)
### 方框滤波 ###
# 基本和均值一样,可以选择归一化
box = cv2.boxFilter(lena,-1,(3,3),normalize=True) # 归一化:3x3矩阵中所有值加起来除以9
cv_show(box)
box = cv2.boxFilter(lena,-1,(3,3),normalize=False) # 不归一化:3x3矩阵中所有值加起来不除以9,可能会越界
cv_show(box)
### 高斯滤波 ###
# 高斯模糊的卷积核里的数值是满足高斯分布,相当于重视中间的
aussian = cv2.GaussianBlur(lena,(5,5),1)
cv_show(aussian)
### 中值滤波 ###
# 相当于用中值代替
median = cv2.medianBlur(lena,5)
cv_show(median)
1.4 图像轮廓
findContours(image, mode, method[, contours[, hierarchy[, offset]]]) -> contours, hierarchy
- mode:
- RETR_CCOMP :检测所有轮廓,并将它们组织为两层:顶层是各部分的边界,第二层是空洞的边界;
- RETR_EXTERNAL:只检索最外边的轮廓;
- RETR_LIST:检测所有轮廓,并将它们保存到一条链表当中;
- RETR_TREE:检测所有轮廓,并重构嵌套轮廓的整个层次;
- method:
- CHAIN_APPROX_NONE:以Freeman链码的方式输出轮廓,所有其他方法输出多边形(顶点的序列)
- CHAIN_APPROX_SIMPLE:以压缩水平的、垂直的和倾斜的部分,也就是,函数只保留他们的终点部分
img = cv2.imread('example.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY) # 转换为灰度图
ret, thresh = cv2.threshold(gray,210,255,cv2.THRESH_BINARY) # 二值化
m_blur = cv2.medianBlur(thresh,9) # 中值滤波
contours, hierarchy = cv2.findContours(m_blur,cv2.RETR_LIST,cv2.CHAIN_APPROX_NONE) # 获取轮廓
### 绘制轮廓 ###
# 传入绘制图像,轮廓,轮廓索引,颜色模式,线条宽度
# 会改变原图,需要copy
draw_img = img.copy()
res = cv2.drawContours(draw_img,contours,-1,(0,0,255),1)
cv_show(res)
### 轮廓特征 ###
cnt = contours[0]
# 面积
cv2.contourArea(cnt)
# 周长
cv2.arcLength(cnt,True)
### 最小外接矩形 ###
# minAreaRect(points) -> retval
# 返回值 = (矩形的中心(x,y),(宽度,高度),旋转角度)
rect = cv2.minAreaRect(cnt)
box = cv2.boxPoints(rect) # 将rect转化为矩形四个顶点
box = np.int0(box) # 取整
### 按照给定角度旋转图像 ###
# getRotationMatrix2D((旋转图像的中心点),旋转角度,图像缩放因子)
h,w,_ = img.shape
angle = rect[2]
M = cv2.getRotationMatrix2D((w/2,h/2),angle,1) # 获取旋转矩阵
rotated = cv2.warpAffine(img,M,(w,h)) # 应用旋转矩阵获得旋转后图像
2. 方案
为了获得纠错笔记的上下底纵坐标需要将纠错笔记区域识别出来
- 首先对图像进行预处理,这一页上纠错区域是紫色,G颜色通道变化率比较大,因此将G通道摘出来单独分析
- 对单独摘出来的G通道图像,先对他二值化,再对二值化的图像进行中值滤波去除黑色区域中的白点,效果如图,可以得到轮廓明显的黑白图像
- 对二值化后的图像提取轮廓,按照面积过滤所需的轮廓,得到纠错区的轮廓
- 对所得的轮廓求最小外接矩形,这是可以得知扫描图像上文字相对水平线的角度
- 利用这一角度旋转图像,并对旋转后的图像重复1-3步骤重新获得一个最小外接矩形,通过矩形的四个角的坐标可以得到所求纠错区域上下底边纵坐标
- 在旋转后的图像上绘制矩形轮廓并保存,将坐标与图像文件名对应保存
- 保存的矩形轮廓是否贴合纠错区域,不贴合的手动修改保存的坐标
- 使用保存的坐标剪裁旋转后的图片,并保存需要的区域
3. 实现
分为两个部分,首先是角度调整和获取剪裁坐标的convert.py
import cv2 as cv2
import numpy as np
import os
from multiprocessing import Pool, Value
'''
目录结构说明
. 当前工作目录
./src 图源
./src/zi 图源中紫色印刷页
./src/lv 图源中绿色印刷页
./src/cheng 图源中橙色印刷页
./check 保存绘制了纠错区轮廓的旋转后的图片,用于检查轮廓准确性
./rotate 保存旋转后的图像
./index.txt 保存每个文件对应的剪裁坐标
'''
base = os.path.abspath('.')
src = os.path.join(base, 'src')
zi = os.path.join(src,'zi')
lv = os.path.join(src,'lv')
cheng = os.path.join(src,'cheng')
ZI_t = 215
LV_t = 230
CHENG_t = 210
ZI_i = 1 # G
LV_i = 2 # R
CHENG_i = 0 # B
colors = [
(ZI_i, zi, ZI_t),
(LV_i, lv, LV_t),
(CHENG_i, cheng, CHENG_t)
]
check_path = os.path.join(base,'check')
rotated_path = os.path.join(base,'rotate')
DEBUG = True
test_no = 0
def show_img(img):
global test_no
if not DEBUG:
return 0
test_path = os.path.join(base,'test')
cv2.imwrite(os.path.join(test_path,f'{test_no}.jpg'),img)
test_no += 1
def grayify(index: int,img):
return img[:,:,index]
def get_cnts(t,img):
# 调整 change_log 210 220
ret, thresh = cv2.threshold(img,t,255,cv2.THRESH_BINARY)
show_img(thresh)
m_blur = cv2.medianBlur(thresh, 9)
show_img(m_blur)
# 边界
contours, hierarchy = cv2.findContours(m_blur,cv2.RETR_TREE,cv2.CHAIN_APPROX_NONE)
# 筛选边界
cnts = list(filter(lambda a:cv2.contourArea(a)>200000 and cv2.contourArea(a)<1000000,contours))
draw_img = img.copy()
res = cv2.drawContours(draw_img,contours,-1,(0,0,255),3)
show_img(res)
l_ = len(cnts)
print(l_)
if l_ < 2 and t<215:
return get_cnts(t+1,img)
return cnts,l_
def cvt(i:str, BGR_I, src_path,T):
try:
# 读取
print(f"processing: {i}")
is_jpg = len(i.split('.'))
if is_jpg == 1:
return 0
in_ = cv2.imread(os.path.join(src_path,i))
h,w,_ = in_.shape
img = grayify(BGR_I, in_)
# 取得边界
cnts, l_ = get_cnts(T,img)
# 计算图形的旋转角度
angle = 0
for cnt in cnts:
rect = cv2.minAreaRect(cnt)
a = rect[2]
if a < -45:
a += 90
angle += (a/l_)
print(f'fix angle: {angle}')
# 计算旋转矩阵并旋转
M = cv2.getRotationMatrix2D((w/2,h/2),angle,1)
rotated = cv2.warpAffine(in_,M,(w,h))
# 将旋转后的图像保存
cv2.imwrite(os.path.join(rotated_path,i),rotated)
# 处理旋转后的图像
gray = grayify(BGR_I, rotated)
cnts, l_ = get_cnts(T,gray)
# 计算边界的最小外接矩形
min_area_rects = [cv2.minAreaRect(cnt) for cnt in cnts]
# 绘制最小外接矩形,并获取切割信息
boxs = []
cut_info = []
for m in min_area_rects:
box = cv2.boxPoints(m)
box = np.int0(box)
boxs.append(box)
heights = [i[1] for i in box]
cut_info.extend([min(heights),max(heights)])
# 写入切割索引
cut_info = sorted(cut_info)
cut_info = list(map(lambda a: str(a),cut_info))
cut_info = ','.join(cut_info)
print(cut_info)
with open('index.txt','a+') as f:
f.write(f'{i}:{cut_info}\n')
draw_img = rotated.copy()
res = cv2.drawContours(draw_img,boxs,-1,(255,0,0),3)
cv2.imwrite(os.path.join(check_path,i),res)
except Exception as e:
print(e)
if __name__ == "__main__":
with open('index.txt','a+') as f:
f.write('____________________________________\n')
p = Pool()
for BGR_I, src_path, T in colors:
imgs = os.listdir(src_path)
for i in imgs:
p.apply_async(cvt,args=(i, BGR_I, src_path, T))
# cvt(i)
p.close()
p.join()
然后是用于剪裁的cut.py
import cv2
import os
'''
目录说明
. 当前目录
./rotate 存放旋转的图片
./cut_output 保存剪裁后的图片
'''
base = os.path.abspath('.')
rotate = os.path.join(base,'rotate')
cut_output = os.path.join(base,'cut_output')
def parse_index():
with open('index.txt','r') as f:
data = f.readlines()
data = map(lambda a: a.strip(), data)
data_set = dict()
for d in data:
fn, cut_info = d.split(':')
cut_info = cut_info.split(',')
cut_info = list(map(int,cut_info))
# print(cut_info)
sec_num = int(len(cut_info)/2)
secs = []
for i in range(sec_num):
secs.append([])
# print(secs)
del cut_info[-1]
secs[0].append(0)
for i in range(len(cut_info)):
secs[int((i+1)/2)].append(cut_info[i])
data_set[fn] = secs
return data_set
if __name__ == "__main__":
c = 1
data_set = parse_index()
# print(data_set)
for i in sorted(data_set,key=lambda a: int(a.split('.')[0])):
img = cv2.imread(os.path.join(rotate,i))
for a,e in data_set[i]:
print(c,'.jpg')
cv2.imwrite(
os.path.join(cut_output,f'{c}.jpg'),
img[a:e,:,:]
)
c+=1
4. 总结
成功从分离出了书中的660道题。
但是我断断续续花了分散在一周中的50小时干这件事,如果用手抄错题早完活了,这一切都值得吗。。。