Xiaomi-OCR-0 读手写批注容易丢字 / 先把图像去噪和二值化做在前面

文章导读
手写批注丢字,先别急着把它记成“模型对手写支持有限”。Xiaomi-OCR-0 拿到的是一张图像,扫描件的灰底、纸张纹理、墨迹发虚,都会在送进模型之前把细笔画削掉一层;同一页上印刷体识别正常、只有手写批注成段丢失,往往更能说明问题出在图像侧。比较稳妥的做法是固定一块含手写批注的区域做对照:原图切片送一次,灰度、去噪、自适应二值化后再送一次,逐字看原来漏掉的字有没有回来,再决定是继续调前处理,还是承
📋 目录
  1. 一 截取一块含手写批注的区域做样本
  2. 二 用 OpenCV 做灰度、去噪、自适应二值化
  3. 三 对同一块样本分别送原始图和处理图
  4. 四 检查二值化是否把细笔画吃掉
A A

手写批注丢字,先别急着把它记成“模型对手写支持有限”。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,否则整块会反相。送模型前确认一下处理图的极性:正常文档应该是白底黑字。

对同一块样本分别送原始图和处理图

两次调用只允许图像不一样,其余参数保持一致,否则对比没有意义。下面给的是通用调用骨架,具体命令按你们实际的接入方式替换,重点是输出要落成文本文件,方便逐字比对。

Xiaomi-OCR-0 读手写批注容易丢字 / 先把图像去噪和二值化做在前面
# 占位示例:换成实际 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。每改一次都重跑一遍同一块样本的识别对比,不要凭处理图“看着干净”就定稿——图干净了但笔画少了,漏字只会更多。