In den letzten Beiträgen habe ich gezeigt, wie sich die Waveshare IMX708 am Raspberry Pi 5 anschließen und mit Picamera2 ansteuern lässt. Anschließend haben wir das Kamerabild über ein kleines Python-Webinterface direkt im Browser sichtbar gemacht.
- Waveshare IMX708 am Raspberry Pi 5: „no cameras available“ beheben
- Raspberry Pi Kamera im Browser anzeigen – Livebild, Foto & Autofokus mit Picamera2
Für den nächsten Schritt wollte ich dieses Bild jedoch nicht mehr nur auf dem PC oder Smartphone ansehen, sondern auf einem kleinen, eigenständigen Display darstellen.
Dafür verwende ich ein CYD – das sogenannte Cheap Yellow Display vom Typ ESP32-2432S028. Das Board kombiniert einen ESP32 mit einem 2,8-Zoll-TFT, Touchcontroller und microSD-Slot auf einer kompakten Platine und eignet sich damit sehr gut als kleines externes Anzeigemodul.
Die Idee klingt zunächst einfach: Der Raspberry Pi stellt das aktuelle Kamerabild als JPEG bereit, das CYD lädt dieses Bild per WLAN herunter und zeigt es anschließend auf dem integrierten Display an.
In der Praxis stellte sich jedoch schnell heraus, dass genau diese Kombination auf meinem Board nicht ganz so problemlos funktioniert wie gedacht. Sobald WLAN aktiv war, wurde die Displayausgabe unzuverlässig und das TFT blieb teilweise komplett weiß.
Die schließlich funktionierende Lösung ist deshalb etwas ungewöhnlicher: Das CYD lädt zunächst das aktuelle JPEG vom Raspberry Pi herunter, speichert es auf der microSD-Karte, schaltet anschließend WLAN vollständig ab und zeigt erst dann das Bild auf dem TFT an. Nach einigen Sekunden startet der ESP32 neu und beginnt den Ablauf erneut.
In diesem Beitrag zeige ich dir genau diesen Aufbau Schritt für Schritt – vom HTTP-Endpunkt auf dem Raspberry Pi bis zur zuverlässigen Bildanzeige auf dem ESP32 CYD.
- Waveshare IMX708 am Raspberry Pi 5: „no cameras available“ beheben
- Raspberry Pi Kamera im Browser anzeigen – Livebild, Foto & Autofokus mit Picamera2
Warum CYD statt TFT direkt am Raspberry Pi?
Natürlich könnte ich ein kleines TFT-Display auch direkt an den Raspberry Pi anschließen. Technisch wäre das kein großes Problem. Für meinen Aufbau wollte ich das Display jedoch bewusst als eigenständige Einheit betreiben.
Ein SPI-TFT benötigt mehrere Leitungen für Versorgung, Takt, Daten und Steuerung. Das funktioniert zwar problemlos, sorgt aber schnell für zusätzliche Verkabelung rund um den Raspberry Pi. Da ich für das spätere Projekt noch weitere Hardware direkt am Pi anschließen möchte, wollte ich die GPIOs möglichst frei halten.
Das ESP32 CYD ist dafür sehr praktisch. Beim ESP32-2432S028 sind Mikrocontroller, 2,8-Zoll-TFT, Touchcontroller und microSD-Slot bereits auf einer Platine untergebracht. Zum Raspberry Pi ist daher keine direkte GPIO-Verbindung notwendig.
Stattdessen erhält das CYD das Kamerabild drahtlos über WLAN. Der Raspberry Pi kümmert sich weiterhin um die Kamera und stellt das aktuelle Bild bereit, während der ESP32 ausschließlich für die Darstellung auf dem Display zuständig ist.
Dadurch ergeben sich für meinen Aufbau mehrere Vorteile:
- keine zusätzliche Display-Verkabelung am Raspberry Pi
- GPIOs des Raspberry Pi bleiben für weitere Hardware frei
- das Display kann unabhängig vom Raspberry Pi positioniert werden
- microSD-Slot und TFT sind bereits auf dem CYD vorhanden
- klare Trennung der Aufgaben zwischen Raspberry Pi und ESP32
Gerade diese Trennung gefällt mir für das spätere Projekt besonders gut. Der Raspberry Pi übernimmt die aufwendigeren Aufgaben rund um Kamera und Bildverarbeitung, während das CYD als kleines externes Anzeigemodul arbeitet.
Im ersten Versuch sollte das CYD das Kamerabild dauerhaft über WLAN abrufen und direkt auf dem TFT aktualisieren. Während der Umsetzung zeigte sich jedoch, dass diese Lösung auf meinem Board nicht zuverlässig funktionierte. Deshalb ist der tatsächliche Ablauf inzwischen etwas anders aufgebaut – und genau darum geht es im nächsten Abschnitt.
So soll das Kamerabild auf das CYD kommen
Die Kamera bleibt weiterhin direkt mit dem Raspberry Pi 5 verbunden und wird dort über Picamera2 angesprochen. Das CYD greift also nicht selbst auf die Waveshare IMX708 zu, sondern bekommt lediglich das bereits erzeugte Kamerabild über WLAN übertragen.
Dafür stellt der Raspberry Pi unter dem HTTP-Endpunkt /frame.jpg jeweils ein aktuelles JPEG bereit. Das CYD verbindet sich mit dem WLAN, lädt dieses Bild herunter und speichert es zunächst auf der microSD-Karte.
Ursprünglich wollte ich das JPEG direkt nach dem Download auf dem TFT anzeigen. Auf meinem verwendeten Board erwies sich dieser Ansatz jedoch als nicht zuverlässig.
Deshalb läuft die Übertragung heute in klar getrennten Schritten ab:
- Der Raspberry Pi erzeugt das aktuelle Kamerabild.
- Das CYD verbindet sich per WLAN mit dem Raspberry Pi.
/frame.jpgwird heruntergeladen und auf der microSD-Karte gespeichert.- Danach wird das WLAN vollständig deaktiviert.
- Erst jetzt wird das TFT-Display initialisiert.
TJpg_Decoderliest das JPEG direkt von der SD-Karte und zeigt es auf dem Display an.- Das Bild bleibt einige Sekunden sichtbar.
- Anschließend startet der ESP32 mit
ESP.restart()neu und beginnt den Ablauf von vorn.
Der Datenfluss sieht damit vereinfacht so aus:
Das CYD zeigt damit kein echtes Live-Video, sondern ein regelmäßig aktualisiertes Einzelbild. Für meinen Aufbau ist das jedoch ausreichend – und vor allem deutlich zuverlässiger als die ursprünglich geplante dauerhaft aktive WLAN-Verbindung.
Verwendete Software und Bibliotheken auf dem ESP32 CYD
Für die Umsetzung verwende ich das ESP32 CYD mit der Arduino IDE. Die erste Idee war deutlich umfangreicher: dauerhaft aktives WLAN, LVGL für die Oberfläche und Touch-Bedienung direkt auf dem Display.
Am Ende habe ich den Aufbau deutlich vereinfacht. Das CYD übernimmt nur noch die Aufgabe, ein aktuelles JPEG vom Raspberry Pi zu laden und auf dem TFT anzuzeigen.
Dafür kommen nur wenige Bibliotheken zum Einsatz:
- TFT_eSPI übernimmt die direkte Ansteuerung des integrierten ILI9341-TFTs.
- WiFi stellt kurzzeitig die WLAN-Verbindung zum Raspberry Pi her.
- HTTPClient lädt das aktuelle Kamerabild über den Endpunkt
/frame.jpg. - SD speichert das heruntergeladene JPEG auf der microSD-Karte des CYD.
- TJpg_Decoder liest das JPEG anschließend direkt von der SD-Karte und gibt die dekodierten Bilddaten auf dem TFT aus.
Auf LVGL und Touch-Bedienung verzichte ich in dieser Version bewusst. Für die reine Bildanzeige wären beide Komponenten zusätzlicher Overhead, ohne für den aktuellen Aufbau einen echten Vorteil zu bringen.
Die Aufgabenverteilung sieht damit so aus:
Mit dieser Variante funktioniert die Bildausgabe auf meinem CYD zuverlässig. Der Raspberry Pi bleibt für Kamera und Bildbereitstellung zuständig, während das CYD ausschließlich als drahtloses Anzeigemodul arbeitet.
Gerade weil WLAN und Display auf meinem verwendeten Board nicht zuverlässig gleichzeitig zusammenarbeiteten, ist diese klare Trennung der einzelnen Schritte entscheidend für die funktionierende Lösung.
Raspberry Pi für das CYD vorbereiten
Damit das CYD ein aktuelles Kamerabild abrufen kann, benötigt es einen eigenen HTTP-Endpunkt.
Python Skript zum bereitstellen eines Servers zum abrufen eines Bildes der Kamera am RPi
#!/usr/bin/env python3
# ==================================================
# Waveshare IMX708 Kamera-Webserver
# ==================================================
#
# Dieses Skript macht aus einem Raspberry Pi mit
# angeschlossener Waveshare IMX708 Kamera einen
# kleinen Kamera-Webserver.
#
# Es stellt drei Funktionen bereit:
#
# 1. Ein Livebild im Browser
# (MJPEG-Stream mit 1280 x 720 Pixeln)
#
# 2. Ein verkleinertes Einzelbild für das ESP32 CYD
# (320 x 180 Pixel, vorher wird scharfgestellt)
#
# 3. Bedienfunktionen im Browser:
# - Autofokus per Knopfdruck
# - Standbild als JPEG herunterladen
#
# Verfügbare Adressen (Port 8000):
#
# GET / -> HTML-Oberfläche
# GET /stream.mjpg -> MJPEG-Livestream
# GET /frame.jpg -> Einzelbild für das CYD
# GET /photo -> Standbild als Download
# POST /autofocus -> Autofokus auslösen
#
# Grundidee der Architektur:
#
# Kamera -> JpegEncoder -> StreamingOutput.frame
# |
# +---------------------+-----------------+
# | | |
# /stream.mjpg /frame.jpg /photo
# (Browser) (ESP32) (Browser)
#
# Die Kamera kodiert jedes Bild nur EINMAL als JPEG.
# Alle Zuschauer bedienen sich aus demselben Puffer.
# Dadurch belasten mehrere Browser den Raspberry Pi
# kaum mehr als ein einzelner.
#
# Start:
#
# python3 camera_web.py
#
# Beenden:
#
# STRG + C
# ==================================================
# --------------------------------------------------
# Benötigte Python-Module importieren
# --------------------------------------------------
# io wird verwendet, um Bilddaten im Arbeitsspeicher
# statt direkt in einer Datei abzulegen.
#
# io.BytesIO verhält sich wie eine Datei, liegt aber
# komplett im RAM. So wird die SD-Karte geschont.
#
# io.BufferedIOBase dient außerdem als Basisklasse
# für unsere StreamingOutput-Klasse.
import io
# Pillow wird verwendet, um das aktuelle Kamerabild
# für das CYD auf 320 x 180 Pixel zu verkleinern.
#
# Installation (falls nötig):
# sudo apt install python3-pil
from PIL import Image
# time benötigen wir unter anderem für den Zeitstempel
# im Dateinamen eines heruntergeladenen Standbildes.
import time
# socketserver stellt die Basis für unseren
# multithreaded HTTP-Server bereit.
#
# Daraus verwenden wir ThreadingMixIn, damit jede
# Anfrage in einem eigenen Thread bearbeitet wird.
import socketserver
# Das Modul http.server enthält die Klassen,
# mit denen wir unseren kleinen Webserver aufbauen.
#
# Wir verwenden:
# server.HTTPServer -> der eigentliche Server
# server.BaseHTTPRequestHandler -> Basis für unseren Handler
from http import server
# Condition wird benötigt, damit der MJPEG-Stream
# auf neue Kamerabilder warten kann.
#
# Eine Condition kombiniert eine Sperre (Lock) mit
# einem Benachrichtigungsmechanismus:
# wait() -> Thread schläft, bis er geweckt wird
# notify_all() -> weckt alle wartenden Threads
from threading import Condition
# Lock sorgt dafür, dass immer nur ein
# Autofokus-Vorgang gleichzeitig läuft.
from threading import Lock
# Picamera2 ist die aktuelle Python-Bibliothek
# für die Kamerasteuerung unter Raspberry Pi OS.
#
# Sie baut auf libcamera auf und ersetzt die
# alte picamera-Bibliothek.
from picamera2 import Picamera2
# JpegEncoder erzeugt aus dem Kamerastream
# fortlaufend JPEG-Bilder.
#
# Jedes einzelne Kamerabild wird dabei zu einem
# eigenständigen JPEG – genau das, was MJPEG braucht.
from picamera2.encoders import JpegEncoder
# FileOutput verbindet den JPEG-Encoder mit
# unserer eigenen StreamingOutput-Klasse.
#
# FileOutput kann in jedes Objekt schreiben, das
# eine write()-Methode besitzt – also auch in
# unsere eigene Klasse.
from picamera2.outputs import FileOutput
# Über controls greifen wir auf Kameraeinstellungen
# wie Autofokus-Modus und Autofokus-Trigger zu.
from libcamera import controls
# --------------------------------------------------
# HTML-Oberfläche
# --------------------------------------------------
# Die komplette Webseite wird direkt als String
# innerhalb des Python-Skripts hinterlegt.
#
# Dadurch benötigen wir keine zusätzliche HTML-Datei
# und können das gesamte Projekt mit nur einer
# Python-Datei betreiben.
#
# Die drei dreifachen Anführungszeichen erlauben
# einen mehrzeiligen String.
#
# Die Seite kommt komplett ohne JavaScript aus:
#
# - Das Livebild ist ein normales <img>-Element,
# das auf /stream.mjpg zeigt.
#
# - Der Autofokus ist ein HTML-Formular,
# das einen POST an /autofocus sendet.
#
# - Der Download ist ein einfacher Link auf /photo.
PAGE = """<!DOCTYPE html>
<html lang="de">
<head>
<!-- Zeichencodierung -->
<meta charset="UTF-8">
<!-- Optimierung für Smartphones und Tablets -->
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<title>Waveshare IMX708 Livebild</title>
<style>
/* --------------------------------------------------
Grunddesign der Webseite
-------------------------------------------------- */
body {
font-family: Arial, sans-serif;
background: #1e1e1e;
color: white;
text-align: center;
margin: 0;
padding: 20px;
}
/* --------------------------------------------------
Container für die komplette Oberfläche
-------------------------------------------------- */
.container {
max-width: 1280px;
margin: 0 auto;
}
/* Überschrift */
h1 {
margin-bottom: 20px;
}
/* --------------------------------------------------
Livebild
-------------------------------------------------- */
.stream {
/* Bild nutzt die verfügbare Breite */
width: 100%;
/* maximal jedoch 1280 Pixel */
max-width: 1280px;
/* leicht abgerundete Ecken */
border-radius: 10px;
/* Rahmen um das Kamerabild */
border: 2px solid #555;
/* schwarzer Hintergrund,
falls noch kein Bild verfügbar ist */
background: #000;
}
/* --------------------------------------------------
Bereich für die Schaltflächen
-------------------------------------------------- */
.buttons {
margin-top: 20px;
}
/* Das Formular des Autofokus-Buttons
soll nicht die ganze Breite einnehmen. */
form {
display: inline-block;
margin: 8px;
}
/* Auch der Download-Link soll wie ein Button
neben dem Autofokus erscheinen. */
.download-link {
display: inline-block;
margin: 8px;
text-decoration: none;
}
/* --------------------------------------------------
Allgemeines Design der Buttons
-------------------------------------------------- */
button,
.download-button {
font-size: 18px;
padding: 12px 20px;
cursor: pointer;
border: none;
border-radius: 8px;
}
/* Autofokus-Button */
.focus {
background: #2196F3;
color: white;
}
/* Foto-Button */
.photo {
background: #4CAF50;
color: white;
}
/* --------------------------------------------------
Statusanzeige
-------------------------------------------------- */
.status {
margin-top: 15px;
font-size: 16px;
color: #ccc;
}
/* Zusätzlicher Hinweis unterhalb der Buttons */
.info {
margin-top: 25px;
font-size: 14px;
color: #aaa;
}
</style>
</head>
<body>
<div class="container">
<h1>Waveshare IMX708 Livebild</h1>
<!-- --------------------------------------------------
Livebild
Der Browser ruft /stream.mjpg auf.
Dieser Endpunkt liefert fortlaufend einzelne
JPEG-Bilder als MJPEG-Stream.
-------------------------------------------------- -->
<img class="stream"
src="/stream.mjpg"
alt="Livebild der Waveshare IMX708">
<!-- --------------------------------------------------
Bedienung der Kamera
-------------------------------------------------- -->
<div class="buttons">
<!-- --------------------------------------------------
Autofokus
Beim Klick sendet der Browser einen
HTTP-POST an /autofocus.
Dafür ist kein JavaScript notwendig.
-------------------------------------------------- -->
<form method="POST"
action="/autofocus">
<button type="submit"
class="focus">
Autofokus
</button>
</form>
<!-- --------------------------------------------------
Standbild herunterladen
Der Browser ruft /photo auf.
Der Python-Webserver nimmt daraufhin ein
aktuelles Kamerabild auf und liefert dieses
als JPEG-Datei zurück.
-------------------------------------------------- -->
<a class="download-link"
href="/photo">
<button type="button"
class="photo download-button">
Standbild herunterladen
</button>
</a>
</div>
<!-- Kleine Statusanzeige -->
<div class="status">
Kamera bereit
</div>
<!-- Hinweis zum Autofokus -->
<div class="info">
Autofokus wird per HTTP POST ausgelöst.
</div>
</div>
</body>
</html>
"""
# --------------------------------------------------
# MJPEG Streaming Output
# --------------------------------------------------
# Picamera2 erzeugt permanent JPEG-Bilder.
#
# Diese Klasse speichert immer nur das aktuellste
# JPEG-Bild im Arbeitsspeicher.
#
# Der Browser kann dieses Bild anschließend über
# /stream.mjpg abrufen.
#
# Warum nur das neueste Bild?
#
# - Der Speicherbedarf bleibt immer gleich klein.
#
# - Ein langsamer Client (z. B. schlechtes WLAN)
# verpasst einfach einzelne Bilder, statt immer
# weiter in Verzug zu geraten.
#
# Zusammenspiel der Threads:
#
# Kamera-Thread Browser-Threads
# ------------- ---------------
# condition.wait() (schlafen)
# write(buf)
# frame = buf
# notify_all() --------> wachen auf
# frame lesen und senden
#
# Die Klasse erbt von io.BufferedIOBase, damit
# Picamera2 sie wie eine normale Datei behandeln kann.
class StreamingOutput(io.BufferedIOBase):
def __init__(self):
# Hier wird das zuletzt erzeugte JPEG gespeichert.
#
# Vor dem ersten Kamerabild ist der Wert None.
self.frame = None
# Condition ermöglicht es mehreren Threads,
# auf ein neues Bild zu warten.
#
# Gleichzeitig schützt sie self.frame davor,
# gelesen zu werden, während es gerade
# überschrieben wird.
self.condition = Condition()
def write(self, buf):
# Sobald der JPEG-Encoder ein neues Bild erzeugt,
# landet es hier in "buf".
#
# Diese Methode wird von Picamera2 in einem
# eigenen Hintergrund-Thread aufgerufen –
# etwa 30 Mal pro Sekunde.
#
# "buf" enthält jeweils ein vollständiges JPEG.
# "with self.condition" sperrt den Zugriff,
# solange der Block ausgeführt wird.
with self.condition:
# Aktuelles Bild speichern.
#
# Das vorherige Bild wird dabei einfach
# überschrieben.
self.frame = buf
# Alle wartenden Browser-Threads informieren,
# dass ein neues Bild verfügbar ist.
self.condition.notify_all()
# Anzahl der geschriebenen Bytes zurückgeben.
#
# Das erwartet die Schnittstelle einer Datei:
# write() liefert immer die geschriebene Menge.
return len(buf)
# --------------------------------------------------
# HTTP Handler
# --------------------------------------------------
# StreamingHandler verarbeitet alle HTTP-Anfragen,
# die vom Browser an den Raspberry Pi gesendet werden.
#
# Für jede einzelne Anfrage erzeugt der Server
# ein neues Objekt dieser Klasse.
#
# Je nach HTTP-Methode wird automatisch aufgerufen:
#
# GET -> do_GET()
# POST -> do_POST()
#
# Die angefragte Adresse steht in self.path,
# z. B. "/" oder "/stream.mjpg".
#
# Jede HTTP-Antwort besteht aus diesen Schritten:
#
# 1. send_response(code) -> Statuszeile, z. B. 200 OK
# 2. send_header(name, ...) -> beliebig viele Header
# 3. end_headers() -> Leerzeile, Header-Ende
# 4. wfile.write(daten) -> der eigentliche Inhalt
class StreamingHandler(server.BaseHTTPRequestHandler):
# --------------------------------------------------
# HTTP GET
# --------------------------------------------------
def do_GET(self):
# --------------------------------------------------
# Hauptseite
# --------------------------------------------------
if self.path == "/":
# HTML-Code in Bytes umwandeln.
#
# Über das Netzwerk können nur Bytes übertragen
# werden, keine Python-Strings.
content = PAGE.encode("utf-8")
# HTTP Status 200 = OK
self.send_response(200)
# Browser mitteilen, dass HTML geliefert wird.
#
# charset=utf-8 sorgt dafür, dass Umlaute
# korrekt dargestellt werden.
self.send_header(
"Content-Type",
"text/html; charset=utf-8"
)
# Größe des Inhalts übertragen.
#
# Dadurch weiß der Browser, wann die Seite
# vollständig empfangen wurde.
self.send_header(
"Content-Length",
len(content)
)
# HTTP-Header abschließen.
self.end_headers()
# Webseite an den Browser senden.
self.wfile.write(content)
# --------------------------------------------------
# MJPEG-Livestream
# --------------------------------------------------
#
# MJPEG (Motion JPEG) ist im Prinzip nur eine
# endlose Folge einzelner JPEG-Bilder.
#
# Die Verbindung zum Browser bleibt dauerhaft
# offen. Jedes neue Bild ersetzt das vorherige.
#
# Auf der Leitung sieht das so aus:
#
# --FRAME
# Content-Type: image/jpeg
# Content-Length: 84213
#
# <JPEG-Daten>
# --FRAME
# Content-Type: image/jpeg
# ...
#
# Dieser Thread bleibt so lange in der Schleife,
# wie der Browser verbunden ist.
# --------------------------------------------------
elif self.path == "/stream.mjpg":
# HTTP Status 200
self.send_response(200)
# --------------------------------------------------
# Browser-Caching deaktivieren
#
# Der Browser soll keine alten Kamerabilder
# zwischenspeichern.
#
# Age: 0 -> Inhalt ist brandneu
# Cache-Control -> nicht zwischenspeichern
# Pragma -> dasselbe für alte Browser
# --------------------------------------------------
self.send_header(
"Age",
"0"
)
self.send_header(
"Cache-Control",
"no-cache, private"
)
self.send_header(
"Pragma",
"no-cache"
)
# --------------------------------------------------
# MJPEG Content-Type
#
# multipart/x-mixed-replace bedeutet:
#
# Der Server liefert permanent neue Inhalte.
#
# FRAME trennt dabei die einzelnen JPEG-Bilder.
#
# "x-mixed-replace" sagt dem Browser zusätzlich,
# dass jeder neue Teil den vorherigen ERSETZT
# und nicht etwa darunter angehängt wird.
# --------------------------------------------------
self.send_header(
"Content-Type",
"multipart/x-mixed-replace; boundary=FRAME"
)
self.end_headers()
try:
# Solange der Browser verbunden ist,
# werden ständig neue JPEG-Bilder übertragen.
#
# Die Schleife endet erst, wenn beim Senden
# ein Fehler auftritt – typischerweise, weil
# der Browser-Tab geschlossen wurde.
while True:
# --------------------------------------------------
# Auf neues Kamerabild warten
#
# wait() gibt die Sperre frei und lässt den
# Thread schlafen, bis StreamingOutput.write()
# notify_all() aufruft.
#
# Danach wird das neue Bild in eine lokale
# Variable kopiert, damit die Sperre sofort
# wieder freigegeben werden kann.
# --------------------------------------------------
with output.condition:
output.condition.wait()
frame = output.frame
# Falls noch kein Bild vorhanden ist,
# einfach auf das nächste warten.
if frame is None:
continue
# --------------------------------------------------
# Beginn eines neuen MJPEG-Frames
#
# "--" + Boundary-Name kennzeichnet den
# Anfang eines neuen Teils im Multipart-Stream.
# --------------------------------------------------
self.wfile.write(
b"--FRAME\r\n"
)
# Bildtyp: JPEG
#
# send_header() schreibt hier die Header
# des einzelnen Teils, nicht der gesamten
# HTTP-Antwort.
self.send_header(
"Content-Type",
"image/jpeg"
)
# Größe des JPEG-Bildes
self.send_header(
"Content-Length",
len(frame)
)
# Leerzeile: Header dieses Teils beendet,
# jetzt folgen die Bilddaten.
self.end_headers()
# JPEG-Bild an den Browser senden
self.wfile.write(frame)
# Zeilenumbruch als Abschluss
self.wfile.write(
b"\r\n"
)
# --------------------------------------------------
# Browser wurde geschlossen
#
# In diesem Fall entsteht häufig ein
# BrokenPipeError oder ConnectionResetError.
#
# Das ist normal und kein echter Fehler.
# --------------------------------------------------
except (
BrokenPipeError,
ConnectionResetError
):
pass
# Andere Fehler ausgeben.
#
# client_address enthält IP-Adresse und Port
# des Clients, z. B. ('192.168.1.20', 53214).
except Exception as e:
print(
"Stream beendet:",
self.client_address,
e
)
# --------------------------------------------------
# Einzelbild für ESP32 CYD
# --------------------------------------------------
#
# Der ESP32 benötigt keinen dauerhaften MJPEG-Stream.
# Stattdessen kann er regelmäßig dieses einzelne JPEG
# abrufen.
#
# Vor jedem Bild wird ein Autofokus ausgelöst.
# Danach stammt das Bild direkt aus output.frame,
# also aus dem bereits laufenden JPEG-Stream.
#
# Anschließend wird es auf 320 x 180 Pixel verkleinert
# und direkt aus dem RAM als JPEG zurückgegeben.
#
# Zeitlicher Ablauf einer Anfrage:
#
# Autofokus ca. 0,3 - 1 s
# auf neues Bild warten ca. 33 ms (bei 30 fps)
# verkleinern + JPEG ca. 20 - 50 ms
#
# Der HTTP-Timeout auf dem ESP32 sollte daher
# großzügig gewählt werden (z. B. 5 Sekunden).
# --------------------------------------------------
elif self.path == "/frame.jpg":
try:
# --------------------------------------------------
# Autofokus vor dem Senden auslösen
#
# autofocus_cycle() startet einen Fokusvorgang
# und wartet, bis dieser abgeschlossen ist.
# Rückgabe: True = scharfgestellt,
# False = Fokus fehlgeschlagen.
#
# Der Lock verhindert, dass mehrere gleichzeitige
# Anfragen sich gegenseitig den Fokus abbrechen.
#
# Kommt eine zweite Anfrage, während noch
# fokussiert wird, wartet sie hier, bis der
# erste Vorgang fertig ist.
# --------------------------------------------------
with autofocus_lock:
focused = picam2.autofocus_cycle()
# Bei einem fehlgeschlagenen Fokus wird das
# Bild trotzdem gesendet – nur mit Hinweis
# in der Konsole.
if not focused:
print("CYD Frame: Autofokus fehlgeschlagen")
# --------------------------------------------------
# Auf ein neues JPEG warten, das erst nach dem
# Fokusvorgang entstanden ist, und dieses holen.
#
# Das Bild, das gerade im Puffer liegt, könnte
# noch während des Fokussierens aufgenommen
# worden und damit unscharf sein.
#
# timeout=2 verhindert, dass die Anfrage ewig
# hängt, falls die Kamera keine Bilder liefert.
# --------------------------------------------------
with output.condition:
output.condition.wait(timeout=2)
frame = output.frame
# Direkt nach dem Start kann es passieren,
# dass noch gar kein Bild vorhanden ist.
#
# HTTP 503 = Dienst vorübergehend nicht verfügbar.
# Der ESP32 kann es einfach erneut versuchen.
if frame is None:
self.send_error(
503,
"Noch kein Kamerabild verfuegbar"
)
return
# --------------------------------------------------
# JPEG aus dem Arbeitsspeicher öffnen.
#
# Image.open() erwartet eine Datei. BytesIO
# stellt die JPEG-Bytes als "Datei im RAM" bereit.
# --------------------------------------------------
input_stream = io.BytesIO(frame)
image = Image.open(input_stream)
# Auf CYD-Auflösung verkleinern.
# 320 x 180 entspricht genau 1/4 von 1280 x 720
# und behält damit das 16:9-Seitenverhältnis bei.
#
# Das CYD-Display hat 320 x 240 Pixel.
# Oben und unten bleiben somit 60 Pixel frei.
image = image.resize((320, 180))
# --------------------------------------------------
# Verkleinertes JPEG wieder im RAM erzeugen.
#
# quality=80 ist ein guter Kompromiss:
# Das Bild ist gut erkennbar und trotzdem
# nur ca. 10 - 20 KB groß.
#
# Das ist wichtig, weil der ESP32 nur wenig
# Arbeitsspeicher besitzt.
# --------------------------------------------------
output_stream = io.BytesIO()
image.save(
output_stream,
format="JPEG",
quality=80
)
# Fertige JPEG-Bytes aus dem Puffer holen.
jpeg = output_stream.getvalue()
# --------------------------------------------------
# JPEG an den ESP32 senden.
#
# Content-Length ist für den ESP32 besonders
# wichtig: Daran erkennt er, wie viele Bytes
# er empfangen und reservieren muss.
#
# no-store verhindert, dass ein Proxy oder
# Client ein altes Bild zwischenspeichert.
# --------------------------------------------------
self.send_response(200)
self.send_header(
"Content-Type",
"image/jpeg"
)
self.send_header(
"Content-Length",
len(jpeg)
)
self.send_header(
"Cache-Control",
"no-store"
)
self.end_headers()
self.wfile.write(jpeg)
# Größe des gesendeten Bildes in der
# Konsole ausgeben (hilfreich zur Kontrolle).
print(
"CYD Frame:",
len(jpeg),
"Bytes"
)
# Jeder Fehler wird in der Konsole ausgegeben
# und mit HTTP 500 (interner Serverfehler)
# an den Client gemeldet.
except Exception as e:
print(
"Fehler bei /frame.jpg:",
e
)
self.send_error(
500,
str(e)
)
# --------------------------------------------------
# Standbild aufnehmen
# --------------------------------------------------
#
# Nimmt ein Bild aus dem laufenden Kamerastream auf
# und schickt es dem Browser als Datei-Download.
#
# Hinweis: Da die Kamera im Videomodus läuft, hat
# das Standbild ebenfalls 1280 x 720 Pixel und nicht
# die volle Sensorauflösung des IMX708.
# --------------------------------------------------
elif self.path == "/photo":
try:
# --------------------------------------------------
# Temporären Speicher erzeugen
#
# Das Foto wird nicht auf dem Raspberry Pi
# gespeichert, sondern zunächst im RAM.
# --------------------------------------------------
stream = io.BytesIO()
# --------------------------------------------------
# Aktuelles Kamerabild aufnehmen
#
# Das Ergebnis wird direkt als JPEG
# in den Speicher geschrieben.
#
# capture_file() funktioniert auch, während
# start_recording() aktiv ist. Der Livestream
# läuft dabei ohne Unterbrechung weiter.
# --------------------------------------------------
picam2.capture_file(
stream,
format="jpeg"
)
# Bytes des JPEG-Bildes abrufen.
image = stream.getvalue()
# --------------------------------------------------
# Dateiname erzeugen
#
# Beispiel:
#
# imx708_2026-10-09_16-45-27.jpg
#
# %Y = Jahr, %m = Monat, %d = Tag
# %H = Stunde, %M = Minute, %S = Sekunde
# --------------------------------------------------
filename = time.strftime(
"imx708_%Y-%m-%d_%H-%M-%S.jpg"
)
# --------------------------------------------------
# JPEG an Browser senden
# --------------------------------------------------
self.send_response(200)
self.send_header(
"Content-Type",
"image/jpeg"
)
# --------------------------------------------------
# Content-Disposition: attachment
#
# Dadurch behandelt der Browser das Bild
# als Download und öffnet es nicht nur.
#
# filename legt den vorgeschlagenen
# Dateinamen im Download-Dialog fest.
# --------------------------------------------------
self.send_header(
"Content-Disposition",
f'attachment; filename="{filename}"'
)
self.send_header(
"Content-Length",
len(image)
)
# Download soll nicht gecacht werden.
#
# Sonst könnte der Browser beim nächsten
# Klick das alte Foto erneut liefern.
self.send_header(
"Cache-Control",
"no-store"
)
self.end_headers()
# JPEG-Daten übertragen.
self.wfile.write(image)
# Ausgabe in der Konsole.
print(
"Standbild aufgenommen:",
filename
)
except Exception as e:
print(
"Fehler beim Standbild:",
e
)
# HTTP Fehler 500 an Browser senden.
self.send_error(
500,
str(e)
)
# --------------------------------------------------
# Unbekannte URL
# --------------------------------------------------
# Alle anderen Adressen gibt es nicht.
#
# HTTP 404 = Nicht gefunden.
else:
self.send_error(404)
# --------------------------------------------------
# HTTP POST
# --------------------------------------------------
def do_POST(self):
# --------------------------------------------------
# Autofokus auslösen
# --------------------------------------------------
#
# Wird vom Autofokus-Button der Webseite aufgerufen.
#
# Anders als bei /frame.jpg wird hier NICHT auf
# das Ende des Fokusvorgangs gewartet. Der Browser
# bekommt sofort eine Antwort.
# --------------------------------------------------
if self.path == "/autofocus":
try:
# --------------------------------------------------
# Autofokus konfigurieren
#
# AfModeEnum.Auto:
# Autofokus läuft nicht permanent,
# sondern wird gezielt ausgelöst.
#
# AfTriggerEnum.Start:
# Startet einen Autofokus-Vorgang.
#
# set_controls() übergibt beide Werte
# gleichzeitig an die Kamera.
# --------------------------------------------------
picam2.set_controls({
"AfMode":
controls.AfModeEnum.Auto,
"AfTrigger":
controls.AfTriggerEnum.Start
})
print(
"Autofokus ausgelöst"
)
# --------------------------------------------------
# Browser zurück auf Hauptseite leiten
#
# HTTP 303 bedeutet:
#
# Der POST wurde verarbeitet.
# Danach soll der Browser eine GET-Anfrage
# auf die angegebene Location durchführen.
#
# Dieses Muster heißt "Post/Redirect/Get".
# Drückt der Nutzer danach F5, wird der
# POST nicht versehentlich erneut gesendet.
# --------------------------------------------------
self.send_response(303)
self.send_header(
"Location",
"/"
)
self.end_headers()
except Exception as e:
print(
"Fehler beim Autofokus:",
e
)
self.send_error(
500,
str(e)
)
# Unbekannte POST-URL
else:
self.send_error(404)
# --------------------------------------------------
# HTTP-Log deaktivieren
# --------------------------------------------------
# Normalerweise würde Python jede Browser-Anfrage
# in der Konsole ausgeben.
#
# Beispiel:
#
# GET /stream.mjpg HTTP/1.1
#
# Für dieses Projekt benötigen wir diese Meldungen
# nicht und unterdrücken sie daher.
#
# Gerade die regelmäßigen Abfragen des CYD würden
# die Konsole sonst sehr schnell füllen.
#
# Dazu wird die Methode log_message() der
# Basisklasse überschrieben und macht einfach nichts.
def log_message(self, format, *args):
return
# --------------------------------------------------
# HTTP Server
# --------------------------------------------------
# ThreadingMixIn sorgt dafür, dass mehrere HTTP-Anfragen
# parallel verarbeitet werden können.
#
# Das ist wichtig, weil der MJPEG-Stream dauerhaft eine
# Verbindung geöffnet hält.
#
# Gleichzeitig sollen trotzdem Autofokus- und
# Foto-Anfragen verarbeitet werden können.
#
# Ohne ThreadingMixIn würde der Server nach dem Öffnen
# des Livestreams auf keine weitere Anfrage mehr
# reagieren – auch nicht auf das CYD.
#
# Wichtig: ThreadingMixIn muss in der Liste der
# Basisklassen VOR HTTPServer stehen, damit seine
# Methoden die von HTTPServer überschreiben.
class StreamingServer(
socketserver.ThreadingMixIn,
server.HTTPServer
):
# Port kann nach einem Neustart schneller
# erneut verwendet werden.
#
# Ohne diese Einstellung erscheint nach einem
# Neustart oft für einige Sekunden der Fehler
# "Address already in use".
allow_reuse_address = True
# Threads werden automatisch beendet,
# sobald der Hauptprozess endet.
#
# So beendet STRG + C das Programm sofort, auch
# wenn noch Browser mit dem Livestream verbunden sind.
daemon_threads = True
# --------------------------------------------------
# Kamera initialisieren
# --------------------------------------------------
# Picamera2-Objekt erzeugen.
#
# Picamera2 verwendet automatisch die vom System
# erkannte Kamera – in unserem Fall die IMX708.
#
# picam2 ist eine globale Variable. Die Methoden des
# StreamingHandler greifen direkt darauf zu.
picam2 = Picamera2()
# --------------------------------------------------
# Kamera-Konfiguration
# --------------------------------------------------
# Für das Browser-Livebild verwenden wir
# eine Auflösung von 1280 x 720 Pixeln.
#
# Das ist ein guter Kompromiss zwischen:
#
# - Bildqualität
# - Datenmenge
# - CPU-Auslastung
# - Netzwerkbelastung
#
# create_video_configuration() ist für den Dauerbetrieb
# mit gleichmäßiger Bildrate optimiert.
#
# "main" ist der Hauptstream der Kamera, aus dem der
# JPEG-Encoder seine Bilder bezieht.
config = picam2.create_video_configuration(
main={
"size": (
1280,
720
)
}
)
# Konfiguration auf die Kamera anwenden.
picam2.configure(
config
)
# --------------------------------------------------
# Autofokus-Grundmodus
# --------------------------------------------------
# Wir setzen den Autofokus auf "Auto".
#
# Anders als bei "Continuous" fokussiert die Kamera
# damit nicht permanent.
#
# Ein Fokusvorgang startet erst, wenn wir über
# /autofocus den AfTrigger auslösen.
#
# Zusätzlich wird vor jedem /frame.jpg automatisch
# fokussiert (siehe autofocus_cycle() im Handler).
picam2.set_controls({
"AfMode":
controls.AfModeEnum.Auto
})
# --------------------------------------------------
# MJPEG-Ausgabe erzeugen
# --------------------------------------------------
# Unsere StreamingOutput-Klasse erzeugen.
#
# output ist eine globale Variable und wird von
# allen Handler-Threads gemeinsam verwendet.
output = StreamingOutput()
# Lock für den Autofokus vor jedem CYD-Einzelbild.
#
# Ebenfalls global, damit alle Handler-Threads
# denselben Lock verwenden.
autofocus_lock = Lock()
# --------------------------------------------------
# Kamera und JPEG-Encoding starten
# --------------------------------------------------
# start_recording startet den Kamerastream.
#
# JpegEncoder:
# Wandelt jedes Kamerabild in JPEG um.
#
# FileOutput(output):
# Übergibt die JPEG-Daten an unsere
# StreamingOutput-Klasse.
#
# Ab diesem Moment ruft Picamera2 in einem
# Hintergrund-Thread für jedes neue Bild
# output.write() auf.
picam2.start_recording(
JpegEncoder(),
FileOutput(
output
)
)
# --------------------------------------------------
# Webserver starten
# --------------------------------------------------
# try / except / finally sorgt dafür, dass die Kamera
# in jedem Fall sauber gestoppt wird – egal ob das
# Programm normal, per STRG + C oder durch einen
# Fehler beendet wird.
try:
# --------------------------------------------------
# Adresse des Servers
#
# "" bedeutet:
#
# Der Server lauscht auf allen
# Netzwerkinterfaces des Raspberry Pi.
#
# Port 8000 ist der verwendete HTTP-Port.
#
# Ports unter 1024 (z. B. 80) würden Root-Rechte
# erfordern. Daher wird hier 8000 verwendet.
# --------------------------------------------------
address = (
"",
8000
)
# HTTP-Server erzeugen.
#
# Übergeben werden die Adresse und die Klasse
# (nicht ein Objekt!), die jede Anfrage bearbeitet.
httpd = StreamingServer(
address,
StreamingHandler
)
# --------------------------------------------------
# Informationen in der Konsole ausgeben
# --------------------------------------------------
print()
print(
"Waveshare IMX708 Webserver gestartet"
)
print()
print(
"Browser öffnen:"
)
print(
"http://<IP-des-Raspberry-Pi>:8000"
)
print()
print(
"IP-Adresse anzeigen mit:"
)
print(
"hostname -I"
)
print()
# --------------------------------------------------
# Webserver dauerhaft laufen lassen
#
# serve_forever() blockiert an dieser Stelle und
# nimmt so lange Anfragen entgegen, bis das
# Programm unterbrochen wird.
# --------------------------------------------------
httpd.serve_forever()
# --------------------------------------------------
# CTRL+C wurde gedrückt
# --------------------------------------------------
# STRG + C löst in Python eine KeyboardInterrupt-
# Exception aus. Diese wird hier abgefangen, damit
# statt einer Fehlermeldung ein sauberer Hinweis
# erscheint.
except KeyboardInterrupt:
print()
print(
"Webserver wird beendet ..."
)
# --------------------------------------------------
# Kamera sauber stoppen
# --------------------------------------------------
# Dieser Block wird immer ausgeführt,
# egal ob das Programm normal oder mit CTRL+C
# beendet wurde.
#
# stop_recording() beendet Kamera und JPEG-Encoder.
# Ohne diesen Schritt könnte die Kamera beim nächsten
# Start noch als "belegt" gelten.
finally:
picam2.stop_recording()
print(
"Kamera gestoppt."
)
Der bereits vorhandene MJPEG-Stream unter /stream.mjpg ist für das CYD nicht geeignet, da es den dauerhaft laufenden Stream in diesem Aufbau nicht zuverlässig verarbeiten kann. Deshalb stellt das Python-Script zusätzlich den Endpunkt /frame.jpg bereit, über den jeweils nur ein einzelnes aktuelles JPEG abgerufen wird.
Bevor dieses Bild an das CYD gesendet wird, löst das Script zunächst einen Autofokus-Vorgang aus. Dafür kommt picam2.autofocus_cycle() zum Einsatz. Erst wenn dieser Vorgang abgeschlossen ist, wartet das Script auf ein neues JPEG aus dem laufenden Kamerastream und verwendet genau dieses Bild für die weitere Verarbeitung. camera_web
Der zentrale Teil sieht so aus:
with autofocus_lock:
focused = picam2.autofocus_cycle()
if not focused:
print("CYD Frame: Autofokus fehlgeschlagen")
with output.condition:
output.condition.wait(timeout=2)
frame = output.frame
Mit autofocus_lock wird verhindert, dass mehrere gleichzeitige Anfragen parallel einen Fokusvorgang starten. Das sorgt dafür, dass sich die einzelnen Zugriffe auf /frame.jpg nicht gegenseitig beeinflussen.
Anschließend wird das aktuelle JPEG mit Pillow geöffnet und auf 320 × 180 Pixel verkleinert:
input_stream = io.BytesIO(frame) image = Image.open(input_stream) image = image.resize((320, 180))
Die Auflösung passt gut zum kleinen Display und reduziert gleichzeitig die Datenmenge, die später über WLAN an das CYD übertragen werden muss. Das Seitenverhältnis von 16:9 bleibt dabei erhalten.
Danach wird das verkleinerte Bild erneut als JPEG erzeugt. Im aktuellen Script wird dafür eine JPEG-Qualität von 80 verwendet. camera_web
Das CYD ruft später lediglich diese Adresse auf: http://<IP-Adresse-RPi>:8000/frame.jpg
Der Raspberry Pi antwortet darauf mit einem normalen JPEG und dem passenden MIME-Type: Content-Type: image/jpeg
Zusätzlich wird das Caching mit Cache-Control: no-store deaktiviert, damit bei jedem Abruf tatsächlich ein aktuelles Bild übertragen wird. camera_web
Damit ist die Raspberry-Pi-Seite vorbereitet. Im nächsten Schritt kümmert sich das CYD darum, dieses JPEG herunterzuladen, auf der microSD-Karte zu speichern und anschließend auf dem TFT anzuzeigen.
Arduino-Programm auf dem ESP32 CYD
Nachdem der Raspberry Pi das aktuelle Kamerabild über /frame.jpg bereitstellt, übernimmt das ESP32 CYD den nächsten Teil der Verarbeitung.
Das Arduino-Programm ist dabei bewusst einfach aufgebaut. Pro Durchlauf wird genau ein aktuelles Kamerabild vom Raspberry Pi geladen, auf der microSD-Karte gespeichert und anschließend auf dem TFT angezeigt. Danach startet der ESP32 neu und beginnt den Ablauf von vorn.
Der komplette Ablauf sieht so aus:
Für das Programm werden folgende Bibliotheken benötigt:
#include <WiFi.h> #include <HTTPClient.h> #include <SD.h> #include <SPI.h> #include <TFT_eSPI.h> #include <TJpg_Decoder.h>
WiFi und HTTPClient übernehmen die Verbindung zum Raspberry Pi und den Download des aktuellen JPEGs. SD speichert das Bild auf der microSD-Karte. TFT_eSPI steuert das Display an und TJpg_Decoder dekodiert das gespeicherte JPEG für die Ausgabe auf dem TFT.
Verbindung zum Raspberry Pi konfigurieren
Zunächst werden die WLAN-Zugangsdaten sowie die Adresse des Raspberry Pi hinterlegt:
const char* WIFI_SSID = "DEIN-WLAN";
const char* WIFI_PASSWORD = "DEIN-PASSWORT";
const char* FRAME_URL =
"http://192.168.178.72:8000/frame.jpg";
Die IP-Adresse muss natürlich an das eigene Netzwerk angepasst werden.
In meinem Aufbau verwende ich zusätzlich eine statische IP-Adresse für das CYD. Dadurch entfällt bei jedem Neustart die erneute Adressvergabe per DHCP und die WLAN-Verbindung kann schneller hergestellt werden.
JPEG direkt auf der SD-Karte speichern
Das vom Raspberry Pi gelieferte Bild wird nicht dauerhaft in einem großen Speicherbereich im RAM gehalten, sondern direkt auf der microSD-Karte gespeichert.
Die Datei trägt dabei denselben Namen wie der verwendete HTTP-Endpunkt:
const char* JPEG_PATH = "/frame.jpg";
Bevor ein neues Bild gespeichert wird, löscht das Programm eine eventuell vorhandene ältere Datei:
if (SD.exists(JPEG_PATH)) {
SD.remove(JPEG_PATH);
}
Anschließend werden die empfangenen JPEG-Daten direkt in /frame.jpg geschrieben.
Damit befindet sich nach jedem Download das aktuelle Kamerabild auf der SD-Karte. Gleichzeitig wird vermieden, für das vollständige JPEG einen zusätzlichen großen Speicherbereich im RAM bereitzuhalten.
Nachdem der Download abgeschlossen ist, wird die HTTP-Verbindung beendet.
WLAN vor der Bildausgabe abschalten
Bevor das Bild auf dem Display ausgegeben wird, wird das WLAN vollständig deaktiviert.
Genau dieser Schritt ist bei meinem verwendeten ESP32-2432S028R entscheidend. Während der Entwicklung zeigte sich, dass die Displayausgabe nicht zuverlässig funktionierte, solange WLAN aktiv war.
Deshalb werden Download und Anzeige bewusst voneinander getrennt:
Erst wenn das Bild sicher auf der SD-Karte gespeichert wurde, wird die WLAN-Verbindung getrennt und der WiFi-Modus deaktiviert.
JPEG auf dem TFT anzeigen
Nach dem Abschalten des WLANs wird das TFT initialisiert. Anschließend liest TJpg_Decoder das gespeicherte JPEG direkt von der microSD-Karte.
Dafür wird zunächst eine Callback-Funktion benötigt:
bool tft_output(
int16_t x,
int16_t y,
uint16_t w,
uint16_t h,
uint16_t* bitmap
) {
tft.pushImage(x, y, w, h, bitmap);
return true;
}
TJpg_Decoder verarbeitet das JPEG blockweise. Jeder dekodierte Bildbereich wird an diese Funktion übergeben und mit tft.pushImage() direkt auf das Display geschrieben.
Anschließend wird der Decoder konfiguriert:
TJpgDec.setCallback(tft_output); TJpgDec.setJpgScale(1);
Das gespeicherte Bild kann danach direkt von der SD-Karte geladen und angezeigt werden:
TJpgDec.drawSdJpg(0, 0, "/frame.jpg");
Die beiden ersten Werte geben die Position auf dem Display an. Mit 0, 0 beginnt die Ausgabe oben links.
Der Vorteil dieser Lösung ist, dass das JPEG nicht erst vollständig in einen zusätzlichen RAM-Puffer geladen werden muss. TJpg_Decoder liest die Datei direkt von der microSD-Karte und übergibt die dekodierten Bildbereiche unmittelbar an TFT_eSPI.
ESP32 neu starten
Nach der Anzeige bleibt das aktuelle Kamerabild für fünf Sekunden auf dem TFT sichtbar.
Anschließend wird der ESP32 vollständig neu gestartet:
delay(5000); ESP.restart();
Der Reboot ist dabei bewusst Teil des Ablaufs.
Während der Tests zeigte sich auf meinem CYD, dass ein erneutes Aktivieren und Deaktivieren des WLANs innerhalb desselben Programmlaufs nicht dauerhaft zuverlässig funktionierte. Deshalb beginnt jeder neue Bildabruf mit einem frischen Start des ESP32.
Nach dem Reboot läuft der Ablauf wieder komplett von vorne.
Das ist sicherlich eine ungewöhnliche Lösung, hat sich auf meinem verwendeten Board aber als zuverlässig erwiesen.
So zeigt das CYD regelmäßig ein aktuelles Kamerabild vom Raspberry Pi an, ohne dass WLAN und Display gleichzeitig aktiv sein müssen.
Fazit
Eigentlich klang die Aufgabe zunächst recht einfach: Das aktuelle Kamerabild vom Raspberry Pi laden und auf dem ESP32 CYD anzeigen. In der Praxis hat sich aber gezeigt, dass genau diese Kombination auf meinem verwendeten ESP32-2432S028R nicht ganz so problemlos funktioniert wie gedacht.
Vor allem die gleichzeitig aktive WLAN-Verbindung und die Displayausgabe sorgten immer wieder für Probleme. Die schließlich funktionierende Lösung trennt beide Aufgaben deshalb konsequent voneinander: Das CYD lädt zunächst /frame.jpg vom Raspberry Pi, speichert das JPEG auf der microSD-Karte, schaltet anschließend WLAN vollständig ab und zeigt das Bild erst danach auf dem TFT an.
Der zusätzliche Neustart nach einigen Sekunden wirkt auf den ersten Blick etwas ungewöhnlich, sorgt in meinem Aufbau aber dafür, dass jeder Durchlauf wieder mit einem sauberen Zustand beginnt.
Ein echtes Livevideo entsteht damit zwar nicht, dafür erhält das CYD regelmäßig ein aktuelles Kamerabild – zuverlässig und ohne zusätzliche Display-Verkabelung am Raspberry Pi.
Für mein weiteres Projekt ist genau das entscheidend: Der Raspberry Pi kann sich weiterhin um Kamera und Bildverarbeitung kümmern, während das CYD als eigenständiges drahtloses Anzeigemodul arbeitet.
Letzte Aktualisierung am: 12. Oktober 2026







