Files
nanocode/web-opencv/index.html
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

776 lines
24 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>OpenCV WASM 测试平台</title>
<style>
/* ===== Reset & Base ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #FEF9E7;
color: #1a1a1a;
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 960px;
margin: 0 auto;
}
/* ===== Header ===== */
h1 {
font-size: 28px;
font-weight: 800;
margin-bottom: 8px;
}
.subtitle {
font-size: 14px;
color: #555;
margin-bottom: 24px;
}
.subtitle code {
background: #eee;
padding: 2px 6px;
border: 1px solid #ccc;
}
/* ===== Tabs ===== */
.tabs {
display: flex;
gap: 0;
border-bottom: 3px solid #000;
margin-bottom: 24px;
}
.tab-btn {
padding: 12px 24px;
font-size: 15px;
font-weight: 700;
background: #e8e0d0;
border: 2px solid #000;
border-bottom: none;
cursor: pointer;
transition: background 0.15s;
position: relative;
margin-right: 4px;
}
.tab-btn:hover { background: #ddd5c5; }
.tab-btn.active {
background: #FEF9E7;
transform: translateY(3px);
border-bottom: 3px solid #FEF9E7;
}
.tab-btn:focus-visible { outline: 3px solid #000; outline-offset: -3px; }
/* ===== Tab Content ===== */
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ===== Card ===== */
.card {
background: #fff;
border: 3px solid #000;
box-shadow: 6px 6px 0 #000;
padding: 24px;
margin-bottom: 24px;
}
/* ===== Buttons ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
font-size: 15px;
font-weight: 700;
border: 3px solid #000;
box-shadow: 4px 4px 0 #000;
cursor: pointer;
transition: all 0.1s;
background: #fff;
}
.btn:hover { transform: translate(-2px, -2px); box-shadow: 6px 6px 0 #000; }
.btn:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: 4px 4px 0 #000;
}
.btn-primary { background: #ffd43b; }
.btn-success { background: #51cf66; }
.btn-danger { background: #ff6b6b; color: #fff; }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; align-items: center; }
/* ===== File Input ===== */
.file-label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
font-size: 15px;
font-weight: 700;
border: 3px solid #000;
box-shadow: 4px 4px 0 #000;
cursor: pointer;
background: #d0ebff;
transition: all 0.1s;
}
.file-label:hover { transform: translate(-2px, -2px); box-shadow: 6px 6px 0 #000; }
.file-label input[type="file"] { display: none; }
/* ===== Output Area ===== */
.output-box {
background: #1a1a1a;
color: #4af626;
font-family: "Courier New", monospace;
font-size: 14px;
padding: 16px;
border: 3px solid #000;
min-height: 80px;
white-space: pre-wrap;
word-break: break-all;
margin-top: 12px;
}
/* ===== Image Compare ===== */
.image-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.image-box {
border: 3px solid #000;
overflow: hidden;
background: #f0f0f0;
}
.image-box .label {
background: #000;
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 6px 12px;
text-align: center;
}
.image-box canvas {
display: block;
width: 100%;
height: auto;
min-height: 200px;
}
.image-box .placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: #999;
font-size: 14px;
}
/* ===== Status Badge ===== */
.badge {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
border: 2px solid #000;
}
.badge-success { background: #51cf66; }
.badge-wait { background: #ffd43b; }
.badge-error { background: #ff6b6b; color: #fff; }
/* ===== Logos / Status ===== */
.status-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
font-size: 14px;
}
/* ===== Responsive ===== */
@media (max-width: 640px) {
.image-grid { grid-template-columns: 1fr; }
.tab-btn { padding: 10px 16px; font-size: 13px; }
.card { padding: 16px; }
}
</style>
</head>
<body>
<div class="container">
<h1>🧪 OpenCV WASM 测试</h1>
<p class="subtitle">测试编译产物:<code>opencv_version.wasm</code> (core+imgproc+imgcodecs) + 自动白平衡</p>
<!-- Tabs -->
<div class="tabs" role="tablist">
<button class="tab-btn active" role="tab" aria-selected="true" data-tab="version">📟 opencv_version</button>
<button class="tab-btn" role="tab" aria-selected="false" data-tab="awb">🎨 自动白平衡</button>
<button class="tab-btn" role="tab" aria-selected="false" data-tab="opencv-awb">🧠 OpenCV AWB</button>
</div>
<!-- ===== Panel: opencv_version ===== -->
<div class="tab-panel active" id="panel-version" role="tabpanel">
<div class="card">
<div class="status-bar">
<span class="badge badge-wait" id="version-status">等待运行</span>
<span id="version-info" style="font-size:13px;color:#666;">
WASM: opencv_version.wasm (230K) · 依赖: libopencv_*.so (3 个动态库)
</span>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-run-version">▶ 运行 opencv_version</button>
<button class="btn" id="btn-reset-version" disabled>↻ 重置</button>
</div>
<p style="font-size:13px;color:#555;margin-bottom:8px;">
点击运行,通过 Emscripten 动态加载 opencv_version.wasm,输出 OpenCV 版本信息。
</p>
<div class="output-box" id="version-output">等待运行…</div>
</div>
<div class="card" style="background:#fff3e0;">
<p style="font-weight:700;margin-bottom:8px;">📋 编译信息</p>
<pre style="font-size:13px;line-height:1.6;white-space:pre-wrap;">
OpenCV 4.10.0
构建模块: core, imgproc, imgcodecs
编译模式: MinSizeRel
SIMD: 启用 (-msimd128)
编译器: Emscripten (emcc)
动态库: libopencv_core.so (1.7M), libopencv_imgproc.so (2.6M), libopencv_imgcodecs.so (1.5M)
</pre>
</div>
</div>
<!-- ===== Panel: 自动白平衡 ===== -->
<div class="tab-panel" id="panel-awb" role="tabpanel">
<div class="card">
<div class="status-bar">
<span class="badge badge-wait" id="awb-status">等待操作</span>
<span style="font-size:13px;color:#666;">
灰度世界假设 (Gray World) 算法 · 无外部依赖
</span>
</div>
<div class="btn-row">
<label class="file-label">
📁 选择图片
<input type="file" id="image-input" accept="image/jpeg,image/png,image/webp">
</label>
<button class="btn btn-success" id="btn-awb" disabled>✨ 白平衡处理</button>
<button class="btn btn-danger" id="btn-reset-awb" disabled>↺ 重置</button>
</div>
<p style="font-size:13px;color:#555;margin-bottom:16px;">
选择一张图片,点击「白平衡处理」即可看到灰度世界算法自动校正色偏的效果。
</p>
<div class="image-grid">
<div class="image-box">
<div class="label">📷 原图</div>
<canvas id="canvas-original"></canvas>
<div class="placeholder" id="placeholder-original">
请选择一张图片
</div>
</div>
<div class="image-box">
<div class="label">✨ 白平衡处理后</div>
<canvas id="canvas-result"></canvas>
<div class="placeholder" id="placeholder-result">
处理后显示在此处
</div>
</div>
</div>
</div>
</div>
<!-- ===== Panel: OpenCV AWB ===== -->
<div class="tab-panel" id="panel-opencv-awb" role="tabpanel">
<div class="card">
<div class="status-bar">
<span class="badge badge-wait" id="ocv-awb-status">等待上传</span>
<span style="font-size:13px;color:#666;">
OpenCV 灰度世界算法 · Server-side WASM
</span>
</div>
<div class="btn-row">
<label class="file-label">
📁 选择图片
<input type="file" id="ocv-image-input" accept="image/jpeg,image/png,image/webp">
</label>
<button class="btn btn-success" id="btn-ocv-awb" disabled>🧠 OpenCV 白平衡</button>
<button class="btn btn-danger" id="btn-ocv-reset" disabled>↺ 重置</button>
</div>
<p style="font-size:13px;color:#555;margin-bottom:16px;">
上传图片后,发送到服务器由 <strong>OpenCV C++ 灰度世界算法</strong>(静态编译 WASM)处理,
返回校正后的图片。基于真实的 OpenCV 实现,与纯 JS 算法对比。
</p>
<div class="image-grid">
<div class="image-box">
<div class="label">📷 原图</div>
<canvas id="ocv-canvas-original"></canvas>
<div class="placeholder" id="ocv-placeholder-original">
请选择一张图片
</div>
</div>
<div class="image-box">
<div class="label">🧠 OpenCV 处理后</div>
<canvas id="ocv-canvas-result"></canvas>
<div class="placeholder" id="ocv-placeholder-result">
处理中...
</div>
</div>
</div>
<div id="ocv-meta-box" class="output-box" style="display:none;margin-top:12px;">
<p style="font-weight:700;margin-bottom:8px;">📊 处理元数据</p>
<pre id="ocv-meta-content" style="font-size:13px;line-height:1.6;white-space:pre-wrap;"></pre>
</div>
</div>
<div class="card" style="background:#fff3e0;">
<p style="font-weight:700;margin-bottom:8px;">📋 编译信息</p>
<pre style="font-size:13px;line-height:1.6;white-space:pre-wrap;">
OpenCV 4.10.0 · 静态编译 (STATIC_LIBS)
构建模块: core, imgproc, imgcodecs + libpng/libjpeg/libwebp
AWB 实现: Gray World 灰度世界算法 (C++ 实现)
编译: em++ -O2 -msimd128 · 输出 awb_static.wasm (2.5M)
运行方式: Server-side Node.js,调用 POST /api/awb 处理
</pre>
</div>
</div>
<!-- ===== Footer ===== -->
<p style="text-align:center;font-size:12px;color:#999;margin-top:32px;border-top:2px solid #ddd;padding-top:16px;">
OpenCV WASM Test · 编译于 Ubuntu 24.04 · Emscripten + CMake
</p>
</div>
<script>
(function() {
'use strict';
// ===== Tab Logic =====
const tabBtns = document.querySelectorAll('.tab-btn');
const panels = {
version: document.getElementById('panel-version'),
awb: document.getElementById('panel-awb'),
'opencv-awb': document.getElementById('panel-opencv-awb')
};
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
tabBtns.forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-selected', 'true');
Object.keys(panels).forEach(k => panels[k].classList.remove('active'));
panels[tab].classList.add('active');
});
});
// ===== opencv_version Runner =====
const btnRun = document.getElementById('btn-run-version');
const btnReset = document.getElementById('btn-reset-version');
const outputEl = document.getElementById('version-output');
const statusEl = document.getElementById('version-status');
let versionRunOnce = false;
function setVersionStatus(text, type) {
statusEl.textContent = text;
statusEl.className = 'badge badge-' + type;
}
btnRun.addEventListener('click', async () => {
if (versionRunOnce) return;
btnRun.disabled = true;
outputEl.textContent = '正在加载 opencv_version.wasm …';
setVersionStatus('加载中', 'wait');
const capturedOutput = [];
try {
// Step 1: override console.log/error to capture output
const origLog = console.log;
const origErr = console.error;
console.log = function() {
const msg = Array.from(arguments).join(' ');
capturedOutput.push(msg);
};
console.error = function() {
const msg = Array.from(arguments).join(' ');
capturedOutput.push(msg);
};
// Step 2: set up Module for Emscripten, with locateFile for .so files
window.Module = {
noInitialRun: true,
arguments: [],
locateFile: function(path, prefix) {
// Redirect .so files to lib/ directory
if (path.endsWith('.so') || path.includes('.so.')) {
return prefix + 'lib/' + path;
}
return prefix + path;
},
onAbort: function() {
// Module aborted (after exit)
},
onRuntimeInitialized: function() {
// Runtime initialized, now we can call main
try {
Module.callMain([]);
} catch(e) {
// exit() throws, that's expected
}
}
};
// Step 3: dynamically load opencv_version.js
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'opencv_version.js';
script.onload = resolve;
script.onerror = () => reject(new Error('加载 opencv_version.js 失败'));
document.head.appendChild(script);
});
// Step 4: restore console
console.log = origLog;
console.error = origErr;
// Step 5: display output
const text = capturedOutput.join('\n').trim();
if (text) {
outputEl.textContent = text;
setVersionStatus('运行成功', 'success');
} else {
outputEl.textContent = '⚠️ 未捕获到输出,尝试通过 fallback 获取版本号…';
// Fallback: try calling the WASM main directly
try {
if (window.Module && Module._main) {
Module.callMain([]);
}
} catch(e2) {}
const text2 = capturedOutput.join('\n').trim();
if (text2) {
outputEl.textContent = text2;
setVersionStatus('运行成功', 'success');
} else {
setVersionStatus('⚠️ 无输出', 'error');
}
}
versionRunOnce = true;
btnReset.disabled = false;
} catch (err) {
console.log = console.log; // restore might have failed, ensure
outputEl.textContent = '❌ 错误: ' + err.message;
setVersionStatus('加载失败', 'error');
btnRun.disabled = false;
}
});
btnReset.addEventListener('click', () => {
versionRunOnce = false;
btnRun.disabled = false;
btnReset.disabled = true;
outputEl.textContent = '等待运行…';
setVersionStatus('等待运行', 'wait');
});
// ===== Auto White Balance (Gray World) =====
const fileInput = document.getElementById('image-input');
const btnAwb = document.getElementById('btn-awb');
const btnResetAwb = document.getElementById('btn-reset-awb');
const canvasOrig = document.getElementById('canvas-original');
const canvasResult = document.getElementById('canvas-result');
const placeOrig = document.getElementById('placeholder-original');
const placeResult = document.getElementById('placeholder-result');
const awbStatus = document.getElementById('awb-status');
let loadedImage = null; // HTMLImageElement
let processedData = null; // ImageData
function setAwbStatus(text, type) {
awbStatus.textContent = text;
awbStatus.className = 'badge badge-' + type;
}
// Load image from file
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
loadedImage = img;
processedData = null;
// Draw original image
canvasOrig.width = img.naturalWidth;
canvasOrig.height = img.naturalHeight;
const ctx = canvasOrig.getContext('2d');
ctx.drawImage(img, 0, 0);
placeOrig.style.display = 'none';
// Clear result
canvasResult.width = 0;
canvasResult.height = 0;
placeResult.style.display = 'flex';
placeResult.textContent = '已加载图片,点击「白平衡处理」';
btnAwb.disabled = false;
btnResetAwb.disabled = false;
setAwbStatus('图片已加载', 'wait');
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
// Gray World Auto White Balance
function grayWorldAWB(imageData) {
const data = imageData.data; // Uint8ClampedArray, RGBA
const len = data.length;
let sumR = 0, sumG = 0, sumB = 0;
for (let i = 0; i < len; i += 4) {
sumR += data[i]; // R
sumG += data[i + 1]; // G
sumB += data[i + 2]; // B
}
const count = len / 4;
const meanR = sumR / count;
const meanG = sumG / count;
const meanB = sumB / count;
const grayMean = (meanR + meanG + meanB) / 3;
// Avoid division by zero
const scaleR = grayMean / (meanR || 1);
const scaleG = grayMean / (meanG || 1);
const scaleB = grayMean / (meanB || 1);
// Apply scaling, clamp to [0, 255]
for (let i = 0; i < len; i += 4) {
data[i] = Math.min(255, Math.max(0, Math.round(data[i] * scaleR)));
data[i + 1] = Math.min(255, Math.max(0, Math.round(data[i + 1] * scaleG)));
data[i + 2] = Math.min(255, Math.max(0, Math.round(data[i + 2] * scaleB)));
// data[i + 3] = alpha, unchanged
}
return imageData;
}
// Process AWB
btnAwb.addEventListener('click', () => {
if (!loadedImage) return;
btnAwb.disabled = true;
setAwbStatus('处理中…', 'wait');
// Use requestAnimationFrame for UI responsiveness
requestAnimationFrame(() => {
try {
// Get original image data
const ctx = canvasOrig.getContext('2d');
const srcData = ctx.getImageData(0, 0, canvasOrig.width, canvasOrig.height);
// Apply Gray World AWB
const resultData = grayWorldAWB(srcData);
// Draw result
canvasResult.width = canvasOrig.width;
canvasResult.height = canvasOrig.height;
const rctx = canvasResult.getContext('2d');
rctx.putImageData(resultData, 0, 0);
placeResult.style.display = 'none';
processedData = resultData;
setAwbStatus('处理完成', 'success');
btnAwb.disabled = false;
} catch (err) {
setAwbStatus('处理出错', 'error');
placeResult.style.display = 'flex';
placeResult.textContent = '❌ 处理失败: ' + err.message;
btnAwb.disabled = false;
}
});
});
// Reset AWB
btnResetAwb.addEventListener('click', () => {
loadedImage = null;
processedData = null;
fileInput.value = '';
canvasOrig.width = 0;
canvasOrig.height = 0;
canvasResult.width = 0;
canvasResult.height = 0;
placeOrig.style.display = 'flex';
placeOrig.textContent = '请选择一张图片';
placeResult.style.display = 'flex';
placeResult.textContent = '处理后显示在此处';
btnAwb.disabled = true;
btnResetAwb.disabled = true;
setAwbStatus('等待操作', 'wait');
});
// ===== OpenCV AWB (Server-side WASM) =====
const ocvFileInput = document.getElementById('ocv-image-input');
const btnOcvAwb = document.getElementById('btn-ocv-awb');
const btnOcvReset = document.getElementById('btn-ocv-reset');
const ocvCanvasOrig = document.getElementById('ocv-canvas-original');
const ocvCanvasResult = document.getElementById('ocv-canvas-result');
const ocvPlaceOrig = document.getElementById('ocv-placeholder-original');
const ocvPlaceResult = document.getElementById('ocv-placeholder-result');
const ocvMetaBox = document.getElementById('ocv-meta-box');
const ocvMetaContent = document.getElementById('ocv-meta-content');
const ocvStatus = document.getElementById('ocv-awb-status');
let ocvLoadedImage = null;
function setOcvStatus(text, type) {
ocvStatus.textContent = text;
ocvStatus.className = 'badge badge-' + type;
}
ocvFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
ocvLoadedImage = img;
ocvCanvasOrig.width = img.naturalWidth;
ocvCanvasOrig.height = img.naturalHeight;
const ctx = ocvCanvasOrig.getContext('2d');
ctx.drawImage(img, 0, 0);
ocvPlaceOrig.style.display = 'none';
ocvCanvasResult.width = 0;
ocvCanvasResult.height = 0;
ocvPlaceResult.style.display = 'flex';
ocvPlaceResult.textContent = '点击「OpenCV 白平衡」发送到服务器处理';
ocvMetaBox.style.display = 'none';
btnOcvAwb.disabled = false;
btnOcvReset.disabled = false;
setOcvStatus('图片已加载', 'wait');
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
btnOcvAwb.addEventListener('click', async () => {
if (!ocvLoadedImage) return;
btnOcvAwb.disabled = true;
setOcvStatus('编码中…', 'wait');
ocvPlaceResult.textContent = '处理中...';
ocvPlaceResult.style.display = 'flex';
try {
// Convert canvas to PNG base64
const canvas = document.createElement('canvas');
canvas.width = ocvLoadedImage.naturalWidth;
canvas.height = ocvLoadedImage.naturalHeight;
canvas.getContext('2d').drawImage(ocvLoadedImage, 0, 0);
const pngB64 = canvas.toDataURL('image/png').split(',')[1];
setOcvStatus('发送到服务器…', 'wait');
// POST to server
const resp = await fetch('/api/awb', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: pngB64 })
});
if (!resp.ok) {
throw new Error('服务器错误: ' + resp.status);
}
const result = await resp.json();
if (!result.success || !result.image) {
throw new Error(result.error || '处理失败');
}
// Decode base64 result and display
const img = new Image();
img.onload = () => {
ocvCanvasResult.width = img.naturalWidth;
ocvCanvasResult.height = img.naturalHeight;
const ctx = ocvCanvasResult.getContext('2d');
ctx.drawImage(img, 0, 0);
ocvPlaceResult.style.display = 'none';
// Show metadata
if (result.metadata) {
ocvMetaContent.textContent = result.metadata;
ocvMetaBox.style.display = 'block';
}
setOcvStatus('处理完成 ✅', 'success');
btnOcvAwb.disabled = false;
};
img.onerror = () => {
throw new Error('无法解码返回的图片');
};
img.src = 'data:image/png;base64,' + result.image;
} catch (err) {
setOcvStatus('处理出错', 'error');
ocvPlaceResult.style.display = 'flex';
ocvPlaceResult.textContent = '❌ ' + err.message;
btnOcvAwb.disabled = false;
}
});
btnOcvReset.addEventListener('click', () => {
ocvLoadedImage = null;
ocvFileInput.value = '';
ocvCanvasOrig.width = 0;
ocvCanvasOrig.height = 0;
ocvCanvasResult.width = 0;
ocvCanvasResult.height = 0;
ocvPlaceOrig.style.display = 'flex';
ocvPlaceOrig.textContent = '请选择一张图片';
ocvPlaceResult.style.display = 'flex';
ocvPlaceResult.textContent = '处理中...';
ocvMetaBox.style.display = 'none';
btnOcvAwb.disabled = true;
btnOcvReset.disabled = true;
setOcvStatus('等待上传', 'wait');
});
})();
</script>
</body>
</html>