Private
Public Access
added point mode
This commit is contained in:
1 parent
044ad2afbe
commit
fce8f62534
16 files changed
+1547
-10
No files matched your search
@@ -43,8 +43,10 @@ Versorgung und Sicherheit im Plan.
|
|||||||
(Beschriftung auf Nutzerfoto bestätigt), eventuell Display,
|
(Beschriftung auf Nutzerfoto bestätigt), eventuell Display,
|
||||||
LEDs, Servos und Lötwerkzeug. 3D-Drucker laut Nutzer vorhanden;
|
LEDs, Servos und Lötwerkzeug. 3D-Drucker laut Nutzer vorhanden;
|
||||||
Modell, Bauraum und Druckmaterial noch unbekannt.
|
Modell, Bauraum und Druckmaterial noch unbekannt.
|
||||||
PCA9685-Modul mit 16 Kanälen laut Nutzer vorhanden; konkrete Platine
|
PCA9685-Modul mit 16 Kanälen per Nutzerfoto bestätigt; `PCA9685_FOTO.jpg`
|
||||||
und Anschlüsse noch nicht geprüft, kein Neukauf nötig.
|
liegt im Projekt. Beschriftung: Gelb=PWM, Rot=V+, Schwarz=GND; Logikpins
|
||||||
|
GND/OE/SCL/SDA/VCC/V+. Hersteller, Klemmenpolung und Funktion noch prüfen;
|
||||||
|
kein Neukauf nötig.
|
||||||
Buchse/Buchse-Steuerleitungen laut Nutzer reichlich vorhanden;
|
Buchse/Buchse-Steuerleitungen laut Nutzer reichlich vorhanden;
|
||||||
kein Neukauf nötig, nicht für Servolast verwenden.
|
kein Neukauf nötig, nicht für Servolast verwenden.
|
||||||
Weitere Teile dürfen vorgeschlagen werden.
|
Weitere Teile dürfen vorgeschlagen werden.
|
||||||
@@ -60,6 +62,31 @@ Versorgung und Sicherheit im Plan.
|
|||||||
Standard-auf-Mini-Kamerakabel für Pi 5 einplanen; vorhandenes Kabel prüfen.
|
Standard-auf-Mini-Kamerakabel für Pi 5 einplanen; vorhandenes Kabel prüfen.
|
||||||
- GPIOs steuern geeignete Treiber; Motoren, Servos und größere LED-Lasten
|
- GPIOs steuern geeignete Treiber; Motoren, Servos und größere LED-Lasten
|
||||||
erhalten eine passend dimensionierte Versorgung.
|
erhalten eine passend dimensionierte Versorgung.
|
||||||
|
- Kamera-Schwenkarm laut Nutzer vorhanden, Kameraservos laut Nutzer Amazon
|
||||||
|
B07KPS9845; genaue Variante und elektrische Daten noch prüfen. Dauerhafter
|
||||||
|
Anschlussplan: `VERDRAHTUNG.md` mit `VERDRAHTUNG.svg` und großer
|
||||||
|
Pi-Pinübersicht `RASPI_PINS.svg`; Änderungen an betroffenen Darstellungen
|
||||||
|
synchron pflegen und in `PLAN.md` erfassen. Geplant: vorhandener PCA9685 am
|
||||||
|
Pi 5, CH0=PAN (unten), CH1=TILT (oben), VCC=3,3 V, eigene vorläufige
|
||||||
|
5-V-Servoversorgung. Platinenfoto und Reihenbeschriftung dokumentiert; Netzteil,
|
||||||
|
Servosteckerpolung, Kameraport und reale Verdrahtung noch ungeprüft. 6-V-Handversorgung nicht auf dieselbe
|
||||||
|
V+-Schiene legen. Nutzer meldet Verdrahtung fertig; aktuell Powerbank für
|
||||||
|
Kameraservos, Modell/Stromreserve und reale Funktion noch ungeprüft.
|
||||||
|
- Neuer Farbpunkt-Test (`halloween/follow.py`): lokale HSV-Farbwahl per
|
||||||
|
flüchtigem Beispielfoto oder Rot/Grün, Vorschau ohne Hardware; echte Ausgabe
|
||||||
|
nur bewusst und außerhalb der automatischen Auslösung am PCA9685 CH0/CH1.
|
||||||
|
1500-µs-Startstellung vorab entkoppelt prüfen, enge konfigurierbare Grenzen,
|
||||||
|
langsame Nachführung. Globaler Stopp/Reset beendet PWM. Heartbeat-/Bild-/
|
||||||
|
Auswertungs- und Zielverlust beenden die Sitzung; kein automatischer Neustart.
|
||||||
|
Farb-/Pulsparameter bleiben in data/follow.json; keine Bilder/Trainingsdaten.
|
||||||
|
Noch keine unabhängige OE-/Hardwareabschaltung; I²C-Ausfall oder harter
|
||||||
|
Prozessabbruch kann PWM stehen lassen. Nur beaufsichtigter Test, echte
|
||||||
|
Servos und Versorgung noch zu prüfen. Details in SOFTWARE.md.
|
||||||
|
- Manueller Kamera-Einrichtmodus: Richtungstasten halten statt automatischer
|
||||||
|
Anschlagfahrt; beide Achsen vor Anschlag stoppen. 40 µs/s, 0,35-s-Bewegungslease,
|
||||||
|
Loslassen hält Position, Stopp beendet PWM. Kein Farbzielsuchen; Grenzen aus
|
||||||
|
Sollwerten mit 10 µs Innenabstand speichern. Keine Endschalter/Strommessung;
|
||||||
|
absolute Testgrenzen 1100–1900 µs bleiben. Kein realer Anschlagtest bestätigt.
|
||||||
- Aktueller Nutzerumfang: zwei Antriebe, einer für die Hand und einer
|
- Aktueller Nutzerumfang: zwei Antriebe, einer für die Hand und einer
|
||||||
für die Atemrotation. Nutzer sieht nun 230 V mit Relais für Rotation
|
für die Atemrotation. Nutzer sieht nun 230 V mit Relais für Rotation
|
||||||
vor; CHANCS 60KTYZ (Amazon B07WXFWYBZ, 220–240 V, ca. 2,5 U/min)
|
vor; CHANCS 60KTYZ (Amazon B07WXFWYBZ, 220–240 V, ca. 2,5 U/min)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -1,5 +1,29 @@
|
|||||||
# Halloween – Projektregeln
|
# Halloween – Projektregeln
|
||||||
|
|
||||||
|
## Verdrahtungsplan vom 15. Oktober 2026
|
||||||
|
|
||||||
|
- Erledigt: dauerhafte Anschlussliste [VERDRAHTUNG.md](VERDRAHTUNG.md) und
|
||||||
|
[Zeichnung](VERDRAHTUNG.svg) für Pi 5, Kamera und zwei Kameraservos angelegt.
|
||||||
|
Kabelkennungen W01–W13; geplante Belegung CH0=PAN, CH1=TILT.
|
||||||
|
- Nutzerstand: Schwenkarm gekauft, Kameraadapter wird gedruckt; Nutzer nennt
|
||||||
|
Amazon B07KPS9845 als Servos. Genaue Servodaten nicht unabhängig bestätigt.
|
||||||
|
- Dokumentarisch geprüft: Pi-/PCA9685-Verbindung gegen Herstellerreferenzen,
|
||||||
|
getrennte Logik-/Servoversorgung und gemeinsame Masse beschrieben.
|
||||||
|
Keine praktische Verdrahtungs- oder Bewegungsprüfung erfolgt.
|
||||||
|
- Per Nutzerfoto bestätigt und lokal abgelegt: PCA9685 mit 16 Kanälen;
|
||||||
|
gelbe Reihe PWM, rote V+, schwarze GND; Plan um das Boardfoto ergänzt.
|
||||||
|
- Revision 4: große Pi-Pinübersicht [RASPI_PINS.svg](RASPI_PINS.svg) mit
|
||||||
|
eindeutiger Draufsicht und vergrößerten ersten drei Pinpaaren ergänzt.
|
||||||
|
- Revision 3: W01 Grün, W02 Blau, W03 Braun, W04 Grau passend zu den
|
||||||
|
Nutzerkabeln in Anschlussliste und Zeichnung dokumentiert.
|
||||||
|
- Offen: Klemmenpolung und Kanalposition am Board, Servotyp/Kabelfarben, geeignetes
|
||||||
|
Servonetzteil und Polung, benutzter Kameraport, I²C-Adresse, Winkelgrenzen
|
||||||
|
und Lasttest. Entwurf noch keine bestätigte Steckanleitung für D1.
|
||||||
|
- Offen: echte Kameraservosteuerung und sichere Start-/Fehlerabschaltung;
|
||||||
|
Handservo, Relais/Motor und Beleuchtung später im selben Plan ergänzen.
|
||||||
|
- Pflege: nach jeder tatsächlichen Anschlussänderung Liste, Zeichnung und
|
||||||
|
Prüfprotokoll gemeinsam aktualisieren; Kabel an beiden Enden beschriften.
|
||||||
|
|
||||||
## Ziel und aktueller Auftrag
|
## Ziel und aktueller Auftrag
|
||||||
|
|
||||||
Interaktives atmendes Grab für Halloween im Vorgarten der Schwester bauen.
|
Interaktives atmendes Grab für Halloween im Vorgarten der Schwester bauen.
|
||||||
@@ -331,3 +355,44 @@ Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen.
|
|||||||
Aufräumen bei Erfolg/Fehler. Ohne Schlüssel sind SSH und sudo zwei Anmeldungen.
|
Aufräumen bei Erfolg/Fehler. Ohne Schlüssel sind SSH und sudo zwei Anmeldungen.
|
||||||
- Lokal verifiziert: 15 Update-/Transport-Simulationstests bestanden, keine
|
- Lokal verifiziert: 15 Update-/Transport-Simulationstests bestanden, keine
|
||||||
Verbindung/Socketerzeugung bei Vorschau. Echte Passwortanzahl am Pi noch offen.
|
Verbindung/Socketerzeugung bei Vorschau. Echte Passwortanzahl am Pi noch offen.
|
||||||
|
|
||||||
|
|
||||||
|
## Farbpunktnachführung vom 15. Oktober 2026
|
||||||
|
|
||||||
|
- Nutzerauftrag: Kameramodus zum Verfolgen eines Fingers beziehungsweise farbigen
|
||||||
|
Punkts, eventuell mit Beispielfoto. Umgesetzt als Farbpunkt-Test, keine
|
||||||
|
Finger-/Handerkennung und kein trainiertes Modell.
|
||||||
|
- Erledigt: Rot/Grün oder Farbwahl im flüchtigen Kamerafoto; eigener synchroner
|
||||||
|
Bildbereich, Vorschau ohne I²C; bewusst gestartete CH0/CH1-Ausgabe mit enger
|
||||||
|
Pulsbegrenzung, Totzone, Richtungsumkehr und begrenzter Geschwindigkeit.
|
||||||
|
- Erledigt: kein gleichzeitiger Automatikbetrieb; Controller bleibt gestoppt.
|
||||||
|
Stop/Reset, Heartbeatverlust, Bild-/Auswertungsfehler und Marker-Verlust
|
||||||
|
beenden Hardware-Sitzung; Konfiguration bleibt erhalten, Aktivierung nicht.
|
||||||
|
- Nutzer meldet Verdrahtung fertig; derzeit separate Powerbank, die bereits
|
||||||
|
zwei Servos betrieben hat. Modell/Stromreserve und Leitungsstärke offen.
|
||||||
|
- Verifiziert lokal: synthetische Bild-/Regelungs-/Fehler-/API-Tests und
|
||||||
|
künstlicher I²C-Bus; Browserdemo prüft Farbwahl, Vorschau, Stopp, Beispielfoto.
|
||||||
|
Vollsuite: 84 Tests, davon 69 bestanden und 15 wegen fehlendem optionalem
|
||||||
|
OpenCV/Modell übersprungen; beide bisherigen JS-UI-Tests und JS-Syntaxprüfung
|
||||||
|
bestanden. Keine echte Bewegung, kein Upload am Pi erfolgt.
|
||||||
|
- Offen am Pi: Upload, I²C/Berechtigung/Adresse, entkoppelte 1500-µs-Zentrierung,
|
||||||
|
Drehrichtungen und Grenzen, Stromreserve, Nachführung mit echter NoIR-Kamera.
|
||||||
|
- Offen: unabhängige OE-/Hardwareabschaltung. PWM-Aus bei funktionierendem I²C
|
||||||
|
ist keine Stromtrennung und schützt nicht vor hartem Prozess-/Pi-Ausfall.
|
||||||
|
|
||||||
|
|
||||||
|
## Manueller Kameraeinrichtmodus vom 15. Oktober 2026
|
||||||
|
|
||||||
|
- Nutzerauftrag: Endpunkte ermitteln, insbesondere Neigung manuell stoppen;
|
||||||
|
keine Endschalter vorhanden. Keine automatische Fahrt gegen Anschläge umgesetzt.
|
||||||
|
- Erledigt: eigene Einrichtsitzung, große Richtungstasten mit Haltefunktion,
|
||||||
|
sichtbare Sollwerte und gespeicherte Grenzen; Richtungsumkehr konsistent.
|
||||||
|
40 µs/s, Bewegungslease 0,35 s, Sequenzen gegen verspätete Befehle; Loslassen
|
||||||
|
hält Position, Kamerastopp beendet PWM. Grenzspeicherung mit 10 µs Innenabstand.
|
||||||
|
- Lokal geprüft: synthetische Hardware-/Lease-/Persistenztests; Browserdemo
|
||||||
|
mit gedrückter Taste, anschließend unveränderter Sollposition und Speicherung.
|
||||||
|
Keine echte Bewegung, kein Upload und keine Bestätigung realer Endpunkte.
|
||||||
|
- Offen am Pi: Mittelstellung, Drehrichtungen, sichere mechanische Grenzen und
|
||||||
|
Loslassen/Stopp bei echtem Aufbau prüfen. Beide Achsen vor Anschlag stoppen.
|
||||||
|
- Abschlussprüfung lokal: 87 Python-Tests, davon 72 bestanden, 15 optionale
|
||||||
|
OpenCV-/Modelltests übersprungen. Bisherige JS-Tests und JS-Syntaxprüfung bestanden.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1100" viewBox="0 0 1500 1100"><rect width="1500" height="1100" fill="white"/><style>text{font-family:DejaVu Sans,Arial,sans-serif;fill:#142233;font-size:28px}.big{font-size:40px;font-weight:bold}.num{font-size:46px;font-weight:bold;text-anchor:middle}.mid{font-size:30px;text-anchor:middle}.small{font-size:22px}</style>
|
||||||
|
<text x="45" y="60" class="big">Pi 5 · Diese vier Pins brauchst du</text>
|
||||||
|
<text x="45" y="107" class="">Von oben auf die Bauteile schauen. USB- und Netzwerkbuchsen nach rechts.</text>
|
||||||
|
<rect x="85" y="145" width="1330" height="245" rx="12" fill="#eaf4e8" stroke="#142233" stroke-width="3"/>
|
||||||
|
<text x="105" y="185" class="small">PLATINENRAND</text>
|
||||||
|
<rect x="185" y="200" width="980" height="85" rx="12" fill="#e2e4e7" stroke="#142233" stroke-width="3"/>
|
||||||
|
<circle cx="210" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="210" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="258" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="258" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="306" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="306" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="354" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="354" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="402" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="402" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="450" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="450" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="498" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="498" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="546" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="546" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="594" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="594" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="642" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="642" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="690" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="690" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="738" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="738" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="786" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="786" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="834" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="834" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="882" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="882" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="930" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="930" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="978" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="978" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="1026" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="1026" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="1074" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="1074" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="1122" cy="224" r="8" fill="#3b4149"/>
|
||||||
|
<circle cx="1122" cy="261" r="8" fill="#3b4149"/>
|
||||||
|
<rect x="190" y="203" width="130" height="76" rx="12" fill="none" stroke="#bb3434" stroke-width="3"/>
|
||||||
|
<text x="210" y="333" class="small">Hier anfangen: Ende gegenüber den USB-Buchsen</text>
|
||||||
|
<rect x="1240" y="200" width="160" height="145" rx="12" fill="#d7dce2" stroke="#142233" stroke-width="3"/>
|
||||||
|
<text x="1320" y="247" class="mid">USB /</text>
|
||||||
|
<text x="1320" y="291" class="mid">LAN</text>
|
||||||
|
<rect x="115" y="356" width="140" height="48" rx="12" fill="#d7dce2" stroke="#142233" stroke-width="3"/>
|
||||||
|
<text x="130" y="388" class="small">USB-C</text>
|
||||||
|
<text x="45" y="465" class="big">VERGRÖSSERT: die ersten drei Pinpaare</text>
|
||||||
|
<text x="45" y="516" class="small">Äußere Reihe: direkt am Platinenrand</text>
|
||||||
|
<text x="45" y="555" class="small">Pins 2 und 4 bleiben frei (5 V).</text>
|
||||||
|
<text x="45" y="816" class="small">Innere Reihe: zur Platinenmitte</text>
|
||||||
|
<circle cx="520" cy="580" r="46" fill="#ffffff" stroke="#142233" stroke-width="4"/>
|
||||||
|
<text x="520" y="596" class="num">2</text>
|
||||||
|
<text x="520" y="665" class="mid">FREI · 5 V</text>
|
||||||
|
<circle cx="860" cy="580" r="46" fill="#ffffff" stroke="#142233" stroke-width="4"/>
|
||||||
|
<text x="860" y="596" class="num">4</text>
|
||||||
|
<text x="860" y="665" class="mid">FREI · 5 V</text>
|
||||||
|
<circle cx="1200" cy="580" r="46" fill="#7a7f87" stroke="#142233" stroke-width="4"/>
|
||||||
|
<text x="1200" y="596" class="num">6</text>
|
||||||
|
<text x="1200" y="665" class="mid">W04 · GRAU → GND</text>
|
||||||
|
<circle cx="520" cy="790" r="46" fill="#20885c" stroke="#142233" stroke-width="4"/>
|
||||||
|
<text x="520" y="806" class="num" style="fill:white">1</text>
|
||||||
|
<text x="520" y="877" class="mid">W01 · GRÜN → VCC</text>
|
||||||
|
<circle cx="860" cy="790" r="46" fill="#316dc8" stroke="#142233" stroke-width="4"/>
|
||||||
|
<text x="860" y="806" class="num" style="fill:white">3</text>
|
||||||
|
<text x="860" y="877" class="mid">W02 · BLAU → SDA</text>
|
||||||
|
<circle cx="1200" cy="790" r="46" fill="#80502e" stroke="#142233" stroke-width="4"/>
|
||||||
|
<text x="1200" y="806" class="num" style="fill:white">5</text>
|
||||||
|
<text x="1200" y="877" class="mid">W03 · BRAUN → SCL</text>
|
||||||
|
<text x="45" y="950" class="">W01: 3,3 V W02: GPIO2 / SDA W03: GPIO3 / SCL W04: Masse</text>
|
||||||
|
<text x="45" y="1008" class="small">Alle Nummern sind physische Pins. Zum Stecken beide Netzteile abziehen.</text>
|
||||||
|
<text x="45" y="1055" class="small">Schema, nicht maßstäblich · 15.10.2026 · Ergänzung zu VERDRAHTUNG.md</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -10,7 +10,9 @@ erkennt NanoDet lokal Personen und löst beim Überqueren einer einstellbaren
|
|||||||
Linie zum Grab hin automatisch den simulierten Effekt aus. Einrichtung und
|
Linie zum Grab hin automatisch den simulierten Effekt aus. Einrichtung und
|
||||||
Aktivierung stehen in [SOFTWARE.md](SOFTWARE.md#personenerkennung-und-linienauslösung).
|
Aktivierung stehen in [SOFTWARE.md](SOFTWARE.md#personenerkennung-und-linienauslösung).
|
||||||
Das echte Kamera-Livebild ist vom Nutzer bestätigt. Personenerkennung am Pi,
|
Das echte Kamera-Livebild ist vom Nutzer bestätigt. Personenerkennung am Pi,
|
||||||
GPIO-Steuerung, Besucher-Wiedererkennung und Clip-Aufnahmen bleiben offen.
|
GPIO-Steuerung der Grabaktoren, Besucher-Wiedererkennung und Clip-Aufnahmen bleiben offen.
|
||||||
|
Der separate [Farbpunkt-Test](SOFTWARE.md#kamera-folgt-einem-farbpunkt) kann nach
|
||||||
|
bewusstem Start die beiden Kameraservos am PCA9685 bewegen; echte Hardwareprüfung steht aus.
|
||||||
|
|
||||||
## Lokal ohne Hardware starten
|
## Lokal ohne Hardware starten
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ manuelle Start für direkten Heimnetzzugriff steht in [SOFTWARE.md](SOFTWARE.md)
|
|||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
|
- [Verdrahtung von Kamera und Schwenkarm](VERDRAHTUNG.md) · [Anschlusszeichnung](VERDRAHTUNG.svg)
|
||||||
- [Software, Pi-Einrichtung und lokale Prüfungen](SOFTWARE.md)
|
- [Software, Pi-Einrichtung und lokale Prüfungen](SOFTWARE.md)
|
||||||
- [Projektdateien auf den Pi übertragen](UPLOAD.md)
|
- [Projektdateien auf den Pi übertragen](UPLOAD.md)
|
||||||
- [Arbeitsplan, Hardwarestand und offene Entscheidungen](PLAN.md)
|
- [Arbeitsplan, Hardwarestand und offene Entscheidungen](PLAN.md)
|
||||||
|
|||||||
+151
-3
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
|
Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
|
||||||
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
|
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
|
||||||
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
**Simulation** des Effektablaufs. Hand, Atemmotor, Lampen und Lautsprecher
|
||||||
Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert
|
bleiben simuliert. Der separate Farbpunkt-Test kann die beiden Kameraservos
|
||||||
|
am PCA9685 nach bewusstem Start tatsächlich bewegen. Eine lokale SQLite-Datenbank speichert
|
||||||
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
|
Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber
|
||||||
am Pi installiert und kalibriert werden. Bei automatischer Auslösung wird
|
am Pi installiert und kalibriert werden. Bei automatischer Auslösung wird
|
||||||
ein Bild des grünen Bereichs lokal gespeichert. Profilzuordnung per Kamera,
|
ein Bild des grünen Bereichs lokal gespeichert. Profilzuordnung per Kamera,
|
||||||
@@ -258,7 +259,7 @@ Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der
|
|||||||
bestehenden Datenbank gespeichert, mit lokalem Bild des grünen Bereichs aus
|
bestehenden Datenbank gespeichert, mit lokalem Bild des grünen Bereichs aus
|
||||||
dem auslösenden Frame. Tracking-IDs werden nicht gespeichert; Profil-Wiedererkennung
|
dem auslösenden Frame. Tracking-IDs werden nicht gespeichert; Profil-Wiedererkennung
|
||||||
und Altersentscheidung fehlen.
|
und Altersentscheidung fehlen.
|
||||||
Aktoren bleiben ausschließlich simuliert.
|
Die Aktoren des Grabablaufs bleiben simuliert; Kameraservos haben einen eigenen Testmodus.
|
||||||
|
|
||||||
### Nahbereich testen: Bewegung in Pfeilrichtung
|
### Nahbereich testen: Bewegung in Pfeilrichtung
|
||||||
|
|
||||||
@@ -338,3 +339,150 @@ HALLOWEEN_TEST_MODEL="$PWD/data/models/object_detection_nanodet_2022nov.onnx" .v
|
|||||||
|
|
||||||
Ohne diese Variable wird nur der optionale Modell-Smoke-Test übersprungen;
|
Ohne diese Variable wird nur der optionale Modell-Smoke-Test übersprungen;
|
||||||
die Linien-/API-/Installer-Tests benötigen weder Modell noch OpenCV.
|
die Linien-/API-/Installer-Tests benötigen weder Modell noch OpenCV.
|
||||||
|
|
||||||
|
|
||||||
|
## Kamera folgt einem Farbpunkt
|
||||||
|
|
||||||
|
In der WebUI gibt es einen eigenen Abschnitt **Kamera folgt einem Farbpunkt ·
|
||||||
|
Testmodus**. Er verfolgt einen deutlich gefärbten Aufkleber oder Punkt, auch
|
||||||
|
am Finger. Eine nackte Hand beziehungsweise ein Finger wird nicht als solcher
|
||||||
|
erkannt. Die Farbe wird lokal mit Pillow ausgewertet, ohne Modelltraining,
|
||||||
|
OpenCV oder Internet. NoIR-Farben, IR-Licht und wechselnder Weißabgleich können
|
||||||
|
vom Tageslichtbild abweichen; unter dem späteren Licht neu auswählen.
|
||||||
|
|
||||||
|
### Vorbereitung am Pi
|
||||||
|
|
||||||
|
Die Abhängigkeit `smbus2` steht in `requirements.txt` und wird beim normalen
|
||||||
|
versionierten Upload in die neue Umgebung installiert. Bei manueller
|
||||||
|
Installation dieselbe Datei in der verwendeten Umgebung installieren.
|
||||||
|
I²C muss am Pi aktiviert sein: `sudo raspi-config` → **Interface Options →
|
||||||
|
I2C**. Der Dienstbenutzer benötigt Zugriff auf `/dev/i2c-1` (Gruppe `i2c`).
|
||||||
|
Fehlende Berechtigung nicht mit einem Webserver als root umgehen.
|
||||||
|
|
||||||
|
Der Treiber verwendet Bus 1, Adresse `0x40`, 50 Hz (nominal), PAN=CH0,
|
||||||
|
TILT=CH1. Die Frequenz gilt für die ganze PCA9685-Platine. Nur eine WebUI-Instanz/ein Worker darf
|
||||||
|
den Treiber steuern; in diesem Test darf
|
||||||
|
sie ausschließlich die beiden Kameraservos versorgen. Alle anderen Kanäle
|
||||||
|
bleiben unbestückt. Verdrahtung siehe [VERDRAHTUNG.md](VERDRAHTUNG.md).
|
||||||
|
|
||||||
|
Aktuell laut Nutzer: Aufbau verdrahtet, separate Powerbank als Servoversorgung;
|
||||||
|
diese hat bereits zwei Servos betrieben. Modell, 5-V-Ausgangsstrom,
|
||||||
|
Spannungseinbrüche, Leitungsstärke und Dauerbetrieb noch nicht geprüft.
|
||||||
|
Vor Updates/Neustarts die Servoversorgung abziehen.
|
||||||
|
|
||||||
|
### Farbe auswählen und ohne Bewegung prüfen
|
||||||
|
|
||||||
|
1. **Automatisch auslösen** ausschalten und **Linie und Bereich speichern**
|
||||||
|
drücken. Farbtest und automatische Auslösung schließen sich gegenseitig aus.
|
||||||
|
2. **Grün wählen** oder **Rot wählen** drücken. Alternativ **Beispielfoto
|
||||||
|
aufnehmen** und im eingefrorenen Foto auf die Mitte des Farbpunkts tippen.
|
||||||
|
Ein ausreichend heller und gesättigter Farbton ist nötig. Das Foto und die
|
||||||
|
Auswertungsbilder bleiben ausschließlich im Arbeitsspeicher; gespeichert
|
||||||
|
werden nur Farbwert, Toleranz, Richtungen und Pulsgrenzen in `data/follow.json`.
|
||||||
|
3. **Nur Farbvorschau starten**. Im eigenen Bild markiert ein Kreis den
|
||||||
|
ausgewerteten Farbpunkt; das Kreuz ist die gewünschte Bildmitte. Dabei
|
||||||
|
erfolgt keinerlei Zugriff auf den PCA9685. Zu kleine oder großflächige
|
||||||
|
Farbflächen und zwei ähnlich große Kandidaten ergeben kein eindeutiges Ziel.
|
||||||
|
4. **Kameraservos stoppen**. Änderungen an Farbe und Grenzen sind nur bei
|
||||||
|
gestoppter Nachführung erlaubt.
|
||||||
|
|
||||||
|
### Erste echte Bewegung
|
||||||
|
|
||||||
|
Der Start der echten Nachführung setzt beide Servos sofort auf **1500 µs**.
|
||||||
|
Das kann aus einer unbekannten bisherigen Stellung eine größere Bewegung
|
||||||
|
sein. Für die erste Zentrierung Servohörner/Gestänge lösen, lose Teile sichern
|
||||||
|
und freie Mittelstellung prüfen. Danach spannungsfrei montieren. Die anfänglichen
|
||||||
|
Grenzen 1400–1600 µs sind ein enger Testbereich, keine für die vorhandene
|
||||||
|
Halterung bestätigten Winkel. Das Kamerakabel darf nicht unter Zug geraten.
|
||||||
|
|
||||||
|
Nach Prüfung den Hinweis zum freien Bewegungsraum bestätigen und **Echte
|
||||||
|
Kameranachführung starten** drücken. PAN- und TILT-Pulsbreiten werden angezeigt.
|
||||||
|
Bewegt sich eine Achse vom Punkt weg, stoppen, unter **Servogrenzen und Richtung
|
||||||
|
einstellen** die betreffende Richtung umkehren und speichern. Grenzen pro Achse
|
||||||
|
schrittweise ermitteln; absolut erlaubt sind 1100–1900 µs mit 1500 innerhalb
|
||||||
|
des Bereichs. Gleiche Minimum-/Maximumwerte von 1500 sperren eine Achse auf die
|
||||||
|
Mittelstellung. Diese Werte sind Pulsbreiten, keine garantierten Winkel.
|
||||||
|
|
||||||
|
Die Regelung hat eine Totzone um die Bildmitte und ändert die Pulse um maximal
|
||||||
|
80 µs/s. Bei fehlendem oder uneindeutigem Ziel wird nicht weiter nachgeführt;
|
||||||
|
nach einer Sekunde wird die Hardware-Sitzung beendet. Es gibt keine Suchfahrt.
|
||||||
|
Sehr schnelle Bewegung oder ein enger eingestellter Bereich kann dazu führen,
|
||||||
|
dass der Punkt aus dem Bild läuft. Der Modus ersetzt keine zuverlässige
|
||||||
|
Personen- oder Fingererkennung.
|
||||||
|
|
||||||
|
### Stopp und Fehler
|
||||||
|
|
||||||
|
**Kameraservos stoppen**, der allgemeine **Stopp** und **Nach Stopp
|
||||||
|
zurücksetzen** beenden die Nachführung und setzen CH0/CH1 auf PWM aus.
|
||||||
|
Der Grabablauf bleibt nach dem Farbtest bis zum bewussten Zurücksetzen
|
||||||
|
angehalten. Schließen/Neuladen des Browserfensters oder ein Wechsel in den
|
||||||
|
Hintergrund beendet die Sitzung über Stopp beziehungsweise den Heartbeat:
|
||||||
|
nach 1,5 Sekunden ohne die startende Bedienseite wird abgeschaltet.
|
||||||
|
Die Nachführung benötigt neue Kamerabilder und neue Auswertungen innerhalb
|
||||||
|
einer Sekunde. Fehler verlangen einen neuen bewussten Start. Einstellungen
|
||||||
|
bleiben erhalten, aktive Nachführung und Hardwarefreigabe werden nie gespeichert.
|
||||||
|
Im `--demo`-Betrieb wird echte Servoansteuerung grundsätzlich abgelehnt.
|
||||||
|
|
||||||
|
**PWM aus trennt nicht die Versorgung.** Ein festhängender Pi, verlorener
|
||||||
|
I²C-Bus oder ein harter Prozessabbruch kann die letzte Ausgabe im PCA9685
|
||||||
|
stehen lassen; es gibt noch keine unabhängige OE-/Hardwareabschaltung.
|
||||||
|
Servoversorgung bei unerwartetem Verhalten sofort abziehen und diesen
|
||||||
|
Versuchsmodus nur beaufsichtigt verwenden. Ein Neustart setzt eine vor einem
|
||||||
|
harten Abbruch verbliebene PWM-Ausgabe nicht verlässlich zurück.
|
||||||
|
|
||||||
|
API: `GET /api/follow` liefert Status und synchrones Auswertungsbild.
|
||||||
|
`POST /api/follow/{config,capture,select,start,heartbeat,stop}` benötigt den
|
||||||
|
bestehenden UI-Header. Nur die startende Seite erhält den flüchtigen
|
||||||
|
Heartbeat-Schlüssel. Farbtests erzeugen weder Ereignisse noch Profile.
|
||||||
|
|
||||||
|
Lokal geprüft: synthetische Farbbilder, Begrenzung/Richtung, Sitzungsablauf,
|
||||||
|
Fehlerfälle, API-Sperren und simulierte I²C-Registerzugriffe. Browserprüfung
|
||||||
|
mit Demo-Kamera: Farbwahl, Vorschau, Stopp und Beispielfoto. Keine Aussage
|
||||||
|
über echte Servobewegung, Stromreserve oder Nachführqualität am Pi.
|
||||||
|
|
||||||
|
Technische Referenzen: [Pillow HSV](https://pillow.readthedocs.io/en/stable/handbook/concepts.html),
|
||||||
|
[NXP PCA9685](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf),
|
||||||
|
[smbus2](https://smbus2.readthedocs.io/en/latest/api.html).
|
||||||
|
|
||||||
|
|
||||||
|
## Endpunkte der Kamerahalterung manuell einrichten
|
||||||
|
|
||||||
|
Im Farbpunkt-Abschnitt **Endpunkte einrichten · manuell** öffnen. Die
|
||||||
|
Servomittelstellung muss wie oben beschrieben vorab geprüft sein. Die
|
||||||
|
Automatik bleibt ausgeschaltet. Bewegungsraum bestätigen und **Einrichtmodus
|
||||||
|
starten** drücken. Der Start setzt wieder beide Achsen auf 1500 µs. Im Demo-
|
||||||
|
Betrieb heißt der Knopf **Einrichtmodus als Demo starten** und bewegt nur
|
||||||
|
virtuelle Werte; es gibt keinen I²C-Zugriff.
|
||||||
|
|
||||||
|
- **Links**, **Rechts**, **Hoch** oder **Runter** gedrückt halten. Immer nur
|
||||||
|
eine Achse wird mit maximal 40 µs/s verstellt. Mit der Tastatur funktioniert
|
||||||
|
das über fokussierte Richtungstaste plus Leertaste/Enter. Loslassen,
|
||||||
|
Fokusverlust oder abgebrochene Zeigergeste beendet die Verstellung.
|
||||||
|
- Vor dem mechanischen Anschlag loslassen, auch links/rechts. Bei fehlendem
|
||||||
|
Tastensignal endet die Verstellung nach 0,35 s; bei Verbindungsverlust gilt
|
||||||
|
zusätzlich die bisherige Abschaltung der ganzen Sitzung. Die Position wird
|
||||||
|
nach dem Loslassen weiter per PWM gehalten, bis **Kameraservos stoppen**
|
||||||
|
beziehungsweise der allgemeine Stopp die Ausgabe beendet.
|
||||||
|
- **Links-Grenze speichern**, **Rechts-Grenze speichern**, **Oben-Grenze
|
||||||
|
speichern** oder **Unten-Grenze speichern** übernimmt den zuletzt
|
||||||
|
angeforderten Wert mit 10 µs Abstand nach innen. Die gespeicherten Grenzen
|
||||||
|
sind unter den Tasten dauerhaft sichtbar und gelten für die Farbnachführung.
|
||||||
|
Richtungsumkehr wird bei Bewegung und Speicherung gleich berücksichtigt.
|
||||||
|
- Der Einrichtmodus darf die bisher gespeicherten Farbtest-Grenzen bewusst
|
||||||
|
überschreiten; die absoluten Softwaregrenzen bleiben **1100–1900 µs**.
|
||||||
|
Ist damit nicht genug Weg verfügbar, stoppen und Mechanik/Servodaten klären,
|
||||||
|
nicht die Halterung gewaltsam verstellen. Die Grenzen müssen weiterhin die
|
||||||
|
1500-µs-Startposition einschließen.
|
||||||
|
|
||||||
|
Es gibt keine automatische Anschlagfahrt und keine Endschalterauswertung.
|
||||||
|
Angezeigte Werte sind **Sollwerte**, keine gemessene Position. Ohne Encoder,
|
||||||
|
Strommessung oder Endschalter kann die Software eine Blockade nicht erkennen.
|
||||||
|
Der 10-µs-Abstand ist ein kleiner zusätzlicher Puffer, keine mechanische
|
||||||
|
Sicherheitsgarantie. Die Einrichtung braucht keinen Farbpunkt; frische Kamera,
|
||||||
|
Sitzungs-Heartbeat und Abschaltregeln bleiben erforderlich.
|
||||||
|
|
||||||
|
API-Ergänzung: `start` nimmt `operation=setup` an; `jog` benötigt den privaten
|
||||||
|
Sitzungsschlüssel, eine Richtung und eine steigende Sequenznummer. Verspätete
|
||||||
|
Tastenbefehle dürfen einen neueren Loslassbefehl nicht überholen. `remember`
|
||||||
|
speichert eine Grenze erst nach beendeter Bewegung. Einstellungen und
|
||||||
|
Verdrahtung sind weiterhin im selben Plan dokumentiert.
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
# Verdrahtung von Kamera und Schwenkarm
|
||||||
|
|
||||||
|
Stand: 15. Oktober 2026 · Revision 6 · Anschlussentwurf
|
||||||
|
|
||||||
|
Dieser Plan dokumentiert Pi 5, Camera Module 3 NoIR und die beiden
|
||||||
|
Kameraservos am vorhandenen PCA9685. Er dient später auch dem Wiederaufbau.
|
||||||
|
Die Kanalnamen sind ab jetzt fest geplant; die tatsächlich gesteckten Kabel
|
||||||
|
werden erst nach Abgleich mit der Platine als geprüft eingetragen.
|
||||||
|
|
||||||
|
**Vor dem Anschließen fehlen noch die Ausgangsdaten des vorgesehenen
|
||||||
|
Servonetzteils und die Prüfung der Servokabel.** Das PCA9685-Foto liegt jetzt vor. Die Zeichnung zeigt
|
||||||
|
elektrische Verbindungen, keine maßstäbliche Anordnung der Steckkontakte.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Geräte und feste Kennungen
|
||||||
|
|
||||||
|
| Kennung | Bauteil | Stand |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | Raspberry Pi 5 | Vorhanden; eigenes USB-C-Netzteil |
|
||||||
|
| C1 | Raspberry Pi Camera Module 3 NoIR | Vorhanden; echtes Livebild bestätigt |
|
||||||
|
| D1 | PCA9685 mit 16 Kanälen | Foto bestätigt PCA9685-Modul mit 16 Kanälen; Hersteller nicht identifiziert |
|
||||||
|
| S1 | Unterer Servo, links und rechts schwenken, PAN | Nutzer nennt Amazon B07KPS9845 für beide Kameraservos |
|
||||||
|
| S2 | Oberer Servo, hoch und runter neigen, TILT | Gleicher Nutzerlink; Typenschild/Stecker noch prüfen |
|
||||||
|
| P1 | Separate Versorgung der Kameraservos | Aktuell laut Nutzer separate Powerbank; Modell/Stromreserve offen. Planungsgröße bleibt 5 V DC / 3 A |
|
||||||
|
|
||||||
|
Der Amazon-Link ließ sich nicht direkt auslesen. Deshalb sind exakte
|
||||||
|
Servovariante, zulässige Spannung, Spitzenstrom und Winkelbereich noch nicht
|
||||||
|
als Herstellerdaten bestätigt. 5 V und 3 A sind eine vorläufige Auslegung,
|
||||||
|
keine gemessene Freigabe. Vor Betrieb Typ/Spannung prüfen; beim gleichzeitigen
|
||||||
|
Anlaufen beider Servos Spannung und Verhalten kontrollieren.
|
||||||
|
|
||||||
|
## Foto der vorhandenen Platine
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Foto vom Nutzer, 15. Oktober 2026. Aufdruck: PCA9685, 16 Kanäle,
|
||||||
|
12-Bit-PWM. Die Logikanschlüsse sind GND, OE, SCL, SDA, VCC und V+
|
||||||
|
beschriftet. Die Platine hat zwei entsprechende Anschlussseiten; eine davon
|
||||||
|
ist mit abgewinkelten Stiften bestückt. Immer nach dem Aufdruck anschließen.
|
||||||
|
Die Polarität der grünen Schraubklemme ist in dieser Aufnahme nicht eindeutig
|
||||||
|
ablesbar; vor Versorgung am Board prüfen.
|
||||||
|
|
||||||
|
## Pi mit dem PCA9685 verbinden
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Den Pi mit Bauteilseite nach oben legen, USB- und Netzwerkbuchsen nach rechts.
|
||||||
|
Die 40-polige Leiste liegt dann oben. Am linken Ende sind außen am
|
||||||
|
Platinenrand die Pins 2, 4, 6; direkt darunter zur Platinenmitte 1, 3, 5.
|
||||||
|
W01 Grün an 1, W02 Blau an 3, W03 Braun an 5, W04 Grau an 6.
|
||||||
|
Pins 2 und 4 bleiben frei. Zum Stecken beide Netzteile abziehen.
|
||||||
|
|
||||||
|
|
||||||
|
**Die Pinnummern sind physische Positionen auf der 40-poligen Pi-Stiftleiste.**
|
||||||
|
GPIO2 und GPIO3 sind zusätzliche Signalnamen, keine Steckplatznummern.
|
||||||
|
Pin 1 am echten Board beziehungsweise am offiziellen Pinbild identifizieren;
|
||||||
|
nicht anhand der Blickrichtung eines fremden Fotos zählen.
|
||||||
|
|
||||||
|
| Kabelkennung | Kabelfarbe | Von R1 | Nach D1 | Funktion |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| W01 | Grün | Pin 1, 3V3 | VCC | 3,3-V-Logikversorgung |
|
||||||
|
| W02 | Blau | Pin 3, GPIO2 / SDA1 | SDA | Steuerdaten |
|
||||||
|
| W03 | Braun | Pin 5, GPIO3 / SCL1 | SCL | Steuertakt |
|
||||||
|
| W04 | Grau | Pin 6, GND | GND | Gemeinsame Signalmasse |
|
||||||
|
|
||||||
|
VCC und V+ sind verschiedene Anschlüsse. D1-VCC bekommt ausschließlich
|
||||||
|
3,3 V vom Pi. Pi-Pin 2 und Pin 4 (5 V) bleiben für diesen Aufbau frei.
|
||||||
|
Die Versorgung der Servos wird nicht mit dem Plus der Pi-Versorgung verbunden.
|
||||||
|
|
||||||
|
## Servoenergie und Stecker
|
||||||
|
|
||||||
|
| Kabelkennung | Von | Nach | Funktion |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| W05 | P1, DC-Plus | D1, V+ der Versorgungsklemme | Servoenergie, geplant 5 V |
|
||||||
|
| W06 | P1, DC-Minus | D1, GND der Versorgungsklemme | Laststrom-Rückleitung |
|
||||||
|
| W07 | D1, Kanal 0: PWM / Signal | S1: Signal | PAN |
|
||||||
|
| W08 | D1, Kanal 0: V+ | S1: Plus | PAN-Versorgung |
|
||||||
|
| W09 | D1, Kanal 0: GND | S1: Minus / GND | PAN-Rückleitung |
|
||||||
|
| W10 | D1, Kanal 1: PWM / Signal | S2: Signal | TILT |
|
||||||
|
| W11 | D1, Kanal 1: V+ | S2: Plus | TILT-Versorgung |
|
||||||
|
| W12 | D1, Kanal 1: GND | S2: Minus / GND | TILT-Rückleitung |
|
||||||
|
|
||||||
|
W07–W09 beziehungsweise W10–W12 gehören jeweils zum dreipoligen Servokabel.
|
||||||
|
Typische Farben sind Braun/Schwarz für GND, Rot für Plus und Orange/Gelb/Weiß
|
||||||
|
für Signal. Die Reihen am fotografierten Modul sind beschriftet:
|
||||||
|
**Gelb = PWM (Signal), Rot = V+ (Servo-Plus), Schwarz = GND (Minus).**
|
||||||
|
Die Zuordnung der Servokabel selbst noch prüfen; nicht allein nach Farben stecken.
|
||||||
|
Die Kanalnummern 0 und 1 sind auf dem Foto nicht sicher lesbar: Position vor
|
||||||
|
dem Stecken direkt an der Platine abgleichen.
|
||||||
|
|
||||||
|
Bei der üblichen PCA9685-Servoplatine sind die GND-Anschlüsse und die
|
||||||
|
V+-Anschlüsse der Kanäle jeweils verbunden. Das ist an D1 noch zu prüfen.
|
||||||
|
P1-Minus und Pi-GND sind damit gemeinsam verbunden; der Servolaststrom
|
||||||
|
fließt über W06 zum Netzteil und nicht über das dünne W04 zum Pi zurück.
|
||||||
|
Für W05/W06 geeignete Stromleitungen und feste Klemmen verwenden, keine
|
||||||
|
lose gesteckten dünnen Steuer-Jumper oder ein Steckbrett als Lastverteilung.
|
||||||
|
Ein Schalter in der geeigneten DC-Zuleitung kann die Servoversorgung lokal
|
||||||
|
abschalten; Auswahl und Belastbarkeit richten sich nach P1 und Leitungen.
|
||||||
|
|
||||||
|
**Die spätere 6-V-Versorgung des Handservos nicht auf diese V+-Schiene
|
||||||
|
legen.** Die Kanal-Pluspins sind keine unabhängig einstellbaren Spannungen.
|
||||||
|
Der Handservo benötigt bei unterschiedlicher Spannung eine getrennte
|
||||||
|
Lastversorgung und eine gesonderte Anschlussplanung.
|
||||||
|
|
||||||
|
## Kamerakabel
|
||||||
|
|
||||||
|
| Kabelkennung | Verbindung | Beim Wiederaufbau beachten |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| W13 | R1 CAM/DISP-Anschluss ↔ C1 CSI-Anschluss | Vorhandenes Standard-auf-Mini-Kabel; benutzten Pi-Port und Kontaktseiten noch per Foto festhalten |
|
||||||
|
|
||||||
|
Pi und Servoversorgung vor dem Lösen oder Einsetzen des Kamerakabels
|
||||||
|
ausschalten und abstecken. Am Pi 5 gibt es zwei CAM/DISP-Anschlüsse;
|
||||||
|
hier ist noch keiner als tatsächlicher Anschluss dokumentiert.
|
||||||
|
Die Schleife muss die ganze erlaubte Schwenk- und Neigebewegung mitmachen,
|
||||||
|
ohne Zug, scharfe Knicke oder Kontakt mit dem Servogestänge.
|
||||||
|
|
||||||
|
## Erstprüfung und Betrieb
|
||||||
|
|
||||||
|
1. Beide Versorgungen abstecken. Anschlüsse, Polung und Kabelkennungen
|
||||||
|
mit dem geprüften Boardfoto vergleichen. D1 isoliert befestigen.
|
||||||
|
2. Servos zunächst abstecken. Ausgangsspannung und Polarität von P1 messen;
|
||||||
|
VCC und V+ auf Verwechslung prüfen.
|
||||||
|
3. Pi mit eigener Versorgung starten, P1 bleibt aus. I²C muss später aktiviert
|
||||||
|
und D1 erkannt werden. Adresse 0x40 ist beim Referenzmodul der Standard,
|
||||||
|
am vorhandenen Board jedoch noch zu bestätigen.
|
||||||
|
4. Erst mit einem vorbereiteten Servotest einen Servo ohne angekoppeltes
|
||||||
|
Gestänge zentrieren. Nicht sofort den ganzen beworbenen Winkel abfahren.
|
||||||
|
Zweiten Servo getrennt prüfen, dann die Halterung vorsichtig montieren.
|
||||||
|
5. Erlaubte Winkel und Kabelbewegung für PAN/TILT einzeln bestimmen und unten
|
||||||
|
eintragen. Schließlich beide zusammen auf Versorgungseinbrüche testen.
|
||||||
|
6. Vor Umstecken, Pi-Neustart oder Softwarewechsel P1 ausschalten. Bei
|
||||||
|
Brummen, Anschlag oder Kabelzug sofort P1 abschalten.
|
||||||
|
|
||||||
|
Die neue Software bietet einen eigenen Farbpunkt-Test für S1/S2, siehe
|
||||||
|
[SOFTWARE.md](SOFTWARE.md#kamera-folgt-einem-farbpunkt). Nur der bewusst
|
||||||
|
aktivierte Hardwaremodus öffnet I²C-Bus 1 / Adresse 0x40 und steuert CH0/CH1.
|
||||||
|
Die anderen Kanäle für diesen Test unbestückt lassen: die PWM-Frequenz ist
|
||||||
|
für die ganze Platine gemeinsam. Die erste Aktivierung setzt beide Servos
|
||||||
|
auf 1500 µs; vorab ohne angekoppeltes Gestänge zentrieren und montieren.
|
||||||
|
|
||||||
|
Der WebUI-Stopp und der eigene Kamerastopp beenden nun die Softwareausgabe
|
||||||
|
auf CH0/CH1. Die Servoversorgung bleibt an. Bei verlorenem Ziel, Kamerabild,
|
||||||
|
Auswertung oder Bedien-Heartbeat wird die Sitzung beendet; sie startet nicht
|
||||||
|
selbstständig neu. Diese Regeln sind lokal mit künstlichem I²C-Bus geprüft,
|
||||||
|
aber nicht an der tatsächlichen Halterung.
|
||||||
|
|
||||||
|
OE (Ausgangsfreigabe) ist noch nicht angeschlossen. Es gibt keine unabhängige
|
||||||
|
Hardwareabschaltung; bei I²C-Ausfall oder hartem Prozess-/Pi-Abbruch kann der
|
||||||
|
letzte Puls stehen bleiben. Vor Neustarts und bei unerwartetem Verhalten
|
||||||
|
Servoversorgung abziehen. Beim Adafruit-Referenzboard aktiviert OE=Low die
|
||||||
|
Ausgänge, OE=High sperrt sie; dessen interne Beschaltung nicht ungeprüft auf
|
||||||
|
D1 übertragen. Nur beaufsichtigter Test.
|
||||||
|
|
||||||
|
## Endpunkte ohne Endschalter einrichten
|
||||||
|
|
||||||
|
Die WebUI bietet jetzt einen manuellen Einrichtmodus für S1/S2. Langsam mit
|
||||||
|
gehaltener Richtungstaste annähern, vor dem Anschlag loslassen, Grenze speichern.
|
||||||
|
Auch PAN nicht gegen den Anschlag drücken. Loslassen hält die Sollposition,
|
||||||
|
der Kamerastopp beendet PWM. Es gibt keine Erkennung mechanischer Endlagen;
|
||||||
|
Sollwerte sind keine gemessenen Winkel. Die gespeicherten Grenzen stehen in
|
||||||
|
`data/follow.json`; die tatsächlichen Werte nach dem Pi-Test auch im obigen
|
||||||
|
Aufbauprotokoll festhalten. Keine zusätzlichen GPIOs/Endschalter angeschlossen.
|
||||||
|
|
||||||
|
## Noch nicht anschlussfertige Erweiterungen
|
||||||
|
|
||||||
|
Handservo, Atemmotor/Relais, LEDs, IR-Beleuchtung und ein möglicher zweiter Pi
|
||||||
|
erhalten hier eigene Anschlusslisten, sobald konkrete Geräte und Versorgung
|
||||||
|
feststehen. Für diese Teile sind noch keine Pins oder Kanäle vergeben.
|
||||||
|
Die 230-V-Seite der Atemrotation bleibt der Elektrofachkraft vorbehalten.
|
||||||
|
Dieser Kameraentwurf ist kein vollständiger Verdrahtungsplan dieser Aktoren.
|
||||||
|
|
||||||
|
## Protokoll für den tatsächlichen Aufbau
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
| --- | --- |
|
||||||
|
| D1 Hersteller / Platinenrevision / Foto | PCA9685-Foto hinterlegt; Hersteller und genaue Revision offen |
|
||||||
|
| D1 Reihenfolge Signal / V+ / GND | Foto: gelbe Reihe PWM, rote Reihe V+, schwarze Reihe GND |
|
||||||
|
| S1/S2 Beschriftung und Kabelfarben | Offen |
|
||||||
|
| P1 Modell, Ausgangsspannung, Strom, Steckerpolung | Offen |
|
||||||
|
| I²C-Adresse | Offen |
|
||||||
|
| R1 verwendeter Kameraport und Kontaktseitenfoto | Offen |
|
||||||
|
| PAN Mittelstellung / erlaubter Bereich / Richtung | Offen |
|
||||||
|
| TILT Mittelstellung / erlaubter Bereich / Richtung | Offen |
|
||||||
|
| Versorgung bei gleichzeitiger Bewegung geprüft | Offen |
|
||||||
|
| Start, Fehler und lokale Abschaltung geprüft | Offen |
|
||||||
|
|
||||||
|
Kabel an beiden Enden mit W01 usw. und Servostecker zusätzlich mit
|
||||||
|
„PAN / CH0“ beziehungsweise „TILT / CH1“ beschriften. Nach Änderungen
|
||||||
|
Anschlussliste, Zeichnung, Prüftabelle und Änderungsverlauf gemeinsam pflegen.
|
||||||
|
Ein Foto des fertig gesteckten Aufbaus ergänzt den Plan; es ersetzt die
|
||||||
|
Anschlussliste nicht.
|
||||||
|
|
||||||
|
## Quellen
|
||||||
|
|
||||||
|
- [Vom Nutzer genannte Kameraservos, Amazon B07KPS9845](https://www.amazon.de/dp/B07KPS9845)
|
||||||
|
- [Adafruit PCA9685: Pi-Anbindung und separate Servoversorgung](https://learn.adafruit.com/16-channel-pwm-servo-driver/python-circuitpython)
|
||||||
|
- [Adafruit PCA9685: VCC, V+, OE und Kanäle](https://learn.adafruit.com/16-channel-pwm-servo-driver/pinouts)
|
||||||
|
- [Raspberry Pi: GPIO-Dokumentation und Pinbild](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#gpio)
|
||||||
|
|
||||||
|
Die Adafruit-Unterlagen sind die elektrische Referenz, keine Identifikation
|
||||||
|
der vorhandenen D1-Platine. Deren sichtbare Anschlussbeschriftung ist jetzt
|
||||||
|
zusätzlich durch das oben abgelegte Nutzerfoto dokumentiert.
|
||||||
|
|
||||||
|
## Änderungen
|
||||||
|
|
||||||
|
| Datum | Revision | Änderung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 15.10.2026 | 1 | Kameraentwurf angelegt; PAN=CH0, TILT=CH1 geplant; Board, Versorgung und echte Verdrahtung offen |
|
||||||
|
| 15.10.2026 | 2 | Echtes Boardfoto abgelegt; Anschlussbeschriftung und gelbe/rote/schwarze Reihen erfasst; Netzteil und praktische Prüfung bleiben offen |
|
||||||
|
| 15.10.2026 | 3 | W01 Grün, W02 Blau, W03 Braun, W04 Grau nach Nutzerkabeln in Liste und Zeichnung eingetragen |
|
||||||
|
| 15.10.2026 | 4 | Große Pinübersicht RASPI_PINS.svg mit Boardausrichtung und Nutzerkabelfarben ergänzt |
|
||||||
|
| 15.10.2026 | 5 | Nutzer meldet verdrahteten Aufbau mit Powerbank; Farbpunkt-Softwaretest mit bewusstem Hardwarestart und PWM-Stopp dokumentiert; echte Hardwareprüfung offen |
|
||||||
|
| 15.10.2026 | 6 | Manuellen Endpunktmodus ohne zusätzliche Verdrahtung dokumentiert; echte mechanische Grenzen weiter offen |
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
|
||||||
|
<rect width="1400" height="1000" fill="#f5f7fa"/>
|
||||||
|
<style>text{font-family:DejaVu Sans,Arial,sans-serif;fill:#172536;font-size:18px}.title{font-size:30px;font-weight:bold}.head{font-size:22px;font-weight:bold}.small{font-size:16px}.wire{fill:none;stroke-width:4;stroke-linejoin:round}</style>
|
||||||
|
<text x="40" y="48" class="title">Kamera und Schwenkarm · Anschlussentwurf</text>
|
||||||
|
<text x="40" y="82" class="">Revision 6 · 15.10.2026 · Boardfoto dokumentiert; Servokabel und Netzteil noch prüfen</text>
|
||||||
|
<rect x="40" y="140" width="330" height="350" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
|
||||||
|
<text x="60" y="176" class="head">R1 · Raspberry Pi 5</text>
|
||||||
|
<text x="60" y="212" class="">Pin 1 · 3V3</text>
|
||||||
|
<text x="60" y="272" class="">Pin 3 · GPIO2 / SDA1</text>
|
||||||
|
<text x="60" y="332" class="">Pin 5 · GPIO3 / SCL1</text>
|
||||||
|
<text x="60" y="392" class="">Pin 6 · GND</text>
|
||||||
|
<text x="60" y="447" class="small">Eigenes USB-C-Netzteil</text>
|
||||||
|
<text x="60" y="475" class="small">Pinzahlen = physische Positionen</text>
|
||||||
|
<rect x="650" y="140" width="300" height="620" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
|
||||||
|
<text x="670" y="176" class="head">D1 · PCA9685</text>
|
||||||
|
<polyline class="wire" points="370,206 650,206" stroke="#20885c"/>
|
||||||
|
<text x="440" y="196" class="">W01 · Grün</text>
|
||||||
|
<text x="670" y="212" class="">VCC · 3,3 V</text>
|
||||||
|
<polyline class="wire" points="370,266 650,266" stroke="#316dc8"/>
|
||||||
|
<text x="440" y="256" class="">W02 · Blau</text>
|
||||||
|
<text x="670" y="272" class="">SDA</text>
|
||||||
|
<polyline class="wire" points="370,326 650,326" stroke="#80502e"/>
|
||||||
|
<text x="440" y="316" class="">W03 · Braun</text>
|
||||||
|
<text x="670" y="332" class="">SCL</text>
|
||||||
|
<polyline class="wire" points="370,386 650,386" stroke="#7a7f87"/>
|
||||||
|
<text x="440" y="376" class="">W04 · Grau</text>
|
||||||
|
<text x="670" y="392" class="">GND</text>
|
||||||
|
<text x="670" y="445" class="">CH0 · PAN</text>
|
||||||
|
<text x="670" y="485" class="">CH1 · TILT</text>
|
||||||
|
<text x="670" y="540" class="small">OE · Beschaltung noch offen</text>
|
||||||
|
<text x="670" y="573" class="small">Keine unabhängige Abschaltung</text>
|
||||||
|
<text x="670" y="650" class="">V+ · Servoenergie</text>
|
||||||
|
<text x="670" y="710" class="">GND · Rückleitung</text>
|
||||||
|
<rect x="1080" y="140" width="280" height="180" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
|
||||||
|
<text x="1100" y="176" class="head">S1 · PAN</text>
|
||||||
|
<text x="1100" y="210" class="">Unterer Servo</text>
|
||||||
|
<text x="1100" y="246" class="">Links / rechts</text>
|
||||||
|
<text x="1100" y="286" class="small">Stecker: PAN / CH0</text>
|
||||||
|
<rect x="1080" y="370" width="280" height="180" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
|
||||||
|
<text x="1100" y="406" class="head">S2 · TILT</text>
|
||||||
|
<text x="1100" y="440" class="">Oberer Servo</text>
|
||||||
|
<text x="1100" y="476" class="">Hoch / runter</text>
|
||||||
|
<text x="1100" y="516" class="small">Stecker: TILT / CH1</text>
|
||||||
|
<polyline class="wire" points="950,440 990,440 990,230 1080,230" stroke="#7b4ba7"/>
|
||||||
|
<text x="968" y="186" class="small">W07–09</text>
|
||||||
|
<polyline class="wire" points="950,480 1025,480 1025,460 1080,460" stroke="#7b4ba7"/>
|
||||||
|
<text x="968" y="576" class="small">W10–12</text>
|
||||||
|
<text x="1080" y="617" class="small">D1 laut Foto:</text>
|
||||||
|
<text x="1080" y="646" class="small">Gelbe Reihe: PWM → Signal</text>
|
||||||
|
<text x="1080" y="675" class="small">Rote Reihe: V+ → Plus</text>
|
||||||
|
<text x="1080" y="704" class="small">Schwarze Reihe: GND → Minus</text>
|
||||||
|
<rect x="40" y="550" width="330" height="210" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
|
||||||
|
<text x="60" y="586" class="head">P1 · Servonetzteil</text>
|
||||||
|
<text x="60" y="621" class="">Vorschlag: 5 V DC / 3 A</text>
|
||||||
|
<text x="60" y="650" class="small">Auslegung noch prüfen</text>
|
||||||
|
<text x="60" y="693" class="">DC-Plus</text>
|
||||||
|
<text x="60" y="736" class="">DC-Minus / GND</text>
|
||||||
|
<polyline class="wire" points="370,687 490,687 490,644 650,644" stroke="#c13b35"/>
|
||||||
|
<text x="510" y="630" class="small">W05 · Plus</text>
|
||||||
|
<polyline class="wire" points="370,730 560,730 560,704 650,704" stroke="#333333"/>
|
||||||
|
<text x="405" y="753" class="small">W06 · Minus</text>
|
||||||
|
<rect x="40" y="800" width="1320" height="153" rx="14" fill="white" stroke="#8796a8" stroke-width="2"/>
|
||||||
|
<text x="60" y="836" class="head">C1 · Camera Module 3 NoIR</text>
|
||||||
|
<text x="60" y="868" class="">W13: Pi CAM/DISP ↔ Kamerakabel ↔ Kamera · Tatsächlichen Pi-Port und Kontaktseiten noch eintragen</text>
|
||||||
|
<text x="60" y="904" class="small">VCC ≠ V+ | Servostrom separat | Gemeinsame Masse | Keine 6 V vom Handservo auf diese V+-Schiene</text>
|
||||||
|
<text x="40" y="982" class="small">Symbolplan: keine räumliche Pinanordnung. Steckerreihenfolge und Erstprüfung stehen in VERDRAHTUNG.md.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,380 @@
|
|||||||
|
"""Local colour-marker preview and deliberately armed camera servo test."""
|
||||||
|
from collections import deque
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
DEFAULT = dict(hue=85, tolerance=18, pan_min=1400, pan_max=1600,
|
||||||
|
tilt_min=1400, tilt_max=1600, pan_reverse=False, tilt_reverse=False)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(value):
|
||||||
|
if set(value) != set(DEFAULT):
|
||||||
|
raise ValueError('Unvollständige Nachführungseinstellungen')
|
||||||
|
result = {}
|
||||||
|
for key, default in DEFAULT.items():
|
||||||
|
v = value[key]
|
||||||
|
if isinstance(default, bool):
|
||||||
|
if type(v) is not bool:
|
||||||
|
raise ValueError(f'{key}: Ja/Nein erwartet')
|
||||||
|
else:
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (float, int)) or not math.isfinite(v):
|
||||||
|
raise ValueError(f'{key}: gültige Zahl erwartet')
|
||||||
|
v = int(v)
|
||||||
|
result[key] = v
|
||||||
|
if not 0 <= result['hue'] <= 255 or not 5 <= result['tolerance'] <= 35:
|
||||||
|
raise ValueError('Ungültige Farbe oder Farbtoleranz')
|
||||||
|
for axis in ('pan', 'tilt'):
|
||||||
|
if not 1100 <= result[axis+'_min'] <= 1500 <= result[axis+'_max'] <= 1900:
|
||||||
|
raise ValueError('Grenzen müssen zwischen 1100 und 1900 µs liegen und 1500 einschließen')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def locate(jpeg, config):
|
||||||
|
"""Largest unambiguous saturated blob, circular hue distance (also red)."""
|
||||||
|
image = Image.open(BytesIO(jpeg)).convert('RGB').resize((160, 120))
|
||||||
|
pixels = list(image.convert('HSV').getdata())
|
||||||
|
mask = set()
|
||||||
|
for i, (h, s, v) in enumerate(pixels):
|
||||||
|
delta = abs(h-config['hue'])
|
||||||
|
if min(delta, 256-delta) <= config['tolerance'] and s >= 100 and v >= 60:
|
||||||
|
mask.add(i)
|
||||||
|
blobs = []
|
||||||
|
while mask:
|
||||||
|
seed = mask.pop()
|
||||||
|
queue, blob = deque([seed]), [seed]
|
||||||
|
while queue:
|
||||||
|
i = queue.popleft()
|
||||||
|
x, y = i % 160, i // 160
|
||||||
|
for n in ((i-1 if x else -1), (i+1 if x < 159 else -1),
|
||||||
|
(i-160 if y else -1), (i+160 if y < 119 else -1)):
|
||||||
|
if n in mask:
|
||||||
|
mask.remove(n); queue.append(n); blob.append(n)
|
||||||
|
if len(blob) >= 12:
|
||||||
|
blobs.append(blob)
|
||||||
|
blobs.sort(key=len, reverse=True)
|
||||||
|
if not blobs or len(blobs[0]) > 160*120*.35:
|
||||||
|
return None
|
||||||
|
if len(blobs) > 1 and len(blobs[1]) >= len(blobs[0])*.6:
|
||||||
|
return None
|
||||||
|
blob = blobs[0]
|
||||||
|
return {'x': sum(i % 160 for i in blob)/len(blob)/160,
|
||||||
|
'y': sum(i // 160 for i in blob)/len(blob)/120,
|
||||||
|
'radius': math.sqrt(len(blob)/math.pi)/160}
|
||||||
|
|
||||||
|
|
||||||
|
class PCA9685:
|
||||||
|
"""Dedicated camera board, bus 1/address 0x40, channels 0 and 1 only.
|
||||||
|
|
||||||
|
Register protocol: https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf
|
||||||
|
No I2C access until an explicit hardware start. No bus scanning/reset broadcast.
|
||||||
|
"""
|
||||||
|
def __init__(self, bus_factory=None):
|
||||||
|
if bus_factory is None:
|
||||||
|
from smbus2 import SMBus
|
||||||
|
bus_factory = SMBus
|
||||||
|
self.bus = bus_factory(1)
|
||||||
|
try:
|
||||||
|
self.off()
|
||||||
|
# Sleep, internal oscillator, auto-increment. 25 MHz / (4096*122).
|
||||||
|
self.bus.write_byte_data(0x40, 0x00, 0x30)
|
||||||
|
self.bus.write_byte_data(0x40, 0xFE, 121)
|
||||||
|
self.bus.write_byte_data(0x40, 0x01, 0x04) # non-inverted totem-pole
|
||||||
|
self.bus.write_byte_data(0x40, 0x00, 0x20)
|
||||||
|
time.sleep(.005)
|
||||||
|
except Exception:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def move(self, pan, tilt):
|
||||||
|
for channel, pulse in enumerate((pan, tilt)):
|
||||||
|
if not 1100 <= pulse <= 1900:
|
||||||
|
raise ValueError('Puls außerhalb der absoluten Testgrenze')
|
||||||
|
count = round(pulse * 25 / 122) # microseconds -> oscillator ticks / prescale
|
||||||
|
self.bus.write_i2c_block_data(0x40, 0x06+4*channel,
|
||||||
|
[0, 0, count & 255, count >> 8])
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
failure = None
|
||||||
|
# Do not use a block write before auto-increment has been initialized.
|
||||||
|
for channel in (0, 1):
|
||||||
|
try:
|
||||||
|
self.bus.write_byte_data(0x40, 0x09+channel*4, 0x10)
|
||||||
|
except Exception as exc:
|
||||||
|
failure = exc
|
||||||
|
if failure:
|
||||||
|
raise failure
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.off()
|
||||||
|
finally:
|
||||||
|
self.bus.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FollowService:
|
||||||
|
def __init__(self, camera, directory, driver_factory=PCA9685, clock=time.monotonic):
|
||||||
|
self.camera, self.clock, self.factory = camera, clock, driver_factory
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.path = Path(directory)/'follow.json'
|
||||||
|
self.config = dict(DEFAULT)
|
||||||
|
self.error = None
|
||||||
|
self.config_fault = False
|
||||||
|
try:
|
||||||
|
if self.path.exists():
|
||||||
|
self.config = validate(json.loads(self.path.read_text()))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
self.error = 'Nachführungseinstellungen beschädigt; neu speichern'
|
||||||
|
self.config_fault = True
|
||||||
|
self.active = False
|
||||||
|
self.operation = 'follow'
|
||||||
|
self.jog_direction = None
|
||||||
|
self.jog_until = 0.
|
||||||
|
self.jog_sequence = -1
|
||||||
|
self.driver = None
|
||||||
|
self.pan = self.tilt = 1500.
|
||||||
|
self.last_ping = self.clock()
|
||||||
|
self.last_result = self.last_seen = None
|
||||||
|
self.number = 0
|
||||||
|
self.target = None
|
||||||
|
self.jpeg = None
|
||||||
|
self.sample = None
|
||||||
|
self.sample_token = None
|
||||||
|
self.session = None
|
||||||
|
self.revision = 0
|
||||||
|
self.thread = None
|
||||||
|
self.quit = threading.Event()
|
||||||
|
|
||||||
|
def _save(self, value):
|
||||||
|
config = validate(value)
|
||||||
|
with self.lock:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, name = tempfile.mkstemp(dir=self.path.parent, prefix='.follow-')
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'w') as f:
|
||||||
|
json.dump(config, f)
|
||||||
|
os.replace(name, self.path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(name): os.unlink(name)
|
||||||
|
self.config, self.error, self.config_fault = config, None, False
|
||||||
|
self.revision += 1
|
||||||
|
return dict(config)
|
||||||
|
|
||||||
|
def configure(self, value):
|
||||||
|
with self.lock:
|
||||||
|
if self.active:
|
||||||
|
raise ValueError('Vor Einstellungsänderungen Nachführung stoppen')
|
||||||
|
return self._save(value)
|
||||||
|
|
||||||
|
def _direction(self, direction):
|
||||||
|
choices = {'left': ('pan', -1), 'right': ('pan', 1),
|
||||||
|
'up': ('tilt', -1), 'down': ('tilt', 1)}
|
||||||
|
if direction not in choices:
|
||||||
|
raise ValueError('Ungültige Richtung')
|
||||||
|
axis, sign = choices[direction]
|
||||||
|
return axis, -sign if self.config[axis+'_reverse'] else sign
|
||||||
|
|
||||||
|
def _require_setup(self, session):
|
||||||
|
self.watchdog()
|
||||||
|
if not self.active or self.operation != 'setup' or session != self.session:
|
||||||
|
raise ValueError('Einrichtsitzung beendet oder nicht freigegeben')
|
||||||
|
|
||||||
|
def jog(self, session, direction, sequence):
|
||||||
|
with self.lock:
|
||||||
|
self._require_setup(session)
|
||||||
|
if type(sequence) is not int or not 0 <= sequence <= 1_000_000_000:
|
||||||
|
raise ValueError('Ungültige Tastensequenz')
|
||||||
|
if direction is not None: self._direction(direction)
|
||||||
|
if sequence <= self.jog_sequence:
|
||||||
|
return {'accepted': False}
|
||||||
|
self.jog_sequence = sequence
|
||||||
|
self.jog_direction = direction
|
||||||
|
self.jog_until = self.clock()+.35 if direction else 0.
|
||||||
|
return {'accepted': True}
|
||||||
|
|
||||||
|
def remember(self, session, direction):
|
||||||
|
with self.lock:
|
||||||
|
self._require_setup(session)
|
||||||
|
if self.jog_direction and self.clock() < self.jog_until:
|
||||||
|
raise ValueError('Richtungstaste vor dem Speichern loslassen')
|
||||||
|
axis, sign = self._direction(direction)
|
||||||
|
# Store a slightly inward commanded limit, never an inferred physical stop.
|
||||||
|
value = round(getattr(self, axis)) + (10 if sign < 0 else -10)
|
||||||
|
key = axis+('_min' if sign < 0 else '_max')
|
||||||
|
if sign < 0 and value > 1500 or sign > 0 and value < 1500:
|
||||||
|
raise ValueError('Position liegt zu nahe an oder auf der anderen Seite der Mitte')
|
||||||
|
return self._save(dict(self.config, **{key:value}))
|
||||||
|
|
||||||
|
def capture(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.active:
|
||||||
|
raise ValueError('Vor dem Farbfoto Nachführung stoppen')
|
||||||
|
_, jpeg, captured = self.camera.latest_timed()
|
||||||
|
if self.camera.error or jpeg is None or captured is None or self.clock()-captured > 1:
|
||||||
|
raise ValueError('Kein frisches Kamerabild')
|
||||||
|
self.sample, self.sample_token = jpeg, secrets.token_hex(16)
|
||||||
|
return {'image_base64': base64.b64encode(jpeg).decode(), 'token': self.sample_token}
|
||||||
|
|
||||||
|
def select(self, token, x, y):
|
||||||
|
with self.lock:
|
||||||
|
if not self.sample or token != self.sample_token:
|
||||||
|
raise ValueError('Beispielfoto erneut aufnehmen')
|
||||||
|
if not all(isinstance(v, (int, float)) and not isinstance(v, bool) and
|
||||||
|
math.isfinite(v) and 0 <= v <= 1 for v in (x, y)):
|
||||||
|
raise ValueError('Punkt außerhalb des Bildes')
|
||||||
|
image = Image.open(BytesIO(self.sample)).convert('HSV')
|
||||||
|
px, py = int(x*(image.width-1)), int(y*(image.height-1))
|
||||||
|
colors = list(image.crop((max(0,px-2), max(0,py-2),
|
||||||
|
min(image.width,px+3), min(image.height,py+3))).getdata())
|
||||||
|
colors = [(h,s,v) for h,s,v in colors if s >= 100 and v >= 60]
|
||||||
|
if len(colors) < 5:
|
||||||
|
raise ValueError('Bitte einen hellen, kräftig farbigen Punkt wählen')
|
||||||
|
# Circular mean avoids averaging red at both ends into cyan.
|
||||||
|
angle = math.atan2(sum(math.sin(h*2*math.pi/256) for h,_,_ in colors),
|
||||||
|
sum(math.cos(h*2*math.pi/256) for h,_,_ in colors))
|
||||||
|
config = dict(self.config, hue=round(angle*256/(2*math.pi)) % 256)
|
||||||
|
return self.configure(config)
|
||||||
|
|
||||||
|
def begin(self, hardware=False, acknowledged=False, operation='follow'):
|
||||||
|
with self.lock:
|
||||||
|
if operation not in ('follow', 'setup'):
|
||||||
|
raise ValueError('Unbekannter Kameramodus')
|
||||||
|
if self.active:
|
||||||
|
raise ValueError('Kameramodus läuft bereits')
|
||||||
|
if self.config_fault:
|
||||||
|
raise ValueError(self.error)
|
||||||
|
self.error = None
|
||||||
|
_, jpeg, captured = self.camera.latest_timed()
|
||||||
|
if self.camera.error or jpeg is None or captured is None or self.clock()-captured > 1:
|
||||||
|
raise ValueError('Kein frisches Kamerabild')
|
||||||
|
if hardware and (self.camera.demo or not acknowledged):
|
||||||
|
raise ValueError('Echte Kamera und bestätigter freier Servotest erforderlich')
|
||||||
|
self.pan = self.tilt = 1500.
|
||||||
|
try:
|
||||||
|
if hardware:
|
||||||
|
self.driver = self.factory()
|
||||||
|
self.driver.move(self.pan, self.tilt)
|
||||||
|
except Exception as exc:
|
||||||
|
self.halt(f'Servostart fehlgeschlagen: {exc}')
|
||||||
|
raise ValueError(self.error) from exc
|
||||||
|
self.active = True
|
||||||
|
self.operation = operation
|
||||||
|
self.jog_direction, self.jog_until, self.jog_sequence = None, 0., -1
|
||||||
|
self.session = secrets.token_hex(16)
|
||||||
|
self.last_ping = self.clock()
|
||||||
|
self.last_result = self.last_seen = self.clock()
|
||||||
|
self.jpeg = None
|
||||||
|
self.target = None
|
||||||
|
self.revision += 1
|
||||||
|
return self.session
|
||||||
|
|
||||||
|
def heartbeat(self, session):
|
||||||
|
with self.lock:
|
||||||
|
if not self.active or session != self.session:
|
||||||
|
raise ValueError('Nachführungssitzung beendet')
|
||||||
|
self.last_ping = self.clock()
|
||||||
|
|
||||||
|
def halt(self, error=None):
|
||||||
|
with self.lock:
|
||||||
|
self.active = False
|
||||||
|
self.session = None
|
||||||
|
self.jog_direction = None
|
||||||
|
self.jog_until = 0.
|
||||||
|
self.revision += 1
|
||||||
|
self.target = None
|
||||||
|
if self.driver:
|
||||||
|
driver, self.driver = self.driver, None
|
||||||
|
try:
|
||||||
|
driver.close()
|
||||||
|
except Exception as exc:
|
||||||
|
error = f'PWM-Abschaltung nicht bestätigt: {exc}; Servoversorgung ausschalten'
|
||||||
|
if error: self.error = error
|
||||||
|
|
||||||
|
def watchdog(self):
|
||||||
|
with self.lock:
|
||||||
|
if not self.active: return
|
||||||
|
now = self.clock()
|
||||||
|
_, _, captured = self.camera.latest_timed()
|
||||||
|
if self.camera.error or captured is None or now-captured > 1:
|
||||||
|
self.halt('Kamerabild ausgefallen; erneut starten')
|
||||||
|
elif now-self.last_ping > 1.5:
|
||||||
|
self.halt('Bedienverbindung verloren; erneut starten')
|
||||||
|
elif self.last_result is not None and now-self.last_result > 1:
|
||||||
|
self.halt('Bildauswertung ausgefallen; erneut starten')
|
||||||
|
|
||||||
|
def process(self, number, jpeg, captured, revision, target):
|
||||||
|
with self.lock:
|
||||||
|
now = self.clock()
|
||||||
|
if not self.active or revision != self.revision or captured is None or now-captured > 1:
|
||||||
|
return
|
||||||
|
self.watchdog()
|
||||||
|
if not self.active: return
|
||||||
|
dt = min(.25, max(0, now-(self.last_result or now)))
|
||||||
|
self.last_result, self.number, self.jpeg, self.target = now, number, jpeg, target
|
||||||
|
if self.operation == 'setup':
|
||||||
|
self.target = None
|
||||||
|
if self.jog_direction and now < self.jog_until:
|
||||||
|
axis, sign = self._direction(self.jog_direction)
|
||||||
|
value = max(1100, min(1900, getattr(self,axis)+sign*40*dt))
|
||||||
|
setattr(self,axis,value)
|
||||||
|
if self.driver: self.driver.move(self.pan,self.tilt)
|
||||||
|
else:
|
||||||
|
self.jog_direction = None
|
||||||
|
return
|
||||||
|
if target:
|
||||||
|
self.last_seen = now
|
||||||
|
for axis, coordinate in (('pan','x'), ('tilt','y')):
|
||||||
|
error = target[coordinate]-.5
|
||||||
|
if abs(error) < .06: continue
|
||||||
|
sign = -1 if self.config[axis+'_reverse'] else 1
|
||||||
|
value = getattr(self, axis) + sign*error*160*dt
|
||||||
|
setattr(self, axis, max(self.config[axis+'_min'], min(self.config[axis+'_max'], value)))
|
||||||
|
if self.driver: self.driver.move(self.pan, self.tilt)
|
||||||
|
# No searching or recentring when the marker disappears. End the
|
||||||
|
# hardware session after one second so old pulses do not hold forever.
|
||||||
|
elif self.driver and self.last_seen is not None and now-self.last_seen > 1:
|
||||||
|
self.halt('Farbpunkt verloren; erneut starten')
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self.lock:
|
||||||
|
return {'active': self.active, 'hardware': self.driver is not None,
|
||||||
|
'operation': self.operation, 'demo': self.camera.demo,
|
||||||
|
'error': self.error, 'config': dict(self.config),
|
||||||
|
'pan_us': round(self.pan), 'tilt_us': round(self.tilt),
|
||||||
|
'target': self.target, 'frame_number': self.number,
|
||||||
|
'image_base64': base64.b64encode(self.jpeg).decode() if self.active and self.jpeg else None}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.thread = threading.Thread(target=self._run, daemon=True, name='colour-follow')
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
last = 0
|
||||||
|
while not self.quit.wait(.05):
|
||||||
|
self.watchdog()
|
||||||
|
with self.lock:
|
||||||
|
if not self.active: continue
|
||||||
|
revision, config, operation = self.revision, dict(self.config), self.operation
|
||||||
|
number, jpeg, captured = self.camera.latest_timed()
|
||||||
|
if number == last or jpeg is None: continue
|
||||||
|
last = number
|
||||||
|
try:
|
||||||
|
target = locate(jpeg, config) if operation == 'follow' else None
|
||||||
|
self.process(number, jpeg, captured, revision, target)
|
||||||
|
except Exception as exc:
|
||||||
|
with self.lock:
|
||||||
|
if revision == self.revision: self.halt(f'Nachführungsfehler: {exc}')
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.quit.set()
|
||||||
|
self.halt()
|
||||||
|
if self.thread: self.thread.join(timeout=2)
|
||||||
+222
-1
@@ -91,7 +91,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button id="save-mode">Übernehmen</button>
|
<button id="save-mode">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
|
<p class="notice"><strong>Grabablauf nur simuliert.</strong> Hand, Atemmotor, Licht und Ton bleiben simuliert. Kameraservos können im separaten Farbtest wirklich bewegt werden. Die automatische Erkennung kann ausschließlich diesen simulierten Ablauf auslösen.</p>
|
||||||
<p class="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
|
<p class="muted">Die folgenden Knöpfe sind für manuelle Tests. Die Automatik gibt sich selbst frei.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="zone-clear">Bereich frei (manueller Test)</button>
|
<button id="zone-clear">Bereich frei (manueller Test)</button>
|
||||||
@@ -103,6 +103,63 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="message" role="status" aria-live="polite"></p>
|
<p id="message" role="status" aria-live="polite"></p>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="events" id="follow-panel">
|
||||||
|
<h2>Kamera folgt einem Farbpunkt · Testmodus</h2>
|
||||||
|
<p>Halte einen kräftig roten oder grünen Aufkleber vor die Kamera. Ein Punkt am Finger geht auch. Kein Training und kein Internet nötig. Zuerst die automatische Auslösung ausschalten und speichern.</p>
|
||||||
|
<div class="row">
|
||||||
|
<button id="follow-green">Grün wählen</button><button id="follow-red">Rot wählen</button>
|
||||||
|
<button id="follow-capture">Beispielfoto aufnehmen</button>
|
||||||
|
</div>
|
||||||
|
<p>Eigene Farbe: Beispielfoto aufnehmen und dort auf die Mitte des Farbpunkts tippen. Das Foto wird nicht gespeichert.</p>
|
||||||
|
<canvas id="follow-canvas" width="640" height="480" style="width:100%;max-width:720px;background:#050808;cursor:crosshair" aria-label="Farbpunkt im Beispielfoto auswählen"></canvas>
|
||||||
|
<p id="follow-status" role="status" aria-live="polite">Farbtest lädt …</p>
|
||||||
|
<details>
|
||||||
|
<summary>Servogrenzen und Richtung einstellen</summary>
|
||||||
|
<p>Einheit µs = Pulsbreite, keine Winkel. Der Start setzt beide Servos auf 1500 µs; dabei kann die Halterung springen. Beim ersten Test Servohörner/Gestänge lösen, Mittelstellung prüfen und danach spannungsfrei wieder montieren. Grenzen nur schrittweise erweitern.</p>
|
||||||
|
<div class="row">
|
||||||
|
<label>PAN Minimum <input id="follow-pan-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
|
||||||
|
<label>PAN Maximum <input id="follow-pan-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
|
||||||
|
<label><input id="follow-pan-reverse" type="checkbox"> PAN umkehren</label>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:.6rem">
|
||||||
|
<label>TILT Minimum <input id="follow-tilt-min" type="number" min="1100" max="1500" step="10" value="1400" style="width:7em"></label>
|
||||||
|
<label>TILT Maximum <input id="follow-tilt-max" type="number" min="1500" max="1900" step="10" value="1600" style="width:7em"></label>
|
||||||
|
<label><input id="follow-tilt-reverse" type="checkbox"> TILT umkehren</label>
|
||||||
|
</div>
|
||||||
|
<label>Farbtoleranz <input id="follow-tolerance" type="number" min="5" max="35" value="18" style="width:6em"></label>
|
||||||
|
<button id="follow-save">Einstellungen speichern</button>
|
||||||
|
</details>
|
||||||
|
<p><label><input id="follow-ack" type="checkbox"> Mittelstellung und Bewegungsraum geprüft, Kamerakabel hat Spiel; Servoversorgung kann ich sofort abziehen.</label></p>
|
||||||
|
<div class="row">
|
||||||
|
<button id="follow-preview">Nur Farbvorschau starten</button>
|
||||||
|
<button id="follow-hardware">Echte Kameranachführung starten</button>
|
||||||
|
<button id="follow-stop" class="danger">Kameraservos stoppen</button>
|
||||||
|
</div>
|
||||||
|
<div style="border:2px solid #759c8f;border-radius:8px;padding:1rem;margin-top:1rem">
|
||||||
|
<h2>Endpunkte einrichten · manuell</h2>
|
||||||
|
<p>Langsam annähern, vor dem Anschlag loslassen und die Grenze speichern. Auch links/rechts nicht gegen den Anschlag drücken. Keine automatische Endpunkterkennung.</p>
|
||||||
|
<button id="setup-start">Einrichtmodus starten</button>
|
||||||
|
<p id="setup-note" role="status">Noch keine Grenze in dieser Sitzung gespeichert.</p>
|
||||||
|
<p id="setup-limits" class="muted"></p>
|
||||||
|
<p id="setup-position" class="value" role="status">Sollposition: PAN 1500 / TILT 1500 µs</p>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(3,minmax(80px,150px));gap:.7rem;max-width:480px">
|
||||||
|
<span></span><button id="setup-up" style="font-size:1.3rem;touch-action:none;user-select:none">▲ Hoch halten</button><span></span>
|
||||||
|
<button id="setup-left" style="font-size:1.3rem;touch-action:none;user-select:none">◀ Links halten</button>
|
||||||
|
<button id="setup-release" class="danger">Bewegung anhalten</button>
|
||||||
|
<button id="setup-right" style="font-size:1.3rem;touch-action:none;user-select:none">Rechts halten ▶</button>
|
||||||
|
<span></span><button id="setup-down" style="font-size:1.3rem;touch-action:none;user-select:none">▼ Runter halten</button><span></span>
|
||||||
|
</div>
|
||||||
|
<p>Taste gedrückt halten: langsame Bewegung. Loslassen: Zielposition bleibt stehen. Bei fehlendem Tastensignal endet die Bewegung spätestens nach 0,35 s, sofern Pi und Treiber reagieren.</p>
|
||||||
|
<div class="row">
|
||||||
|
<button id="setup-save-left">Links-Grenze speichern</button>
|
||||||
|
<button id="setup-save-right">Rechts-Grenze speichern</button>
|
||||||
|
<button id="setup-save-up">Oben-Grenze speichern</button>
|
||||||
|
<button id="setup-save-down">Unten-Grenze speichern</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Speichert den angeforderten Pulswert mit 10 µs Abstand nach innen, keine gemessene Position. Beim Einrichten gelten 1100–1900 µs, auch außerhalb der bisherigen Farbtest-Grenzen. Falls das nicht reicht: stoppen, nichts erzwingen. Zum Beenden „Kameraservos stoppen“ drücken. Neue Sitzung startet wieder bei 1500 µs.</p>
|
||||||
|
</div>
|
||||||
|
<p class="notice">Nur Kameraservos CH0/CH1. Kein automatischer Start nach Neustart. Im Farbtest: Bei Punktverlust keine Suchbewegung; nach einer Sekunde wird die Servositzung beendet. Bei verlorener Bedienverbindung oder Kamerafehler werden die PWM-Signale abgeschaltet, soweit die Verbindung zum Treiber funktioniert. Die Stromversorgung bleibt eingeschaltet. Bei einem Hardwarefehler Servoversorgung abziehen.</p>
|
||||||
|
</section>
|
||||||
<section class="events">
|
<section class="events">
|
||||||
<h2>Gespeicherte Ereignisse</h2>
|
<h2>Gespeicherte Ereignisse</h2>
|
||||||
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
<p class="muted">Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.</p>
|
||||||
@@ -403,6 +460,170 @@
|
|||||||
detectionConfig = c; $('detection-enabled').checked = c.enabled;
|
detectionConfig = c; $('detection-enabled').checked = c.enabled;
|
||||||
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
|
$('detection-method').value = c.method; updateDetectionHelp(); drawDetection();
|
||||||
}).catch(error => { $('detection-status').textContent = error.message; });
|
}).catch(error => { $('detection-status').textContent = error.message; });
|
||||||
|
// Camera marker test has its own frame/overlay, never the trigger geometry.
|
||||||
|
let followConfig = null, followSession = null, followSample = null, followBusy = false;
|
||||||
|
let followEpoch = 0;
|
||||||
|
const followCanvas = $('follow-canvas'), followCtx = followCanvas.getContext('2d');
|
||||||
|
function fillFollow(c) {
|
||||||
|
followConfig = c;
|
||||||
|
for (const axis of ['pan','tilt']) {
|
||||||
|
$( `follow-${axis}-min`).value = c[axis+'_min'];
|
||||||
|
$( `follow-${axis}-max`).value = c[axis+'_max'];
|
||||||
|
$( `follow-${axis}-reverse`).checked = c[axis+'_reverse'];
|
||||||
|
}
|
||||||
|
$('follow-tolerance').value = c.tolerance;
|
||||||
|
}
|
||||||
|
async function showFollowImage(b64, target, epoch) {
|
||||||
|
const image = new Image(); image.src = 'data:image/jpeg;base64,'+b64;
|
||||||
|
await image.decode();
|
||||||
|
if (epoch !== followEpoch) return;
|
||||||
|
followCtx.drawImage(image,0,0,640,480);
|
||||||
|
followCtx.strokeStyle = '#fff'; followCtx.lineWidth = 2;
|
||||||
|
followCtx.beginPath(); followCtx.moveTo(305,240); followCtx.lineTo(335,240);
|
||||||
|
followCtx.moveTo(320,225); followCtx.lineTo(320,255); followCtx.stroke();
|
||||||
|
if (target) {
|
||||||
|
followCtx.strokeStyle = '#ffca78'; followCtx.lineWidth = 5;
|
||||||
|
followCtx.beginPath(); followCtx.arc(target.x*640,target.y*480,Math.max(15,target.radius*640),0,Math.PI*2); followCtx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function followAction(fn) {
|
||||||
|
if (followBusy) return;
|
||||||
|
followBusy = true;
|
||||||
|
try { await fn(); }
|
||||||
|
catch (e) { $('follow-status').textContent = e.message; }
|
||||||
|
finally { followBusy = false; }
|
||||||
|
}
|
||||||
|
async function saveFollow() {
|
||||||
|
const c = {...followConfig};
|
||||||
|
for (const axis of ['pan','tilt']) {
|
||||||
|
c[axis+'_min'] = Number($(`follow-${axis}-min`).value);
|
||||||
|
c[axis+'_max'] = Number($(`follow-${axis}-max`).value);
|
||||||
|
c[axis+'_reverse'] = $(`follow-${axis}-reverse`).checked;
|
||||||
|
}
|
||||||
|
c.tolerance = Number($('follow-tolerance').value);
|
||||||
|
fillFollow(await api('/api/follow/config',c));
|
||||||
|
}
|
||||||
|
for (const [id,hue] of [['follow-green',85],['follow-red',0]]) {
|
||||||
|
$(id).onclick = () => followAction(async () => {
|
||||||
|
if (!followConfig) throw new Error('Einstellungen noch nicht geladen');
|
||||||
|
fillFollow(await api('/api/follow/config',{...followConfig,hue}));
|
||||||
|
$('follow-status').textContent = 'Farbe gespeichert. Vorschau starten.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('follow-save').onclick = () => followAction(async () => {
|
||||||
|
await saveFollow(); $('follow-status').textContent = 'Grenzen und Richtung gespeichert';
|
||||||
|
});
|
||||||
|
$('follow-capture').onclick = () => followAction(async () => {
|
||||||
|
const photo = await api('/api/follow/capture');
|
||||||
|
followSample = photo.token; followEpoch++;
|
||||||
|
await showFollowImage(photo.image_base64,null,followEpoch);
|
||||||
|
$('follow-status').textContent = 'Standbild: Jetzt auf die Mitte des Farbpunkts tippen.';
|
||||||
|
});
|
||||||
|
followCanvas.onclick = e => followAction(async () => {
|
||||||
|
if (!followSample) return;
|
||||||
|
const rect = followCanvas.getBoundingClientRect();
|
||||||
|
const config = await api('/api/follow/select',{token:followSample,
|
||||||
|
x:(e.clientX-rect.left)/rect.width,y:(e.clientY-rect.top)/rect.height});
|
||||||
|
fillFollow(config); $('follow-status').textContent = 'Eigene Farbe übernommen. Vorschau starten.';
|
||||||
|
});
|
||||||
|
async function beginFollow(hardware, operation = 'follow') {
|
||||||
|
if (hardware && !$('follow-ack').checked) throw new Error('Bitte zuerst Mittelstellung und freien Bewegungsraum prüfen und bestätigen.');
|
||||||
|
await saveFollow();
|
||||||
|
const result = await api('/api/follow/start',{hardware,acknowledged:$('follow-ack').checked,operation});
|
||||||
|
releaseJog(); jogSequence = 0; followSession = result.session; followSample = null; followEpoch++;
|
||||||
|
$('follow-status').textContent = hardware ? 'Echte Servos aktiv' : 'Farbvorschau ohne Servobewegung';
|
||||||
|
}
|
||||||
|
$('follow-preview').onclick = () => followAction(() => beginFollow(false));
|
||||||
|
$('follow-hardware').onclick = () => followAction(() => beginFollow(true));
|
||||||
|
$('follow-stop').onclick = async () => {
|
||||||
|
releaseJog(); setupReady = false; followSession = null; followEpoch++;
|
||||||
|
try { await api('/api/follow/stop'); $('follow-status').textContent = 'Kameraservos gestoppt'; }
|
||||||
|
catch(e) { $('follow-status').textContent = 'Stopp nicht bestätigt – Servoversorgung abziehen. '+e.message; }
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden && followSession) $('follow-stop').click();
|
||||||
|
});
|
||||||
|
// Hold-to-jog: separate short motion lease; sequence numbers reject late requests.
|
||||||
|
let jogTimer = null, jogSequence = 0, setupReady = false, followDemo = false;
|
||||||
|
async function sendJog(direction) {
|
||||||
|
if (!followSession) return;
|
||||||
|
return api('/api/follow/jog',{session:followSession,direction,sequence:++jogSequence});
|
||||||
|
}
|
||||||
|
function releaseJog() {
|
||||||
|
if (jogTimer === null) return;
|
||||||
|
clearInterval(jogTimer); jogTimer = null;
|
||||||
|
sendJog(null).catch(e => { $('follow-status').textContent = 'Tastenstopp nicht bestätigt: '+e.message; });
|
||||||
|
}
|
||||||
|
function pressJog(direction) {
|
||||||
|
if (!setupReady || !followSession || jogTimer !== null) return;
|
||||||
|
const send = () => sendJog(direction).catch(e => {
|
||||||
|
releaseJog(); $('follow-status').textContent = e.message;
|
||||||
|
});
|
||||||
|
jogTimer = setInterval(send,100); send();
|
||||||
|
}
|
||||||
|
for (const direction of ['left','right','up','down']) {
|
||||||
|
const button = $('setup-'+direction);
|
||||||
|
button.disabled = true;
|
||||||
|
button.onpointerdown = e => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault(); button.setPointerCapture(e.pointerId); pressJog(direction);
|
||||||
|
};
|
||||||
|
button.onpointerup = button.onpointercancel = button.onlostpointercapture = releaseJog;
|
||||||
|
button.onkeydown = e => {
|
||||||
|
if ([' ','Enter'].includes(e.key)) { e.preventDefault(); if (!e.repeat) pressJog(direction); }
|
||||||
|
};
|
||||||
|
button.onkeyup = e => { if ([' ','Enter'].includes(e.key)) { e.preventDefault(); releaseJog(); } };
|
||||||
|
button.onblur = releaseJog;
|
||||||
|
$('setup-save-'+direction).disabled = true;
|
||||||
|
$('setup-save-'+direction).onclick = () => followAction(async () => {
|
||||||
|
releaseJog();
|
||||||
|
await sendJog(null);
|
||||||
|
fillFollow(await api('/api/follow/remember',{session:followSession,direction}));
|
||||||
|
$('setup-note').textContent = 'Grenze gespeichert – mit 10 µs Abstand nach innen.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('setup-release').onclick = releaseJog;
|
||||||
|
document.addEventListener('pointerup',releaseJog);
|
||||||
|
document.addEventListener('pointercancel',releaseJog);
|
||||||
|
window.addEventListener('blur',releaseJog);
|
||||||
|
$('setup-start').onclick = () => followAction(() => beginFollow(!followDemo,'setup'));
|
||||||
|
|
||||||
|
async function pollFollow() {
|
||||||
|
try {
|
||||||
|
if (followSession) await api('/api/follow/heartbeat',{session:followSession});
|
||||||
|
const r = await fetch('/api/follow',{cache:'no-store'});
|
||||||
|
if (!r.ok) throw new Error('Farbtest nicht erreichbar');
|
||||||
|
const f = await r.json();
|
||||||
|
followDemo = f.demo;
|
||||||
|
$('setup-start').textContent = followDemo ? 'Einrichtmodus als Demo starten' : 'Einrichtmodus starten';
|
||||||
|
setupReady = f.active && f.operation === 'setup' && !!followSession;
|
||||||
|
if (!setupReady) releaseJog();
|
||||||
|
for (const direction of ['left','right','up','down']) {
|
||||||
|
$('setup-'+direction).disabled = !setupReady;
|
||||||
|
$('setup-save-'+direction).disabled = !setupReady;
|
||||||
|
}
|
||||||
|
$('setup-limits').textContent = 'Gespeicherte Grenzen: PAN '+f.config.pan_min+'–'+f.config.pan_max+' / TILT '+f.config.tilt_min+'–'+f.config.tilt_max+' µs';
|
||||||
|
$('setup-position').textContent = 'Sollposition: PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
|
||||||
|
if (!followConfig) {
|
||||||
|
fillFollow(f.config);
|
||||||
|
$('follow-status').textContent = 'Bereit. Farbe wählen oder Beispielfoto aufnehmen.';
|
||||||
|
}
|
||||||
|
if (!f.active) followSession = null;
|
||||||
|
if (f.active || f.error) $('follow-status').textContent = f.error ||
|
||||||
|
(f.operation === 'setup' ? (f.hardware ? 'Einrichtmodus · echte Servos' : 'Einrichtmodus · nur Demo')
|
||||||
|
: (f.hardware ? 'Echte Servos aktiv' : 'Vorschau ohne Bewegung'))+' · '+
|
||||||
|
(f.operation === 'setup' ? 'Richtungstaste gedrückt halten' : (f.target ? 'Punkt erkannt' : 'Kein eindeutiger Farbpunkt'))+' · PAN '+f.pan_us+' / TILT '+f.tilt_us+' µs';
|
||||||
|
if (f.active && f.image_base64 && !followSample) await showFollowImage(f.image_base64,f.target,followEpoch);
|
||||||
|
} catch(e) {
|
||||||
|
if (followSession) {
|
||||||
|
releaseJog(); setupReady = false; followSession = null;
|
||||||
|
$('follow-status').textContent = 'Verbindung verloren; Nachführung wird beendet. '+e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(pollFollow,300);
|
||||||
|
}
|
||||||
|
pollFollow();
|
||||||
|
|
||||||
refreshDetection();
|
refreshDetection();
|
||||||
setInterval(drawDetection, 250);
|
setInterval(drawDetection, 250);
|
||||||
refresh();
|
refresh();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie.
|
|||||||
Internetfreigabe ist nicht beauftragt. Zugangsschutz bleibt offen.
|
Internetfreigabe ist nicht beauftragt. Zugangsschutz bleibt offen.
|
||||||
- Laufzeitdaten liegen standardmäßig unter `data/` und werden weder
|
- Laufzeitdaten liegen standardmäßig unter `data/` und werden weder
|
||||||
hochgeladen noch in Git aufgenommen.
|
hochgeladen noch in Git aufgenommen.
|
||||||
- Echte GPIO-/Aktorsteuerung erst nach geklärter Mechanik, Versorgung,
|
- Echte GPIO-/Grabaktorsteuerung erst nach geklärter Mechanik, Versorgung,
|
||||||
sicherem Stopp und den Hardwaretests aus `PLAN.md` ergänzen.
|
sicherem Stopp und den Hardwaretests aus `PLAN.md` ergänzen.
|
||||||
|
|
||||||
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
|
Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests
|
||||||
@@ -93,3 +93,38 @@ unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen.
|
|||||||
bestätigtes Bildlöschen; Ereignis/Profilzuordnung bleiben bestehen. Löschen
|
bestätigtes Bildlöschen; Ereignis/Profilzuordnung bleiben bestehen. Löschen
|
||||||
betrifft den aktiven Bestand, keine Upload-Sicherungen. Keine automatische
|
betrifft den aktiven Bestand, keine Upload-Sicherungen. Keine automatische
|
||||||
Alterslöschung von Bildern und keine Änderung der Auto-Profil-Frist.
|
Alterslöschung von Bildern und keine Änderung der Auto-Profil-Frist.
|
||||||
|
|
||||||
|
|
||||||
|
## Kamera folgt einem Farbpunkt
|
||||||
|
|
||||||
|
- `follow.py` bietet lokale Pillow-HSV-Erkennung ohne OpenCV/Training und einen
|
||||||
|
optional bewusst gestarteten PCA9685-Kameratest. Vorschau und Demo greifen
|
||||||
|
niemals auf I²C zu; Demo lehnt Hardwarestart ab. Echte Ausgabe nur CH0/CH1,
|
||||||
|
Bus 1 / 0x40, nominal 50 Hz. Platine für diesen Test ausschließlich dafür nutzen.
|
||||||
|
- Farbwert, Toleranz, Achsrichtungen und Pulsgrenzen atomar in data/follow.json;
|
||||||
|
Beispielfoto und Analysebilder nur flüchtig. Bildauswahl nutzt Token und exakt
|
||||||
|
das fotografierte Bild; keine neuen Ereignisse, Profile oder Trainingsbilder.
|
||||||
|
- Automatische Auslösung und Follow-Sitzung gegenseitig ausschließen. Follow
|
||||||
|
hält Controller an; allgemeiner Stopp und Reset müssen zuerst Follow stoppen.
|
||||||
|
Danach bleibt bewusste Rücksetzung des Grabablaufs nötig.
|
||||||
|
- Start braucht frische Kamera und explizite Hardware-/Mechanikbestätigung.
|
||||||
|
Beide Achsen starten bei 1500 µs (möglicher Sprung, vorab entkoppelt prüfen).
|
||||||
|
Defaults 1400–1600, absolute Eingabegrenzen 1100–1900 inklusive 1500,
|
||||||
|
maximal 80 µs/s, keine Suchfahrt; Richtungswechsel nur gestoppt.
|
||||||
|
- Sitzung startet nie automatisch. Startender Browser erneuert geheimen
|
||||||
|
Sitzungsschlüssel; 1,5 s ohne Heartbeat, 1 s ohne Bild/Auswertung oder 1 s
|
||||||
|
ohne eindeutigen Marker im Hardwarebetrieb schaltet PWM aus. Stale Resultate
|
||||||
|
und alte Heartbeats dürfen keine neu gestartete Sitzung steuern.
|
||||||
|
- PWM-Abschaltfehler sichtbar melden; keine sichere Hardwareabschaltung aus
|
||||||
|
Softwaretests ableiten. I²C-Ausfall/harter Prozessabbruch können PWM stehen
|
||||||
|
lassen. Keine unabhängige OE-Sperre; nur beaufsichtigter Test.
|
||||||
|
|
||||||
|
- `operation=setup` ist ein eigener manueller Einrichtmodus derselben exklusiven
|
||||||
|
Kamerasitzung. Kein Farbzielsuchen/-verluststopp; Kamera und Heartbeat bleiben
|
||||||
|
erforderlich. Richtungstaste halten: maximal 40 µs/s, Lease 0,35 s und steigende
|
||||||
|
Sequenznummer gegen verspätete Befehle. Loslassen hält die Sollposition per
|
||||||
|
PWM; globaler/Kamerastopp beendet PWM.
|
||||||
|
- Einrichtung darf bisherige Follow-Grenzen überschreiten, niemals 1100–1900 µs.
|
||||||
|
Grenzspeicherung braucht passende Sitzung und beendete Bewegung; Richtungs-
|
||||||
|
umkehr beachten, 10 µs nach innen speichern, atomar in follow.json. Keine
|
||||||
|
Anschlagerkennung behaupten; nur Sollwerte, beide Achsen vor Anschlag stoppen.
|
||||||
+53
-1
@@ -1,4 +1,4 @@
|
|||||||
"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert."""
|
"""Lokale WebUI und MJPEG-Livebild; Grabaktoren simuliert, Kameraservos separat freigebbar."""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -15,6 +15,7 @@ from .camera import CameraStream
|
|||||||
from .controller import Controller, MODES
|
from .controller import Controller, MODES
|
||||||
from .event_store import EventStore
|
from .event_store import EventStore
|
||||||
from .detection import DetectionService
|
from .detection import DetectionService
|
||||||
|
from .follow import FollowService
|
||||||
|
|
||||||
|
|
||||||
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
||||||
@@ -46,6 +47,9 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
events = EventStore(directory)
|
events = EventStore(directory)
|
||||||
detection = DetectionService(camera, controller, events, directory)
|
detection = DetectionService(camera, controller, events, directory)
|
||||||
|
|
||||||
|
follow = FollowService(camera, directory)
|
||||||
|
follow_api_lock = asyncio.Lock()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
async def periodic_profile_cleanup():
|
async def periodic_profile_cleanup():
|
||||||
@@ -59,16 +63,19 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
async def detection_watchdog():
|
async def detection_watchdog():
|
||||||
while True:
|
while True:
|
||||||
detection.watchdog()
|
detection.watchdog()
|
||||||
|
follow.watchdog()
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
camera.start()
|
camera.start()
|
||||||
detection.start()
|
detection.start()
|
||||||
|
follow.start()
|
||||||
watchdog_task = asyncio.create_task(detection_watchdog())
|
watchdog_task = asyncio.create_task(detection_watchdog())
|
||||||
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
cleanup_task = asyncio.create_task(periodic_profile_cleanup())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
controller.stop()
|
controller.stop()
|
||||||
|
follow.stop()
|
||||||
watchdog_task.cancel()
|
watchdog_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await watchdog_task
|
await watchdog_task
|
||||||
@@ -83,6 +90,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
app.state.controller = controller
|
app.state.controller = controller
|
||||||
app.state.events = events
|
app.state.events = events
|
||||||
app.state.detection = detection
|
app.state.detection = detection
|
||||||
|
app.state.follow = follow
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_headers(request: Request, call_next):
|
async def security_headers(request: Request, call_next):
|
||||||
@@ -112,6 +120,8 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/api/detection/config", dependencies=[Depends(require_ui)])
|
@app.post("/api/detection/config", dependencies=[Depends(require_ui)])
|
||||||
async def configure_detection(payload: dict):
|
async def configure_detection(payload: dict):
|
||||||
|
if follow.active and payload.get("enabled"):
|
||||||
|
raise HTTPException(status_code=409, detail="Zuerst Kameranachführung stoppen")
|
||||||
try:
|
try:
|
||||||
return detection.configure(payload)
|
return detection.configure(payload)
|
||||||
except (ValueError, TypeError, KeyError, IndexError) as exc:
|
except (ValueError, TypeError, KeyError, IndexError) as exc:
|
||||||
@@ -119,6 +129,46 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
|
||||||
|
|
||||||
|
@app.get("/api/follow")
|
||||||
|
async def follow_status():
|
||||||
|
return follow.status()
|
||||||
|
|
||||||
|
@app.post("/api/follow/{action}", dependencies=[Depends(require_ui)])
|
||||||
|
async def follow_action(action: str, payload: dict):
|
||||||
|
async with follow_api_lock:
|
||||||
|
try:
|
||||||
|
if action == "config":
|
||||||
|
return follow.configure(payload)
|
||||||
|
if action == "capture":
|
||||||
|
return follow.capture()
|
||||||
|
if action == "select":
|
||||||
|
return follow.select(payload.get("token"), payload.get("x"), payload.get("y"))
|
||||||
|
if action == "start":
|
||||||
|
if detection.get_config()["enabled"]:
|
||||||
|
raise ValueError("Zuerst automatische Auslösung ausschalten und speichern")
|
||||||
|
if type(payload.get("hardware", False)) is not bool:
|
||||||
|
raise ValueError("Ungültige Servofreigabe")
|
||||||
|
controller.stop()
|
||||||
|
session = follow.begin(payload.get("hardware", False),
|
||||||
|
payload.get("acknowledged") is True,
|
||||||
|
payload.get("operation", "follow"))
|
||||||
|
return {"session": session}
|
||||||
|
if action == "jog":
|
||||||
|
return follow.jog(payload.get("session"), payload.get("direction"), payload.get("sequence"))
|
||||||
|
if action == "remember":
|
||||||
|
return follow.remember(payload.get("session"), payload.get("direction"))
|
||||||
|
if action == "heartbeat":
|
||||||
|
follow.heartbeat(payload.get("session"))
|
||||||
|
return {"ok": True}
|
||||||
|
if action == "stop":
|
||||||
|
follow.halt()
|
||||||
|
return follow.status()
|
||||||
|
raise HTTPException(status_code=404, detail="Unbekannte Aktion")
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
async def recent_events(limit: int = Query(20, ge=1, le=100)):
|
async def recent_events(limit: int = Query(20, ge=1, le=100)):
|
||||||
try:
|
try:
|
||||||
@@ -213,11 +263,13 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.post("/api/stop", dependencies=[Depends(require_ui)])
|
@app.post("/api/stop", dependencies=[Depends(require_ui)])
|
||||||
async def stop():
|
async def stop():
|
||||||
|
follow.halt()
|
||||||
controller.stop()
|
controller.stop()
|
||||||
return controller.status()
|
return controller.status()
|
||||||
|
|
||||||
@app.post("/api/reset", dependencies=[Depends(require_ui)])
|
@app.post("/api/reset", dependencies=[Depends(require_ui)])
|
||||||
async def reset():
|
async def reset():
|
||||||
|
follow.halt()
|
||||||
controller.reset()
|
controller.reset()
|
||||||
detection.reset()
|
detection.reset()
|
||||||
return controller.status()
|
return controller.status()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi>=0.115,<1
|
fastapi>=0.115,<1
|
||||||
uvicorn>=0.30,<1
|
uvicorn>=0.30,<1
|
||||||
Pillow>=10,<13
|
Pillow>=10,<13
|
||||||
|
smbus2>=0.4.3,<1
|
||||||
@@ -64,3 +64,13 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware.
|
|||||||
privaten Socket für alle Schritte, BatchMode auf Slaves, TTY für sudo sowie
|
privaten Socket für alle Schritte, BatchMode auf Slaves, TTY für sudo sowie
|
||||||
Aufräumen bei Erfolg/Verbindungs-/Transfer-/Aktivierungsfehlern und pure Vorschau.
|
Aufräumen bei Erfolg/Verbindungs-/Transfer-/Aktivierungsfehlern und pure Vorschau.
|
||||||
Dies bestätigt keine reale SSH-/Passwortanmeldung am Pi.
|
Dies bestätigt keine reale SSH-/Passwortanmeldung am Pi.
|
||||||
|
|
||||||
|
|
||||||
|
- `test_follow.py` prüft synthetische Farbpunkte (auch Rot um die HSV-Grenze),
|
||||||
|
Mehrdeutigkeit, Bildauswahl, Persistenz ohne Fotos, Pulsgrenzen/Geschwindigkeit,
|
||||||
|
Richtungswechsel, alte Resultate/Sitzungen, Fehler-/Abbruchpfade, API-Interlock
|
||||||
|
und I²C-Protokoll mit künstlichem Bus. Kein echter Pi oder Servo wird angesteuert.
|
||||||
|
|
||||||
|
- Follow-Einrichttests prüfen Hold/Release, ablaufende Bewegungslease, verspätete
|
||||||
|
Tastenbefehle, falsche/alte Sitzung, Grenzen jenseits gespeicherter Followwerte,
|
||||||
|
absolute Pulsgrenzen, Richtungsumkehr und persistente Grenze mit Innenabstand.
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import asyncio
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from halloween.follow import DEFAULT, FollowService, PCA9685, locate, validate
|
||||||
|
from halloween.web import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def frame(color='lime', double=False):
|
||||||
|
image = Image.new('RGB', (640,480), '#181818')
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
draw.ellipse((440,180,510,250), fill=color)
|
||||||
|
if double: draw.ellipse((100,180,170,250), fill=color)
|
||||||
|
data = BytesIO(); image.save(data, 'JPEG'); return data.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class Camera:
|
||||||
|
demo = False
|
||||||
|
error = None
|
||||||
|
def __init__(self, clock): self.clock, self.jpeg, self.captured = clock, frame(), clock()
|
||||||
|
def latest_timed(self): return 1, self.jpeg, self.captured
|
||||||
|
|
||||||
|
|
||||||
|
class Driver:
|
||||||
|
def __init__(self): self.moves, self.closed = [], False
|
||||||
|
def move(self, pan, tilt): self.moves.append((pan,tilt))
|
||||||
|
def close(self): self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class FollowTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.folder = tempfile.TemporaryDirectory(); self.addCleanup(self.folder.cleanup)
|
||||||
|
self.now = 10.
|
||||||
|
self.camera = Camera(lambda:self.now)
|
||||||
|
self.drivers = []
|
||||||
|
def factory():
|
||||||
|
driver = Driver(); self.drivers.append(driver); return driver
|
||||||
|
self.follow = FollowService(self.camera, Path(self.folder.name), factory, lambda:self.now)
|
||||||
|
|
||||||
|
def start(self): return self.follow.begin(True, True)
|
||||||
|
def step(self, target):
|
||||||
|
self.now += .2; self.camera.captured = self.now
|
||||||
|
self.follow.heartbeat(self.follow.session)
|
||||||
|
self.follow.process(2,self.camera.jpeg,self.now,self.follow.revision,target)
|
||||||
|
|
||||||
|
def test_marker_and_ambiguity(self):
|
||||||
|
target = locate(frame(), DEFAULT)
|
||||||
|
self.assertGreater(target['x'], .7)
|
||||||
|
self.assertIsNone(locate(frame(double=True), DEFAULT))
|
||||||
|
self.assertIsNone(locate(frame('gray'), DEFAULT))
|
||||||
|
self.assertIsNone(locate(frame('red'), DEFAULT))
|
||||||
|
self.assertIsNotNone(locate(frame('red'), dict(DEFAULT,hue=0)))
|
||||||
|
|
||||||
|
def test_photo_selection_and_no_persistent_image(self):
|
||||||
|
shot = self.follow.capture()
|
||||||
|
config = self.follow.select(shot['token'],.74,.45)
|
||||||
|
self.assertLess(abs(config['hue']-85),3)
|
||||||
|
self.assertEqual([p.name for p in Path(self.folder.name).iterdir()], ['follow.json'])
|
||||||
|
with self.assertRaises(ValueError): self.follow.select('old',.74,.45)
|
||||||
|
with self.assertRaises(ValueError): self.follow.select(shot['token'],float('nan'),.5)
|
||||||
|
with self.assertRaises(ValueError): self.follow.select(shot['token'],.2,.2)
|
||||||
|
|
||||||
|
def test_no_hardware_without_explicit_start_and_no_restart_resume(self):
|
||||||
|
self.follow.begin(False)
|
||||||
|
self.step({'x':.9,'y':.9})
|
||||||
|
self.assertEqual(self.drivers, [])
|
||||||
|
self.follow.halt()
|
||||||
|
with self.assertRaises(ValueError): self.follow.begin(True,False)
|
||||||
|
self.assertFalse(FollowService(self.camera,Path(self.folder.name)).active)
|
||||||
|
self.assertEqual(self.drivers, [])
|
||||||
|
|
||||||
|
def test_rate_limits_reverse_deadband_and_stop(self):
|
||||||
|
self.start(); driver=self.drivers[0]
|
||||||
|
self.step({'x':.9,'y':.5})
|
||||||
|
self.assertAlmostEqual(self.follow.pan,1512.8)
|
||||||
|
self.assertEqual(self.follow.tilt,1500)
|
||||||
|
self.assertLessEqual(abs(driver.moves[-1][0]-driver.moves[-2][0]),16)
|
||||||
|
for _ in range(30): self.step({'x':1.,'y':1.})
|
||||||
|
self.assertEqual(self.follow.pan,1600)
|
||||||
|
self.follow.halt(); self.assertTrue(driver.closed)
|
||||||
|
count=len(driver.moves)
|
||||||
|
self.follow.process(99,self.camera.jpeg,self.now,self.follow.revision,{'x':0,'y':0})
|
||||||
|
self.assertEqual(len(driver.moves),count)
|
||||||
|
self.follow.configure(dict(DEFAULT,pan_reverse=True))
|
||||||
|
self.start(); self.step({'x':.9,'y':.5})
|
||||||
|
self.assertLess(self.follow.pan,1500)
|
||||||
|
|
||||||
|
def test_stale_results_cannot_move_new_session(self):
|
||||||
|
self.start(); revision=self.follow.revision
|
||||||
|
self.follow.halt(); self.start(); driver=self.drivers[-1]
|
||||||
|
self.follow.process(2,self.camera.jpeg,self.now,revision,{'x':1,'y':1})
|
||||||
|
self.assertEqual(driver.moves,[(1500,1500)])
|
||||||
|
|
||||||
|
def test_lost_marker_heartbeat_and_camera_latch_off(self):
|
||||||
|
for reason in ('marker','heartbeat','camera','inference'):
|
||||||
|
self.follow.halt(); self.camera.captured=self.now
|
||||||
|
self.start(); driver=self.drivers[-1]
|
||||||
|
if reason=='marker':
|
||||||
|
for _ in range(6): self.step(None)
|
||||||
|
else:
|
||||||
|
self.now+=2
|
||||||
|
if reason!='camera': self.camera.captured=self.now
|
||||||
|
if reason=='inference': self.follow.heartbeat(self.follow.session)
|
||||||
|
self.follow.watchdog()
|
||||||
|
self.assertFalse(self.follow.active, reason)
|
||||||
|
self.assertTrue(driver.closed, reason)
|
||||||
|
self.assertIsNotNone(self.follow.error)
|
||||||
|
|
||||||
|
def test_invalid_limits_and_corrupt_saved_config(self):
|
||||||
|
for config in (dict(DEFAULT,pan_min=1600), dict(DEFAULT,pan_max=2000),
|
||||||
|
dict(DEFAULT,hue=float('inf')), dict(DEFAULT,pan_reverse='false')):
|
||||||
|
with self.assertRaises(ValueError): validate(config)
|
||||||
|
Path(self.folder.name,'follow.json').write_text('bad')
|
||||||
|
follow=FollowService(self.camera,Path(self.folder.name))
|
||||||
|
with self.assertRaises(ValueError): follow.begin()
|
||||||
|
|
||||||
|
def test_close_error_is_visible_and_restart_is_explicit(self):
|
||||||
|
self.start()
|
||||||
|
def bad_close(): raise OSError('I2C ausgefallen')
|
||||||
|
self.drivers[0].close=bad_close
|
||||||
|
self.follow.halt()
|
||||||
|
self.assertFalse(self.follow.active)
|
||||||
|
self.assertIn('Servoversorgung ausschalten',self.follow.error)
|
||||||
|
|
||||||
|
def test_failed_start_disables_channels_and_demo_rejects_hardware(self):
|
||||||
|
driver=Driver()
|
||||||
|
def bad_move(*args): raise OSError('I2C fehlgeschlagen')
|
||||||
|
driver.move=bad_move
|
||||||
|
self.follow.factory=lambda:driver
|
||||||
|
with self.assertRaises(ValueError): self.start()
|
||||||
|
self.assertTrue(driver.closed)
|
||||||
|
self.assertFalse(self.follow.active)
|
||||||
|
self.camera.demo=True
|
||||||
|
with self.assertRaises(ValueError): self.start()
|
||||||
|
|
||||||
|
def test_old_heartbeat_cannot_renew_new_session(self):
|
||||||
|
old=self.start(); self.follow.halt(); current=self.start()
|
||||||
|
self.assertNotEqual(old,current)
|
||||||
|
with self.assertRaises(ValueError): self.follow.heartbeat(old)
|
||||||
|
self.now+=2; self.camera.captured=self.now
|
||||||
|
self.follow.watchdog()
|
||||||
|
self.assertFalse(self.follow.active)
|
||||||
|
|
||||||
|
def test_setup_hold_release_expiry_and_out_of_order_requests(self):
|
||||||
|
session=self.follow.begin(True,True,'setup')
|
||||||
|
driver=self.drivers[-1]
|
||||||
|
self.follow.jog(session,'right',1)
|
||||||
|
self.step(None)
|
||||||
|
self.assertAlmostEqual(self.follow.pan,1508)
|
||||||
|
self.step(None) # no renewal: 0.35 s motion lease has expired
|
||||||
|
self.assertAlmostEqual(self.follow.pan,1508)
|
||||||
|
self.follow.jog(session,'right',2)
|
||||||
|
self.follow.jog(session,None,3)
|
||||||
|
self.assertFalse(self.follow.jog(session,'right',2)['accepted'])
|
||||||
|
self.step(None)
|
||||||
|
self.assertAlmostEqual(self.follow.pan,1508)
|
||||||
|
for _ in range(8): self.step(None)
|
||||||
|
self.assertTrue(self.follow.active) # setup needs no colour marker
|
||||||
|
self.follow.halt()
|
||||||
|
with self.assertRaises(ValueError): self.follow.jog(session,'up',4)
|
||||||
|
self.assertTrue(driver.closed)
|
||||||
|
|
||||||
|
def test_setup_limits_reverse_and_remember_are_persistent(self):
|
||||||
|
self.follow.configure(dict(DEFAULT,pan_min=1500,pan_max=1500,tilt_reverse=True))
|
||||||
|
session=self.follow.begin(True,True,'setup')
|
||||||
|
for seq in range(1,5):
|
||||||
|
self.follow.jog(session,'right',seq); self.step(None)
|
||||||
|
self.assertGreater(self.follow.pan,1500) # setup intentionally exceeds saved limits
|
||||||
|
with self.assertRaises(ValueError): self.follow.remember(session,'right')
|
||||||
|
self.follow.jog(session,None,5)
|
||||||
|
c=self.follow.remember(session,'right')
|
||||||
|
self.assertEqual(c['pan_max'],1522)
|
||||||
|
self.assertEqual(FollowService(self.camera,Path(self.folder.name)).config['pan_max'],1522)
|
||||||
|
self.follow.jog(session,'up',6); self.step(None)
|
||||||
|
self.assertGreater(self.follow.tilt,1500)
|
||||||
|
for seq in range(7,80):
|
||||||
|
self.follow.jog(session,'up',seq); self.step(None)
|
||||||
|
self.assertEqual(self.follow.tilt,1900)
|
||||||
|
self.follow.jog(session,None,80)
|
||||||
|
self.assertEqual(self.follow.remember(session,'up')['tilt_max'],1890)
|
||||||
|
|
||||||
|
def test_setup_cannot_be_controlled_by_wrong_session_or_follow_mode(self):
|
||||||
|
session=self.start()
|
||||||
|
with self.assertRaises(ValueError): self.follow.jog(session,'up',1)
|
||||||
|
with self.assertRaises(ValueError): self.follow.remember(session,'up')
|
||||||
|
self.follow.halt(); session=self.follow.begin(True,True,'setup')
|
||||||
|
for sequence in (True,-1,1.5):
|
||||||
|
with self.assertRaises(ValueError): self.follow.jog(session,'up',sequence)
|
||||||
|
with self.assertRaises(ValueError): self.follow.jog('wrong','up',1)
|
||||||
|
with self.assertRaises(ValueError): self.follow.remember('wrong','up')
|
||||||
|
self.now+=2; self.camera.captured=self.now
|
||||||
|
with self.assertRaises(ValueError): self.follow.jog(session,'up',1)
|
||||||
|
self.assertFalse(self.follow.active)
|
||||||
|
|
||||||
|
def test_register_protocol(self):
|
||||||
|
class Bus:
|
||||||
|
def __init__(self,n): self.writes=[]; self.closed=False
|
||||||
|
def write_byte_data(self,a,r,v): self.writes.append((a,r,v))
|
||||||
|
def write_i2c_block_data(self,a,r,v): self.writes.append((a,r,v))
|
||||||
|
def close(self): self.closed=True
|
||||||
|
driver=PCA9685(Bus)
|
||||||
|
self.assertEqual(driver.bus.writes[:2],[(0x40,0x09,0x10),(0x40,0x0d,0x10)])
|
||||||
|
self.assertIn((0x40,0xfe,121),driver.bus.writes)
|
||||||
|
driver.move(1500,1500)
|
||||||
|
self.assertEqual(driver.bus.writes[-2:],[(0x40,6,[0,0,51,1]),(0x40,10,[0,0,51,1])])
|
||||||
|
driver.close(); self.assertTrue(driver.bus.closed)
|
||||||
|
|
||||||
|
def test_api_guard_interlock_global_stop_and_reset(self):
|
||||||
|
async def scenario():
|
||||||
|
app=create_app(demo=True,state_dir=Path(self.folder.name))
|
||||||
|
app.state.follow.camera=self.camera
|
||||||
|
app.state.follow.clock=lambda:self.now
|
||||||
|
app.state.follow.factory=lambda:Driver()
|
||||||
|
app.state.camera.demo=True
|
||||||
|
h={'X-Halloween-UI':'1'}
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app),base_url='http://local') as c:
|
||||||
|
self.assertEqual((await c.post('/api/follow/start',json={})).status_code,403)
|
||||||
|
self.assertEqual((await c.post('/api/follow/start',headers=h,json={'hardware':False})).status_code,200)
|
||||||
|
cfg=app.state.detection.get_config(); cfg['enabled']=True
|
||||||
|
self.assertEqual((await c.post('/api/detection/config',headers=h,json=cfg)).status_code,409)
|
||||||
|
await c.post('/api/stop',headers=h)
|
||||||
|
self.assertFalse(app.state.follow.active)
|
||||||
|
await c.post('/api/follow/start',headers=h,json={'hardware':False})
|
||||||
|
await c.post('/api/reset',headers=h)
|
||||||
|
self.assertFalse(app.state.follow.active)
|
||||||
|
await c.post('/api/detection/config',headers=h,json=cfg)
|
||||||
|
self.assertEqual((await c.post('/api/follow/start',headers=h,json={})).status_code,422)
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
if __name__=='__main__': unittest.main()
|
||||||
+1
-1
@@ -20,7 +20,7 @@ class WebTest(unittest.TestCase):
|
|||||||
page = await client.get("/")
|
page = await client.get("/")
|
||||||
self.assertEqual(page.status_code, 200)
|
self.assertEqual(page.status_code, 200)
|
||||||
self.assertIn("/stream.mjpg", page.text)
|
self.assertIn("/stream.mjpg", page.text)
|
||||||
self.assertIn("Aktoren nur simuliert", page.text)
|
self.assertIn("Grabablauf nur simuliert", page.text)
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
if (await client.get("/api/status")).json()["camera"]["ready"]:
|
if (await client.get("/api/status")).json()["camera"]["ready"]:
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in new issue
Block a user