目錄
核心思路
分步實現與代碼解析
1. 環境準備與工具函數定義
2. 圖片預處理
3. 輪廓提取與篩選
3. 輪廓提取與篩選
4. 透視變換(矯正傾斜答題卡)
5. 閾值處理(突出填涂區域)
6. 提取選項圓圈輪廓
7. 選項輪廓排序(按題目順序排列)
在日常教學與考試場景中,人工批改答題卡不僅耗時耗力,還容易因主觀疲勞導致誤判。本篇將基于 OpenCV 實現全自動答題卡識別與改分,通過圖像處理技術精準提取答案區域、比對標準答案,并自動計算得分,大幅提升批改效率與準確性。
核心思路
答題卡識別改分的核心是 “從圖像中精準定位有效信息并與標準對比”,整體流程分為 9 個關鍵步驟:
- 圖片預處理(去噪、增強)
- 邊緣檢測(突出答題卡輪廓)
- 輪廓提取與篩選(定位答題卡主體)
- 輪廓近似(確定答題卡四角,為透視變換做準備)
- 透視變換(將傾斜答題卡矯正為正視圖)
- 閾值處理(將圖像轉為 “非黑即白”,突出填涂區域)
- 選項圓圈輪廓提取(定位每道題的 5 個選項)
- 答案比對(識別填涂選項,與標準答案匹配)
- 分數計算(統計正確率,生成最終得分)
分步實現與代碼解析
項目答題卡如下:
1. 環境準備與工具函數定義
首先導入所需庫,并定義圖像顯示函數(方便中間結果查看):
import cv2
import numpy as np# 圖像顯示函數:接收窗口名和圖像,按任意鍵關閉窗口
def cv_show(name, img):cv2.imshow(name, img)cv2.waitKey(0) # 等待按鍵輸入(0表示無限等待)cv2.destroyWindow(name) # 關閉當前窗口
2. 圖片預處理
原始圖像可能存在噪聲、色彩干擾,預處理的目標是 “簡化圖像信息,突出關鍵邊緣”,步驟包括:
- 灰度化:將彩色圖像轉為單通道灰度圖,減少計算量
- 高斯濾波:通過平滑處理去除高頻噪聲(如紙張紋理、拍攝噪點)
- 邊緣檢測:用 Canny 算法提取圖像邊緣,為后續輪廓定位做準備
"""-----1. 圖片預處理-----"""
# 讀取答題卡圖像(替換為你的圖像路徑)
image = cv2.imread(r'./images/answer_sheet_01.jpg')
contours_img = image.copy() # 備份原始圖像,用于后續繪制輪廓# 1.1 灰度化
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # BGR轉灰度(OpenCV默認讀取格式為BGR)
# 1.2 高斯濾波(5x5卷積核,標準差0,平衡去噪與邊緣保留)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
cv_show('Gaussian Blur (去噪后)', blurred) # 查看去噪效果
# 1.3 Canny邊緣檢測(閾值1=75,閾值2=200,僅保留對比度高的邊緣)
edged = cv2.Canny(blurred, 75, 200)
cv_show('Canny Edges (邊緣檢測結果)', edged) # 查看邊緣檢測效果
3. 輪廓提取與篩選
邊緣檢測后,需要從邊緣圖中提取閉合輪廓,并篩選出 “答題卡主體輪廓”(通常為矩形,即 4 個頂點):
"""-----2. 輪廓提取與篩選-----"""
# 2.1 提取輪廓(RETR_EXTERNAL:僅保留最外層輪廓;CHAIN_APPROX_SIMPLE:簡化輪廓點)
# OpenCV 3.x返回值為 (_, cnts, _),OpenCV 4.x返回值為 (cnts, _),此處兼容3.x
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 2.2 繪制所有輪廓(紅色,線寬3),查看輪廓提取效果
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('All Contours (所有輪廓)', contours_img)# 2.3 篩選答題卡輪廓(按面積降序排序,優先保留大面積輪廓)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
doc_cnt = None # 存儲答題卡的最終輪廓for c in cnts:# 計算輪廓周長(True表示輪廓閉合)peri = cv2.arcLength(c, True)# 輪廓近似(0.02*peri:近似精度,值越小越接近原始輪廓)approx = cv2.approxPolyDP(c, 0.02 * peri, True)# 答題卡為矩形,近似后輪廓應包含4個頂點if len(approx) == 4:doc_cnt = approxbreak# 繪制篩選出的答題卡輪廓(綠色,線寬2)
cv2.drawContours(image, [doc_cnt], -1, (0, 255, 0), 2)
cv_show('Answer Sheet Contour (答題卡輪廓)', image)
輪廓入戲:
3. 輪廓提取與篩選
邊緣檢測后,需要從邊緣圖中提取閉合輪廓,并篩選出 “答題卡主體輪廓”(通常為矩形,即 4 個頂點):
"""-----2. 輪廓提取與篩選-----"""
# 2.1 提取輪廓(RETR_EXTERNAL:僅保留最外層輪廓;CHAIN_APPROX_SIMPLE:簡化輪廓點)
# OpenCV 3.x返回值為 (_, cnts, _),OpenCV 4.x返回值為 (cnts, _),此處兼容3.x
cnts = cv2.findContours(edged.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 2.2 繪制所有輪廓(紅色,線寬3),查看輪廓提取效果
cv2.drawContours(contours_img, cnts, -1, (0, 0, 255), 3)
cv_show('All Contours (所有輪廓)', contours_img)# 2.3 篩選答題卡輪廓(按面積降序排序,優先保留大面積輪廓)
cnts = sorted(cnts, key=cv2.contourArea, reverse=True)
doc_cnt = None # 存儲答題卡的最終輪廓for c in cnts:# 計算輪廓周長(True表示輪廓閉合)peri = cv2.arcLength(c, True)# 輪廓近似(0.02*peri:近似精度,值越小越接近原始輪廓)approx = cv2.approxPolyDP(c, 0.02 * peri, True)# 答題卡為矩形,近似后輪廓應包含4個頂點if len(approx) == 4:doc_cnt = approxbreak# 繪制篩選出的答題卡輪廓(綠色,線寬2)
cv2.drawContours(image, [doc_cnt], -1, (0, 255, 0), 2)
cv_show('Answer Sheet Contour (答題卡輪廓)', image)
4. 透視變換(矯正傾斜答題卡)
實際拍攝的答題卡可能存在傾斜,透視變換可將 “傾斜的矩形” 轉為 “正對著鏡頭的矩形”,方便后續選項定位:
- 第一步:定義
order_points
函數,將 4 個頂點按 “左上→右上→右下→左下” 排序 - 第二步:定義
four_point_transform
函數,計算透視變換矩陣并應用變換
"""-----3. 透視變換(矯正答題卡)-----"""
def order_points(pts):"""將4個頂點按“左上(tl)→右上(tr)→右下(br)→左下(bl)”排序"""rect = np.zeros((4, 2), dtype="float32") # 初始化排序后的坐標# 1. 左上點:x+y最小;右下點:x+y最大s = pts.sum(axis=1) # 每個點的x+y求和rect[0] = pts[np.argmin(s)] # 左上(tl)rect[2] = pts[np.argmax(s)] # 右下(br)# 2. 右上點:x-y最小;左下點:x-y最大diff = np.diff(pts, axis=1) # 每個點的x-y差值(axis=1:按行計算后一列減前一列)rect[1] = pts[np.argmin(diff)] # 右上(tr)rect[3] = pts[np.argmax(diff)] # 左下(bl)return rectdef four_point_transform(image, pts):"""透視變換:將傾斜的答題卡轉為正視圖"""# 步驟1:獲取排序后的4個頂點rect = order_points(pts)tl, tr, br, bl = rect # 解包頂點坐標# 步驟2:計算目標圖像的寬度和高度(取最大值確保覆蓋完整答題卡)# 寬度:右下→左下 的水平距離 / 右上→左上 的水平距離width_a = np.sqrt(((br[0] - bl[0]) ** 2) + ((br[1] - bl[1]) ** 2))width_b = np.sqrt(((tr[0] - tl[0]) ** 2) + ((tr[1] - tl[1]) ** 2))max_width = max(int(width_a), int(width_b)) # 目標寬度# 高度:右上→右下 的垂直距離 / 左上→左下 的垂直距離height_a = np.sqrt(((tr[0] - br[0]) ** 2) + ((tr[1] - br[1]) ** 2))height_b = np.sqrt(((tl[0] - bl[0]) ** 2) + ((tl[1] - bl[1]) ** 2))max_height = max(int(height_a), int(height_b)) # 目標高度# 步驟3:定義目標圖像的4個頂點(正視圖的四個角)dst = np.array([[0, 0], # 左上[max_width - 1, 0], # 右上(-1是因為像素索引從0開始)[max_width - 1, max_height - 1], # 右下[0, max_height - 1] # 左下], dtype="float32")# 步驟4:計算透視變換矩陣M,應用變換得到正視圖M = cv2.getPerspectiveTransform(rect, dst) # 生成3x3變換矩陣warped = cv2.warpPerspective(image, M, (max_width, max_height)) # 執行變換return warped# 執行透視變換(doc_cnt是答題卡的4個頂點,需轉為float32格式)
warped = four_point_transform(image, doc_cnt.reshape(4, 2))
cv_show('Warped Sheet (矯正后答題卡)', warped)
5. 閾值處理(突出填涂區域)
矯正后的答題卡仍為灰度圖,通過 “閾值二值化” 將圖像轉為 “非黑即白”,讓填涂的選項(深色)與空白選項(白色)對比更強烈:
"""-----4. 閾值處理(突出填涂區域)-----"""
# 5.1 將矯正后的彩色圖轉為灰度圖
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
# 5.2 二值化(THRESH_BINARY_INV:黑白反轉;THRESH_OTSU:自動計算最佳閾值)
# 效果:填涂區域為白色(255),空白區域為黑色(0)
thresh = cv2.threshold(warped_gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
cv_show('Thresholded (二值化結果)', thresh)# 備份二值化圖像,用于后續繪制選項輪廓
thresh_copy = thresh.copy()
6. 提取選項圓圈輪廓
答題卡每道題包含 5 個圓形選項,需從二值化圖中提取這些圓圈輪廓,并篩選出 “符合選項大小” 的輪廓:
"""-----5. 提取選項圓圈輪廓-----"""
# 6.1 提取二值化圖中的所有輪廓(僅保留最外層輪廓)
option_cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[1]
# 6.2 繪制所有輪廓(綠色,線寬1),查看選項定位效果
warped_with_options = cv2.drawContours(warped.copy(), option_cnts, -1, (0, 255, 0), 1)
cv_show('All Option Contours (所有選項輪廓)', warped_with_options)# 6.3 篩選“有效選項輪廓”(排除過小/過扁的干擾輪廓)
valid_option_cnts = []
for c in option_cnts:# 獲取輪廓的邊界矩形(x:左上角x坐標,y:左上角y坐標,w:寬度,h:高度)x, y, w, h = cv2.boundingRect(c)# 計算寬高比(圓形的寬高比接近1)aspect_ratio = w / float(h)# 篩選條件:寬度≥20px、高度≥20px、寬高比0.9~1.1(接近圓形)if w >= 20 and h >= 20 and aspect_ratio >= 0.9 and aspect_ratio <= 1.1:valid_option_cnts.append(c)# 繪制篩選后的有效選項輪廓(紅色,線寬1)
warped_valid_options = cv2.drawContours(warped.copy(), valid_option_cnts, -1, (0, 0, 255), 1)
cv_show('Valid Option Contours (有效選項輪廓)', warped_valid_options)
? ? ? ? ? ? ? ?
7. 選項輪廓排序(按題目順序排列)
提取的選項輪廓可能雜亂無章,需按 “從上到下、從左到右” 排序,確保與題目順序對應:
"""-----6. 選項輪廓排序(按題目順序)-----"""
def sort_contours(cnts, method='left-to-right'):"""按指定方向排序輪廓:left-to-right(左右)、top-to-bottom(上下)"""reverse = False # 是否反向排序axis = 0 # 排序依據的軸(0:x軸,1:y軸)# 1. 確定排序方向和軸if method in ['right-to-left', 'bottom-to-top']:reverse = True # 反向排序(從右到左/從下到上)if method in ['top-to-bottom', 'bottom-to-top']:axis = 1 # 按y軸排序(上下方向)# 2. 為每個輪廓創建“邊界矩形”,按矩形的x/y軸坐標排序bounding_boxes = [cv2.boundingRect(c) for c in cnts] # 每個輪廓的邊界矩形# 按“邊界矩形的指定軸”排序(zip:將輪廓與矩形綁定;sorted:按軸排序)cnts, bounding_boxes = zip(*sorted(zip(cnts, bounding_boxes),key=lambda b: b[1][axis], # 排序鍵:矩形的axis軸坐標(x或y)reverse=reverse))return cnts, bounding_boxes# 7.1 先按“從上到下”排序(每道題的5個選項為一組)
sorted_option_cnts, _ = sort_contours(valid_option_cnts, method='top-to-bottom')
# 7.2 繪制排序后的輪廓(藍色,線寬1)
warped_sorted_options = cv2.drawContours(warped.copy(), sorted_option_cnts, -1, (255, 0, 0), 1)
cv_show('Sorted Option Contours (排序后選項輪廓)', warped_sorted_options)
8. 識別填涂答案與標準答案比對
核心邏輯:通過 “掩膜 + 像素計數” 識別每道題的填涂選項,再與標準答案對比,標記對錯:
? 掩膜(mask):為每個選項創建 “僅包含該選項的黑白圖”
? 像素計數:填涂選項的白色像素(255)數量遠多于空白選項,以此定位填涂位置
"""-----7. 答案識別與比對-----"""
# 8.1 定義標準答案(鍵:題序號0~4,值:正確選項序號0~4,對應每道題的5個選項)
ANSWER_KEY = {0: 1, 1: 4, 2: 0, 3: 3, 4: 1}
correct_count = 0 # 正確題數
warped_result = warped.copy() # 用于繪制結果的圖像# 8.2 按“每5個選項一組”遍歷(每道題對應5個選項)
for (question_idx, i) in enumerate(np.arange(0, len(sorted_option_cnts), 5)):# 取當前題的5個選項輪廓,按“從左到右”排序current_question_cnts, _ = sort_contours(sorted_option_cnts[i:i+5], method='left-to-right')bubbled_idx = None # 存儲當前題的填涂選項序號(0~4)# 遍歷當前題的5個選項,找到填涂的選項for (option_idx, c) in enumerate(current_question_cnts):# 步驟1:為當前選項創建掩膜(僅該選項區域為白色,其余為黑色)mask = np.zeros(thresh.shape, dtype="uint8")cv2.drawContours(mask, [c], -1, 255, -1) # -1表示填充輪廓內部# cv_show(f'Option {option_idx} Mask (選項{option_idx}掩膜)', mask)# 步驟2:掩膜與二值化圖做“與運算”,僅保留當前選項的填涂區域masked_thresh = cv2.bitwise_and(thresh, thresh, mask=mask)# cv_show(f'Masked Thresh (選項{option_idx}與運算結果)', masked_thresh)# 步驟3:計算白色像素數量(填涂區域的像素數)white_pixel_count = cv2.countNonZero(masked_thresh)# 步驟4:確定填涂選項(白色像素最多的選項即為填涂選項)if bubbled_idx is None or white_pixel_count > bubbled_idx[0]:bubbled_idx = (white_pixel_count, option_idx) # (像素數, 選項序號)# 8.3 與標準答案比對,標記對錯correct_option_idx = ANSWER_KEY[question_idx] # 當前題的正確選項序號if bubbled_idx[1] == correct_option_idx:# 正確:綠色輪廓(線寬2),正確題數+1color = (0, 255, 0)correct_count += 1else:# 錯誤:紅色輪廓(線寬2)color = (0, 0, 255)# 繪制當前題的正確選項輪廓(標記對錯)cv2.drawContours(warped_result, [current_question_cnts[correct_option_idx]], -1,
最終結果如下: