目录
- 一、任务概述
- 二、数据模型与数据库升级
-
- [2.1 使用labelme标注数据](#2.1 使用labelme标注数据)
- [2.2 修改数据模型 (`models.py`)](#2.2 修改数据模型 (
models.py)) - [2.3 更新数据库迁移](#2.3 更新数据库迁移)
- [2.4 更新数据初始化脚本 (`init_db.py`)](#2.4 更新数据初始化脚本 (
init_db.py))
- 三、前端组件集成
-
- [3.1 本地集成Bootstrap 5](#3.1 本地集成Bootstrap 5)
-
- [3.1.1 集成Bootstrap 5组件](#3.1.1 集成Bootstrap 5组件)
- [3.1.2 集成Bootstrap Icons](#3.1.2 集成Bootstrap Icons)
- [3.1.3 页面集成](#3.1.3 页面集成)
- [3.2 交互式API文档的离线配置与访问](#3.2 交互式API文档的离线配置与访问)
-
- [3.2.1 下载Swagger UI静态文件](#3.2.1 下载Swagger UI静态文件)
- [3.2.2 组织项目文件](#3.2.2 组织项目文件)
- [3.2.3 修改main.py以加载本地文件](#3.2.3 修改main.py以加载本地文件)
- 四、开发审核模块
-
- [4.1 准备静态资源与模板](#4.1 准备静态资源与模板)
- [4.2 FastAPI主程序实现 (`main.py`)](#4.2 FastAPI主程序实现 (
main.py)) - [4.3 客户端调用验证脚本开发](#4.3 客户端调用验证脚本开发)
-
- [4.3.1 脚本实现逻辑](#4.3.1 脚本实现逻辑)
- [4.3.2 代码清单](#4.3.2 代码清单)
- 五、小结
一、任务概述
在前序构建的全球证件智能识别系统中,后端服务已具备高效的证件检索与识别能力。为满足业务流程中人工复核与审查的需求,系统需要提供一个直观的可视化审核界面。该界面不仅需要展示待审核证件与标准模板的图像对比,还需具备基于鼠标交互的字段信息提示功能,以辅助业务人员快速定位关键信息。
本篇博客将重点实现以下功能:
- 数据库与模型扩展:修改数据模型,支持存储由LabelMe软件生成的JSON版面标注数据;更新初始化脚本,将标注文件内容同步导入SQLite数据库。
- 可视化审核接口开发 :在FastAPI后端开发
/api/audit_render接口,接收经预处理的证件图像与国家代码,检索最匹配的样证模板,并结合模板的JSON标注数据,服务端渲染出包含交互逻辑的HTML页面。 - 前端模板集成:开发基于Canvas的交互式HTML模板,实现鼠标悬停时自动高亮显示证件对应区域字段名称的功能。
二、数据模型与数据库升级
2.1 使用labelme标注数据
为了实现字段级的可视化交互,系统需要存储每种证件模板的版面布局信息。这些信息本文使用LabelMe软件标注生成JSON文件。
labelme是开源的用来为深度学习任务所开发的标注软件,支持矩形、多边形等多种标注形式。本文使用这款软件来标注证件信息。
具体的,采用labelme软件进行标注,通过该软件的矩形框模式,在证件图像上标注矩形框,然后再标注对应的字段。例如在某张证上的某个区域画个矩形框,再给这个框打上类别标记:姓名。为了能支持中文标注,需要采用能支持中文标注的labelme版本(注意,不是所有版本都支持中文标注的,这里我提供一个可用版本的下载链接)。
注意,标注完所产生的的json标注文件如果要手动修改,必须使用notepad++工具。默认编码格式为ANSI。
2.2 修改数据模型 (models.py)
在CertificateTemplate模型中增加两个字段front_layout_schema和back_layout_schema,用于以字符串形式存储正反面模板的JSON标注内容。
代码清单:models.py
python
from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel
# 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="证件模板的详细描述")
region: str = Field(default="", 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)")
# 新增:版面标注数据 (存储LabelMe生成的JSON字符串)
front_layout_schema: Optional[str] = Field(default=None, description="正面JSON标注数据")
back_layout_schema: Optional[str] = Field(default=None, description="反面JSON标注数据")
country_id: Optional[int] = Field(default=None, foreign_key="country.id")
country: Optional[Country] = Relationship(back_populates="certificate_templates")
# SampleRecord类代码不变
2.3 更新数据库迁移
由于修改了模型定义,需重新生成数据库迁移脚本或重置数据库。考虑到开发环境的便捷性,采用重置数据库的方式。
执行命令:
bash
# 删除旧数据库文件
rm card_db.sqlite
# 清除旧的迁移版本文件
rm -rf alembic/versions/*
# 生成新的迁移脚本
alembic revision --autogenerate -m "add_layout_schema"
# 在生成的脚本中添加 import sqlmodel
# 应用迁移
alembic upgrade head
2.4 更新数据初始化脚本 (init_db.py)
init_db.py脚本负责遍历samples目录并将数据导入数据库。此次更新需加入对LabelMe生成的JSON文件的读取逻辑。根据规则,同一版本的证件复用1_front_white.json和1_back_white.json。
代码清单:init_db.py
python
import re
import json
from pathlib import Path
from sqlmodel import Session, select, SQLModel
from database import engine
from feature_extractor import ImageFeatureExtractor
from models import CertificateTemplate, Country
def read_file_bytes(file_path: Path) -> bytes:
"""读取文件二进制内容"""
if file_path and file_path.exists() and file_path.is_file():
return file_path.read_bytes()
return b''
def read_json_content(file_path: Path) -> str:
"""读取JSON文件内容并返回字符串,支持多种编码"""
if not file_path or not file_path.exists():
return None
encodings = ['utf-8', 'gbk', 'gb2312', 'mbcs']
for enc in encodings:
try:
with open(file_path, 'r', encoding=enc) as f:
# 验证是否为有效JSON
json_obj = json.load(f)
return json.dumps(json_obj, ensure_ascii=False)
except Exception:
continue
return None
def main():
print("开始初始化数据库...")
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
extractor = ImageFeatureExtractor(model_path="mobilenetv3_finetuned.pth")
samples_dir = Path("samples")
if not samples_dir.exists():
print(f"错误: 未找到样本数据目录 '{samples_dir}'")
return
for country_dir in samples_dir.iterdir():
if not country_dir.is_dir(): continue
match = re.match(r'(.+)_(\d{3})$', country_dir.name)
if not match: continue
country_name, country_code = match.groups()
# 检查或创建国家
statement = select(Country).where(Country.code == country_code)
db_country = session.exec(statement).first()
if not db_country:
db_country = Country(name=country_name, code=country_code)
session.add(db_country)
session.commit()
session.refresh(db_country)
for state_dir in country_dir.iterdir():
if not state_dir.is_dir(): continue
state_name = state_dir.name
# 处理地区名称:如果是other则置为空字符串
display_region = state_name if state_name.lower() != 'other' else ""
for template_dir in state_dir.iterdir():
if not template_dir.is_dir(): continue
template_id = template_dir.name
print(f"\n处理模板: {country_name} - {state_name} - {template_id}")
# 1. 查找并读取该模板版本的公共标注文件 (1_front_white.json / 1_back_white.json)
# 规则:同一文件夹下的所有样本共用一套JSON标注
front_json_path = template_dir / "1_front_white.json"
back_json_path = template_dir / "1_back_white.json"
front_layout_str = read_json_content(front_json_path)
back_layout_str = read_json_content(back_json_path)
if front_layout_str:
print(" - 已加载正面标注数据")
if back_layout_str:
print(" - 已加载反面标注数据")
# 2. 扫描目录下的所有样本图像
all_files = list(template_dir.glob("*.jpg"))
sample_indices = sorted(list(set([f.name.split('_')[0] for f in all_files])))
for index in sample_indices:
# 图像路径
front_white_path = template_dir / f"{index}_front_white.jpg"
back_white_path = template_dir / f"{index}_back_white.jpg"
front_uv_path = template_dir / f"{index}_front_uv.jpg"
back_uv_path = template_dir / f"{index}_back_uv.jpg"
# 读取图像数据
fw_bytes = read_file_bytes(front_white_path)
bw_bytes = read_file_bytes(back_white_path)
fu_bytes = read_file_bytes(front_uv_path)
bu_bytes = read_file_bytes(back_uv_path)
# 提取特征
feat_fw = extractor.extract_features(fw_bytes) if fw_bytes else b''
feat_bw = extractor.extract_features(bw_bytes) if bw_bytes else b''
feat_fu = extractor.extract_features(fu_bytes) if fu_bytes else b''
feat_bu = extractor.extract_features(bu_bytes) if bu_bytes else b''
# 构建名称
template_name = f"{country_name} {display_region} 模板{template_id}"
desc = f"标准样证-{country_name}-{state_name}-{template_id}-样本{index}"
# 创建记录
new_template = CertificateTemplate(
name=template_name.strip(),
description=desc,
region=display_region, # 存储处理后的地区名
image_front_white=fw_bytes,
image_back_white=bw_bytes,
image_front_uv=fu_bytes,
image_back_uv=bu_bytes,
feature_front_white=feat_fw,
feature_back_white=feat_bw,
feature_front_uv=feat_fu,
feature_back_uv=feat_bu,
front_layout_schema=front_layout_str, # 存入JSON字符串
back_layout_schema=back_layout_str, # 存入JSON字符串
country_id=db_country.id
)
session.add(new_template)
print(f" - 样本 {index} 入库")
session.commit()
print("\n数据库初始化完成。")
if __name__ == "__main__":
main()
三、前端组件集成
3.1 本地集成Bootstrap 5
为了方便制作界面,本文采用Bootstrap 5来制作前端界面。在实际开发中,特别是考虑到网络环境的稳定性,将Bootstrap框架的静态资源文件下载到本地项目中使用,是一种比通过外部链接更可靠的做法。
3.1.1 集成Bootstrap 5组件
访问Bootstrap官方网站(https://getbootstrap.com),下载其"已编译的CSS和JS"(Compiled CSS and JS)发行版文件。下载后会得到一个zip压缩包。解压下载的压缩包,可以看到css和js两个文件夹。为保持项目结构的清晰,在项目根目录先创建一个static文件夹,专门用于存放静态资源文件。在static下创建一个名为bootstrap的文件夹,并将解压后的css和js文件夹完整地复制进去。
一个典型的项目目录结构如下所示:
bash
project_root/
├── static/
│ └── bootstrap/
│ ├── css/
│ │ └── bootstrap.min.css
│ └── js/
│ └── bootstrap.bundle.min.js
├── templates/
│ └── xxxx.html
└── main.py
其中,bootstrap.min.css是经过压缩的、用于生产环境的CSS文件,包含了所有样式。bootstrap.bundle.min.js是包含其所有JavaScript插件的捆绑包,推荐在项目中使用此文件。
3.1.2 集成Bootstrap Icons
此外,为了提升页面的视觉表现力与信息传达的直观性,图标是不可或缺的元素。本节还将详细讲解如何引入Bootstrap官方的图标库Bootstrap Icons,并以完全离线的方式将其集成到项目中,确保在任何网络环境下都能稳定、快速地加载。
访问Bootstrap Icons的官方网站(https://icons.getbootstrap.com/),在首页找到并单击下载按钮。网站会提供一个包含所有图标资源的zip压缩包。解压下载的压缩包。在众多文件中,当前项目所必需的核心文件是bootstrap-icons.min.css以及存放图标字体的fonts文件夹。为保持所有Bootstrap相关资源都集中存放,将它们统一放入项目的static/bootstrap/目录下。具体步骤如下:
(1)将bootstrap-icons.min.css文件复制到项目的static/bootstrap/css/目录下。
(2)将整个fonts文件夹复制到项目的static/bootstrap/css/目录下。
完成后的static目录结构应如下所示:
bash
static/
├── bootstrap/
│ ├── css/
│ │ ├── bootstrap-icons.min.css # 新增的图标样式文件
│ │ ├── fonts/ # 新增的图标字体文件夹
│ │ │ ├── bootstrap-icons.woff
│ │ │ └── bootstrap-icons.woff2
│ │ └── bootstrap.min.css
│ └── js/
│ └── bootstrap.bundle.min.js
bootstrap-icons.min.css文件中定义了所有图标的CSS类,它会自动从当前fonts文件夹中加载字体文件。因此,将fonts文件夹与bootstrap-icons.min.css放在同一层级的css目录内,是确保图标能正确显示的关键。
3.1.3 页面集成
完成上述配置以后,要在HTML中使用Bootstrap,只需要按照如下方法引入:
html
<head>
<!-- 引入所有Bootstrap的CSS文件 -->
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap-icons.min.css">
</head>
<body>
<!-- 在body末尾引入所有Bootstrap的JS文件 -->
<script src="/static/bootstrap/js/bootstrap.bundle.min.js"></script>
</body>
3.2 交互式API文档的离线配置与访问
FastAPI最受欢迎的特性之一是能够根据代码自动生成基于Swagger UI的交互式API文档。默认情况下,Swagger UI页面需要从互联网CDN加载一些CSS和JavaScript文件,这在网络环境不佳时可能会导致加载失败。为了确保Swagger UI可以稳定可靠的在离线环境中被加载,需要将其配置为离线运行模式。
3.2.1 下载Swagger UI静态文件
首先,需要从官方仓库下载Swagger UI的发行包。访问https://github.com/swagger-api/swagger-ui/releases,选择一个最新的源代码压缩包(例如swagger-ui-5.28.0.zip)。
3.2.2 组织项目文件
解压下载的文件,会看到一个dist文件夹,其中包含了所有必需的静态资源。
(1)在项目根目录下,新建一个名为static的文件夹。
(2)在static文件夹内,再新建一个名为swagger-ui的文件夹。
(3)将dist文件夹中的所有内容复制到前面创建的swagger-ui文件夹中。
完成后的项目目录结构应如下所示:
bash
project_root/
├── venv/
├── static/
│ └── swagger-ui/
│ ├── swagger-ui.css
│ ├── swagger-ui-bundle.js
│ ├── swagger-ui-standalone-preset.js
│ ├── ... (其他文件)
└── main.py
3.2.3 修改main.py以加载本地文件
最后一步是修改应用程序代码,告知FastAPI框架停止从互联网CDN加载swagger-ui,转而使用项目本地static目录下的文件。
此过程需要对main.py文件进行修改,相关代码修改如下:
python
from fastapi import FastAPI
# 导入必要的模块
from fastapi.staticfiles import StaticFiles
from fastapi.openapi.docs import get_swagger_ui_html
# 创建FastAPI实例时,禁用默认的文档URL
app = FastAPI(docs_url=None, redoc_url=None)
# 创建自定义的路由来提供Swagger UI页面
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
# 调用函数生成HTML页面,并传入本地静态文件的URL
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
)
# 将URL路径"/static"与本地的"static"文件夹关联
app.mount("/static", StaticFiles(directory="static"), name="static")
# 应用原有的业务逻辑路由保持不变
四、开发审核模块
4.1 准备静态资源与模板
FastAPI服务需要增加静态文件挂载以支持CSS样式的访问,并实现/api/audit_render接口,该接口负责执行检索逻辑并返回渲染后的HTML。
具体的,在项目根目录下创建templates目录,然后在templates中创建audit_result.html文件。
代码清单:templates/audit_result.html
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>证件识别结果</title>
<!-- 为了能返回纯粹的页面给请求端,整个审核页面不能包含任何外部资源,如CSS、JS等,所有样式全部内联 -->
<style>
.section-title {
background-color: #7ca1e7;
padding: 10px 15px;
margin-bottom: 20px;
font-size: 1.1rem;
color: #fff;
}
.info-item { margin-bottom: 10px; }
.info-label {
font-weight: bold;
color: #495057;
background-color: #cde2fd;
padding: 6px 12px;
border-radius: 4px;
font-size: 1.15rem;
}
.info-item span:not(.info-label) {
background-color: #f0f0f0;
padding: 6px 12px;
border-radius: 4px;
font-size: 1.15rem;
margin-left: 8px;
color: #333;
}
.image-container {
position: relative;
margin-bottom: 30px;
width: 100%;
display: flex;
gap: 20px;
}
.license-image {
width: 100%;
height: 100%;
object-fit: contain;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.annotation-canvas {
position: absolute;
top: 0; left: 0;
pointer-events: none;
width: 100%; height: 100%;
}
.image-label {
text-align: center;
color: #6c757d;
font-size: 0.9em;
margin-top: 5px;
}
.image-wrapper {
position: relative;
width: 75%;
height: 400px;
margin-bottom: 15px;
background-color: #f0f0f0;
color: #000;
border-radius: 6px;
}
.annotation-info {
width: 25%;
background-color: #f8f9fa;
border-radius: 4px;
padding: 15px;
height: 400px;
overflow-y: auto;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.comparison-container {
width: 100%;
margin-bottom: 40px;
overflow: hidden;
}
.comparison-column {
width: 48%;
float: left;
margin: 0 1%;
}
.comparison-column-title {
background-color: #f0f0f0;
color: #000;
font-size: 1.05rem;
font-weight: bold;
margin-bottom: 15px;
text-align: center;
padding: 8px;
border-radius: 4px;
}
.annotation-list-title {
font-size: 18px;
font-weight: bold;
color: #000;
margin-bottom: 15px;
}
.annotation-item {
font-size: 14px;
color: #000;
margin-bottom: 6px;
line-height: 1.3;
padding: 4px 6px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 6px;
transition: background-color 0.2s;
position: relative;
padding-left: 25px;
}
.annotation-item.highlight {
background-color: #4a90e2;
color: #fff;
}
.annotation-number {
background-color: rgba(30, 144, 255, 1);
color: white;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
position: absolute;
left: 4px;
top: 50%;
transform: translateY(-50%);
}
.annotation-text {
flex-grow: 1;
line-height: 1.2;
}
.no-annotation {
color: #6c757d;
font-style: italic;
text-align: center;
margin-top: 20px;
padding: 15px;
background-color: #fff;
border-radius: 4px;
border: 1px dashed #dee2e6;
}
/* 错误提示页面的特定样式,居中显示 */
.error-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 60vh;
text-align: center;
}
.error-icon {
font-size: 4rem;
color: #dc3545;
margin-bottom: 20px;
}
.error-text {
font-size: 1.5rem;
color: #343a40;
margin-bottom: 10px;
}
.error-subtext {
color: #6c757d;
}
</style>
</head>
<body>
<div class="container mt-4 mb-4">
{% if error_message %}
<!-- ================= 错误状态显示区域 ================= -->
<div class="error-container">
<div class="error-icon">⚠️</div>
<div class="error-text">未找到样证</div>
<div class="alert alert-danger mt-3" role="alert" style="min-width: 50%;">
{{ error_message }}
</div>
<div class="error-subtext mt-2">
请检查上传的图像质量或确认该国家/地区是否已录入样证模板。
</div>
</div>
{% else %}
<!-- ================= 正常状态显示区域 ================= -->
<!-- 基本信息部分 -->
<div class="row mb-4">
<div class="col-12">
<h4 class="section-title">基本信息</h4>
<div class="row">
<div class="col-md-6">
<div class="info-item">
<span class="info-label">国家:</span>
<span>{{ country_name }}</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<span class="info-label">地区:</span>
<span>{{ region or '' }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 图片对比部分 -->
<div class="row mb-3">
<div class="col-12">
<h4 class="section-title">图片对比</h4>
</div>
</div>
<div class="comparison-container">
<!-- 采集图片列 -->
<div class="comparison-column">
<div class="comparison-column-title">采集图片</div>
<!-- 正面采集图片 -->
<div class="image-container">
<div class="image-wrapper">
<img src="data:image/jpeg;base64,{{ uploaded_front }}" class="license-image" alt="正面采集图">
<div class="image-label">正面照片</div>
</div>
</div>
<!-- 背面采集图片 -->
{% if uploaded_back %}
<div class="image-container">
<div class="image-wrapper">
<img src="data:image/jpeg;base64,{{ uploaded_back }}" class="license-image" alt="背面采集图">
<div class="image-label">背面照片</div>
</div>
</div>
{% endif %}
</div>
<!-- 样本图片列 -->
<div class="comparison-column">
<div class="comparison-column-title">样本图片</div>
<!-- 正面样本和标注 -->
<div class="image-container">
<div class="image-wrapper">
<img src="data:image/jpeg;base64,{{ sample_front }}" class="license-image" id="sampleFrontImage" alt="正面样本">
<canvas class="annotation-canvas" id="frontCanvas"></canvas>
<div class="image-label">正面照片</div>
</div>
<div class="annotation-info" id="frontAnnotationInfo">
<div class="annotation-list-title">正面标注信息</div>
<div id="frontAnnotationList">
<div class="no-annotation">鼠标悬停在图片上的标注框查看详细信息</div>
</div>
</div>
</div>
<!-- 背面样本和标注 -->
{% if sample_back %}
<div class="image-container">
<div class="image-wrapper">
<img src="data:image/jpeg;base64,{{ sample_back }}" class="license-image" id="sampleBackImage" alt="背面样本">
<canvas class="annotation-canvas" id="backCanvas"></canvas>
<div class="image-label">背面照片</div>
</div>
<div class="annotation-info" id="backAnnotationInfo">
<div class="annotation-list-title">背面标注信息</div>
<div id="backAnnotationList">
<div class="no-annotation">鼠标悬停在图片上的标注框查看详细信息</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %} <!-- 结束 if error_message -->
</div>
<!-- 仅在没有错误时才注入数据和执行脚本 -->
{% if not error_message %}
<script type="application/json" id="template-data">
{{ {'front': front_template, 'back': back_template}|tojson|safe }}
</script>
<script>
var templateData = JSON.parse(document.getElementById('template-data').textContent);
var frontTemplate = templateData.front;
var backTemplate = templateData.back;
var lastScrolledIndex = -1;
var scrollTimeout = null;
function drawAnnotations(image, canvas, template) {
if (!template || !template.shapes) return;
const ctx = canvas.getContext('2d');
const originalWidth = template.imageWidth;
const originalHeight = template.imageHeight;
const containerWidth = image.parentElement.clientWidth;
const containerHeight = image.parentElement.clientHeight;
let displayWidth, displayHeight;
const imageRatio = originalWidth / originalHeight;
const containerRatio = containerWidth / containerHeight;
if (imageRatio > containerRatio) {
displayWidth = containerWidth;
displayHeight = containerWidth / imageRatio;
} else {
displayHeight = containerHeight;
displayWidth = containerHeight * imageRatio;
}
const offsetX = (containerWidth - displayWidth) / 2;
const offsetY = (containerHeight - displayHeight) / 2;
const scaleX = displayWidth / originalWidth;
const scaleY = displayHeight / originalHeight;
canvas.width = containerWidth;
canvas.height = containerHeight;
ctx.lineWidth = 2;
template.shapes.forEach(shape => {
if (shape.shape_type === 'rectangle') {
const [[x1, y1], [x2, y2]] = shape.points;
const scaledX1 = x1 * scaleX + offsetX;
const scaledY1 = y1 * scaleY + offsetY;
const scaledWidth = (x2 - x1) * scaleX;
const scaledHeight = (y2 - y1) * scaleY;
ctx.strokeStyle = 'rgba(30, 144, 255, 0.8)';
ctx.fillStyle = 'rgba(30, 144, 255, 0.1)';
ctx.strokeRect(scaledX1, scaledY1, scaledWidth, scaledHeight);
ctx.fillRect(scaledX1, scaledY1, scaledWidth, scaledHeight);
const index = template.shapes.indexOf(shape) + 1;
ctx.fillStyle = 'rgba(30, 144, 255, 1)';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.beginPath();
ctx.arc(scaledX1 - 8, scaledY1 + scaledHeight/2, 8, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'white';
ctx.fillText(index.toString(), scaledX1 - 8, scaledY1 + scaledHeight/2);
}
});
return template.shapes.map(shape => {
if (shape.shape_type === 'rectangle') {
const [[x1, y1], [x2, y2]] = shape.points;
return {
x: x1 * scaleX + offsetX,
y: y1 * scaleY + offsetY,
width: (x2 - x1) * scaleX,
height: (y2 - y1) * scaleY,
index: template.shapes.indexOf(shape)
};
}
return null;
}).filter(Boolean);
}
function isPointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width &&
y >= rect.y && y <= rect.y + rect.height;
}
function displayAllAnnotations(template, side) {
const annotationList = document.getElementById(side === 'front' ? 'frontAnnotationList' : 'backAnnotationList');
if (!template || !template.shapes) {
annotationList.innerHTML = '<div class="no-annotation">无标注信息</div>';
return;
}
const annotationsHtml = template.shapes.map((shape, index) => {
return `
<div class="annotation-item">
<span class="annotation-number">${index + 1}</span>
<span class="annotation-text">${shape.label}</span>
</div>
`;
}).join('');
annotationList.innerHTML = annotationsHtml || '<div class="no-annotation">无标注信息</div>';
}
function handleMouseMove(e, annotations, canvas, side) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const annotationList = document.getElementById(side === 'front' ? 'frontAnnotationList' : 'backAnnotationList');
const items = annotationList.getElementsByClassName('annotation-item');
Array.from(items).forEach(item => item.classList.remove('highlight'));
for (const anno of annotations) {
if (isPointInRect(x, y, anno)) {
const index = anno.index;
if (items[index]) {
items[index].classList.add('highlight');
if (lastScrolledIndex !== index) {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const annotationInfo = document.getElementById(side === 'front' ? 'frontAnnotationInfo' : 'backAnnotationInfo');
const containerRect = annotationInfo.getBoundingClientRect();
const itemRect = items[index].getBoundingClientRect();
const scrollTop = annotationInfo.scrollTop;
const itemTop = itemRect.top - containerRect.top + scrollTop;
const targetScrollTop = itemTop - (annotationInfo.clientHeight / 2) + (itemRect.height / 2);
annotationInfo.scrollTo({top: targetScrollTop, behavior: 'smooth'});
lastScrolledIndex = index;
}, 100);
}
}
break;
}
}
}
window.onload = function() {
var frontImage = document.getElementById('sampleFrontImage');
var frontCanvas = document.getElementById('frontCanvas');
var frontAnnotations = [];
if (frontImage && frontTemplate) {
if (frontImage.complete) {
frontAnnotations = drawAnnotations(frontImage, frontCanvas, frontTemplate) || [];
displayAllAnnotations(frontTemplate, 'front');
} else {
frontImage.onload = function() {
frontAnnotations = drawAnnotations(frontImage, frontCanvas, frontTemplate) || [];
displayAllAnnotations(frontTemplate, 'front');
};
}
frontCanvas.closest('.image-wrapper').addEventListener('mousemove', function(e) {
handleMouseMove(e, frontAnnotations, frontCanvas, 'front');
});
}
var backImage = document.getElementById('sampleBackImage');
var backCanvas = document.getElementById('backCanvas');
var backAnnotations = [];
if (backImage && backTemplate) {
if (backImage.complete) {
backAnnotations = drawAnnotations(backImage, backCanvas, backTemplate) || [];
displayAllAnnotations(backTemplate, 'back');
} else {
backImage.onload = function() {
backAnnotations = drawAnnotations(backImage, backCanvas, backTemplate) || [];
displayAllAnnotations(backTemplate, 'back');
};
}
backCanvas.closest('.image-wrapper').addEventListener('mousemove', function(e) {
handleMouseMove(e, backAnnotations, backCanvas, 'back');
});
}
};
</script>
{% endif %}
</body>
</html>
4.2 FastAPI主程序实现 (main.py)
更新main.py,配置静态文件路径、模板引擎,并实现/api/audit_render接口。该接口核心逻辑与recognize_document相似,均基于余弦相似度检索,但返回的是HTML页面而非JSON数据。
代码清单:main.py
python
import base64
import json
import pickle
import numpy as np
from typing import Optional
from fastapi import FastAPI, Request, Form, Depends, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, select
from database import get_session
from feature_extractor import ImageFeatureExtractor
from models import CertificateTemplate
from models import Country
# --- 辅助函数 ---
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
norm_vec1 = vec1 / np.linalg.norm(vec1)
norm_vec2 = vec2 / np.linalg.norm(vec2)
similarity = np.dot(norm_vec1, norm_vec2)
return float(similarity)
# --- 创建FastAPI应用实例 ---
app = FastAPI(
title="全球证件智能识别系统 - 后端服务",
description="用于接收多光谱证照图像并返回识别结果的API",
version="1.0.0",
docs_url=None,
redoc_url=None
)
# 创建自定义的路由来提供Swagger UI页面
@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html():
# 调用函数生成HTML页面,并传入本地静态文件的URL
return get_swagger_ui_html(
openapi_url=app.openapi_url,
title=app.title + " - Swagger UI",
swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
swagger_css_url="/static/swagger-ui/swagger-ui.css",
)
# 1. 挂载静态文件和模板
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 2. 实例化依赖
extractor = ImageFeatureExtractor()
# --- 请求模型 ---
class AuditRequest(BaseModel):
country_code: str = Field(..., description="国家代码")
image_front: str = Field(..., description="正面白光Base64")
image_back: str = Field(..., description="反面白光Base64")
@app.post("/api/audit_render", response_class=HTMLResponse,summary="可视化审核接口")
async def audit_render(
request: Request,
audit_req: AuditRequest,
session: Session = Depends(get_session)
):
"""
可视化审核接口:接收图像和国家代码,渲染审核页面。
"""
try:
# 1. 特征提取
front_bytes = base64.b64decode(audit_req.image_front)
back_bytes = base64.b64decode(audit_req.image_back)
query_feature_front = pickle.loads(extractor.extract_features(front_bytes))
query_feature_back = pickle.loads(extractor.extract_features(back_bytes))
# 2. 数据库检索模板
statement = select(CertificateTemplate).where(
CertificateTemplate.country.has(code=audit_req.country_code)
)
templates_db = session.exec(statement).all()
# 如果未找到模板
if not templates_db:
# 尝试查询国家名称,以便给出更友好的提示(例如 "美国(840)暂无模板")
country_stmt = select(Country).where(Country.code == audit_req.country_code)
country = session.exec(country_stmt).first()
country_display = country.name if country else audit_req.country_code
return templates.TemplateResponse("audit_result.html", {
"request": request,
"error_message": f"未在数据库中找到国家【{country_display}】的任何样证模板。"
})
# 3. 相似度比对
best_match_template = None
max_similarity = -1.0
for template in templates_db:
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)
avg_similarity = (sim_front + sim_back) / 2
if avg_similarity > max_similarity:
max_similarity = avg_similarity
best_match_template = template
# 4. 准备模板数据
if not best_match_template:
return templates.TemplateResponse("audit_result.html", {
"request": request,
"error_message": "在模板库中进行特征比对时发生未知错误,未能定位最佳模板。"
})
# 解析JSON字符串为字典对象
front_json_data = json.loads(best_match_template.front_layout_schema) if best_match_template.front_layout_schema else None
back_json_data = json.loads(best_match_template.back_layout_schema) if best_match_template.back_layout_schema else None
# 准备样证图像Base64
sample_front_b64 = base64.b64encode(best_match_template.image_front_white).decode('utf-8')
sample_back_b64 = base64.b64encode(best_match_template.image_back_white).decode('utf-8') if best_match_template.image_back_white else ""
# 获取国家名称
country_name = best_match_template.country.name
# 5. 渲染HTML (成功情况)
return templates.TemplateResponse("audit_result.html", {
"request": request,
# 基础信息
"country_name": country_name,
"region": best_match_template.region,
# 图像数据
"uploaded_front": audit_req.image_front,
"uploaded_back": audit_req.image_back,
"sample_front": sample_front_b64,
"sample_back": sample_back_b64,
# 标注数据
"front_template": front_json_data,
"back_template": back_json_data,
# 确保 error_message 为空
"error_message": None
})
except Exception as e:
print(f"审核页面渲染失败: {e}")
# 发生异常时也返回同一个页面,带上异常信息
return templates.TemplateResponse("audit_result.html", {
"request": request,
"error_message": f"系统内部处理错误: {str(e)}"
})
# ... (保留其他API接口)
4.3 客户端调用验证脚本开发
为验证服务端 /api/audit_render 接口的响应逻辑及 HTML 模板的渲染效果,需开发一个独立的 Python 脚本 client_check.py。该脚本模拟 Qt 客户端的行为,读取本地测试图像,构建 API 请求,并将服务端返回的 HTML 流保存为本地文件,以便通过浏览器查看最终的可视化效果。
4.3.1 脚本实现逻辑
脚本主要执行以下步骤:
- 图像编码:读取指定的测试用证件图像(法国驾驶证正反面),将其转换为 Base64 编码字符串。
- 载荷构建 :按照
AuditRequest数据模型构建 JSON 请求体,设定国家代码为 "250"(法国ISO 代码)。 - 请求发送:向服务端发送 HTTP POST 请求。
- 结果保存 :接收服务端返回的 HTML 响应内容,并将其写入本地
audit_output.html文件。
4.3.2 代码清单
在项目根目录下新建 client_check.py 文件,完整代码如下:
代码清单:client_check.py
python
import os
import sys
import base64
import requests
def encode_image_to_base64(image_path: str) -> str:
"""
读取本地图像文件并转换为Base64字符串。
Args:
image_path (str): 图像文件的相对或绝对路径。
Returns:
str: 图像的Base64编码字符串。
Raises:
FileNotFoundError: 当指定路径的文件不存在时抛出。
"""
if not os.path.exists(image_path):
print(f"[错误] 文件未找到: {image_path}")
sys.exit(1)
with open(image_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
return encoded_string
def main():
# --- 配置参数 ---
# 服务端接口地址 (根据实际部署端口调整,此处假设为8001)
api_url = "http://127.0.0.1:8001/api/audit_render"
# 测试数据路径 (需确保本地test目录下存在相应图片)
# 假设测试样本为法国证件
front_img_path = os.path.join("test", "france_front.jpg")
back_img_path = os.path.join("test", "france_back.jpg")
# 法国 ISO 3166-1 数字代码
country_code = "250"
# --- 1. 数据准备 ---
print("正在读取并编码测试图像...")
try:
front_b64 = encode_image_to_base64(front_img_path)
back_b64 = encode_image_to_base64(back_img_path)
except SystemExit:
return
# --- 2. 构建请求载荷 ---
payload = {
"country_code": country_code,
"image_front": front_b64,
"image_back": back_b64
}
# --- 3. 发送请求 ---
print(f"正在向服务端 {api_url} 发送审核渲染请求...")
try:
# 设置超时时间为30秒,避免长时间阻塞
response = requests.post(api_url, json=payload, timeout=30)
# --- 4. 处理响应 ---
if response.status_code == 200:
# 检查返回内容是否为HTML
content_type = response.headers.get('Content-Type', '')
if 'text/html' in content_type:
output_filename = "audit_output.html"
# 将返回的HTML内容写入本地文件
with open(output_filename, "w", encoding="utf-8") as f:
f.write(response.text)
print(f"[成功] 服务端渲染完成。")
print(f"[提示] 请使用浏览器打开当前目录下的 '{output_filename}' 查看结果。")
else:
print(f"[警告] 服务端返回了非HTML内容,Content-Type: {content_type}")
print(f"响应内容: {response.text[:200]}...")
else:
print(f"[失败] 请求失败,状态码: {response.status_code}")
print(f"错误详情: {response.text}")
except requests.exceptions.ConnectionError:
print("[错误] 无法连接到服务端,请确认后端服务已启动。")
except requests.exceptions.Timeout:
print("[错误] 请求超时,服务端处理时间过长。")
except Exception as e:
print(f"[错误] 发生未预期的异常: {str(e)}")
if __name__ == "__main__":
main()
五、小结
本篇博客详细阐述了为全球证件智能识别系统后端服务新增"可视化审核接口"的开发过程。该功能旨在通过服务端渲染HTML的方式,为用户提供一个直观、交互式的证件审查界面。
核心实现内容包括:
- 数据模型与数据库扩展 :在
CertificateTemplate模型中增加了front_layout_schema和back_layout_schema字段,用于存储LabelMe标注生成的JSON布局信息。数据库迁移脚本已更新,以支持这些新字段的存储。 - 后端API开发 :新增
/api/audit_render接口,该接口接收客户端上传的证件图像和国家代码,首先进行模板匹配,检索出最相似的标准样本。随后,利用匹配到的模板的JSON布局数据,动态生成一个包含交互式标注信息的HTML页面。 - 前端模板集成 :创建了
audit_result.html模板,并引入了Bootstrap 5框架和自定义CSS样式。该模板能够根据后端返回的数据,在图像上绘制标注框,并实现鼠标悬停时显示对应字段名称的交互效果。同时,对错误处理逻辑进行了优化,确保在未找到模板或发生异常时,能返回统一的、包含错误信息的HTML提示页。
通过这一系列的开发与集成,系统在满足基础识别需求的基础上,进一步提升了用户在审核环节的体验和效率。可视化审核界面的引入,使得操作人员能够更直观地核对证件细节,并为后续可能的数据标注与模型迭代提供了便利。