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.
This commit is contained in:
christian committed 2026-10-14 18:55:23 +00:00
commit cc9e0786c2
189 files changed
+29792

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
"""Erzeugt plausible Beispiel-Sichtungen, damit sich die Oberfläche vor dem
ersten echten Vogel beurteilen lässt.
Bewusst kein Teil des Normalbetriebs: ``--clear`` räumt alles wieder weg.
Die Bilder sind die Referenzfotos aus Wikipedia, keine echten Kameraaufnahmen
— die Sichtungen sind also erkennbar Attrappe.
"""
from __future__ import annotations
import random
from datetime import timedelta
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.utils import timezone
from dex import scoring
from dex.models import DexEntry, EarnedBadge, Sighting, Species
# Wie wahrscheinlich taucht eine Seltenheitsstufe auf? Grob an einem echten
# Garten orientiert: Kohlmeisen im Dauerbetrieb, der Sperber einmal im Monat.
RARITY_WEIGHT = {
"haeufig": 60,
"gelegentlich": 22,
"selten": 6,
"episch": 1.2,
"legendaer": 0.3,
}
# Vögel am Futterhaus haben zwei Stoßzeiten: kurz nach Sonnenaufgang und am
# späten Nachmittag.
HOUR_WEIGHT = [
0.1, 0.1, 0.1, 0.2, 0.6, 2.5, 6.0, 9.0, 8.0, 6.0, 4.5, 3.5,
3.0, 3.2, 4.0, 5.5, 7.0, 6.5, 4.0, 1.5, 0.5, 0.2, 0.1, 0.1,
]
class Command(BaseCommand):
help = "Legt Beispiel-Sichtungen an (nur zum Ansehen der Oberfläche)."
def add_arguments(self, parser):
parser.add_argument("--tage", type=int, default=21)
parser.add_argument("--pro-tag", type=int, default=9)
parser.add_argument(
"--clear",
action="store_true",
help="Alle Sichtungen, Dex-Einträge und Abzeichen löschen.",
)
parser.add_argument("--seed", type=int, default=1312)
def handle(self, *args, **opts):
if opts["clear"]:
n = Sighting.objects.count()
Sighting.objects.all().delete()
DexEntry.objects.all().delete()
EarnedBadge.objects.all().delete()
self.stdout.write(self.style.SUCCESS(f"{n} Sichtungen gelöscht."))
return
if Sighting.objects.exists():
self.stdout.write(
self.style.WARNING(
"Es gibt bereits Sichtungen. Erst 'demo_data --clear' ausführen, "
"sonst mischen sich Demo- und Echtdaten."
)
)
return
species = list(Species.objects.all())
if not species:
self.stdout.write(self.style.ERROR("Katalog leer — erst 'seed_dex'."))
return
rng = random.Random(opts["seed"])
weights = [RARITY_WEIGHT.get(s.rarity, 1) for s in species]
# Chronologisch anlegen, damit Erstfänge, XP und Abzeichen in der
# richtigen Reihenfolge verbucht werden.
now = timezone.localtime()
events = []
for day_offset in range(opts["tage"], -1, -1):
day = (now - timedelta(days=day_offset)).date()
for _ in range(rng.randint(max(1, opts["pro_tag"] - 4), opts["pro_tag"] + 4)):
hour = rng.choices(range(24), weights=HOUR_WEIGHT)[0]
start = timezone.make_aware(
timezone.datetime.combine(day, timezone.datetime.min.time())
) + timedelta(hours=hour, minutes=rng.randint(0, 59), seconds=rng.randint(0, 59))
if start > timezone.now():
continue
events.append(start)
events.sort()
created = 0
for start in events:
sp = rng.choices(species, weights=weights)[0]
# Ein Teil der Besuche bleibt unbestimmt — so sieht es im Betrieb
# auch aus, und die Oberfläche muss den Fall aushalten.
unbestimmt = rng.random() < 0.12
confidence = 0.0 if unbestimmt else rng.uniform(0.55, 0.99)
duration = rng.uniform(3, 75)
sighting = Sighting(
species=None if unbestimmt else sp,
confidence=confidence,
started_at=start,
ended_at=start + timedelta(seconds=duration),
frames_total=int(duration * 4),
frames_with_bird=int(duration * 4 * rng.uniform(0.5, 0.95)),
votes={sp.slug: round(confidence, 3)} if not unbestimmt else {},
mean_bird_score=rng.uniform(0.4, 0.95),
)
if not unbestimmt and sp.reference_image:
try:
sp.reference_image.open("rb")
data = sp.reference_image.read()
sp.reference_image.close()
sighting.image.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
sighting.crop.save(f"demo-{sp.slug}.jpg", ContentFile(data), save=False)
except OSError:
pass
sighting.save()
scoring.register(sighting)
created += 1
p = scoring.progress()
self.stdout.write(
self.style.SUCCESS(
f"{created} Beispiel-Sichtungen angelegt. "
f"Level {p.level} ({p.rank}), {p.xp} XP, "
f"{p.species_seen}/{p.species_total} Arten, {p.badges} Abzeichen."
)
)