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.
136 lines
5.2 KiB
HTML
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 …</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 %}
|