別:圖像預(yù)處理與相似度判定實(shí)戰(zhàn))
簡(jiǎn)介基于Python與OpenCV的筆跡識(shí)別系統(tǒng)項(xiàng)目源碼面向計(jì)算機(jī)視覺課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)以及圖像識(shí)別入門學(xué)習(xí)者旨在解決筆跡檢測(cè)、特征提取和比對(duì)識(shí)別等典型問題。該項(xiàng)目為已獲導(dǎo)師指導(dǎo)并通過的九十七分高分課程大作業(yè)代碼完整下載后即可運(yùn)行也便于按需修改與功能擴(kuò)展。壓縮包大小約三十八兆字節(jié)內(nèi)含完整的項(xiàng)目工程以Python源碼為核心覆蓋圖像讀取、灰度化、二值化、輪廓提取、特征計(jì)算、筆跡匹配及結(jié)果可視化等模塊結(jié)構(gòu)清晰適合深入研讀。目前已有九百五十八人學(xué)習(xí)說明該資源受到較多使用者認(rèn)可可靠度較高。通過本項(xiàng)目讀者既能系統(tǒng)掌握?qǐng)D像處理與模式識(shí)別的基本方法也能獲得一個(gè)可直接用于演示或答辯的完整系統(tǒng)適合快速完成課程作業(yè)、畢設(shè)選題或項(xiàng)目實(shí)戰(zhàn)實(shí)用性很強(qiáng)。1. 基于PythonOpenCV的筆跡識(shí)別這套系統(tǒng)到底解決什么問題一套基于Python和OpenCV的筆跡識(shí)別系統(tǒng)輸入是幾張手寫筆跡的掃描圖或拍照?qǐng)D輸出是“這些筆跡是否來自同一個(gè)人”。它做的是圖像識(shí)別里典型的“相似度判定”任務(wù)先用OpenCV把筆跡從背景里干凈地?fù)赋鰜碓侔衙繌垐D的形狀特征變成一串?dāng)?shù)字最后用距離公式判斷兩串?dāng)?shù)字靠不靠近。這個(gè)方向適合兩類人一是要做圖像識(shí)別課程設(shè)計(jì)或畢業(yè)設(shè)計(jì)的開發(fā)者二是想把筆跡比對(duì)做成工具的原型驗(yàn)證。它不依賴深度學(xué)習(xí)框架不需要GPU純CPU就能跑核心功夫全在圖像預(yù)處理和特征選得好不好。下面按復(fù)現(xiàn)順序拆開講從預(yù)處理到比對(duì)再到最容易踩坑的地方。2. 圖像預(yù)處理與特征提取從彩色圖到干凈前景的必經(jīng)步驟2.1 筆跡識(shí)別的前提把筆畫從背景里干凈地?fù)赋鰜砉P跡識(shí)別的第一道坎不是算法而是圖像本身太臟。掃描件上有紙張紋理、有不均勻的照明、有掃描噪點(diǎn)手機(jī)拍照還會(huì)有陰影和透視變形。如果直接拿彩色圖去算特征光照變化和紙張底色會(huì)干擾所有數(shù)值。常見的做法是先把彩色圖轉(zhuǎn)成灰度再做二值化把“是筆跡”的像素變成白色其余全變成黑色。這一步做對(duì)了后面所有特征才有意義。下面是最小可用的預(yù)處理函數(shù)import cv2 import numpy as np def preprocess(image_path): # 讀入圖像OpenCV 默認(rèn)返回 BGR 三通道 img cv2.imread(image_path) if img is None: raise FileNotFoundError(f無法讀取圖片: {image_path}) # 轉(zhuǎn)灰度筆跡識(shí)別不依賴顏色灰度能減少光照和紙張底色的干擾 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小選 3 或 5過大會(huì)把細(xì)筆畫的邊緣也抹掉 blur cv2.GaussianBlur(gray, (5, 5), 0) # OTSU 自動(dòng)二值化逆二值化讓筆畫變白、背景變黑方便后續(xù)輪廓處理 _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) return binary這里有兩個(gè)關(guān)鍵選擇。第一是為什么用cv2.THRESH_OTSU而不是固定閾值手寫筆跡的灰度分布很不均勻同一張紙上有的筆畫墨水深、有的淺固定閾值顧此失彼OTSU 會(huì)在灰度直方圖上自動(dòng)找一個(gè)峰谷分割點(diǎn)對(duì)不同深淺的筆跡都相對(duì)公平。第二是高斯模糊的核大小(5, 5)是折中值——核越大去噪越強(qiáng)但細(xì)筆畫的邊緣也會(huì)被磨掉如果原圖是 300dpi 掃描件可以降到(3, 3)。這一步最常見的翻車是二值化后前景和背景反了。cv2.THRESH_BINARY_INV表示“灰度值大于閾值的設(shè)為 0”因?yàn)楣P跡顏色深、灰度值低所以要加INV把筆畫翻成白色。如果忘了加INV前景變黑背景變白后續(xù)findContours找出來的會(huì)是一大塊白色背景而不是筆畫輪廓。2.2 傾斜校正同一句話寫成15度斜的特征就全變了預(yù)處理里最容易忽略的是傾斜校正。同一個(gè)人隨手寫的兩張紙行傾斜角差個(gè)十度很常見。如果不校正任何跟寬高、方向有關(guān)的特征全都會(huì)偏移。做筆跡識(shí)別時(shí)我一般會(huì)在二值化之后加一步把整張二值圖里所有非零像素當(dāng)成一個(gè)整體用最小外接矩形求出平均傾斜角再旋轉(zhuǎn)回水平。def deskew(binary): # 找到所有非零像素點(diǎn)坐標(biāo)相當(dāng)于把所有筆畫當(dāng)成一個(gè)整體 coords cv2.findNonZero(binary) # minAreaRect 返回 ((cx, cy), (w, h), angle)angle 范圍是 [-90, 0) rect cv2.minAreaRect(coords) angle rect[-1] # 換算成 getRotationMatrix2D 需要的旋轉(zhuǎn)角 if angle -45: angle -(90 angle) else: angle -angle h, w binary.shape center (w // 2, h // 2) M cv2.getRotationMatrix2D(center, angle, 1.0) # 旋轉(zhuǎn)后空白區(qū)域用黑色填充確保背景仍是 0 rotated cv2.warpAffine(binary, M, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_CONSTANT, borderValue0) return rotated這里的角度換算是個(gè)容易繞暈的細(xì)節(jié)。cv2.minAreaRect返回的角度是矩形寬邊與水平線的夾角范圍在[-90, 0)。OpenCV 的旋轉(zhuǎn)矩陣默認(rèn)逆時(shí)針為正所以要分兩段換算角度小于-45時(shí)說明矩形是豎著的需要先轉(zhuǎn)成-(90 angle)否則直接用-angle。這個(gè)換算不仔細(xì)核對(duì)的話旋轉(zhuǎn)方向反了筆跡會(huì)越轉(zhuǎn)越歪。整個(gè)傾斜校正對(duì)“單行文字”的樣本效果很穩(wěn)但對(duì)“整頁手寫筆記”會(huì)出問題——頁面上不同行的傾斜角可能不一樣強(qiáng)行旋轉(zhuǎn)整頁反而會(huì)讓部分行更歪。如果源碼包里的樣本是整頁掃描件我一般會(huì)先按行分割每行單獨(dú)校正傾斜再做特征提取。2.3 把預(yù)處理串成一條可復(fù)用流水線上面兩步單獨(dú)寫沒問題但實(shí)際調(diào)試時(shí)要反復(fù)改參數(shù)、反復(fù)看中間結(jié)果。所以我會(huì)把它們封裝成一個(gè)完整的流水線并且隨時(shí)能輸出中間圖像方便定位問題出在模糊、二值化還是旋轉(zhuǎn)。def build_pipeline(image_path, debugFalse): img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) _, binary cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) # 對(duì)單行筆跡樣本做傾斜校正 binary deskew(binary) if debug: # 調(diào)試模式下保存中間結(jié)果方便肉眼確認(rèn)每一步是否正常 cv2.imwrite(debug_gray.png, gray) cv2.imwrite(debug_binary.png, binary) return binarydebug參數(shù)是我自己寫這類系統(tǒng)時(shí)一定會(huì)留的口子。圖像處理是典型的黑匣子系統(tǒng)參數(shù)一多你光看最終結(jié)果根本不知道是哪一步出了問題。把每一步的中間圖落盤一次就能看清“原圖、灰度、二值化、旋轉(zhuǎn)后”四個(gè)狀態(tài)哪里壞了一眼定位。這個(gè)習(xí)慣在調(diào)閾值時(shí)特別值得后面第五章還會(huì)提到。3. 筆跡比對(duì)核心實(shí)現(xiàn)輪廓特征與相似度判定的完整鏈路3.1 用findContours提取每一筆的幾何輪廓預(yù)處理完成后得到的是黑底白筆畫的二值圖。接下來要做的是把每一塊連通的白色區(qū)域找出來這就是cv2.findContours的職責(zé)。對(duì)筆跡識(shí)別來說輪廓本身攜帶了字形的大量信息筆畫的長(zhǎng)短、粗細(xì)、彎曲程度都體現(xiàn)在輪廓的形狀里。# OpenCV 4.x 的 findContours 返回兩個(gè)值輪廓列表和層級(jí)關(guān)系 contours, hierarchy cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)這里有兩個(gè)參數(shù)必須說清楚。第一個(gè)是RETR_EXTERNAL它只取最外層的輪廓適合字與字之間分隔清楚的樣本。如果筆跡里有很多封閉字形“口”“日”“回”內(nèi)部的白洞也是特征那就得改成RETR_CCOMP或RETR_LIST否則字形內(nèi)部的空白信息會(huì)全部丟光。第二個(gè)是CHAIN_APPROX_SIMPLE它只保留輪廓的端點(diǎn)壓縮掉直線上的冗余點(diǎn)對(duì)性能友好如果要計(jì)算曲率這類精細(xì)特征再換CHAIN_APPROX_NONE。拿到輪廓之后不能直接全用。二值化后的圖像通常有椒鹽噪點(diǎn)這些噪點(diǎn)也會(huì)形成小輪廓。我會(huì)先做一個(gè)面積過濾把明顯小于正常筆畫的輪廓丟掉避免它們污染后續(xù)特征統(tǒng)計(jì)。def filter_contours(contours, min_area20): filtered [] for c in contours: area cv2.contourArea(c) if area min_area: # 小于閾值的輪廓大概率是噪點(diǎn)或紙張纖維直接丟棄 continue x, y, w, h cv2.boundingRect(c) if w 2 or h 2: # 寬度或高度只有 1 像素的邊緣輪廓通常是孤立噪點(diǎn) continue filtered.append(c) return filteredmin_area是個(gè)跟具體數(shù)據(jù)集強(qiáng)相關(guān)的參數(shù)。300dpi 掃描的正常漢字筆畫單個(gè)輪廓面積通常在幾百到幾千像素如果是手機(jī)拍的低分辨率圖一個(gè)筆畫可能只有幾十像素。這個(gè)值設(shè)大了會(huì)把細(xì)筆畫過濾掉設(shè)小了噪點(diǎn)混進(jìn)來。我的習(xí)慣是先把二值圖上所有輪廓的面積分布打出來然后取面積的 5% 分位數(shù)作為初始閾值。3.2 挑選穩(wěn)定的特征面積、寬高比、周長(zhǎng)和像素密度輪廓有了下一步是把每個(gè)輪廓變成幾個(gè)數(shù)值這些數(shù)值就是“特征”。特征選得好不好決定了比對(duì)的上限。對(duì)筆跡識(shí)別來說我一般選三到四個(gè)幾何特征它們都滿足同一個(gè)要求對(duì)同一人的不同書寫狀態(tài)相對(duì)穩(wěn)定對(duì)不同人的筆跡有區(qū)分度。def extract_features(binary, min_area20): contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) feats [] for c in contours: if cv2.contourArea(c) min_area: continue x, y, w, h cv2.boundingRect(c) area cv2.contourArea(c) perimeter cv2.arcLength(c, True) # 緊湊度面積相同的情況下周長(zhǎng)越小形狀越接近圓 compactness 4 * np.pi * area / (perimeter * perimeter 1e-6) # 長(zhǎng)寬比反映這個(gè)字形的扁與長(zhǎng)寫成“一”和“丨”差異極大 aspect_ratio w / (h 1e-6) # 像素密度輪廓區(qū)域內(nèi)實(shí)際筆畫像素占的比例 roi binary[y:y h, x:x w] density cv2.countNonZero(roi) / (w * h 1e-6) feats.append([compactness, aspect_ratio, density]) return np.array(feats)這三個(gè)特征各有講究。緊湊度的取值范圍是 0 到 1越接近 1 說明形狀越像圓同一個(gè)人寫“口”字時(shí)外輪廓的緊湊度通常穩(wěn)定在某個(gè)小區(qū)間而換成另一個(gè)人寫同一個(gè)字方框的長(zhǎng)寬比例會(huì)明顯不同。長(zhǎng)寬比是用來區(qū)分字形走向的這個(gè)特征對(duì)潦草的連筆字尤其敏感——連筆會(huì)把豎向筆畫拉長(zhǎng)長(zhǎng)寬比變化很大。像素密度則反映了筆畫的粗細(xì)習(xí)慣有人寫字重、筆畫粗同一個(gè)字的輪廓框里非零像素占比就高。注意分母都加了1e-6這是為了防止perimeter或h為 0 導(dǎo)致除零錯(cuò)誤。代碼里每個(gè)輪廓都被看作一個(gè)獨(dú)立樣本一張筆跡圖最后得到的特征矩陣形狀是(N, 3)N 是輪廓數(shù)量。比對(duì)時(shí)不能只拿某一個(gè)輪廓去比而要把整張圖所有輪廓的特征聚合起來常用的做法是取每列特征的均值和標(biāo)準(zhǔn)差拼成一個(gè)長(zhǎng)向量。均值反映整體風(fēng)格標(biāo)準(zhǔn)差反映書寫穩(wěn)定性——同一人的筆跡各字大小均勻標(biāo)準(zhǔn)差就小不同人的書寫忽大忽小標(biāo)準(zhǔn)差差異明顯。3.3 相似度計(jì)算歸一化距離的坑特征提取完最后一步就是判定。判定邏輯很直接把待測(cè)樣本的特征向量和庫里每個(gè)模板的特征向量算距離距離小于閾值的判為“同一個(gè)人”否則判為“不是同一個(gè)人”。def normalize_features(feats): # 按列做 min-max 歸一化特征數(shù)值都?jí)旱?0~1 區(qū)間 min_vals feats.min(axis0) max_vals feats.max(axis0) eps 1e-6 return (feats - min_vals) / (max_vals - min_vals eps) def compute_distance(feat_a, feat_b): # 歸一化后的歐氏距離數(shù)值越小表示越相似 return np.linalg.norm(feat_a - feat_b)歸一化是這里最不能省的一步。如果跳過它原始特征里“面積”可能是幾千上萬的數(shù)量級(jí)而“緊湊度”只有 0 到 1面積會(huì)把其他特征完全壓垮算出來的距離基本只反映面積差異等于其他特征白提了。歸一化之后所有特征都變成 0 到 1 的數(shù)值每個(gè)維度對(duì)距離的貢獻(xiàn)才公平。我見過不少翻車案例都是卡在這一步?jīng)]做歸一化距離永遠(yuǎn)都很大于是閾值設(shè)得很大最后所有樣本都判成“同一人”準(zhǔn)確率看起來很高實(shí)際完全沒用。正確的做法是先對(duì)特征矩陣做歸一化再把歸一化后的距離分布打出來看同一人筆跡的距離和不同人筆跡的距離有沒有明顯分界然后在這個(gè)分界附近選閾值。不要拍腦袋設(shè)閾值距離分布是真實(shí)的、可以看的選閾值就不該靠猜。4. 復(fù)現(xiàn)源碼包的常見報(bào)錯(cuò)排查環(huán)境、數(shù)據(jù)類型與路徑4.1 import cv2 報(bào)模塊找不到先分清是沒裝還是裝錯(cuò)包ModulenotFoundError: No module named cv2是復(fù)現(xiàn)這個(gè)項(xiàng)目時(shí)出現(xiàn)頻率最高的報(bào)錯(cuò)幾乎每個(gè)新手都會(huì)遇見?,F(xiàn)象很直接運(yùn)行程序第一行import cv2就崩。原因通常不是沒裝而是裝進(jìn)了錯(cuò)誤的 Python 環(huán)境。最常見的是 PyCharm 下項(xiàng)目解釋器選成了系統(tǒng)自帶的 Python而pip install opencv-python裝進(jìn)了 conda 的另一個(gè)環(huán)境兩邊互相看不見。解決的第一步是先確認(rèn)當(dāng)前解釋器路徑再?zèng)Q定往哪裝python -c import sys; print(sys.executable) pip install opencv-python numpy如果是已經(jīng)安裝了opencv-python-headless導(dǎo)致cv2.imshow報(bào)錯(cuò)那是另一個(gè)坑headless 版本去掉了 GUI 支持適合服務(wù)器端但本地調(diào)試看不了圖。解決辦法是卸載重裝完整版pip uninstall opencv-python-headless然后pip install opencv-python。我一般會(huì)建議新手在項(xiàng)目根目錄建一個(gè)requirements.txt把opencv-python和numpy的版本寫進(jìn)去換機(jī)器重裝時(shí)一條命令搞定省得每換一臺(tái)電腦就重新踩一遍環(huán)境坑。4.2 cv2.error: OpenCV(4.4.0) 斷言失敗findContours的輸入和返回值陷阱運(yùn)行到cv2.findContours時(shí)報(bào)類似cv2.error: OpenCV(4.4.0) ... error: (-215:Assertion failed) npoints 0 ...這是圖像識(shí)別項(xiàng)目里最典型的翻車現(xiàn)場(chǎng)?,F(xiàn)象有兩種一是程序直接拋異常崩潰二是明明圖像看起來正常卻報(bào)輸入格式錯(cuò)誤。第一個(gè)原因是 OpenCV 版本升級(jí)后的返回值變化。OpenCV 3.x 的findContours返回三個(gè)值(image, contours, hierarchy)OpenCV 4.x 返回兩個(gè)值(contours, hierarchy)。源碼包如果是照著老版本寫的在 4.x 上解包就會(huì)報(bào)ValueError: not enough values to unpack。解決方法是把contours, hierarchy cv2.findContours(...)改成四個(gè)變量的寫法但說實(shí)話直接改成兩個(gè)變量的寫法更干凈因?yàn)閕mage那個(gè)返回值在 4.x 已經(jīng)沒有意義了。查自己裝的版本用cv2.__version__。第二個(gè)原因是輸入圖像不是單通道二值圖。findContours要求輸入是uint8類型的單通道圖。有些源碼里先做了cv2.cvtColor轉(zhuǎn)灰度接著不小心又做了一次cv2.cvtColor把灰度轉(zhuǎn)成了三通道或者二值化后沒有轉(zhuǎn)成uint8都會(huì)觸發(fā)斷言失敗。解決方法是傳參前加一道保險(xiǎn)binary np.asarray(binary, dtypenp.uint8)并檢查len(binary.shape)必須等于 2。4.3 全部匹配同一人閾值與歸一化的連環(huán)坑識(shí)別結(jié)果“全部判為同一個(gè)人”或者“全部判為不同人”這種結(jié)果通常不是代碼邏輯錯(cuò)了而是數(shù)沒算對(duì)?,F(xiàn)象是跑完比對(duì)腳本準(zhǔn)確率要么 100% 要么 0%怎么看都覺得不對(duì)勁。原因九成出在特征沒有歸一化。前面提過面積特征的數(shù)值可能是緊湊度的一萬倍歐氏距離會(huì)被面積完全主導(dǎo)不同人的筆跡面積差異大算出來的距離全部很大閾值稍微設(shè)高點(diǎn)就全軍覆沒“全中”設(shè)低點(diǎn)就“全不中”。另一個(gè)原因是我見過有人把距離閾值設(shè)成float(inf)或一個(gè)巨大的數(shù)來“確保有匹配結(jié)果”這等于沒有判定。解決方法是先歸一化特征再畫距離分布圖。把同一人筆跡之間的距離和不同人筆跡之間的距離分別畫成直方圖兩個(gè)分布有重疊就說明特征選得不夠重疊越小說明特征區(qū)分度越高。閾值取兩個(gè)分布峰值的中間位置比任何拍腦袋的數(shù)值都可靠。注意歸一化的min和max必須在訓(xùn)練集上計(jì)算不能用全部數(shù)據(jù)——否則等于讓模型偷看了答案換新樣本就失效。4.4 圖片路徑報(bào)錯(cuò)源碼包目錄結(jié)構(gòu)與中文路徑最后一道坎是路徑問題。源碼包解壓后圖片放在data/目錄下代碼里寫的是相對(duì)路徑data/samples/001.jpg。如果你在別的目錄下運(yùn)行腳本Python 的工作目錄跟代碼目錄不一致就會(huì)報(bào)FileNotFoundError。這幾乎是壓縮包類源碼的通病。解決方法是不要依賴“當(dāng)前工作目錄”而是把路徑基于腳本所在目錄計(jì)算from pathlib import Path # 用腳本所在目錄作為基準(zhǔn)而不是當(dāng)前終端所在目錄 BASE_DIR Path(__file__).resolve().parent img_path BASE_DIR / data / samples / 001.jpg另一個(gè)更隱蔽的坑是中文路徑。cv2.imread在 Windows 下對(duì)帶中文的路徑支持不好通常不會(huì)報(bào)錯(cuò)而是靜默返回None導(dǎo)致后續(xù)代碼拿空值計(jì)算報(bào)錯(cuò)信息跟路徑毫無關(guān)系。如果樣本文件夾名字帶了中文我習(xí)慣用cv2.imdecode配合np.fromfile繞過這個(gè)限制import numpy as np import cv2 def imread_unicode(path): # 先用 numpy 讀取原始字節(jié)再用 imdecode 解碼繞過中文路徑限制 data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)判斷讀圖是否成功在imread后立刻檢查返回值是否為None是就打印路徑并跳過別等到后面空指針崩潰再回頭查。5. 讓識(shí)別率從能跑變成能用參數(shù)調(diào)節(jié)與效果驗(yàn)證5.1 先立一個(gè)評(píng)估標(biāo)準(zhǔn)把“像不像”變成數(shù)字很多復(fù)現(xiàn)者跑通之后就停了覺得“能出結(jié)果”就是勝利。實(shí)際上程序能跑和系統(tǒng)能用是兩回事。要判斷參數(shù)調(diào)得好不好得先把“準(zhǔn)確率”這個(gè)數(shù)字定義出來。常見的做法是留出法每個(gè)人準(zhǔn)備兩組筆跡一組做模板庫一組做待測(cè)樣本測(cè)試時(shí)把待測(cè)樣本跟模板庫里每個(gè)人比對(duì)看能否正確匹配到本人。def evaluate_system(samples_by_person, threshold10.0): persons list(samples_by_person.keys()) total 0 correct 0 # 遍歷所有成對(duì)組合同一人的兩兩算一對(duì)不同人的兩兩也算一對(duì) for i in range(len(persons)): for j in range(i 1, len(persons)): for feat_a in samples_by_person[persons[i]]: for feat_b in samples_by_person[persons[j]]: dist compute_distance(feat_a, feat_b) predicted_same dist threshold actual_same persons[i] persons[j] total 1 if predicted_same actual_same: correct 1 return correct / total if total else 0評(píng)估函數(shù)里必須同時(shí)包含“同一人”和“不同人”的樣本對(duì)否則結(jié)果會(huì)失真。如果只測(cè)同一人的匹配把所有樣本都判成同一人準(zhǔn)確率也有 100%一點(diǎn)參考價(jià)值都沒有。真實(shí)場(chǎng)景里拒認(rèn)把本人判錯(cuò)和誤認(rèn)把別人判成自己是兩碼事評(píng)估腳本里這兩個(gè)錯(cuò)誤率要分開看。把predicted_same和actual_same的四種組合打印出來你能直觀看到錯(cuò)誤是偏向哪個(gè)方向。5.2 三個(gè)最值得調(diào)的參數(shù)二值化閾值、最小輪廓面積、距離閾值整個(gè)系統(tǒng)里最影響結(jié)果的參數(shù)有三個(gè)其他的都可以先放著不動(dòng)。這三個(gè)參數(shù)相互牽連調(diào)的時(shí)候要按順序來否則容易越調(diào)越亂。參數(shù)常見范圍影響調(diào)參方向二值化閾值OTSU 自動(dòng)或 150~200閾值高筆畫變細(xì)斷筆閾值低筆畫粘連有粘連時(shí)降低有斷筆時(shí)升高最小輪廓面積10~50 像素過濾噪點(diǎn)也過濾細(xì)碎筆畫先看面積分布直方圖再定距離閾值歸一化后 0~2決定判定松緊越小越嚴(yán)格按距離分布的分位數(shù)取比如 10% 分位調(diào)參順序建議從圖開始先不要碰代碼里的數(shù)字。把二值化后的圖像保存下來用圖片查看器放大看筆畫有沒有斷、有沒有粘連、噪點(diǎn)是不是已經(jīng)被濾干凈了。圖干凈了再調(diào)最小輪廓面積把輪廓數(shù)量打印出來跟圖像上肉眼可見的筆畫數(shù)量對(duì)比。最后才調(diào)距離閾值這一步必須依賴評(píng)估腳本的數(shù)字來選看哪個(gè)閾值下拒認(rèn)率和誤認(rèn)率的總和最低。我自己的經(jīng)驗(yàn)是這三個(gè)參數(shù)每換一批掃描設(shè)備或紙張大概率要重新調(diào)一遍。這不是代碼寫錯(cuò)了是圖像本身的分辨率和噪聲特性變了。所以源碼包里如果帶了批處理腳本我會(huì)提前留一個(gè)參數(shù)配置文件把這三個(gè)值單獨(dú)放在文件頭部不需要為改參數(shù)去翻幾百行代碼。5.3 用形態(tài)學(xué)操作處理連筆膨脹腐蝕的邊界設(shè)置手寫筆跡最難處理的問題是連筆。字與字之間的游絲、筆畫之間的細(xì)連接會(huì)讓findContours把兩個(gè)字當(dāng)成一個(gè)輪廓輪廓數(shù)量驟減特征統(tǒng)計(jì)完全失真。解決連筆的常見做法是形態(tài)學(xué)開運(yùn)算——先腐蝕再膨脹可以切斷細(xì)的連接線同時(shí)保留筆畫的粗壯主體。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)開運(yùn)算的核大小非常敏感。(2, 2)的核能切斷很細(xì)的連接但對(duì)筆畫正常的字幾乎無影響(3, 3)的核會(huì)明顯削掉筆畫邊緣讓字整體變細(xì)特征里的密度值會(huì)下降。我的建議是從(2, 2)開始觀察開運(yùn)算后的結(jié)果圖如果連筆還在逐漸加大核的大小直到邊界情況——某個(gè)字的正常筆畫開始出現(xiàn)斷點(diǎn)這時(shí)候回退到上一檔就是當(dāng)前數(shù)據(jù)集的最優(yōu)核。這個(gè)操作只對(duì)“細(xì)連接”有效。如果兩個(gè)人的字已經(jīng)重重疊在一起開運(yùn)算會(huì)把真正的筆畫也干掉。對(duì)于這種樣本任何形態(tài)學(xué)操作都救不回來能做的就是放棄該區(qū)域的輪廓只保留完整獨(dú)立的字形參與特征統(tǒng)計(jì)。識(shí)別系統(tǒng)跟人一樣遇到太潦草的字也會(huì)猶豫與其給一個(gè)錯(cuò)的答案不如直接標(biāo)記為“低置信度需要人工復(fù)核”。6. 進(jìn)階方向從輪廓特征到輕量級(jí)模型的可擴(kuò)展路徑這套基于輪廓特征的方案天花板在于連筆嚴(yán)重時(shí)特征會(huì)失真而且它本質(zhì)上是“特征工程 距離度量”對(duì)潦草字的泛化能力有限。如果樣本量足夠、識(shí)別率不夠用我有三個(gè)已驗(yàn)證的進(jìn)階方向可以順著現(xiàn)有代碼改。第一個(gè)方向是把特征從幾何統(tǒng)計(jì)換成方向梯度直方圖HOG。HOG 關(guān)注的是筆畫局部的方向分布比面積、周長(zhǎng)這類全局統(tǒng)計(jì)更抗連筆干擾。OpenCV 里cv2.HOGDescriptor可以直接算特征維度從 3 維漲到 300 多維歸一化后仍用歐氏距離或余弦距離判定代碼改動(dòng)的部分只是特征提取那一段后面比對(duì)的鏈路完全復(fù)用。第二個(gè)方向是用特征點(diǎn)匹配替代輪廓比對(duì)。對(duì)筆跡做 SIFT 或 SURF 關(guān)鍵點(diǎn)提取然后比對(duì)兩幅圖的關(guān)鍵點(diǎn)匹配率。這個(gè)思路對(duì)同一人筆跡的局部形變更魯棒但手寫筆跡紋理弱關(guān)鍵點(diǎn)可能不夠多實(shí)際效果取決于樣本清晰度需要先驗(yàn)證再?zèng)Q定是否投入。第三個(gè)方向是上輕量級(jí)模型。每個(gè)人準(zhǔn)備 5 份以上筆跡樣本裁剪成固定尺寸的灰度圖用小型 CNN兩三個(gè)卷積層就夠訓(xùn)練成特征提取器取倒數(shù)第二層的輸出作為筆跡 embedding。這一步能顯著提升對(duì)連筆字的魯棒性代價(jià)是標(biāo)注成本——至少要幾十個(gè)人的樣本才能訓(xùn)起來。驗(yàn)證方法也順手把提取到的特征向量導(dǎo)出成 CSV用 t-SNE 降維到二維平面畫散點(diǎn)圖同一人的筆跡點(diǎn)聚得攏說明特征有效聚不攏就回頭加數(shù)據(jù)或調(diào)整網(wǎng)絡(luò)結(jié)構(gòu)。最后分享一個(gè)我自己的教訓(xùn)最初做這個(gè)系統(tǒng)時(shí)調(diào)參全靠目測(cè)覺得“看起來挺像”就完事。后來同一批算法換了一臺(tái)掃描儀識(shí)別率從 85% 直接掉到 40%。從那以后我再也不憑感覺設(shè)閾值每次都會(huì)把距離分布直方圖打出來看兩個(gè)分布的重疊面積決定要不要?jiǎng)犹卣鞫皇侵徽{(diào)閾值硬掰。這套系統(tǒng)的價(jià)值不在于算法多高級(jí)而在于它把“像不像”變成了可量化、可復(fù)現(xiàn)的數(shù)字——這本身就是圖像識(shí)別項(xiàng)目里最值錢的一步。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取