Files
vehicle-counter/templates/webcam.html
Joachim Hummel 264b2b3e3b Fix: Offline-Erkennung loeste Reconnect-Resets aus (Zaehlung gestoert)
Bei GRABBER_ALWAYS_ON=0 kappte kurzes Zuschauer-Aus (<img>-Reload der
Auto-Recovery) die Kameraverbindung -> Reconnect -> Tracker/Zaehlzustand
wurde zurueckgesetzt. Bei ~1 FPS riss das die Zaehlung auseinander.

- Grabber: Karenzzeit (VIEWER_GRACE_SEC, Default 15s) bevor die Kamera
  bei fehlenden Zuschauern freigegeben wird -> kein Reconnect-Churn
- Frontend: Overlay/Reload erst nach ~6s echtem Ausfall (3 Polls),
  nicht bei einzelnen langsamen Frames -> kein Verbindungs-Churn

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

329 lines
12 KiB
HTML

<!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; }
.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="offlineOverlay" class="offline-overlay">
<div class="icon">📷</div>
<div class="msg">Kamera offline</div>
<div class="sub">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>
</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');
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"
async function checkCamStatus() {
let online = false;
try {
const res = await fetch('/api/webcam_status', { cache: 'no-store' });
online = (await res.json()).online;
} catch (e) {
online = false;
}
if (online) {
// Nur nach einem laengeren Ausfall den MJPEG-Stream neu anstossen
// (sonst Verbindungs-/Reconnect-Churn bei kurzen Aussetzern).
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) {
offlineOverlay.classList.add('show');
reloadPending = true;
}
}
}
checkCamStatus();
setInterval(checkCamStatus, 2000);
</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>