<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rust SIFT WebGPU Demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f0f2f5;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
max-width: 800px;
width: 100%;
}
h1 {
margin-top: 0;
color: #333;
}
.controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
button {
background-color: #0070f3;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #0051a2;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
select {
padding: 8px;
border-radius: 4px;
border: 1px solid #ccc;
}
.canvas-wrapper {
position: relative;
width: 640px;
height: 480px;
background: #000;
border-radius: 4px;
overflow: hidden;
margin: 0 auto;
}
video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.stats {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
font-family: monospace;
background: #eee;
padding: 10px;
border-radius: 4px;
}
.stat-item span {
font-weight: bold;
color: #0070f3;
}
#error-msg {
color: red;
margin-top: 10px;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>SIFT-rs WebGPU Demo</h1>
<div class="controls">
<button id="start-btn">Start Camera</button>
<select id="backend-select">
<option value="gpu">WebGPU (Async)</option>
<option value="cpu">CPU (Sync)</option>
</select>
<select id="resolution-select">
<option value="320">320px</option>
<option value="640">640px</option>
<option value="1280">1280px</option>
</select>
<label>
<input type="checkbox" id="draw-video" checked> Show Video
</label>
<span id="status">Loading WASM...</span>
</div>
<div class="canvas-wrapper">
<video id="video" playsinline></video>
<canvas id="canvas"></canvas>
</div>
<div id="error-msg"></div>
<div class="stats">
<div class="stat-item">FPS: <span id="fps">0</span></div>
<div class="stat-item">Processing Time: <span id="time">0</span> ms</div>
<div class="stat-item">Keypoints: <span id="kps">0</span></div>
</div>
</div>
<script type="module">
import init, { init_logging, detect_sift_cpu, SiftDetector } from './pkg/sift.js';
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const startBtn = document.getElementById('start-btn');
const backendSelect = document.getElementById('backend-select');
const statusSpan = document.getElementById('status');
const errorMsg = document.getElementById('error-msg');
const fpsSpan = document.getElementById('fps');
const timeSpan = document.getElementById('time');
const kpsSpan = document.getElementById('kps');
let isRunning = false;
let animationId;
let lastFrameTime = 0;
let frameCount = 0;
let lastFpsTime = 0;
let gpuDetector = null;
let processWidth = 320; let processHeight = 240;
let scaleX = 1;
let scaleY = 1;
const resolutionSelect = document.getElementById('resolution-select');
resolutionSelect.onchange = () => {
processWidth = parseInt(resolutionSelect.value);
if (video.videoWidth > 0 && video.videoHeight > 0) {
const aspect = video.videoWidth / video.videoHeight;
processHeight = Math.round(processWidth / aspect);
scaleX = canvas.width / processWidth;
scaleY = canvas.height / processHeight;
}
};
async function main() {
try {
await init();
init_logging();
statusSpan.textContent = 'Ready';
startBtn.onclick = toggleCamera;
} catch (e) {
showError(`Failed to load WASM: ${e}`);
}
}
function showError(msg) {
errorMsg.textContent = msg;
errorMsg.style.display = 'block';
console.error(msg);
}
async function toggleCamera() {
if (isRunning) {
stopCamera();
} else {
await startCamera();
}
}
async function startCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
showError("Camera API not available. This usually means you are not using HTTPS or Localhost.");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: 'environment'
}
});
video.srcObject = stream;
await video.play();
processHeight = Math.round(processWidth * (video.videoHeight / video.videoWidth));
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
scaleX = video.videoWidth / processWidth;
scaleY = video.videoHeight / processHeight;
isRunning = true;
startBtn.textContent = 'Stop Camera';
errorMsg.style.display = 'none';
requestAnimationFrame(processFrame);
} catch (e) {
showError(`Camera error: ${e.message}`);
}
}
function stopCamera() {
isRunning = false;
cancelAnimationFrame(animationId);
if (video.srcObject) {
video.srcObject.getTracks().forEach(track => track.stop());
video.srcObject = null;
}
startBtn.textContent = 'Start Camera';
fpsSpan.textContent = '0';
timeSpan.textContent = '0';
kpsSpan.textContent = '0';
}
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
async function processFrame(timestamp) {
if (!isRunning) return;
if (timestamp - lastFpsTime >= 1000) {
fpsSpan.textContent = frameCount;
frameCount = 0;
lastFpsTime = timestamp;
}
frameCount++;
if (tempCanvas.width !== processWidth || tempCanvas.height !== processHeight) {
tempCanvas.width = processWidth;
tempCanvas.height = processHeight;
}
tempCtx.drawImage(video, 0, 0, processWidth, processHeight);
const imageData = tempCtx.getImageData(0, 0, processWidth, processHeight);
const backend = backendSelect.value;
const startTime = performance.now();
let result;
try {
if (backend === 'gpu') {
if (!gpuDetector) {
console.log("Initializing GPU SIFT...");
gpuDetector = await SiftDetector.new();
}
result = await gpuDetector.detect(imageData.data, processWidth, processHeight);
} else {
result = detect_sift_cpu(imageData.data, processWidth, processHeight);
}
const processTime = performance.now() - startTime;
timeSpan.textContent = processTime.toFixed(1);
if (document.getElementById('draw-video').checked) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
} else {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
drawKeypoints(result);
kpsSpan.textContent = result.keypoint_count();
result.free();
} catch (e) {
console.error("Processing error:", e);
}
animationId = requestAnimationFrame(processFrame);
}
function drawKeypoints(result) {
const count = result.keypoint_count();
const flatKps = result.get_keypoints_flat();
ctx.lineWidth = 1;
for (let i = 0; i < count; i++) {
const base = i * 6;
const x = flatKps[base] * scaleX;
const y = flatKps[base + 1] * scaleY;
const size = flatKps[base + 2] * scaleX; const angle = flatKps[base + 3];
ctx.beginPath();
ctx.arc(x, y, size, 0, 2 * Math.PI);
ctx.strokeStyle = '#00ff00'; ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(angle) * size, y + Math.sin(angle) * size);
ctx.strokeStyle = '#ff0000'; ctx.stroke();
}
}
main();
</script>
</body>
</html>