286 lines
12 KiB
HTML
286 lines
12 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>尿检试纸识别 — 纯 JS 版</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: #f0f2f5; color: #333; min-height: 100vh; }
|
||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||
h1 { font-size: 24px; margin-bottom: 8px; color: #1a1a2e; }
|
||
.subtitle { font-size: 13px; color: #999; margin-bottom: 20px; }
|
||
.card { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 20px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
.card h2 { font-size: 16px; color: #666; margin-bottom: 16px; }
|
||
|
||
.upload-zone { border: 2px dashed #ccc; border-radius: 12px; padding: 40px;
|
||
text-align: center; cursor: pointer; transition: all 0.2s; }
|
||
.upload-zone:hover, .upload-zone.dragover { border-color: #4a90d9; background: #f0f7ff; }
|
||
.upload-zone input { display: none; }
|
||
.upload-zone .icon { font-size: 48px; margin-bottom: 12px; }
|
||
.upload-zone .text { font-size: 14px; color: #999; }
|
||
.upload-zone .hint { font-size: 12px; color: #bbb; margin-top: 8px; }
|
||
|
||
.sample-bar { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; align-items: center; }
|
||
.sample-label { font-size: 12px; color: #999; }
|
||
.sample-btn { padding: 6px 16px; border: 1px solid #ddd; border-radius: 20px;
|
||
background: #fff; cursor: pointer; font-size: 12px; transition: all 0.2s; }
|
||
.sample-btn:hover { border-color: #4a90d9; color: #4a90d9; }
|
||
.sample-btn:active { background: #f0f7ff; }
|
||
|
||
.loading { display: none; align-items: center; gap: 8px; margin-top: 12px; }
|
||
.loading.active { display: flex; }
|
||
.spinner { width: 20px; height: 20px; border: 3px solid #e0e0e0;
|
||
border-top-color: #4a90d9; border-radius: 50%; animation: spin 0.6s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.status-bar { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px;
|
||
font-size: 14px; display: none; }
|
||
.status-bar.success { display: block; background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
|
||
.status-bar.error { display: block; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
|
||
|
||
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||
gap: 10px; margin-bottom: 20px; }
|
||
.info-item { background: #f8f9fa; border-radius: 8px; padding: 12px; text-align: center; }
|
||
.info-item .label { font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.info-item .value { font-size: 18px; font-weight: 700; margin-top: 4px; }
|
||
.info-item .value.ok { color: #27ae60; }
|
||
.info-item .value.warn { color: #e67e22; }
|
||
|
||
.result-section { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||
@media (max-width: 768px) { .result-section { grid-template-columns: 1fr; } }
|
||
.image-box { background: #f8f9fa; border-radius: 8px; overflow: hidden; border: 1px solid #eee; }
|
||
.image-box img, .image-box canvas { width: 100%; height: auto; display: block; }
|
||
.image-box .label { padding: 8px 12px; font-size: 12px; color: #666;
|
||
background: #f5f5f5; border-bottom: 1px solid #eee; }
|
||
|
||
.colors-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.colors-table th { background: #f8f9fa; padding: 8px 10px; text-align: left;
|
||
font-weight: 600; font-size: 11px; color: #666; border-bottom: 2px solid #eee; }
|
||
.colors-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; font-variant-numeric: tabular-nums; }
|
||
.colors-table tr:hover { background: #f8f9fa; }
|
||
.color-dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%;
|
||
vertical-align: middle; margin-right: 6px; border: 1px solid rgba(0,0,0,0.1); }
|
||
|
||
.json-box { background: #1a1a2e; border-radius: 8px; padding: 16px; margin-top: 12px; }
|
||
.json-box pre { color: #e8e8e8; font-size: 12px; line-height: 1.5;
|
||
max-height: 250px; overflow: auto; white-space: pre-wrap; }
|
||
.json-box .label { color: #999; font-size: 11px; margin-bottom: 8px; text-transform: uppercase; }
|
||
|
||
.footer { text-align: center; color: #bbb; font-size: 12px; padding: 20px 0; }
|
||
.footer a { color: #4a90d9; text-decoration: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🧪 尿检试纸识别</h1>
|
||
<div class="subtitle">纯 JavaScript 实现 · 无外部依赖 · 浏览器端运行</div>
|
||
|
||
<div class="card">
|
||
<div class="upload-zone" id="uploadZone">
|
||
<div class="icon">📸</div>
|
||
<div class="text">点击或拖拽图片到此处</div>
|
||
<div class="hint">支持 JPG / PNG / WebP</div>
|
||
<input type="file" accept="image/*" id="fileInput">
|
||
</div>
|
||
<div class="sample-bar">
|
||
<span class="sample-label">试试示例:</span>
|
||
<button class="sample-btn" data-sample="1">Sample 1 (S型)</button>
|
||
<button class="sample-btn" data-sample="2">Sample 2 (L型)</button>
|
||
</div>
|
||
<div class="loading" id="loading">
|
||
<div class="spinner"></div>
|
||
<span>识别中...</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar" id="statusBar"></div>
|
||
|
||
<div class="card" id="resultCard" style="display:none">
|
||
<h2>识别结果</h2>
|
||
<div class="info-grid" id="infoGrid"></div>
|
||
|
||
<div class="result-section">
|
||
<div class="image-box">
|
||
<div class="label">原图</div>
|
||
<img id="originalImg" src="" alt="原图">
|
||
</div>
|
||
<div class="image-box">
|
||
<div class="label">标注图</div>
|
||
<canvas id="annotatedCanvas" style="width:100%;height:auto"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 style="margin-bottom:12px; font-size:16px; color:#666;">色块颜色数据</h2>
|
||
<div style="overflow-x:auto">
|
||
<table class="colors-table" id="colorsTable">
|
||
<thead>
|
||
<tr><th>#</th><th>色样</th><th>R</th><th>G</th><th>B</th><th>H</th><th>S</th><th>V</th><th>L</th><th>a</th><th>b</th></tr>
|
||
</thead>
|
||
<tbody id="colorsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="json-box">
|
||
<div class="label">原始数据 (JSON)</div>
|
||
<pre id="jsonOutput"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer">
|
||
PaperRecognizer · 基于 HarmonyOS 原版 C++ 逻辑移植 · 开源实现
|
||
</div>
|
||
</div>
|
||
|
||
<script src="paper-recognizer.js"></script>
|
||
<script>
|
||
(function() {
|
||
'use strict';
|
||
|
||
const fileInput = document.getElementById('fileInput');
|
||
const uploadZone = document.getElementById('uploadZone');
|
||
const loading = document.getElementById('loading');
|
||
const statusBar = document.getElementById('statusBar');
|
||
const resultCard = document.getElementById('resultCard');
|
||
const infoGrid = document.getElementById('infoGrid');
|
||
const originalImg = document.getElementById('originalImg');
|
||
const annotatedCanvas = document.getElementById('annotatedCanvas');
|
||
const colorsBody = document.getElementById('colorsBody');
|
||
const jsonOutput = document.getElementById('jsonOutput');
|
||
|
||
const { PaperRecognizer, PST_NAMES } = window;
|
||
|
||
// Events
|
||
uploadZone.addEventListener('click', () => fileInput.click());
|
||
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
||
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
||
uploadZone.addEventListener('drop', e => {
|
||
e.preventDefault(); uploadZone.classList.remove('dragover');
|
||
if (e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]);
|
||
});
|
||
fileInput.addEventListener('change', () => {
|
||
if (fileInput.files.length) processFile(fileInput.files[0]);
|
||
});
|
||
|
||
document.querySelectorAll('.sample-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => loadSample(btn.dataset.sample));
|
||
});
|
||
|
||
async function loadSample(n) {
|
||
loading.classList.add('active');
|
||
hideStatus();
|
||
resultCard.style.display = 'none';
|
||
try {
|
||
const resp = await fetch(`samples/sample${n}.jpg`);
|
||
if (!resp.ok) throw new Error(`示例加载失败 (HTTP ${resp.status})`);
|
||
const blob = await resp.blob();
|
||
await processBlob(blob, `Sample ${n}`);
|
||
} catch (e) {
|
||
showError(`加载失败: ${e.message}`);
|
||
} finally {
|
||
loading.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
function processFile(file) {
|
||
if (!file) return;
|
||
loading.classList.add('active');
|
||
hideStatus();
|
||
resultCard.style.display = 'none';
|
||
processBlob(file, file.name).finally(() => loading.classList.remove('active'));
|
||
}
|
||
|
||
function hideStatus() { statusBar.className = 'status-bar'; }
|
||
function showError(msg) { statusBar.className = 'status-bar error'; statusBar.textContent = msg; }
|
||
function showSuccess(msg) { statusBar.className = 'status-bar success'; statusBar.textContent = msg; }
|
||
|
||
async function processBlob(blob, name) {
|
||
try {
|
||
const bitmap = await createImageBitmap(blob);
|
||
const w = bitmap.width, h = bitmap.height;
|
||
if (w * h > 4000000) {
|
||
showError(`图片过大 (${w}×${h}),建议缩小后重试`);
|
||
bitmap.close();
|
||
return;
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(bitmap, 0, 0);
|
||
bitmap.close();
|
||
|
||
const imageData = ctx.getImageData(0, 0, w, h);
|
||
originalImg.src = URL.createObjectURL(blob);
|
||
|
||
// Run recognition
|
||
const startTime = performance.now();
|
||
const recognizer = new PaperRecognizer();
|
||
const result = recognizer.recognize(imageData.data, w, h);
|
||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(2);
|
||
|
||
if (!result.success) {
|
||
showError(`识别失败 [${result.errorCode}] ${result.errorMessage} (${elapsed}s)`);
|
||
return;
|
||
}
|
||
|
||
showSuccess(`✅ 识别成功 — ${elapsed}s`);
|
||
|
||
// Info grid
|
||
const blurNames = ['正常', '轻微模糊', '严重模糊'];
|
||
const colorNames = ['未知', '红', '绿', '蓝'];
|
||
infoGrid.innerHTML = `
|
||
<div class="info-item"><div class="label">试纸类型</div><div class="value ok">${PST_NAMES[result.paperType] || '未知'}</div></div>
|
||
<div class="info-item"><div class="label">色块数</div><div class="value">${result.itemCount}</div></div>
|
||
<div class="info-item"><div class="label">定位块颜色</div><div class="value">${colorNames[result.locatorColor] || '未知'}</div></div>
|
||
<div class="info-item"><div class="label">小定位块方向</div><div class="value">${result.smallLocatorOnLeft ? '← 左侧' : '→ 右侧'}</div></div>
|
||
<div class="info-item"><div class="label">清晰度</div><div class="value ${result.blurLevel > 0 ? 'warn' : ''}">${blurNames[result.blurLevel] || '未知'} (${(result.blurScore||0).toFixed(0)})</div></div>
|
||
<div class="info-item"><div class="label">原图尺寸</div><div class="value">${w}×${h}</div></div>
|
||
`;
|
||
|
||
// Annotated canvas
|
||
const ap = recognizer.getAnnotatedPixels();
|
||
const aw = recognizer.getAnnotatedWidth();
|
||
const ah = recognizer.getAnnotatedHeight();
|
||
if (ap && aw > 0 && ah > 0) {
|
||
annotatedCanvas.width = aw;
|
||
annotatedCanvas.height = ah;
|
||
annotatedCanvas.style.width = '100%';
|
||
annotatedCanvas.style.height = 'auto';
|
||
const actx = annotatedCanvas.getContext('2d');
|
||
const imgData = actx.createImageData(aw, ah);
|
||
imgData.data.set(ap);
|
||
actx.putImageData(imgData, 0, 0);
|
||
}
|
||
|
||
// Colors table
|
||
const colors = result.colors || [];
|
||
colorsBody.innerHTML = colors.map(c => `
|
||
<tr>
|
||
<td><b>${c.id}</b></td>
|
||
<td><span class="color-dot" style="background:rgb(${Math.round(c.r)},${Math.round(c.g)},${Math.round(c.b_val)})"></span></td>
|
||
<td>${c.r.toFixed(0)}</td>
|
||
<td>${c.g.toFixed(0)}</td>
|
||
<td>${c.b_val.toFixed(0)}</td>
|
||
<td>${c.h.toFixed(1)}</td>
|
||
<td>${c.s.toFixed(0)}</td>
|
||
<td>${c.v.toFixed(0)}</td>
|
||
<td>${c.l.toFixed(1)}</td>
|
||
<td>${c.a.toFixed(1)}</td>
|
||
<td>${c.b_val.toFixed(1)}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
jsonOutput.textContent = recognizer.getResultJson();
|
||
resultCard.style.display = 'block';
|
||
} catch (e) {
|
||
showError(`处理错误: ${e.message}`);
|
||
}
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|