MiniCPM-OCR 单图识别快、整页长图要自己切分

文章导读
单图识别快,是因为一张截图的高度通常落在模型一次能处理完的范围内;整页长截图被丢进去后,输出常常在中途就结束,剩下的内容不是识别错,而是根本没进模型。判断方法很直接:把长图按固定高度切开、按序号拼接、在重叠区去重,再和原图逐段对照。要确认的是三件事:模型可接受的图像长边或内容范围、切片与重叠的具体参数、以及漏检到底出在哪一段高度上。
📋 目录
  1. A 先用一张整页长图确认输出被截断的位置
  2. B 设计切片高度与重叠比例
  3. C 逐片识别并按顺序拼接
  4. D 在重叠区做重复行去重
  5. E 对比整页与切片两种跑法的耗时和漏检
A A

单图识别快,是因为一张截图的高度通常落在模型一次能处理完的范围内;整页长截图被丢进去后,输出常常在中途就结束,剩下的内容不是识别错,而是根本没进模型。判断方法很直接:把长图按固定高度切开、按序号拼接、在重叠区去重,再和原图逐段对照。要确认的是三件事:模型可接受的图像长边或内容范围、切片与重叠的具体参数、以及漏检到底出在哪一段高度上。

整页长截图丢内容,多数情况是输入超出一次可处理范围,不是识别质量差。先用一张整页图定位截断点对应的高度区间,再按“单片高度 + 重叠区”切图,文件名带补零序号并记录每片的 y 偏移,识别后按序号拼接,只在相邻片边界窗口内去重,最后用锚点行对照原图抽检。短图仍走整页;高度超限或已出现截断就走切片。切片高度要以配置或报错中的实际限制为准,不能照搬示例值。

先用一张整页长图确认输出被截断的位置

不要一上来就切图。先把整页长截图原样送一次,拿到输出后从第一段开始与原图对照,找到最后一条还能对上的文本行,它之后的内容就是截断区。这里要记三样东西:截断点最后一行的文本、这一行在原图上的大致 y 像素区间、以及截断处前后各两行的上下文。三者缺一个,后面就无法判断是范围问题还是识别问题。

y 坐标可以从看图工具里读,也可以裁一条窄带单独验证:

from PIL import Image
img = Image.open('page.png')
print(img.size)                      # (宽, 高),先确认总高度
img.crop((0, 2400, img.size[0], 2600)).save('probe.png')   # 截断点附近 200px

把 probe.png 单独识别一次。如果这一小块能正常出字,说明整页那条输入超出了处理范围,必须切片;如果单独裁出来仍然缺行,那就是该区域本身的问题,例如水印压字、对比度太低、字号过小或者表格线遮挡,此时换切片策略也不会改善,需要先处理图像。这个对照步骤通常只做一次,后面所有长图都按同一套参数走。

设计切片高度与重叠比例

切片高度的上限来自模型侧对图像长边或内容量的实际限制,应该从配置项、报错信息或返回的警告里去确认,而不是猜。确认之后把单片高度压到限制以内并留出余量,同时保证每一片至少包含若干完整的文本行,避免正好从一行字中间切开。重叠区的作用是兜住被切断的那一行,建议取一到两行文本的高度,或者切片高度的百分之十到十五,按实际字号调整。

from PIL import Image
import json, os

SRC     = 'page.png'     # 整页长截图
OUT     = 'slices'       # 切片输出目录
SLICE_H = 1600           # 示例值:按实际可接受长边留余量后设定
OVERLAP = 200            # 相邻片重叠高度,至少覆盖 1~2 行文本

os.makedirs(OUT, exist_ok=True)
img = Image.open(SRC)
W, H = img.size
step = SLICE_H - OVERLAP
manifest, i, y = [], 1, 0
while y < H:
    bottom = min(y + SLICE_H, H)
    name = 'page_%04d.png' % i
    img.crop((0, y, W, bottom)).save(os.path.join(OUT, name))
    manifest.append({'idx': i, 'file': name, 'y0': y, 'y1': bottom})
    if bottom >= H:
        break
    y += step
    i += 1

with open(os.path.join(OUT, 'manifest.json'), 'w', encoding='utf-8') as f:
    json.dump(manifest, f, ensure_ascii=False, indent=2)
print(W, H, len(manifest))

命名用四位补零的 page_0001.png,序号顺序就是从上到下的阅读顺序,补零是为了让字符串排序和数值排序一致。同时在 manifest 里记录每片的起始 y 和结束 y,后面定位漏检、核对边界都靠这两个值,不要只靠文件名。

逐片识别并按顺序拼接

识别阶段保证每片的输出落成 ocr/0001.txt 这类与片号一一对应的小文件,不要追加到一个大文件里,否则中途失败就无法定位是哪一片出错。拼接按 manifest 的 idx 排序读取,不按文件名字符串排序,虽然补零后两者通常一致,但显式按数值排更稳妥。

MiniCPM-OCR 单图识别快、整页长图要自己切分
import json, re

def norm(s):
    s = re.sub(r'\s+', '', s.strip())
    return s.strip(',。,.::;;|')

man = json.load(open('slices/manifest.json', encoding='utf-8'))
man.sort(key=lambda m: m['idx'])
blocks = [open('ocr/%04d.txt' % m['idx'], encoding='utf-8').read().splitlines() for m in man]

merged = blocks[0]
for nxt in blocks[1:]:
    k = 0
    for k in range(min(len(merged), len(nxt), 8), 0, -1):
        if [norm(x) for x in merged[-k:]] == [norm(x) for x in nxt[:k]]:
            break
    else:
        k = 0
    merged.extend(nxt[k:])

open('merged.txt', 'w', encoding='utf-8').write('\n'.join(merged))

顺序错乱时按三个点查:一是 manifest 里的 y0 是否严格递增,递增说明切片本身没错;二是每片输出首行是否与上一片尾部语义衔接,接不上说明中间有片丢失或读错文件;三是拼接后的总行数是否小于各片行数之和,若等于甚至更大,说明重叠区没被识别出来,去重逻辑没生效。片序标记最省事的做法就是文件名序号加 manifest 的 y 区间,不必在图片上画水印。

在重叠区做重复行去重

去重只在相邻两片的边界窗口内做,取上一片末尾若干行与下一片开头若干行,逐行归一化后比较,完全相等才删除下一片那几行。归一化至少要处理空白字符、全半角标点和行尾多余符号,否则同一行会因为标点差异被判定为不同。不要对整个文档做全局去重,正文里的重复表头、重复列表项是真实内容,全局去重会把它们误删。

边界行要单独处理:如果重叠区里下一片的首行本身就是被切开的半行,它常常对不上任何完整行,这时保留上一片的完整行、丢弃或标记该残行,并在 merged 里留一条注释方便回查。相似但不完全相等的两行,宁可都留下并加标记,人工扫一眼也比自动删掉安全。抽检时在每个边界 y 附近取原图裁片,对照拼接结果看上三下三行,重点看是否出现半行、重复行和跳行。

对比整页与切片两种跑法的耗时和漏检

两条路线各跑一次,用同一张长图,字段统一记录,不要凭感觉判断哪个更好:

跑法调用次数总耗时输出行数漏检行数截断位置
整页一次1________________________________________________________
切片拼接按片数填__________________________________________不适用

漏检行数靠锚点核对得出:在原图上挑几处好认的行(标题、编号、金额、表格首行),记录它在整页跑法和切片跑法里是否出现,逐条累加。耗时按整体从提交到拼接完成计,别只算单次请求,切片路线多了切图、合并和去重的开销。

路线的落点比较清楚:一屏以内、像素高度在可接受范围内、整页跑法没有截断的截图,直接整页识别,调用次数最少也最省事;只要整页跑法出现截断,或者图像高度接近甚至超过限制,就切分处理,漏检可以用 manifest 的 y 区间快速定位到具体片。实际限制值和中文字号对应的行高需要结合自己的环境确认,参数确定后固定下来,不要每张图都重新调。