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

+118
View File
@@ -0,0 +1,118 @@
# Generated by Django 6.1 on 2026-08-28 09:08
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Badge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=48, unique=True)),
('name', models.CharField(max_length=80)),
('description', models.CharField(max_length=200)),
('emoji', models.CharField(default='🏅', max_length=8)),
('xp', models.PositiveIntegerField(default=50)),
('sort', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'Abzeichen',
'verbose_name_plural': 'Abzeichen',
'ordering': ['sort', 'name'],
},
),
migrations.CreateModel(
name='Sighting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('confidence', models.FloatField(default=0.0)),
('started_at', models.DateTimeField(db_index=True)),
('ended_at', models.DateTimeField()),
('image', models.ImageField(blank=True, upload_to='sightings/%Y/%m/')),
('crop', models.ImageField(blank=True, upload_to='crops/%Y/%m/')),
('frames_total', models.PositiveIntegerField(default=0)),
('frames_with_bird', models.PositiveIntegerField(default=0)),
('votes', models.JSONField(blank=True, default=dict)),
('mean_bird_score', models.FloatField(default=0.0)),
('is_first_of_species', models.BooleanField(default=False)),
('xp_awarded', models.PositiveIntegerField(default=0)),
],
options={
'verbose_name': 'Sichtung',
'verbose_name_plural': 'Sichtungen',
'ordering': ['-started_at'],
},
),
migrations.CreateModel(
name='Species',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=64, unique=True)),
('german', models.CharField(max_length=80, verbose_name='Deutscher Name')),
('scientific', models.CharField(max_length=120, verbose_name='Wissenschaftlicher Name')),
('english', models.CharField(blank=True, max_length=120, verbose_name='Englischer Name')),
('rarity', models.CharField(choices=[('haeufig', 'Häufig'), ('gelegentlich', 'Gelegentlich'), ('selten', 'Selten'), ('episch', 'Episch'), ('legendaer', 'Legendär')], max_length=16)),
('xp', models.PositiveIntegerField(default=10)),
('fact', models.TextField(blank=True, verbose_name='Steckbrief')),
('size_cm', models.CharField(blank=True, max_length=16)),
],
options={
'verbose_name': 'Art',
'verbose_name_plural': 'Arten',
'ordering': ['german'],
},
),
migrations.CreateModel(
name='EarnedBadge',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('earned_at', models.DateTimeField(default=django.utils.timezone.now)),
('badge', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='earned', to='dex.badge')),
('sighting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dex.sighting')),
],
options={
'verbose_name': 'Verdientes Abzeichen',
'verbose_name_plural': 'Verdiente Abzeichen',
'ordering': ['-earned_at'],
},
),
migrations.AddField(
model_name='sighting',
name='confirmed_species',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='confirmations', to='dex.species', verbose_name='Von Hand bestätigt als'),
),
migrations.AddField(
model_name='sighting',
name='species',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sightings', to='dex.species'),
),
migrations.CreateModel(
name='DexEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_seen', models.DateTimeField()),
('last_seen', models.DateTimeField()),
('count', models.PositiveIntegerField(default=0)),
('best_sighting', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dex.sighting')),
('species', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='entry', to='dex.species')),
],
options={
'verbose_name': 'Dex-Eintrag',
'verbose_name_plural': 'Dex-Einträge',
'ordering': ['-count'],
},
),
migrations.AddIndex(
model_name='sighting',
index=models.Index(fields=['-started_at', 'species'], name='dex_sightin_started_7b6a20_idx'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-08-28 09:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='species',
name='reference_credit',
field=models.CharField(blank=True, max_length=250, verbose_name='Urheber'),
),
migrations.AddField(
model_name='species',
name='reference_image',
field=models.ImageField(blank=True, upload_to='referenz/', verbose_name='Referenzfoto'),
),
migrations.AddField(
model_name='species',
name='reference_license',
field=models.CharField(blank=True, max_length=80, verbose_name='Lizenz'),
),
migrations.AddField(
model_name='species',
name='reference_source',
field=models.URLField(blank=True, max_length=400, verbose_name='Quelle'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 6.1 on 2026-08-28 11:11
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0002_species_reference_credit_species_reference_image_and_more'),
]
operations = [
migrations.CreateModel(
name='Camera',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=80)),
('slug', models.SlugField(max_length=48, unique=True)),
('location', models.CharField(blank=True, max_length=120, verbose_name='Standort')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('active', models.BooleanField(default=True, verbose_name='Aktiv')),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('last_seen', models.DateTimeField(blank=True, null=True, verbose_name='Zuletzt gemeldet')),
],
options={
'verbose_name': 'Kamera',
'verbose_name_plural': 'Kameras',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='sighting',
name='camera',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sightings', to='dex.camera', verbose_name='Kamera'),
),
]
@@ -0,0 +1,77 @@
# Generated by Django 6.1 on 2026-08-28 15:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0003_camera_sighting_camera'),
]
operations = [
migrations.AlterModelOptions(
name='species',
options={'ordering': ['number', 'german'], 'verbose_name': 'Art', 'verbose_name_plural': 'Arten'},
),
migrations.AddField(
model_name='species',
name='beschreibung',
field=models.TextField(blank=True, verbose_name='Beschreibung'),
),
migrations.AddField(
model_name='species',
name='gewicht_g',
field=models.FloatField(blank=True, null=True, verbose_name='Gewicht (g)'),
),
migrations.AddField(
model_name='species',
name='kategorie',
field=models.CharField(blank=True, max_length=80, verbose_name='Kategorie'),
),
migrations.AddField(
model_name='species',
name='lebensraum',
field=models.CharField(blank=True, max_length=200, verbose_name='Lebensraum'),
),
migrations.AddField(
model_name='species',
name='nahrung',
field=models.CharField(blank=True, max_length=200, verbose_name='Nahrung'),
),
migrations.AddField(
model_name='species',
name='number',
field=models.PositiveIntegerField(db_index=True, default=0, verbose_name='Dex-Nummer'),
),
migrations.AddField(
model_name='species',
name='spannweite_cm',
field=models.FloatField(blank=True, null=True, verbose_name='Spannweite (cm)'),
),
migrations.AddField(
model_name='species',
name='stimme',
field=models.CharField(blank=True, max_length=200, verbose_name='Stimme'),
),
migrations.AddField(
model_name='species',
name='typ',
field=models.CharField(blank=True, max_length=24, verbose_name='Typ'),
),
migrations.AddField(
model_name='species',
name='verwechslung',
field=models.JSONField(blank=True, default=list, verbose_name='Verwechslungsgefahr'),
),
migrations.AddField(
model_name='species',
name='zug',
field=models.CharField(blank=True, max_length=24, verbose_name='Zugverhalten'),
),
migrations.AlterField(
model_name='species',
name='fact',
field=models.TextField(blank=True, verbose_name='Kurzfakt'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 6.1 on 2026-08-28 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0004_alter_species_options_species_beschreibung_and_more'),
]
operations = [
migrations.AddField(
model_name='species',
name='beschreibung_en',
field=models.TextField(blank=True, verbose_name='Beschreibung (en)'),
),
migrations.AddField(
model_name='species',
name='kategorie_en',
field=models.CharField(blank=True, max_length=80, verbose_name='Kategorie (en)'),
),
migrations.AddField(
model_name='species',
name='lebensraum_en',
field=models.CharField(blank=True, max_length=200, verbose_name='Lebensraum (en)'),
),
migrations.AddField(
model_name='species',
name='nahrung_en',
field=models.CharField(blank=True, max_length=200, verbose_name='Nahrung (en)'),
),
migrations.AddField(
model_name='species',
name='stimme_en',
field=models.CharField(blank=True, max_length=200, verbose_name='Stimme (en)'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-08-28 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0005_species_beschreibung_en_species_kategorie_en_and_more'),
]
operations = [
migrations.AddField(
model_name='species',
name='lebensraum_typ',
field=models.CharField(db_index=True, default='garten', max_length=16, verbose_name='Lebensraum-Gruppe'),
),
migrations.AddField(
model_name='species',
name='regions',
field=models.JSONField(blank=True, default=list, verbose_name='Regionen'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-09-12 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0006_species_lebensraum_typ_species_regions'),
]
operations = [
migrations.AddField(
model_name='camera',
name='live_enabled',
field=models.BooleanField(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.', verbose_name='Live-Bild zeigen'),
),
migrations.AddField(
model_name='camera',
name='live_url',
field=models.URLField(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.', verbose_name='Live-Adresse'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-09-12 13:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0007_camera_live_enabled_camera_live_url'),
]
operations = [
migrations.AddField(
model_name='camera',
name='detect_threshold',
field=models.FloatField(default=0.4, help_text='Ab welcher Sicherheit etwas als Vogel gilt (0–1).', verbose_name='Schwelle Vogelerkennung'),
),
migrations.AddField(
model_name='camera',
name='habitats',
field=models.CharField(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.', max_length=120, verbose_name='Lebensräume'),
),
migrations.AddField(
model_name='camera',
name='min_bird_score',
field=models.FloatField(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.', verbose_name='Mindestanteil heimischer Arten'),
),
migrations.AddField(
model_name='camera',
name='stream_url',
field=models.URLField(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.', verbose_name='Stream-Adresse'),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 6.1 on 2026-09-23 20:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0008_camera_detect_threshold_camera_habitats_and_more'),
]
operations = [
migrations.CreateModel(
name='Benachrichtigung',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('aktiv', models.BooleanField(default=False, verbose_name='Meldungen einschalten')),
('server', models.URLField(default='https://ntfy.sh', help_text='Eigener Server oder ntfy.sh', verbose_name='ntfy-Server')),
('thema', models.CharField(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.', max_length=100, verbose_name='Thema')),
('sichtungen', models.CharField(choices=[('aus', 'keine Sichtungsmeldungen'), ('neue', 'nur neue Arten (Erstfang)'), ('alle', 'alle Sichtungen')], default='neue', help_text="Ein gut besuchtes Futterhaus erzeugt schnell Dutzende Meldungen am Tag. 'Nur neue Arten' bleibt dauerhaft interessant.", max_length=8, verbose_name='Sichtungen')),
('stoerungen', models.BooleanField(default=True, help_text='Kamera meldet sich nicht mehr, Erkennung steht.', verbose_name='Störungen melden')),
('stille_minuten', models.PositiveIntegerField(default=30, help_text='So lange darf eine Kamera schweigen, bevor es eine Meldung gibt. Zu knapp bedeutet Fehlalarm bei jedem Router-Neustart.', verbose_name='Störung nach … Minuten Stille')),
],
options={
'verbose_name': 'Benachrichtigung',
'verbose_name_plural': 'Benachrichtigungen',
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-23 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0009_benachrichtigung'),
]
operations = [
migrations.AddField(
model_name='camera',
name='letzter_kontakt',
field=models.DateTimeField(blank=True, null=True, verbose_name='Letztes Lebenszeichen'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-09-24 05:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0010_camera_letzter_kontakt'),
]
operations = [
migrations.AddField(
model_name='camera',
name='live_bildrate',
field=models.FloatField(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.', verbose_name='Bilder je Sekunde in der Vorschau'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-01 06:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0011_camera_live_bildrate'),
]
operations = [
migrations.AddField(
model_name='camera',
name='stoerung_gemeldet_seit',
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Störung gemeldet seit'),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.1 on 2026-10-01 12:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0012_camera_stoerung_gemeldet_seit'),
]
operations = [
migrations.AddField(
model_name='sighting',
name='latitude',
field=models.FloatField(blank=True, null=True, verbose_name='Breitengrad'),
),
migrations.AddField(
model_name='sighting',
name='longitude',
field=models.FloatField(blank=True, null=True, verbose_name='Längengrad'),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 6.1 on 2026-10-01 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0013_sighting_latitude_sighting_longitude'),
]
operations = [
migrations.AddField(
model_name='benachrichtigung',
name='fotos_gemeldet_seit',
field=models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Wartende Fotos gemeldet seit'),
),
migrations.AddField(
model_name='camera',
name='mobil',
field=models.BooleanField(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.', verbose_name='Mobiles Gerät'),
),
migrations.AddField(
model_name='sighting',
name='wartet_auf_bestimmung',
field=models.BooleanField(db_index=True, default=False, verbose_name='Wartet auf Bestimmung'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.1 on 2026-10-01 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0014_benachrichtigung_fotos_gemeldet_seit_camera_mobil_and_more'),
]
operations = [
migrations.AddField(
model_name='camera',
name='min_mean_bird_score',
field=models.FloatField(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.', verbose_name='Mindest-Vogelwert über den Besuch'),
),
]
+33
View File
@@ -0,0 +1,33 @@
# Generated by Django 6.1 on 2026-10-14 18:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dex', '0015_camera_min_mean_bird_score'),
]
operations = [
migrations.AddField(
model_name='sighting',
name='box_x0',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: links'),
),
migrations.AddField(
model_name='sighting',
name='box_x1',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: rechts'),
),
migrations.AddField(
model_name='sighting',
name='box_y0',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: oben'),
),
migrations.AddField(
model_name='sighting',
name='box_y1',
field=models.FloatField(blank=True, null=True, verbose_name='Vogel: unten'),
),
]
View File
Whitespace-only changes.