Files
vogelalbum/web/templates/dex/live.html
T
christian c11fe9ab04 Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
2026-10-15 14:30:02 +00:00

136 lines
5.2 KiB
HTML

{% extends "dex/base.html" %}
{% load i18n l10n %}
{% block title %}{% trans "Live" %} — Vogelalbum{% endblock %}
{% block content %}
<h2>{% trans "Live" %}
<span class="count">
{% blocktrans count n=cameras|length %}{{ n }} Kamera{% plural %}{{ n }} Kameras{% endblocktrans %}
</span>
</h2>
<div class="live-grid">
{% for kamera in cameras %}
<figure class="live-karte">
{% comment %}
Das Bild kommt vom Live-Verteiler des Detectors, nicht von der Kamera —
die liefert nur einen Stream, und den braucht die Erkennung. Der Browser
lädt es direkt, der Server reicht nichts durch: sonst hinge an jedem
Zuschauer eine offene Verbindung in der Web-App.
{% endcomment %}
<div class="live-bild">
<img alt="{{ kamera.name }}"
data-quelle="{{ kamera.live_bild_url }}"
data-rate="{{ kamera.live_bildrate|unlocalize }}">
{% comment %}
Der Name liegt AUF dem Bild: In einer Reihe gleich aussehender
Gartenbilder erkennt man sonst nicht, welche Kamera welche ist —
und beim Scrollen ist die Bildunterschrift oft aus dem Blick.
⚠ Diese lange Kommentarform benutzen, nicht die kurze mit der
Raute: Die kurze gilt nur für EINE Zeile. Über mehrere Zeilen wird
sie nicht entfernt, sondern steht wörtlich auf der Seite. Genau das
ist am 23.09.2026 passiert, und man sieht es erst im Browser.
{% endcomment %}
<span class="warte">Bild wird geholt &hellip;</span>
<span class="live-schild">{{ kamera.name }}</span>
</div>
<figcaption>
<strong>{{ kamera.name }}</strong>
{% if kamera.location %}<span class="live-ort">{{ kamera.location }}</span>{% endif %}
</figcaption>
</figure>
{% endfor %}
</div>
<p class="d" style="margin-top:14px">
{% trans "Bleibt das Bild schwarz, läuft der Detector nicht — die Erkennung verteilt das Livebild aus ihrem eigenen Bildstrom." %}
</p>
<script>
/*
* Einzelbilder statt Dauerstrom.
*
* 🔑 Am 24.09.2026 gemessen und an der Anlage bestaetigt: EINE Kamera
* erscheint sofort, ZWEI nebeneinander lassen die Seite haengen. Ein
* MJPEG-Strom belegt seine Verbindung fuer immer; mit zwei solchen kommt
* mancher Browser durcheinander -- auf dem Handy besonders.
*
* Ein Einzelbild ist eine kurze, gewoehnliche Anfrage. Sie blockiert
* nichts, und nach einem Fehler wird beim naechsten Mal einfach wieder
* gefragt. Wie oft, sagt die Bildrate der Kamera aus dem Backend.
*
* ⚠ Der Zeitstempel an der Adresse ist Pflicht: Ohne ihn liefert der
* Browser sein zwischengespeichertes Bild aus und man sieht ein
* Standbild, das aussieht wie ein Livebild.
*/
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".live-bild img").forEach(function (bild) {
var warte = bild.parentElement.querySelector(".warte");
var quelle = bild.dataset.quelle;
var rate = parseFloat(bild.dataset.rate) || 2;
var pause = Math.max(200, 1000 / rate);
var laeuft = false;
if (!quelle) return;
bild.addEventListener("load", function () {
if (warte) warte.style.display = "none";
laeuft = false;
});
bild.addEventListener("error", function () {
if (warte) {
warte.style.display = "";
warte.textContent = "Kein Bild — neuer Versuch …";
}
laeuft = false;
});
function holen() {
// Nicht nachlegen, solange das vorige Bild noch unterwegs ist --
// sonst stapeln sich Anfragen, wenn die Strecke langsam ist.
if (!laeuft) {
laeuft = true;
bild.src = quelle + "?t=" + Date.now();
}
setTimeout(holen, pause);
}
holen();
});
});
</script>
<style>
.live-grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
.live-karte{margin:0;background:#000;border-radius:10px;overflow:hidden}
.live-karte img{display:block;width:100%;height:auto}
/* ⚠ Die Karte ist schwarz, die Schriftfarbe der Seite aber fast auch
(--text: #16211a). Ohne eigene Farbe stand die Beschriftung schwarz
auf schwarz — sie war da und trotzdem unsichtbar. */
.live-karte figcaption{padding:8px 12px;display:flex;gap:10px;
align-items:baseline;color:#fff}
.live-karte figcaption .live-ort{color:#9fb3a6;font-size:13px}
/* ⚠ Feste Form, auch wenn kein Bild da ist. Ohne das faellt die Karte
in sich zusammen, sobald der Bildstrom abreisst -- dann steht da nur
noch die Beschriftung auf einem schwarzen Strich, und es sieht nach
einem kaputten Programm aus statt nach einer Kamera ohne Bild. */
.live-bild{position:relative;aspect-ratio:16/9;background:#000;
display:flex;align-items:center;justify-content:center}
.live-bild img{position:absolute;inset:0;width:100%;height:100%;
object-fit:cover}
.live-bild .warte{color:#8a8a94;font-size:14px;text-align:center;
padding:0 20px;line-height:1.5}
.live-schild{position:absolute;top:10px;left:10px;
padding:4px 10px;border-radius:999px;
background:rgba(0,0,0,.55);color:#fff;
font-size:13px;font-weight:600;letter-spacing:.01em;
/* Damit der Name auch auf hellem Himmel lesbar bleibt. */
backdrop-filter:blur(4px);text-shadow:0 1px 2px rgba(0,0,0,.6)}
</style>
{% endblock %}