Files
vehicle-counter/templates/webcam.html
Joachim Hummel 07c0e44cd8 Webcam: Buttons fuer Kamera-Aufloesung (VGA/SVGA)
Schaltet die ESP32-CAM-Aufloesung per Button um (VGA 640x480 = mehr FPS,
SVGA 800x600 = mehr Detail). Server proxyt den /control-Aufruf an die
Kamera (Host automatisch aus CAMERA_URL, Port 80).

- /api/cam_framesize (Whitelist 10=VGA, 11=SVGA)
- zwei Buttons + Status-Feedback im Frontend

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

380 lines
14 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>
<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);
</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>