ow
This commit is contained in:
1 parent
419c22cb74
commit
59ec58a1eb
8 files changed
+1163
No files matched your search
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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": "<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 <token>` |
|
||||
|
||||
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 存在兼容问题。
|
||||
@@ -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 <token>(标准),兼容 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 <token> ← 标准写法,推荐
|
||||
X-API-Key: <token> ← 旧写法,保留兼容
|
||||
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 <token>(兼容 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})
|
||||
@@ -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"(?<![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
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Reference in new issue
Block a user