diff --git a/idcard-api/.env.example b/idcard-api/.env.example new file mode 100644 index 0000000..8e32a0f --- /dev/null +++ b/idcard-api/.env.example @@ -0,0 +1,42 @@ +# 复制为 .env 后按需修改:cp .env.example .env +# .env 会被 docker compose 自动读取,不要提交到公开仓库 + +# ============ 鉴权(三选一,推荐第 1 种) ============ +# 生成 token:openssl rand -hex 24 或 python -c "import secrets;print(secrets.token_urlsafe(32))" + +# 1) 多 token(推荐):名称:token,名称2:token2 +# 每个调用方发一把,吊销时只删对应的那一段,不影响别人。 +# 日志里会打印调用方名称,方便排查是谁在调。 +API_TOKENS=frontend:REPLACE_ME_FRONTEND,win-service:REPLACE_ME_WIN + +# 2) 旧版单密钥(兼容保留,不推荐):所有调用方共用一把 +# API_KEY= + +# 3) 短期 token(可选):设置后启用 POST /api/v1/auth/token +# 调用方用 client_secret 换有效期 TOKEN_TTL 秒的签名 token, +# 适合前端——页面里不硬编码长期密钥,只持有短时 token。 +# CLIENT_ID=default +# CLIENT_SECRET= +# TOKEN_TTL=3600 + +# ============ 跨域 ============ +# 允许跨域的前端来源,逗号分隔。浏览器里的 Vue/React 页面调这个接口必须配。 +# * = 允许所有来源(方便开发,但任何网页都能读结果,生产环境务必收紧) +# 例:CORS_ORIGINS=http://10.1.1.163:5173,http://10.1.1.163:8080 +# 后端到后端调用(curl / requests / 其他容器)不受此项影响。 +CORS_ORIGINS=* + +# ============ 基础镜像源 ============ +# 默认国内公共源(fnOS 自带加速源需授权,常 401)。 +# 备选: +# BASE_IMAGE=docker.1ms.run/library/python:3.11-slim +# BASE_IMAGE=mirror.ccs.tencentyun.com/library/python:3.11-slim +# 离线导入基础镜像(docker load)后改用本地 tag: +# BASE_IMAGE=python:3.11-slim +# BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim + +# —— 以下一般不用改,低配吃紧时再动 —— +# MAX_SIDE=960 +# DET_LIMIT_SIDE_LEN=736 +# MAX_DIMENSION=6000 +# MAX_IMAGE_BYTES=5242880 diff --git a/idcard-api/Dockerfile b/idcard-api/Dockerfile new file mode 100644 index 0000000..36b06a9 --- /dev/null +++ b/idcard-api/Dockerfile @@ -0,0 +1,55 @@ +# 极限低配版:1 核 CPU / 2G 内存。镜像层尽量小,运行期只跑 1 个 worker。 +# 注意:不要加 "# syntax=docker/dockerfile:1" —— 那是 BuildKit 前端镜像指令, +# 构建时会去拉 docker/dockerfile:1,国内 NAS 的镜像加速源常不代理这个冷门镜像。 +# +# 基础镜像:直接写国内镜像仓库全路径,绕过 daemon 的 registry-mirrors 配置。 +# 原因:fnOS 的 Docker 应用会用自己的界面配置覆盖 /etc/docker/daemon.json, +# 加速源一旦失效(如 docker.fnnas.com 未授权返回 401)手工改配置不生效。 +# 海外环境或自建源可覆盖:--build-arg BASE_IMAGE=python:3.11-slim +# 备用源:docker.1ms.run/library/python:3.11-slim +# mirror.ccs.tencentyun.com/library/python:3.11-slim +ARG BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim +FROM ${BASE_IMAGE} + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HOME=/app \ + OMP_NUM_THREADS=1 \ + OPENBLAS_NUM_THREADS=1 \ + MKL_NUM_THREADS=1 \ + OMP_DISPLAY_AFFINITY=FALSE \ + OMP_PROC_BIND=FALSE \ + MAX_IMAGE_BYTES=5242880 \ + MAX_SIDE=1200 \ + MAX_DIMENSION=6000 \ + DET_LIMIT_SIDE_LEN=960 \ + REC_BATCH_NUM=1 \ + RAPIDOCR_MODEL_DIR=/app/models + +WORKDIR /app + +# 只装运行必需的系统库,装完立刻清缓存,压镜像体积 +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 libgl1 libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +COPY requirements.txt . +# pip 默认走清华源(国内 NAS 直连 pypi.org 慢且常断)。注意这与镜像加速源无关, +# 是独立的网络路径。需要时构建可覆盖:--build-arg PIP_INDEX_URL=https://pypi.org/simple +ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +# opencv-python 带 GUI 依赖(多 60MB+ 系统库),rapidocr 只用到 imread/imwrite, +# 换 headless 版对运行内存无影响,纯粹是给镜像瘦身 +RUN pip install --no-cache-dir -i $PIP_INDEX_URL -r requirements.txt \ + && pip uninstall -y opencv-python opencv-contrib-python >/dev/null 2>&1 || true \ + && pip install --no-cache-dir -i $PIP_INDEX_URL opencv-python-headless + +# 构建期预热:把 onnx 模型下进镜像 /app/models,运行时不再依赖外网。 +# 注意:如果 rapidocr_onnxruntime 忽略 model_dir 参数,模型会落到 $HOME/.rapidocr_model, +# 所以上面把 HOME 也指到 /app,两种路径都在 /app 下,卷挂载能一并覆盖。 +COPY app ./app +RUN python -c "from rapidocr_onnxruntime import RapidOCR; RapidOCR()" \ + && python -c "import app.main as m; m.get_engine()" + +EXPOSE 8000 +# 单 worker:每个 worker 都会独立加载一份模型,低配下不要加 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/idcard-api/README.md b/idcard-api/README.md new file mode 100644 index 0000000..524b8ac --- /dev/null +++ b/idcard-api/README.md @@ -0,0 +1,351 @@ +# 身份证识别 API(免费 · 离线 · Docker · 极限低配可用) + +上传身份证图片 → 返回结构化 JSON。OCR 用 **RapidOCR**(PaddleOCR 的 ONNX 版,Apache-2.0 可商用),服务用 **FastAPI**,全程**不需要 GPU、不需要联网、不需要任何云服务密钥**。 + +目标硬件:**1 核 CPU / 2G 内存配额**即可运行。速度高度依赖 CPU 单核性能:现代 x86 NAS(J4125 及以上)单张约 0.5–1.5s;**老旧低端机型(如 Celeron 1007U @1.5GHz)约 2–6s**,属于正常范围。 + +## 能力 + +- 正面:姓名、性别、民族、出生日期、住址、公民身份号码 +- 反面:签发机关、有效期限 +- 自动判正反面(也可用 `side=front/back` 强制指定) +- 身份证号 **ISO 7064 MOD 11-2 校验位验证**,识别错了会被标记 `id_number_valid=false` +- 返回每字段平均置信度与耗时 +- 可选脱敏返回(`mask=true`) + +## 1 核 2G 极限低配适配(重点) + +2G 内存的真正风险不是模型本身(det/cls/rec 三个 onnx 权重合计约 15MB),而是三件事: + +1. **onnxruntime 的线程风暴**——Docker 的 `cpus: 1.0` 限额**不会**改变容器内 `os.cpu_count()`,onnxruntime / OpenBLAS 仍会按宿主机核数建线程池。64 核宿主机上容器能起出几十个线程,1 核机器上直接 OOM 或被 cgroup 杀。必须显式钉死。 +2. **大图的解码缓冲 + det 中间张量**——内存随图片面积平方增长。 +3. **多 worker**——每个 worker 独立加载一份模型,直接翻倍。 + +本项目已做六层控制: + +| 层 | 手段 | 效果 | +|---|---|---| +| 进程 | `--workers 1`,引擎全局单例 | 不会加载多份模型;每多一个 worker 多 400–600MB | +| 线程 | `OMP/OPENBLAS/MKL_NUM_THREADS=1` + `cv2.setNumThreads(0)` | 三重钉死,杜绝线程风暴 | +| 模型 | 关闭方向分类器 `use_angle_cls=False` | 身份证基本正向,少一个模型、少一次推理 | +| 输入 | 图片最长边 960px + `det_limit_side_len=736`(低端机型下调过,通用默认 1200/960) | 解码缓冲和 det 张量双降 | +| 推理 | `rec_batch_num=1` | batch=6 在单核下只涨峰值内存,不省时间 | +| 容器 | `cpus: 1.0`、`mem_limit: 1024m` | 留 1G 给系统 / Docker 本身 / 其他服务 | +| 入口 | content-length 预检 + 5MB 上限 + 6000px 尺寸硬上限 | 三层拦超大图,防解压炸弹打爆内存 | + +**内存预算(估算,建议你本地 `docker stats` 实测校准)** + +| 阶段 | RSS | +|---|---| +| Python 3.11 + FastAPI + uvicorn | ~150–200 MB | +| numpy + opencv-headless + pillow | ~120–150 MB | +| onnxruntime 运行时 | ~80–100 MB | +| 三个 onnx 模型常驻 + 图优化副本 | +150–250 MB | +| 单张 960px 图推理峰值 | +80–150 MB | +| **稳态 / 峰值** | **约 500–650 MB / 800MB–1.0GB** | + +容器限 1024m 缓冲不多,**峰值贴着上限时先加 swap,再考虑降 `MAX_SIDE=736`**。低端机型(4G 单条)强烈建议提前加 2G swap,这是最便宜的保险: + +## 快速开始 + +```bash +cd idcard-api +docker compose up -d --build # 首次构建会预热模型,稍慢 +docker stats idcard-api # 看内存是否在预期区间 +curl http://127.0.0.1:8000/api/v1/idcard/health # 直接看 mem_mb 字段 +``` + +接口文档:`http://127.0.0.1:8000/docs` + +> 💡 根路径 `/` 返回服务信息与端点清单,不是 404;`/api/v1/idcard/health` 返回状态和内存。 +> 若 `/docs` 打开是白屏,说明页面依赖的外网 CDN(jsdelivr)被墙或超时——**不是服务故障**,直接用 curl / Postman / Apifox 调接口即可,请求格式见下方「调用」。 + +## 发布到飞牛 fnOS(Docker 应用) + +适合把服务放到 NAS 上、给局域网其他应用调用。**主推 SSH 路径**(多文件项目必须整体上传,Compose 界面只能贴单个 yaml,上下文文件传不上去)。 + +### 第 0 步:三项前置检查(SSH 进 fnOS 后执行) + +```bash +uname -m # 必须是 x86_64;若是 aarch64 见下方说明 +free -h # 看可用内存,关键!见下方警告 +ss -tlnp | grep 8000 # 确认 8000 端口没被别的容器占用 +``` + +> ⚠️ **内存警告**:`mem_limit: 1024m` 是给**容器**的配额,fnOS 系统本身要占 1G+。当前默认值已针对 **Celeron 1007U / 4GB 单条内存**这类低端机型调好;NAS 总内存 ≥8G 可调回 `1280m` 并把 `MAX_SIDE` 调回 `1200`。4G 机型务必加 2G swap 兜底。 + +> ⏱️ **速度预期(Celeron 1007U @1.5GHz)**:单张身份证约 **2–6 秒**。这块 ULV 单核比常见 NAS 的 J4125 弱 3 倍以上,`MAX_SIDE=960` 就是为速度做的妥协(文字仍有约 25px 高,清晰印刷体识别率影响很小)。慢是正常的,不是没调好。 + +### 第 1 步:把整个项目目录传到 NAS + +```bash +# fnOS 上建目录(存储池编号按实际改,/volume1 或 /vol1 都见过) +mkdir -p /volume1/docker/idcard-api +``` + +用 WinSCP / FileZilla 把本地的 `idcard-api` 目录**整个**拖上去(`app/`、`Dockerfile`、`docker-compose.yml`、`requirements.txt`、`client_example.py`、`.env.example`),保持目录结构。或者用 scp: + +```bash +scp -r idcard-api root@飞牛IP:/volume1/docker/ +``` + +### 第 2 步:配置 .env + +```bash +cd /volume1/docker/idcard-api +cp .env.example .env +vi .env # 配 API_TOKENS(每方一把 token),见「鉴权」一节 +``` + +### 第 3 步:构建并启动(最容易卡的是模型下载) + +```bash +docker compose config # 先检查 yaml 语法,没问题再往下 +docker compose up -d --build +docker compose logs -f # 看到 "engine ready" 且无 Traceback 即成功 +``` + +**构建卡住/失败的两种原因和对策**: + +1. **pip 慢**:Docker Hub 基础镜像可在 fnOS「设置 → Docker → 镜像仓库」配轩辕等加速源;pip 慢就在 Dockerfile 的 `pip install` 后加 `-i https://pypi.tuna.tsinghua.edu.cn/simple`。 +2. **onnx 模型下载失败**(构建到预热那步报错,国内访问 GitHub 不稳):在能上网的 Windows 上先执行 + ```powershell + pip install rapidocr-onnxruntime + python -c "from rapidocr_onnxruntime import RapidOCR; RapidOCR()" + ``` + 把生成模型目录(一般在 `C:\Users\<你>\.rapidocr_model`)传到 NAS `/volume1/docker/idcard-api/models`,compose 已挂载 `/app/models`,重跑 `docker compose up -d --build` 即可。 + +### 第 4 步:验证 + +```bash +curl http://127.0.0.1:8000/api/v1/idcard/health # 看 status / mem_mb +curl -X POST http://127.0.0.1:8000/api/v1/idcard/recognize \ + -F "file=@/volume1/docker/idcard-api/test.jpg" -F "side=auto" \ + -H "X-API-Key: 你的密钥" +docker stats idcard-api # 看内存峰值是否贴着 1280m +``` + +建议先拿一张真实身份证正反面各测一次,确认字段和 `id_number_valid=true` 再交给调用方。 + +### 第 5 步:接入调用方 + +- **同 NAS 上的其他 Docker 容器**:把调用方容器加入 `idcard-api_idcard-api` 网络(或在调用方 compose 里加 `external_links` / 同一自定义网络),地址写 `http://idcard-api:8000`;图省事直接用 `http://飞牛IP:8000`。 +- **局域网其他设备**:`http://飞牛IP:8000`,带 `X-API-Key` 头。 +- **要域名 + HTTPS**:用 fnOS 自带的反向代理/远程访问把域名指到 `127.0.0.1:8000`,不要在路由器上映射公网端口直连。 +- **ARM 机型(aarch64)**:python:3.11-slim 和 opencv-headless 有 ARM 版,但 onnxruntime 需 ≥1.19 才有完善的 ARM wheel,先在 NAS上 `docker compose build` 试;若 onnxruntime 装不上,只能换 x86 机型或用云侧部署。 + +## 调用 + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/idcard/recognize \ + -F "file=@/path/idcard.jpg" -F "side=auto" +``` + +base64 方式: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/idcard/recognize/base64 \ + -H "Content-Type: application/json" \ + -d '{"image_base64": "", "side": "auto", "mask": false}' +``` + +**返回示例** + +```json +{ + "code": 0, + "data": { + "side": "front", + "name": "张三", + "gender": "男", + "nation": "汉", + "birth": "19900101", + "address": "北京市朝阳区某某路1号", + "id_number": "110101199001011237", + "id_number_valid": true, + "issuer": null, + "validity": null, + "field_count": 6, + "confidence": 0.96, + "elapsed_ms": 980.5, + "raw_text": "姓名张三 | 性别男 | ..." + } +} +``` + +Python 示例见 `client_example.py`。 + +## 鉴权(Token 验证) + +支持三种方式,全部用**标准库实现**(HMAC-SHA256),不引入 PyJWT 等依赖,对 1C/2G 无额外开销。 + +### 方式一:多 token(推荐) + +`.env` 里按 `名称:token` 配置,多个用逗号分隔: + +```bash +API_TOKENS=frontend:abc123def456,win-service:xyz789uvw012 +``` + +好处是**每个调用方一把,能单独吊销**(删掉对应那段重启即可),日志里也会打出调用方名称,出问题能定位是谁在调。 + +调用时带标准 Bearer 头: + +```bash +curl -X POST http://10.1.1.163:8000/api/v1/idcard/recognize \ + -H "Authorization: Bearer xyz789uvw012" \ + -F "file=@idcard.jpg" +``` + +旧的 `X-API-Key` 头仍然有效(兼容不改代码的老调用方);`API_KEY` 单密钥配置也继续支持。 + +### 方式二:短期 token(前端场景推荐) + +设置 `CLIENT_SECRET` 后,多出一个换 token 的接口: + +```bash +# ① 用 client_secret 换 token(默认 1 小时有效) +curl -X POST http://10.1.1.163:8000/api/v1/auth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"frontend","client_secret":"你的密钥"}' + +# 返回 +{"token":"eyJzdWIiOiJmcm9udGVuZCIsImlhdCI6...9f2a","expires_in":3600,"token_type":"Bearer"} + +# ② 后续请求带这个 token +curl -X POST http://10.1.1.163:8000/api/v1/idcard/recognize \ + -H "Authorization: Bearer eyJzdWIi..." \ + -F "file=@idcard.jpg" +``` + +token 是 `base64(payload) + "." + HMAC签名` 的结构(类似 JWT 但没有第三段),服务端校验签名和 `exp` 过期时间。`TOKEN_TTL` 可调有效期。 + +适合前端的原因:页面里不硬编码长期密钥,泄露了也就一小时的窗口。 + +### 方式三:不配置(本地调试) + +`API_TOKENS`、`API_KEY`、`CLIENT_SECRET` 全为空时,接口完全开放。**只适合本机调试**——`/api/v1/idcard/health` 会返回 `auth_enabled: false`,启动日志也会警告。 + +### 几个实现细节 + +- **`/api/v1/idcard/health` 不鉴权**,方便探活和监控;它不返回任何身份证数据。 +- **401 响应带 `WWW-Authenticate: Bearer` 头**,符合 HTTP 标准,前端库能自动识别。 +- **签名比较用 `hmac.compare_digest`**(常数时间),避免时序侧信道攻击。 +- **`/docs` 不做鉴权**,方便调试;要锁起来就在 Nginx 反代层加 basic auth。 +- **日志只记调用方名称和耗时,不记姓名/身份证号**。 +- 静态 token **以明文存在容器环境变量里**,能读 `docker inspect` 的人就能看到。安全要求更高的话,用短期 token 模式 + 只把 `CLIENT_SECRET` 挂在服务端,客户端只拿短时 token。 + +## 跨域(CORS) + +**先分清调用方**: + +| 调用方 | 有跨域限制吗 | 需要做什么 | +|---|---|---| +| 后端服务(curl、Python requests、其他 Docker 容器、Java/Go 等) | ❌ 没有 | 什么都不用配 | +| 浏览器前端(Vue / React / 原生 JS 页面) | ✅ 有 | 服务端开 CORS + 请求头带 `Authorization: Bearer ` | + +CORS 是**浏览器的同源策略**,只在浏览器环境生效。所以「别的应用调用」如果是后端服务,完全不受影响;如果是网页前端,才会撞到。 + +服务已内置 `CORSMiddleware`,由环境变量 `CORS_ORIGINS` 控制: + +```bash +# .env 里配置,逗号分隔;* 表示允许所有来源(开发方便) +CORS_ORIGINS=http://10.1.1.163:5173,http://10.1.1.163:8080 +``` + +几个要点: + +1. **预检请求**:前端带 `X-API-Key` 自定义头会触发 `OPTIONS` 预检,中间件已自动处理(返回 204 + 允许头),无需额外路由。 +2. **鉴权头必须在 `allow_headers` 里**:已设 `allow_headers=["*"]`,`X-API-Key` 可通过。 +3. **`allow_credentials=False` 是刻意的**:本项目用请求头鉴权、不用 Cookie,而 `allow_origins=["*"]` 与 credentials=True 是非法组合,浏览器会直接拒绝。 +4. **安全提醒**:身份证是敏感数据。`CORS_ORIGINS=*` 且未配置任何 token 时,局域网内任何一个网页的 JS 都能悄悄调用你的接口读数据。**生产必须二选一**:配 `API_TOKENS`,或把 `CORS_ORIGINS` 收紧到具体前端地址。 +5. **生产更推荐同源**:用 Nginx / fnOS 反向代理把前端和 API 放在同一域名下(`/api` 转发到 8000),浏览器视为同源,连 CORS 都不需要,也少一层预检开销。 + +## 如果内存还是吃紧(降级路径) + +按代价从低到高: + +1. `MAX_SIDE=960` + `DET_LIMIT_SIDE_LEN=736`:身份证文字在 960px 下仍有 ~25px 高,识别基本无损,通常再省 100–150MB +2. 换 PP-OCR **mobile** 版识别模型(`ch_PP-OCRv4_mobile_rec`),体积和内存都小于 server 版,身份证这种清晰印刷体精度损失很小 +3. 加 swap(2–4G)作为兜底——会拖慢速度,但能防 OOM 被杀。**这是 2G 机器强烈建议做的**: + ```bash + sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile + sudo mkswap /swapfile && sudo swapon /swapfile + ``` +4. 极端情况换 Tesseract + `chi_sim`:内存可压到 200MB 内,但中文证件识别率明显下降,不推荐 + +**并发上不去时**:低配下不要加 worker,改成前面挂 Nginx 限流排队。1 核的现实吞吐大约 **1–2 QPS**,超过就会堆积。要扛量就横向多开几个容器 + 前面 LB,而不是在单机上加进程。 + +## 准确率与调优 + +- 清晰扫描件 / 规整拍照:字段级 95%+;模糊、反光、强倾斜、复印件会明显下降。 +- 掉得最多的通常是**住址**(长文本)和**名字里的生僻字**。建议对 `id_number_valid=false` 的结果走人工复核,不要直接采信。 +- 拍照场景强烈建议前端先做一次**方向校正 + 边缘裁剪**,比在服务端补救有效得多。 +- 想加"先检测身份证区域再 OCR"(防复杂背景),可接 YOLOv8n 做前置裁剪,挂在 `_load_image` 之后即可——**1C/2G 下不建议加**,会再吃 100MB 内存和一大截 CPU。 + +## 合规与安全(重要) + +身份证号属于**敏感个人信息**,受《个人信息保护法》《数据安全法》约束: + +1. 这个方案的价值就在**数据不出你自己的机器**。别为了省事换成公有云 API,那等于把 PII 交给第三方。 +2. 服务**不落盘、不缓存**图片,`raw_text` 含明文,生产环境建议要么关掉、要么 `mask=true`。 +3. 日志里**不要打印身份证号和姓名**。当前实现只打加载信息。 +4. 对外必须 HTTPS + 鉴权 + 限流;已做两层大小防护(content-length 预检 + 惰性尺寸检查),`MAX_IMAGE_BYTES=5MB`。 +5. 收集前应有用户授权与隐私政策,明确保留期限——最安全的做法是识别完即丢弃。 + +## 目录 + +``` +idcard-api/ +├── app/ +│ ├── main.py # FastAPI 服务、鉴权、图片校验、低配参数 +│ └── parser.py # 关键词锚点解析 + 身份证校验位 +├── Dockerfile +├── docker-compose.yml +├── requirements.txt +└── client_example.py +``` + +## 常见问题 + +**构建时报 `401 Unauthorized`(拉不下 `python:3.11-slim` 元数据)**:Docker 镜像源问题,与项目无关。注意 **fnOS 的 Docker 应用会用自己的界面配置覆盖 `/etc/docker/daemon.json`**,手工改文件常常不生效(报错 URL 仍是 `docker.fnnas.com` 就是没生效)。本项目已改走第三条路——**在 Dockerfile 里直接写国内镜像仓库全路径**,不依赖 daemon 配置: + +```dockerfile +ARG BASE_IMAGE=docker.m.daocloud.io/library/python:3.11-slim +FROM ${BASE_IMAGE} +``` + +切换源只需在 `.env` 里设 `BASE_IMAGE`,构建前先探活: +```bash +for m in docker.m.daocloud.io docker.1ms.run mirror.ccs.tencentyun.com; do + echo -n "$m -> "; curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 \ + "https://$m/v2/library/python/manifests/3.11-slim" +done +``` +返回 `200` 可用;`401` 且带 `WWW-Authenticate` 头属于 Registry 正常认证流程,Docker 能自动处理;连接超时/`5xx` 则换下一个。全部不通就走离线导入兜底: + +```bash +# 1) 在能联网且有 Docker 的机器上(你的 Windows 本机装了 Docker Desktop 即可) +docker pull python:3.11-slim +docker save python:3.11-slim -o python311slim.tar +# 2) 传到 NAS 后导入 +docker load -i python311slim.tar +# 3) 构建时指向本地 tag +# 在 .env 里设 BASE_IMAGE=python:3.11-slim,然后 docker compose up -d --build +``` + +**pip 装包慢/断**:与镜像源无关,是另一条网络路径。Dockerfile 已默认清华源(`PIP_INDEX_URL`),需要时构建覆盖。 + +**构建时模型下载失败**:国内访问模型源不稳。先在能上网的机器上 `python -c "from rapidocr_onnxruntime import RapidOCR; RapidOCR()"` 生成模型目录,再拷进 `/app/models` 卷;pip 可加 `-i https://pypi.tuna.tsinghua.edu.cn/simple`。 + +**容器起不来 / 报 OOM**:先 `docker stats` 看峰值,或 `curl .../health` 看 `mem_mb`。若峰值贴着 `mem_limit`,按上面降级路径降 `MAX_SIDE`;反复被杀就先加 swap。 + +**`RapidOCR rejected {...}` 警告**:正常,代码在逐级降级尝试构造参数,最终会用可用组合启动,不会失败。锁死 `requirements.txt` 版本后这条警告会消失。 + +**CPU 跑满但吞吐很低**:典型症状是线程在抖。检查容器内 `echo $OMP_NUM_THREADS` 必须是 1;如果宿主机是多核而 compose 里只写了 `cpus: 1.0`,环境变量没生效就会这样。 + +**返回 422 no text detected**:图片不是身份证、或分辨率过低/文字太小。 + +**性别/民族识别成空**:正面右侧那一行字小,常见于低清图。提高图片质量,或换 mobile/server 识别模型。 + +**numpy 相关报错**:`requirements.txt` 里锁了 `numpy<2`,不要手动升。onnxruntime 与 numpy 2 存在兼容问题。 diff --git a/idcard-api/app/main.py b/idcard-api/app/main.py new file mode 100644 index 0000000..5320cfb --- /dev/null +++ b/idcard-api/app/main.py @@ -0,0 +1,420 @@ +"""身份证识别 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() + +_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}) diff --git a/idcard-api/app/parser.py b/idcard-api/app/parser.py new file mode 100644 index 0000000..57d687c --- /dev/null +++ b/idcard-api/app/parser.py @@ -0,0 +1,185 @@ +"""身份证字段解析器。 + +输入 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"(? 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 diff --git a/idcard-api/client_example.py b/idcard-api/client_example.py new file mode 100644 index 0000000..d6d2827 --- /dev/null +++ b/idcard-api/client_example.py @@ -0,0 +1,52 @@ +"""调用示例:上传本地图片,拿回结构化 JSON。 + +三种鉴权方式按需选一种(与服务端 .env 对应): + 1. 静态 token(推荐):TOKEN = "你的 token",走 Authorization: Bearer + 2. 短期 token:填 CLIENT_SECRET,脚本自动换 token(适合前端后端代理场景) + 3. 不配置:TOKEN 和 CLIENT_SECRET 都留空,服务端也必须未配鉴权 +""" +import json +import sys + +import requests + +BASE = "http://10.1.1.163:8000" # 换成你的服务地址 +TOKEN = "" # 静态 token(API_TOKENS 里那把) +CLIENT_ID = "default" +CLIENT_SECRET = "" # 填了就走短期 token 流程 + + +def get_token() -> str: + """用 client_secret 换一个短期 token。""" + r = requests.post( + f"{BASE}/api/v1/auth/token", + json={"client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}, + timeout=10, + ) + r.raise_for_status() + data = r.json() + print(f"[token] 有效期 {data['expires_in']}s", file=sys.stderr) + return data["token"] + + +def recognize(path: str, side: str = "auto", mask: bool = False) -> dict: + token = TOKEN or (get_token() if CLIENT_SECRET else "") + headers = {"Authorization": f"Bearer {token}"} if token else {} + + with open(path, "rb") as f: + r = requests.post( + f"{BASE}/api/v1/idcard/recognize", + files={"file": (path, f, "image/jpeg")}, + data={"side": side, "mask": str(mask).lower()}, + headers=headers, + timeout=120, # 低端 NAS 第一次要加载模型,给足时间 + ) + if r.status_code == 401: + raise SystemExit("鉴权失败:token 无效或已过期(检查 .env 里的 API_TOKENS / CLIENT_SECRET)") + r.raise_for_status() + return r.json() + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "idcard.jpg" + print(json.dumps(recognize(path), ensure_ascii=False, indent=2)) diff --git a/idcard-api/docker-compose.yml b/idcard-api/docker-compose.yml new file mode 100644 index 0000000..b95d45f --- /dev/null +++ b/idcard-api/docker-compose.yml @@ -0,0 +1,49 @@ +services: + idcard-api: + build: + context: . + # 基础镜像默认走国内公共源,绕开 fnOS 的 daemon 加速配置(那个源需授权,常 401)。 + # 想换源或改回官方:在 .env 里设 BASE_IMAGE=python:3.11-slim + args: + BASE_IMAGE: ${BASE_IMAGE:-docker.m.daocloud.io/library/python:3.11-slim} + container_name: idcard-api + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + - API_KEY=${API_KEY:-} # 旧版单密钥,兼容保留 + # 多 token 鉴权:名称:token,名称2:token2 —— 每个调用方一把,可单独吊销 + - "API_TOKENS=${API_TOKENS:-}" + # 短期 token(可选):设了 CLIENT_SECRET 才启用 /api/v1/auth/token + - "CLIENT_ID=${CLIENT_ID:-default}" + - "CLIENT_SECRET=${CLIENT_SECRET:-}" + - "TOKEN_TTL=${TOKEN_TTL:-3600}" + # 浏览器前端跨源调用需要。默认 * 方便开发;生产建议改成实际前端地址(逗号分隔) + - "CORS_ORIGINS=${CORS_ORIGINS:-*}" + - MAX_IMAGE_BYTES=5242880 # 5MB,身份证拍照 2-4MB 足够 + - MAX_SIDE=960 # Celeron 1007U @1.5G 单核弱,960 换速度;文字仍有约 25px + - MAX_DIMENSION=6000 # header 尺寸硬上限,防解压炸弹;手机拍照 3000-4000px 不受影响 + - DET_LIMIT_SIDE_LEN=736 + - REC_BATCH_NUM=1 # 低内存必须为 1,batch 只涨峰值不省时间 + - OMP_NUM_THREADS=1 # 关键:cpus 限额不改变容器内 os.cpu_count() + - OPENBLAS_NUM_THREADS=1 + - MKL_NUM_THREADS=1 + ports: + # 去掉 127.0.0.1 绑定:局域网其他容器/设备要能调用。 + # 安全靠 API_KEY 鉴权 + 不要在路由器上映射公网端口。 + - "8000:8000" + volumes: + - rapidocr-models:/app/models + - rapidocr-home:/app/.rapidocr_model + # 低配约束:只用 1 核,容器限 1G,其余留给 fnOS 系统和其他服务。 + # 本配置针对 Celeron 1007U / 4GB 单条内存机型调低;更好机器可调回 1280m + MAX_SIDE=1200 + cpus: 1.0 + mem_limit: 1024m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + rapidocr-models: + rapidocr-home: diff --git a/idcard-api/requirements.txt b/idcard-api/requirements.txt new file mode 100644 index 0000000..17cfce1 --- /dev/null +++ b/idcard-api/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +python-multipart>=0.0.9 +pillow>=10.0 +numpy<2 +rapidocr-onnxruntime>=1.4.0 +# 锁 onnxruntime 版本范围:1.19 之前的旧版在容器环境有内存/兼容问题, +# numpy 2 与部分 onnxruntime 组合不兼容,两边都别手动升 +onnxruntime>=1.19,<2.0