"""身份证识别 API 服务(极限低配版:1 核 CPU / 2G 内存)。 POST /api/v1/idcard/recognize 上传图片(multipart) POST /api/v1/idcard/recognize/base64 base64 JSON POST /api/v1/auth/token 用 client_secret 换短期 token(可选) GET /api/v1/idcard/health 健康检查(含内存读数) GET / 服务信息 鉴权三层(全部标准库实现,无额外依赖): 1. 静态 token:API_TOKENS=名称:token,名称2:token2,每个调用方一把,可单独吊销 2. 请求头:Authorization: Bearer (标准),兼容 X-API-Key 3. 短期 token:设 CLIENT_SECRET 后由 /api/v1/auth/token 签发,HMAC-SHA256 签名带过期 低配适配策略见 README,核心五点: 1. 单 worker + 引擎全局单例,绝不加载多份模型 2. 线程三重钉死:OMP/OPENBLAS/MKL=1 + cv2.setNumThreads(0) —— Docker 的 cpus 限额不改变容器内 os.cpu_count(),onnxruntime 会按 宿主机核数建线程池,1 核机器上直接线程风暴,必须显式限死 3. 关闭方向分类器(身份证基本正向) 4. 输入图最长边 1200px + det_limit_side_len=960 5. content-length 预检 + 惰性尺寸检查,防大图/解压炸弹打爆内存 """ from __future__ import annotations import base64 import binascii import hashlib import hmac import io import json import logging import os import time from typing import Optional # —— 线程限制必须早于 numpy / onnxruntime import 才会生效 —— # setdefault:不覆盖用户显式设置,未设置时兜底为 1 for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"): os.environ.setdefault(_v, "1") import numpy as np from fastapi import FastAPI, File, Header, HTTPException, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from PIL import Image from pydantic import BaseModel, Field try: # OpenCV 自建线程池,1 核机器上是纯负担 import cv2 cv2.setNumThreads(0) except Exception: # pragma: no cover - headless 缺失时不影响主流程 cv2 = None from . import parser as id_parser logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("idcard-api") MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_BYTES", 5 * 1024 * 1024)) MAX_SIDE = int(os.environ.get("MAX_SIDE", 1200)) # 输入图最长边,超过则等比缩小 MAX_DIMENSION = int(os.environ.get("MAX_DIMENSION", 6000)) # header 尺寸硬上限,超过直接拒 DET_LIMIT_SIDE_LEN = int(os.environ.get("DET_LIMIT_SIDE_LEN", 960)) REC_BATCH_NUM = int(os.environ.get("REC_BATCH_NUM", 1)) # 低内存必须为 1 MODEL_DIR = os.environ.get("RAPIDOCR_MODEL_DIR") or None # 允许跨域的前端来源,逗号分隔;默认 * 方便本地前端开发。 # 生产环境建议收紧为实际地址,例如 http://10.1.1.163:5173,http://nas.local CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "*").strip() # —— 鉴权配置 —— # 1) 静态 token(主力用法):每个调用方发一把,可单独吊销。 # 格式:名称:token,名称2:token2 例如 frontend:abc123,win-service:def456 API_TOKENS_RAW = os.environ.get("API_TOKENS", "").strip() # 2) 兼容旧配置:单个 X-API-Key API_KEY = os.environ.get("API_KEY", "").strip() # 3) 可选短期 token:设了 CLIENT_SECRET 后启用 /api/v1/auth/token 换 token CLIENT_ID = os.environ.get("CLIENT_ID", "default").strip() CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "").strip() TOKEN_TTL = int(os.environ.get("TOKEN_TTL", 3600)) # 短期 token 有效期(秒) def _parse_tokens() -> dict: """解析 API_TOKENS 为 {token: 调用方名称}。token 里不要含逗号/冒号。""" tokens = {} for item in API_TOKENS_RAW.split(","): item = item.strip() if not item: continue if ":" in item: name, tok = item.split(":", 1) if tok.strip(): tokens[tok.strip()] = name.strip() or "client" else: tokens[item] = "client" if API_KEY: tokens[API_KEY] = "legacy-key" return tokens _TOKEN_MAP = _parse_tokens() # 弱口令提醒:示例占位符(REPLACE_ME_*)或过短的 token 不该出现在正式环境。 # 只告警不阻止启动,避免影响联调;换成真随机串后这条日志自动消失。 _weak_clients = sorted( {name for tok, name in _TOKEN_MAP.items() if "REPLACE_ME" in tok.upper() or len(tok) < 12} ) if _weak_clients: log.warning( "token 过弱或仍是示例占位符,请尽快更换:%s(生成:openssl rand -hex 24)", ", ".join(_weak_clients), ) _engine = None def get_engine(): """懒加载 OCR 引擎(单例)。 rapidocr-onnxruntime 不同版本的构造参数略有差异,这里逐级降级尝试, 避免因某个参数不存在直接启动失败。生产环境请锁定 requirements.txt 版本。 """ global _engine if _engine is None: from rapidocr_onnxruntime import RapidOCR log.info("loading RapidOCR engine (model_dir=%s)", MODEL_DIR) base = {"model_dir": MODEL_DIR} if MODEL_DIR else {} for kwargs in ( { **base, "rec_batch_num": REC_BATCH_NUM, "det_limit_side_len": DET_LIMIT_SIDE_LEN, "use_angle_cls": False, }, {**base, "rec_batch_num": REC_BATCH_NUM, "use_angle_cls": False}, {**base, "rec_batch_num": REC_BATCH_NUM}, base, ): try: _engine = RapidOCR(**kwargs) log.info("engine ready with kwargs=%s", sorted(kwargs)) break except TypeError as ex: log.warning("RapidOCR rejected %s: %s", sorted(kwargs), ex) if _engine is None: raise RuntimeError("cannot initialize RapidOCR engine") return _engine app = FastAPI( title="IDCard Recognition API", version="2.0.0", description="离线身份证信息识别服务,返回结构化 JSON。极限低配硬件友好(1C/2G)。", ) # CORS:浏览器里的前端页面跨源调用时必须开启,否则预检(OPTIONS)直接失败。 # 后端到后端调用(curl / requests / 其他容器)不受 CORS 影响,开不开都能用。 # 鉴权走 X-API-Key 请求头,不用 cookie,所以 allow_credentials 保持 False # (allow_origins=["*"] 与 credentials=True 是非法组合,浏览器会拒绝)。 _cors_origins = [o.strip() for o in CORS_ORIGINS.split(",") if o.strip()] or ["*"] app.add_middleware( CORSMiddleware, allow_origins=_cors_origins, allow_credentials=False, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], expose_headers=["*"], max_age=600, ) if "*" in _cors_origins and not _TOKEN_MAP and not CLIENT_SECRET: log.warning( "CORS 允许全部来源且未配置任何 token:局域网内任意网页都能读取识别结果。" " 生产环境请设置 API_TOKENS 并收紧 CORS_ORIGINS。" ) @app.middleware("http") async def _limit_payload(request: Request, call_next): """第一道防线:按 content-length 预检,别让超大请求先读进内存。""" cl = request.headers.get("content-length") if cl: try: if int(cl) > MAX_IMAGE_BYTES: return JSONResponse( status_code=413, content={"detail": "payload too large, max %d bytes" % MAX_IMAGE_BYTES}, ) except ValueError: pass return await call_next(request) class Base64Body(BaseModel): image_base64: str = Field(..., description="图片 base64,可带 data:image/... 前缀") side: str = Field("auto", description="auto / front / back") mask: bool = Field(False, description="是否脱敏返回") class TokenRequest(BaseModel): client_id: str = Field("default", description="调用方标识") client_secret: str = Field(..., description="客户端密钥(与 CLIENT_SECRET 一致)") def _b64u(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() def _b64u_decode(text: str) -> bytes: return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) def _sign(payload: str) -> str: """HMAC-SHA256 签名(标准库,无额外依赖)。""" digest = hmac.new(CLIENT_SECRET.encode(), payload.encode(), hashlib.sha256).digest() return _b64u(digest) def issue_token(subject: str) -> dict: """签发短期 token:base64(payload).hmac —— 结构类似 JWT,但不依赖 PyJWT。""" payload = _b64u( json.dumps( {"sub": subject, "iat": int(time.time()), "exp": int(time.time()) + TOKEN_TTL}, separators=(",", ":"), ).encode() ) return {"token": payload + "." + _sign(payload), "expires_in": TOKEN_TTL, "token_type": "Bearer"} def _verify_short_token(token: str) -> Optional[str]: """校验短期 token 的签名和有效期,成功返回 subject。""" if not CLIENT_SECRET or "." not in token: return None payload, sig = token.rsplit(".", 1) if not hmac.compare_digest(sig, _sign(payload)): # 常数时间比较,防时序侧信道 return None try: data = json.loads(_b64u_decode(payload)) except Exception: return None # 到达 exp 时刻即失效(用 >=,避免整数截断让 token 多活最多 1 秒) if int(time.time()) >= int(data.get("exp", 0)): return None return str(data.get("sub") or "token") def _authenticate(authorization: Optional[str], x_api_key: Optional[str]) -> str: """统一鉴权,返回调用方名称用于日志。 支持两种请求头(同时兼容): Authorization: Bearer ← 标准写法,推荐 X-API-Key: ← 旧写法,保留兼容 token 可以是 API_TOKENS 里的静态 token,也可以是 /auth/token 签发的短期 token。 完全没配置鉴权时放行(返回 anonymous),方便本地调试。 """ if not _TOKEN_MAP and not CLIENT_SECRET: return "anonymous" token = "" if authorization and authorization.lower().startswith("bearer "): token = authorization[7:].strip() elif x_api_key: token = x_api_key.strip() if not token: raise HTTPException( status_code=401, detail="missing token", headers={"WWW-Authenticate": "Bearer"}, ) if token in _TOKEN_MAP: return _TOKEN_MAP[token] subject = _verify_short_token(token) if subject: return subject raise HTTPException( status_code=401, detail="invalid or expired token", headers={"WWW-Authenticate": "Bearer"}, ) def _load_image(data: bytes) -> Image.Image: """读图 + 尺寸收敛。低配关键:图越大,解码缓冲和 det 张量越吃内存。""" if len(data) > MAX_IMAGE_BYTES: raise HTTPException(status_code=413, detail="image too large, max %d bytes" % MAX_IMAGE_BYTES) try: img = Image.open(io.BytesIO(data)) # 惰性:只读 header,不解码 except Exception: raise HTTPException(status_code=400, detail="invalid image file") # 解压炸弹防护:解码前按 header 尺寸拦截。 # 6000px 是硬上限(解码后约 108MB 缓冲,可接受);手机常见 3000-4000px 拍照 # 必须放行、交给下面的 thumbnail 缩小,不能用 MAX_SIDE 当阈值。 if max(img.size) > MAX_DIMENSION: raise HTTPException( status_code=413, detail="image dimension too large, max %d px" % MAX_DIMENSION ) try: img.load() except Exception: raise HTTPException(status_code=400, detail="invalid image file") if img.mode != "RGB": img = img.convert("RGB") if max(img.size) > MAX_SIDE: img.thumbnail((MAX_SIDE, MAX_SIDE), Image.LANCZOS) return img def _run_ocr(img: Image.Image) -> dict: engine = get_engine() started = time.perf_counter() result, _elapse = engine(np.array(img)) lines = id_parser._lines_from_result(result) data = id_parser.parse(lines) data["elapsed_ms"] = round((time.perf_counter() - started) * 1000, 1) if not lines: raise HTTPException(status_code=422, detail="no text detected in image") if data["field_count"] == 0: raise HTTPException(status_code=422, detail="no id card fields detected") return data def _mem_mb() -> Optional[int]: """容器/进程内存读数,方便 docker stats 之外直接看接口。""" for path in ( "/sys/fs/cgroup/memory.current", # cgroup v2 "/sys/fs/cgroup/memory/memory.usage_in_bytes", # cgroup v1 ): try: with open(path) as f: return int(f.read().strip()) // (1024 * 1024) except (OSError, ValueError): continue try: with open("/proc/self/status") as f: for line in f: if line.startswith("VmRSS:"): return int(line.split()[1]) // 1024 except OSError: pass return None @app.get("/") def root(): """根路径给个可读的入口说明,避免直接访问域名时看到 404。""" return { "service": "idcard-api", "version": app.version, "status": "running", "docs": "/docs", "endpoints": { "health": "GET /api/v1/idcard/health", "recognize": "POST /api/v1/idcard/recognize (multipart, 字段名 file)", "recognize_base64": "POST /api/v1/idcard/recognize/base64 (JSON)", "token": "POST /api/v1/auth/token (需配置 CLIENT_SECRET)", }, "auth": { "enabled": bool(_TOKEN_MAP) or bool(CLIENT_SECRET), "scheme": "Authorization: Bearer (兼容 X-API-Key 头)", "short_lived_token": bool(CLIENT_SECRET), }, } @app.get("/api/v1/idcard/health") def health(): return { "status": "ok", "engine_loaded": _engine is not None, "mem_mb": _mem_mb(), "auth_enabled": bool(_TOKEN_MAP) or bool(CLIENT_SECRET), } @app.post("/api/v1/auth/token") def create_token(body: TokenRequest): """用 client_id + client_secret 换一个短期 token(默认 1 小时)。 适合前端场景:不在页面里硬编码长期密钥,只持有短时 token。 """ if not CLIENT_SECRET: raise HTTPException(status_code=400, detail="token endpoint disabled, CLIENT_SECRET not set") if not hmac.compare_digest(body.client_secret, CLIENT_SECRET): raise HTTPException(status_code=401, detail="invalid client credentials") payload = issue_token(body.client_id or CLIENT_ID) log.info("token issued for client=%s", body.client_id or CLIENT_ID) return payload @app.post("/api/v1/idcard/recognize") async def recognize( file: UploadFile = File(..., description="身份证图片 jpg/png"), side: str = "auto", mask: bool = False, authorization: Optional[str] = Header(None), x_api_key: Optional[str] = Header(None), ): caller = _authenticate(authorization, x_api_key) if file.content_type and not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="content type must be image/*") data = _run_ocr(_load_image(await file.read())) if side in ("front", "back"): data["side"] = side # 只记调用方和耗时,不记姓名/身份证号(合规要求) log.info("recognize by=%s side=%s %sms", caller, data["side"], data["elapsed_ms"]) return _respond(data, mask) @app.post("/api/v1/idcard/recognize/base64") def recognize_base64( body: Base64Body, authorization: Optional[str] = Header(None), x_api_key: Optional[str] = Header(None), ): caller = _authenticate(authorization, x_api_key) raw = body.image_base64 if "," in raw and raw.strip().startswith("data:"): raw = raw.split(",", 1)[1] try: payload = base64.b64decode(raw, validate=True) except (binascii.Error, ValueError): raise HTTPException(status_code=400, detail="invalid base64 payload") out = _run_ocr(_load_image(payload)) if body.side in ("front", "back"): out["side"] = body.side log.info("recognize_b64 by=%s side=%s %sms", caller, out["side"], out["elapsed_ms"]) return _respond(out, body.mask) def _respond(data: dict, mask: bool) -> JSONResponse: return JSONResponse({"code": 0, "data": id_parser.mask(data) if mask else data})