This commit is contained in:
owenchen committed 2026-10-18 07:40:42 +08:00
1 parent 419c22cb74
commit 59ec58a1eb
8 files changed
+1163

No files matched your search

+42
View File
@@ -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
+55
View File
@@ -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"]
+351
View File
@@ -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 存在兼容问题。
+420
View File
@@ -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})
+185
View File
@@ -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
+52
View File
@@ -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))
+49
View File
@@ -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:
+9
View File
@@ -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