EdgeAI_Digit_Recognition/templates/index.html

485 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EdgeAI Digit Reader</title>
<!-- External Libraries -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
<style>
:root { --sidebar-width: 260px; }
body {
background-color: #121212;
color: #e0e0e0;
height: 100vh;
overflow: hidden;
}
/* Sidebar - Dark Theme */
.sidebar {
width: var(--sidebar-width); height: 100vh; position: fixed; left: 0; top: 0;
background: #1e1e1e; border-right: 1px solid #333; padding: 1rem; z-index: 1000; overflow-y: auto;
}
/* Main Content Layout */
.main-content {
margin-left: var(--sidebar-width);
height: 100vh;
display: flex;
flex-direction: column;
}
/* Camera Items */
.camera-item {
cursor: pointer; padding: 12px; border-radius: 6px; margin-bottom: 8px;
background: #2c2c2c; transition: all 0.2s; border-left: 3px solid transparent;
}
.camera-item:hover { background-color: #383838; }
.camera-item.active {
background-color: #333;
border-left-color: #0d6efd;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.status-badge { font-size: 0.7em; padding: 2px 6px; border-radius: 4px; }
.status-online { background: #198754; color: white; }
/* Toolbar */
.toolbar {
flex-shrink: 0;
background: #1e1e1e; padding: 10px 20px; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center;
}
/* Debug Panel (Top Position) */
.debug-panel {
flex-shrink: 0;
background: #1e1e1e;
border-bottom: 1px solid #333;
font-family: monospace; font-size: 0.8em;
max-height: 30%;
overflow-y: auto;
padding: 5px 20px;
z-index: 2000;
}
/* Video Stage (Fills remaining space) */
.video-stage {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
overflow: hidden;
position: relative;
padding: 10px;
min-height: 0; /* Critical for flex shrinking */
}
.video-wrapper {
position: relative; display: inline-block;
box-shadow: 0 0 20px rgba(0,0,0,0.5); user-select: none;
max-width: 100%; max-height: 100%;
}
#video-feed {
display: block;
max-width: 100%; max-height: 100%;
object-fit: contain;
pointer-events: none;
}
/* ROI Overlays */
#roi-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 10; }
.roi-box {
position: absolute; border: 2px solid #00ff00;
background-color: rgba(0, 255, 0, 0.15); cursor: move; box-sizing: border-box;
transform-origin: center center;
}
.roi-box:hover { border-color: #fff; z-index: 100; background-color: rgba(0, 255, 0, 0.25); }
.roi-box.selected { border-color: #ffc107; z-index: 110; background-color: rgba(255, 193, 7, 0.2); }
.resize-handle {
width: 12px; height: 12px; background: #fff; border: 1px solid #000;
position: absolute; bottom: -6px; right: -6px; cursor: nwse-resize; z-index: 120;
}
.rotate-handle {
width: 12px; height: 12px; background: #0d6efd; border: 1px solid #fff; border-radius: 50%;
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
cursor: grab; z-index: 120; display: none;
}
.rotate-handle::after {
content: ''; position: absolute; top: 10px; left: 50%; width: 2px; height: 10px;
background: #0d6efd; transform: translateX(-1px);
}
.roi-box:hover .rotate-handle, .roi-box.selected .rotate-handle { display: block; }
.delete-roi {
position: absolute; top: -12px; right: -12px; background: #dc3545; color: white;
border-radius: 50%; width: 24px; height: 24px; font-size: 14px;
display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 120;
display: none; box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.roi-box:hover .delete-roi, .roi-box.selected .delete-roi { display: flex; }
.detection-badge {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8); color: #0f0; font-weight: bold; padding: 2px 6px;
pointer-events: none; border-radius: 4px; font-size: 1.2em; text-shadow: 0 0 5px #000;
}
</style>
</head>
<body>
<div x-data="app()" x-init="initApp()" class="d-flex"
@mousemove.window="handleGlobalMove($event)"
@mouseup.window="handleGlobalUp($event)">
<!-- Sidebar -->
<div class="sidebar d-flex flex-column">
<h5 class="mb-4 text-primary fw-bold"><i class="fas fa-microchip me-2"></i>EdgeAI Monitor</h5>
<div class="mb-3 flex-grow-1 overflow-auto">
<label class="small text-muted mb-2 text-uppercase fw-bold" style="font-size: 0.7em; letter-spacing: 1px;">Cameras</label>
<div x-show="cameras.length === 0" class="text-muted small text-center py-4">
<i class="fas fa-spinner fa-spin mb-2"></i><br>Loading...
</div>
<template x-for="cam in cameras" :key="cam.id">
<div class="camera-item"
:class="{'active': currentCamera && currentCamera.id === cam.id}"
@click="selectCamera(cam)">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold text-white" x-text="cam.name"></span>
<span class="status-badge status-online">LIVE</span>
</div>
<div class="small text-muted font-monospace" x-text="cam.id" style="font-size: 0.8em"></div>
</div>
</template>
</div>
<div class="mt-auto pt-3 border-top border-secondary">
<div class="d-grid gap-2">
<button class="btn btn-outline-secondary btn-sm text-light" @click="loadROIs()" :disabled="!currentCamera">
<i class="fas fa-sync me-2"></i>Reload ROIs
</button>
<button class="btn btn-primary btn-sm" @click="saveROIs()" :disabled="!currentCamera">
<i class="fas fa-save me-2"></i>Save Config
</button>
</div>
</div>
</div>
<!-- Main Content -->
<div class="main-content flex-grow-1">
<!-- Empty State -->
<div x-show="!currentCamera" class="h-100 d-flex flex-column justify-content-center align-items-center text-secondary">
<i class="fas fa-video fa-3x mb-3"></i>
<h3>Select a Camera</h3>
<p>Choose a stream from the sidebar to begin.</p>
</div>
<!-- Camera View -->
<div x-show="currentCamera" class="d-flex flex-column h-100">
<!-- 1. Top Toolbar -->
<div class="toolbar">
<div class="d-flex align-items-center gap-3">
<h5 class="m-0 text-white" x-text="currentCamera ? currentCamera.name : ''"></h5>
<!-- Flip Controls -->
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" @click="rotateFlip('none')" title="Normal"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-outline-secondary" @click="rotateFlip('horizontal')" title="Flip Horizontal"><i class="fas fa-arrows-alt-h"></i></button>
<button class="btn btn-outline-secondary" @click="rotateFlip('vertical')" title="Flip Vertical"><i class="fas fa-arrows-alt-v"></i></button>
<button class="btn btn-outline-secondary" @click="rotateFlip('both')" title="Flip Both"><i class="fas fa-compress-arrows-alt"></i></button>
</div>
<!-- Aspect Ratio Toggle -->
<div class="form-check form-switch ms-3 text-white small m-0">
<input class="form-check-input" type="checkbox" id="lockRatio" x-model="lockRatio">
<label class="form-check-label" for="lockRatio">Lock 20:32</label>
</div>
</div>
<div class="d-flex align-items-center">
<span class="badge bg-secondary me-3">ROIs: <span x-text="rois.length"></span></span>
<button class="btn btn-outline-light btn-sm me-2" @click="takeSnapshot()">
<i class="fas fa-camera"></i>
</button>
<button class="btn btn-success btn-sm" @click="testDetection()">
<i class="fas fa-play me-2"></i>Test
</button>
</div>
</div>
<!-- 2. Debug Panel (Top Position) -->
<div class="debug-panel">
<button class="btn btn-link btn-sm text-secondary p-0 text-decoration-none" @click="showDebug = !showDebug">
<i class="fas" :class="showDebug ? 'fa-chevron-down' : 'fa-chevron-right'"></i> Debug JSON
</button>
<div x-show="showDebug" class="mt-2">
<pre class="m-0 text-info" x-text="getDebugJSON()"></pre>
</div>
</div>
<!-- 3. Video Stage (Fills Rest) -->
<div class="video-stage">
<div class="video-wrapper"
x-ref="videoWrapper"
@mousedown="startDraw($event)">
<img :src="videoUrl" id="video-feed"
x-ref="videoImg"
@load="onImageLoad">
<div id="roi-overlay">
<template x-for="(roi, index) in rois" :key="roi.id">
<div class="roi-box"
:class="{'selected': selectedRoiId === roi.id}"
:style="`left: ${roi.x}px; top: ${roi.y}px; width: ${roi.width}px; height: ${roi.height}px; transform: rotate(${roi.angle || 0}deg);`"
@mousedown.stop="startMove(roi.id, $event)">
<div class="delete-roi" @mousedown.stop="deleteRoi(index)"><i class="fas fa-times"></i></div>
<div class="resize-handle" @mousedown.stop="startResize(index, $event)"></div>
<div class="rotate-handle" @mousedown.stop="startRotate(index, $event)"></div>
<div class="detection-badge" x-show="roi.lastValue !== undefined" x-text="roi.lastValue" :style="`transform: rotate(-${roi.angle || 0}deg);`"></div>
<div class="position-absolute top-0 start-0 text-white bg-primary px-1" style="font-size:10px" x-text="index+1"></div>
</div>
</template>
<div x-show="interactionMode === 'drawing'" class="roi-box"
style="border-style: dashed; pointer-events: none;"
:style="`left: ${drawBox.x}px; top: ${drawBox.y}px; width: ${drawBox.w}px; height: ${drawBox.h}px;`">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('app', () => ({
cameras: [],
currentCamera: null,
videoUrl: '',
rois: [],
selectedRoiId: null,
showDebug: false,
// Interaction State
interactionMode: null, // 'drawing', 'moving', 'resizing', 'rotating'
activeRoiIndex: -1,
dragStart: {x:0, y:0},
drawStart: {x:0, y:0},
drawBox: {x:0, y:0, w:0, h:0},
lockRatio: true,
imgReady: false,
async initApp() { await this.fetchCameras(); },
async fetchCameras() {
try {
const res = await fetch('/cameras');
this.cameras = await res.json();
} catch(e) {}
},
selectCamera(cam) {
this.currentCamera = cam;
this.rois = [];
this.imgReady = false; // Reset on switch
this.videoUrl = `/video/${cam.id}?t=${Date.now()}`;
},
onImageLoad(e) {
const w = e.target.clientWidth;
const h = e.target.clientHeight;
this.imgReady = true; // <--- TRIGGER UPDATE
if(this.currentCamera) this.loadROIs(w, h);
},
async loadROIs(w, h) {
if(!w || !h) {
const img = this.$refs.videoImg;
if(img) { w=img.clientWidth; h=img.clientHeight; } else { w=640; h=360; }
}
try {
const res = await fetch(`/rois/${this.currentCamera.id}?img_width=${w}&img_height=${h}`);
const data = await res.json();
if(Array.isArray(data)) {
this.rois = data.map(r => ({
id: r.id || Date.now().toString(),
x: Number(r.x), y: Number(r.y), width: Number(r.width), height: Number(r.height),
angle: Number(r.angle || 0), lastValue: undefined
}));
}
} catch(e) {}
},
async saveROIs() {
const img = this.$refs.videoImg;
if(!img) return;
try {
const res = await fetch('/save_rois', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
camera_id: this.currentCamera.id,
rois: this.rois,
img_width: img.clientWidth,
img_height: img.clientHeight
})
});
const d = await res.json();
if(d.success) this.showToast("Saved!", "success");
else this.showToast("Save Failed: " + d.error, "error");
} catch(e) { this.showToast("Network Error", "error"); }
},
async rotateFlip(type) {
await fetch('/update_camera_config', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({camera_id: this.currentCamera.id, flip_type: type})});
this.selectCamera(this.currentCamera);
},
async takeSnapshot() { if(this.currentCamera) window.open(`/snapshot/${this.currentCamera.id}`, '_blank'); },
async testDetection() {
this.showToast("Scanning...", "info");
try {
const res = await fetch('/detect_digits', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({camera_id: this.currentCamera.id})});
const d = await res.json();
if(d.detected_digits) {
d.detected_digits.forEach((val, i) => { if(this.rois[i]) this.rois[i].lastValue = val; });
this.showToast(`Read: ${d.final_number}`, "success");
} else if(d.error) { this.showToast(d.error, "error"); }
} catch(e) {}
},
// --- COMPUTED DEBUG ---
getDebugJSON() {
if(!this.imgReady) return "Loading image data...";
const img = this.$refs.videoImg;
if(!img || !img.naturalWidth) return "Waiting for stream...";
const scaleX = img.naturalWidth / img.clientWidth;
const scaleY = img.naturalHeight / img.clientHeight;
const report = this.rois.map(r => ({
id: r.id,
display: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
real: {
x: Math.round(r.x * scaleX),
y: Math.round(r.y * scaleY),
w: Math.round(r.width * scaleX),
h: Math.round(r.height * scaleY)
},
angle: Math.round(r.angle || 0)
}));
return JSON.stringify(report, null, 2);
},
// --- INTERACTION ---
getRelPos(e) {
const rect = this.$refs.videoWrapper.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
},
startDraw(e) {
const pos = this.getRelPos(e);
this.interactionMode = 'drawing';
this.drawStart = pos;
this.drawBox = {x: pos.x, y: pos.y, w:0, h:0};
this.selectedRoiId = null;
},
startMove(id, e) {
this.selectedRoiId = id;
this.activeRoiIndex = this.rois.findIndex(r => r.id === id);
this.interactionMode = 'moving';
this.dragStart = {x: e.clientX, y: e.clientY};
},
startResize(index, e) {
this.activeRoiIndex = index;
this.interactionMode = 'resizing';
this.dragStart = {x: e.clientX, y: e.clientY};
},
startRotate(index, e) {
this.activeRoiIndex = index;
this.interactionMode = 'rotating';
this.dragStart = {x: e.clientX, y: e.clientY};
},
handleGlobalMove(e) {
if (!this.interactionMode) return;
if (this.interactionMode === 'drawing') {
const pos = this.getRelPos(e);
let w = pos.x - this.drawStart.x;
let h = pos.y - this.drawStart.y;
if(this.lockRatio) {
const signW = Math.sign(w) || 1;
const signH = Math.sign(h) || 1;
const absW = Math.abs(w);
const targetRatio = 20/32;
w = absW * signW;
h = (absW / targetRatio) * signH;
}
this.drawBox = { x: w<0?pos.x:this.drawStart.x, y: h<0?pos.y:this.drawStart.y, w: Math.abs(w), h: Math.abs(h) };
}
else if (this.interactionMode === 'moving' && this.activeRoiIndex > -1) {
const dx = e.clientX - this.dragStart.x;
const dy = e.clientY - this.dragStart.y;
const roi = this.rois[this.activeRoiIndex];
roi.x += dx; roi.y += dy;
this.dragStart = {x: e.clientX, y: e.clientY};
}
else if (this.interactionMode === 'resizing' && this.activeRoiIndex > -1) {
const dx = e.clientX - this.dragStart.x;
const dy = e.clientY - this.dragStart.y;
const roi = this.rois[this.activeRoiIndex];
let newW = Math.max(10, roi.width + dx);
let newH = Math.max(10, roi.height + dy);
if(this.lockRatio) {
const targetRatio = 20/32;
newH = newW / targetRatio;
}
roi.width = newW; roi.height = newH;
this.dragStart = {x: e.clientX, y: e.clientY};
}
else if (this.interactionMode === 'rotating' && this.activeRoiIndex > -1) {
const dx = e.clientX - this.dragStart.x;
const roi = this.rois[this.activeRoiIndex];
roi.angle = (roi.angle || 0) + (dx * 0.5);
this.dragStart = {x: e.clientX, y: e.clientY};
}
},
handleGlobalUp(e) {
if (this.interactionMode === 'drawing' && this.drawBox.w > 10 && this.drawBox.h > 10) {
this.rois.push({
id: Date.now().toString(),
x: Math.round(this.drawBox.x), y: Math.round(this.drawBox.y),
width: Math.round(this.drawBox.w), height: Math.round(this.drawBox.h),
angle: 0
});
}
this.interactionMode = null;
this.activeRoiIndex = -1;
},
deleteRoi(index) { if(confirm("Delete ROI?")) this.rois.splice(index, 1); },
showToast(msg, type="info") { Toastify({ text: msg, duration: 2000, style: { background: type=="error"?"#dc3545":"#198754" } }).showToast(); }
}));
});
</script>
</body>
</html>