CameraCtrl

Raspberry Pi Timelapse Camera

Ich bin fasziniert von Timelapse-Aufnahmen und habe schon länger mit der Idee gespielt, den Fortschritt meiner Projekte im Keller auf diese Weise festzuhalten. Wegen des beengten Raumes wollte ich unbedingt eine Weitwinkel-Kamera, um alles gut einfangen zu können. Ich habe dann eine Weile mit der GoPro geliebäugelt. Da hätte es dann auch ein älteres Modell getan. Allerdings war das (nicht ganz so alte) Modell, das ich ins Auge gefasst hatte, mit allem drum und dran nicht gerade für lau zu haben. Ich wollte jedenfalls für diese Spielerei nicht so viel Geld ausgeben (da ich nicht daran glaubte, dass ich die Kamera außerhalb des Kellers groß einsetzen würde).

Sehr angetan war ich, als ich auf das Raspberry Pi Camera Module 3 stieß. Qualitativ kann das sicher nicht mit einer hochwertigen Kamera mithalten, aber mir erscheint die Bildqualität großartig. Und bei dem Preis hätte ich es im Zweifel auch verschmerzen können, wenn es am Ende doch ungenutzt in irgendeiner Schublade verschwunden wäre.

Ich wollte das Kameramodul mit dem Raspberry Pi Zero W 2 betreiben, da dieser deutlich mehr Leisung als sein Vorgänger hat und somit bei hochauflösenden Videoaufnahmen deutlich bessere Chancen mitzukommen ohne den Hitzetod zu sterben.

Da der Zero 2 allerdings noch immer auf unbestimmte Zeit nicht verfügbar ist, bin ich auf einen alten Zero ausgewichen, den ich aus dem Kadaver – ich meine Korpus – des ersten Gehäuses für meinen Wecker entwendete.
Für die Timelapse-Aufnahmen erzielt man mit Fotos in der Regel bessere Ergebnisse und die packt der alte Zero auch.

Um das Kameramodul am schmaleren Port des Pi Zero (Zero 2 genauso) anschließen zu können, ist ein anderes als das mitgelieferte Kabel nötig. Das kann man einfach gleich mitbestellen.

Den Gesamtaufwand für das Projekt habe ich nicht genau mitgetrackt. Ich habe es in einem Zeitraum von wenig mehr als einem Monat umgesetzt. Dabei hatte mich die Programmierung allerdings so gepackt, dass ich einige lange Abende am Rechner verbracht habe. Bei ein paar der folgenden Abschnitte habe ich den groben Zeitaufwand mit angeben können.

Den aktuellen Stand des Quellcode kannst du im CameraCtrl Repository einsehen. Die Code-Schnipsel in den folgenden Abschnitten dienen eher der Erläuterung und sind oft nicht auf dem finalen Stand.

Einrichten der Kamera

Quelle: https://notenoughtech.com/raspberry-pi/raspberry-pi-camera-module-3/

Das Anstecken ist ziemlich simpel. Meine Befestigung für das Kameramodul zunächst sehr provisorisch.

Auch das Einrichten war überraschend einfach. Im Grunde gab es gar nichts einzurichten.

Ich hatte vor einer Weile (aus anderen Gründen) das aktuelle Raspbian Bullseye auf dem Pi installiert. Dort ist libcamera bereits installiert.

Testbild erstellen: libcamera-jpeg -o test.jpg

Das Testbild hole ich mir auf meinen Desktop-Rechner via scp ausguss@camera:~/test.jpg test in ein lokal dafür angelegtes Testverzeichnis.

Ich freue mich über ein sehr scharfes weitwinkeliges Bild meiner Wohnzimmerdecke einschließlich meiner wundervoll glänzenden Stirnpartie.

Bilder

Schritt 1: Regelmäßige Screenshots

Aufwand: ca. 1.5h

Ein erstes kleines Python-Skript zum Erstellen eines Bildes mit Timestamp:

from datetime import datetime
from pathlib import Path
from subprocess import run


def timestamp():
    return datetime.now().strftime("%Y-%m-%d_%H-%M-%S")


def take_shot():
    out_file_path = Path.home() / "shots" / ("shot_" + timestamp() + ".jpg")
    run(["libcamera-jpeg", "-o", out_file_path])
    

if __name__ == "__main__":
    take_shot()

Und das sollte dann erstmal als Cronjob regelmäßig ausgeführt werden. Zum Beispiel alle 5 Minuten:

*/5 * * * * ausguss python /home/ausguss/camerashot.py

Verdauungsprobleme

Nachdem ich die Kamera ein paar Stunden laufen lassen hatte, stellte sich heraus, dass sie irgendwann aufgehört hat, Bilder zu machen. Der Cronjob rief weiterhin lustig das Skript auf und auch das tat was es sollte. Allerdings war wohl irgendwann libcamera hängen geblieben, sodass spätere Aufrufe nicht mehr auf die Kamera zugreifen konnten. Es hat auch nicht gereicht, den libcamera-jpeg-Prozess zu töten (hat nur die Fehlermeldung geändert). Vermutlich hing noch etwas herum. Ich habe allerdings nicht weiter recherchiert, sondern den Pi neu gestartet, was das Problem (vorübergehend zumindest) lösen konnte.

Mein Plan war ohnehin, das Picamera2 Python-Modul zum Steuern der Kamera zu verwenden, anstatt direkt auf die Library zuzugreifen. Ich hoffte, das würde dann auch das Problem von Verklemmungen lösen und hatte bisher keine derartigen Probleme mehr. Eventuell hat es auch geholfen, alles auf den neuesten verfügbaren Stand zu aktualisieren. An der Kameraanbindung wird noch recht viel gearbeitet, sodass sich dort öfter mal was tut.

Gehäuse

Aufwand: ca. 3.5h

Das Gehäuse habe ich relativ provisorisch aus Resten von dünnem Bastel-Laubholz zusammengeklopft und war dabei auch etwas verpeilt unterwegs. Es erfüllt seinen Zweck, ansonsten gibt es dazu nicht allzuviel zu sagen.

Ich habe eine metrische Mutter verwendet als Aufnahme für das Kamera-Stativ. Die Gewinde sind allerdings nicht kompatibel, daher passt das nicht so recht und vermutlich mache ich mir beide Gewinde kaputt. Ich habe das in Kauf genommen, da ich nichts Passendes da hatte und es trotzdem hält. Besser wäre, eine geeignete Aufnahme (also Mutter mit passendem Gewinde) zu kaufen.

Bilder

Erste Timelapse Video Creation

Aufwand: ca. 0.5h

Quelle: https://stackoverflow.com/questions/44947505/how-to-make-a-movie-out-of-images-in-python

pip install opencv-python
import cv2  
from pathlib import Path  
  
  
def create_timelapse(image_folder_path: Path, out_file_path: Path) -%3E None:  
    images = [img for img in image_folder_path.iterdir() if img.suffix.lower() == ".jpg"]  
    frame = cv2.imread(str(images[0]))  
    height, width, layers = frame.shape  
  
    video = cv2.VideoWriter(str(out_file_path), 0, 1, (width, height))  
  
    for image in images:  
        video.write(cv2.imread(str(image)))  
  
    cv2.destroyAllWindows()  
    video.release()  
  
  
if __name__ == "__main__":  
    create_timelapse(Path('test'), Path("test-video.avi"))

Alternativ wäre auch ffmpeg eine Möglichkeit, etwas in der Art: ffmpeg -r 1 -pattern_type glob -i "shot_*.jpg" -vcodec libx264 timelapse.mp4

Weitere Schritte

Aufwand: ca. 0.5h

Verkabeln

Erstaunlich aufwändig, die passenden Kabel zu suchen, auszuprobieren, auszutauschen. Außerdem habe ich kein passendes Netzteil. Ich musste vorläufig ein übertrieben langes Verlängerungskabel einstecken, weil sich die vorhandenen Netzteile nicht richtig in die Steckerleiste stecken lassen wollten.

Start/Pause

Während der RPi lief machte er nun fröhlich Bilder. Daher habe ich das Skript angepasst. Es prüft einfach, ob eine bestimmte Datei vorhanden ist und verzichtet in diesem Fall auf ein Foto.

from pathlib import Path


def take_shot():
	if Path('pause.file').exists():
		return

Die Datei legte ich zunächst manuell mit touch an. Inzwischen wird das Anlegen und Entfernen natürlich über das Python-Web-Interface gesteuert.

Web-Interface

Code: CameraCtrl Repository

Quellen:

  • Einführung für Flask Web Interface: Ich habe schon für meinen Wecker eine Flask-Anwendung geschrieben. Ich wusste jedoch nicht, wie einfach sich da ein Web-Frontend für den Browser integrieren lässt und fand dieses Tutorial sehr hilfreich.
  • Wiederkehrende Tasks mit APScheduler: Für meinem Wecker hatte ich keine einfache und zufriedenstellende Lösung für das Ausführen von Aufgaben zu bestimmten Zeiten gefunden. Hier ist sie nun.
  • Event-Loop und Flask: Für den apscheduler.AsyncIOScheduler benötige ich eine Asyncio Event-Loop, die nicht der Ausführung der Flask-App in die Quere kommt. Das war, glaube ich, der Punkt an dem ich bei meinem Wecker aufgegeben und eine andere Lösung gefunden habe. Der Trick ist jedoch eigentlich ganz einfach: Die Event-Loop in einem eigenen Thread starten.

Erste Schritte

Aufwand: ca. 5h (?)

Ich setze eine Flask-App auf. Zunächst gibt es nur eine Route ('/'). Dort nutzte ich die render_template-Funktion, um aus einem index.html-Template das Web-Interface zu generieren.

Website-Template

Die Webseite besteht zunächst im Wesentlichen aus ein paar Überschriften, einem Bild und zwei Buttons. Die Variablen innerhalb der {{ }}-Ausdrücke werden der render_template-Funktion übergeben.

Vorschaubild:

  <img src="{{ url_for('static', filename= image ) }}"/>

Update-Button:

<button type="submit" name="update" class="btn btn-primary">Update</button>

Pause-Button:

<button type="submit" name="toggle_pause" class="btn btn-primary">{{ pause_btn_text }}</button>

So einfach übergibt man Variablen in render_template:

pause_btn_text = 'Resume' if camera.paused else 'Pause'  
return render_template('index.html', image=camera.preview_path, pause_btn_text=pause_btn_text)

Auf Buttons hören

Die /-Route muss nun auch auf Post-Requests hören:

@app.route('/', methods=('GET', 'POST'))

So kann ich nun über die Namen der Buttons herausfinden, ob einer von ihnen gedrückt wurde, z.B.:

    if request.method == 'POST':  
        if 'update' in request.form:  
            camera.update_preview()

Dummy-Camera

Ich entwickle den Großteil der Anwendung auf meinem Windows-Rechner. Dort habe ich natürlich kein Raspberry Pi Camera Modul. Ich könnte versuchen, meine Webcam in Python anzusteuern, aber die Mühe spare ich mir, da ich die Anwendung ja nur auf dem RPi brauche.

Die Camera-Klasse hat einen Scheduler mit einem Job, der ganz ähnlich zum Cron-Job funktioniert:

self.scheduler = AsyncIOScheduler(event_loop=event_loop)  
self.job = self.scheduler.add_job(self._shoot_if_running, "cron", minute="*")

Über den Funktionsnamen _shoot_if_running kann ich mich ein bisschen freuen. Die Dummy-Kamera macht nichts anderes, als eine Nachricht ins Log-File zu schreiben:

logging.info(datetime.now())

Die Ausführung kann ganz einfach pausiert und fortgesetzt werden.

self.scheduler.pause()
self.scheduler.resume()

Bei mehreren Jobs könnte man das, soweit ich weiß, auch pro Job steuern. Ich lege übrigens trotzdem noch eine Datei paused.file an bzw. entferne sie wieder, damit der aktuelle Zustand auch über einen Neustart der Anwendung erhalten bleibt.

Die update_preview-Funktion kopiert einfach abwechselnd eine von zwei Bilddateien an den Preview-Pfad, damit in der Weboberfläche ein Unterschied sichtbar wird. Die echte Kamera sollte hier natürlich ein Bild machen (also die Vorschau durch einen aktuellen Schnappschuss ersetzen).

Ich werde wohl auch zukünftig die Dummy-Kamera-Implementierung parallel zur Picamera-Implementierung behalten, damit unter Windows das Ausführen von Tests möglich ist.

Picamera2

Quellen:

Picamera2 bietet verschiedene High- und Low-Level Funktionen zum Zugriff auf die Kamera. Ich nehme im ersten Anlauf eher High-Level-Funktionen, um schnell ein brauchbares Ergebnis zu erzielen.

Konfiguration für Fotos:

self._cam.configure(self._cam.create_still_configuration())

Foto schießen:

self._cam.start_and_capture_file(name=str(file_path), delay=0, show_preview=False)

Längerfristig werde ich vermutlich auf die Low-Level Funktionen umsteigen, um mehr Einfluss auf Details zu haben.

Config

Ich habe aus folgenden Gründen ein Config-Objekt eingeführt:

  • Konfigurations-Einstellungen wie z.B. Pfade sind an einem Ort hinterlegt und überall zugreifbar.
  • Einstellungen werden in einer JSON-Datei abgelegt und können dort angepasst werden ohne den Python-Code zu ändern.
  • Die individuelle Konfiguration im JSON ist nicht Teil des Git-Repository und bleibt somit privat (was insbesondere für die Authentifizierung relevant ist).

GET/DELETE shots

Über die Route /shots/ kann man sich im JSON-Format eine Liste der verfügbaren Bilder geben lassen.

Flask jsonify baut aus dem Dict mit den Dateinamen die korrekte JSON-Response zusammen:

    return jsonify(shots_dict)

Über die folgende Route können einzelne Dateien heruntergeladen und gelöscht werden:

@app.route('/shots/<path:file_name>', methods=['GET', 'DELETE'])  
@auth.login_required  
def shot(file_name):  
    if request.method == 'GET':  
        try:  
            return send_from_directory(config.shots_dir, file_name, as_attachment=True)  
        except FileNotFoundError:  
            abort(404)  
    elif request.method == 'DELETE':  
        matches = [file for file in config.shots_dir.iterdir() if file.match(file_name)]  
        if len(matches) == 0:  
            abort(404)  
        else:  
            for m in matches:  
                logging.info(f"Deleting {m}")  
                m.unlink()  
            return jsonify({'success': True})

Authentifizierung

Für die Authentifizierung verwende ich ein einfaches Bearer-Token, das über den Request-Header mitgeschickt werden muss. Das Token ist in der Config hinterlegt und natürlich in der config.json abgeändert.

from flask_httpauth import HTTPTokenAuth

auth = HTTPTokenAuth(scheme='Bearer')    
  
@auth.verify_token  
def verify_token(token: str):  
    if token == config.bearer_token:  
        return "ok"  
    else:  
        logging.info("Invalid authentication token: " + token)  
        return None

Die Routen werden dann mit @auth.login_required gekennzeichnet.

Um den Authentifizierungs-Header im Browser mitschicken zu können, ist ein Plugin nötig. Ich gebe hier lieber keine Empfehlung ab, aber bei mir klappt es und ich hoffe, es ist keine Spyware.

Multi-Auth

Quellen:

Ich möchte in der Lage sein, über mein Handy auf die Kamera zuzugreifen, um zwischendurch die Kamera pausieren/fortsetzen zu können und die Vorschau des Bildausschnitts abzurufen. Da ich für das Handy keine einfache Möglichkeit gefunden habe, im Browser einen eigenen Header mitzuschicken, habe hier später nachgebessert und erlaube nun alternativ die Authentifizierung über Benutzername und Passwort.

    basic_auth = HTTPBasicAuth()  
    token_auth = HTTPTokenAuth(scheme='Bearer')  
    multi_auth = MultiAuth(basic_auth, token_auth)

Für basic_auth wird über folgende Funktion die Password-Verifizierung gemacht:

def verify_password(self, username, password)

Ich habe in der Zwischenzeit meine Anwendung umstrukturiert, sodass ich nun eine Klasse habe, die vom Flask-Server erbt (mehr dazu unten im Abschnitt “Tests”). Daher kann ich allerdings nicht mehr alle Annotations nutzen. Anstelle von @auth.verify_token bzw. @auth.verify_password tritt also nun in der __init__ der Klasse die Zuweisung der Callbacks:

self.basic_auth.verify_password_callback = self.verify_password  
self.token_auth.verify_token_callback = self.verify_token

Die Authentifizierungs-Annotations an den Routen funktionieren aber noch. Hier habe ich jetzt wahlweise @multi_auth.login_required oder nur @token_auth.login_required.

Es sei angemerkt, dass das hier nicht unbedingt der sicherste Weg der Authentifizierung ist und es nicht gerade schön ist, dass die entsprechenden Daten im Klartext auf dem Raspberry Pi liegen. Für meine Zwecke reicht es aber, da ich hier ohnehin nicht mit sensiblen Daten hantiere. Zumal die meisten (Bild-)Daten ohnehin das Internet als finale Destination haben.

Auf das Thema Https und die Frage, warum ich überhaupt dieses ganze Authentifizierungs-Gedöns einbauen, wenn sowieso alles nur im lokalen Netzwerk zugreifbar ist, gehe ich weiter unten ein, wenn es um die Produktivumgebung geht.

Tests

Als Vorbereitung auf die Tests habe ich meine Anwendung umstrukturiert. Der überwiegende Teil des Server-Codes ist nun in einer eigenen Klasse CameraCtrlApp(Flask) gekapselt. So habe ich deutlich bessere Kontrolle über die Pfade und andere Konfigurationseinstellungen. Ich kann der Anwendung also nun für die Tests eine eigene Konfiguration unterschieben und alle angelegten Verzeichnisse und Dateien unter einem temporären Verzeichnis sammeln, das nach den Tests (und zwischen den Tests) gelöscht wird.

Meine Routen sind nun Funktionen innerhalb der neuen Klasse. Dadurch funktionieren allerdings die Route-Annotations nicht mehr. Stattdessen werden nun in der __init__-Funktion die Routen angelegt, z.B.:

self.add_url_rule('/', 'index', self.index, methods=['GET', 'POST'])

Production Server

Der in Flask integrierte Webserver ist nicht als Produktivumgebung vorgesehen und sollte auch nicht als solche verwendet werden. Für die Produktivumgebung fahre ich wieder das gleiche Rezept wie auch schon bei meinem Wecker: Supervisor + Gunicorn + Nginx

Anleitungen findet man dazu zu Genüge, z.B. hier. Das Einrichten ist eigentlich nicht besonders schwierig, aber ich schätze man kann viel falsch machen und ich finde die Fehlersuche nicht ganz einfach.

Unter /etc/supervisor/conf.d/cameractrl.conf habe ich eine Supervisor-Konfiguration hinterlegt, die das automatische (und manuelle) Starten/Neustarten des Gunicorn-Servers händelt.

Der Server wird dort über folgenden Befehl gestartet: gunicorn --workers 1 --bind unix:cameractrl.sock -m 007 run_server:app
Dadurch wird ein Socket angelegt, über den Nginx dann mit dem Server kommunizieren kann. Ich weiß nicht, wofür -m 007 steht, aber fand es cool und habe es daher übernommen. Die Flask-App wird über run_server.py gefunden und ausgeführt. Man könnte auch run_server:run() nutzen, allerdings müsste dann vermutlich in der run()-Funktion noch der Port für die Flask-App übergeben werden. Zumindest wurde bei mir die App dauernd beendet und neu gestartet. Ich denke, das lag daran, dass der Standard-Port von Flask bereits anderweitig in Verwendung war (für das Neustarten war natürlich Supervisor verantwortlich).

Nun soll noch Nginx die Schnittstelle zur Außenwelt übernehmen. Dafür gibt es eine weitere Config-Datei: /etc/nginx/sites-enabled/cameractrl Darin ist der Servername und Port definiert und über Locations geklärt, wo die anzubietenden Dateien bzw. der Socket liegen. Details stehen ja in der Anleitung.

Ich habe wieder ziemlich lange gesucht, was bei mir falsch läuft (502 Bad Gateway).
Problem: Ich hatte einen spezifischen User unter dem mein gunicorn cameractrl-Server läuft und die Anwendungsdateien im Home-Verzeichnis dieses Users liegen. Ich dachte mir schon, dass ich Nginx irgendwie beibringen muss, mit dem User klarzukommen. Die Lösung war dann, Nginx unter diesem User auszuführen anstelle des Default-Users www-data. Das passiert in der /etc/nginx/nginx.config. Und schon geht es.

CORS

Im Zuge der Fehlersuche bezüglich der Einrichtung der Server-Umgebung habe ich die Möglichkeit geschaffen CORS zu aktivieren (Cross origin resource sharing).

Das brauchte ich bei meinem Wecker, um Probleme in der Kommunikation zu vermeiden. Für CameraCtrl ist es aber aktuell nicht nötig, daher habe ich es in der .env wieder deaktiviert.

Https

Quellen:

Um die Authentifizierungs-Daten und alles Weitere nicht unverschlüsselt durch den Äther zu jagen, soll die Kommunikation über Https stattfinden.

Den verlinkten Blogbeitrag fand ich sehr aufschlussreich. Da ich den Angaben dort weitgehend folge, erspare ich mir/euch hier, alles nochmal abzutippen. Hier die Kurzfassung:

Der Raspberry Pi benötigt ein SSL-Zertifikat.

Ich wähle den Weg, ein selbstgeneriertes Zertifikat in Nginx zu konfigurieren. Da nur ich selbst auf die Kamera zugreifen möchte, ist das ausreichend. Ich kann dann in meinem Browser einstellen, dem Zertifikat zu trauen und werde danach nicht mehr mit Warnungen belästigt. Ich muss dafür allerdings den großen Schritt wagen, mir selbst, als Verfasser des Zertifikats, zu vertrauen.

Das Zertifikat wird über openssl generiert. Die relevante Zeile in oben angegebener Anleitung (ich habe sie für meine Zwecke leicht angepasst):

openssl req -x509 -newkey rsa:4096 -nodes -out cert.pem -keyout key.pem -days 365

Der CN (allgemeine Name) des Zertifikats muss üblicherweise mit dem Hostnamen übereinstimmen für den das Zertifikat gilt. Ein anderer Weg wird im BrainBytez-Tutorial beschrieben. Diesen Weg habe ich nun für mich gewählt, da ich dann in einer Datei namens openssl.ss.cnf alternative Hostnamen angeben kann, sodass ich das gleiche Zertifikat sowohl für meine öffentliche Domain als auch für den Hostnamen im lokalen Netz nutzen kann.

Nun bringe ich noch Nginx bei, das Zertifikat für CameraCtrl zu nutzen. Dafür hinterlege ich in der Konfiguration für meine Seite die Pfade zu den beiden gerade generierten Zertifikatsdateien und ergänze am Ende des listen-Eintrags noch ssl http2, im Beispiel aus dem Blogbeitrag:

server {
    listen 443 ssl http2;
    server_name example.com;
    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;
    # &hellip;
}

In diesem Beispiel wird der Standard-Port für Https verwendet (443). So kann einfach eine Regel eingerichtet werden, dass Aufrufe von Port 80 (Http) auf Port 443 umgeleitet werden. Ich habe allerdings einen eigenen Port verwendet und niemand kann mich davon abhalten, beim Aufruf der Seite statt https:// sturr http:// davor zu schreiben. Also muss ich mich wieder mal vor mir selbst schützen. Hilfe dazu habe ich im oben verlinkten Stackoverflow-Beitrag gefunden.

Die magische Zeile lautet:

error_page 497 301 =307 https://$host:$server_port$request_uri;

Da wird über die Fehlerseite, die genau für diesen Fall gedacht ist, die Weiterleitung getriggert. In meinem Fall hat $server_port nicht funktioniert. Vermutlich, weil ich es nicht in der location konfiguriert habe. Aber da ich immer den gleichen Port nutze, habe ich geschummelt und ihn einfach direkt eingetragen.

Aber warum überhaupt Sicherheit?

Zum einen natürlich, weil ich es einfach mal ausprobieren wollte.

Es gibt aber auch einen weiteren Grund: Mein Handy hat im Keller keinen WLAN-Empfang. Ich möchte aber ja gerade im Keller in der Lage sein, über mein Handy die Kamera zu steuern.

Für dieses Problem sind mir verschiedene Lösungsansätze eingefallen:

  • Die Geräte in ein gemeinsames VPN stecken, damit sie über das Internet privat miteinander sprechen können.
    • NordVPN bietet für solche Zwecke eine Meshnet-Funktion. Ich habe aber nach viel Gemurkse irgendwann eingesehen, dass NordVPN auf dem Raspberry Pi Zero nicht läuft (vielleicht ja auf dem Zero 2, wenn er denn irgendwann mal wieder verfügbar wird).
    • Es gibt bestimmt auch noch andere Lösungen, aber ich habe an der Stelle entschieden, das erstmal nicht weiter zu verfolgen.
  • Einen WLAN-Repeater in den Keller hängen.
    • Das ist irgendwie zu einfach. Vielleicht mache ich das irgendwann aber noch.
  • Den RPi selbst zum WLAN-Repeater machen.
    • Das soll wohl gehen, aber man kann viel falsch machen. Im Zweifel macht man dann das ganze WLAN unsicher. Und ich habe weder Ahnung davon, noch Motivation mich da reinzufuchsen.
  • Den RPi über das Internet öffentlich verfügbar machen.
    • Für diesen Weg habe ich mich entschieden. Das ist natürlich auch eine Sicherheitslücke, aber mit der kann ich leben.
    • Und spätestens dann machen natürlich auch die Themen Zugriffsschutz und Verschlüsselung wieder Sinn.

Zugriff auf CameraCtrl über das Internet

Ich richte in meinem Router Port-Forwarding ein: Wenn der Router über den eingestellten öffentlichen Port aufgerufen wird, leitet er auf den Raspberry Pi am angegebenen Port um. Letzteres ist natürlich der Port über den Nginx CameraCtrl bereitstellt.

Nun ist noch die Hürde zu nehmen, dass mein Router keine feste IP nach außen hat (was ja aus Sicherheits- bzw. Datenschutzgründen auch gut ist). Diese Hürde lässt sich über dynamisches DNS nehmen. Das läuft über ein Zusammenspiel aus Einstellungen bei meinem Domain-Registrar und meinen Router. Ich lege bei meinem Domain-Registrar ein DynDNS-Konto an, das für eine angegebene Subdomain die DynDNS-Weiterleitung bereitstellt. Der Router erhält Zugriff auf dieses Konto und kann dann bei Änderung der IP-Adresse diese an den Domain-Registrar weitergeben, der dadurch dann wiederum weiß, an welche Adresse er Anfragen an die Subdomain weiterleiten muss.

In meinem konkreten Fall hat diese Anleitung weitergeholfen: https://mxblg.de/unifi-speedport-inwx-vpn/

Client

Code: CameraCtrl Repository

Kivy UI

Damit das Synchronisieren von Bilddaten, das Erstellen von Videos und ähnliche Funktionalität bequem benutzbar wird, habe ich mich entschieden eine UI dafür zu basteln. Dabei lege ich zumindest vorerst keinen besonderen Wert auf Ästhetik.

In Kivy als UI-Framework habe ich mich anderweitig schon etwas eingewöhnt und Spaß gehabt, daher entscheide ich mich hier wieder für diese Lösung.

Freeze

Etwas überrascht war ich, als ich feststellte, dass bei länger dauernden Aktionen die UI einfriert, selbst wenn ich die Aktion mittels Kivys Clock.schedule_once starte. Also habe ich wieder meinen liebgewonnenen APScheduler ausgepackt und bin dabei auf eine andere Schwierigkeit gestoßen: Kivy-Properties (die ich für die Anzeige in der UI nutze, z.B. für das Kamera-Vorschaubild) wurden nun bei Aufrufen über Funktionen, die über den Scheduler gestartet wurden nicht mehr korrekt aktualisiert. Im Falle des Bildes zuckte die UI kurz, der Pfad des Bildes wurde aber nicht in der Anzeige übernommen. Problem: Beides lief in verschiedenen Threads und das hat Kivy nicht geschmeckt. Zum Glück gab es auch dafür eine Lösung: Das Callback (z.B. zum Aktualisieren der Vorschau) musste mit @mainthread annotiert werden (kivy.clock.mainthread). Dadurch wird die Funktion bei Aufruf in der Event-Loop von Kivy einsortiert und bei nächster Gelegenheit (im richtigen Thread) ausgeführt.

UI-Screenshot


Der Log-Bereich oben kann per Mausklick vergrößert werden. Ein Klick auf die Vorschau aktualisiert das Bild.

Timelapse Video Export Verbesserungen

Ich verwende nun den H.264-Codec für das Video. Durch die Komprimierung wird die Video-Größe deutlich reduziert. Für diesen Codec ist es allerdings nötig, die DLL openh264-1.8.0-win64.dll im Windows System32-Verzeichnis zu hinterlegen. Es gibt auch eine deutlich neuere Version der DLL, aber OpenCV verlangt bei mir leider (aktuell) exakt Version 1.8.0. Und mit dem Ergebnis bin ich glücklich, daher passt das wohl.

Außerdem habe ich die Auflösung und Framerate einstellbar gemacht.

fourcc = cv2.VideoWriter.fourcc(*'mp4v')  # also might use x264 or h264 for .avi  
out_file_path = out_folder / (file_name + '.mp4')  
video = cv2.VideoWriter(str(out_file_path), fourcc=fourcc, frameSize=dimensions, fps=fps)

Die Bilder werden entsprechend verkleinert.

image = cv2.imread(str(image_path))
resized = cv2.resize(image, dimensions, interpolation=cv2.INTER_AREA)

In einem ersten Test habe ich 15 FPS und 720p gewählt. Dadurch wird bei mir aus ca. 290 MB Bilddaten (213 Bilder) ein ca. 10 MB großes Video (ca. 14 Sekunden) an dessen Qualität ich nichts auszusetzen habe.

Wenn ich die Bilder serverseitig reduzieren würde, könnte die Synchronisierung sicher wesentlich beschleunigt werden. Da ich die Bilder aber auf dem Server löschen und gerne die vollen Bilddaten im Client behalten möchte, habe ich diesen Gedanken verworfen. So halte ich mir beispielsweise die Möglichkeit offen, zu einer Bildreihe später noch ein höher aufgelöstes Video zu erstellen.

Timelapse mit Musik hinterlegen

Nach etwas Recherche entscheide ich mich, das Hinterlegen des Videos mit Musik über MoviePy umzusetzen. Das geht ziemlich einfach.

Dateien öffnen:

    video = movie_editor.VideoFileClip(str(video_in_path.resolve()))  
    audio = movie_editor.AudioFileClip(str(audio_path.resolve()))  

So lange das Audio wiederholen, bis es lang genug für das Video ist:

    while final_audio.end < video.end:  
        next_loop = final_audio.copy()  
        next_loop = next_loop.set_start(final_audio.end, change_end=True)  
        final_audio = movie_editor.CompositeAudioClip([final_audio, next_loop])  
    if final_audio.end > video.end:  
        final_audio = final_audio.set_end(video.end)  

Zusammenkleben, rausschreiben und nicht vergessen abzuschließen:

    final_video = video.set_audio(final_audio)  
    final_video.write_videofile(str(video_out_path.resolve()))  
    video.close()  
    audio.close()  
    final_audio.close()  
    final_video.close()  

Bild-Overlays für Timelapse

Die Idee: Im Unterverzeichnis overlays des ausgewählten Timelapse-Bildverzeichnis können Bilder hinterlegt werden, die dann über das Video gelegt werden.
Da das mit PNGs am Besten klappte, wird auch dieses Format erwartet. Die Bilder müssen die gleiche Größe wie die Timelapse-Bilder (vor der Verarbeitung) haben.
Über den Dateinamen kann zudem gesteuert werden, von wann bis wann das Bild gezeigt wird – das ist sowohl über die Frame-Zahl als auch über eine Sekundenangabe möglich.
Beispiele: 10-30_intro-overlay.png oder 1s-5s_intro-overlay.png

Diese Informationen werden in der Hilfsfunktion _prepare_overlay_data ausgewertet. Dort werden auch die Bilddaten verarbeitet, um sie später über das Video legen zu können:

# Extract image data  
overlay_image = cv2.imread(str(overlay_path), -1)  # -1: with transparency  
overlay_color = overlay_image[:, :, :-1]  
mask = overlay_image[:, :, 3]  

# Mask out the overlay from the overlay image.  
overlay_image = cv2.bitwise_and(overlay_color, overlay_color, mask=mask)  

In der create_timelapse-Funktion werden nun für jedes Bild des Timelapse die Overlays gecheckt und bei Bedarf über das Bild gelegt:

for image_path in image_paths:  
	image = cv2.imread(str(image_path))  

	for overlay in overlays:  
		if overlay["first_frame"] <= current <= overlay["last_frame"]:  
			# Black-out the area behind the overlay in the original image  
			image = cv2.bitwise_and(image, image, mask=cv2.bitwise_not(overlay["mask"]))  

			# Update the image with the overlay  
			image = cv2.add(image, overlay["overlay"])  

	image = fit_image_to_frame(image, video_width, video_height)  
	video.write(image)  

Timelapse Demo

Das Demo-Video besteht aus 108 Bildern. Es wurde 1 Bild pro Minute aufgenommen.
Frame-Rate: 2 FPS
Audio: In the Forest - Lesfm

Für den Youtube-Export habe ich 4k-Qualität ausgewählt. Damit erreicht das Video (dank Komprimierung) eine Größe von 30,2 MB.

Das Nilpferd war wütend mit mir, weil ich ein zukünftiges Projekt spoilen wollte.

Und so sieht es aus: