Files
vehicle-counter/templates/webcam.html
Joachim Hummel f9ce2e2dc1 Webcam: Zaehler-Box im "LIVE COUNT"-Design (farbige Icons + Total)
Reines Restyling der bestehenden verschiebbaren HTML-Box: dunkles Panel,
LIVE-COUNT-Header, farbige Fahrzeug-Icons (SVG) + farbige Zahlen, TOTAL-
Zeile. Gleiche /api/counts-Daten, gleiche IDs, gleiches Drag/Polling.
Keine Aenderung an der Video-Pipeline -> kein Performance-Einfluss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:40:57 +02:00

490 lines
21 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webcam Feed</title>
<script>
(function(){var t=localStorage.getItem('theme')||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();
</script>
<style>
:root {
--bg: #f0f0f0; --fg: #213547; --panel: #ffffff;
--border: #cccccc; --muted: #555555;
}
:root[data-theme="dark"] {
--bg: #16181c; --fg: #e3e3e3; --panel: #23262b;
--border: #3a3f46; --muted: #9aa0a6;
}
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: var(--bg);
color: var(--fg);
transition: background-color 0.3s, color 0.3s;
padding: 20px;
}
.theme-toggle {
position: fixed;
top: 12px;
right: 12px;
padding: 8px 12px;
font-size: 18px;
line-height: 1;
border: 1px solid var(--border);
border-radius: 8px;
background-color: var(--panel);
color: var(--fg);
cursor: pointer;
z-index: 1000;
transition: background-color 0.3s, color 0.3s;
}
.theme-toggle:hover {
border-color: var(--muted);
}
.offline-overlay {
position: absolute;
top: 0;
left: 0;
width: 1020px;
height: 600px;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(0, 0, 0, 0.78);
color: #fff;
border-radius: 8px;
text-align: center;
z-index: 5;
}
.offline-overlay.show { display: flex; }
.offline-overlay .icon { font-size: 54px; }
.offline-overlay .msg { font-size: 22px; font-weight: 600; }
.offline-overlay .sub { font-size: 14px; opacity: 0.8; }
.counter-box {
position: absolute;
top: 14px;
left: 14px;
background: rgba(18, 20, 26, 0.82);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px 16px;
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
cursor: move;
user-select: none;
z-index: 4;
min-width: 215px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.cb-header {
display: flex; align-items: center; gap: 8px;
font-size: 12px; letter-spacing: 1.5px; color: #cfd3dc;
padding-bottom: 10px; margin-bottom: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.cb-dot { width: 9px; height: 9px; border-radius: 50%; background: #3b82f6; box-shadow: 0 0 6px #3b82f6; }
.cb-row { display: flex; align-items: center; gap: 12px; padding: 6px 2px; }
.cb-icon { width: 24px; height: 24px; flex: 0 0 24px; color: var(--accent, #fff); }
.cb-label { flex: 1; font-size: 14px; letter-spacing: 0.5px; color: #e7e9ee; }
.cb-val { font-size: 18px; font-weight: 700; color: var(--accent, #fff); font-variant-numeric: tabular-nums; }
.cb-car { --accent: #22c55e; }
.cb-truck { --accent: #3b82f6; }
.cb-bus { --accent: #a855f7; }
.cb-moto { --accent: #ef4444; }
.cb-divider { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 6px 0; }
.cb-total-row { padding-top: 4px; }
.cb-total-label { flex: 1; font-size: 13px; letter-spacing: 1.5px; color: #9aa0a6; }
.cb-total-val { font-size: 24px; font-weight: 800; color: #fff; font-variant-numeric: tabular-nums; }
.video-container {
position: relative;
width: 1020px;
height: 600px;
}
#videoFeed {
width: 1020px;
height: 600px;
border: 2px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
display: block;
}
#lineCanvas {
position: absolute;
top: 0;
left: 0;
width: 1020px;
height: 600px;
cursor: crosshair;
pointer-events: none;
}
#lineCanvas.active {
pointer-events: auto;
}
.controls {
margin-top: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
button {
padding: 10px 20px;
font-size: 16px;
border: none;
border-radius: 5px;
cursor: pointer;
background-color: #007bff;
color: white;
transition: background-color 0.3s;
}
button:hover {
background-color: #0056b3;
}
button.active {
background-color: #28a745;
}
button.danger {
background-color: #dc3545;
}
button.danger:hover {
background-color: #c82333;
}
a {
margin-top: 20px;
text-decoration: none;
color: #007bff;
font-size: 18px;
}
a:hover {
text-decoration: underline;
}
.info {
margin-top: 10px;
color: var(--muted);
font-size: 14px;
}
</style>
</head>
<body>
<button id="themeToggle" class="theme-toggle" onclick="toggleTheme()" aria-label="Theme umschalten" title="Hell/Dunkel umschalten">🌙</button>
<h1>Webcam Object Detection</h1>
<div class="video-container">
<img id="videoFeed" src="{{ url_for('webcam_feed') }}" />
<canvas id="lineCanvas"></canvas>
<div id="counterBox" class="counter-box" title="Zum Verschieben ziehen">
<div class="cb-header"><span class="cb-dot"></span>LIVE COUNT</div>
<div class="cb-row cb-car">
<svg class="cb-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>
<span class="cb-label">CARS</span><span class="cb-val" id="cCar">0</span>
</div>
<div class="cb-row cb-truck">
<svg class="cb-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/></svg>
<span class="cb-label">TRUCKS</span><span class="cb-val" id="cTruck">0</span>
</div>
<div class="cb-row cb-bus">
<svg class="cb-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6v6"/><path d="M15 6v6"/><path d="M2 12h19.6"/><path d="M18 18h3s.5-1.7.8-2.8c.1-.4.2-.8.2-1.2 0-.4-.1-.8-.2-1.2l-1.4-5C20.1 6.8 19.1 6 18 6H4a2 2 0 0 0-2 2v10h3"/><circle cx="7" cy="18" r="2"/><path d="M9 18h5"/><circle cx="16" cy="18" r="2"/></svg>
<span class="cb-label">BUSES</span><span class="cb-val" id="cBus">0</span>
</div>
<div class="cb-row cb-moto">
<svg class="cb-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18.5" cy="17.5" r="3.5"/><circle cx="5.5" cy="17.5" r="3.5"/><circle cx="15" cy="5" r="1"/><path d="M12 17.5V14l-3-3 4-3 2 3h2"/></svg>
<span class="cb-label">MOTORCYCLES</span><span class="cb-val" id="cMoto">0</span>
</div>
<div class="cb-divider"></div>
<div class="cb-row cb-total-row">
<span class="cb-total-label">TOTAL</span><span class="cb-total-val" id="cTotal">0</span>
</div>
</div>
<div id="offlineOverlay" class="offline-overlay">
<div class="icon" id="ovIcon">📷</div>
<div class="msg" id="ovMsg">Kamera offline</div>
<div class="sub" id="ovSub">Versuche neu zu verbinden…</div>
</div>
</div>
<div class="controls">
<button id="setLineBtn">Zähllinie setzen</button>
<button id="resetCountBtn" class="danger">Zähler zurücksetzen</button>
<button id="grabberBtn">Kamera freigeben</button>
<button id="resVgaBtn" title="640×480 mehr FPS">VGA</button>
<button id="resSvgaBtn" title="800×600 mehr Detail">SVGA</button>
</div>
<div class="info" id="infoText">Klicke auf "Zähllinie setzen" und dann zweimal auf das Video, um die Zähllinie zu definieren.</div>
<a href="/">Back to Home</a>
<script>
const streamId = "{{ stream_id | default('') }}";
const canvas = document.getElementById('lineCanvas');
const ctx = canvas.getContext('2d');
const setLineBtn = document.getElementById('setLineBtn');
const resetCountBtn = document.getElementById('resetCountBtn');
const infoText = document.getElementById('infoText');
let isSettingLine = false;
let firstPoint = null;
let currentLine = null;
// Load existing line from server
fetch('/api/get_line')
.then(res => res.json())
.then(data => {
currentLine = data;
drawLine();
});
setLineBtn.addEventListener('click', () => {
isSettingLine = !isSettingLine;
if (isSettingLine) {
setLineBtn.textContent = 'Abbrechen';
setLineBtn.classList.add('active');
canvas.classList.add('active');
firstPoint = null;
infoText.textContent = 'Klicke auf den Startpunkt der Zähllinie...';
} else {
setLineBtn.textContent = 'Zähllinie setzen';
setLineBtn.classList.remove('active');
canvas.classList.remove('active');
firstPoint = null;
infoText.textContent = 'Klicke auf "Zähllinie setzen" und dann zweimal auf das Video, um die Zähllinie zu definieren.';
drawLine();
}
});
canvas.addEventListener('click', (e) => {
if (!isSettingLine) return;
const rect = canvas.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
if (!firstPoint) {
firstPoint = { x, y };
infoText.textContent = 'Klicke auf den Endpunkt der Zähllinie...';
drawTemporaryPoint(x, y);
} else {
currentLine = { x1: firstPoint.x, y1: firstPoint.y, x2: x, y2: y };
// Send line to server
fetch('/api/set_line', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentLine)
})
.then(res => res.json())
.then(data => {
console.log('Line set:', data);
infoText.textContent = 'Zähllinie erfolgreich gesetzt!';
setTimeout(() => {
infoText.textContent = 'Klicke auf "Zähllinie setzen" und dann zweimal auf das Video, um die Zähllinie zu definieren.';
}, 2000);
});
isSettingLine = false;
setLineBtn.textContent = 'Zähllinie setzen';
setLineBtn.classList.remove('active');
canvas.classList.remove('active');
firstPoint = null;
drawLine();
}
});
resetCountBtn.addEventListener('click', () => {
if (confirm('Zähler zurücksetzen?')) {
fetch('/api/reset_count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stream_id: streamId })
})
.then(res => {
if (!res.ok) {
throw new Error('Zurücksetzen fehlgeschlagen');
}
return res.json();
})
.then(() => {
infoText.textContent = 'Zähler wird zurückgesetzt...';
setTimeout(() => {
infoText.textContent = 'Klicke auf "Zähllinie setzen" und dann zweimal auf das Video, um die Zähllinie zu definieren.';
}, 2000);
})
.catch(err => alert(err.message));
}
});
function drawLine() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (currentLine) {
ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)';
ctx.lineWidth = 3;
ctx.setLineDash([10, 10]);
ctx.beginPath();
ctx.moveTo(currentLine.x1, currentLine.y1);
ctx.lineTo(currentLine.x2, currentLine.y2);
ctx.stroke();
ctx.setLineDash([]);
}
}
function drawTemporaryPoint(x, y) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawLine();
ctx.fillStyle = 'rgba(255, 255, 0, 0.8)';
ctx.beginPath();
ctx.arc(x, y, 5, 0, 2 * Math.PI);
ctx.fill();
}
// Redraw line periodically in case it gets cleared
setInterval(() => {
if (!isSettingLine && currentLine) {
drawLine();
}
}, 100);
// --- Kamera-Online-Status pollen + Auto-Recovery des Streams ---
const videoFeed = document.getElementById('videoFeed');
const offlineOverlay = document.getElementById('offlineOverlay');
const ovIcon = document.getElementById('ovIcon');
const ovMsg = document.getElementById('ovMsg');
const ovSub = document.getElementById('ovSub');
const grabberBtn = document.getElementById('grabberBtn');
let offlineStreak = 0; // aufeinanderfolgende Offline-Abfragen
let reloadPending = false; // erst nach echtem Ausfall den Stream neu laden
const OFFLINE_POLLS = 3; // ~6s -> erst dann "wirklich offline"
function showOverlay(icon, msg, sub) {
ovIcon.textContent = icon; ovMsg.textContent = msg; ovSub.textContent = sub;
offlineOverlay.classList.add('show');
}
async function checkCamStatus() {
let online = false, paused = false;
try {
const d = await (await fetch('/api/webcam_status', { cache: 'no-store' })).json();
online = d.online; paused = d.paused;
} catch (e) { online = false; }
grabberBtn.textContent = paused ? 'Kamera übernehmen' : 'Kamera freigeben';
grabberBtn.classList.toggle('active', paused);
if (paused) {
showOverlay('⏸️', 'Kamera freigegeben', 'Slot frei für anderen Zugriff Zählung pausiert');
offlineStreak = 0;
reloadPending = true; // beim Wiederaufnehmen Stream neu laden
return;
}
if (online) {
if (reloadPending) {
videoFeed.src = "{{ url_for('webcam_feed') }}?t=" + Date.now();
reloadPending = false;
}
offlineStreak = 0;
offlineOverlay.classList.remove('show');
} else {
offlineStreak++;
if (offlineStreak >= OFFLINE_POLLS) {
showOverlay('📷', 'Kamera offline', 'Versuche neu zu verbinden…');
reloadPending = true;
}
}
}
async function setFramesize(val, label) {
infoText.textContent = `Setze Auflösung auf ${label}`;
try {
const r = await fetch('/api/cam_framesize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ val })
});
const d = await r.json();
infoText.textContent = d.ok
? `Auflösung auf ${label} gesetzt`
: `Fehler: ${d.error || 'Kamera nicht erreichbar'}`;
} catch (e) {
infoText.textContent = 'Fehler beim Setzen der Auflösung';
}
setTimeout(() => {
infoText.textContent = 'Klicke auf "Zähllinie setzen" und dann zweimal auf das Video, um die Zähllinie zu definieren.';
}, 2500);
}
document.getElementById('resVgaBtn').addEventListener('click', () => setFramesize(10, 'VGA 640×480'));
document.getElementById('resSvgaBtn').addEventListener('click', () => setFramesize(11, 'SVGA 800×600'));
grabberBtn.addEventListener('click', async () => {
grabberBtn.disabled = true;
try {
await fetch('/api/grabber_toggle', { method: 'POST' });
} catch (e) { /* ignorieren */ }
grabberBtn.disabled = false;
checkCamStatus();
});
checkCamStatus();
setInterval(checkCamStatus, 2000);
// --- Zaehler-Box: Werte pollen ---
const cEl = {
total: document.getElementById('cTotal'),
car: document.getElementById('cCar'),
truck: document.getElementById('cTruck'),
bus: document.getElementById('cBus'),
moto: document.getElementById('cMoto'),
};
async function updateCounts() {
try {
const d = await (await fetch('/api/counts', { cache: 'no-store' })).json();
cEl.total.textContent = d.total;
cEl.car.textContent = d.car;
cEl.truck.textContent = d.truck;
cEl.bus.textContent = d.bus;
cEl.moto.textContent = d.motorcycle;
} catch (e) { /* ignorieren */ }
}
updateCounts();
setInterval(updateCounts, 1000);
// --- Zaehler-Box: per Maus verschiebbar (Position gemerkt) ---
const counterBox = document.getElementById('counterBox');
const videoContainer = document.querySelector('.video-container');
const savedPos = JSON.parse(localStorage.getItem('counterPos') || 'null');
if (savedPos) {
counterBox.style.left = savedPos.x + 'px';
counterBox.style.top = savedPos.y + 'px';
}
let dragging = false, offX = 0, offY = 0;
counterBox.addEventListener('mousedown', (e) => {
dragging = true;
offX = e.clientX - counterBox.offsetLeft;
offY = e.clientY - counterBox.offsetTop;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const maxX = videoContainer.clientWidth - counterBox.offsetWidth;
const maxY = videoContainer.clientHeight - counterBox.offsetHeight;
const x = Math.max(0, Math.min(e.clientX - offX, maxX));
const y = Math.max(0, Math.min(e.clientY - offY, maxY));
counterBox.style.left = x + 'px';
counterBox.style.top = y + 'px';
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
localStorage.setItem('counterPos', JSON.stringify({ x: counterBox.offsetLeft, y: counterBox.offsetTop }));
});
</script>
<script>
function applyThemeBtn(){var d=document.documentElement.getAttribute('data-theme')==='dark';var b=document.getElementById('themeToggle');if(b)b.textContent=d?'☀️':'🌙';}
function toggleTheme(){var n=document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);localStorage.setItem('theme',n);applyThemeBtn();}
applyThemeBtn();
</script>
</body>
</html>