Files
Docker/idcard-api/app/parser.py
T
2026-10-18 07:40:42 +08:00

186 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""身份证字段解析器。
输入 RapidOCR 的识别结果(文本框列表),输出结构化身份证信息。
设计原则:不依赖固定像素坐标,只用「关键词锚点 + 身份证号校验位」,
对扫描件、手机拍照、轻微倾斜都能工作。
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Sequence
# ISO 7064 MOD 11-2 校验位权重
_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
_CHECK = "10X98765432"
_ID_RE = re.compile(r"(?<![0-9])([0-9]{17}[0-9Xx])(?![0-9])")
_BIRTH_RE = re.compile(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日")
_VALID_RE = re.compile(
r"(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})\s*[-—~至]\s*(\d{4})[.\-/](\d{1,2})[.\-/](\d{1,2})"
)
# 关键词锚点(允许 OCR 把字读漏或在中间插入空格)
_ANCHORS = {
"name": r"姓\s*名",
"gender": r"性\s*别",
"nation": r"民\s*族",
"birth": r"出\s*生",
"address": r"住\s*址",
"id_number": r"公\s*民\s*身\s*份\s*号\s*码",
"issuer": r"签\s*发\s*机\s*关",
"validity": r"有\s*效\s*期\s*限",
}
# 正面/反面判定词
_FRONT_MARKERS = ("公民身份号码", "住址", "民族")
_BACK_MARKERS = ("签发机关", "有效期限")
def verify_id_number(number: str) -> bool:
"""校验 18 位身份证号(含最后一位校验位)。"""
number = (number or "").strip().upper()
if not re.fullmatch(r"\d{17}[\dX]", number):
return False
total = sum(int(number[i]) * _WEIGHTS[i] for i in range(17))
return _CHECK[total % 11] == number[17]
def _lines_from_result(result: Optional[Sequence]) -> List[Dict[str, Any]]:
"""把 RapidOCR 结果整理成按阅读顺序排列的行。
result: [[box, text, score], ...] 或 None
返回: [{"text": str, "score": float, "box": [...]}, ...],按先上下后左右排序。
"""
if not result:
return []
items = []
for entry in result:
try:
box, text, score = entry
except (ValueError, TypeError):
continue
text = str(text).strip()
if not text:
continue
try:
cy = sum(float(p[1]) for p in box) / len(box)
cx = sum(float(p[0]) for p in box) / len(box)
except (TypeError, ZeroDivisionError):
cy, cx = 0.0, 0.0
items.append({"text": text, "score": float(score), "cy": cy, "cx": cx})
items.sort(key=lambda it: (round(it["cy"] / 12.0), it["cx"]))
return items
def _cut(text: str, start_pat: str, end_pats: Sequence[str]) -> Optional[str]:
"""从 start 关键词之后切到最近的 end 关键词之前。"""
m = re.search(start_pat, text)
if not m:
return None
rest = text[m.end():]
end = len(rest)
for pat in end_pats:
m2 = re.search(pat, rest)
if m2:
end = min(end, m2.start())
value = rest[:end].strip()
return value.strip(" ::·,,.。;;\t ") or None
def _clean_name(value: Optional[str]) -> Optional[str]:
if not value:
return None
value = re.sub(r"[^\u4e00-\u9fa5·]", "", value)
return value[:30] or None
def _clean_short(value: Optional[str], keep: int) -> Optional[str]:
if not value:
return None
m = re.search(r"[\u4e00-\u9fa5]{1,%d}" % keep, value)
return m.group(0) if m else None
def _clean_birth(value: Optional[str]) -> Optional[str]:
if not value:
return None
m = _BIRTH_RE.search(value)
if not m:
return None
y, mo, d = m.groups()
return "%s%02d%02d" % (y, int(mo), int(d))
def _clean_address(value: Optional[str]) -> Optional[str]:
if not value:
return None
value = re.sub(r"\s+", "", value)
return value[:120] or None
def _find_id_number(text: str) -> Optional[str]:
"""全文找身份证号;若多个候选,取通过校验位的那个。"""
candidates = _ID_RE.findall(text)
for c in candidates:
if verify_id_number(c):
return c.upper()
return candidates[0].upper() if candidates else None
def parse(lines: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
"""主入口:OCR 行 -> 结构化身份证信息。"""
full = "".join(item["text"] for item in lines)
number = _find_id_number(full)
gender_raw = _cut(full, _ANCHORS["gender"], (_ANCHORS["nation"], _ANCHORS["birth"], _ANCHORS["address"]))
nation = _clean_short(_cut(full, _ANCHORS["nation"], (_ANCHORS["birth"], _ANCHORS["address"])), 8)
issuer = _cut(full, _ANCHORS["issuer"], (_ANCHORS["validity"],))
validity_raw = _cut(full, _ANCHORS["validity"], ()) or ""
vm = _VALID_RE.search(validity_raw)
validity = "%s.%s.%s-%s.%s.%s" % (vm.groups()) if vm else (validity_raw.strip()[:40] or None)
is_front = any(m in full for m in _FRONT_MARKERS)
is_back = any(m in full for m in _BACK_MARKERS)
side = "front" if is_front and not is_back else ("back" if is_back else "unknown")
data = {
"side": side,
"name": _clean_name(_cut(full, _ANCHORS["name"], (_ANCHORS["gender"], _ANCHORS["nation"], _ANCHORS["birth"], _ANCHORS["address"]))),
"gender": _clean_short(gender_raw, 2),
"nation": nation,
"birth": _clean_birth(_cut(full, _ANCHORS["birth"], (_ANCHORS["address"], _ANCHORS["id_number"]))),
"address": _clean_address(_cut(full, _ANCHORS["address"], (_ANCHORS["id_number"],))),
"id_number": number,
"id_number_valid": verify_id_number(number) if number else None,
"issuer": issuer,
"validity": validity,
}
# 只统计 8 个业务字段,side/valid/confidence/raw_text 不计入
business_fields = (
"name", "gender", "nation", "birth", "address",
"id_number", "issuer", "validity",
)
filled = sum(1 for k in business_fields if data.get(k) not in (None, ""))
data["field_count"] = filled
data["raw_text"] = " | ".join(item["text"] for item in lines)
scores = [item["score"] for item in lines if item.get("score") is not None]
data["confidence"] = round(sum(scores) / len(scores), 4) if scores else None
return data
def mask(data: Dict[str, Any]) -> Dict[str, Any]:
"""敏感字段脱敏,用于日志或对外返回。"""
out = dict(data)
if out.get("id_number"):
n = out["id_number"]
out["id_number"] = n[:6] + "*" * 8 + n[-4:]
if out.get("address"):
out["address"] = out["address"][:6] + "****"
if out.get("name"):
n = out["name"]
out["name"] = n[0] + "*" * (len(n) - 1)
out.pop("raw_text", None)
return out