手写批注丢字,先别急着把它记成“模型对手写支持有限”。Xiaomi-OCR-0 拿到的是一张图像,扫描件的灰底、纸张纹理、墨迹发虚,都会在送进模型之前把细笔画削掉一层;同一页上印刷体识别正常、只有手写批注成段丢失,往往更能说明问题出在图像侧。比较稳妥的做法是固定一块含手写批注的区域做对照:原图切片送一次,灰度、去噪、自适应二值化后再送一次,逐字看原来漏掉的字有没有回来,再决定是继续调前处理,还是承认这属于模型边界。
适用场景:扫描或拍照文档,印刷体识别基本正常、手写批注成段丢失。动作:先用裁剪把变量锁到一块区域,再做灰度、去噪、自适应二值化,用同一块样本对比原图与处理图的识别输出。验证:逐字核对漏字位置,同时看二值化后笔画连通域数量有没有异常下降。边界:如果二值化后细笔画被吃掉、漏字反而变多,说明前处理过度;如果两种图都稳定漏同一批字,更可能是模型对手写体的覆盖范围问题,前处理救不回来。
截取一块含手写批注的区域做样本
整页送进去比对,变量太多:排版、页眉页脚、表格线、印章都会干扰判断。先把范围缩到一块手写批注,后面所有对比都在这块图上做,结论才站得住。坐标不要凭感觉估,先把整页缩小看一遍批注大致落在哪一段,再按原图比例换算回像素值;裁的时候四边各留一点白边,避免把字的起笔切掉。
import cv2
import numpy as np
# cv2.imread 对中文路径不友好,用 fromfile 更稳
buf = np.fromfile("page.png", dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
print("原图尺寸:", img.shape) # (h, w, 3),裁剪坐标按 h/w 换算
x, y, w, h = 820, 1180, 640, 260 # 换成你实际量到的批注区域
crop = img[y:y+h, x:x+w]
cv2.imwrite("crop_raw.png", crop)
# 4 倍最近邻放大,方便肉眼核对笔画
zoom = cv2.resize(crop, None, fx=4, fy=4, interpolation=cv2.INTER_NEAREST)
cv2.imwrite("crop_raw_x4.png", zoom)
print("切片尺寸:", crop.shape)
裁剪区域建议选一行以上、包含至少两三个完整手写词的那一块,太小看不出丢字规律,太大又不好逐字核对。样本图存成单独的 crop_raw.png,后面每一步处理都基于它,不要中途换图。
用 OpenCV 做灰度、去噪、自适应二值化
三步各管一件事:灰度去掉颜色通道的干扰,去噪压掉纸张纹理和扫描噪点,自适应二值化把前景笔画从局部背景里分离出来。手写批注通常墨色偏淡、笔画偏细,全局固定阈值容易把浅的部分整片判成白,所以用自适应阈值,让阈值跟着局部均值走。
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
cv2.imwrite("crop_gray.png", gray)
# h 越大去噪越强、细节损失也越多;手写偏细时可以先用 5~7
den = cv2.fastNlMeansDenoising(gray, None,
h=6,
templateWindowSize=7,
searchWindowSize=21)
cv2.imwrite("crop_denoise.png", den)
# blockSize 必须是奇数,决定局部邻域大小;C 是从局部均值里减掉的常数
bin_ = cv2.adaptiveThreshold(den, 255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
blockSize=31,
C=10)
cv2.imwrite("crop_bin.png", bin_)
# 处理前后并排放大,便于直接看图
side = cv2.hconcat([
cv2.resize(den, None, fx=4, fy=4, interpolation=cv2.INTER_NEAREST),
cv2.resize(bin_, None, fx=4, fy=4, interpolation=cv2.INTER_NEAREST),
])
cv2.imwrite("crop_compare_x4.png", side)
h:去噪强度。调大噪点更少,但浅墨的细笔画会一起被抹平,先小后大。templateWindowSize:参与比较的模板块边长,取奇数,一般保持 7 即可。blockSize:自适应阈值的邻域大小。批注笔画细、字又小,blockSize 太大等于退化成全局阈值,可以先试 25~35 之间的奇数。C:偏移量。C 越大,越多像素被判成背景(白),浅笔画最容易在这里消失。
如果手写是深底浅字(例如深色底的批注、盖章页面),阈值类型要换成 cv2.THRESH_BINARY_INV,否则整块会反相。送模型前确认一下处理图的极性:正常文档应该是白底黑字。
对同一块样本分别送原始图和处理图
两次调用只允许图像不一样,其余参数保持一致,否则对比没有意义。下面给的是通用调用骨架,具体命令按你们实际的接入方式替换,重点是输出要落成文本文件,方便逐字比对。
# 占位示例:换成实际 CLI 或 HTTP 调用
python ocr_call.py `--image` crop_raw.png `--out` out_raw.txt
python ocr_call.py `--image` crop_bin.png `--out` out_bin.txt
# 逐行看差异
diff -u out_raw.txt out_bin.txt
比对不要只看“整段有没有识别出来”,要回到原图逐字核。把这块区域手工录一份标准文本,然后按位置对齐三列:标准文本、原图输出、处理图输出,只标记两类情况——原来漏掉的字现在出现了,以及新出现的错字。
| 位置 | 标准文本(手工录) | 原图切片输出 | 处理图输出 | 判断 |
|---|---|---|---|---|
| 第 1 行第 3 字 | (填实际字) | (填实际输出) | (填实际输出) | 回来 / 仍漏 / 变错字 |
| 第 2 行连笔处 | (填实际字) | (填实际输出) | (填实际输出) | 回来 / 仍漏 / 变错字 |
| 行尾标点 | (填实际字) | (填实际输出) | (填实际输出) | 回来 / 仍漏 / 变错字 |
结果大致会落到三种情形。处理后漏字明显减少:说明图像质量是主因,前处理值得保留并继续调参数。两次输出漏的是同一批字、位置也一致:更像是模型对手写体的覆盖边界,换更强的去噪也很难补回来,可以改为对该区域单独做人工复核。处理后反而多出新的错字、或整行消失:先别怀疑模型,回到下一节检查二值化是否把笔画吃掉了。
检查二值化是否把细笔画吃掉
自适应阈值不是越“干净”越好。手写批注的起笔、收笔、连笔,像素往往只有一两个灰度级差,C 调大一点就可能整段判成背景。视觉上最直接的办法是把处理前后同一处局部放大并排看,重点盯细横、细竖、连笔和行尾小标点。
# 局部再放大:只取一块,比如左边的两三个字
lx, ly, lw, lh = 0, 40, 300, 120
before = cv2.resize(den[ly:ly+lh, lx:lx+lw], None, fx=8, fy=8, interpolation=cv2.INTER_NEAREST)
after = cv2.resize(bin_[ly:ly+lh, lx:lx+lw], None, fx=8, fy=8, interpolation=cv2.INTER_NEAREST)
cv2.imwrite("detail_before_x8.png", before)
cv2.imwrite("detail_after_x8.png", after)
# 连通域统计:面积过小的分量多半是噪点,被抹掉的分量可能是笔画
for name, m in (("denoise", cv2.threshold(den, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]),
("adaptive", bin_)):
n, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
areas = stats[1:, cv2.CC_STAT_AREA]
print(name, "连通域总数:", n - 1,
"面积>=8:", int((areas >= 8).sum()),
"面积<8:", int((areas < 8).sum()))
统计时把 Otsu 全局二值化结果作为参照,和自适应结果比:如果自适应后的连通域数量比参照明显变少,同时小面积分量大量消失,基本就是 C 偏大或 blockSize 偏小,把细笔画判成了背景。反过来,如果连通域数量明显变多、小面积分量暴涨,多半是去噪不够,噪点也被切成了独立分量,这时应该先回调 h 再动阈值参数。
调整顺序建议固定:先压 h 保住笔画,再动 blockSize 调邻域,最后才动 C。每改一次都重跑一遍同一块样本的识别对比,不要凭处理图“看着干净”就定稿——图干净了但笔画少了,漏字只会更多。