485 lines
22 KiB
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>
|