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:
commit
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -0,0 +1,171 @@
|
||||
"""Holt für jede Art ein Referenzfoto aus der deutschen Wikipedia.
|
||||
|
||||
Damit weiß man, wonach man Ausschau hält, bevor eine Art zum ersten Mal selbst
|
||||
vor der Kamera saß. Urheber und Lizenz werden mitgespeichert und in der
|
||||
Oberfläche angezeigt — die Bilder stehen fast alle unter CC-BY-SA und die
|
||||
Namensnennung ist Bedingung, nicht Höflichkeit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from PIL import Image
|
||||
|
||||
from dex.models import Species
|
||||
|
||||
UA = "VogelalbumBot/1.0 (https://gaertner.onl; webmaster@gaertner.onl)"
|
||||
WP_API = "https://de.wikipedia.org/w/api.php"
|
||||
COMMONS_API = "https://commons.wikimedia.org/w/api.php"
|
||||
|
||||
MAX_WIDTH = 900
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _get(url: str, params: dict) -> dict:
|
||||
full = f"{url}?{urllib.parse.urlencode(params)}"
|
||||
req = urllib.request.Request(full, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _strip_html(value: str) -> str:
|
||||
"""Der Artist kommt als HTML-Schnipsel mit Link. Wir wollen nur den Namen."""
|
||||
text = TAG_RE.sub("", value or "")
|
||||
return " ".join(text.split())[:250]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Lädt Referenzfotos samt Lizenzangaben aus Wikipedia/Commons."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Auch Arten neu laden, die schon ein Referenzfoto haben.",
|
||||
)
|
||||
parser.add_argument("--only", help="Nur diese Art (Slug).")
|
||||
parser.add_argument(
|
||||
"--delay",
|
||||
type=float,
|
||||
default=0.6,
|
||||
help="Pause zwischen Anfragen; Wikipedia bittet um Zurückhaltung.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
qs = Species.objects.all()
|
||||
if opts["only"]:
|
||||
qs = qs.filter(slug=opts["only"])
|
||||
if not opts["force"]:
|
||||
qs = qs.filter(reference_image="")
|
||||
|
||||
ok = skipped = failed = 0
|
||||
for species in qs:
|
||||
try:
|
||||
if self._fetch(species, opts["delay"]):
|
||||
ok += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✓ {species.german}"))
|
||||
else:
|
||||
skipped += 1
|
||||
self.stdout.write(f" – {species.german}: kein Bild gefunden")
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
self.stdout.write(self.style.WARNING(f" ✗ {species.german}: {exc}"))
|
||||
time.sleep(opts["delay"])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\n{ok} geladen, {skipped} ohne Bild, {failed} Fehler.")
|
||||
)
|
||||
|
||||
def _lead_image(self, title: str) -> dict | None:
|
||||
page = _get(
|
||||
WP_API,
|
||||
{
|
||||
"action": "query",
|
||||
"titles": title,
|
||||
"prop": "pageimages",
|
||||
"piprop": "original|name",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
"redirects": "1",
|
||||
},
|
||||
)
|
||||
pages = page.get("query", {}).get("pages", [])
|
||||
if not pages or "original" not in pages[0]:
|
||||
return None
|
||||
return pages[0]
|
||||
|
||||
def _fetch(self, species: Species, delay: float) -> bool:
|
||||
# 1. Artikelbild der deutschen Wikipedia finden. Der deutsche Name ist
|
||||
# meist das Artikellemma — außer bei Gimpel, Kleiber, Sperber, Star:
|
||||
# das sind Begriffsklärungsseiten ohne Bild. Der wissenschaftliche
|
||||
# Name leitet dort zuverlässig auf den Artikel weiter.
|
||||
page = self._lead_image(species.german)
|
||||
if page is None:
|
||||
time.sleep(delay)
|
||||
page = self._lead_image(species.scientific)
|
||||
if page is None:
|
||||
return False
|
||||
|
||||
img_url = page["original"]["source"].split("?", 1)[0]
|
||||
file_name = page.get("pageimage")
|
||||
|
||||
# 2. Lizenz und Urheber von Commons nachschlagen.
|
||||
credit = license_name = source = ""
|
||||
if file_name:
|
||||
time.sleep(delay)
|
||||
meta = _get(
|
||||
COMMONS_API,
|
||||
{
|
||||
"action": "query",
|
||||
"titles": f"File:{file_name}",
|
||||
"prop": "imageinfo",
|
||||
"iiprop": "extmetadata|url",
|
||||
"format": "json",
|
||||
"formatversion": "2",
|
||||
},
|
||||
)
|
||||
info = meta.get("query", {}).get("pages", [{}])[0].get("imageinfo")
|
||||
if info:
|
||||
ext = info[0].get("extmetadata", {})
|
||||
credit = _strip_html(ext.get("Artist", {}).get("value", ""))
|
||||
license_name = _strip_html(
|
||||
ext.get("LicenseShortName", {}).get("value", "")
|
||||
)
|
||||
source = info[0].get("descriptionurl", "")
|
||||
|
||||
# 3. Bild holen und auf vernünftige Größe bringen.
|
||||
req = urllib.request.Request(img_url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||
raw = resp.read()
|
||||
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
if img.width > MAX_WIDTH:
|
||||
img = img.resize(
|
||||
(MAX_WIDTH, round(img.height * MAX_WIDTH / img.width)), Image.LANCZOS
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85, optimize=True)
|
||||
|
||||
species.reference_image.save(
|
||||
f"{species.slug}.jpg", ContentFile(buf.getvalue()), save=False
|
||||
)
|
||||
species.reference_credit = credit
|
||||
species.reference_license = license_name
|
||||
species.reference_source = source
|
||||
species.save(
|
||||
update_fields=[
|
||||
"reference_image",
|
||||
"reference_credit",
|
||||
"reference_license",
|
||||
"reference_source",
|
||||
]
|
||||
)
|
||||
return True
|
||||
Reference in new issue
Block a user