在文件辨識的應用中,傳統 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

