把 Xiaomi-OCR-0 接进文档流水线、识别结果要按页归档并保留坐标框

文章导读
把 Xiaomi-OCR-0 接进文档流水线时,先要确定结果以什么形式保存,而不只是先把它接进流水线。按页归档、每页一条 JSON、每条文本块带一个坐标框,这套结构定下来之后,接模型的代码基本是外围工作;坐标对不上,多半是两件事:送进模型的那一版图片被缩放或转成归一化坐标,而你没有留下基准尺寸,或者归档时把整份文档写成一个文件,出问题后定位不到具体哪一页。下面给的是可替换的通用骨架,字段名和调用方
📋 目录
  1. A 定义每页结果的三个必填字段
  2. B 在送入模型前记录每页图片的宽高
  3. C 把识别结果按页写入独立文件
  4. D 用坐标框在原图上画框做回填校验
A A

把 Xiaomi-OCR-0 接进文档流水线时,先要确定结果以什么形式保存,而不只是先把它接进流水线。按页归档、每页一条 JSON、每条文本块带一个坐标框,这套结构定下来之后,接模型的代码基本是外围工作;坐标对不上,多半是两件事:送进模型的那一版图片被缩放或转成归一化坐标,而你没有留下基准尺寸,或者归档时把整份文档写成一个文件,出问题后定位不到具体哪一页。下面给的是可替换的通用骨架,字段名和调用方式按你实际部署的接口替换即可。

建议先把归档结构固定为 page、text_blocks、bbox 三个必填字段,并在每页记录送模型那一版图片的宽高;坐标统一用相对该页原图的像素坐标,归一化结果在写入前换算。归档按页一个文件,命名带页号。校验方式是用 PIL 把 bbox 画回原图逐页查看:框贴不上文字,就先查宽高基准和缩放,再查坐标顺序。

定义每页结果的三个必填字段

一页一条记录,最少留三个字段:page(页号,从 1 开始)、text_blocks(文本块数组)、每个块里的 bbox(坐标框)。除此之外都算可选,但 page_meta 强烈建议一起带上,它决定坐标能不能还原。

{
  "page": 1,
  "source": "contract.pdf",
  "page_meta": {
    "width": 1654,
    "height": 2339,
    "coord_unit": "pixel",
    "bbox_order": "xyxy"
  },
  "text_blocks": [
    {
      "id": "p0001_b000",
      "text": "合同编号:A-001",
      "bbox": [120, 210, 640, 260],
      "conf": 0.93
    },
    {
      "id": "p0001_b001",
      "text": "甲方(盖章):",
      "bbox": [120, 300, 520, 348],
      "conf": 0.88
    }
  ]
}

两个约定必须写死在文档里,别留给下游猜:bbox 用 [x1, y1, x2, y2] 还是 [x, y, w, h],以及是像素还是 0-1 归一化。上面的示例用像素、xyxy。如果模型返回的是归一化坐标,写入前换算:x_px = round(x_norm * meta["width"]),y_px = round(y_norm * meta["height"]);反过来也成立。同一份归档里不要混用两种单位,混用是后面最难查的一类偏移。

在送入模型前记录每页图片的宽高

坐标的唯一基准是“送进模型的那张图”的尺寸,不是你磁盘上原始 PDF 的尺寸,也不是页面渲染尺寸。所以这一步放在图片落到临时目录之后、调用模型之前,用一个函数统一产出 page_meta:

from PIL import Image

def build_page_meta(img_path, page_no, dpi=None, resized_from=None):
    with Image.open(img_path) as im:
        w, h = im.size
    meta = {
        "page": page_no,
        "width": w,
        "height": h,
        "coord_unit": "pixel",
        "bbox_order": "xyxy",
        "dpi": dpi,
    }
    if resized_from:
        meta["orig_width"], meta["orig_height"] = resized_from
    return meta

如果你为了控制显存或请求体把长边压到了某个上限,就把压缩前的宽高填进 resized_from,归档里同时保留两套尺寸。这样下游要回填原图时,缩放比例是 orig_width / width,直接乘上去即可,不用再猜。page_meta 保存位置有两个选择:写进每页 JSON 的 page_meta 字段(推荐,一页一个自包含文件),或者单独落一个 out/page_0001.meta.json。前者更适合重跑,后者适合元信息要单独查询的场景。

验证方式很直接:抽几页比对 page_meta.width/height 和 Image.open(img).size 的打印结果,不一致说明基准记错了,后面的框一定偏。

把 Xiaomi-OCR-0 接进文档流水线、识别结果要按页归档并保留坐标框

把识别结果按页写入独立文件

整份文档写一个大 JSON,问题不在体积,在于重跑和定位:一页失败要全部重来,报错也说不清是第几页。按页落盘,命名固定成 out/page_0001.json,页号四位补零,保证 ls 和字符串排序都是页序。页数超过 9999 时改成五位,格式一旦定下就不要中途改。

import json, pathlib

OUT = pathlib.Path("out")
OUT.mkdir(exist_ok=True)

def page_path(page_no):
    return OUT / f"page_{page_no:04d}.json"

def save_page(page_no, meta, blocks):
    record = {
        "page": page_no,
        "page_meta": meta,
        "text_blocks": blocks,
    }
    target = page_path(page_no)
    tmp = target.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2),
                   encoding="utf-8")
    tmp.replace(target)

# 主循环骨架
for page_no, img_path in enumerate(page_images, start=1):
    meta = build_page_meta(img_path, page_no, dpi=200)
    blocks = run_ocr(img_path)          # 替换成你的模型调用
    save_page(page_no, meta, blocks)
    print(f"page {page_no}: {len(blocks)} blocks")

先写 .tmp 再 replace,是为了避免进程中断留下半截 JSON,下游解析时炸在文件末尾。日志里打页号和块数,重跑时对着 out/ 目录就能看出哪页缺、哪页是空结果。空页也要写文件,text_blocks 给空数组,别跳过——跳过之后你分不清“这页没识别出来”和“这页根本没跑”。

用坐标框在原图上画框做回填校验

归档完不要只看 JSON,把框画回图上,一眼就能看出整体缩放、上下颠倒、单位换算这类系统性问题。

from PIL import Image, ImageDraw
import json, pathlib

def draw_check(img_path, json_path, out_path, width=3):
    record = json.loads(pathlib.Path(json_path).read_text(encoding="utf-8"))
    meta = record["page_meta"]
    im = Image.open(img_path).convert("RGB")
    if im.size != (meta["width"], meta["height"]):
        im = im.resize((meta["width"], meta["height"]))
    draw = ImageDraw.Draw(im)
    for blk in record["text_blocks"]:
        draw.rectangle(blk["bbox"], outline="red", width=width)
    im.save(out_path)
    print(record["page"], im.size, len(record["text_blocks"]))

for p in sorted(pathlib.Path("out").glob("page_*.json")):
    n = int(p.stem.split("_")[1])
    draw_check(f"images/page_{n:04d}.png", p, f"check/page_{n:04d}.png")

逐页检查时看三点:框是否包住整行文字(偏小偏大是坐标顺序写反或少乘了宽高)、框整体往一个方向平移(缩放比例没还原)、框位置正确但文字是斜的(页面本身有旋转,需要单独处理旋转角度)。连续几页都准、个别页不准,多半是那页图片尺寸异常或版面特殊,回去看 page_meta 就能确认。

这套做法的边界也要说清楚:坐标框依赖模型输出的框精度,画框只能证明“归档没把坐标弄坏”,不能证明识别内容正确;旋转页、非常规裁切的扫描件可能需要额外的方向校正,具体怎么处理要结合你的文档来源确认。上线前先用十几页不同类型页面跑一遍画框,确认没问题再放开批量。