发票、小票、合同本地 OCR 归档:离线识别 + 按日期金额自动归类(2026)

「票据」这个词听起来像会计的事,但实际生活里需要它的场景比想象中密集:

  • 报销:公司报销有期限,找不齐就得自己承担
  • 保修与索赔:家电、数码产品坏了,要出示购买凭证,而它可能躺在三年前的某个抽屉里
  • 租房与押金:退租时房东说「墙面有损坏」,你得翻出当初的交接单和转账记录
  • 装修与维权:合同、报价单、付款凭证、验收单,缺哪一样都说不清
  • 个税专项附加扣除:部分扣除项目需要留存备查资料
  • 经营与报账:个体经营、自由职业者的成本票据,本身就是钱

共同的痛点只有一个:票据是「当下没用、需要时找不到」的东西。纸质件塞抽屉、电子发票散在邮箱和下载目录里,真要用的时候翻不出来。这篇给一套完整的本地归档流程:离线识别中文票据、提取日期和金额、按规则重命名归档。

为什么不用在线 OCR 服务

在线 OCR 很方便,但票据这个场景恰好是它最不合适的场景,三个原因:

第一,票据上全是敏感信息。发票有纳税人识别号,合同有身份信息和金额,收据有银行卡尾号。把这类文件上传到第三方服务器做识别,等于把最不该外流的东西送出去。

第二,按次付费的成本会累积。单张看起来只有几分钱,但归档这件事是长期的、批量的。一年积累几百上千份,费用不小,而且你没法预知什么时候需要重跑一遍(比如换了归档规则要重新识别)。

第三,服务的不确定性。接口调整、免费额度变化、服务下线,都会让你的流程中断。归档是十年尺度的事,依赖一个可能消失的服务不合理。

Umi-OCR:国产开源离线方案

Umi-OCR(仓库 hiroi-sora/Umi-OCR)是国内开发者维护的开源离线 OCR 工具,MIT 协议,GitHub 上近 3 万 star,由作者利用业余时间持续维护。它完全离线运行,解压即用,不需要联网,也不需要显卡。安装包从 GitHub Releases 拿,作者另外提供了国内免注册的下载渠道。

对本场景最有用的四个能力:

  • 批量识别:把几十上百张图片或 PDF 拖进去,一次性识别完,导出成每份文件一个 txt
  • PDF 识别与双层 PDF 输出:把扫描件转成「视觉上还是原扫描件、但文字层可搜索可复制」的双层 PDF。这是归档场景的核心能力——原件形态不变,检索能力补上了
  • 识别引擎可切换:自带两个离线引擎(PaddleOCR 和 RapidOCR),也可以装插件扩展,识别效果不理想时可以换引擎试
  • 命令行与 HTTP 接口:可以被脚本调用,接进自己的自动化流程

它还支持截图识别、二维码识别、公式识别,日常用起来更像一个工具箱。语言方面支持简体、繁体、英文、日文等多语种,中文识别是它的主场——这一点是很多国外 OCR 工具的短板。

两步流程:先识别,再归档

整个流程刻意分成两步,因为这是最稳的组合:OCR 交给成熟工具,归档用自己的脚本。两者之间用「每份文件一个 txt」这个最简单的格式衔接。

第一步(Umi-OCR 手工完成):把要归档的图片或 PDF 拖进批量识别,导出结果到一个目录。导出的 txt 和原文件同名,这一步不需要写任何代码。

第二步(下面的脚本自动完成):读取这批 txt,从文字里提取「日期、金额、票据类型」,然后重命名成 日期_类型_金额.后缀 并归入 年-月 子目录,最后生成一份汇总表。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""票据归档:把 OCR 出来的发票/小票/合同按日期金额归类

配合本地离线 OCR(如 Umi-OCR 批量识别导出 txt)使用:
  1. 用 OCR 把图片/PDF 批量识别成同名 .txt
  2. 本脚本读 .txt,提取日期、金额、票据类型
  3. 生成归档计划:重命名成「日期_金额_类型.xxx」,归入 年/月/ 目录

用法:
    python receipt_organize.py ./ocr_out                 # 只预览
    python receipt_organize.py ./ocr_out --apply         # 真正移动/重命名
    python receipt_organize.py ./ocr_out --ext .pdf      # 指定原始文件后缀
"""

import argparse
import csv
import os
import re
import shutil
import sys
from datetime import datetime

DATE_PATTERNS = [
    (r"(d{4})[-/.年](d{1,2})[-/.月](d{1,2})", lambda m: (int(m.group(1)), int(m.group(2)), int(m.group(3)))),
    (r"(d{4})[-/.年](d{1,2})", lambda m: (int(m.group(1)), int(m.group(2)), 1)),
]

AMOUNT_PATTERNS = [
    r"(?:小写|金额|合计|总计|应付|实付|价税合计|押金|租金|月租|首付|尾款)[^0-9]{0,10}([d,]+.?d{0,2})",
    r"[¥¥]s*([d,]+.?d{0,2})",
    r"([d,]+.d{2})s*元",
]

TYPES = [
    ("发票", ["发票", "增值税", "电子发票", "发票号码", "税号"]),
    ("小票", ["小票", "收银", "购物小票", "欢迎光临", "实付", "找零"]),
    ("火车票", ["火车票", "铁路", "车次", "12306", "乘车日期"]),
    ("机票", ["机票", "航班", "登机牌", "行程单", "boarding"]),
    ("话费", ["话费", "通信费", "中国移动通信", "中国电信", "中国联通"]),
    ("水电燃气", ["电费", "水费", "燃气", "供电", "自来水", "国网"]),
    ("快递", ["快递", "运单", "寄件", "收件", "物流"]),
    ("合同", ["合同", "协议", "甲方", "乙方", "签订日期"]),
    ("医疗", ["门诊", "医疗", "医院", "挂号", "处方", "发票监制"]),
    ("收据", ["收据", "收条", "今收到"]),
]


def find_date(text):
    for pat, conv in DATE_PATTERNS:
        m = re.search(pat, text)
        if m:
            y, mo, d = conv(m)
            if 2000 <= y <= 2100 and 1 <= mo <= 12 and 1 <= d <= 31:
                return "%04d-%02d-%02d" % (y, mo, d)
    return None


def find_amount(text):
    cands = []
    for pat in AMOUNT_PATTERNS:
        for m in re.finditer(pat, text):
            try:
                v = float(m.group(1).replace(",", ""))
            except ValueError:
                continue
            if 0 < v < 1000000:
                cands.append(v)
    if not cands:
        return None
    # 「合计/价税合计」通常就是应付额;取最大的往往是合计,但小票里最大的可能是原价
    # 这里取出现金额里的最大值,并在无法判断时标 needs_check
    return max(cands)


def find_type(text, filename):
    best, score = "其他", 0
    for name, kws in TYPES:
        s = sum(1 for k in kws if k in text or k in filename)
        if s > score:
            best, score = name, s
    return best


def scan(indir, ext):
    """扫描 OCR 出的 .txt,按同名匹配原始文件"""
    items = []
    for fn in sorted(os.listdir(indir)):
        if not fn.lower().endswith(".txt"):
            continue
        base = fn[:-4]
        src = None
        if ext:
            p = os.path.join(indir, base + ext)
            if os.path.exists(p):
                src = p
        if src is None:
            for cand in os.listdir(indir):
                if cand == fn:
                    continue
                if os.path.splitext(cand)[0] == base:
                    src = os.path.join(indir, cand)
                    break
        with open(os.path.join(indir, fn), encoding="utf-8", errors="ignore") as f:
            text = f.read()
        items.append({
            "txt": fn, "src": src, "text": text,
            "date": find_date(text) or find_date(base),
            "amount": find_amount(text),
            "type": find_type(text, base),
        })
    return items


def w(s, n):
    width = sum(2 if ord(c) > 127 else 1 for c in s)
    return s + " " * max(0, n - width)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("indir")
    ap.add_argument("--apply", action="store_true")
    ap.add_argument("--ext", default="")
    ap.add_argument("--out", default="")
    a = ap.parse_args()

    if not os.path.isdir(a.indir):
        sys.exit("目录不存在:%s" % a.indir)

    items = scan(a.indir, a.ext)
    if not items:
        sys.exit("目录下没有 .txt,先把 OCR 结果导出到这个目录")

    outdir = a.out or os.path.join(a.indir, "归档")
    print("共 %d 份票据n" % len(items))
    print(w("提取日期", 12) + w("金额", 12) + w("类型", 10) + w("原文件", 26) + "状态")
    print("-" * 92)

    ok, miss, total = [], 0, 0.0
    for it in items:
        if it["date"] and it["amount"]:
            status = "OK"
            ok.append(it)
            total += it["amount"]
        else:
            status = "缺" + ("日期 " if not it["date"] else "") + ("金额" if not it["amount"] else "")
            miss += 1
        print(w(it["date"] or "-", 12) + w("%.2f" % it["amount"] if it["amount"] else "-", 12)
              + w(it["type"], 10) + w(os.path.basename(it["src"] or it["txt"])[:24], 26) + status)

    print("-" * 92)
    print("可归档 %d 份,合计 %.2f;识别不全 %d 份(需手工补)n" % (len(ok), total, miss))

    # 月度汇总
    by_month = {}
    for it in ok:
        by_month.setdefault(it["date"][:7], [0, 0.0])
        by_month[it["date"][:7]][0] += 1
        by_month[it["date"][:7]][1] += it["amount"]
    if by_month:
        print("月度分布:")
        for m in sorted(by_month):
            n, s = by_month[m]
            print("  %s  %2d 份  %10.2f" % (m, n, s))
        print()

    if not a.apply:
        print("以上是预览。确认无误后加 --apply 执行归档。")
        return

    os.makedirs(outdir, exist_ok=True)
    moved = 0
    for it in ok:
        if not it["src"]:
            continue
        ym = it["date"][:7]
        target_dir = os.path.join(outdir, ym)
        os.makedirs(target_dir, exist_ok=True)
        ext = os.path.splitext(it["src"])[1]
        newname = "%s_%s_%.2f%s" % (it["date"], it["type"], it["amount"], ext)
        dst = os.path.join(target_dir, newname)
        i = 1
        while os.path.exists(dst):
            dst = os.path.join(target_dir, newname.replace(ext, "_%d%s" % (i, ext)))
            i += 1
        shutil.copy2(it["src"], dst)
        moved += 1

    csvpath = os.path.join(outdir, "汇总.csv")
    with open(csvpath, "w", encoding="utf-8-sig", newline="") as f:
        wcsv = csv.writer(f)
        wcsv.writerow(["日期", "类型", "金额", "归档文件名"])
        for it in ok:
            if not it["src"]:
                continue
            ext = os.path.splitext(it["src"])[1]
            wcsv.writerow([it["date"], it["type"], "%.2f" % it["amount"],
                           "%s_%s_%.2f%s" % (it["date"], it["type"], it["amount"], ext)])
    print("已归档 %d 份到 %s,并生成 %s" % (moved, outdir, csvpath))
    print("原文件保留不动(用的是复制),确认后再手工清理。")


if __name__ == "__main__":
    main()

它同样是纯标准库,不需要装任何依赖。默认只做预览,确认无误后加 --apply 才真正操作,而且用的是复制而不是移动,原文件保持不动——归档出错时可以随时重来。

用 5 份模拟票据(发票、超市小票、话费账单、租赁合同、一张模糊件)跑出来的结果:

共 5 份票据

提取日期    金额        类型      原文件                    状态
--------------------------------------------------------------------------------------------
2026-03-14  268.00      发票      bill01.txt                OK
2026-04-02  213.80      小票      bill02.txt                OK
2026-05-01  128.00      话费      bill03.txt                OK
2026-01-05  4800.00     合同      bill04.txt                OK
-           -           其他      bill05.txt                缺日期 金额
--------------------------------------------------------------------------------------------
可归档 4 份,合计 5409.80;识别不全 1 份(需手工补)

月度分布:
  2026-03   1 份      268.00
  2026-04   1 份      213.80
  2026-05   1 份      128.00

执行 --apply 后会得到这样的结构,并额外生成一份汇总 CSV:

归档/
  2026-01/
    2026-01-05_合同_4800.00.PDF
  2026-03/
    2026-03-14_发票_268.00.png
  2026-04/
    2026-04-02_小票_213.80.jpg
  2026-05/
    2026-05-01_话费_128.00.png
  汇总.csv

几个必须知道的坑

第一,识别不全的必须被显式标出来,不能默默放过。上面那张模糊件就被标成了「缺日期 金额」。这是整个流程最重要的设计:全自动归档最危险的失败模式不是报错,而是「悄悄漏掉一份」。所以脚本把所有识别不全的单独列出来,让你手工处理——宁可留一份要人看,也不要少一份。

第二,金额提取靠关键词,不是万能的。脚本用「合计 / 小写 / 价税合计 / 押金 / 租金 / 实付」这类词去定位金额。我在测试时就发现过这种情况:合同里写的是「押金 4800.00」,最初的词表里没有「押金」,结果金额提取失败。所以在自己用的时候,遇到没被识别的类别,就把那个关键词加进词表。这也是为什么建议先跑一遍预览、看输出再决定是否执行。

第三,OCR 识别率受拍摄质量影响极大。倾斜、反光、褶皱、盖章遮挡都会明显降低准确率。实用建议是:拍摄时保证单据平整、光线均匀、尽量正对,背景与纸张颜色反差大。一次拍好,比事后修图省事得多。

第四,手写和特殊格式别指望自动。手写收据、特殊行业单据、复杂表格,识别效果都不稳定。这类老老实实人工归类,只在备注里记录。

纸质件数字化:一次整理,长期受益

如果家里已经攒了几年的纸质票据,建议按这个顺序做一次性整理:

  1. 先按年份分堆,不要一边翻一边归档,那会没完没了
  2. 过期的直接处理掉:保修期已过、报销期已过、金额极小的,可以拍个照就丢——重点是别让它们占用你的注意力
  3. 重要的先拍照:合同、大额发票、房产与车辆相关、医疗票据,这几类优先
  4. 统一命名再批量识别:照片文件名尽量带上日期(相机默认名称通常就有),能减少后续人工判断
  5. 跑一遍脚本看预览,把识别不全的手工补,再执行归档
  6. 做备份:归档目录至少复制一份到另一块硬盘或另一个位置

关于长期保存,两个细节值得注意:第一,原始扫描件的格式优先用 PDF 或 PNG,避免反复压缩的 JPG;第二,除了文件名,归档时生成的汇总 CSV 就是你的「票据台账」——按年份留着,将来查「某年某月买过什么」比翻文件夹快得多。

和已有的省钱流程怎么接

这套归档流程不是孤立的,它是前面几篇的基础设施:

  • 跟比价与价保配合:申请价保要提交订单和价格截图,归档后随时能翻出来
  • 跟话费宽带降档配合:申诉时需要的账单明细、业务办理截图、工单记录,按月份归档就是现成的证据包
  • 跟账单优化配合:电费、话费的历史账单本身就是趋势分析的数据源,三年后还能查到当时的单价和用量
  • 跟发票与报销配合:英文发票、境外服务账单能不能报销、怎么入账,那篇里单独写过

按需获取,减少需要归档的订阅账单

顺带说一句:减少订阅,本身就是在减少要归档的账单。每少一个按月扣款的服务,就少一份要追踪、要留证的记录。素材这类低频需求尤其如此——按项目按张获取,用一次结一次,账目也干净得多。 代下载仅提供文件获取服务,商用授权需自行购买。

搜索并获取需要的素材文件 →

常见问题

Umi-OCR 和其他离线 OCR 比,优势在哪?

它的优势集中在三点:中文识别质量(国产工具在本土文档上通常更稳)、开箱即用(解压就能跑,不用配环境、不依赖显卡)、以及可被程序调用(提供命令行和 HTTP 接口,能接进自动化流程)。另外它带双层 PDF 输出,这个功能在免费工具里不算常见,对归档场景很关键。

脚本能不能直接调用 OCR,做成全自动?

技术上可以——Umi-OCR 提供命令行和 HTTP 接口,可以把识别环节也接进脚本。但我不建议一上来就这么做,原因是:识别结果需要人判断的地方比想象中多(模糊件、金额有歧义、多张票据混在一页)。先用两步流程跑一段时间,摸清自己票据的识别情况,再考虑把第一步也自动化。先让流程可靠,再让它全自动。

公司报销要求原件,数字化还有用吗?

有用,而且作用不同。数字化不替代原件,它解决的是「找得到、看得清、能检索」:报销时先查台账定位是哪几张,再去翻原件;原件丢失时,扫描件也能作为辅助证明;多个渠道报销时,扫描件副本可以先用。原件该留还是要留,两者不冲突。

归档目录要不要放到网盘同步?

看你对隐私的要求。票据里有身份信息、金额、账号,放到第三方网盘意味着这些数据离开你的设备。如果要用同步,至少有两条底线:选择支持客户端加密的存储方式,并且不要用同一个账号存放与身份直接关联的其他资料。更稳妥的做法是本地保存 + 定期冷备份到移动硬盘。

延伸阅读

评论 (0)