Files
vehicle-counter/templates/webcam.html
Joachim Hummel 85747d7a42 Webcam: Button zum Freigeben/Uebernehmen der Kamera (ESP32-Slot)
Trotz GRABBER_ALWAYS_ON=1 kann der ESP32-Slot per Button manuell
freigegeben werden (z.B. um die Cam woanders direkt zu oeffnen).
Freigegeben = Slot frei + Zaehlung pausiert; uebernehmen = wieder zaehlen.

- Grabber: paused-Flag, hat Vorrang vor ALWAYS_ON/Karenzzeit
- /api/grabber_toggle + paused-Feld in /api/webcam_status
- Button + eigener Overlay-Text ("Kamera freigegeben") im Frontend

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

356 lines
13 KiB
HTML
Raw 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; }
.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" 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>
</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;
}
}
}
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);
</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>