手里有一批中文票据和表格,想知道 MiniCPM-OCR 能不能直接用,答案通常不在模型名上,而在票据的版面和语言组合上。稳妥的做法是先挑 6 张左右能覆盖横排与竖排、有线表格与无线表格、印刷与手写的图片跑一遍,看它在哪一类版式上开始丢行、错列或串行,再决定是接入现有流程、加人工复核,还是换方案。下面这套流程只依赖配置文件、日志和脚本输出,不需要额外环境就能判断边界。
MiniCPM-OCR 对中文票据的可用性,应按版面类型分别判断,而不是笼统问“识别得准不准”。建议先取横排印刷、有线表格、含手写或竖排的票据各两张跑通单张推理,记录输出形态与失败位置;对失败样本再做放大、灰度、二值化、旋转校正的对照,能把输入质量问题和模型能力边界分开。只有在可用版面清单明确、且失败类型可枚举之后,再谈接入;印章覆盖、密集手写和无框线表格这类版面,需要预留人工复核位。
挑三类票据各两张做最小样本集
不要拿一张图下结论。单张图往往同时具备“印刷体、横排、有表格线”几个有利条件,跑通之后很容易误以为整批票据都能过。最小样本集的作用是把版面维度拉开,让失败在早期暴露出来。
建议按下面几个维度各取一份,总数控制在 6 到 10 张:
- 排版方向:横排为主、竖排或混合排版;
- 表格形态:有完整框线、只有横线或竖线、完全无框线靠留白对齐;
- 文字来源:印刷体、手写填单、印刷与手写混排;
- 附加干扰:盖章覆盖文字、二维码或条码、折痕与阴影、透视倾斜;
- 语言构成:纯中文、中英混排、数字与中文大写金额并存。
文件命名直接带上维度,后面排查时不用反复打开图片猜。建议用“类别_版式_干扰_序号.png”的形式,类别如 invoice、receipt、form,版式用 hr 表示横排、vt 表示竖排、tbl 表示有线表格、notbl 表示无框线,干扰用 stamp、hand、qr 标注:
samples/
invoice_tbl_stamp_01.png
invoice_notbl_02.png
receipt_hr_hand_01.png
receipt_vt_02.png
form_tbl_hand_03.png
form_notbl_qr_04.png目录保持平铺即可,不要一开始就做多层分类,命名已经足够区分。每张图额外记一条备注,写清“这张我预期它能不能识别”,跑完后用来对照自己的预判是否成立。
跑通单张图的推理脚本骨架
先确认输入输出形态,再谈识别效果。MiniCPM-OCR 这类模型在不同仓库里的加载方式和返回结构不一致,有的返回纯文本,有的返回带坐标的 JSON。脚本不要写死接口名,按实际仓库 README 替换加载和调用两处即可。
from pathlib import Path
import json
from PIL import Image
MODEL_DIR = "path/to/MiniCPM-OCR" # 替换为实际下载路径
IMG_DIR = Path("samples")
OUT_DIR = Path("runs/baseline")
def load_model(model_dir):
"""按仓库文档替换:可能是 AutoModel + trust_remote_code,
也可能是仓库自带 pipeline 或 CLI 封装。"""
raise NotImplementedError
def read_image(path):
return Image.open(path).convert("RGB")
def run_ocr(model, image, prompt=None):
"""返回结构由仓库决定,这里统一包一层 {text, raw}。"""
raw = model.generate(image, prompt=prompt) # 按实际签名替换
return {"text": raw if isinstance(raw, str) else None, "raw": raw}
def save_result(name, result):
OUT_DIR.mkdir(parents=True, exist_ok=True)
(OUT_DIR / f"{name}.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8"
)
if __name__ == "__main__":
model = load_model(MODEL_DIR)
for img_path in sorted(IMG_DIR.glob("*.png")):
result = run_ocr(model, read_image(img_path))
save_result(img_path.stem, result)
print(img_path.name, type(result["raw"]).__name__)第一次运行只跑一张,先打印返回对象的类型和字段名(是 str、dict 还是带 bbox 的 list),确认结果里有没有坐标信息。落盘时保留原始返回,不要只存清洗后的文本,否则后面标注失败位置时缺少定位依据。执行位置建议在模型目录下的虚拟环境中,输出统一进 runs/baseline,便于和预处理版本对比。
逐张标注识别失败的版面类型
“效果差”不是可用信息。要把失败归到具体版式和一个可描述的位置上,才能判断是偶发还是结构性限制。
建议用一个 csv 或表格逐张记录,字段固定下来:
| 图片名 | 失败类型 | 位置描述 | 图片特征 |
|---|---|---|---|
| receipt_vt_02 | 串行 | 第 3 行金额并入第 2 行 | 竖排、行距窄、无框线 |
| invoice_tbl_stamp_01 | 缺行 | 印章覆盖处整行缺失 | 红色印章压在表格中部 |
| form_tbl_hand_03 | 错列 | 手写金额落到相邻列 | 手写倾斜、跨列书写 |
失败类型建议收敛成四类,便于统计:缺行(整行没出来)、错列(内容出现但归错字段)、串行(两行合并成一行或顺序错乱)、字符级错误(文字位置对但个别字或数字识别错)。每条记录都要能回答“这张图长什么样”,而不是只写“识别不准”。如果模型返回了坐标,把异常框的坐标一并记下,后续调预处理时有参照。
用同一批样本做图片预处理对照
失败到底来自输入质量还是模型能力,靠对照能分开。做法是让原始样本和预处理后的样本走同一个推理脚本,输出到不同目录,逐张比对同一位置是否还错。
from PIL import Image, ImageOps
def upscale(img, factor=2):
w, h = img.size
return img.resize((w * factor, h * factor), Image.LANCZOS)
def to_gray(img):
return ImageOps.grayscale(img).convert("RGB")
def binarize(img, thresh=180):
g = ImageOps.grayscale(img)
return g.point(lambda p: 255 if p > thresh else 0).convert("RGB")
def rotate(img, angle):
return img.rotate(angle, expand=True, fillcolor="white")
VARIANTS = {
"raw": lambda im: im,
"up2x": upscale,
"gray": to_gray,
"bin180": binarize,
"rot_deg": lambda im: rotate(im, 2), # 角度按肉眼或投影估计填
}每个变体输出到 runs/up2x、runs/gray 这类独立目录,文件主名保持不变,方便和 baseline 一对一对照。比较时不用看全文,只盯失败记录表里那几行:某张图在 baseline 缺行,放大后补齐了,说明是分辨率不足;灰度或二值化后印章覆盖处恢复,说明干扰主要来自颜色;旋转校正后串行消失,说明是倾斜导致的行切分错误;四种变体都没改善,才更可能是版式或语言本身超出了模型的处理范围。二值化阈值不要一次调很多档,先在 150 到 200 之间取一两档看趋势,避免把调参当成结论。
整理能否接入的判断清单
试跑之后的产出应该是一份分档清单,而不是一句“可以用”。三档划分如下,判定理由必须来自上面的记录表,不能凭印象。
| 档位 | 版面特征 | 判定理由 | 接入方式 |
|---|---|---|---|
| 可用 | 横排印刷、有线表格、无遮挡 | 样本中未出现缺行错列,字段顺序稳定 | 自动入库,抽检 |
| 需人工复核 | 含手写、印章覆盖、轻微倾斜 | 失败集中在特定字段,预处理可部分改善 | 先出结果再核对金额与日期 |
| 不建议使用 | 竖排密集、无框线多列、大面积手写 | 预处理后仍串行或错列,错误不可预测 | 走人工录入或换专用方案 |
落到执行时,把每张样本的图片名填进对应档位,清单本身就是接入依据:可用档先接入并保留原图与输出日志,用于事后追查;复核档在流程里加一个字段级校验位,金额、日期、票号三类字段重点比对;不建议使用的版面直接在前置环节分流出去,不要让它进模型再靠后处理兜底。另外提醒一点,边界会随票据来源变化,换一批供应商或地区的票据时,用同样 6 张的样本集重跑一次这套流程,比沿用旧结论稳妥。