Files
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

958 lines
39 KiB
Python
Raw Permalink 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.
"""Datenmodell des Vogelalbum.
Drei Ebenen:
* ``Species`` — der Katalog. Was es theoretisch zu finden gibt.
* ``Sighting`` — ein einzelner Besuch vor der Kamera. Rohdaten, unveränderlich.
* ``DexEntry`` — der Sammel-Fortschritt pro Art. Abgeleitet, aber gespeichert,
damit die Dex-Seite nicht bei jedem Aufruf über alle Sichtungen aggregieren
muss.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import timedelta
from django.conf import settings
from django.db import models
from django.db.models.functions import Coalesce
from django.utils import timezone
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class Camera(models.Model):
"""Eine Kamera, die Sichtungen meldet.
Der Token identifiziert die Kamera — die Web-App muss sie deshalb nie
erreichen können. Der Detector meldet ausgehend, es gibt keinen Port, der
von außen offen sein müsste. Genau das erlaubt es, die Erkennung im
Heimnetz laufen zu lassen und die Sammlung öffentlich zu hosten.
Gespeichert wird nur der SHA-256 des Tokens. Ein hochentropischer Token
braucht kein Salt, und wer die Datenbank in die Hand bekommt, kann damit
trotzdem keine Sichtungen einliefern.
"""
name = models.CharField(max_length=80)
slug = models.SlugField(max_length=48, unique=True)
location = models.CharField("Standort", max_length=120, blank=True)
token_hash = models.CharField(max_length=64, unique=True, editable=False)
active = models.BooleanField("Aktiv", default=True)
created_at = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField("Zuletzt gemeldet", null=True, blank=True)
# 🔑 Getrennt von last_seen, und das ist der Kern der Sache: last_seen
# heisst "zuletzt einen Vogel gesehen". Daran zu messen, ob die Kamera
# lebt, waere falsch -- an einem ruhigen Nachmittag kommt stundenlang
# nichts, und die Anlage ist trotzdem in Ordnung.
#
# Hier steht, wann sich der Detector zuletzt gemeldet hat. Er fragt
# ohnehin alle fuenf Minuten die Konfiguration ab; dieser Abruf ist das
# Lebenszeichen. Kein zusaetzlicher Verkehr, kein neuer Endpunkt.
letzter_kontakt = models.DateTimeField(
"Letztes Lebenszeichen", null=True, blank=True)
# ⚠ Hier und nicht im Prozessspeicher, und das ist der ganze Witz:
# `stoerungen_pruefen` ist ein Einmal-Lauf. Ein Modul-Set lebt genau
# einen Durchlauf lang und ist beim naechsten wieder leer -- die
# Entprellung griff deshalb NIE. Am 01.10.2026 kam der Beweis aufs
# Handy: nach dem Stromausfall meldete der Waechter beide Kameras alle
# zehn Minuten neu, viermal in zwei Laeufen, "schon gemeldet" kein
# einziges Mal. Genau das Summen, das der Befehl vermeiden wollte.
#
# Ein Zeitstempel statt eines Ja/Nein: so steht da auch, SEIT WANN
# gemeldet ist. Wer spaeter eine Erinnerung nach einigen Stunden will,
# braucht dafuer kein neues Feld.
stoerung_gemeldet_seit = models.DateTimeField(
"Störung gemeldet seit", null=True, blank=True, editable=False)
# Was der Detector von hier holt statt von seiner Kommandozeile. Bis diese
# Felder existierten, stand die Einstellung nur im Startskript auf dem
# Rechner, auf dem der Detector läuft — wer die Kamera umstellen wollte,
# brauchte eine Shell.
stream_url = models.URLField(
"Stream-Adresse",
blank=True,
help_text="MJPEG- oder Snapshot-Adresse der Kamera, z.B. "
"http://vogelkamera.local:8080/stream. Leer heißt: der Detector "
"nimmt, was auf seiner Kommandozeile steht.",
)
habitats = models.CharField(
"Lebensräume",
max_length=120,
default="garten",
help_text="Kommagetrennt: garten, wald, offenland, wasser, gebirge. "
"Schränkt ein, welche Arten der Klassifikator überhaupt "
"erwägt — eine Kamera am Futterhaus soll gar nicht erst "
"überlegen, ob da ein Kranich sitzt. Leer lässt alle zu.",
)
detect_threshold = models.FloatField(
"Schwelle Vogelerkennung",
default=0.4,
help_text="Ab welcher Sicherheit etwas als Vogel gilt (0–1).",
)
min_bird_score = models.FloatField(
"Mindestanteil heimischer Arten",
default=0.05,
help_text="Darunter gilt die Sichtung als unbestimmt. Gemessen ist "
"0.05 richtig: bei 0.10 fliegen richtige Bestimmungen raus, "
"weil bei manchen Arten global die amerikanische "
"Schwesterart gewinnt.",
)
# ⚠️ **Nicht zu verwechseln mit ``min_bird_score`` eine Zeile höher.** Die
# Namen ähneln sich, die Bedeutung ist verschieden:
#
# min_bird_score gilt je EINZELBILD
# min_mean_bird_score gilt für den DURCHSCHNITT des ganzen Besuchs
#
# 🔑 Genau dieser Durchschnitt war die Lücke. Mit ``--min-bird-frames 1``
# genügte ein einziger Zufallstreffer unter 73 Bildern, um einen Besuch
# anzulegen — so entstand am 24.09.2026 eine „Kohlmeise" aus einer
# Befestigungsschraube (1 von 73 Bildern, Wert 0,098), und am 27.09.
# vierundzwanzig Besuche aus Spiegelungen des Mondes (Wert 0,000).
#
# 📌 **Vorgabe 0, und das ist Absicht.** Alles bei exakt null wird ohnehin
# abgewiesen; darüber hinaus will der Wert gemessen und nicht geraten sein.
# Aus den Daten vom 01.10.2026: echte Sichtungen lagen bei 0,269 bis 0,600,
# die Schraube bei 0,098, die Spiegelungen bei 0,000. Eine Grenze um 0,15
# fienge beide Fehler ab — belegt ist das aber nur durch sieben echte
# Sichtungen, und das ist zu wenig. Wer mehr Daten hat, dreht hier hoch.
min_mean_bird_score = models.FloatField(
"Mindest-Vogelwert über den Besuch",
default=0.0,
help_text="Liegt der mittlere Vogelwert eines Besuchs darunter, "
"entsteht gar keine Sichtung. 0 heißt: nur Meldungen ohne "
"jeden Vogelwert werden abgewiesen. Erst hochdrehen, wenn "
"genug eigene Sichtungen vorliegen — sonst wirft man "
"richtige Bestimmungen weg.",
)
# 🔑 Ein Handy ist auch eine Kamera — es hängt nur nicht am Haus. Es lädt
# Fotos hoch statt zu streamen, bringt seinen Ort je Aufnahme mit und hat
# weder Live-Bild noch Bildeinstellungen.
#
# ⚠️ **Entscheidend für den Störungswächter.** `stoerungen_pruefen` meldet
# eine Kamera, die sich länger als `stille_minuten` nicht gemeldet hat. Ein
# Vogelhaus meldet sich alle fünf Minuten, ein Handy alle paar Tage — ohne
# dieses Kennzeichen käme nach dem ersten ruhigen Wochenende „Kamera still:
# Handy" aufs Telefon. Das ist dieselbe Fehlerklasse wie das Abzeichen für
# Mondspiegelungen am 01.10.2026: eine Regel, die für den einen Gerätetyp
# richtig ist und beim anderen Fehlalarm erzeugt.
mobil = models.BooleanField(
"Mobiles Gerät",
default=False,
help_text="Lädt Fotos hoch statt zu streamen (Handy, Tablet). Solche "
"Geräte werden nicht überwacht — sie melden sich nur, wenn "
"jemand unterwegs fotografiert.",
)
# Nicht die Kamera selbst, sondern der Live-Verteiler des Detectors
# (`python -m detector watch --live-port`). Die Kamera liefert nur einen
# Stream; wer direkt bei ihr zuschaut, nimmt ihn der Erkennung weg.
live_bildrate = models.FloatField(
"Bilder je Sekunde in der Vorschau", default=2.0,
help_text=(
"Gilt NUR für Zuschauer — die Erkennung bekommt immer jedes Bild. "
"Gemessen am 24.09.2026: ohne Begrenzung schickt eine Kamera in "
"HD rund 6 bis 10 Mbit/s. Zwei Kameras nebeneinander sind über 16 "
"Mbit/s, und auf dem Handy dauert es entsprechend, bis das erste "
"Bild erscheint. 2 Bilder/s sind flüssig genug fürs Zuschauen und "
"brauchen etwa ein Achtel davon. Höher setzen heißt: ruckelfreier, "
"aber träger Start und mehr Funklast. 0 schaltet die Begrenzung ab."
),
)
live_url = models.URLField(
"Live-Adresse",
blank=True,
help_text="MJPEG-Adresse des Live-Verteilers, z.B. "
"http://erkennung.local:8131/live — NICHT die Kamera selbst, "
"sonst fehlt der Erkennung das Bild.",
)
live_enabled = models.BooleanField(
"Live-Bild zeigen",
default=False,
help_text="Zeigt das Livebild dieser Kamera auf der Seite /live/. "
"Bewusst standardmäßig aus: ein Livebild aus dem eigenen "
"Garten gehört nicht auf eine öffentliche Seite.",
)
class Meta:
verbose_name = "Kamera"
verbose_name_plural = "Kameras"
ordering = ["name"]
def __str__(self) -> str:
return self.name
@staticmethod
def new_token() -> str:
return secrets.token_urlsafe(32)
def set_token(self, token: str) -> None:
self.token_hash = hash_token(token)
@classmethod
def by_token(cls, token: str) -> "Camera | None":
return cls.objects.filter(token_hash=hash_token(token), active=True).first()
@property
def is_stale(self) -> bool:
"""Kein Lebenszeichen seit über einer Stunde — da hängt vermutlich etwas.
⚠️ **Gemessen wird ``letzter_kontakt``, NICHT ``last_seen``.** Die
beiden Felder sehen ähnlich aus und bedeuten Verschiedenes:
``last_seen`` heißt „zuletzt einen Vogel gesehen", ``letzter_kontakt``
heißt „die Kamera hat sich gemeldet". An ``last_seen`` zu messen hieße
zu warnen, wenn nichts los ist, statt wenn etwas kaputt ist.
🔑 **Genau dieser Fehler stand hier bis zum 01.10.2026.** Die
Dev-Server zeigte das Warnzeichen vor beiden Kameras, während beide
tadellos liefen: Lebenszeichen vor einer Minute, letzter Vogel aber
1810 bzw. 5435 Minuten her. Ein Zeichen, das fast immer steht, sagt
nichts mehr.
⚠️ Dass es ein bekannter Fehler ist, macht es schlimmer: In
``stoerungen_pruefen`` steht seit jeher der Kommentar „an last_seen zu
messen waere der naheliegende Fehler -- an einem ruhigen Nachmittag
kommt stundenlang kein Vogel". Dort wurde er erkannt und behoben, hier
blieb er stehen. Eine Lehre gilt für alle Stellen, nicht nur für die,
an der sie aufgefallen ist.
⚠️ **Mobile Geräte sind nie „still".** Ein Handy meldet sich, wenn
jemand spazieren geht; „seit einer Stunde nichts" ist dort der
Normalfall. Dieselbe Unterscheidung trifft ``stoerungen_pruefen``.
"""
if self.mobil:
return False
# 📌 Noch nie gemeldet bleibt auffällig. Der Störungswächter wertet das
# anders — er meldet eine frisch eingerichtete Kamera nicht aufs
# Telefon, und das ist dort richtig: Es ist keine Störung. Auf der
# Seite ist es trotzdem die Wahrheit, nämlich „von hier kam noch
# nichts". Dieses Verhalten stand schon vorher so im Test
# ``test_camera_without_contact_is_stale`` und bleibt unverändert;
# korrigiert wurde am 01.10.2026 allein die Messgröße.
if self.letzter_kontakt is None:
return True
return timezone.now() - self.letzter_kontakt > timedelta(hours=1)
@property
def shows_live(self) -> bool:
"""Ob diese Kamera ein Livebild zeigen darf.
Drei Bedingungen, und die Umgebung hat das letzte Wort: auf einer
öffentlichen Instanz steht ``VOGELALBUM_LIVE_ERLAUBT=0``, und dann nützt
auch ein Häkchen im Admin nichts. Ein Livebild aus einem Privatgarten
soll nicht an einem Fehlklick hängen.
"""
return bool(
settings.LIVE_ALLOWED
and self.active
and self.live_enabled
and self.live_url
)
@property
def habitat_set(self) -> set[str]:
"""Die Lebensräume als Menge. Leer heißt: alle Arten zulassen."""
return {h.strip() for h in self.habitats.split(",") if h.strip()}
@property
def live_bild_url(self) -> str:
"""Einzelbild statt Dauerstrom.
🔑 Warum nicht der MJPEG-Strom in der Übersicht: Am 24.09.2026
gemessen — eine Kamera allein erscheint sofort, zwei nebeneinander
lassen den Browser hängen. Ein Dauerstrom belegt seine Verbindung
für immer, und mit zweien kommt mancher Browser (besonders auf dem
Handy) durcheinander.
Einzelbilder sind kurze, gewöhnliche Anfragen: Sie blockieren
nichts, holen sich nach einem Fehler von selbst wieder, und die
Bildrate bestimmt schlicht, wie oft gefragt wird.
Für EINE Kamera bleibt der Strom die bessere Wahl -- deshalb ist
live_url weiterhin da.
"""
if not self.live_url:
return ""
return self.live_url.rstrip("/").removesuffix("/live") + "/bild.jpg"
def konfiguration(self) -> dict:
"""Was der Detector über ``/api/config/`` bekommt.
Die Web-App ist damit die Wahrheit darüber, welche Arten zählen und
ab welcher Sicherheit — nicht mehr die Kommandozeile auf dem Rechner,
auf dem der Detector läuft. Die Artenliste kommt aus der Datenbank,
eingeschränkt auf Region und Lebensräume dieser Kamera.
"""
arten = Species.objects.all()
if self.habitat_set:
arten = arten.filter(lebensraum_typ__in=self.habitat_set)
# Die Region wird in Python geprüft: SQLite kann kein 'contains' auf
# einem JSON-Feld, und ein Textvergleich wäre falsch — bei
# zweibuchstabigen Kürzeln findet 'de' auch 'den'. Bei einigen hundert
# Arten kostet die Schleife nichts.
region = settings.REGION
arten = [a for a in arten.order_by("scientific") if region in (a.regions or [])]
return {
"kamera": self.slug,
"stream_url": self.stream_url,
"habitats": sorted(self.habitat_set),
"live_bildrate": self.live_bildrate,
"detect_threshold": self.detect_threshold,
"min_bird_score": self.min_bird_score,
# ⚠️ Zwei ähnlich klingende Werte, verschiedene Bedeutung:
# min_bird_score gilt je Einzelbild, min_mean_bird_score für den
# Durchschnitt des ganzen Besuchs. Der Detector braucht beide —
# den zweiten, um Besuche gar nicht erst zu senden, die die
# Web-App ohnehin abweisen würde. Das spart vor allem Funk: Ein
# Bild hochzuladen und dann verworfen zu bekommen, kostet eine
# Kamera am Gartenzaun echte Bandbreite.
"min_mean_bird_score": self.min_mean_bird_score,
"region": settings.REGION,
# Der wissenschaftliche Name ist gleichzeitig der Schlüssel ins
# Label-File des Klassifikators — deshalb braucht es hier kein
# zweites Mapping.
"arten": [
{
"scientific": a.scientific,
"german": a.german,
"english": a.english,
"rarity": a.rarity,
"lebensraum": a.lebensraum_typ,
}
for a in arten
],
}
@classmethod
def live_cameras(cls):
if not settings.LIVE_ALLOWED:
return cls.objects.none()
return cls.objects.filter(active=True, live_enabled=True).exclude(live_url="")
RARITY_CHOICES = [
("haeufig", "Häufig"),
("gelegentlich", "Gelegentlich"),
("selten", "Selten"),
("episch", "Episch"),
("legendaer", "Legendär"),
]
RARITY_ORDER = {k: i for i, (k, _) in enumerate(RARITY_CHOICES)}
class Species(models.Model):
slug = models.SlugField(max_length=64, unique=True)
german = models.CharField("Deutscher Name", max_length=80)
scientific = models.CharField("Wissenschaftlicher Name", max_length=120)
english = models.CharField("Englischer Name", max_length=120, blank=True)
rarity = models.CharField(max_length=16, choices=RARITY_CHOICES)
xp = models.PositiveIntegerField(default=10)
fact = models.TextField("Kurzfakt", blank=True)
size_cm = models.CharField(max_length=16, blank=True)
# Steckbrief im Stil einer Sammelkarte. Alles optional, damit neue
# Arten in den Katalog können, bevor der Text dazu geschrieben ist.
number = models.PositiveIntegerField("Dex-Nummer", default=0, db_index=True)
typ = models.CharField("Typ", max_length=24, blank=True)
kategorie = models.CharField("Kategorie", max_length=80, blank=True)
beschreibung = models.TextField("Beschreibung", blank=True)
gewicht_g = models.FloatField("Gewicht (g)", null=True, blank=True)
spannweite_cm = models.FloatField("Spannweite (cm)", null=True, blank=True)
zug = models.CharField("Zugverhalten", max_length=24, blank=True)
nahrung = models.CharField("Nahrung", max_length=200, blank=True)
lebensraum = models.CharField("Lebensraum", max_length=200, blank=True)
stimme = models.CharField("Stimme", max_length=200, blank=True)
verwechslung = models.JSONField("Verwechslungsgefahr", default=list, blank=True)
# Wo die Art vorkommt und wo sie lebt. Die Region entscheidet, welcher
# Katalog überhaupt gilt; der Lebensraum teilt ihn in Portionen, die man
# noch überblickt.
regions = models.JSONField("Regionen", default=list, blank=True)
lebensraum_typ = models.CharField(
"Lebensraum-Gruppe", max_length=16, default="garten", db_index=True
)
# Englische Fassung der Texte. Bewusst eigene Felder statt einer
# Übersetzungs-Bibliothek: es sind fünf Felder in zwei Sprachen, und die
# Texte sind geschrieben, nicht übersetzt — eine wörtliche Übertragung
# liest sich in beiden Richtungen schlecht.
kategorie_en = models.CharField("Kategorie (en)", max_length=80, blank=True)
beschreibung_en = models.TextField("Beschreibung (en)", blank=True)
nahrung_en = models.CharField("Nahrung (en)", max_length=200, blank=True)
lebensraum_en = models.CharField("Lebensraum (en)", max_length=200, blank=True)
stimme_en = models.CharField("Stimme (en)", max_length=200, blank=True)
# Referenzfoto aus Wikipedia: zeigt, wonach man Ausschau hält, bevor die
# Art zum ersten Mal selbst vor der Kamera saß. Lizenz und Urheber werden
# mitgespeichert — die Bilder stehen unter CC und wollen genannt werden.
reference_image = models.ImageField(
"Referenzfoto", upload_to="referenz/", blank=True
)
reference_credit = models.CharField("Urheber", max_length=250, blank=True)
reference_license = models.CharField("Lizenz", max_length=80, blank=True)
reference_source = models.URLField("Quelle", max_length=400, blank=True)
class Meta:
verbose_name = "Art"
verbose_name_plural = "Arten"
ordering = ["number", "german"]
def __str__(self) -> str:
return self.german
@property
def dex_nr(self) -> str:
return f"#{self.number:03d}" if self.number else "#—"
def _lang(self, feld: str) -> str:
"""Feld in der aktiven Sprache, mit Rückfall auf Deutsch.
Der Rückfall ist Absicht: der Katalog wächst, und eine Art ohne
englischen Text soll den deutschen zeigen statt gar nichts.
"""
from django.utils.translation import get_language
if (get_language() or "de").startswith("en"):
return getattr(self, f"{feld}_en", "") or getattr(self, feld, "")
return getattr(self, feld, "")
@property
def name(self) -> str:
"""Artname in der aktiven Sprache."""
from django.utils.translation import get_language
if (get_language() or "de").startswith("en") and self.english:
return self.english
return self.german
@property
def zweitname(self) -> str:
"""Der jeweils andere Name — steht klein unter dem Titel."""
from django.utils.translation import get_language
if (get_language() or "de").startswith("en") and self.english:
return self.german
return self.english
@property
def kategorie_lokal(self) -> str:
return self._lang("kategorie")
@property
def nahrung_lokal(self) -> str:
return self._lang("nahrung")
@property
def lebensraum_lokal(self) -> str:
return self._lang("lebensraum")
@property
def stimme_lokal(self) -> str:
return self._lang("stimme")
@property
def zug_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import ZUG_LABEL, ZUG_LABEL_EN
table = (
ZUG_LABEL_EN if (get_language() or "de").startswith("en") else ZUG_LABEL
)
return table.get(self.zug, self.zug)
@property
def typ_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import TYPE_LABEL_EN, TYPE_STYLE
if (get_language() or "de").startswith("en"):
return TYPE_LABEL_EN.get(self.typ, self.typ or "—")
return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[0]
@property
def rarity_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import RARITY_LABEL_EN
if (get_language() or "de").startswith("en"):
return RARITY_LABEL_EN.get(self.rarity, self.rarity)
return self.get_rarity_display()
@property
def typ_color(self) -> str:
from .species_meta import TYPE_STYLE
return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[1]
@property
def typ_ink(self) -> str:
"""Schriftfarbe auf der Typfläche — die Farben sind zu hell für Weiß."""
from .species_meta import TYPE_STYLE
return TYPE_STYLE.get(self.typ, ("—", "#7f9b6a", "#101a14"))[2]
@property
def typ_vars(self) -> str:
"""Alle abgeleiteten Typfarben als fertige CSS-Variablen.
Vorberechnet statt per ``color-mix()`` im Stylesheet: das gibt es erst
ab Safari 16.2, und auf älteren Geräten fällt jede betroffene Regel
ersatzlos weg.
"""
from .species_meta import type_vars
return type_vars(self.typ)
@property
def absaetze(self) -> list[str]:
text = self._lang("beschreibung")
return [p.strip() for p in text.split("\n\n") if p.strip()]
def balken(self) -> list[dict]:
"""Statuswerte als Balken, wie die Werte auf einer Sammelkarte.
Die Bezugsgrößen sind die Extreme unseres eigenen Katalogs: der
Zaunkönig mit 9 g gegen den Kolkraben mit 1200 g. Ein Balken zeigt
also „wo steht diese Art unter unseren Arten", nicht einen absoluten
Anteil — genau das macht ihn auf einen Blick lesbar.
"""
from django.utils.translation import gettext as _
rows = []
def add(label: str, value, maximum: float, einheit: str, log: bool = False):
if value in (None, "", 0):
return
import math
if log:
pct = math.log10(value + 1) / math.log10(maximum + 1) * 100
else:
pct = value / maximum * 100
wert = f"{value:g} {einheit}".strip()
rows.append({"label": label, "wert": wert, "pct": max(4, min(100, round(pct)))})
# Gewicht logarithmisch: linear würde alles unter 100 g zu einem
# ununterscheidbaren Strich zusammenfallen.
add(_("Gewicht"), self.gewicht_g, 1200, "g", log=True)
add(_("Spannweite"), self.spannweite_cm, 125, "cm")
try:
groesse = float(str(self.size_cm).split()[0]) if self.size_cm else None
except (ValueError, IndexError):
groesse = None
add(_("Größe"), groesse, 64, "cm")
seltenheit = _("Seltenheit")
add(seltenheit, (self.rarity_rank + 1) * 20, 100, "")
if rows and rows[-1]["label"] == seltenheit:
rows[-1]["wert"] = self.rarity_label
return rows
@property
def habitat_label(self) -> str:
from django.utils.translation import get_language
from .species_meta import HABITATS
paar = HABITATS.get(self.lebensraum_typ)
if not paar:
return self.lebensraum_typ
return paar[1] if (get_language() or "de").startswith("en") else paar[0]
@property
def rarity_rank(self) -> int:
return RARITY_ORDER.get(self.rarity, 0)
@property
def rarity_color(self) -> str:
from .species_meta import RARITY_COLOR
return RARITY_COLOR.get(self.rarity, "#7f9b6a")
@property
def rarity_glow(self) -> str:
from .species_meta import RARITY_GLOW
return RARITY_GLOW.get(self.rarity, "none")
class SichtungsQuerySet(models.QuerySet):
"""Was als Besuch zählt — an EINER Stelle festgelegt.
🔑 Vorher stand diese Entscheidung siebenmal im Code, jedes Mal etwas
anders: mal ``Sighting.objects.count()`` (zählt alles), mal
``filter(species__isnull=False)`` (zählt die rohe Erkennung). Am
30.09.2026 war das Ergebnis zu sehen — die Übersicht meldete
„31 Besuche insgesamt" bei 7 echten Vögeln und „2 Arten heute" bei
einer. Beides waren keine Rechenfehler, sondern zwei verschiedene
Vorstellungen davon, was ein Besuch ist.
⚠️ ``effective_species`` ist eine Python-Property und in der Datenbank
nicht vorhanden. Wer danach filtern will, muss ``Coalesce`` nehmen —
sonst fällt es erst auf, wenn eine korrigierte Sichtung weiter bei der
falschen Art mitzählt.
"""
def mit_art(self):
"""Hängt ``art_id`` an: die bestätigte Art, sonst die erkannte."""
return self.annotate(
art_id=Coalesce("confirmed_species_id", "species_id")
)
def bestimmt(self):
"""Nur Sichtungen mit einer Art — das sind die echten Besuche.
📌 Unbestimmte Sichtungen bleiben in der Liste sichtbar (sie zeigen,
dass die Kamera arbeitet), aber sie sind kein Besuch: Am 30.09.2026
waren 24 von 31 Spiegelungen des Mondes auf der Vogeltränke.
📌 **Hochgeladene Fotos, die noch auf den Detector warten, fallen hier
von selbst heraus** — sie haben noch keine Art. Das ist kein Zufall,
auf den man sich verlässt, sondern dieselbe Regel: Was keine Art hat,
ist kein Besuch. Deshalb springt der Besuchszähler beim Hochladen
nicht hoch, um nach der Bestimmung wieder zurückzufallen.
"""
return self.filter(
models.Q(confirmed_species__isnull=False)
| models.Q(species__isnull=False)
)
def wartend(self):
"""Hochgeladene Fotos, die der Detector noch nicht bearbeitet hat.
Älteste zuerst — wer zuerst hochgeladen hat, wartet am längsten.
"""
return self.filter(wartet_auf_bestimmung=True).order_by("started_at")
def arten_anzahl(self) -> int:
"""Wie viele VERSCHIEDENE Arten — nach Bestätigung, nicht nach Modell."""
return (
self.bestimmt().mit_art()
.values("art_id").distinct().count()
)
class Sighting(models.Model):
"""Ein Besuch. Auch unbestimmte Besuche werden gespeichert — sie zeigen,
dass die Kamera arbeitet, und lassen sich später nachbestimmen.
⚠️ Zum Zählen immer ``objects.bestimmt()`` nehmen, nie ``objects.count()``
— siehe :class:`SichtungsQuerySet`.
"""
objects = SichtungsQuerySet.as_manager()
species = models.ForeignKey(
Species,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="sightings",
)
camera = models.ForeignKey(
Camera,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="sightings",
verbose_name="Kamera",
)
confidence = models.FloatField(default=0.0)
started_at = models.DateTimeField(db_index=True)
ended_at = models.DateTimeField()
image = models.ImageField(upload_to="sightings/%Y/%m/", blank=True)
crop = models.ImageField(upload_to="crops/%Y/%m/", blank=True)
# Wo der Vogel war. Leer bei allem, was aus einem Vogelhaus kommt: diese
# Kamera hängt fest, ihr Standort steht am Gerät (``Camera.location``) und
# gehört nicht an jede einzelne Sichtung.
#
# 🔑 Gefüllt wird das von unterwegs — ein Foto vom Spaziergang hat keinen
# festen Platz, und erst der Ort macht daraus mehr als ein Bild.
#
# ⚠️ **Koordinaten sind personenbezogene Daten.** Wer im eigenen Garten
# fotografiert, legt damit seine Wohnadresse in die Datenbank. Deshalb wird
# schon beim Einliefern auf rund einen Kilometer gerundet (``ORT_STELLEN``
# in ``api.py``) — die genaue Position entsteht gar nicht erst. Angezeigt
# wird sie zudem nicht von selbst. Dieselbe Überlegung wie beim Livebild
# (siehe ``Camera.live_enabled``): Was aus dem Privaten nach außen geht,
# darf nicht die Voreinstellung sein.
latitude = models.FloatField("Breitengrad", null=True, blank=True)
longitude = models.FloatField("Längengrad", null=True, blank=True)
# 🔑 **Noch nicht bestimmt ist etwas anderes als nicht bestimmbar.** Beides
# sieht an `species` gleich aus — leer. Ohne diese Unterscheidung könnte
# der Detector nicht wissen, welche Fotos auf ihn warten, und die
# Oberfläche nicht zwischen „gleich fertig" und „da war nichts" trennen.
#
# Gesetzt wird es beim Hochladen aus der App oder über das Formular, und
# vom Detector wieder entfernt, sobald er das Bild bearbeitet hat — egal ob
# mit Ergebnis oder ohne.
#
# ⚠️ Wartende Sichtungen sind KEIN Besuch (siehe ``SichtungsQuerySet``):
# sonst springt der Zähler beim Hochladen hoch und nach der Bestimmung
# wieder zurück, wenn gar kein Vogel drauf war.
wartet_auf_bestimmung = models.BooleanField(
"Wartet auf Bestimmung", default=False, db_index=True)
frames_total = models.PositiveIntegerField(default=0)
frames_with_bird = models.PositiveIntegerField(default=0)
votes = models.JSONField(default=dict, blank=True)
mean_bird_score = models.FloatField(default=0.0)
# 🔑 **Wo der Vogel im Bild sitzt — als vier Zahlen, nicht als zweites
# Bild.**
#
# Bis zum 14.10.2026 speicherte das Projekt neben dem Foto einen fertig
# geschnittenen Ausschnitt. Der Zuschnitt war damit für immer festgelegt:
# Eine bessere Regel half nur neuen Sichtungen, und alle vorhandenen
# mussten einzeln nachgezogen werden.
#
# Aufgefallen ist es an Handyfotos. Dort ist der Vogel klein im Bild, der
# enge Ausschnitt entsprechend schmal und hoch — und in einer quadratischen
# Kachel blieb davon die Bauchmitte übrig, formatfüllend und unscharf.
# Das Urteil darüber, und es war das richtige: *„Mir geht es darum, dass ein
# Nutzer das so nicht verwenden kann."*
#
# Mit den Koordinaten ist der Zuschnitt eine **Regel statt einer Datei**:
# Die Anzeige schneidet bei jedem Aufruf neu aus dem Original. Eine
# geänderte Regel wirkt sofort auf den gesamten Bestand, Website und App
# dürfen verschieden schneiden, und niemand braucht den Detector dafür.
#
# Werte sind relativ (0..1), damit sie eine Verkleinerung des Originals
# überleben. Leer heißt: von vor dieser Änderung — dann gilt weiter das
# gespeicherte ``crop``.
box_x0 = models.FloatField("Vogel: links", null=True, blank=True)
box_y0 = models.FloatField("Vogel: oben", null=True, blank=True)
box_x1 = models.FloatField("Vogel: rechts", null=True, blank=True)
box_y1 = models.FloatField("Vogel: unten", null=True, blank=True)
is_first_of_species = models.BooleanField(default=False)
xp_awarded = models.PositiveIntegerField(default=0)
# Wer die Sammlung betreut, kann eine Bestimmung überstimmen. Das ist später die
# Trainingsmenge für ein eigenes Modell auf genau dieser Kamera.
confirmed_species = models.ForeignKey(
Species,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="confirmations",
verbose_name="Von Hand bestätigt als",
)
class Meta:
verbose_name = "Sichtung"
verbose_name_plural = "Sichtungen"
ordering = ["-started_at"]
indexes = [models.Index(fields=["-started_at", "species"])]
def __str__(self) -> str:
who = self.effective_species.german if self.effective_species else "unbestimmt"
return f"{who} am {timezone.localtime(self.started_at):%d.%m. %H:%M}"
@property
def effective_species(self) -> Species | None:
"""Handkorrektur schlägt Modell."""
return self.confirmed_species or self.species
@property
def vogel_box(self) -> tuple[float, float, float, float] | None:
"""Wo der Vogel sitzt, relativ — oder ``None`` bei Altbestand."""
werte = (self.box_x0, self.box_y0, self.box_x1, self.box_y1)
if any(w is None for w in werte):
return None
x0, y0, x1, y1 = werte
if x1 <= x0 or y1 <= y0:
return None
return (x0, y0, x1, y1)
@property
def kachel_url(self) -> str:
"""Das Bild für eine Kachel: aus dem Original geschnitten.
🔑 Rückfall auf das gespeicherte ``crop`` für Sichtungen von vor der
Umstellung, danach auf das Original. So bleibt jede alte Sammlung
anzeigbar, ohne dass jemand etwas nachziehen muss.
"""
from .zuschnitt import adresse
neu = adresse(self)
if neu:
return neu
if self.crop:
return self.crop.url
return self.image.url if self.image else ""
@property
def duration_s(self) -> float:
return (self.ended_at - self.started_at).total_seconds()
class DexEntry(models.Model):
"""Sammel-Fortschritt für eine Art."""
species = models.OneToOneField(
Species, on_delete=models.CASCADE, related_name="entry"
)
first_seen = models.DateTimeField()
last_seen = models.DateTimeField()
count = models.PositiveIntegerField(default=0)
best_sighting = models.ForeignKey(
Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
verbose_name = "Dex-Eintrag"
verbose_name_plural = "Dex-Einträge"
ordering = ["-count"]
def __str__(self) -> str:
return f"{self.species.german} ×{self.count}"
@property
def is_new(self) -> bool:
return timezone.now() - self.first_seen < timedelta(days=3)
class Badge(models.Model):
"""Abzeichen. ``code`` wird von der Auswertung in ``scoring.py`` gesetzt."""
code = models.SlugField(max_length=48, unique=True)
name = models.CharField(max_length=80)
description = models.CharField(max_length=200)
emoji = models.CharField(max_length=8, default="🏅")
xp = models.PositiveIntegerField(default=50)
sort = models.PositiveIntegerField(default=0)
class Meta:
verbose_name = "Abzeichen"
verbose_name_plural = "Abzeichen"
ordering = ["sort", "name"]
def __str__(self) -> str:
return self.name
# Abzeichen sind ein fester, im Code definierter Satz. Deshalb liegt in der
# Datenbank der deutsche Text und die Übersetzung passiert bei der Ausgabe
# — anders als bei den Arten, wo der Katalog wächst und eigene Felder
# nötig sind. Ein Sprachwechsel braucht so keine Migration.
@property
def name_lokal(self) -> str:
from django.utils.translation import gettext as _
return _(self.name)
@property
def description_lokal(self) -> str:
from django.utils.translation import gettext as _
return _(self.description)
class EarnedBadge(models.Model):
badge = models.OneToOneField(Badge, on_delete=models.CASCADE, related_name="earned")
earned_at = models.DateTimeField(default=timezone.now)
sighting = models.ForeignKey(
Sighting, null=True, blank=True, on_delete=models.SET_NULL, related_name="+"
)
class Meta:
verbose_name = "Verdientes Abzeichen"
verbose_name_plural = "Verdiente Abzeichen"
ordering = ["-earned_at"]
def __str__(self) -> str:
return f"{self.badge.name} ({self.earned_at:%d.%m.%Y})"
class Benachrichtigung(models.Model):
"""Einstellungen für Meldungen über ntfy.
Es gibt genau einen Datensatz. Warum als Modell und nicht als Angabe in
den Einstellungsdateien: Man soll im Backend umstellen können,
ohne dass jemand die Anwendung neu startet -- und ohne dass er dafür
jemanden fragen muss.
"""
class Sichtungen(models.TextChoices):
AUS = "aus", "keine Sichtungsmeldungen"
NEUE = "neue", "nur neue Arten (Erstfang)"
ALLE = "alle", "alle Sichtungen"
aktiv = models.BooleanField("Meldungen einschalten", default=False)
server = models.URLField(
"ntfy-Server", default="https://ntfy.sh",
help_text="Eigener Server oder ntfy.sh",
)
thema = models.CharField(
"Thema", max_length=100, blank=True,
help_text="Der Name, den du in der ntfy-App abonnierst. "
"⚠ Wer das Thema kennt, kann mitlesen — nimm etwas, das "
"niemand errät.",
)
sichtungen = models.CharField(
"Sichtungen", max_length=8,
choices=Sichtungen.choices, default=Sichtungen.NEUE,
help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende "
"Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft "
"interessant.",
)
stoerungen = models.BooleanField(
"Störungen melden", default=True,
help_text="Kamera meldet sich nicht mehr, Erkennung steht.",
)
stille_minuten = models.PositiveIntegerField(
"Störung nach … Minuten Stille", default=30,
help_text="So lange darf eine Kamera schweigen, bevor es eine "
"Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem "
"Router-Neustart.",
)
# ⚠️ Merker für die Meldung „Fotos warten auf Bestimmung" — dasselbe
# Muster wie ``Camera.stoerung_gemeldet_seit`` und aus demselben Grund in
# der Datenbank statt im Prozess: ``stoerungen_pruefen`` ist ein Einmal-
# Lauf, ein Merker im Speicher lebt genau einen Durchlauf. Am 01.10.2026
# hat das eine Störung alle zehn Minuten neu aufs Telefon geschickt, bis
# der Merker hier gelandet ist. Ohne dieses Feld wäre derselbe Fehler mit
# der neuen Meldung sofort wieder da.
fotos_gemeldet_seit = models.DateTimeField(
"Wartende Fotos gemeldet seit", null=True, blank=True, editable=False)
class Meta:
verbose_name = "Benachrichtigung"
verbose_name_plural = "Benachrichtigungen"
def __str__(self):
return "Benachrichtigungen" + ("" if self.aktiv else " (aus)")
@classmethod
def laden(cls):
"""Den einen Datensatz holen, notfalls anlegen."""
eintrag, _ = cls.objects.get_or_create(pk=1)
return eintrag
@property
def ziel(self):
"""Vollständige Adresse, oder leer wenn nichts eingerichtet ist."""
if not self.thema:
return ""
return f"{self.server.rstrip('/')}/{self.thema}"