在文件辨識的應用中,傳統 OCR 通常只能取得圖片中的文字,如果想進一步知道哪一串數字是金額、商店代號或航班號碼,往往還需要另外撰寫欄位位置與文字規則。

現在透過 LLM(Large Language Model,大語言模型),模型除了能辨識文字,也能理解圖片中的版面與欄位關係。因此,我們可以直接告訴模型需要哪些資料,再將辨識結果整理成 JSON。

這次使用 LMDeploy 在地端部署 LLM,先從版面較單純的發票開始,再進一步挑戰資訊更密集的登機證。

環境準備

首先安裝 LMDeploy:

pip install lmdeploy

實戰一:發票辨識

不同店家的發票排版可能完全不同,例如有些欄位左右排列,有些則是由上往下排列。如果使用固定座標或 Regex 規則,每增加一種版型,就可能需要重新調整。

使用 LLM 時,我們可以直接把需要擷取的欄位寫進 Prompt,讓模型根據圖片中的文字與語意判斷欄位。

以下是兩張手機直拍的樣本:

台茂購物中心發票全聯福利中心發票
NCCC 格式,欄位左右分兩欄排列單欄冒號對齊,欄位名稱含全形空白卡  號

基本用法

以上是有陰影、有紙張捲的圖片,面對這種差異,我們只把欄位定義寫進 prompt,其餘交給模型判斷:

from lmdeploy import pipeline, GenerationConfig, PytorchEngineConfig
from lmdeploy.vl import load_image

MODEL = 'OpenGVLab/InternVL3-8B-hf'

PROMPT = """請讀取這張發票,輸出 JSON,欄位如下:
merchant_name(店家名稱)、merchant_id(商店代號)、terminal_id(端末機代號)、
card_brand(卡別)、card_number_masked(卡號,保留星號遮罩)、
transaction_datetime(YYYY-MM-DD HH:mm:ss)、approval_code(授權碼)、
amount(金額,保留千分位逗號)、currency(幣別,NT$ 正規化為 TWD)

【約束】看不到的欄位填 null,嚴禁猜測;數字逐字元比對,注意 0/O、1/I、5/S 混淆;
只輸出單一 JSON 物件,前後不得有任何說明文字。"""


def main():
    pipe = pipeline(MODEL, backend_config=PytorchEngineConfig(session_len=16384))

    gen_config = GenerationConfig(temperature=0.0, max_new_tokens=1024)

    image = load_image('a.jpg')
    print(pipe((PROMPT, image), gen_config=gen_config).text)

if __name__ == '__main__':
    main()

即使兩張發票的欄位名稱、位置與版面不同,LLM 仍可以利用文字語意將資料對應到相同的欄位結構。

用 JSON Schema 固定輸出格式

實際串接系統時,只在 Prompt 中要求模型「輸出 JSON」仍可能遇到模型額外產生說明文字的情況,因此也可以透過 JSON Schema 限制輸出格式:

import json

SCHEMA = {
    "type": "object",
    "properties": {
        "merchant_name": {"type": ["string", "null"]},
        "amount":        {"type": ["string", "null"]},
        "currency":      {"type": ["string", "null"]},
        "card_brand":    {"type": ["string", "null"],
                          "enum": ["VISA", "MASTERCARD", "JCB", "UNIONPAY", None]},
    },
    "required": ["merchant_name", "amount"],
}

gen_config = GenerationConfig(
    temperature=0.0,
    max_new_tokens=1024,
    response_format={"type": "json_schema",
                     "json_schema": {"name": "receipt", "schema": SCHEMA}},
)

image = load_image('a.jpg')
print(json.loads(pipe((PROMPT, image), gen_config=gen_config).text))

這樣可以讓後端直接解析模型輸出的 JSON,減少額外處理模型回答格式的問題。

實戰二:挑戰更複雜的登機證

接著,我們把相同的方法用在資訊更密集的登機證

登機證除了航班、座位與日期之外,還可能同時出現登機時間、起飛時間、PNR、序號與機場資訊,而且常有中英文或日英文混合的標籤。

實測後發現,一個很重要的原則是:

讓模型負責「看圖與抄寫」,不要讓模型自己推算圖片上沒有的資訊。

例如票面只印 21JAN,就保留 21JAN,不要要求模型自己補上年份;如果圖片中沒有抵達時間,就直接填 null

登機證 Schema 與 Prompt

BP_SCHEMA = {
    "type": "object",
    "properties": {
        "passenger_name_raw": {"type": ["string", "null"]},
        "pnr_raw":            {"type": ["string", "null"]},
        "ticket_number_raw":  {"type": ["string", "null"]},
        "segments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "carrier_raw":        {"type": ["string", "null"]},
                    "flight_raw":         {"type": ["string", "null"]},
                    "from_raw":           {"type": ["string", "null"]},
                    "to_raw":             {"type": ["string", "null"]},
                    "date_raw":           {"type": ["string", "null"]},
                    "departure_time_raw": {"type": ["string", "null"]},
                    "boarding_time_raw":  {"type": ["string", "null"]},
                    "arrival_time_raw":   {"type": ["string", "null"]},
                    "seat_raw":           {"type": ["string", "null"]},
                    "gate_raw":           {"type": ["string", "null"]},
                    "class_raw":          {"type": ["string", "null"]},
                    "seq_raw":            {"type": ["string", "null"]},
                },
                "required": ["carrier_raw", "flight_raw", "from_raw", "to_raw",
                             "date_raw", "departure_time_raw", "boarding_time_raw",
                             "arrival_time_raw", "seat_raw", "gate_raw",
                             "class_raw", "seq_raw"],
            },
        },
    },
    "required": ["passenger_name_raw", "pnr_raw", "ticket_number_raw", "segments"],
}

BP_PROMPT = """請讀取這張登機證(boarding pass),把票面文字抄進 JSON。
票面可能是中英、日英雙語,標籤與值請一併判讀(如 便名/FLIGHT、出発地/FROM、
出発時刻/DEP. TIME、搭乗時刻/Boarding Time、Seat 座位、Seq No. 序號、Carrier 航空公司)。

**最重要的規則:所有欄位都是「票面原樣」,逐字照抄,不准做任何轉換或補充。**

- 時間就抄票面看到的樣子:票面印 `0930` 就填 `"0930"`,印 `09:15` 就填 `"09:15"`。
  不要自己加冒號、不要補零、不要換算。
- **登機時間與起飛時間是兩個不同的值,必須分別對準各自的標籤再抄**:
  `BOARDING TIME` / `搭乗時刻` → `boarding_time_raw`
  `ETD` / `DEP. TIME` / `出発時刻` → `departure_time_raw`
  這兩組標籤在票面上常左右並排,值分別在各自標籤的下方或右方。
  起飛時間一定晚於登機時間(通常差 15~45 分鐘)。
  若你抄出兩個相同的值,代表看錯欄位了,請重新對齊標籤再讀一次。
- 日期就抄票面看到的樣子:印 `08FEB23` 填 `"08FEB23"`,印 `21JAN` 填 `"21JAN"`。
  **絕對不要自己補上年份**,也不要轉成西元格式。
- 地名就抄票面看到的樣子:印 `FRANKFURT/FRA` 填 `"FRANKFURT/FRA"`,
  印 `KAOHSIUNG` 填 `"KAOHSIUNG"`。**不要自己換成機場代碼**。
- 班號就抄票面看到的樣子:印 `LH 1030` 填 `"LH 1030"`(含空格),印 `NX657` 填 `"NX657"`。
  不要拆開、不要補上航空公司代碼。
- 座位就抄票面看到的樣子:印 `1 D` 填 `"1 D"`(含空格),不要寫成 `D1` 或 `1D`。
- 姓名就抄票面看到的樣子,含斜線與空格。

其他規則:
1. 一張登機證通常只有一個航段;segments 僅在票面確實印出多段時才超過一個。
   副券/存根重複的同一班資訊不算第二段。
2. pnr_raw 只填票面明確標示 PNR 的值;票面沒有 PNR 欄位就填 null。
   不要拿航空公司代碼、序號、姓名或其他數字充當 PNR。
3. ticket_number_raw 只填票面明確標示的機票號碼。
   SEQ/Seq No./序號不是票號,要填進 seq_raw。登機證通常沒有票號,填 null。
4. arrival_time_raw:登機證幾乎不印抵達時間,票面沒有就填 null,嚴禁推算。
5. 條碼與 QR Code 區域不含可讀文字,請完全忽略,不得從中產生任何內容。
6. **票面上沒有印的欄位一律填 null。寧可留空,也不要猜。**"""

登機證結果驗證

模型完成辨識後,再利用 Python 的固定規則檢查 PNR、班號、時間、日期等欄位,避免錯誤資料直接進入後端。

import re

TIME_RE = re.compile(r"(?:[01]\d|2[0-3]):?[0-5]\d")

DATE_RE = re.compile(r"\d{1,2}\s*[A-Z]{3}\s*(?:\d{2}|\d{4})?"
                     r"|\d{4}[/-]\d{1,2}[/-]\d{1,2}"
                     r"|\d{1,2}[/-]\d{1,2}[/-]\d{2,4}")
PNR_RE = re.compile(r"[A-Z0-9]{6}")

FLIGHT_RE = re.compile(r"(?:[A-Z]{2}|[A-Z]\d|\d[A-Z]|[A-Z]{3})\s?\d{1,4}[A-Z]?")

NULLISH = {"", "null", "none", "nil", "n/a", "na", "-", "--"}

def clean(value):
    """把字串型的空值("null"、"N/A"…)正規化為 None,並去除前後空白。"""
    if not isinstance(value, str):
        return value
    stripped = value.strip()
    return None if (stripped.lower() in NULLISH) else stripped

def validate_boarding_pass(data: dict) -> list[str]:
    """對票面原樣抄寫結果做確定性驗證,回傳錯誤清單;空清單代表通過。"""
    errors = []

    tn = (clean(data.get("ticket_number_raw")) or "").replace("-", "")
    if re.fullmatch(r"\d{13}", tn) and ((int(tn[:12]) % 7) != int(tn[12])):
        errors.append(f"票號檢查碼不符:{tn}")

    pnr = clean(data.get("pnr_raw")) or ""
    if pnr and (not PNR_RE.fullmatch(pnr)):
        errors.append(f"PNR 格式不符(應為 6 碼英數):{pnr}")

    name = clean(data.get("passenger_name_raw")) or ""
    if name and (name.startswith("/") or name.endswith("/")):
        errors.append(f"姓名斜線一側為空(應為 SURNAME/GIVENNAME):{name}")

    for idx, seg in enumerate(data.get("segments") or [], start=1):
        def val(key):
            return clean(seg.get(key))

        seq = val("seq_raw")
        if seq and tn and (seq.lstrip("0") == tn.lstrip("0")):
            errors.append(f"航段 {idx} 票號與 SEQ 序號相同({seq}),疑似誤填。")

        flight = val("flight_raw")
        if flight and (not FLIGHT_RE.fullmatch(flight.upper())):
            errors.append(f"航段 {idx} 班號格式不符:{flight}")

        for key in ("departure_time_raw", "boarding_time_raw", "arrival_time_raw"):
            t = val(key)
            if t and (not TIME_RE.fullmatch(t)):
                errors.append(f"航段 {idx}{key} 非票面時間格式:{t}")

        date = val("date_raw")
        if date and (not DATE_RE.fullmatch(date.upper())):
            errors.append(f"航段 {idx} 的 date_raw 非票面日期格式:{date}")

        dep_t, brd_t = val("departure_time_raw"), val("boarding_time_raw")
        if dep_t and brd_t and (dep_t == brd_t):
            errors.append(f"航段 {idx} 登機時間與起飛時間相同({dep_t}),疑似同值誤填。")

        arr_t = val("arrival_time_raw")
        if arr_t:
            errors.append(f"航段 {idx} 出現抵達時間({arr_t});"
                          f"登機證通常不印,疑似把 ETD/登機時間誤填。")

        frm, to = val("from_raw"), val("to_raw")
        if frm and to and (frm.upper() == to.upper()):
            errors.append(f"航段 {idx} 起點與終點相同({frm}),至少一邊抄錯。")

    return errors

總結

從發票到登機證,可以看到 LLM 最大的優勢,就是不再完全依賴固定版面與座標規則。

透過 LMDeploy + LLM,模型可以負責理解圖片與擷取欄位,而較複雜的資料轉換與格式驗證則交由 Python 處理。

尤其到了登機證這類資訊較密集的文件,與其要求模型自行推算,不如讓模型忠實讀取票面內容,再由程式負責後續檢查。如此一來,不但能降低模型猜測錯誤,也能讓整套文件辨識流程更容易維護與擴充。

如果您正尋求高精度、高安全性的企業級文檔處理解決方案,歡迎了解並測試我們的 ezAcquire AI+OCR 系統。 另外,我們也供AI+OCR雲端辨識服務 https://aiocr.io