目录
- [一、 任务概述](#一、 任务概述)
- [二、 前端逻辑设计与重构](#二、 前端逻辑设计与重构)
-
- [2.1 纯黑图像的动态生成](#2.1 纯黑图像的动态生成)
- [2.2 界面布局升级](#2.2 界面布局升级)
- [三、 核心代码实现](#三、 核心代码实现)
- [四、 操作说明与成果验证](#四、 操作说明与成果验证)
- [五、 总结](#五、 总结)
一、 任务概述
在全球证件智能识别系统的实际落地过程中,不仅需要软件团队的算法支撑,还需要与多家硬件设备厂家的多光谱采集仪进行联调对接。为了让各硬件厂家能够独立、高效地验证其设备采集的图像是否能顺利通过我们的后端算法(包括版式检索、防伪特征检测和OCR大模型提取),我们需要提供一个贴近真实的测试沙箱。
在之前的开发中,我们构建了一个基于Web的简易测试控制台(/test),但该页面仅支持上传"正面白光"和"反面白光"两张图像,紫外图像和红外图像均使用了白光图进行占位处理。
本篇博客的任务是扩展该Web测试控制台:
- 增加紫外图像上传功能:新增"正面紫外"和"反面紫外"图像的上传入口(选填)。
- 纯黑图像智能补全 :若测试人员未上传紫外图像,系统需自动在前端生成一张与对应白光图像尺寸完全一致的纯黑图像作为默认值,以确保后台算法(尤其是国内证件的YOLO紫外防伪检测)不会因接收到白光图占位而产生严重误判。
- 全景图像对比展示:调整识别结果区域的布局,将"用户上传的4张图像"与"后台匹配返回的4张样证图像"进行左右同屏对比展示,完美模拟真实客户端的视觉效果。
通过这一升级,设备厂家只需将采集的测试图片打包,在浏览器中即可完成全面的闭环联调。
二、 前端逻辑设计与重构
为了实现上述功能,我们无需修改 FastAPI 后端接口(/api/recognize 已经原生支持接收多光谱图像并返回处理后的图像),所有工作均集中在前端模板文件 templates/test_recognize.html 的重构上。
2.1 纯黑图像的动态生成
为了防止向后台传入 1x1 像素等极端尺寸的图片导致 OpenCV 缩放或 YOLO 检测抛出异常,我们将使用 HTML5 的 Canvas API。当用户没有上传紫外图时,通过读取白光图的原始宽高,动态绘制一张尺寸完全一致的黑色背景图片,并导出为 Base64 字符串发给后端。
2.2 界面布局升级
- 上传区 :采用一行四列(
col-md-3)的紧凑布局,分别为:正面白光(必填)、正面紫外(选填)、反面白光(必填)、反面紫外(选填)。 - 结果区:引入 Bootstrap 的边框分割线,将结果区一分为二。左半部分展示上传请求的 4 张图像,右半部分展示后端返回的 4 张模板图像。
三、 核心代码实现
请将原本的 templates/test_recognize.html 内容替换为以下完整代码。注意查看代码中针对本次新增功能添加的详细注释。
代码清单:templates/test_recognize.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全球证件智能识别测试台</title>
<!-- 引入本地 Bootstrap 5 样式 -->
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap-icons.min.css">
<style>
.img-preview {
height: 150px; object-fit: contain; background-color: #f8f9fa;
border: 2px dashed #dee2e6; display: flex; align-items: center;
justify-content: center; cursor: pointer; transition: all 0.3s ease;
}
.img-preview:hover { background-color: #e9ecef; border-color: #adb5bd; }
.img-preview img { max-width: 100%; max-height: 100%; }
.result-img { width: 100%; height: 160px; object-fit: contain; border: 1px solid #ced4da; padding: 4px; }
#loadingOverlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.8); z-index: 9999;
display: none; flex-direction: column; align-items: center; justify-content: center;
}
.sample-img-container { height: 180px; background-color: #e9ecef; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.sample-img-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
#logTextArea {
height: 600px; font-family: 'Consolas', 'Courier New', monospace;
font-size: 14px; background-color: #1e1e1e; color: #d4d4d4; resize: none;
}
</style>
</head>
<body class="bg-light">
<!-- 加载遮罩 -->
<div id="loadingOverlay">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden"></span>
</div>
<div class="mt-2 fw-bold text-primary" id="loadingText">处理中,请稍候...</div>
</div>
<div class="container py-4">
<div class="text-center mb-4">
<h2 class="fw-bold"><i class="bi bi-shield-check text-primary"></i> 智能识别设备对接测试台</h2>
</div>
<!-- 顶部导航 Tabs -->
<ul class="nav nav-tabs mb-4" id="mainTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active fw-bold" id="test-tab" data-bs-toggle="tab" data-bs-target="#test-pane" type="button" role="tab">
<i class="bi bi-cpu"></i> 设备对接测试
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-bold" id="samples-tab" data-bs-toggle="tab" data-bs-target="#samples-pane" type="button" role="tab" onclick="loadSamples(1)">
<i class="bi bi-images"></i> 样证管理
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-bold" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs-pane" type="button" role="tab" onclick="loadLogs()">
<i class="bi bi-terminal"></i> 服务日志
</button>
</li>
</ul>
<!-- 选项卡内容区 -->
<div class="tab-content" id="mainTabContent">
<!-- ==================== Tab 1: 接口测试 ==================== -->
<div class="tab-pane fade show active" id="test-pane" role="tabpanel">
<div class="card shadow-sm mb-4">
<div class="card-body">
<!-- 图像上传区域:扩展为4个上传口 -->
<div class="row g-3">
<div class="col-md-3">
<label class="form-label fw-bold text-primary">1. 正面白光 <span class="text-danger">*</span></label>
<div class="img-preview rounded" onclick="document.getElementById('fileFrontWhite').click()">
<img id="previewFrontWhite" src="" style="display:none;">
<span id="textFrontWhite" class="text-muted small"><i class="bi bi-plus-lg"></i> 点击上传</span>
</div>
<input type="file" id="fileFrontWhite" class="d-none" accept="image/*" onchange="previewImage(this, 'previewFrontWhite', 'textFrontWhite')">
</div>
<div class="col-md-3">
<label class="form-label fw-bold text-secondary">2. 正面紫外 (选填)</label>
<div class="img-preview rounded" onclick="document.getElementById('fileFrontUV').click()">
<img id="previewFrontUV" src="" style="display:none;">
<span id="textFrontUV" class="text-muted small"><i class="bi bi-plus-lg"></i> 点击上传</span>
</div>
<input type="file" id="fileFrontUV" class="d-none" accept="image/*" onchange="previewImage(this, 'previewFrontUV', 'textFrontUV')">
</div>
<div class="col-md-3">
<label class="form-label fw-bold text-primary">3. 反面白光 <span class="text-danger">*</span></label>
<div class="img-preview rounded" onclick="document.getElementById('fileBackWhite').click()">
<img id="previewBackWhite" src="" style="display:none;">
<span id="textBackWhite" class="text-muted small"><i class="bi bi-plus-lg"></i> 点击上传</span>
</div>
<input type="file" id="fileBackWhite" class="d-none" accept="image/*" onchange="previewImage(this, 'previewBackWhite', 'textBackWhite')">
</div>
<div class="col-md-3">
<label class="form-label fw-bold text-secondary">4. 反面紫外 (选填)</label>
<div class="img-preview rounded" onclick="document.getElementById('fileBackUV').click()">
<img id="previewBackUV" src="" style="display:none;">
<span id="textBackUV" class="text-muted small"><i class="bi bi-plus-lg"></i> 点击上传</span>
</div>
<input type="file" id="fileBackUV" class="d-none" accept="image/*" onchange="previewImage(this, 'previewBackUV', 'textBackUV')">
</div>
</div>
<div class="row mt-4 align-items-end">
<div class="col-md-4">
<label class="form-label">国家/地区代码</label>
<input type="text" id="countryCode" class="form-control" value="840">
</div>
<div class="col-md-4">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="enableLLM" checked>
<label class="form-check-label" for="enableLLM">启用大模型 OCR</label>
</div>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-primary w-100 fw-bold" onclick="startRecognition()">
<i class="bi bi-lightning-charge"></i> 发起识别请求
</button>
</div>
</div>
</div>
</div>
<!-- 测试结果展示区 -->
<div id="resultSection" class="d-none">
<!-- 文本结果 -->
<div class="card shadow-sm mb-4 border-primary">
<div class="card-header bg-primary text-white fw-bold">智能识别结论</div>
<div class="card-body">
<div class="alert alert-secondary mb-0" style="white-space: pre-wrap;" id="resultMessage"></div>
</div>
</div>
<!-- 对比图像结果 -->
<div class="card shadow-sm">
<div class="card-header bg-white fw-bold"><i class="bi bi-layout-split"></i> 多光谱图像全景对比</div>
<div class="card-body">
<div class="row">
<!-- 左侧:上传采集图像 -->
<div class="col-md-6 border-end">
<h6 class="text-center fw-bold mb-3 text-primary">当前设备采集图像</h6>
<div class="row text-center g-2">
<div class="col-6"><span class="small text-muted">正面 - 白光</span><img id="reqFrontWhite" class="result-img rounded bg-light"></div>
<div class="col-6"><span class="small text-muted">正面 - 紫外</span><img id="reqFrontUV" class="result-img rounded bg-dark"></div>
<div class="col-6 mt-3"><span class="small text-muted">反面 - 白光</span><img id="reqBackWhite" class="result-img rounded bg-light"></div>
<div class="col-6 mt-3"><span class="small text-muted">反面 - 紫外</span><img id="reqBackUV" class="result-img rounded bg-dark"></div>
</div>
</div>
<!-- 右侧:后台样证图像 -->
<div class="col-md-6">
<h6 class="text-center fw-bold mb-3 text-success">匹配的标准样证图像</h6>
<div class="row text-center g-2">
<div class="col-6"><span class="small text-muted">正面 - 白光</span><img id="resFrontWhite" class="result-img rounded bg-light"></div>
<div class="col-6"><span class="small text-muted">正面 - 紫外 (标记防伪)</span><img id="resFrontUV" class="result-img rounded bg-dark"></div>
<div class="col-6 mt-3"><span class="small text-muted">反面 - 白光</span><img id="resBackWhite" class="result-img rounded bg-light"></div>
<div class="col-6 mt-3"><span class="small text-muted">反面 - 紫外 (标记防伪)</span><img id="resBackUV" class="result-img rounded bg-dark"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== Tab 2 & 3: 样证管理与日志 (保持与第22篇一致) ==================== -->
<!-- 为篇幅简洁,此处省略 Tabs 2 & 3 的HTML,实际使用请保留上一篇中该部分的代码 -->
<div class="tab-pane fade" id="samples-pane" role="tabpanel">...</div>
<div class="tab-pane fade" id="logs-pane" role="tabpanel">...</div>
</div>
</div>
<!-- 引入本地 Bootstrap JS -->
<script src="/static/bootstrap/js/bootstrap.bundle.min.js"></script>
<script>
// ================= 工具函数 =================
function showLoading(text="处理中,请稍候...") {
document.getElementById('loadingText').innerText = text;
document.getElementById('loadingOverlay').style.display = 'flex';
}
function hideLoading() {
document.getElementById('loadingOverlay').style.display = 'none';
}
// 图片本地预览
function previewImage(input, imgId, textId) {
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById(imgId).src = e.target.result;
document.getElementById(imgId).style.display = 'block';
document.getElementById(textId).style.display = 'none';
}
reader.readAsDataURL(input.files[0]);
}
}
// 提取纯 Base64 数据
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = error => reject(error);
});
}
// 【新增】根据参考图片动态生成同尺寸的纯黑背景 Base64 图像
function generateBlackImageBase64(referenceFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#000000'; // 纯黑填充
ctx.fillRect(0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', 0.9).split(',')[1]);
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(referenceFile);
});
}
// ================= 核心:设备对接识别逻辑 =================
async function startRecognition() {
const fileFrontWhite = document.getElementById('fileFrontWhite').files[0];
const fileFrontUV = document.getElementById('fileFrontUV').files[0];
const fileBackWhite = document.getElementById('fileBackWhite').files[0];
const fileBackUV = document.getElementById('fileBackUV').files[0];
const countryCode = document.getElementById('countryCode').value;
const enableLLM = document.getElementById('enableLLM').checked;
// 白光图像为核心识别依据,必须上传
if (!fileFrontWhite || !fileBackWhite) {
return alert("请上传必须的正面和反面白光图像!");
}
if (!countryCode) return alert("请输入国家/地区代码!");
showLoading("算法检索与大模型推理中,请稍候...");
document.getElementById('resultSection').classList.add('d-none');
try {
// 解析白光图像
const b64FrontWhite = await fileToBase64(fileFrontWhite);
const b64BackWhite = await fileToBase64(fileBackWhite);
// 解析或智能补全紫外图像
let b64FrontUV = "";
if (fileFrontUV) {
b64FrontUV = await fileToBase64(fileFrontUV);
} else {
b64FrontUV = await generateBlackImageBase64(fileFrontWhite);
}
let b64BackUV = "";
if (fileBackUV) {
b64BackUV = await fileToBase64(fileBackUV);
} else {
b64BackUV = await generateBlackImageBase64(fileBackWhite);
}
// 构造请求载荷,红外暂无需求,以白光占位保障大模型拼图不出错
const payload = {
country_code: countryCode,
enable_llm: enableLLM,
image_front_white: b64FrontWhite,
image_front_uv: b64FrontUV,
image_front_ir: b64FrontWhite, // 红外继续用白光占位
image_back_white: b64BackWhite,
image_back_uv: b64BackUV,
image_back_ir: b64BackWhite // 红外继续用白光占位
};
const response = await fetch('/api/recognize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
hideLoading();
if (data.code === 1) {
document.getElementById('resultSection').classList.remove('d-none');
// 1. 渲染文字结果
document.getElementById('resultMessage').textContent = data.message;
// 2. 渲染左侧:上传的采集图
document.getElementById('reqFrontWhite').src = `data:image/jpeg;base64,${b64FrontWhite}`;
document.getElementById('reqFrontUV').src = `data:image/jpeg;base64,${b64FrontUV}`;
document.getElementById('reqBackWhite').src = `data:image/jpeg;base64,${b64BackWhite}`;
document.getElementById('reqBackUV').src = `data:image/jpeg;base64,${b64BackUV}`;
// 3. 渲染右侧:后台返回的样证及检测图
document.getElementById('resFrontWhite').src = `data:image/jpeg;base64,${data.result_front_white}`;
document.getElementById('resFrontUV').src = `data:image/jpeg;base64,${data.result_front_uv}`;
document.getElementById('resBackWhite').src = `data:image/jpeg;base64,${data.result_back_white}`;
document.getElementById('resBackUV').src = `data:image/jpeg;base64,${data.result_back_uv}`;
} else {
alert("识别失败: " + data.message);
}
} catch (error) {
hideLoading();
alert("请求发生异常: " + error);
}
}
// ================= Tabs 2 & 3 历史代码逻辑 (同第22篇) =================
// ...
</script>
</body>
</html>
四、 操作说明与成果验证
更新 templates/test_recognize.html 文件后,直接刷新浏览器(无需重启后端服务)。
设备厂家对接测试操作步骤:
- 硬件设备厂家可将由他们采集仪拍摄的一组照片(白光和紫外)拷贝到本机。
- 访问
http://<服务器IP>:8001/test。 - 点击上传对应的图片。
- 场景A:仅上传白光图片测试版式检索和OCR。提交时,页面会自动用 Canvas 绘制同比例的黑图补齐参数。
- 场景B:上传白光+紫外图片测试国内证件防伪。
- 输入对应的国家代码,点击 发起识别请求。
验证结果:
- 识别完成后,你将在屏幕下半部分看到一个精美的双栏对比视图。左侧严丝合缝地重现了你刚刚上传的设备采集数据(即使是动态生成的黑图也会展示),右侧则是系统匹配到的模板图与 YOLO 检测标注结果。
- 这种一目了然的设计,能让硬件工程师迅速研判设备采集图像的色差、反光和畸变是否影响了算法发挥,极大提高了联调沟通的效率。
五、 总结
本篇博客是对整个服务体系外围支撑能力的一次细节打磨。通过在不增加后端负担的前提下,巧妙利用 HTML5 Canvas 实现了纯黑图像动态占位,不但满足了接口的严格校验,更防止了防伪算法的异常崩溃。与此同时,可视化呈现方式从"单侧输出"走向"全景双栏对比",标志着我们的系统从"自测用"向"平台化、标准化输出"迈进了一大步。