回頭看 D2 的資料流:
[2] 格式解析 / OCR → [3] PII 偵測 → [4] 去識別化 → [5] 送出至 LLM
邊界畫在 [4] 和 [5] 之間。這張圖隱含一個假設:[2] 是在行內做的。
但如果 OCR 用的是雲端服務呢?那實際的資料流變成:
[2] OCR(雲端) ← 原始圖檔在這裡就出行了
↓
[3] 偵測(行內)
↓
[4] 去識別化(行內)
↓
[5] 送出至 LLM
你的整條去識別化管線,保護的是「已經出過行一次」的資料。
而且送出去的是原始圖檔——一張身分證的照片,比任何文字都更完整地暴露個資(照片、簽名、發證日期、戶籍地)。
這個破口在架構圖上完全看不出來,因為 OCR 通常被畫成一個不起眼的前處理方塊。
選項一:地端 OCR。
Tesseract、PaddleOCR 都可以本機跑。優點是資料不出行,缺點是中文手寫、印章遮蓋、歪斜掃描的表現明顯不如商用服務。
import pytesseract
from PIL import Image
text = pytesseract.image_to_string(
Image.open(path),
lang="chi_tra+eng",
config="--psm 6",
)
法院文件多半是印刷體,Tesseract 的表現可以接受。客戶手寫的陳情書就很吃力。
選項二:雲端 OCR,但先做圖像層去識別化。
先在地端偵測圖片上的敏感區域、遮蓋,再送雲端 OCR。問題是:要知道哪裡要遮,你得先看懂圖片上寫什麼——這是雞生蛋問題。
實務上只能做粗略處理:對已知版式的證件(身分證、健保卡),用固定座標遮蓋。
# 針對已知版式的證件,用相對座標遮蓋
ID_CARD_REGIONS = {
"id_number": (0.55, 0.28, 0.95, 0.38), # 相對座標 (x1,y1,x2,y2)
"birth_date": (0.55, 0.40, 0.95, 0.50),
"photo": (0.05, 0.25, 0.30, 0.75),
}
def mask_known_layout(img, layout):
w, h = img.size
draw = ImageDraw.Draw(img)
for x1, y1, x2, y2 in layout.values():
draw.rectangle([x1*w, y1*h, x2*w, y2*h], fill="black")
return img
但這只對版式固定、拍攝角度正的圖片有效。歪斜或裁切過的照片就失準了,而且失準的方式是遮錯位置——個資還在,但你以為遮過了。這比不遮更危險。
選項三:雲端 OCR,但把它納入「行內邊界」。
用 VPC Service Controls 把 Document AI 圈進資料邊界,用 CMEK 控制金鑰,用區域端點確保資料留在指定地區。技術上資料仍然離開了你的機房,但它沒有離開你控制的邊界。
gcloud access-context-manager perimeters update pii_perimeter \
--add-restricted-services=documentai.googleapis.com \
--policy=POLICY_ID
這是不是可接受,是一個治理決策而不是技術決策。 它取決於機構怎麼定義「資料不出行」——是物理邊界還是邏輯邊界。這件事必須在架構定案前跟法遵、稽核講清楚,不要留到上線後才發現雙方認知不同。
跟 D13 的結論一致:
| 文件類型 | OCR 路徑 |
|---|---|
| 證件影本(身分證、健保卡、護照) | 地端,絕不上雲 |
| 含特種個資的文件 | 地端 |
| 法院文書、一般契約 | 雲端(邊界內) |
| 公開資料、範本 | 雲端 |
證件影本那一列沒有商量餘地。一張身分證照片的個資密度太高,而且它的版式固定、印刷體清晰,地端 OCR 完全夠用——沒有理由送出去。
OCR 的輸出跟原生文字不一樣,偵測規則要跟著調整。
問題一:辨識錯字。
A123456789 → Al23456789 (1 誤認成 l)
0912345678 → O912345678 (0 誤認成 O)
正確答案的個資,OCR 完之後不再符合 regex。這是 D8 說的 Noisy 子集要涵蓋的情境。
處理方式是加入常見混淆對的容錯:
CONFUSION = {"l": "1", "I": "1", "O": "0", "o": "0", "S": "5", "B": "8"}
def normalize_ocr(text: str) -> str:
"""僅在數字上下文中套用,避免破壞正常文字"""
def fix(m):
return "".join(CONFUSION.get(c, c) for c in m.group())
# 只處理「看起來像號碼」的片段
return re.sub(r"[A-Za-z0-9lIOoSB]{8,12}", fix, text)
要小心這個轉換會製造誤判。它應該用於產生額外的候選,而不是取代原文——原文和轉換後版本都跑一次偵測,取聯集。
問題二:版面順序。
OCR 的輸出順序不一定是閱讀順序。表格會被拆成一行行,欄位標籤跟值可能分開。這會破壞 D7 提到的 hotword 機制——「帳號」和號碼可能相隔很遠。
處理方式是用帶版面資訊的 OCR(Document AI 的 Form Parser 會回傳欄位配對),而不是純文字 OCR。
問題三:置信度。
OCR 會回傳每個字的置信度。低置信度的區域應該標記為「需人工複核」,而不是當成正常文字處理。
def flag_low_confidence(ocr_result, threshold=0.7):
suspect_regions = []
for token in ocr_result.tokens:
if token.confidence < threshold:
suspect_regions.append({
"text": token.text,
"bbox": token.bounding_box,
"confidence": token.confidence,
})
return suspect_regions
因為低置信度區域正是「可能有個資但沒被正確辨識」的地方。
偵測到個資之後,如果要輸出去識別化的圖檔(而不只是文字),需要用 bounding box 在圖上遮蓋。
def redact_image(img_path, findings, out_path):
img = Image.open(img_path).convert("RGB")
draw = ImageDraw.Draw(img)
for f in findings:
bbox = f["bounding_box"]
# 外擴幾個像素,避免邊緣殘留
draw.rectangle(
[bbox.x1 - 3, bbox.y1 - 3, bbox.x2 + 3, bbox.y2 + 3],
fill="black",
)
# 重新編碼,不要保留原圖層
img.save(out_path, "PNG")
兩個關鍵:
外擴幾個像素。 OCR 的 bounding box 有時候會切邊,剛好留下最後一個數字的一角。外擴是廉價的保險。
重新編碼、不要用「疊加圖層」的方式遮蓋。 在 PDF 上畫一個黑色矩形,原始文字仍然在文字層裡,複製貼上就能拿到。這是媒體上出過好幾次的經典事故。正確做法是點陣化後重新輸出,或者用真正的 redaction API(PyMuPDF 的 add_redact_annot + apply_redactions)。
# PyMuPDF 的真正 redaction:會實際移除底層內容
page.add_redact_annot(rect, fill=(0, 0, 0))
page.apply_redactions()
最後別忘了 EXIF。手機拍的照片帶 GPS 座標、拍攝時間、裝置識別碼。這些全部是個資,而且完全不在圖像內容裡。
img = Image.open(path)
data = list(img.getdata())
clean = Image.new(img.mode, img.size)
clean.putdata(data) # 只複製像素,不帶 metadata
clean.save(out_path)
明天談 Token 一致性:同一個人在不同頁、不同文件要拿到同一個 Token。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。