├── README.md ├── card.py ├── db ├── DB.py └── gendb.py ├── main.py ├── obj_reco.py ├── proce.py ├── runserver.py ├── template.py ├── templates ├── correct.html ├── rank.html ├── result.html ├── success.html └── upload.html └── test1.png /README.md: -------------------------------------------------------------------------------- 1 | # AnswerCard 2 | 3 | ## 答题卡识别+网上阅卷 基于Python+OpenCV+flask+sqlite 4 | 5 | 6 | 本人河南2020高三党一枚 由于疫情宅家学习 平时组织考试同学互改效率很低 7 | 8 | 于是想起了去年写的这个程序 其中图像识别部分程序在初中毕业前就已基本完成 9 | 10 | 当时为了参加程序设计比赛周末只花了一天时间整理了下 功能非常简陋 11 | 12 | 而且没有系统学过程序设计 各模块较混乱 13 | 14 | 现在迫于学业压力也没有精力再进行完善 15 | 16 | **希望能分享给各路大神借鉴/开发** 17 | 18 | ## 思路及原理 19 | 用excel表格 按比例制作答题卡,并将外框加粗。 20 | 21 | 手机拍照后通过flask web上传,后端openCV识别答题区域边框并裁切 22 | 23 | 客观题自动识别填涂点位置 主观题按比例位置裁切 24 | 25 | 教师/学生通过web阅卷判分 最后汇总得分 26 | 27 | ## 使用说明 28 | ```pip install imutils==0.4.3 numpy==1.13.3 opencv-python==3.3.0.10 scipy==1.0.0 flask==1.0.2 -i https://pypi.tuna.tsinghua.edu.cn/simple some-package``` 29 | 30 | 先编辑好member.csv 学生名单,答题卡模板.xslx,并将对应的题目信息修改template.py 31 | 32 | 再执行python main.py 按提示生成数据库文件 并启动web服务器 33 | 34 | Web服务器默认端口5000 35 | ``` 36 | ├─source 37 | │ │ card.py #答题卡检测模块 38 | │ │ main.py #生成数据库模块 39 | │ │ obj_reco.py #填涂识别模块 40 | │ │ proce.py #图像处理模块 41 | │ │ runserver.py #web服务器启动 42 | │ │ template.py #答题卡配置文件 43 | │ │ 44 | │ ├─data #数据存放目录 45 | │ ├─db #数据库相关模块目录 46 | │ │ DB.py #数据库操作模块 47 | │ │ gendb.py #数据库创建模块 48 | │ └─templates #网页模板文件 49 | │ correct.html #批改页模板 50 | │ rank.html #排名页模板 51 | │ result.html #个人答案页模板 52 | │ success.html #上传成功模板 53 | │ upload.html #上传页模板 54 | URL 55 | / 上传图片 56 | /result/ 批改结果 57 | /correct/ 批改指定题目 58 | /rank 得分排名 59 | ``` 60 | 61 | ## 中国加油! 62 | -------------------------------------------------------------------------------- /card.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from imutils.perspective import four_point_transform 3 | import imutils 4 | import cv2 5 | 6 | 7 | def findcnt(img): 8 | edged = cv2.Canny(img, 50, 150) # 边缘图 9 | cnts = cv2.findContours(edged, cv2.RETR_EXTERNAL, 10 | cv2.CHAIN_APPROX_SIMPLE) # 寻找答题区边框 11 | cnts = cnts[0] if imutils.is_cv2() else cnts[1] 12 | docCnt = None 13 | if len(cnts) > 0: 14 | cnts = sorted(cnts, key=cv2.contourArea, reverse=True) # 按轮廓面积倒序 15 | for c in cnts: 16 | peri = cv2.arcLength(c, True) 17 | approx = cv2.approxPolyDP(c, 0.02 * peri, True) # 顶点数 18 | if len(approx) == 4:#四边形 19 | docCnt = approx 20 | break 21 | return four_point_transform(img, docCnt.reshape(4, 2) )# 四点变换并裁切 22 | 23 | 24 | def cut(img, area, wd, ht): # 裁切相应区域 25 | return img[(area[1]-1)*ht:area[3]*ht, (area[0]-1)*wd:area[2]*wd] 26 | 27 | def areasize(ax,ay,bx,by): 28 | return (bx-ax,by-ay) -------------------------------------------------------------------------------- /db/DB.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | conn = None 4 | cur =None 5 | def opendb(workd): 6 | global cur,conn 7 | conn = sqlite3.connect(workd+"/data.db",check_same_thread=False) 8 | cur = conn.cursor() 9 | 10 | 11 | def stuinfo(group,no):#小组号确定 12 | global cur 13 | cursor = cur.execute('SELECT * FROM stulist WHERE "group"=%d AND "no"=%d'%(group,no)) 14 | for r in cursor: 15 | return (r[0],r[3]) 16 | return None 17 | 18 | def stuname(stuid):#学生姓名确定 19 | global cur 20 | cursor = cur.execute('SELECT * FROM stulist WHERE "stuid"=%d'%stuid) 21 | for r in cursor: 22 | return (r[0],r[3]) 23 | return None 24 | 25 | def getResult(stuid):#获得试卷结果 26 | global cur 27 | result={"name":stuname(stuid)[1]} 28 | cursor = cur.execute('SELECT * FROM answerlist WHERE "stuid"=%d'%stuid) 29 | result["obj"]={} 30 | result["sub"]={} 31 | for r in cursor: 32 | if r[3]==1:# 客观题 33 | result["obj"][r[1]]=(r[2],r[4]) 34 | else:#主观题 35 | result["sub"][r[1]]=(r[2],r[4]) 36 | return result 37 | 38 | def saveAns(stuid,quesn,ans,type_,score): 39 | global cur 40 | cur.execute("INSERT INTO answerlist VALUES (%s,'%s','%s','%s',%s)" % (stuid,quesn,ans,type_,score))# 41 | 42 | def saveResult(result):#保存试卷结果 43 | global cur,conn 44 | stuid=result['stuid'] 45 | cur.execute('DELETE FROM answerlist WHERE "stuid"=%d'%stuid) 46 | for n,a in result["obj"].items(): 47 | saveAns(stuid,n,a[0],1,str(a[1])) 48 | for n,a in result["sub"].items(): 49 | saveAns(stuid,n,a,2,"null") 50 | conn.commit() 51 | 52 | def getSubAns(quesno):#互评 53 | global cur 54 | cursor = cur.execute('SELECT * FROM answerlist WHERE quesn="%s" AND score IS NULL LIMIT 0,1'%quesno) 55 | for c in cursor: 56 | return c 57 | return None 58 | 59 | def markedscore(stuid,quesno,score):#互评 60 | global cur,conn 61 | cur.execute('UPDATE answerlist SET score=%s WHERE quesn="%s" AND stuid=%s and score IS NULL'%(score,quesno,stuid)) 62 | conn.commit() 63 | 64 | def getRank(): 65 | global cur,conn 66 | cursor = cur.execute('SELECT s.name,s.stuid,sum(a.score) FROM answerlist a \ 67 | JOIN stulist s on a.stuid = s.stuid GROUP BY a.stuid ORDER BY a.score DESC') 68 | return list(cursor) 69 | -------------------------------------------------------------------------------- /db/gendb.py: -------------------------------------------------------------------------------- 1 | # 生成测验数据库 2 | import csv 3 | import sqlite3 4 | import template 5 | 6 | def genAnsTab(c): # 生成答题表 7 | c.execute('''CREATE TABLE "answerlist" ( 8 | "stuid" INTEGER NOT NULL, 9 | "quesn" TEXT, 10 | "answer" text, 11 | "type" integer, 12 | "score" real 13 | );''') 14 | def genQuesTab(c, tmp): # 生成题目表 15 | c.execute('''CREATE TABLE "questionlist" ( 16 | "quesn" NOT NULL, 17 | "answer" text, 18 | "type" integer, 19 | "score" real, 20 | PRIMARY KEY ("quesn") 21 | );''') 22 | 23 | for n, a in tmp.a_obj_list.items():## 添加客观题部分 type=1 24 | ans = tmp.ans_obj[int(n)-1] 25 | ascore = a[2] 26 | c.execute("INSERT INTO questionlist VALUES ('%s','%s',%d,%s)" % (n,ans,1,ascore)) 27 | 28 | for n, a in tmp.a_sub.items():## 添加客观题部分 type=2 29 | ans = None 30 | ascore = a[1] 31 | c.execute("INSERT INTO questionlist VALUES ('%s','%s',%d,%s)" % (n,ans,2,ascore)) 32 | 33 | def genStuTab(c, f_stulist): # 生成学生名单 34 | reader = csv.reader(f_stulist) 35 | c.execute('''CREATE TABLE "stulist" ( 36 | "stuid" INTEGER NOT NULL, 37 | "group" INTEGER, 38 | "no" INTEGER, 39 | "name" TEXT(8), 40 | PRIMARY KEY ("stuid") 41 | );''') 42 | n=0 43 | for row in reader: 44 | c.execute("INSERT INTO stulist VALUES (%s,%s,%s,'%s')" % tuple(row)) 45 | n+=1 46 | return n 47 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | import os 4 | import sqlite3 5 | import db.gendb as dg 6 | print("欢迎使用作业互动批改系统") 7 | print("作者:河南省南阳市一中 高二9班 李逸凡") 8 | 9 | testid = time.strftime("%Y%m%d%H%M%S", time.localtime()) 10 | workd = "./data/"+testid 11 | os.mkdir(workd) 12 | os.mkdir(workd+"/paper") 13 | os.mkdir(workd+"/raw") 14 | print("数据存放目录:"+workd) 15 | 16 | conn = sqlite3.connect(workd+"/data.db") 17 | c = conn.cursor() 18 | f_stul = input("请输入花名册csv文件路径:") 19 | try: 20 | with open(f_stul, "r", encoding="utf8") as f: 21 | n = dg.genStuTab(c, f) 22 | print("共导入%d人" % n) 23 | 24 | except FileNotFoundError: 25 | print("文件不存在") 26 | else: 27 | import template 28 | for n in template.a_sub.keys():## 客观题存放文件夹 29 | os.mkdir(workd+"/paper/"+n) 30 | dg.genQuesTab(c, template) 31 | dg.genAnsTab(c) 32 | conn.commit() 33 | print("生成题目数据表成功") 34 | 35 | print("请执行python runserver.py %s 来启动web服务器"%workd ) -------------------------------------------------------------------------------- /obj_reco.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from imutils.perspective import four_point_transform 3 | import imutils 4 | import cv2 5 | 6 | def recodot(img,wd,ht):#识别填涂点 7 | cnts = cv2.findContours(img, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) 8 | cnts = cnts[0] if imutils.is_cv2() else cnts[1] 9 | dots=[] 10 | for c in cnts: 11 | (x, y, w, h) = cv2.boundingRect(c) 12 | if wd*1.5>w>wd*0.5 and ht*1.6>h>ht*0.4: 13 | M = cv2.moments(c) 14 | dX = int(M["m10"] / M["m00"]) 15 | dY = int(M["m01"] / M["m00"]) 16 | cv2.circle(img, (dX, dY), 7, 128, -1) 17 | dots.append((dX, dY)) 18 | return dots 19 | 20 | def posdot(dots,wd,ht):#化为相对网格坐标 21 | pos=[] 22 | for d in dots: 23 | pos.append((d[0]//wd+1,d[1]//ht+1)) 24 | return pos 25 | 26 | def toanswer(pos,alist,default=""): 27 | answer={} 28 | for no,ans in alist.items(): 29 | answer[no]=default 30 | for i in range(len(ans[1])): 31 | if((ans[0][0]+i,ans[0][1]) in pos): 32 | answer[no]+=ans[1][i] 33 | return answer -------------------------------------------------------------------------------- /proce.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from imutils.perspective import four_point_transform 3 | import imutils 4 | import cv2 5 | import obj_reco 6 | import card 7 | import template as te 8 | from template import row, col, wd, ht 9 | 10 | 11 | def autoresize(img): 12 | h, w = img.shape 13 | if w > h: 14 | if w > 1600: 15 | img = cv2.resize(img, (1600, int(h/w*1600)), cv2.INTER_LANCZOS4) 16 | else: 17 | if h > 1600: 18 | img = cv2.resize(img, (int(w/h*1600), 1600), cv2.INTER_LANCZOS4) 19 | return img 20 | 21 | 22 | def fromimg(d, p_wd, p_rawimg): 23 | result = {} 24 | image = cv2.imread(p_wd+"/raw/"+p_rawimg) 25 | kernel = np.uint8(np.zeros((5, 5))) 26 | for x in range(5): 27 | kernel[x, 2] = 1 28 | kernel[2, x] = 1 29 | 30 | gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 灰度图 31 | gray = autoresize(gray) 32 | # cv2.imshow("raw", gray) 33 | paper = card.findcnt(gray) 34 | #cv2.imshow("area", paper) 35 | # cv2.waitKey() 36 | if paper.size < 10000: 37 | return -2 38 | paper = cv2.resize(paper, (row*wd, col*ht), cv2.INTER_LANCZOS4) 39 | paperth = cv2.adaptiveThreshold( 40 | paper, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 33, 7) 41 | paperth = cv2.morphologyEx(paper, cv2.MORPH_CLOSE, kernel) # 二值化并进行开运算 42 | paperth = cv2.threshold(paper, 70, 255, cv2.THRESH_BINARY)[1] 43 | 44 | # 个人信息处理 45 | i_info = card.cut(paperth, te.a_info, wd, ht) 46 | d_info = obj_reco.recodot(i_info, wd, ht) 47 | #cv2.imshow("info_area", i_info) 48 | p_info = obj_reco.posdot(d_info, wd, ht) 49 | info = obj_reco.toanswer(p_info, te.a_info_list, 0) 50 | who = d.stuinfo(info["group"], info["no"]) 51 | if who: 52 | result["stuid"] = who[0] 53 | result["name"] = who[1] 54 | 55 | else: 56 | return -1 57 | 58 | i_obj = card.cut(paperth, te.a_obj, wd, ht) 59 | d_obj = obj_reco.recodot(i_obj, wd, ht) 60 | #cv2.imshow("select_area", i_obj) 61 | p_obj = obj_reco.posdot(d_obj, wd, ht) 62 | ans_obj = obj_reco.toanswer(p_obj, te.a_obj_list) 63 | for n, a in ans_obj.items(): # 判分 64 | score = 0 65 | if te.ans_obj[int(n)-1] == a: 66 | score = te.a_obj_list[n][2] 67 | ans_obj[n] = (a, score) 68 | 69 | result["obj"] = ans_obj 70 | result["sub"] = {} 71 | for n, a in te.a_sub.items(): 72 | i_ = card.cut(paper, a[0], wd, ht) 73 | #cv2.imshow("q%s_area" % n, i_) 74 | ans_t = "/%s/%d.jpg" % (n, result["stuid"]) 75 | cv2.imwrite(p_wd+"/paper"+ans_t, i_) 76 | result["sub"][n] = ans_t 77 | d.saveResult(result) 78 | # cv2.waitKey() 79 | return result 80 | -------------------------------------------------------------------------------- /runserver.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask import render_template 3 | from flask import request, Response 4 | import time 5 | import os 6 | import sys 7 | import db.DB 8 | 9 | app = Flask(__name__) 10 | workdir = "" 11 | 12 | 13 | @app.route('/') 14 | def index(): 15 | return render_template('upload.html') 16 | 17 | 18 | @app.route('/cardimg', methods=['POST']) 19 | def upload(): 20 | timestamp = time.strftime("%Y%m%d%H%M%S", time.localtime()) 21 | if request.method == 'POST': 22 | f = request.files['card'] 23 | f.save(workdir + "/raw/"+timestamp+".jpg") 24 | import proce 25 | r = proce.fromimg(db.DB, workdir, timestamp+".jpg") 26 | if r == -2: 27 | return "图像识别错误,请尝试重新拍摄" 28 | if r == -1: 29 | return "未找到学生信息" 30 | db.DB.saveResult(r) 31 | return render_template("success.html", r=r) 32 | 33 | 34 | @app.route('/cardimg//') 35 | def showimg(who, qno): 36 | with open("%s/paper/%s/%s" % (workdir, qno, who), 'rb') as image: 37 | image = image.read() 38 | resp = Response(image, mimetype="image/jpeg") 39 | return resp 40 | 41 | 42 | @app.route('/result/') 43 | def showres(stuid): 44 | r = db.DB.getResult(int(stuid)) 45 | return render_template("result.html", r=r) 46 | 47 | 48 | @app.route('/correct/') 49 | def correct(qno): 50 | t = db.DB.getSubAns(qno) 51 | if t == None: 52 | return "啊哈,没有可以批改的了" 53 | return render_template("correct.html", quesno=qno, stuid=t[0], a=t[2]) 54 | 55 | 56 | @app.route('/correct', methods=['POST']) 57 | def marked(): 58 | if request.method == 'POST': 59 | quesno = request.form['quesno'] 60 | stuid = request.form['stuid'] 61 | score = request.form['score'] 62 | db.DB.markedscore(stuid, quesno, score) 63 | return '成功,再改一份' % quesno 64 | 65 | 66 | @app.route('/rank') 67 | def rank(): 68 | ranks = db.DB.getRank() 69 | return render_template("rank.html", ranks=ranks) 70 | 71 | 72 | if __name__ == '__main__' and len(sys.argv) > 1: 73 | workdir = sys.argv[1] 74 | db.DB.opendb(os.path.abspath(workdir)) 75 | print("正在启动web服务器") 76 | app.run('0.0.0.0') 77 | -------------------------------------------------------------------------------- /template.py: -------------------------------------------------------------------------------- 1 | workn = "每日作业20" 2 | 3 | row, col = 25, 33 # 网格的列、行数 4 | wd, ht = 50, 29 # 网格的宽、高对应图片上的像素点数 5 | 6 | a_info=(20,2,25,4) #考生信息区答题区域网格范围 7 | a_info_list={"group":((2,1),(1,2,4,8)),#考生信息区答题区域设置 信息项:第一个选项位置,选项内容 8 | "no":((2,2),(1,2,4,8))} 9 | 10 | a_obj = (1, 4, 25, 9) # 客观题答题区域网格 11 | a_obj_list={"1":((3,1),"ABCD",3),#客观题选项设置区域(相对) 题号:第一个选项位置,选项内容,分值 12 | "2":((3,2),"ABCD",3), 13 | "3":((3,3),"ABCD",3), 14 | "4":((3,4),"ABCD",3), 15 | "5":((3,5),"ABCD",3), 16 | 17 | "6":((9,1),"ABCD",3), 18 | "7":((9,2),"ABCD",3), 19 | "8":((9,3),"ABCD",3), 20 | "9":((9,4),"ABCD",3), 21 | "10":((9,5),"ABCD",3), 22 | 23 | "11":((15,1),"ABCD",3), 24 | "12":((15,2),"ABCD",3), 25 | "13":((15,3),"ABCD",3), 26 | "14":((15,4),"ABCD",3), 27 | "15":((15,5),"ABCD",3), 28 | 29 | "16":((21,1),"ABCD",3), 30 | "17":((21,2),"ABCD",3), 31 | "18":((21,3),"ABCD",3), 32 | "19":((21,4),"ABCD",3), 33 | "20":((21,5),"ABCD",3)} 34 | 35 | a_sub = {"21":((1, 10, 25, 23),20),#主观题 题号:网格范围,分值 36 | "22":((1, 24, 25, 33),20)} 37 | 38 | ans_obj=["A","C","B","D","C","A","C","B","D","C","A","C","B","D","C","A","C","B","D","C"]##客观题答案 -------------------------------------------------------------------------------- /templates/correct.html: -------------------------------------------------------------------------------- 1 | 2 | 作业互评-{{quesno}} 3 |
4 | 5 | 6 | 7 | 8 | 9 |

输入得分

10 | 11 |
-------------------------------------------------------------------------------- /templates/rank.html: -------------------------------------------------------------------------------- 1 | 2 | 查看排名 3 | 4 | 8 |

作业排名

9 | 10 | 11 | 12 | 13 | 14 | 15 | {% for s in ranks %} 16 | 17 | 18 | 19 | 20 | {% endfor %} 21 | 22 | 23 |
姓名得分
{{s[0]}}{{s[2]}}
-------------------------------------------------------------------------------- /templates/result.html: -------------------------------------------------------------------------------- 1 | 2 | 查看作业 3 | 4 | 11 |

姓名:{{r["name"]}}

12 |

客观题

13 | 14 | 15 | {% for n in r["obj"].keys() %} 16 | 17 | {% endfor %} 18 | 19 | 20 | {% for a in r["obj"].values() %} 21 | {% if a[1]==0%} 25 | {% endfor %} 26 | 27 |
{{n}}
22 | {% else %} 23 | {% endif %} 24 | {{a[0]}}
28 |

主观题

29 | {% for n,a in r["sub"].items() %} 30 |

{{n}} 得分:{{a[1]}}/互评

31 | 32 | {% endfor %} -------------------------------------------------------------------------------- /templates/success.html: -------------------------------------------------------------------------------- 1 | 2 | 上传成功 3 | 4 | 7 | 8 |

{{r["name"]}},答案提交成功 9 | 查看结果

-------------------------------------------------------------------------------- /templates/upload.html: -------------------------------------------------------------------------------- 1 | 2 | 在线提交作业 3 |
4 | 请上传答题卡图片 5 | 6 | 7 |
-------------------------------------------------------------------------------- /test1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyifan2002/AnswerCard/0315c2e475acbafdbf3655b3cd8a73306457cbee/test1.png --------------------------------------------------------------------------------