目录
- 一、任务概述
- 二、后端架构重构:回归SQLite
-
- [2.1 修改数据库连接配置](#2.1 修改数据库连接配置)
- [2.2 修改数据模型定义](#2.2 修改数据模型定义)
- [2.3 修改特征提取器](#2.3 修改特征提取器)
- [2.4 修改主服务逻辑](#2.4 修改主服务逻辑)
- [2.5 修改初始化数据库](#2.5 修改初始化数据库)
- [2.6 数据库初始化与迁移重置](#2.6 数据库初始化与迁移重置)
- 三、运维管理工具集
-
- [3.1 快速模板检索工具](#3.1 快速模板检索工具)
- [3.2 样证数据批量导出工具](#3.2 样证数据批量导出工具)
- [3.3 数据库重置工具](#3.3 数据库重置工具)
- [3.4 大模型测试工具](#3.4 大模型测试工具)
- 四、构建离线Docker镜像
-
- [4.1 拉取基础镜像并启动容器](#4.1 拉取基础镜像并启动容器)
- [4.2 容器内环境初始化](#4.2 容器内环境初始化)
- [4.3 部署代码与环境](#4.3 部署代码与环境)
- [4.4 导出离线镜像](#4.4 导出离线镜像)
- 四、内网环境离线部署
- 五、总结
一、任务概述
在前序的开发工作中,全球证件智能识别系统已完成全链路的功能开发。在尝试引入PostgreSQL作为生产数据库进行内网部署的实践中,发现由于内网环境的网络策略限制、权限管控以及依赖库(如libpq、psycopg2)的兼容性问题,导致数据库服务的连接与维护异常复杂。
针对单机离线部署且并发量可控的业务场景,SQLite数据库凭借其"单文件、零配置、无服务进程"的特性,展现出了极高的便携性与稳定性。为了降低内网部署的复杂度,确保系统"开箱即用",本篇博客将对后端架构进行轻量化重构。
任务目标包含三个方面:
- 架构回退:将FastAPI后端服务的数据持久化层从PostgreSQL完整回退至SQLite。这将涉及数据模型定义、数据库连接配置以及特征向量存储方式(从原生数组回退至二进制序列化)的全面修改。
- 镜像构建:基于Docker容器技术,构建包含系统OS、Python环境、依赖库及SQLite数据库文件的全量镜像,并导出为离线包。
- 运维工具适配:开发适配SQLite架构的运维管理脚本,涵盖样证模板检索、数据导出及数据库重置功能。
二、后端架构重构:回归SQLite
由于SQLite不支持PostgreSQL特有的ARRAY数据类型,且对高维向量的直接存储支持有限,必须对代码进行深度调整。核心策略是恢复使用Python的pickle库,将特征向量序列化为二进制数据(Bytes)存入数据库的BLOB字段。
2.1 修改数据库连接配置
修改database.py,移除PostgreSQL相关的连接逻辑,配置SQLite连接参数,并开启多线程共享支持。
代码清单:database.py
python
import os
from sqlmodel import create_engine, Session
# 定义数据库文件路径
# 在Docker容器中,建议使用绝对路径
SQLITE_FILE_NAME = "card_db.sqlite"
DATABASE_URL = f"sqlite:///{SQLITE_FILE_NAME}"
# 创建数据库引擎
# connect_args={"check_same_thread": False} 对于FastAPI多线程环境是必须的
engine = create_engine(DATABASE_URL, echo=False, connect_args={"check_same_thread": False})
def get_session():
"""
FastAPI 依赖注入函数,用于获取数据库会话
"""
with Session(engine) as session:
yield session
2.2 修改数据模型定义
修改models.py,将特征向量字段的类型从浮点数列表恢复为字节流(bytes),以便存储序列化后的NumPy数组。
代码清单:models.py
python
from datetime import datetime
from typing import Optional, List
from sqlmodel import Field, Relationship, SQLModel
class Country(SQLModel, table=True):
"""
国家数据模型
"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="国家中文名称")
code: str = Field(unique=True, description="ISO 3166-1三位数字代码")
certificate_templates: List["CertificateTemplate"] = Relationship(back_populates="country")
class CertificateTemplate(SQLModel, table=True):
"""
证件模板数据模型
"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="证件模板名称")
description: str = Field(description="证件模板的详细描述")
# 图像数据,以二进制格式存储
image_front_white: bytes = Field(description="正面白光样证图")
image_front_uv: bytes = Field(description="正面紫外样证图")
image_back_white: bytes = Field(description="反面白光样证图")
image_back_uv: bytes = Field(description="反面紫外样证图")
# 特征向量数据,使用pickle序列化为bytes存储
feature_front_white: bytes = Field(description="正面白光图特征向量(Pickled)")
feature_front_uv: bytes = Field(description="正面紫外图特征向量(Pickled)")
feature_back_white: bytes = Field(description="反面白光图特征向量(Pickled)")
feature_back_uv: bytes = Field(description="反面紫外图特征向量(Pickled)")
country_id: Optional[int] = Field(default=None, foreign_key="country.id")
country: Optional[Country] = Relationship(back_populates="certificate_templates")
class SampleRecord(SQLModel, table=True):
"""
样证上传记录表
"""
id: Optional[int] = Field(default=None, primary_key=True)
country_code: str = Field(index=True)
device_sn: str = Field(index=True)
created_at: datetime = Field(default_factory=datetime.now)
is_suspicious: bool = Field()
remarks: Optional[str] = Field(default=None)
image_front_white: bytes
image_front_uv: bytes
image_front_ir: bytes
image_back_white: bytes
image_back_uv: bytes
image_back_ir: bytes
2.3 修改特征提取器
修改feature_extractor.py,恢复使用pickle对提取出的特征向量进行序列化,返回bytes类型。
代码清单:feature_extractor.py
python
import io
import pickle
import numpy as np
import torch
import torch.nn as nn
from PIL import Image
from torchvision import models, transforms
class EmbeddingNet(nn.Module):
def __init__(self, base_model):
super(EmbeddingNet, self).__init__()
self.features = nn.Sequential(*list(base_model.children())[:-1])
self.flatten = nn.Flatten()
def forward(self, x):
x = self.features(x)
x = self.flatten(x)
return x
class ImageFeatureExtractor:
def __init__(self, model_path="mobilenetv3_finetuned.pth"):
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
base_model = models.mobilenet_v3_large()
self.model = EmbeddingNet(base_model).to(self.device)
# 加载权重,处理可能的map_location问题
if torch.cuda.is_available():
self.model.load_state_dict(torch.load(model_path))
else:
self.model.load_state_dict(torch.load(model_path, map_location=torch.device('cpu')))
self.model.eval()
self.preprocess = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def extract_features(self, image_bytes: bytes) -> bytes:
"""
返回序列化后的二进制特征向量
"""
try:
image = Image.open(io.BytesIO(image_bytes)).convert("L").convert("RGB")
input_tensor = self.preprocess(image)
input_batch = input_tensor.unsqueeze(0).to(self.device)
with torch.no_grad():
output_features = self.model(input_batch)
feature_np = output_features.cpu().numpy().flatten()
# 使用pickle序列化
return pickle.dumps(feature_np)
except Exception as e:
print(f"特征提取失败: {e}")
return b''
2.4 修改主服务逻辑
修改main.py中的检索逻辑,增加pickle反序列化步骤,将从数据库读取的二进制数据还原为NumPy数组进行计算。
代码清单:main.py (部分核心逻辑)
python
# ... (保留原有导入)
import pickle # 必须导入
# ...
@app.post("/api/recognize", response_model=RecognitionResponse)
async def recognize_document(request: RecognitionRequest, session: Session = Depends(get_session)):
# ... (省略前置日志和解码代码)
# 1. 提取待查询图像特征 (extractor返回的是bytes)
# 需反序列化为numpy数组用于计算
query_feature_front = pickle.loads(extractor.extract_features(front_white_bytes))
query_feature_back = pickle.loads(extractor.extract_features(back_white_bytes))
# ... (数据库检索代码)
for template in templates:
# 2. 反序列化数据库中的特征向量
template_feature_front = pickle.loads(template.feature_front_white)
template_feature_back = pickle.loads(template.feature_back_white)
sim_front = cosine_similarity(query_feature_front, template_feature_front)
sim_back = cosine_similarity(query_feature_back, template_feature_back)
# ... (后续比对逻辑不变)
# 5. 二次校验:紫外荧光图像相似度检查
# ...
# 提取待查询图像的紫外特征
query_feature_uv_front = pickle.loads(extractor.extract_features(query_uv_front_bytes))
query_feature_uv_back = pickle.loads(extractor.extract_features(query_uv_back_bytes))
# 反序列化样证的紫外特征
template_feature_uv_front = pickle.loads(best_match_template.feature_front_uv)
template_feature_uv_back = pickle.loads(best_match_template.feature_back_uv)
# ... (后续防伪检测与大模型调用逻辑不变)
2.5 修改初始化数据库
修改init_db.py文件:
python
feature_front_white = extractor.extract_features(front_white_bytes) if front_white_bytes else b''
feature_back_white = extractor.extract_features(back_white_bytes) if back_white_bytes else b''
feature_front_uv = extractor.extract_features(front_uv_bytes) if front_uv_bytes else b''
feature_back_uv = extractor.extract_features(back_uv_bytes) if back_uv_bytes else b''
上述代码表示,如果图像缺失,返回的默认值应该改为b''。
2.6 数据库初始化与迁移重置
由于数据库类型变更,原有的迁移脚本已失效。需要删除旧的迁移记录并重新生成。
- 清理环境 :删除项目目录下的
alembic/versions文件夹内的所有.py文件,删除旧的database.db或card_db.sqlite文件。 - 修改
alembic/env.py:确保其指向新的 SQLite 配置。
代码清单:alembic/env.py (修改部分)
python
# ...
from database import DATABASE_URL, engine # 导入
def run_migrations_online() -> None:
connectable = engine # 直接使用database.py中创建好的engine
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
- 生成新迁移脚本并应用:
bash
alembic revision --autogenerate -m "init_sqlite"
# (可选) 手动检查生成的版本文件,确保包含 import sqlmodel
alembic upgrade head
- 数据初始化 :
执行init_db.py(该脚本需确认已适配pickle序列化,与models.py一致)。
bash
python3 init_db.py
至此,后端代码已完全切换回SQLite架构。
三、运维管理工具集
为了方便后续管理,需要添加一些运维管理工具脚本。
3.1 快速模板检索工具
脚本名称:tool_search_template.py
python
import argparse
import pickle
import numpy as np
from sqlmodel import Session, select
from database import engine
from models import CertificateTemplate
from feature_extractor import ImageFeatureExtractor
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
norm_vec1 = vec1 / np.linalg.norm(vec1)
norm_vec2 = vec2 / np.linalg.norm(vec2)
return float(np.dot(norm_vec1, norm_vec2))
def main():
parser = argparse.ArgumentParser(description="快速样证模板检索工具(SQLite版)")
parser.add_argument("--front", required=True, help="证件正面图像路径")
parser.add_argument("--back", required=True, help="证件反面图像路径")
args = parser.parse_args()
extractor = ImageFeatureExtractor(model_path="mobilenetv3_finetuned.pth")
try:
with open(args.front, "rb") as f:
front_bytes = f.read()
with open(args.back, "rb") as f:
back_bytes = f.read()
except FileNotFoundError as e:
print(f"错误:{e}")
return
# 提取特征并反序列化
query_feature_front = pickle.loads(extractor.extract_features(front_bytes))
query_feature_back = pickle.loads(extractor.extract_features(back_bytes))
best_match = None
max_score = -1.0
with Session(engine) as session:
templates = session.exec(select(CertificateTemplate)).all()
for template in templates:
# 数据库字段直接反序列化
feat_front = pickle.loads(template.feature_front_white)
feat_back = pickle.loads(template.feature_back_white)
score_front = cosine_similarity(query_feature_front, feat_front)
score_back = cosine_similarity(query_feature_back, feat_back)
avg_score = (score_front + score_back) / 2.0
if avg_score > max_score:
max_score = avg_score
best_match = template
if best_match:
print(f"匹配结果:成功 | 模板:{best_match.name} | 分数:{max_score:.4f}")
else:
print("未找到匹配模板")
if __name__ == "__main__":
main()
使用时可以使用下面的命令:
bash
python3 tool_search_template.py --front ./test/12_06_57_front_white.jpg --back ./test/12_06_57_back_white.jpg
3.2 样证数据批量导出工具
脚本名称:tool_export_samples.py
python
import argparse
import os
from datetime import datetime
from pathlib import Path
from sqlmodel import Session, select
from database import engine
from models import SampleRecord
def save_image(data: bytes, path: Path):
if data:
with open(path, "wb") as f:
f.write(data)
def main():
parser = argparse.ArgumentParser(description="样证导出工具")
parser.add_argument("--start", default="1970-01-01")
parser.add_argument("--end", default="2099-12-31")
parser.add_argument("--out", default="./exported_samples")
args = parser.parse_args()
start_date = datetime.strptime(args.start, "%Y-%m-%d")
end_date = datetime.strptime(args.end, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
output_root = Path(args.out)
output_root.mkdir(parents=True, exist_ok=True)
with Session(engine) as session:
statement = select(SampleRecord).where(
SampleRecord.created_at >= start_date,
SampleRecord.created_at <= end_date
)
records = session.exec(statement).all()
for record in records:
date_dir = output_root / record.created_at.strftime("%Y-%m-%d")
date_dir.mkdir(exist_ok=True)
base_name = f"{record.id}_{record.country_code}"
save_image(record.image_front_white, date_dir / f"{base_name}_front_white.jpg")
save_image(record.image_back_white, date_dir / f"{base_name}_back_white.jpg")
save_image(record.image_front_uv, date_dir / f"{base_name}_front_uv.jpg")
save_image(record.image_back_uv, date_dir / f"{base_name}_back_uv.jpg")
# 4. 生成详细的描述文本文件
txt_path = date_dir / f"{base_name}_info.txt"
with open(txt_path, "w", encoding="utf-8") as f:
f.write(f"记录ID: {record.id}\n")
f.write(f"国家代码: {record.country_code}\n")
f.write(f"设备序列号 (SN): {record.device_sn}\n")
# 格式化时间显示
f.write(f"上传时间: {record.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n")
# 将布尔值转换为易读的中文状态
f.write(f"人工核验结果: {'存疑' if record.is_suspicious else '正常'}\n")
# 处理备注为空的情况
f.write(f"详细备注: {record.remarks if record.remarks else '无'}\n")
print(f"导出完成,共 {len(records)} 条。")
if __name__ == "__main__":
main()
使用时可以使用下面的命令:
bash
python3 tool_export_samples.py --start 2019-08-01 --end 2026-08-01 --out ./exported_samples
3.3 数据库重置工具
针对SQLite,重置数据库较为简单,直接删除文件重建即可。
脚本名称:tool_reset_db.py
python
import os
import sys
from database import SQLITE_FILE_NAME
import init_db
def main():
# 1. 删除数据库文件
if os.path.exists(SQLITE_FILE_NAME):
os.remove(SQLITE_FILE_NAME)
print("数据库文件已删除。")
# 2. 重新应用迁移 (确保 alembic.ini 配置正确)
os.system("alembic upgrade head")
# 3. 初始化数据
init_db.main()
print("数据库重置完成。")
if __name__ == "__main__":
main()
如果模型重新训练了或者samples文件夹中的图像发生了很多变化,可以使用python3 tool_reset_db.py完成整个数据库的重建。
3.4 大模型测试工具
脚本名称:tool_test_llm.py
python
import argparse
import base64
import json
import requests
import os
import sys
def encode_image(image_path):
"""
读取图像文件并转换为Base64字符串
"""
if not os.path.exists(image_path):
print(f"错误:文件不存在 - {image_path}")
sys.exit(1)
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
def main():
parser = argparse.ArgumentParser(description="大模型版面识别接口验证工具")
parser.add_argument("--front", required=True, help="证件正面白光图像路径")
parser.add_argument("--back", required=True, help="证件反面白光图像路径")
parser.add_argument("--host", default="http://127.0.0.1:8001", help="后端服务地址")
args = parser.parse_args()
print("正在读取并编码图像...")
front_b64 = encode_image(args.front)
back_b64 = encode_image(args.back)
# 构建请求Payload
# 注意:API要求提供完整的多光谱图像字段。
# 为简化测试,此处将白光图像填充至紫外和红外字段,仅用于通过格式校验。
payload = {
"country_code": "840", # 设置为国外代码(如美国)以触发LLM流程
"enable_llm": True, # 强制开启大模型识别
"image_front_white": front_b64,
"image_front_uv": front_b64, # 占位填充
"image_front_ir": front_b64, # 占位填充
"image_back_white": back_b64,
"image_back_uv": back_b64, # 占位填充
"image_back_ir": back_b64 # 占位填充
}
api_url = f"{args.host}/api/recognize"
print(f"正在向 {api_url} 发送请求,请稍候(大模型推理可能需要数秒)...")
try:
response = requests.post(api_url, json=payload, headers={"Content-Type": "application/json"})
if response.status_code == 200:
result = response.json()
print("-" * 30)
print("【请求成功】")
print(f"状态码 (Code): {result.get('code')}")
# 提取并打印核心消息
message = result.get('message', '')
print("\n>>> 识别结果详情 <<<")
print(message)
# 检查是否包含大模型返回的版面信息
if "--- 版面信息 ---" in message:
print("\n[验证结论]: 大模型调用成功,版面信息已返回。")
else:
print("\n[验证结论]: 警告 - 未检测到版面信息,请检查大模型服务状态或国家代码配置。")
else:
print("-" * 30)
print(f"【请求失败】 HTTP状态码: {response.status_code}")
print(f"错误信息: {response.text}")
except requests.exceptions.ConnectionError:
print(f"错误:无法连接到后端服务 {args.host},请确认服务已启动。")
except Exception as e:
print(f"发生未预期的错误: {e}")
if __name__ == "__main__":
main()
使用时可以使用下面的命令:
bash
python3 tool_test_llm.py --front ./test/12_06_57_front_white.jpg --back ./test/12_06_57_back_white.jpg --host http://127.0.0.1:8001
四、构建离线Docker镜像
采用"单体容器"策略,将FastAPI服务与SQLite数据库文件封装在同一镜像中,无需安装额外的数据库服务软件。
4.1 拉取基础镜像并启动容器
在联网机器上操作:
bash
# 1. 拉取基础镜像(由于网络限制,暂定从hub.rat.dev来获取)
sudo docker pull hub.rat.dev/ubuntu:22.04
# 2. 启动构建容器
sudo docker run -dit --net=host --name card_system_build --shm-size="8g" hub.rat.dev/ubuntu:22.04 bash
# 3. 进入容器
sudo docker exec -it card_system_build bash
4.2 容器内环境初始化
在容器内部安装依赖。
bash
# 1. 更新源
apt-get update
# 2. 安装Python及基础工具
apt-get install -y \
python3 \
python3-pip \
python3-venv \
vim \
libgl1-mesa-glx \
libglib2.0-0 \
libgomp1 \
tzdata
# 3. 设置时区
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
dpkg-reconfigure -f noninteractive tzdata
4.3 部署代码与环境
bash
# 1. 创建目录
mkdir -p /code/foreign_card
# --- 此时需在宿主机开启新终端,将代码复制进容器 ---
# cd ForeignCardDetec
# sudo docker cp ./ card_system_build:/code/foreign_card/
# 2. 回到容器终端,安装Python依赖
cd /code/foreign_card
# 优先安装PyTorch CPU版以减小体积(需要参照torch官网的命令下载对应的cpu版本)
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cpu
# 安装其他依赖
pip3 install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 3. 初始化数据库
# 在容器内直接执行,生成 card_db.sqlite 文件
rm -rf alembic/versions/* # 确保清理旧迁移
rm -f card_db.sqlite
alembic revision --autogenerate -m "init_docker_sqlite"
# 在生成的迁移脚本头部补充 import sqlmodel (可通过 sed 命令自动处理)
sed -i '1s/^/import sqlmodel\n/' alembic/versions/*.py
alembic upgrade head
python3 init_db.py
4.4 导出离线镜像
bash
# 1. 提交镜像
sudo docker commit card_system_build card_system_sqlite:v1.0
# 2. 导出为 tar 包
sudo docker save -o card_system_sqlite_v1.0.tar card_system_sqlite:v1.0
# 3. 赋权
sudo chmod 777 card_system_sqlite_v1.0.tar
四、内网环境离线部署
将 card_system_sqlite_v1.0.tar 拷贝至内网服务器。
bash
# 1. 加载镜像
sudo docker load -i card_system_sqlite_v1.0.tar
# 2. 启动容器
sudo docker run -dit --net=host --name card_system_prod --shm-size="8g" card_system_sqlite:v1.0 bash
# 3. 进入容器并移动数据库文件(首次部署)
sudo docker exec -it card_system_prod bash
# 如果挂载目录为空,将构建时生成的数据库移动过去,并建立软链接
if [ ! -f "/code/foreign_card/db_data/card_db.sqlite" ]; then
mv /code/foreign_card/card_db.sqlite /code/foreign_card/db_data/
fi
# 确保代码读取的是挂载目录下的数据库
ln -sf /code/foreign_card/db_data/card_db.sqlite /code/foreign_card/card_db.sqlite
# 4. 启动服务
cd /code/foreign_card
nohup uvicorn main:app --host 0.0.0.0 --port 8001 > server.log 2>&1 &
五、总结
本篇博客通过回归SQLite数据库,极大地简化了"全球证件智能识别系统"在内网环境的部署架构。通过单体Docker容器的构建与分发,消除了复杂的数据库服务配置和依赖管理,实现了真正的"零配置"交付。配套开发的运维工具集,确保了系统上线后在数据维护、故障排查和模型迭代方面的可操作性,为项目的最终落地提供了坚实的工程保障。