From d370f992ce3b428e55b175d08c64232fb3d25db1 Mon Sep 17 00:00:00 2001 From: marcinator_2 Date: Wed, 14 Oct 2026 05:07:07 +0200 Subject: [PATCH] kamera geht --- AGENTS.md | 23 +- PLAN.md | 1339 +++++------------------------- README.md | 10 +- SOFTWARE.md | 108 ++- UPLOAD.md | 18 + halloween/camera.py | 4 + halloween/controller.py | 4 + halloween/detection.py | 351 ++++++++ halloween/event_store.py | 8 +- halloween/index.html | 131 ++- halloween/instructions.md | 18 + halloween/nanodet-model.json | 9 + halloween/web.py | 36 +- requirements-detection.txt | 6 + tests/instructions.md | 10 + tests/test_detection.py | 300 +++++++ tests/test_detection_model.py | 66 ++ tools/install_detection_model.py | 47 ++ tools/instructions.md | 5 + 19 files changed, 1365 insertions(+), 1128 deletions(-) create mode 100644 halloween/detection.py create mode 100644 halloween/nanodet-model.json create mode 100644 requirements-detection.txt create mode 100644 tests/test_detection.py create mode 100644 tests/test_detection_model.py create mode 100644 tools/install_detection_model.py diff --git a/AGENTS.md b/AGENTS.md index eeb71a3..f01de58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,19 +169,38 @@ Versorgung und Sicherheit im Plan. `run.py` und systemd bleiben unverändert bei Loopback. - Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde nur der Demo-Feed geprüft, nicht die echte Pi-Kamera. -- Moduswahl und simulierte Sequenz sind implementiert. GPIO, automatische - Personenauslösung und Clip-Speicherung sind noch offen. +- Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit + gerichteter Linienüberquerung sind implementiert. Erkennung benötigt optional + OpenCV und ausdrücklich installiertes SHA-256-geprüftes Modell unter data/. + Am Pi fertige Systempakete python3-opencv/python3-numpy in einer Umgebung + mit --system-site-packages verwenden; OpenCV mindestens 4.10 prüfen. + requirements-detection.txt erlaubt nur Wheels, keinen impliziten Quellbuild. + Erkennung startet deaktiviert; Linie/Bereich/Richtung in der UI einstellen. + Gruppen verlangen neuen freien Bereich und Sperrzeit; Erkennungsfehler bei + aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und + Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile. + Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen. - SQLite speichert Testereignisse und Profilkennungen unter `data/`; automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera schreibt derzeit keine solchen Datensätze. - Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben. +- Nutzer meldet am 14. Oktober 2026 die Pi-Kamera als angeschlossen; + Ausgabe von `rpicam-hello --list-cameras` bestätigt `imx708_noir` + mit 4608×2592 Pixeln. Nutzer bestätigt anschließend echtes Livebild + in der WebUI nach Einrichtung von Picamera2. - Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als `marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`. Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen. - Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim Upload eingerichtet; auf einem echten Pi noch nicht geprüft. +- Nutzer-Ausgaben bestätigen Raspbian GNU/Linux 13 (trixie), Python 3.13.5 + und Kernelarchitektur aarch64; Paket-/Python-Bitness noch nicht geprüft. + Nach Netzteilwechsel SSH wieder erreichbar, get_throttled=0x0; + keine Diagnose des vorherigen Ausfalls daraus ableiten. Kein OpenCV-Build + in der übermittelten Prozessliste; Dell-Netzteilwerte bleiben ungeprüft. + ## Prüfung - Softwareprüfungen, Hardwaretests und Außentests getrennt berichten. diff --git a/PLAN.md b/PLAN.md index e7672be..a567f13 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,1110 +1,229 @@ -# Halloween: interaktives atmendes Grab - -Stand: 14. Oktober 2026. Status: Entwurf zur Abstimmung; erste Software mit -Livebild und simulierter Steuerung vorhanden, reale Mechanik noch offen. - -## Zielbild - -Eine Grabattrappe im Vorgarten atmet langsam. Nähert sich ein Besucher, -stoppt die Bewegung. Nach einer kurzen wechselnden Pause folgen Kratzen, -ein kräftiger Geräuschimpuls, eine kurze Bewegung und Licht. Optional hebt -sich eine Skelett-Hand innerhalb der Attrappe. - -Die Weboberfläche zeigt das Livebild, Betriebszustand und Ereignisse. -Bilder und kurze Videoclips lassen sich auf dem Laptop speichern und -manuell prüfen. Die Erkennung meldet Personen oder Bewegungen in definierten -Bereichen. Eine spätere Kamera-Zuordnung zu Profilen soll nur bei -ausreichender Sicherheit erfolgen; sonst bleibt das Ereignis ohne Profil. - -## Aktueller Aufbau und noch offene Teile - -Auswahl vom 14. Oktober: Nutzer entscheidet sich zunächst für den -[CHANCS 60KTYZ, Amazon B07WXFWYBZ](https://www.amazon.de/dp/B07WXFWYBZ), -220–240 V AC, ca. 2,5 U/min bei 50 Hz, mit Halterung. Kauf noch nicht -bestätigt. Aktueller Warenkorb enthält nun das Waveshare-Relais-HAT -RPI RELAIS 3CH; die frühere Aussage, ein Relais sei vorhanden und entfalle, -wurde korrigiert. Versorgungsleitung laut Nutzer vorhanden; Eignung prüfen. -Motorlast-Eignung des Relais noch nicht abschließend bestätigt. -Zusätzliche Untersetzung mit gedruckten Teilen bleibt eine spätere Option. - -Warenkorbstand laut Nutzer-Screenshots vom 14. Oktober, keine Kaufbestätigung: - -| Händler | Teil | Menge | Gesamtpreis im Screenshot | -|---|---|---:|---:| -| Amazon | CHANCS 60KTYZ B07WXFWYBZ, 2.5RPM + Halterung | 1 | 30,99 Euro | -| Amazon | GTIWUNG B082WYFWSY, 30 Servoverlängerungen | 1 Set | 11,99 Euro | -| Amazon | Poppstar B07WS7TX1R | 1 | 28,60 Euro | -| Amazon | Miuzei B0CBBS6KX6, 35kg / 270 Grad | 1 | 23,99 Euro | -| Reichelt | Delock 65485 | 1 | 1,99 Euro | -| Reichelt | RPI PS 27W WT EU | 1 | 11,10 Euro | -| Reichelt | WAGO 221-413 | 5 | 2,75 Euro | -| Reichelt | RPI RELAIS 3CH, Waveshare | 1 | 11,90 Euro | - -Amazon-Teilesumme 95,57 Euro; Reichelt-Teilesumme 27,74 Euro; -zusammen 123,31 Euro, zuzüglich gegebenenfalls Versand. -Relais-HAT: Shop nennt Pi-5-Kompatibilität und 250 V AC / 5 A; -konkrete Freigabe für CHANCS-Motorlast/Einschaltstrom bleibt offen. - -Neue Recherche auf Nutzerwunsch auch außerhalb Amazon/Reichelt: -[FENGTECH YJF-6130-AC230V-3.2RPM bei Wittko](https://wittko.eu/product-ac-spaltpol-getriebemotor-230v-3-2rpm-25nm) -als stärkerer Rotationskandidat. Händlerangaben: 230 V / 50 Hz, 3,2 U/min, -60 W, Drehmoment >25 Nm, 1,65 kg, Welle 9,4 x 34 mm, Anschluss über -6,3-mm-Flachstecker. Am 13. Oktober angezeigt: 89,95 Euro inkl. MwSt., -Versand innerhalb DE inklusive, 17 Stück auf Lager. Noch nicht ausgewählt -oder gekauft. Dauerdrehmoment, Anlaufmoment und zulässige Betriebsart sind -auf der Produktseite nicht getrennt spezifiziert; vor Kauf klären. -80TYD mit 3,5 U/min / beworbenen 30 Nm ist eine weitere Option, bei Wittko -jedoch als vergriffen angezeigt. Händlerwerte sind keine eigene Lastprüfung. - -Arbeitsstand vom 13. Oktober: Atemrotation mit einem 230-V-Getriebemotor -und Relais; Hand mit dem Miuzei-35kg-/270°-Positionsservo. Der Servo, -das vorgeschlagene stabilisierte 6-V-/5-A-Netzteil Poppstar 1010329 und -der Anschlussadapter Delock 65485 sind Kandidaten, kein bestätigter Bestand. -Der Pi bekommt ein eigenes passendes Netzteil. - -Aktueller Einkaufsumfang laut Nutzer: zunächst nur Elektronik; Mechanik und -Gehäuse folgen später. USB-Kabel sind vorhanden, ein Pi-Netzteil fehlt. -Miuzei ist als Handkandidat akzeptiert. Das Poppstar-Netzteil -[Amazon B07WS7TX1R](https://www.amazon.de/dp/B07WS7TX1R) liegt laut Nutzer -im Warenkorb, noch nicht als gekauft bestätigt. Produktseite am 13. Oktober -geprüft: 6–12 V mit 5 A; vorgesehen sind 6 V und der passende 5,5/2,5-mm-Tip. -Rotation und Relais sind im aktuellen Warenkorb ausgewählt; Lastprüfung offen. - -Ergänzende Einkaufsvorschläge, noch nicht gekauft: - -- Pi 5: offizielles 27-W-Netzteil, Reichelt RPI PS 27W WT EU - (Produkt 360112), 5,1 V / 5 A, mit festem USB-C-Kabel. -- PWM: PCA9685 mit 16 Kanälen laut Nutzer bereits vorhanden; kein Neukauf - nötig. Hersteller und konkrete Platine noch nicht geprüft. Logik mit - 3,3 V betreiben; Versorgung und Anschlüsse der vorhandenen Platine sowie - tatsächliche Miuzei-Signalverträglichkeit vor Anschluss prüfen. -- Servoanschluss: Nutzer bevorzugt GTIWUNG-JR-Verlängerungsset 22 AWG, - [Amazon B082WYFWSY](https://www.amazon.de/dp/B082WYFWSY), statt B08JCVMXQX. - Produktseite bestätigt 30 gerade Verlängerungen, je sechs in - 10/15/30/50/60 cm; noch nicht als gekauft bestätigt. - Leitungslänge, Kontaktbelastung und Spannungsabfall unter Last prüfen. -- Steuerleitungen: Buchse/Buchse-Leitungen laut Nutzer reichlich vorhanden; - kein Neukauf nötig. Ausschließlich für Logiksignale und Logikversorgung, - nicht für Servolast verwenden. -- Verteilung: fünf WAGO 221-413 im Warenkorb als Kleinspannungs-Klemmenreserve; - jede Klemme verbindet intern alle drei Anschlüsse, Plus und Masse getrennt. - Kurze kräftigere Versorgungsleitungen separat vorsehen. Servostrom - zunächst direkt verteilen, nicht ungeprüft über PWM-Platinenkontakte führen. - -- **Rotation:** ausgewählten CHANCS 60KTYZ und Waveshare-Relais-HAT prüfen; - Motorlast, Einschaltstrom und passende Pi-Ansteuerung berücksichtigen. - Der Motor aus dem US-Referenzlink ist kein direkt geeigneter 230-V-Motor. -- **Hand:** Gewicht, Hebellänge und Bewegungsbereich festlegen; separate - Lagerung, Servoaufnahme, Befestigung und gedämpfte Anschläge auslegen. - PWM-Steuerung über vorhandenes PCA9685 und 3,3-V-Signalkompatibilität klären. -- **Mechanik:** Rahmen, Stangenlager, Kupplung, Motorhalter und Befestigung - der beiden Flaschen bauen; Widerstand mit der echten Abdeckung messen. -- **Anschluss und Schutz:** geeignete Servostecker/Verlängerung, Leitungen, - Verteilung und Absicherung dimensionieren; geschütztes Gehäuse, - Zugentlastung und einen unabhängigen lokalen Hardware-Stopp vorsehen. - Sichere Handposition auch beim Abschalten prüfen. Die 230-V-Seite - geschlossen und fachgerecht installieren lassen. -- **Pi und Kamera:** vorhandenes Pi-Netzteil, Kühlung und Speicher prüfen; - Pi-5-Kamerakabel und IR-Beleuchtung für die NoIR-Kamera klären. -- **Licht und Ton:** gewünschte LEDs, Lautsprecher und Verstärker samt - deren Versorgung und Ansteuerung noch konkret festlegen. -- **Software und Prüfung:** reale Relais-/PWM-Ansteuerung, automatische - Auslösung und Clip-Speicherung fehlen noch. Kamera, einzelne Aktoren, - Abschalten/Neustart und Gesamtablauf am echten Aufbau testen. - -Die früheren 12-V-Motoren, das große 12-V-Motornetzteil und der L298N -gehören nicht zum aktuellen Antriebsvorschlag. Eine Feder-/Schnurmechanik -ist für den zunächst vorgesehenen langsamen direkten Handhub nicht nötig. - -## Bisherige Wünsche und Bestand - -- Einsatz draußen im Vorgarten; möglichst starker Schreckeffekt. -- Nutzerwunsch für die Graboberfläche: Folie, darüber grobes Gewebe und - Blätter. Die Attrappe soll ungefähr die Proportionen eines Jugendlichen - haben; „besenstiellang“ war die erste Größenbeschreibung. Exakte Länge, - Breite und bewegter Bereich sind noch offen. -- Die Atembewegung soll wie im Referenzvideo mehrere ungleichmäßige, - örtliche Wölbungen erzeugen. Die Gestaltung darf detailreicher und - realistischer werden als die Vorlage. -- Am vorgesehenen Standort stehen nach grober Schätzung des Nutzers etwa - 2 m² zur Verfügung. Länge, Breite und Abstand zum Besucherweg sind noch - nicht gemessen. -- [Originalclip](references/atmendes-grab-original.mp4) als Referenz vom - Nutzer bereitgestellt; [öffentliche Quelle](https://www.facebook.com/joshua.brawer/videos/1075933321689584/). -- Raspberry Pis 3–5 vorhanden. Raspberry Pi 5 anhand der Platinenbeschriftung - auf Nutzerfoto bestätigt; vermutlich 8 GB RAM laut Nutzer, noch zu prüfen. -- Der Pi 5 unterstützt laut [Herstellerdaten](https://www.raspberrypi.com/products/raspberry-pi-5/) - 2,4- und 5-GHz-WLAN (Wi-Fi 5/802.11ac). Empfang am Aufstellort und die - tatsächliche Verbindung sind noch nicht geprüft. -- Aufkleber am Pi zeigt `raspi5WLAN.local` und darunter `_2`; tatsächlicher - SSH-Zugang über `raspi5wlan.local` als `marcinator_2` ist durch den - Nutzer-Screenshot vom 14. Oktober 2026 bestätigt. -- „2mini“ vorhanden: genaue Bezeichnung noch klären. -- Kamera: Raspberry Pi Camera Module 3 NoIR, anhand der Beschriftung auf dem - Nutzerfoto identifiziert. Funktion noch nicht getestet; Display eventuell vorhanden. -- LEDs, Servos und Lötwerkzeug vorhanden; weitere Teile können gekauft werden. -- 3D-Drucker laut Nutzer vorhanden; Modell, Bauraum und Material noch offen. -- Weiterer Bestand auf Nutzerfoto `references/PXL_20261013_214821984.jpg`: - Schrittmotor mit Beschriftung 28BYJ-48 / 5 V DC und OPITEC-Getriebemotor- - Werkpackung 224105 mit Zahnrädern, Achsen und Halterungen. Vollständigkeit, - Zustand und vorhandener Schrittmotortreiber nicht geprüft. Für Versuche - mit Untersetzungen nutzbar; keine bestätigte Eignung für die Grabrotation - oder Übertragung des Drehmoments eines starken 230-V-Motors. - Herstellerseite am 14. Oktober geprüft: Bausatz 224105 enthält Motor R20, - 1,5–4,5 V, Stromverbrauch ca. 350–500 mA; Zahnräder Modul 0,5 ermöglichen - Untersetzungen von 5:1 bis 28125:1. Drehmoment und zulässige Dauerlast - werden nicht genannt. Zuordnung des fotografierten Einzelmotors zum - ursprünglichen Bausatz bleibt eine Annahme. Kein direkter Anschluss an - das auf 6 V eingestellte Servonetzteil. -- Kameraauswertung soll Hinweise auf kleine Kinder berücksichtigen. -- Der volle Schreck soll nach Auslösung automatisch ablaufen, ohne - Freigabe für jeden einzelnen Besucher. -- „Voller Schreck“ ist der gewünschte Standardmodus; der Modus soll später - in der WebUI auf Sanft, Atmosphäre oder Aus umstellbar sein. -- Liveansicht sowie Speicherung von Fotos und Videoclips auf dem Laptop. -- Neuer Wunsch nach Personenprofilen und automatischer Zuordnung - wiederkehrender Besucher per Kamera. Die bisherige Einschränkung auf - manuelles Vergleichen wurde damit geändert; Erkennungsverfahren und - Zuverlässigkeit am echten Kamerabild sind noch offen. -- Unbekannte Besucher sollen automatisch neue Profile erhalten. Automatisch - erzeugte Profile und Gesichtsmerkmale sollen 30 Tage nach der letzten - Sichtung gelöscht werden; der Kamerateil ist noch nicht implementiert. - -## Erkenntnisse aus dem Referenzvideo - -Im etwa 17,5 Sekunden langen Clip ist ein rechteckiger Holzrahmen zu sehen. -Eine längs durch den Rahmen geführte Stange trägt mehrere versetzt befestigte -Kunststoffflaschen. Ihre Lage ändert sich im Video; sie wirken wie leichte -Exzenter, die die Abdeckung an mehreren Stellen unterschiedlich anheben. -Anschließend wird eine dunkle Folie über den Rahmen gelegt. Im fertigen -Aufbau liegen darüber ein grobes helles Gewebe und Blätter; blaue Beleuchtung -verstärkt den Eindruck. Die Abdeckung wölbt sich örtlich, statt als starre -Fläche insgesamt hochzufahren. - -Das Video belegt weder Antriebsmodell noch Drehmoment, genaue Abmessungen, -Hub oder wetterfeste Ausführung. Die Flaschen sind eine Anschauung für das -Bewegungsprinzip, noch keine Festlegung für unseren Bau. Die Kombination aus -Folie, Gewebe und Blättern ist nun als gewünschter Aufbau festgehalten. - -**Vom Nutzer gefundene Bauanleitung, 13. Oktober 2026:** -[Breathing Grave DIY Halloween Prop CHEAP](https://www.youtube.com/watch?v=L113yCMObY4&t=8s), -Kanal Digital Decor Store, veröffentlicht am 21. Februar 2022. Titel, -Beschreibung und verlinktes Motorangebot direkt geprüft; mehrere -YouTube-Storyboard-Bildtafeln betrachtet, keine vollständige Videowiedergabe. -Die Bilder zeigen eine längs durch einen Holzrahmen geführte Welle mit -versetzten Kunststoffflaschen und dünner dunkler Abdeckung; passend zum -bekannten Prinzip. Aufbau wirkt kompakt, exakte Abmessungen sind nicht -verifiziert. Ob dieser Upload die Erstveröffentlichung des lokalen -Referenzclips ist, bleibt offen; bisherige Facebook-Quellenangabe beibehalten. - -Der Motor-Kurzlink aus der Beschreibung löst aktuell auf -[Amazon.com, ASIN B07NNRP7VX](https://www.amazon.com/dp/B07NNRP7VX) auf. -Angebot: Vier Synchronmotoren, 100–127 V AC, 50/60 Hz, 4 W, 2,5–3 U/min, -7-mm-Welle, mit Kupplungen; kein belastbares Drehmoment angegeben. -Keine Kaufempfehlung für die 12-V-Pi-Anlage und kein direkter Anschluss -an 230 V oder den DC-Treiber L298N. Produktdaten sind Angebotsangaben, -keine Prüfung des tatsächlich im Video verwendeten Exemplars. -Kleine Bauform und 4-W-Angabe belegen weder, dass der GM27 genügt, noch -dass 3 Nm nötig sind. E192 bleibt die auf Nutzerwunsch recherchierte -Reserveoption. Originaldrehzahl 2,5–3 U/min entspricht 20–24 Sekunden -pro Umdrehung; langsame Wirkung bei unserem Prototyp ebenfalls bewerten. - -**Gestaltungsvorschlag für mehr Realismus:** Matte, unregelmäßig fallende -Gewebeschicht statt sichtbarer glänzender Folie; unterschiedlich dichte -Laubstellen und eine flache, nicht symmetrische Kontur. Rahmenkanten, -Antrieb und Kabel werden aus Besucherperspektive verdeckt. Grabmarkierung, -Licht und Geräusche sollen die Illusion unterstützen, ohne die bewegte -Fläche unnötig zu beschweren. Materialoptik bei Tageslicht und bei der -geplanten Nachtbeleuchtung prüfen; lose Blätter und nasses Gewebe gehören -zum Belastungstest. - -## Motorkandidaten für die Atemrotation - -Recherche vom 13. Oktober 2026; unverbindliche Vorschläge für die längs -gelagerte Stange mit versetzten Hebepunkten. Kein Motor ausgewählt oder -am Aufbau getestet. Gewicht, Hebelradius und erforderliches Drehmoment -bleiben offen; insbesondere die nasse Abdeckung berücksichtigen. - -Nutzerpräzisierung: Der Rotationsantrieb dreht lediglich eine Stange mit -leeren Kunststoffflaschen. Neue Maße laut Nutzer: ca. 1,5 m Stangenlänge, -zwei Flaschen, waagerechte Welle. Für diesen leichten Aufbau war die bisherige -Bevorzugung des Phidgets zu großzügig angesetzt. Bisheriger Vorschlag war -ein Versuch mit einem kleinen, langsam laufenden Getriebemotor, bei -Reichelt dem Micro Motors L149.12.392. Nutzer wünscht nun ausdrücklich -einen kräftigeren Rotationsmotor: Neuer bevorzugter Reservekandidat ist -der Reichelt GM40,5 625 12V / E192.12.625, siehe unten. Nicht gekauft -oder am Grab getestet. Pollin- und Phidgets-Verweise unten sind historische -Recherche; aktuelle Kaufvorschläge bleiben auf Reichelt/Amazon beschränkt. -Die Flaschen drücken weiterhin gegen die Abdeckung; deren -Widerstand und die Lagerreibung entscheiden über die tatsächliche Last. - -**Erneute Drehmomentbewertung, 13. Oktober 2026:** Nutzer bezweifelt die -Eignung des GM27 392 und zitiert eine fremde KI-Antwort, die 1,5 m als -Drehmomenthebel und die 20 Ncm als Haltedrehmoment behandelt. Für die im -Referenzaufbau vorgesehene Rotation um die Stangenlängsachse ist beides -unzutreffend: Maßgeblich ist der wirksame Abstand zur Drehachse, nicht -die Länge entlang der Achse. Hersteller nennt 20 Ncm Nenndrehmoment -bei laufendem Motor (12 V: 5 U/min), nicht Haltedrehmoment. -[Drehmomentdefinition](https://openstax.org/books/university-physics-volume-1/pages/10-6-torque) -und Herstellerblatt unten aktuell geprüft. - -Eine Eignungszusage folgt daraus nicht. Unwucht, Anfahrbeschleunigung, -Lagerreibung sowie Folie, Gewebe und insbesondere nasse Blätter bleiben -die zu erfassenden Lasten. Noch offen: Stangengewicht/-durchmesser, -Flaschengewicht, radialer Versatz, Kontaktgeometrie und Abdeckungswiderstand. -Beispielrechnung für tangentiale Kraft, noch ohne Reibung oder Reserve: -0,20 Nm entsprechen bei 5 cm Radius insgesamt 4 N (Gewichtskraft ca. -408 g), bei 10 cm insgesamt 2 N (ca. 204 g); nicht je Flasche. - -Vorgeschlagener Lasttest vor endgültiger Motorwahl: Stange separat -gelagert und vom Motor getrennt, komplette Abdeckung auflegen; mit -einem 10-cm-Hebel und Feder-/Zugwaage tangential langsam durch eine -volle Umdrehung ziehen, Losbrechmoment und größte Kraft erfassen. -Auch ungünstige/nasse Abdeckung prüfen. Drehmoment = Zugkraft in N -mal 0,10 m; zeigt die Waage kg an, erst mit 9,81 in N umrechnen. -Dieser Test erfasst den langsamen Widerstand, nicht die dynamische -Anfahrreserve. Als vorläufigen Auslegungsvorschlag 2–3-fache Reserve -gegenüber dem größten gemessenen Moment prüfen, keine allgemeine Norm. -Noch kein Test durchgeführt; Bedarf für stärkeren Motor nicht gemessen. - -**Stärkerer Rotationsmotor auf Nutzerwunsch, 13. Oktober 2026:** -- [Reichelt GM40,5 625 12V / Micro Motors E192.12.625](https://www.reichelt.de/de/de/shop/produkt/getriebemotor_40_5_mm_625_1_12_v_dc-159655): - 12 V, 3 Nm (300 Ncm) Nenndrehmoment, 6 U/min bei Nennlast, - 6,4 U/min im Leerlauf, 0,46 A bei Nennlast. 15-faches Nenndrehmoment - gegenüber dem GM27 392 bei ähnlicher Drehzahl. Welle 8 mm, - Gehäuse ø 40,5 × 107 mm, Anschluss Lötfahnen. Händler nennt - 77,95 Euro inkl. MwSt. zzgl. Versand und ab Lager, 1–2 Werktage. - [Aktuelles Herstellerdatenblatt](https://www.micromotors.eu/wp-content/uploads/2019/11/E192.pdf) - bestätigt die Leistungswerte; keine Anlauf-/Blockierstromangabe darin. - Vorschlag mit deutlicher Reserve, kein Lastnachweis für die Abdeckung. - Stange separat lagern, passende Kupplung/Riemenanbindung für 8 mm - statt der bisherigen 4 mm planen. PWM über geeigneten Treiber möglich; - genaue Drehzahl ohne Rückmeldung nicht garantiert. -- [Reichelt GM39,6 500 12V / RH158.12.510](https://www.reichelt.de/de/de/shop/produkt/getriebemotor_39_6_mm_500_1_12_v_dc-159648): - günstigere Alternative, 1 Nm, 10,5–12 U/min, 0,30 A, 6-mm-Welle, - 35,35 Euro inkl. MwSt. zzgl. Versand. Direkt geöffnete Produktseite - nennt erst 13. November 2026 als erwarteten Liefertermin; passt damit - derzeit nicht zur Beschaffung für Halloween. Ältere Suchergebnisse - nennen Lagerbestand und sind durch diese aktuelle Angabe überholt. - -JOY-IT L298N: 0,46 A Nennstrom liegt unter der 2-A-Chipgrenze je Kanal; -das bestätigt keine Eignung bei Anlauf oder Blockade. Treiberpassung, -Verlustwärme und motorbezogene Strombegrenzung vor Anschluss prüfen. -Netzteil noch nicht neu dimensioniert; Nennstrom ersetzt keine Spitzenlast. - -- **Phidgets 3268_1:** 12 V, 18 U/min bei Nennlast, 17,3 kg·cm - (ca. 1,70 Nm) Nenndrehmoment, 8-mm-Welle, 0,65 A Nennstrom und - 2,4 A Blockierstrom. Reserveoption, falls der Versuch mit einem kleinen - Motor zeigt, dass mehr Drehmoment erforderlich ist. - [Herstellerdaten](https://www.phidgets.com/?prodid=295). -- **Pololu 4756, 131:1 mit Encoder:** 12 V, 76 U/min im Leerlauf, - 6-mm-Welle. Herstellergrenze für kontinuierliche Getriebelast: - 10 kg·cm (ca. 0,98 Nm); die beworbenen 45 kg·cm sind extrapoliertes - Blockiermoment. Zusätzliche Untersetzung sinnvoll, z. B. 5:1 als - Ausgangspunkt; Encoder ermöglicht eine Drehzahlregelung. Encoderpegel - vor Pi-Anschluss anpassen. - [Herstellerdaten](https://www.pololu.com/product/4756). -- **Pollin TDY-12, Artikel 310753:** 12 V, 8 U/min, 7-mm-Welle. - Alternativer Kandidat für den leichten Rotationsprototyp; Händler nennt - kein Drehmoment, daher Eignung mit der tatsächlichen Abdeckung prüfen. - [Händlerdaten](https://www.pollin.de/p/gleichstrom-getriebemotor-tdy-12-12-v-0-18-a-8-u-min-310753). -- **Micro Motors L149.12.392, Reichelt GM27 392 12V:** 12 V, - 7 U/min ohne Last und 5 U/min bei 20 Ncm (0,20 Nm) Nenndrehmoment, - ca. 55 g und 4-mm-Welle. Daten aus dem - [Herstellerdatenblatt](https://www.micromotors.eu/wp-content/uploads/2019/09/L149-1.pdf). - [Reichelt-Produkt](https://www.reichelt.de/de/de/shop/produkt/getriebemotor_27_mm_392_1_12_v_dc-159637): - bei Recherche 25,65 Euro, ab Lager. Versuchskandidat, keine bestätigte - Eignung unter der tatsächlichen Grababdeckung. Stange separat lagern. - -Vorgeschlagener Startbereich: 5–15 U/min an der Grabstange, anschließend -Wirkung prüfen. Bei einem Hub pro Umdrehung entspricht das 4–12 Sekunden -pro Zyklus. Stange separat lagern; Motor über geeignete Kupplung oder -Riemen antreiben. Separate Versorgung, geeigneter Motortreiber mit -Strombegrenzung, lokaler Stopp und geschützter Einbau bleiben erforderlich. -Der schnelle Schreckimpuls wird weiterhin separat ausgelegt. - -**Versorgung, 13. Oktober 2026:** Nutzer bestätigt, dass 12 V über ein -Netzteil möglich sind. Gewünschter Umfang: zwei Motoren, einer für die -Hand und einer für die Atemrotation, sowie Versorgung des Raspberry Pi. -Nutzerzuordnung: GM27 392 12V für die Rotation, die schnellere Variante -für die Hand. Die vorherige Abbildung mit 8 Ncm und 18–30 U/min passt -zum **GM27 90 12V / L149.12.90**: -[Reichelt-Produkt](https://www.reichelt.de/de/de/shop/produkt/getriebemotor_27_mm_90_1_12_v_dc-159631). -Beide sind gewünschte Kandidaten, keine bestätigten Käufe oder geprüften -Antriebe. Der Handmotor hat 0,08 Nm Nenndrehmoment und benötigt eine -geeignete Hubmechanik; Gewicht, Hub und gewünschte Hubzeit bleiben offen. -Beispiel: Bei einem wirksamen Hebelarm von 5 cm entsprechen 0,08 Nm -rechnerisch rund 1,6 N bzw. der Gewichtskraft von 163 g, ohne Reibung, -Beschleunigung oder Reserve. Die höhere Drehzahl allein belegt deshalb -keine Eignung für den Handimpuls. Endlagen weiterhin separat begrenzen. -Beide Motoren sind bei Reichelt mit 0,05 A Bemessungsstrom angegeben; -zusammen ca. 0,10 A bei Nennlast, kein Anlauf-/Blockierstromnachweis. - -### Handimpuls durch Gummizug – neuer Mechanikvorschlag - -**Stärkere Handantriebe und langsamere Bewegung, 13. Oktober 2026:** -Nutzer akzeptiert langsames Hervorkommen als gruseligen Effekt, fragt -nach einem etwas stärkeren Antrieb und erlaubt ausdrücklich auch 230 V -für die Hand. Schneller Federimpuls ist damit keine notwendige Vorgabe; -direkte, kontrollierte Bewegung zuerst bewerten. Noch keine Bestellung. - -**Neuer Ersatzkandidat vom Nutzer:** ZHITING DS3235 ist laut Nutzer -nicht lieferbar; SAIA-230-V-Kandidat ist ihm zu teuer. Beide werden -für die aktuelle Auswahl zurückgestellt. Nutzer verlinkt stattdessen -[Miuzei 35kg RC Servo 270°, ASIN B0CBBS6KX6](https://www.amazon.de/dp/B0CBBS6KX6). -Direkt gelesene Amazon-Produktbeschreibung nennt Positionssteuerung -0–270° über 500–2500 µs, 4,8–8,4 V, Metallgetriebe und 25T-Servoarm. -Das ist kein kontinuierlich rotierender 360°-Servo; die beschriebene -360°-Drehbarkeit bei ausgeschaltetem Strom ist davon zu unterscheiden. -Beworbenes Blockiermoment: 29,5 kgf·cm bei 4,8 V (ca. 2,89 Nm), -38 kgf·cm bei 8,4 V (ca. 3,73 Nm). Anbieter nennt 2200–3200 mA, -ohne genaue Kennzeichnung als Betriebs-/Blockierstrom, und -0,13–0,10 s/60°. Diese Leistungswerte nicht als Dauerlast oder -garantierte kombinierte Last/Geschwindigkeit verwenden; keine eigene -Messung oder unabhängige Herstellerbestätigung. Preis und Versand -sind beim Abruf nicht zuverlässig sichtbar. - -Für die befestigte Hand funktional ein stärkerer Servo-Versuchskandidat. -PWM-Winkelzuordnung von vorher 180° auf 270° anpassen und auf den -tatsächlich erlaubten Armweg begrenzen; mechanische Befestigung prüfen. -Separate Versorgung innerhalb des Spannungsbereichs mit Spitzenstromreserve -vorsehen, nicht über Pi-GPIO speisen. Kein L298N erforderlich. -Noch keine Kaufbestätigung, Hand-/Hebellast und Außentauglichkeit nicht -am realen Gerät geprüft. Der neue Kandidat ist keine Geschossanwendung. - -- [Amazon ZHITING DS3235, 180°, ASIN B08MQK5FCQ](https://www.amazon.de/dp/B08MQK5FCQ): - Produktbeschreibung direkt gelesen. Anbieter nennt 5–7,4 V, - maximales Moment 32 kgf·cm bei 6 V (ca. 3,14 Nm), 35 kgf·cm - bei 7,4 V (ca. 3,43 Nm), 180° PWM-Positionierung und 25T-Abtrieb. - Bei gleicher 6-V-Versorgung etwa 75 % höherer beworbener Maximalwert - als COM-MOTOR02. Keine kontinuierliche Lastzusage oder eigene Messung; - die Varianten unter DS3235 unterscheiden sich. Preis/Lieferbarkeit - des Hauptartikels aktuell nicht zuverlässig auslesbar; angezeigte - 24,99 Euro gehören zu einem anderen empfohlenen Artikel und dürfen - nicht übernommen werden. Blockierstrom dieser konkreten Ausführung - fehlt; separate Versorgung entsprechend erst nach Prüfung auswählen. -- [Reichelt SAIA UFR1ND5F25CNNN, Artikel 360900](https://www.reichelt.de/de/de/shop/produkt/synchrongetriebemotor_230_v_50_hz_3_3w_2_u_min_4_nm-360900): - reversierbarer 230-V-/50-Hz-Getriebemotor, 3,3 W, 2 U/min. Shop-Titel - nennt 4 Nm, Datentabelle dagegen 40 Ncm = 0,4 Nm. Preisrecherche - liefert je Abruf 142,95 bzw. 149,35 Euro inkl. MwSt. zzgl. Versand, - jeweils ab Lager; Preis vor Bestellung prüfen. - [Herstellerkatalog, gedruckte S. 98](https://www.johnsonelectric.com/pub/media/wysiwyg/je/je_pdf/saia_motor_catalog.pdf): - UFR1 mit UGF-Untersetzung 250 ergibt laut Momentkurve ungefähr 4 Nm; - Diagramm visuell geprüft. Das macht die Titelangabe plausibel, ist - aber kein spezifisches Typenblatt für die Bestellnummer. Das Diagramm - unterscheidet außerdem erreichbares Motormoment und niedrigere - Lebensdauergrenzen im Dauerbetrieb. Widerspruch vor Kauf klären, - nicht als pauschal freigegebenen 4-Nm-Dauerantrieb darstellen. - Bei 2 U/min benötigt die Welle rechnerisch 7,5 s für 90°. - Keine eingebaute Positionssteuerung: verriegelte Richtungssteuerung, - Endlagenabschaltung und lokale Abschaltung erforderlich. UFR-Serie - IP40 laut Hersteller, deshalb kein ungeschützter Außeneinsatz. - -Vergleichsvorschlag: Für den kleinen Schwenkarm zunächst stärkeren -Modellbauservo bewerten; 230-V-Kandidat ermöglicht langsame Bewegung, -kostet aber rund 150 Euro zuzüglich Steuer-/Anschlussmaterial und -besitzt ungeklärte Artikelangaben. Keine Last-/Passungsprüfung möglich, -solange Handgewicht und Hebelgeometrie fehlen. Keine zusätzliche -Geschossmechanik auslegen. - -**Schwenkarm und günstiger Servo, 13. Oktober 2026:** Nutzer zeigt einen -katapultartigen Hebel und verlinkt -[Industrial servo marble catapult](https://www.youtube.com/watch?v=IF78MpQqejI). -Direkt gelesene Videobeschreibung: Matthias random stuff, Industrieservo -von DMM. Mehrere Storyboard-Bildtafeln zeigen Motor, Schwenkhebel und -Industrieservotreiber; vollständiger Bewegungsablauf nicht abgespielt, -Gummizugfunktion und genaue Motorleistung nicht sicher bestimmt. -Die zuvor beschriebene langsame Feder-Spann-/Freigabemechanik war unser -Alternativvorschlag, keine verifizierte Beschreibung dieses Videos. - -Für die fest befestigte Halloween-Hand ist ein direkt servoangetriebener -Schwenkhebel eine weitere Option. Günstiger Versuchskandidat: -[Reichelt GRABIT SERVO / JOY-IT COM-MOTOR02](https://www.reichelt.de/de/de/shop/produkt/pwm_servomotor_bis_20_kg_cm_-_ersatzservo_fuer_grab-it_roboterarm-219023), -12,40 Euro inkl. MwSt. zzgl. Versand, ab Lager laut direkter Produktseite. -[Herstellerblatt bei Reichelt](https://cdn-reichelt.de/documents/datenblatt/A300/COM-MOTOR02-DATENBLATT.pdf): -5–7,4 V, 180° Stellbereich; bei 6 V 0,16 s/60° ohne Last, -maximale Drehmomentangabe 18,3 kgf·cm (ca. 1,79 Nm), Blockierstrom -1,8 A. Maximales Moment nicht als kontinuierlich nutzbares Moment -oder gleichzeitig mit Leerlaufgeschwindigkeit behandeln. Reale -Handmasse, Hebelarm, Abdeckungswiderstand und Beschleunigung fehlen. -Separate passende Servo-Versorgung und PWM-Steuersignal nötig; -kein L298N als Servosteller, kein direktes 12-/230-V-Anschließen. - -Noch keine Kauf-/Bauentscheidung oder Hardwareprüfung. Separat gelagerte -Armachse, befestigte leichte Hand, begrenzter Bewegungsbogen und -gedämpfte Anschläge für eine Animationsmechanik bewerten. Keine -Geschossfunktion aus der Videovorlage übernehmen. Zuerst direkte -Servobewegung mit geringer Geschwindigkeit testen; eine Federlösung -erst beurteilen, falls der direkte Handhub die Anforderungen nicht erfüllt. - -**Zusätzliche Nutzeridee, 13. Oktober 2026:** Elektrischer Linearantrieb -für die Hand. Noch kein Modell gewählt; Handgewicht, Hub und Hubzeit -fehlen weiterhin. Gemeint sein könnte ein motorischer Spindelaktuator; -einen echten elektromagnetischen Linearmotor nicht ohne Klärung annehmen. -Direkten Hub und Rückspannen eines separaten Federhubs unterscheiden. -[Herstellerbeispiel L12](https://www.actuonix.com/l12-100-50-6-s) -zeigt 25 mm/s maximale Leerlaufgeschwindigkeit: 100 mm Hub brauchen -schon ohne Last mindestens vier Sekunden. Das ist eine technische -Geschwindigkeitsreferenz, kein Kaufvorschlag außerhalb Amazon/Reichelt. -Ein selbsthemmender Spindelaktuator darf einen gewünschten schnellen -Federhub nicht blockieren; Entkopplung, sichere Haltefunktion und -geführte Bewegung müssen dafür gesondert ausgelegt werden. - -Nutzer erwähnt außerdem eine separate spätere Idee, kleine Metallkugeln -mit Federenergie zu verschießen. Kein Auftrag für Bau oder Auslegung -dieses Projekts; keine Wurf-/Schussfunktion für das Halloween-Grab. -Handmechanik und dieses separate Vorhaben nicht miteinander gleichsetzen. - -Nutzeridee: Gummizug beschleunigt die leichte Hand nach oben; ein langsamer -Motor zieht sie wieder ein und spannt den Gummizug. Noch keine gebaute -Mechanik oder ausgewählte Kupplung. Der GM27 90 ist damit vorerst nur -der Kandidat des vorherigen direkten Motorhubs. Ob der langsamere GM27 -392 für das Rückspannen reicht, muss anhand der maximalen Zugkraft, -Trommelgröße und gewünschten Rückstellzeit geprüft werden. Der Gummizug -verlagert den schnellen Energieabruf, reduziert aber nicht automatisch -das zum Spannen erforderliche Drehmoment. - -Nutzer weist ausdrücklich auf notwendige Entkopplung hin. Vorgeschlagene -Lösung mit mechanischer Verriegelung und vorab freigegebener Zugschnur: -1. Hand auf geführtem Schlitten; Gummizug wirkt nach oben innerhalb des Grabs. -2. Motor zieht über eine Seiltrommel die Hand nach unten, bis ein - mechanischer Riegel einrastet und die Gummizugkraft selbst übernimmt. -3. Erst nach bestätigter Verriegelung wickelt der Motor genug Schnur für - den vollständigen Hub wieder ab. Schnurreserve geführt aufnehmen, - Verfangen und Abrutschen von der Trommel verhindern. -4. Ein kleiner zusätzlicher Auslöser, beispielsweise ein Servo, öffnet den - Riegel beim gültigen Trigger. Hand springt hoch, ohne den Getriebemotor - rückwärts antreiben zu müssen. Oberer Anschlag mit Dämpfung begrenzt - den Hub; Hand bleibt befestigt und innerhalb des erlaubten Bereichs. -5. Nach freiem Bereich langsam zurückziehen und erneut verriegeln. - -Damit bleibt die Zahl der Antriebsmotoren zwei, aber ein zusätzlicher -Entriegelungsaktor wäre nötig und ist noch nicht abgestimmt/ausgewählt. -Eine aktiv schaltbare Kupplung an der Seiltrommel ist eine Alternative; -eine einfache Freilaufkupplung ist nicht ohne Prüfung der Wickel-/ -Abwickelrichtungen gleichwertig. Im ersten Vorschlag ist keine separate -Kupplung notwendig, wenn die gesamte Hubstrecke bereits Schnurreserve hat. - -Sicherheitsanforderung für diese Variante: Verriegelung hält bei Stromausfall -mechanisch; der Servo trägt nicht die Gummizuglast. Motor-Stopp entlädt -den gespannten Gummizug nicht. Für Wartung kontrollierte Entspannung/ -mechanische Sicherung vorsehen; nach Entriegelung ist ein bereits -gestarteter Gummihub nicht per Motor-Stopp sofort anzuhalten. Kleine -gespeicherte Energie, gekapselte Mechanik, gedämpfter Anschlag und sichere -Endlagen/Verriegelungsprüfung müssen vor Bau konkret ausgelegt werden. - -**Schnurführung und Druckvorlagen, 13. Oktober 2026:** Nutzer benennt -Verheddern der vorab abgewickelten Schnur als Problem und fragt nach -vorhandenen 3D-Druckzeichnungen. Die Schnurreserve-Variante oben bleibt -ohne konkret ausgelegte Aufnahme ungeklärt. Eine seitlich geführte -Trommel allein nimmt keine lose Hubreserve auf; für eine Seilvariante -müssen Schnurspannung, Aufnahme, Trommelüberlauf und Kupplung gemeinsam -ausgelegt werden. - -Recherchierte Grundlagen, keine vollständige oder getestete Handmechanik: -- [Roger Frouds Kurvenscheiben-/Federmechanik](https://www.model-engineer.co.uk/723909/3d-printed-parts-for-roger-frouds-cutting-oil-delivery-system/): - Originalveröffentlichung beschreibt eine motorgetriebene Schneckenkurve - mit Feder für eine Ölpumpe und bietet `Oil-Delivery-Parts.zip` mit STL - und STP an. Vorschlag für das Grab: Über einen passend angeordneten - Hebel langsam nach unten spannen, an der Abfallkante freigeben. - Dadurch entfiele die Schnurtrommel. Führung, Hebel, Hub, Motoraufnahme - und eine stromlos haltende Verriegelung müssten neu ausgelegt werden; - die Pumpendateien sind keine direkt passende Handkonstruktion. -- [CLARK-3, gedruckte Winde mit Seilführung](https://pmc.ncbi.nlm.nih.gov/articles/PMC11982481/): - Primärveröffentlichung mit Montageanleitung und STEP-Dateien; - [Dateirepository](https://doi.org/10.17632/pd67rx3k5y.1). - Seitlich laufende Seilführung ordnet die Wicklungen. Umfangreiche - Drohnenwinde, für die leichte Hand eher Konstruktionsreferenz. - Kein Nachweis einer passenden Schnellfreigabe/Kupplung; die Führung - ersetzt keine Aufnahme loser Schnur. Autoren druckten eine - Führungsspindel mit SLA, übrige Teile überwiegend mit FDM. - -Quellenbeschreibungen recherchiert; Dateien nicht heruntergeladen, -CAD-Passung und Druckbarkeit am Nutzergerät nicht geprüft. Für eine -maßgerechte Anpassung fehlen Handgewicht, Hub, verfügbarer Bauraum, -Gummizugkraft und Druckerdaten. Noch keine Mechanik ausgewählt. - -### Versorgungskandidaten - -**Versorgung für Miuzei 35kg/270°:** Vorschlag für einen Servo ist ein -stabilisiertes 6-V-DC-Netzteil mit mindestens 5 A Stromkapazität -(30 W bei 6 V). Das ist Reserve gegenüber den beworbenen 2,2–3,2 A, -keine bestätigte Blockierstrommessung. Spannung am Servo unter realer -Last und Netzteilverhalten bei Anlauf prüfen. Pi bleibt am eigenen -USB-C-Netzteil; 6 V nicht auf dessen Versorgungsanschluss geben. - -Technisch passendes Beispiel: Poppstar 1010329, 6/7,5/9/12 V mit 5 A, -zunächst auf 6 V stellen. Daten in -[Anleitung auf Amazon-CDN](https://m.media-amazon.com/images/I/A1ltMcW-dxL.pdf) -und Herstellerseite geprüft. Geschlossenes Universalnetzteil mit -Wechselsteckern, unter anderem 5,5/2,1 und 5,5/2,5 mm; Ausgang ist DC. -Amazon-Angebot wird von einer Vergleichsseite verlinkt, aber der genaue -Produktlink sowie aktuelle Amazon-Lieferbarkeit/Preis konnten nicht -verifiziert werden. Daher noch keine bestätigte Beschaffungsempfehlung. -Nicht den ähnlichen Poppstar 1010328 mit nur 3 A verwechseln. - -Niederspannungsanschluss: Netzteil-Plus an Servo-Versorgungsplus, -Netzteil-Minus an Servo-GND; für eine nicht galvanisch getrennte -PWM-Schnittstelle Pi-GND ebenfalls mit dieser Masse verbinden. -Pi liefert nur das Steuersignal über eine geeignete PWM-Schnittstelle; -3,3-V-Signalverträglichkeit des konkreten Servos vor Direktanschluss -prüfen. Pi-/Servo-Versorgungsplus nicht verbinden. Passender -Hohlstecker-Schraubklemmenadapter und Leitungen müssen mindestens -für den vorgesehenen Strom ausgelegt sein; Polung vor Anschluss messen. -Konkreter Adapterkandidat auf Nutzerwunsch: -[Reichelt DELOCK 65485](https://www.reichelt.de/de/de/shop/produkt/kupplung_aussen_5_5_mm_innen_2_5_mm_5_v_10_a-225379), -DC-Buchse 5,5/2,5 mm auf zweipoligen Schraub-Terminalblock, 1,99 Euro -inkl. MwSt. zzgl. Versand, versandfertig in 3–5 Werktagen laut Shop. -Reichelt nennt 10 A; das verlinkte ältere Herstellerblatt bestätigt -Geometrie, Anschluss und Polung, enthält aber keinen Stromwert. -Keine eigene Belastungsprüfung; für die geplante 6-V-/5-A-Versorgung -als Kandidat anhand der Händlerangabe verwenden. Am Poppstar den -mitgelieferten 5,5/2,5-mm-Stecker einsetzen, nicht 5,5/2,1 mm. -Adapterbelegung innen Plus, außen Minus; tatsächliche Netzteilpolung -vor Anschluss prüfen. Zweipoliger Adapter führt nur die Versorgung, -Servosignal bleibt eine separate Verbindung. DELOCK 65421 nicht als -gleichwertig einsetzen: Reichelt nennt dort lediglich 2 A. -Netzteil wettergeschützt, Leitungen kurz und mit geringem Spannungsabfall. -Servo-Steckerbelegung am tatsächlichen Exemplar prüfen, keine alleinige -Farbanahme. Keine echte Anschluss-/Lastprüfung erfolgt. - -**Neue Kosten-/Steuerungsalternative, 13. Oktober 2026:** Nutzer schlägt -Motoren an Netzspannung mit Relaissteuerung vor, um große 12-V-Netzteile -und Motortreiber zu sparen. Für die Atemrotation mit fester Drehzahl -ist ein geeigneter 230-V-Synchrongetriebemotor mit Ein/Aus-Steuerung -eine Alternative. Nutzer hat inzwischen 230 V mit Relais als vorgesehene -Richtung für die Rotation bestätigt; konkreter Motor und Relais fehlen. -Handantrieb und dessen Spannung bleiben offen. Noch kein -Nachweis einer Gesamtersparnis. Der zuvor verlinkte Amazon-US-Motor -ist 100–127 V AC, keine passende 230-V-Ausführung. - -Pi steuert ein geeignetes Relaismodul mit 3,3-V-kompatiblem Eingang; -Relaiskontakte schalten die davon getrennte Motorversorgung. Kontakte -für Motorlast und Einschaltstrom auswählen; aufgedruckte Stromwerte -für ohmsche Last sind kein Motorlastnachweis. -[Herstellerhinweise zur Relaisauswahl](https://www.ia.omron.com/product/cautions/36/safety_precautions.html). -Relaissteuerung liefert Ein/Aus, keine PWM-Drehzahlregelung eines -Synchronmotors. Drehrichtung/Anlaufverhalten der konkreten Ausführung -vor Auswahl prüfen. Handrückstellung, Entriegelung und Endlagen separat -auslegen; Pi-Versorgung bleibt erforderlich. - -Die früheren 8,5–15-A-Netzteile sind Reservekandidaten, kein gemessener -Bedarf. Auch die 12-V-Option mit E192 benötigt bei Nennlast nur 0,46 A -für diesen Motor; notwendige Versorgung nach Anlauf-/Blockierströmen -und übrigen Lasten bestimmen. Ein kleineres Netzteil kann die 12-V- -Variante ebenfalls günstiger machen. Beide Varianten erst anhand -Motor, Schaltung, Gehäuse und Zubehör als komplette Stückliste vergleichen. -Bei 230 V am Aufbau: Netzspannungsseite geschlossen, berührungsgeschützt, -für Außeneinsatz ausgelegt und durch eine Elektrofachkraft aufgebaut/ -geprüft; Stromversorgung und lokaler Stopp in den Schutzplan aufnehmen. - -Versorgungskandidaten, noch nicht gekauft oder praktisch geprüft. -Die bisherige 8,5-A-Auslegung ist eine Reserveoption für den stärkeren -Rotationsmotor, keine festgestellte Anforderung der leeren Flaschen. -Ein kleineres Netzteil anhand der vorgesehenen GM27-Motoren neu bewerten; -Handmechanik und Stromspitzen sind weiterhin offen. - -**Bezugsquellen und Reservewunsch, 13. Oktober 2026:** Nutzer beschränkt -Produktvorschläge und Kaufverweise auf Amazon oder Reichelt; -höhere Stromkapazität bei Bedarf ist ausdrücklich -gewünscht. Verifizierte Reichelt-Produkte, Preise inkl. MwSt. zzgl. Versand -als Momentaufnahme, keine Bestellung: - -- [GST160A12-R7B, 12 V / 11,5 A / 138 W](https://www.reichelt.de/de/de/shop/produkt/tischnetzteil_140_w_12_v_11_5_a-171071): - 60,20 Euro, versandfertig in 3–5 Werktagen. Kandidat mit großzügiger - Reserve; Bedarf nach Auswahl des Handmotors endgültig berechnen. -- [GST220A12-R7B, 12 V / 15 A / 180 W](https://www.reichelt.de/de/de/shop/produkt/tischnetzteil_180_w_12_v_15_a-171076): - 82,95 Euro, ab Lager. Stärkere Alternative, derzeit kein belegter Bedarf. -- [GST90A12-P1M, 12 V / 6,67 A / 80 W](https://www.reichelt.de/de/de/shop/produkt/tischnetzteil_80_w_12_v_6_67_a-171058): - 30,40 Euro laut Händlerangebot. Günstigere Option, falls die Summe der - Motor-Anlaufströme samt Reserve darunter bleibt. -- [Offizielles Pi-5-Netzteil, RPI PS 27W WT EU](https://www.reichelt.de/de/de/shop/produkt/raspberry_pi_-_netzteil_5_1_v_5_0_a_usb_type-c_eu-stecker_w-360112): - 11,10 Euro, ab Lager. -- Das bisherige GST120A12-R7B ist bei Reichelt bei Recherche ausverkauft. - -Die R7B-Netzteile brauchen ein geeignetes 4-poliges Gegenstück sowie ein -separates Kaltgeräte-Netzkabel. Stromkapazität des Netzteils ersetzt keine -passend bemessenen Abgangssicherungen und keine Motor-Strombegrenzung. - -Anschlussrecherche vom 13. Oktober 2026: -- GST120A12-R7B, GST160A12-R7B und GST220A12-R7B: festes Ausgangskabel - mit 4-poligem Power-DIN-Stecker R7B; Eingang IEC C14. -- [Reichelt MW DIO-BU4 / Kycon KPJX-4S-S](https://www.reichelt.de/de/de/shop/produkt/power-din-einbaubuchse_4-pol-162282): - passende Power-DIN-Buchse, 4,30 Euro, ab Lager. Bauteil zur - Printmontage/Lötverdrahtung, kein fertiger Schraubklemmenadapter. - [Kycon-Datenblatt bei Reichelt](https://cdn-reichelt.de/documents/datenblatt/C160/KPJX-4S-S.pdf) - nennt 7,5 A je Kontakt. Beide Kontakte je Versorgungspol gemäß - Netzteilbelegung verwenden; Leiterbahnen, Verdrahtung und Absicherung - passend zum tatsächlich benötigten Strom bemessen. Nicht mit einer - gewöhnlichen 4-poligen Mini-DIN-Signalbuchse verwechseln. - Rechnerisch ergeben 11,5 A bei gleichmäßiger Aufteilung 5,75 A pro - Kontakt je Pol. Die Verdrahtung muss diese Aufteilung ermöglichen; - eine pauschale 15-A-Freigabe für den fertigen Anschluss folgt daraus - nicht. Bei 15 A Gesamtlast wären beide Kontakte bereits mit je 7,5 A - am Grenzwert, ohne Reserve; für diese volle Last einen Anschluss mit - höherer Belastbarkeit auswählen oder die Last geringer begrenzen. -- [Reichelt NKSK 200 SW GG](https://www.reichelt.de/de/de/shop/produkt/netzkabel_schutzkontaktstecker_1_8_m_schw_c13-133143): - Schuko-auf-C13-Netzleitung als steckbare Zuleitung; separat erforderlich, - falls nicht vorhanden. Kein Öffnen des Netzteils vorgesehen. -- GST90A12-P1M (6,67 A) hat stattdessen einen Hohlstecker 5,5/2,5 mm; - GST60A12-P1J (5 A) einen Hohlstecker 5,5/2,1 mm. -- Das offizielle Pi-Netzteil besitzt ein festes USB-C-Kabel und wird direkt - am Pi 5 eingesteckt; kein zusätzlicher Ausgangsadapter erforderlich. - -- **MEAN WELL GST120A12-R7B:** geschlossenes Tisch-Netzteil mit - 12 V / 8,5 A / 102 W, laut - [Herstellerdatenblatt](https://www.meanwell.com/Upload/PDF/GST120A/GST120A-SPEC.PDF). - Vorläufige Auslegung für Phidgets 3268_1 (2,4 A Blockierstrom) plus - Handantrieb mit höchstens 4 A Anlauf-/Blockierstrom: zusammen 6,4 A, - verbleibende Reserve 2,1 A unter den Datenblattbedingungen. Die 4 A - sind eine Auswahlgrenze, kein Messwert eines vorhandenen Handmotors. - Weitere Lasten und thermische Leistungsreduzierung separat prüfen. - Netzleitung und ausreichend belastbares R7B-Gegenstück/Adapter einplanen. -- **Offizielles Raspberry Pi 27-W-USB-C-Netzteil, EU-Ausführung:** - 5,1 V / 5 A für den Pi 5, laut - [Herstellerdaten](https://www.raspberrypi.com/products/27w-power-supply/). - Separate Pi-Versorgung bevorzugt. Eine gemeinsame 12-V-Quelle würde - einen geeigneten 5,1-V-Wandler mit USB-C-Versorgung und zusätzlicher - Leistungsreserve/Entstörung erfordern; 12 V niemals direkt an den Pi. -- **JOY-IT MotoDriver2, Reichelt DEBO MOTODRIVER2:** - [Produktseite](https://www.reichelt.de/de/de/shop/produkt/entwicklerboards_-_motodriver2_l298n-202829), - bei Recherche 6,99 Euro, ab Lager. L298N für bis zu zwei DC-Motoren, - 5–35 V Motorspannung; Händler nennt 2 A, keine garantierte gemeinsame - Dauerlast beider Kanäle angenommen. Das - [L298-Datenblatt bei Reichelt](https://cdn-reichelt.de/documents/datenblatt/A200/L298.pdf) - nennt 2 A im DC-Betrieb je Motorkanal unter den absoluten Grenzwerten, - insgesamt bis zu 4 A für den Chip. Ein Kanal besteht aus zwei - Motoranschlüssen; deren Stromwerte werden für einen Motor nicht addiert. - Diese Chipgrenzen sind keine Zusicherung für den Dauerbetrieb des - fertigen Moduls bei 2 A auf beiden Kanälen. Die - [Herstelleranleitung bei Reichelt](https://cdn-reichelt.de/documents/datenblatt/X200/SBC-MOTODRIVER2-ANLEITUNG.pdf) - beschreibt PWM-Ansteuerung am Arduino sowie GPIO-Ansteuerung am Pi. - Bisher Kandidat für die GM27-Rotation; beim neuen E192.12.625 - Spitzenstrom und thermische Eignung erneut prüfen. L298N verursacht Spannungsabfall und - Verlustwärme. Handmotor-Eignung nach Anlaufstrom und thermischer Last - prüfen. Alte RPi.GPIO-Beispiele nicht als am Pi 5 getestet ausgeben. - Motorbezogene Strombegrenzung/Blockierabschaltung bleibt auszulegen. - Der zuvor genannte Cytron ist ohne bestätigtes Angebot bei den - erlaubten Händlern kein aktueller Kaufvorschlag. -- Sicherungen und Leitungen nach Motorstrom/Leitungslänge bemessen; - Endlagen der Hand hardwareseitig begrenzen, Rückfahrt ermöglichen. - Lokaler Stopp unterbricht die Motorversorgung unabhängig vom Pi; - Rücksetzen ohne selbsttätiges Wiederanlaufen vorsehen. Separate Lager, - passende Kupplung, mechanische Anschläge und Quetschschutz einplanen. - Netzteile trocken und geschützt, Treiber/Pi in geeignetem Gehäuse mit - Zugentlastung unterbringen. IR-Licht/LEDs/Audio sind nicht mitgerechnet. - -Als unverbindliche 230-V-Alternative ist die -[Oriental Motor US2-Serie](https://www.orientalmotor.com/ac-motors-gear-motors/variable-speed-motor-speed-controller-us2.html) -mit Motor, Getriebe und Drehzahlregler recherchiert. 220/230-V-Ausführungen -sind vorhanden; konkrete Leistung und Übersetzung noch nicht ausgewählt. -Die [Anleitung, S. 15](https://www.orientalmotor.com/products/pdfs/opmanuals/HM-9350-3E.pdf) -beschreibt optogekoppelte FWD/REV-Eingänge mit interner 24-V-Schaltung -für externe Relaiskontakte. Keine direkte GPIO-Verbindung; Pi-seitig -geeignete galvanisch getrennte Kontaktschnittstelle vorsehen. Diese -Eingänge dienen Start/Stopp und Richtung; die Drehzahl wird am Regler -eingestellt. Diese Alternative wird derzeit nicht weiterverfolgt; -der Nutzer hat die 12-V-Versorgung bestätigt. -Bei dieser Variante muss die Netzspannungsinstallation durch eine -Elektrofachkraft ausgeführt und für den Außeneinsatz geeignet sein. - -## Vorgeschlagene Architektur - -| Komponente | Aufgabe | -|---|---| -| Pi 5 | Kamera, Personenerkennung, spätere Profilzuordnung, WebUI, Ereignisse und Clip-Puffer | -| Pi 3 oder 4 (optional) | Effekte, Ablaufsteuerung und lokale Bedienelemente | -| Laptop | WebUI aufrufen, Livebild ansehen, Clips herunterladen | -| Treiber und separate Versorgung | Motoren, Servos und Beleuchtung versorgen | - -Der gewählte Auslöser meldet Ereignisse an die Effektsteuerung. Diese -begrenzt Sequenzdauer und Wiederholungen selbst. Bei Kommunikationsverlust -führt sie keine neuen Schreckbefehle aus. - -**Architekturentscheidung:** In Phase 3 den vollständigen Ablauf mit einem -Pi und simulierten Ausgängen prüfen. Über einen zweiten Pi nach Integration -von Kamera und WebUI entscheiden, wenn Leitungswege, Aufstellung und Last -bekannt sind. Lokaler Stopp und sichere Abschaltung -der Antriebe dürfen in keiner Variante von WebUI oder WLAN abhängen. Falls -zwei Pis nötig sind, erhält jeder Schreckbefehl eine begrenzte Gültigkeit; -nach Verbindungsverlust bleibt die Effektseite ohne neue Auslösung. - -### Software-Stack - -Der gesamte Code wird in Python umgesetzt. Vorgeschlagene Technologien: - -| Bereich | Vorschlag | -|---|---| -| WebUI / API | FastAPI mit uvicorn | -| Livebild | picamera2 oder gstreamer, je nach Test | -| Personen-/Bewegungserkennung | OpenCV, optional Raspberry Pi AI Kit / Hailo | -| GPIO / Effekte | gpiozero oder lgpio | -| Konfiguration | YAML- oder JSON-Dateien | -| Logging / Clips | Standardbibliothek `logging`, Video mit OpenCV/FFmpeg | - -### Code-Struktur - -Die Dateien bleiben, soweit sinnvoll, unter 400 Zeilen. Dabei bleibt -Wartbarkeit wichtiger als sture Einhaltung der Grenze. Vorgesehene Module: - -- `config.py` – zentrale Konfiguration und Validierung -- `camera.py` – Kamerazugriff und Capture-Puffer -- `detector.py` – Personen-/Bewegungserkennung als Auslöser -- `event_store.py` – SQLite für Testereignisse und Profilkennungen; - Clip-Speicherung und Kamera-Zuordnung folgen später -- `effects.py` – Effektablaufsteuerung, bei Bedarf auf dem Effekt-Pi -- `web.py` – FastAPI-Endpunkte und MJPEG-Livebild in der ersten Version -- `main_controller.py` – Start, Verbindung und Hauptloop auf Pi 5 -- `main_effects.py` – bei zwei Pis: Start und Überwachung auf dem Effekt-Pi -- `shared/protocol.py` – bei zwei Pis: Nachrichtenformat zwischen Pis - -Der Laptop muss für Liveansicht eingeschaltet sein. Vorgeschlagen ist ein -begrenzter Clip-Puffer auf dem Pi, damit Ereignisse auch ohne Laptop später -abrufbar bleiben. Speicherziel und Löschfrist sind noch offen. Für einen -vollen Speicher ist ein sichtbarer Fehlerzustand vorzusehen; die -Effektsteuerung darf dadurch nicht blockieren. - -Für die erste nutzbare Version genügen ein Auslösebereich, ein kurzer -Effektablauf, lokaler Stopp, Betriebsanzeige und ein abrufbarer Ereignisclip. -Hand, Display und weitere Effekte folgen erst nach bestandenem Außentest -dieses Ablaufs. - -## Kamera und Nachtbetrieb - -Das vorhandene Camera Module 3 NoIR besitzt Autofokus und keinen IR-Sperrfilter. -Mit zusätzlicher Infrarotbeleuchtung ist Nachtbetrieb möglich; ohne Beleuchtung -liefert es in völliger Dunkelheit kein brauchbares Bild. IR-Leuchte, Reichweite, -Position und Reflexionen am Schutzfenster müssen am Aufstellort getestet werden. - -Für Pi 5 wird ein Kamerakabel von Standard (15-polig, kameraseitig) auf Mini -(22-polig, Pi-seitig) benötigt. Die Pi-seitige Ausführung des vorhandenen Kabels -ist auf dem Foto nicht sichtbar und bleibt zu prüfen. Wetterschutz und Sichtfeld -sind noch offen. Personenmodelle müssen mit den tatsächlichen Nachtbildern -geprüft werden; NoIR allein garantiert keine zuverlässige Erkennung. - -Vor dem Bau am Aufstellort Sichtfeld, Dunkelbild, Gegenlicht und Reflexionen -prüfen. Dabei entscheiden, ob die Kamera zuverlässig auslösen kann oder ob -ein zusätzlicher, noch auszuwählender Sensor den Auslöseimpuls liefert und -die Kamera Bild und Clip aufzeichnet. - -Quellen: [Camera Module 3](https://www.raspberrypi.com/products/camera-module-3/) -und [Kamerakabel](https://www.raspberrypi.com/products/camera-cable/). - -## Effektablauf und Modi - -1. Ruhe: langsames Atmen, schwaches Licht, gelegentliche Geräusche. -2. Annäherung: Atmen stoppt, kurze Stille. -3. Auslösung: Kratzen, kurzer Tonimpuls, begrenzter Ruck und Licht. -4. Optional: leichte Hand hebt sich am Grabrand. -5. Rückkehr in sicheren Zustand, anschließend Sperrzeit. - -Modi: Aus, Atmosphäre, Sanft, voller Schreck und Wartung/Test. -Vorschlag: Ohne gespeicherte Auswahl ist „voller Schreck“ der Standard; -eine Änderung in der WebUI bleibt nach normalem Neustart erhalten. -Ein lokaler Stopp muss unabhängig vom Browser funktionieren. -Im Modus „voller Schreck“ startet die Sequenz nach einem gültigen Trigger -automatisch; Sperrzeit und Stopp bleiben wirksam. Nach dem Einschalten erst -Betriebsbereitschaft und einen freien Auslösebereich abwarten. Nur ein neuer -Trigger danach darf eine Sequenz starten; ein alter Zustand löst nichts aus. -Nach Stopp oder Fehler ist ein bewusstes Rücksetzen nötig. - -Vor der Umsetzung für jeden Aktor die sichere Ruhelage, mechanischen -Anschlag und maximal erlaubte Laufzeit festlegen. Ein gut erreichbarer -lokaler Schalter soll die Antriebsversorgung unabhängig vom Programm -unterbrechen können; danach darf kein selbsttätiger Neustart erfolgen. -Ein weiterer Trigger während der Sequenz löst keinen zweiten Effekt aus. -Bei unklarem Steuersignal endet die Bewegung. - -Körpergröße im Kamerabild ist nur ein unsicherer Hinweis. Abstand, Verdeckung, -Kostüme und Kinderwagen können die Auswertung verfälschen. Eine zuverlässige -automatische Altersentscheidung ist damit nicht möglich. Der Nutzer wünscht -dennoch „voller Schreck“ als Standard mit automatischer Auslösung. Im -vollen Modus können deshalb auch Kinder den vollen Effekt abbekommen. -Die WebUI bietet Sanft für entsprechende Betriebssituationen an. Ob ein -unsicherer Kamerahinweis zusätzlich automatisch auf Sanft zurückschaltet -oder nur als Hinweis angezeigt wird, bleibt vor Umsetzung zu entscheiden. -Keine Freigabe pro Besucher. - -## Weboberfläche - -- Livebild mit Auslösebereich und optional maskierten Bildbereichen. -- Moduswahl, manuelle Effektknöpfe und klar sichtbarer Stopp. -- Kamera- und Steuerungsstatus sowie letzte Auslösung. -- Ereignisliste mit Zeit, Vorschaubild, Ereignistyp und Clip. -- Bild-/Clip-Download auf den Laptop. -- Einstellungen für Lautstärke, Sperrzeit, Bereiche und Aufbewahrung. -- Lokaler Zugangsschutz; keine öffentliche Internetfreigabe vorgesehen. -- Aufnahmebereich vor dem Einsatz begrenzen und nötige Bildbereiche - maskieren; Tonaufnahme bleibt standardmäßig aus. - -## Phasen mit Abnahmekriterien - -### 1. Anforderungen und Hardware klären - -- [ ] Hardware- und Sicherheitsplan vor Bau und Aktoranschluss abschließen. -- [x] Kameramodell anhand des Fotos feststellen: Camera Module 3 NoIR. -- [ ] Pi-5-Kabel, Kamerafunktion und Nachtbild mit IR-Beleuchtung prüfen. -- [ ] Sichtfeld und mögliche Auslösung am echten Aufstellort prüfen; - Kamera- oder Zusatzsensor als Trigger festlegen. -- [ ] Pis, Display, Servos und vorhandene Netzteile konkret erfassen. -- [x] Raspberry Pi 5 anhand des Nutzerfotos identifiziert. -- [ ] RAM, tatsächlichen Hostnamen, Betriebssystem, Netzteil und Kühlung des Pi 5 prüfen. -- [ ] Die grob geschätzten 2 m² am Aufstellort als Länge × Breite und mit - Abstand zum Besucherweg nachmessen; daraus jugendlich wirkende - Grabmaße festlegen. Gewicht von Folie/Gewebe/Blättern und Hub messen. -- [ ] Aufstellort, Besucherweg, Wetterschutz, Strom und Netzverbindung klären. -- [ ] Budget, Clip-Aufbewahrung und Reaktion auf unsichere Kamerahinweise - bei Kindern abstimmen. -- [x] Automatische Auslösung statt Freigabe pro Besucher festgelegt. -- [x] „Voller Schreck“ als Standard, per WebUI umstellbar, festgelegt. -- [ ] Sichere Ruhelage, Bewegungsgrenzen und Stoppkonzept je Aktor festlegen. -- [ ] Akzeptable Auslöseverzögerung, Sperrzeit und Fehlalarmrate festlegen. - -Abnahme: konkrete Bauteile, Abmessungen, Grenzen und ein am Einsatzort -plausibler Auslöser sind dokumentiert. - -### 2. Mechanischen Prototyp bauen - -- [ ] Leichten Rahmen und bewegte Oberfläche konstruieren. -- [ ] Lokale Wölbungen durch eine längs gelagerte Stange mit weichen, - versetzten Hebepunkten im Tischversuch bewerten. -- [ ] Folie, Befestigung und Blattauflage auch im nassen Zustand prüfen. -- [ ] Abdeckung aus Besucherperspektive bei Tag und Nacht auf sichtbare - Mechanik, glänzende Stellen und verrutschende Blätter prüfen. -- [ ] Atembewegung mit geeignetem Antrieb auf dem Tisch testen. -- [ ] Schreckimpuls separat auswählen und begrenzen. -- [ ] Anschläge, Abdeckungen und lokalen Stopp einplanen. - -Abnahme: wiederholbare Bewegung ohne zugängliche Quetschstellen; keine -Bewegung in den Besucherweg. Dauerbetrieb und Stopp sind praktisch geprüft. - -### 3. Effektsteuerung entwickeln - -- [x] Einen vollständigen Ablauf zunächst mit simulierten Ausgängen umsetzen. -- [ ] Licht, Ton und Bewegung einzeln anschließen und testen. -- [ ] Modi, Sperrzeit, Sequenzbegrenzung und Fehlerzustände umsetzen. -- [ ] Stopp, Neustart, hängenden Befehl und Verbindungsverlust testen. -- [x] Prüfen, dass nach Start erst ein freier Bereich und danach ein neuer - Trigger die automatische Auslösung ermöglichen. -- [x] Moduswechsel per Web-API und gespeicherten Modus nach Neustart lokal prüfen. - -Abnahme: vollständiger Ablauf funktioniert ohne Kamera und Laptop. Nach -Stopp oder Fehler bleibt der Antrieb aus und startet nur nach bewusstem -Rücksetzen neu. - -### 4. Kamera und WebUI ergänzen - -- [x] MJPEG-Livebild, Schnappschuss und WebUI im Demo-Modus lokal prüfen. -- [x] SQLite für Testereignisse und Profile lokal prüfen; automatische - Kamera-Zuordnung ist noch nicht aktiv. -- [ ] Livebild und geschützten lokalen Zugriff bereitstellen. -- [ ] Person-/Bereichserkennung als Auslöser einrichten. -- [ ] Erkennungsverfahren für wiederkehrende Personen auswählen, mit echten - Tag-/Nachtbildern auf Fehlzuordnungen prüfen und erst dann aktivieren. -- [ ] Gewählten Auslöser mit dem Effektablauf verbinden und Fehlalarme messen. -- [ ] Ereignisse mit begrenztem Vor- und Nachlauf aufzeichnen. -- [ ] Übersicht, Download und automatische Löschung umsetzen. -- [ ] Speichergrenze und sichtbaren Fehler bei vollem Speicher prüfen. -- [ ] Unsichere Erkennung und Sanftmodus sichtbar darstellen. - -Abnahme: ein Ereignis erscheint mit passendem Clip, lässt sich herunterladen -und wird nach der festgelegten Frist gelöscht; ohne Laptop gehen vorgesehene -Ereignisse innerhalb der Speichergrenzen nicht verloren. - -### 5. Außentest und Erweiterungen - -- [ ] Dunkelheit, Beleuchtung, Verkleidungen und Personengruppen testen. -- [ ] Auslösebereich und Fehlalarme praktisch prüfen. -- [ ] Wetterschutz, Kabel und Temperatur im geschützten Aufbau prüfen. -- [ ] Gesamtablauf mehrfach testen und Lautstärke abstimmen. -- [ ] Stopp und Strom-/Netzausfall am aufgebauten Grab erneut prüfen. -- [ ] Optional Skelett-Hand, Display oder weitere Effekte ergänzen. -- [ ] Kurze Anleitung für Aufstellen, Start, Stopp und Abbau erstellen. - -Abnahme: zuverlässiger Betrieb am vorgesehenen Ort; Grenzen der Erkennung -sind bekannt und der Betreiber kann jederzeit stoppen oder sanft schalten. - -## Offene Entscheidungen für den nächsten Schritt - -1. Ist ein Pi-5-kompatibles Kamerakabel und eine IR-Leuchte vorhanden? -2. Wie verteilen sich die grob geschätzten 2 m² auf Länge und Breite, und - welcher Abstand zum Besucherweg bleibt für das Grab? -3. Welches Budget ist vorgesehen? -4. WLAN oder Kabel am Aufstellort? Wo kommen Netzteile geschützt unter? -5. Clips auf dem Pi vorhalten oder nur bei eingeschaltetem Laptop speichern? -6. Wie lange sollen Aufnahmen bleiben und wer darf die WebUI bedienen? -7. Soll ein unsicherer Kamerahinweis auf kleine Besucher automatisch auf - Sanft zurückschalten oder in der WebUI nur warnen? -8. Soll die Kamera auslösen, oder soll ein zusätzlicher Sensor auslösen und - die Kamera nur dokumentieren? -9. Reicht nach dem ersten Test ein Pi, oder begründen Aufbau und Last einen - zweiten Pi? -10. Wie werden Profilbeispiele erfasst und geprüft, und ab welcher - Unsicherheit bleibt eine Kamera-Zuordnung offen? - -## Fortschritt - -- [x] Projektregeln und erster Plan angelegt. -- [x] Allgemeines Uploadskript und Anleitung für Projektdateien vorbereitet. -- [x] SSH-Zugang und Projektdateien unter `~/halloween` auf dem Pi durch - Nutzer-Screenshot vom 14. Oktober 2026 bestätigt. -- [ ] Pi-Dateien mit aktuellem lokalen Stand vergleichen; Python-Einrichtung - und Start der WebUI am Pi prüfen. -- [x] Nutzer-Screenshot vom 14. Oktober 2026 zeigt erfolgreichen Start von - `run.py` mit Uvicorn auf `127.0.0.1:8000` am Pi. Kamera und Browserzugriff - sind dadurch noch nicht bestätigt. -- [x] Alternativen manuellen Uvicorn-Start für den gewünschten direkten - Heimnetzzugriff dokumentiert; kein Code-Update am Pi nötig. -- [ ] Direkten Browserzugriff über `http://raspi5wlan.local:8000` bestätigen; - Zugangsschutz bleibt offen, keine Internetfreigabe beauftragt. -- [x] Erste Kamera-WebUI mit Demo-Feed und simulierter Steuerung erstellt; - fünf lokale Softwaretests bestanden. -- [x] SQLite-Ereignisdatenbank mit Profilkennungen und WebUI-Verwaltung - implementiert. Die 30-Tage-Löschung für Auto-Profile ist im - Datenmodell und als laufende Bereinigung vorbereitet. -- [x] Optionales Installationsskript für WebUI-Autostart beim Pi-Start und - Integration in das Uploadskript erstellt; nur lokal geprüft. -- [x] Lokale Git-Versionsverwaltung für Code, Planung und bearbeitbare - Blender-Szenen vorbereitet; Renderdateien bleiben lokal. -- [x] Gitea-Veröffentlichung vorbereitet: Projektübersicht in `README.md`, - Ziel `https://gitea.fritz.box/marcinator_2/Halloween`, Branch `main`. -- [x] Privates Gitea-Repository laut Nutzer angelegt, ohne Initialisierung. -- [ ] `main` hochladen; Git benötigt eine lokale HTTPS-Anmeldung. -- [ ] Remote-Commit nach dem Push mit lokalem `main` vergleichen. -- [ ] Plan abgestimmt und freigegeben. -- [x] Softwareumsetzung begonnen; echte Kamera und Aktoren nicht geprüft. - -Die WebUI ist lokal per ASGI und Demo-Bild geprüft. Der Nutzer-Screenshot -vom 14. Oktober 2026 zeigt einen erfolgreichen SSH-Login auf `raspi5wlan.local` -als `marcinator_2` und Projektdateien unter `~/halloween`. Übertragungsmethode, -Zeitpunkt und Standgleichheit sind offen. Pi-Kamera, WebUI auf dem Pi, -SSH-Tunnel, Verdrahtung, Aktoren und Außeneinsatz sind nicht praktisch -verifiziert. Das Upload- und Autostartskript wurde nur lokal geprüft; -Unit-Installation und Neustart am Pi wurden noch nicht getestet. -Einkaufslisten mit konkreten Modellen folgen nach Phase 1. - -Gitea-Prüfung am 14. Oktober 2026: Instanz mit aktivierter TLS-Prüfung und -vorhandenem öffentlichem Zertifikat erreichbar (Version 1.24.0). Die -unangemeldete API-Abfrage für `marcinator_2/Halloween` lieferte HTTP 404; -laut Nutzer besteht noch kein Repository. Kein nutzbarer HTTPS-Login -hinterlegt; der geprüfte SSH-Zugang wurde abgelehnt. Die 56 bisher -versionierten Dateien enthalten keine ausgeschlossenen Laufzeit- oder -Renderdateien; eine Musterprüfung der Texthistorie fand keine Geheimnisse. -Dies ist keine vollständige Sicherheitsprüfung. Die Repository-Anlage -wurde anschließend vom Nutzer bestätigt. Der Push-Versuch -scheiterte an fehlender HTTPS-Anmeldung (`could not read Username`); -ein Upload ist noch nicht bestätigt. - - -## Blender-Konzeptvorschau (separat freigegeben) - -- [x] Vorgarten, Grabattrappe und kostümierte Besucher als Blender-Szene erstellt. -- [x] 20-Sekunden-Ablauf mit sanfter Familiensequenz und Schreck für Erwachsene animiert. -- [x] Selbst erzeugte Tonspur mit Atem-, Kratz- und Knurrgeräuschen erstellt. -- [x] Drei Vorschaubilder geprüft und Nachtbeleuchtung/Bildausschnitt überarbeitet. -- [x] Vollständiges Video gerendert: 20 Sekunden, 1280 × 720, 24 fps, H.264 + AAC. -- [x] Zwölf Bilder aus der fertigen MP4-Datei dekodiert und visuell geprüft. - -Dateien liegen unter `preview/`; Reproduktionsskript: `tools/render_preview.py`. -Die Figuren sind vereinfachte 3D-Modelle in Kapuzenkostümen. Garten, Maße und -Reaktionen sind inszeniert; keine echte Kindererkennung oder mechanische -Funktionsprüfung. Die Freigabe dieser Vorschau umfasst keine Effektsoftware -oder Hardwareinstallation. - - -### Zweite Bildfassung nach Nutzerwunsch - -- [x] Dichte Laubschicht ergänzt, synchron mit der Atembewegung. -- [x] Dunklere Stimmung, rotes Grablicht, kahle Äste und Bodennebel ergänzt. -- [x] Kinder bleiben in der fiktiven Schreckszene bei den Erwachsenen. -- [x] Gelenkige Beinbewegungen und schwingende Ärmel angelegt. -- [x] Drei finale Beispielbilder visuell geprüft; nur Bilder gerendert. -- [x] Acht animierte Beine und synchronisierte Laub-/Grabbewegung geprüft. -- [ ] Laufbewegung im vollständigen Video prüfen, falls später beauftragt. - -Ergebnisse: `preview/v2/`. Das ursprüngliche Video und seine Blender-Datei -bleiben erhalten. Die filmische Inszenierung mit Kindern ändert keine -Sicherheits-/Freigaberegeln für den realen Betrieb. - - -### Dritte Bildfassung: mittige Hand und Laubwirbel - -- [x] Hand und verdeckte Austrittsöffnung in die Grabmitte versetzt. -- [x] 110 rotierende Blätter steigen beim Handimpuls auf und fallen zurück. -- [x] Drei Beispielbilder vor, während und nach dem Hochkommen gerendert - und visuell geprüft. Ergebnisse: `preview/v3/`. -- [ ] Neuer Videorender weiterhin nicht beauftragt. - - -### Vierte Bildfassung: dunklere Nachtbeleuchtung - -- [x] Dunkler Nachthimmel und schwächeres Mond-/Grablicht eingerichtet. -- [x] Fensterlicht und sichtbare warme Hauslaterne als Lichtquellen ergänzt. -- [x] Gerichteter Lichtimpuls, Kontaktschatten, dezente Reflexionen und - Lichtstreuung im Bodennebel eingerichtet. -- [x] Drei finale Standbilder visuell geprüft; Ergebnisse: `preview/v4/`. -- [ ] Kein neuer Videorender beauftragt. - - -### Videorender der dunklen Fassung (freigegeben) - -- [x] Acht bewegte Beingelenke in der gespeicherten Szene überprüft. -- [x] 20-Sekunden-Video in 720p/24 fps mit Ton fertig gerendert. -- [x] MP4-Metadaten und zwölf dekodierte Bilder einschließlich Schrittstellungen geprüft. - -Ziel: `preview/v4/halloween_dark.mp4`, bearbeitbare Szene -`preview/v4/halloween_dark.blend`. Das Renderprofil verwendet acht -EEVEE-Bildsamples und 16 Nebelsamples; Lichtquellen, Schatten und -Reflexionen der Bildfassung bleiben enthalten. - -Der vollständige Render erfolgte mit freigegebenem Grafikkartenzugriff. - - -### Tonergänzung beim Herauskommen der Hand - -- [x] Tiefes synthetisches Stöhnen mit kurzem Höhlennachhall ergänzt, - Beginn bei 13,04 Sekunden, Ausklang bis etwa 16,6 Sekunden. -- [x] Bestehende Videobilder mit neuer Tonspur exportiert; 20 Sekunden, - 720p/24 fps und AAC-Spur geprüft. Ergebnis: `preview/v4/halloween_dark_groan.mp4`. -- [x] Separate bearbeitbare Blender-Datei mit gepackter Tonspur gespeichert. - - -### Fiktive Scan-/Datenbankvisualisierung im Video - -- [x] Simulierte Scanrahmen und bewegte Scanlinie auf den 3D-Figuren ergänzt. -- [x] Anschließend vier erfundene Datensätze als gespeichert visualisiert. -- [x] Beide Einblendungen sichtbar als 3D-Demo/Simulation gekennzeichnet. -- [x] Standbilder visuell sowie 20 Sekunden, 720p/24 fps und Tonspur geprüft. - -Ergebnis: `preview/v4/halloween_scan_demo.mp4`; Schnittprojekt: -`preview/v4/halloween_scan_demo.blend`. Keine tatsächliche Erkennung, -biometrische Verarbeitung oder Verbindung zur SQLite-Datenbank der Pi-WebUI. - - -### Scan-Kegel mit fiktiven Gesichtern und IDs - -- [x] Kameraperspektivisch projizierten Lichtkegel aus dem Grabstein ergänzt. -- [x] Wandernde Scanfläche und vier gezeichnete Gesichts-Avatare ergänzt. -- [x] Erfundenen IDs DEMO-001 bis DEMO-004 und Simulationskennzeichnung angezeigt. -- [x] Beispielbilder visuell und Videoeigenschaften geprüft: 20 Sekunden, - 720p/24 fps, AAC-Tonspur. Ergebnis: `preview/v4/halloween_cone_demo.mp4`. - -### Überarbeiteter Stöhneinsatz - -- [x] Alte modulierende Bassanteile aus beiden Stöhnlagen entfernt. -- [x] Weichen, hauchigeren Stimmeinsatz beim Handimpuls ergänzt. -- [x] Neu gemischt und mit vollständigen Scan-Kegel-Bildern exportiert; - keine digitale Übersteuerung, 20 Sekunden, 720p/24 fps und AAC geprüft. - Ergebnis: `preview/v4/halloween_cone_demo_audio_v2.mp4`. - -### Gruppenfoto am Videoende - -- [x] Drei Sekunden Fotoanzeige mit Blitz, Auslösegeräusch und Speicherbestätigung. -- [x] Bild der vier fiktiven Besucher auch separat gespeichert. -- [x] Schlussbild visuell und Export geprüft: 23 Sekunden, 720p/24 fps, AAC. - Ergebnis: `preview/v4/halloween_group_photo.mp4`. - -- [x] Gruppenfoto auf tatsächliche 3D-Perspektive des simulierten Scanners - im Grabstein korrigiert, separat gerendert und im Schlussbild ersetzt. - Ergebnis: `preview/v4/halloween_scanner_group_photo.mp4`; - Foto: `preview/v4/gruppenfoto_scanner.png`. +# Halloween – Projektregeln + +## Ziel und aktueller Auftrag + +Interaktives atmendes Grab für Halloween im Vorgarten der Schwester bauen. +Gewünscht sind ein kräftiger Schreckeffekt, Kamera, Weboberfläche und lokal +gespeicherte Bilder/Videoclips. Der Nutzer hat nun eine erste Pi-Software mit +Liveansicht und simuliertem Effektablauf beauftragt; das allgemeine +Uploadskript und die Blender-Konzeptvorschau wurden ebenfalls freigegeben. +Hardwarebau und Anschluss echter Aktoren folgen nach Klärung der Mechanik, +Versorgung und Sicherheit im Plan. + +## Arbeitsweise + +- Auf Deutsch, klar und knapp kommunizieren. +- `PLAN.md` als Arbeitsplan pflegen; erledigte Punkte, offene Entscheidungen + und verifizierte Ergebnisse getrennt dokumentieren. +- Diese `AGENTS.md` nach dauerhaften Änderungen an Architektur, Betrieb, + Sicherheit oder Werkzeugen im selben Arbeitsgang aktualisieren. +- Vor Änderungen in `halloween/`, `tools/`, `tests/` oder `preview/` die dortige + `instructions.md` lesen und bei geänderten Verträgen oder Abläufen + aktualisieren. Neue Modulordner erhalten eine eigene `instructions.md`. +- Vor Änderungen vorhandene Dateien und tatsächliche Hardware prüfen. +- Vorschläge und Annahmen ausdrücklich kennzeichnen; keine Kameramodelle, + Motorleistungen, Anschlüsse, Netzwerkadressen oder Preise erfinden. +- Produktvorschläge und Kaufverweise ausschließlich für Reichelt oder + Amazon; technische Herstellerunterlagen dürfen zur Prüfung dienen. +- Klein anfangen: einen vollständigen Effektablauf zuverlässig umsetzen, + anschließend zusätzliche Effekte ergänzen. +- Dokumentation übersichtlich halten; diese Datei unter 400 Zeilen halten. +- Im lokalen Git Code, Planung und bearbeitbare Blender-Szenen versionieren; + Renderdateien, Referenzclip, `.venv` und `data/` bleiben unversioniert. +- Gitea-Ziel für dieses Projekt: `https://gitea.fritz.box/marcinator_2/Halloween`; + Branch `main`, Repository zunächst privat. HTTPS-Zertifikatsprüfung aktiv + halten; das öffentliche Zertifikat nur lokal in Git konfigurieren. + Zugangsdaten gehören nicht in Git oder Chat. + +## Hardware und Architektur + +- Vorhanden laut Nutzer: Raspberry Pis 3–5; Pi 5 auf Nutzerfoto bestätigt, + RAM vermutlich 8 GB laut Nutzer, noch nicht am Gerät geprüft; + außerdem „2mini“ (genaue Geräte unbekannt), Camera Module 3 NoIR + (Beschriftung auf Nutzerfoto bestätigt), eventuell Display, + LEDs, Servos und Lötwerkzeug. 3D-Drucker laut Nutzer vorhanden; + Modell, Bauraum und Druckmaterial noch unbekannt. + PCA9685-Modul mit 16 Kanälen laut Nutzer vorhanden; konkrete Platine + und Anschlüsse noch nicht geprüft, kein Neukauf nötig. + Buchse/Buchse-Steuerleitungen laut Nutzer reichlich vorhanden; + kein Neukauf nötig, nicht für Servolast verwenden. + Weitere Teile dürfen vorgeschlagen werden. + Weiteres Nutzerfoto bestätigt einen 28BYJ-48-Schrittmotor mit 5-V-DC- + Beschriftung und OPITEC-Werkpackung 224105 mit Getriebeteilen. + Vollständigkeit, Treiber, Funktion und Lastfestigkeit noch ungeprüft. + OPITEC nennt für Werkpackung 224105 Motor R20 mit 1,5–4,5 V und + ca. 350–500 mA; Drehmoment fehlt. Fotografierter Einzelmotor nicht + unabhängig als R20 bestätigt; 6-V-Servoversorgung nicht direkt anschließen. +- Vorgeschlagene Aufteilung: Pi 5 für Kamera/WebUI/Ereignisse und Pi 3/4 + für Effektsteuerung. Noch keine verbindliche Hardwareentscheidung. +- Für die NoIR-Kamera IR-Beleuchtung bei Dunkelheit und ein passendes + Standard-auf-Mini-Kamerakabel für Pi 5 einplanen; vorhandenes Kabel prüfen. +- GPIOs steuern geeignete Treiber; Motoren, Servos und größere LED-Lasten + erhalten eine passend dimensionierte Versorgung. +- 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 + vor; CHANCS 60KTYZ (Amazon B07WXFWYBZ, 220–240 V, ca. 2,5 U/min) + ist nun ausgewählt, Kauf noch nicht bestätigt. Waveshare RPI RELAIS 3CH + ist im aktuellen Warenkorb; Motorlastfreigabe noch offen. Versorgungsleitung + laut Nutzer vorhanden; Eignung vor Anschluss prüfen. Für die + Hand ist der Miuzei-35kg-/270°-Servo der aktuelle Kandidat, mit + separater stabilisierter 6-V-/5-A-Versorgung und Delock 65485 als + Anschlussadapter. Langsames Hervorkommen ist ausdrücklich akzeptiert. + Hub, Last, PWM-Steuerung und sichere Bewegungsgrenzen noch festlegen. + Motoren nicht gekauft oder praktisch geprüft. + GM40,5 625 12V / E192.12.625 war die vorherige Reserveoption für + Rotation; GM27 90 12V der frühere Kandidat für direkten Handhub. + Beide sind historische Vorschläge, keine verbindliche Auswahl. + Separate Pi-5-Versorgung ist der aktuelle Vorschlag; + Netzteilreserve anhand beider Anlauf-/Blockierströme prüfen. +- Atemrotation laut Nutzer: ca. 1,5 m lange waagerechte Stange mit zwei + leeren Kunststoffflaschen; vorgesehen ist Rotation um die Längsachse. + Abdeckungs- und Lagerwiderstand mitmessen, keine schwere Hubfläche + als Last voraussetzen. + Stangenlänge ist dabei kein Drehmomenthebel; wirksamen Abstand zur + Drehachse und Last über eine ganze Umdrehung prüfen. Keine + abgeschlossene Dimensionierung aus den früheren Kandidaten ableiten. +- Mechanik erst nach Klärung von Abmessungen, Gewicht, Hub und Bewegungsraum + auswählen. Atembewegung und kurzer Schreckimpuls getrennt bewerten. +- Neue Handidee: Gummizug für schnellen Hub, langsamer Motor zum + Rückspannen. Verriegelung mit vorab abgewickelter Schnurreserve oder + schaltbarer Kupplung ist noch ein Vorschlag. Ein zusätzlicher + Entriegelungsaktor wäre beim Riegelvorschlag nötig; nicht ausgewählt. + Motor muss die Spannkraft aufbringen; Gummizug ersetzt diese Last nicht. +- Elektrischer Linearantrieb ist eine zusätzliche Handoption; direktes + Ausfahren und langsames Spannen eines getrennten Federhubs unterscheiden. + Selbsthemmender Spindelantrieb gibt einen schnellen Federhub nicht + automatisch frei. Hand bleibt befestigt, geführt und im Grab begrenzt. +- Weitere Handoption auf Nutzerwunsch: Schwenkhebel nach einer + Industrieservo-Videovorlage. Günstiger Kandidat für direkten Handhub + ist JOY-IT COM-MOTOR02 (Reichelt GRABIT SERVO); noch nicht ausgewählt + oder getestet. Maximales Servomoment und Leerlaufgeschwindigkeit + sind keine zugesicherte kombinierte Lastleistung. Eigenes passendes + Servonetzteil und PWM-Steuerung vorsehen; keine Geschossfunktion. +- Neuer stärkerer Handkandidat vom Nutzer: Amazon Miuzei 35kg/270°, + ASIN B0CBBS6KX6. Anbieterwerte/Spitzenstrom und reale Last prüfen; + PWM-Winkelzuordnung und Bewegungsgrenzen für 270° auslegen. + Versorgungsvorschlag: getrenntes stabilisiertes 6-V-Netzteil mit + mindestens 5 A, Anlaufverhalten noch prüfen. Pi behält eigenes Netzteil; + bei nicht isolierter Steuerung gemeinsame Masse, Versorgungsplus getrennt. + ZHITING DS3235 ist laut Nutzer nicht lieferbar, SAIA-230-V-Kandidat + zu teuer; beide für die aktuelle Auswahl zurückgestellt. + Langsamen direkten Hub zuerst bewerten; + kein schneller Federimpuls zwingend erforderlich. +- Nutzer weist auf Verheddern abgewickelter Schnur hin. Die Variante mit + Schnurreserve ist erst nach Auslegung einer geführten Aufnahme sinnvoll. + Kurvenscheibe mit Hebel als schnurlose Alternative prüfen; + recherchierte Druck-/CAD-Vorlagen sind noch keine passende Handmechanik. + +## Betriebsregeln + +- Bewegung bleibt im Grab und außerhalb des Besucherwegs; Quetschstellen + abdecken. Leichte bewegte Bauteile und mechanische Anschläge vorsehen. +- Elektronik für Außeneinsatz geschützt planen; Kleinspannung am Grab + bleibt der bisherige Vorschlag. Für die neue 230-V-Relaisalternative + Netzspannungsseite geschlossen, berührungsgeschützt und für Außeneinsatz + auslegen; durch eine Elektrofachkraft aufbauen/prüfen lassen. + Pi-kompatible Steuerschnittstelle, geeignete Motorlastkontakte, + Zugentlastung und passende Stromversorgung vorsehen. +- Bei Start, Verbindungsverlust oder Fehler keine neue Schrecksequenz + auslösen; definierte sichere Zustände und lokalen Stopp vorsehen. +- Bei Gummizugmechanik stromlos haltende mechanische Verriegelung und + kontrollierte Entspannung für Wartung vorsehen. Motor-Stopp nimmt die + gespeicherte Energie nicht weg und hält einen entriegelten Hub nicht + sofort an; Führung, Befestigung und gedämpfte Anschläge begrenzen ihn. +- Sperrzeit und belegten Auslösebereich berücksichtigen. +- Kinder-/Sanftmodus anbieten. Kamerabasierte Größenhinweise sind unsicher + und dürfen nicht als zuverlässige Altersbestimmung gelten. +- Nutzerwunsch: „voller Schreck“ ist der anfängliche Standard und wird nach + neuem gültigem Trigger automatisch ausgelöst, ohne Einzelfreigabe. + Modi sind in der WebUI umstellbar; eine gewählte Einstellung soll einen + normalen Neustart überdauern. Nach Start erst freien Auslösebereich + abwarten; nach Stopp oder Fehler bewusst zurücksetzen. Kamerahinweise + gelten nicht als verlässliche Altersentscheidung; automatische Reaktion + auf Hinweise zu kleinen Besuchern vor Implementierung abstimmen. + +## Kamera, Aufnahmen und Zugriff + +- Personenerkennung für Auslösung und Profilzuordnung als getrennte Schritte + behandeln; unsichere Zuordnungen keinem Profil zuschreiben. +- Neuer Nutzerwunsch: Personenprofile und später automatische Zuordnung + wiederkehrender Besucher per Kamera. Aktuell ist nur die lokale Datenbank + mit manueller Zuordnung umgesetzt; automatische Erkennung nicht als + funktionsfähig ausgeben, bevor Verfahren und echte Kamera geprüft sind. +- Automatisch erzeugte Profile samt Merkmalsvektoren 30 Tage nach der letzten + Sichtung löschen. Manuell angelegte Profile bleiben bis zur Löschung bestehen. +- Clips mit Zeit, Vorschaubild und Ereignistyp zum manuellen Prüfen anbieten. +- Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen; + Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen. +- Tonwiedergabe und Tonaufnahme unterscheiden; Tonaufnahme standardmäßig aus. +- WebUI zunächst nur im lokalen Netz; keine öffentliche Freigabe ohne + ausdrücklichen Auftrag. Zugangsschutz einplanen. + +## Erste Softwareversion + +- `run.py` startet die FastAPI-WebUI nur auf `127.0.0.1`; Laptop-Zugriff + erfolgt per SSH-Tunnel. `--demo` kennzeichnet ein synthetisches Bild. +- Nutzer wünscht nun direkten Zugriff im Heimnetz. Manueller Start über + Uvicorn mit `--factory --host 0.0.0.0` ist in `SOFTWARE.md` beschrieben. + Aktuell ohne Login; keine öffentliche Internetfreigabe beauftragt. + `run.py` und systemd bleiben unverändert bei Loopback. +- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde + nur der Demo-Feed geprüft, nicht die echte Pi-Kamera. +- Moduswahl, simulierte Sequenz und lokale NanoDet-Personendetektion mit + gerichteter Linienüberquerung sind implementiert. Erkennung benötigt optional + OpenCV und ausdrücklich installiertes SHA-256-geprüftes Modell unter data/. + Am Pi fertige Systempakete python3-opencv/python3-numpy in einer Umgebung + mit --system-site-packages verwenden; OpenCV mindestens 4.10 prüfen. + requirements-detection.txt erlaubt nur Wheels, keinen impliziten Quellbuild. + Erkennung startet deaktiviert; Linie/Bereich/Richtung in der UI einstellen. + Gruppen verlangen neuen freien Bereich und Sperrzeit; Erkennungsfehler bei + aktivierter Automatik sperren bis zum bewussten Rücksetzen. Frames und + Personenrahmen werden synchron angezeigt; Tracks sind flüchtig, keine Profile. + Echte Personenauslösung am Pi, GPIO und Clip-Speicherung sind noch offen. +- SQLite speichert Testereignisse und Profilkennungen unter `data/`; + automatische Kamera-Zuordnung fehlt noch. Die Datenbank kann bereits + Merkmalsvektoren für Auto-Profile mit 30-Tage-Frist speichern; die Kamera + schreibt derzeit keine solchen Datensätze. +- Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben. +- Nutzer meldet am 14. Oktober 2026 die Pi-Kamera als angeschlossen; + Ausgabe von `rpicam-hello --list-cameras` bestätigt `imx708_noir` + mit 4608×2592 Pixeln. Nutzer bestätigt anschließend echtes Livebild + in der WebUI nach Einrichtung von Picamera2. +- Nutzer-Screenshot vom 14. Oktober 2026 bestätigt SSH-Zugang als + `marcinator_2` auf `raspi5wlan.local` und Projektdateien unter `~/halloween`. + Standgleichheit, Python-Einrichtung und laufende WebUI noch prüfen. +- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim + Upload eingerichtet; auf einem echten Pi noch nicht geprüft. + +## Prüfung + +- Softwareprüfungen, Hardwaretests und Außentests getrennt berichten. +- Erst ohne angeschlossene Aktoren simulieren, dann einzelne Aktoren testen, + anschließend Gesamtablauf und Fehlerfälle prüfen. +- Kamera bei tatsächlicher Dunkelheit, Beleuchtung und Verkleidungen testen. +- Sicheren Zustand bei Netzwerkausfall, Neustart und Stopp verifizieren. +- Dokumentationsänderungen erfordern keine zusätzlichen Softwaretests. + +## Fiktive Videoeinblendungen + +Scan-/Datenbankdarstellungen in Vorschauvideos verwenden erfundene +Avatare und IDs, mit sichtbarer Simulationskennzeichnung. Externe +Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen. + +- Nutzer-Ausgaben bestätigen Raspbian GNU/Linux 13 (trixie), Python 3.13.5 + und Kernelarchitektur aarch64; Paket-/Python-Bitness noch nicht geprüft. + Nach Netzteilwechsel SSH wieder erreichbar, get_throttled=0x0; + keine Diagnose des vorherigen Ausfalls daraus ableiten. Kein OpenCV-Build + in der übermittelten Prozessliste; Dell-Netzteilwerte bleiben ungeprüft. + + +### Pi-Installation nach unterbrochenem OpenCV-Quellbuild + +- Nutzer bestätigt Raspbian 13/trixie, Python 3.13.5, Kernel aarch64 und + `get_throttled=0x0`. Paketarchitektur ist damit noch nicht nachgewiesen. +- SSH nach Netzteilwechsel wieder möglich; Prozessliste zeigt nur PipeWire, + keinen laufenden pip-/Compiler-Build. Ausfallursache bleibt ungeklärt. +- Pi-Anleitung auf fertige Systempakete umgestellt; pip-Requirements erlauben + nur Wheels und brechen andernfalls ab. Kein weiterer Quellbuild vorgesehen. +- Offen: installierte OpenCV-Version/-Herkunft und echte Modellinferenz am Pi + prüfen. Dell-Netzteil laut Nutzer passend, Ausgangsprofile nicht verifiziert. diff --git a/README.md b/README.md index 71d3a47..59319b0 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ Weboberfläche und geplantem Effektablauf. Die erste Softwareversion bietet ein Picamera2-Livebild, einen Demo-Feed, gespeicherte Moduswahl, einen simulierten Effektablauf sowie eine lokale -SQLite-Datenbank für Testereignisse und manuell zugeordnete Profile. -GPIO-Steuerung, automatische Personenerkennung und Aufnahme von Clips -sind noch nicht umgesetzt. Die echte Kamera und die Aktoren sind noch -nicht praktisch geprüft. +SQLite-Datenbank für Ereignisse und manuell zugeordnete Profile. Optional +erkennt NanoDet lokal Personen und löst beim Überqueren einer einstellbaren +Linie zum Grab hin automatisch den simulierten Effekt aus. Einrichtung und +Aktivierung stehen in [SOFTWARE.md](SOFTWARE.md#personenerkennung-und-linienauslösung). +Das echte Kamera-Livebild ist vom Nutzer bestätigt. Personenerkennung am Pi, +GPIO-Steuerung, Besucher-Wiedererkennung und Clip-Aufnahmen bleiben offen. ## Lokal ohne Hardware starten diff --git a/SOFTWARE.md b/SOFTWARE.md index e7beeef..c18949a 100644 --- a/SOFTWARE.md +++ b/SOFTWARE.md @@ -4,8 +4,9 @@ Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine **Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren, Servos, Lampen oder Lautsprecher. Eine lokale SQLite-Datenbank speichert -Testereignisse und Profilkennungen. Automatische Personenerkennung und -Profilzuordnung per Kamera, Clips und Maskierung sind noch nicht umgesetzt. +Testereignisse und Profilkennungen. Lokale Personenerkennung mit Linienauslösung ist implementiert, muss aber +am Pi installiert und kalibriert werden. Profilzuordnung per Kamera, Clips +und Maskierung sind noch nicht umgesetzt. Die 30-Tage-Löschung ist für künftige Auto-Profile vorbereitet. Daher vorerst nur im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen richten. @@ -126,3 +127,106 @@ SSH-Tunnel und GPIOs müssen am echten Pi bzw. Aufstellort geprüft werden. Technische Grundlagen: [Picamera2-Handbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf), [FastAPI-StreamingResponse](https://fastapi.tiangolo.com/advanced/stream-data/). + +## Personenerkennung und Linienauslösung + +Nach Übertragung des aktuellen Codes im Projektordner auf dem Pi installieren: + +```bash +cd ~/halloween +sudo apt update +sudo apt install python3-opencv python3-numpy +python3 -m venv --system-site-packages .venv +.venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)" +.venv/bin/python tools/install_detection_model.py +``` + +Das Skript lädt ausschließlich das unquantisierte Modell +`object_detection_nanodet_2022nov.onnx` des offiziellen OpenCV Zoo. +Version, URL, Größe und SHA-256 stehen in `halloween/nanodet-model.json`. +Das Modell liegt unter `data/models/`; ein bestehendes korrektes Modell wird +nicht erneut geladen. Download erfolgt nur auf diesen ausdrücklichen Aufruf, +HTTPS-Zertifikatsprüfung bleibt aktiv. Der Server lädt nichts aus dem Internet. +Bei abweichendem `HALLOWEEN_STATE_DIR` diesen beim Installer ebenfalls setzen +oder `--state-dir` angeben. Modellgewichte bleiben unversioniert und werden +nicht vom Upload überschrieben. Auf dem Pi das fertige Systempaket verwenden; +Picamera2 bleibt ebenfalls das Raspberry-Pi-Systempaket. OpenCV muss mindestens +4.10 sein; die tatsächliche installierte Version und Inferenz am Pi prüfen. +Für andere Entwicklungsrechner kann `requirements-detection.txt` genutzt werden: +Es erlaubt ausschließlich fertige Wheels und bricht ohne passende Pakete ab. +Ein vorheriger pip-Installationsversuch kann lokale Pakete hinterlassen, die +Systempakete überdecken; bei Importfehlern die ausgegebenen Pfade prüfen. + +Server anschließend wie oben starten. In der Liveansicht: + +1. Orange Linienendpunkte und die zwei grünen Bereichsecken ziehen. Die Linie + muss vollständig innerhalb des Rechtecks liegen. Außerhalb des privaten + Testbereichs darf die Auslösung nicht eingerichtet werden. +2. Mit „Richtung umkehren“ den Pfeil zum Grab ausrichten. +3. „Automatisch auslösen“ auswählen und „Linie und Bereich speichern“ drücken. +4. Mindestens zwei Sekunden den gesamten Bereich freihalten. Danach auf der + Ankunftsseite beginnen und über die Linie zum Grab gehen. + +Zunächst bleibt die automatische Auslösung aus. Gespeicherte Konfiguration +und Aktivierung bleiben nach normalem Neustart erhalten, aber die +Bereichsfreigabe und Tracking-IDs werden immer neu aufgebaut. Bei ungültiger +Konfiguration bleibt die Automatik aus; der Fehler wird angezeigt. + +Es werden nur Personen ausgewertet (Startschwelle 0,6). Mindestens drei +aufeinanderfolgende Erkennungen bestätigen einen Track. Der Fußpunkt ist die +Mitte der unteren Rahmenkante. Er muss von der Ankunftsseite über das endliche +Liniensegment auf die Grabseite wechseln, jeweils mindestens 3 % der Bildhöhe +von der Linie entfernt. Kurz auftauchende Personen hinter der Linie, +Rückwege, Bewegung entlang der Linie und Überquerungen außerhalb des +Segments lösen nichts aus. Verdeckung oder zu schnelles Überqueren kann +zur Nichterkennung führen; die Startwerte sind praktisch zu kalibrieren. + +Eine Gruppe erhält einen Effekt: Nach Auslösung sind zehn Sekunden Sperrzeit +und mindestens zwei Sekunden freier Auslösebereich nötig. Auch unbestätigte +Personenrahmen im Rechteck verhindern die Freigabe. Überquerungen während +Sperre werden verworfen. Start, Moduswechsel, Rücksetzen und Änderungen der +Konfiguration verlangen erneut einen freien Bereich. Stopp bleibt wirksam; +ein manueller Testbutton umgeht keine Fehlersperre. + +Kamera-/Modellfehler und mehr als zwei Sekunden ohne gültige Auswertung +sperren die aktivierte Automatik und stoppen den simulierten Ablauf. Nach +Beheben des Fehlers „Nach Stopp zurücksetzen“ verwenden und Bereich +freihalten. Modellprobleme bei deaktivierter Automatik verhindern keine +manuellen Tests. Demo braucht weder OpenCV noch Modell; ihre synthetischen +Bilder sind kein Test der echten Personenerkennung. + +Die Auswertung arbeitet im Hintergrund mit dem jeweils neuesten Bild, Ziel +fünf Auswertungen/s. Die UI zeigt die gemessene Verarbeitungsrate. Bei +Erkennungsergebnissen zeigt sie das zugehörige Bild statt des unabhängigen +MJPEG-Bildes, damit Rahmen und Bild zusammenpassen. Nach zwei Sekunden sind +Ergebnisse veraltet und die Rahmen verschwinden. Ohne Erkennungsbild bleibt +der normale MJPEG-Stream verfügbar. `/api/detection` liefert Frame-Nummer, +JPEG als Base64, normierte Personenrahmen, flüchtige Tracking-IDs und Status; +`GET/POST /api/detection/config` liest/speichert Aktivierung, Linie, Richtung +und Rechteck. `POST` benötigt wie andere Schreibaufrufe `X-Halloween-UI: 1`. +`/api/status` enthält zusätzlich `detection`. Der Header ersetzt keinen Login. + +Automatische Auslösungen werden als `person_line_crossing` ohne Profil in der +bestehenden Datenbank gespeichert. Weder Kamerabilder noch Tracking-IDs werden +automatisch gespeichert; Profil-Wiedererkennung und Altersentscheidung fehlen. +Aktoren bleiben ausschließlich simuliert. + +### Abnahme am echten Pi (noch offen) + +- Angezeigte Rate und Reaktionsverzögerung prüfen; Livebild und Stopp müssen + bedienbar bleiben. Zehn Annäherungen und zehn Rückwege ausprobieren. +- Gruppe, Verdeckung, Stehenbleiben, Linie entlanggehen, Neustart mit Person + im Bereich sowie Kamera-/Erkennungsausfall und bewusstes Rücksetzen testen. +- Danach Kostüme, tatsächliche Dunkelheit und IR-Beleuchtung am Aufstellort + testen. Nachtbetrieb und Zuverlässigkeit sind noch nicht bestätigt. + +Modellgrundlage: [OpenCV Zoo NanoDet](https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet). + +Optionaler echter Modell-Smoke-Test auf einem Rechner mit OpenCV und Modell: + +```bash +HALLOWEEN_TEST_MODEL="$PWD/data/models/object_detection_nanodet_2022nov.onnx" .venv/bin/python -m unittest discover -s tests -v +``` + +Ohne diese Variable wird nur der optionale Modell-Smoke-Test übersprungen; +die Linien-/API-/Installer-Tests benötigen weder Modell noch OpenCV. diff --git a/UPLOAD.md b/UPLOAD.md index 225fca9..55ef37f 100644 --- a/UPLOAD.md +++ b/UPLOAD.md @@ -71,3 +71,21 @@ Projektdateien auf dem Pi: unter anderem `run.py`, `halloween/`, `tools/`, Übertragung sowie die Übereinstimmung mit dem aktuellen lokalen Stand sind offen. Installation der Python-Pakete, WebUI-Start, Kamera, SSH-Tunnel und systemd-Autostart sind damit noch nicht bestätigt. + +## Personenerkennung nach dem Upload einrichten + +Die Modellgewichte und Erkennungseinstellungen liegen unter `data/` und bleiben +beim Upload erhalten. Bei der ersten Einrichtung nach Code-Übertragung auf dem +Pi die zusätzlichen Pakete und das geprüfte Modell ausdrücklich installieren: + +```bash +cd ~/halloween +sudo apt update +sudo apt install python3-opencv python3-numpy +python3 -m venv --system-site-packages .venv +.venv/bin/python -c "import cv2, numpy; print(cv2.__version__, cv2.__file__); print(numpy.__version__)" +.venv/bin/python tools/install_detection_model.py +``` + +Danach den Server neu starten und Linie, Richtung, Bereich und Aktivierung in +der WebUI speichern. Details und Fehlerbehandlung in `SOFTWARE.md`. diff --git a/halloween/camera.py b/halloween/camera.py index d372223..6286ebd 100644 --- a/halloween/camera.py +++ b/halloween/camera.py @@ -99,6 +99,10 @@ class CameraStream: with self._condition: return self._number, self._frame + def latest_timed(self): + with self._condition: + return self._number, self._frame, self._time + def wait_next(self, number: int, timeout: float = 10): with self._condition: self._condition.wait_for( diff --git a/halloween/controller.py b/halloween/controller.py index 69b1467..3f68569 100644 --- a/halloween/controller.py +++ b/halloween/controller.py @@ -25,6 +25,7 @@ class Controller: self.started_at = None self.cooldown_until = 0.0 self.last_event = None + self.generation = 0 def _load_mode(self): if not self._path.exists(): @@ -59,6 +60,7 @@ class Controller: with self._lock: self._save_mode(mode) if mode != self.mode: + self.generation += 1 self.started_at = None self.zone_clear_seen = False self.mode = mode @@ -94,12 +96,14 @@ class Controller: def stop(self): with self._lock: self.stopped = True + self.generation += 1 self.started_at = None self.zone_clear_seen = False def reset(self): with self._lock: self.stopped = False + self.generation += 1 self.started_at = None self.zone_clear_seen = False diff --git a/halloween/detection.py b/halloween/detection.py new file mode 100644 index 0000000..a75336d --- /dev/null +++ b/halloween/detection.py @@ -0,0 +1,351 @@ +"""Lokale Personendetektion und zeitlich begrenzte Linienverfolgung.""" + +import base64 +import hashlib +import json +import math +import os +from pathlib import Path +import tempfile +import threading +import time +from io import BytesIO + +from PIL import Image + + +DEFAULT_CONFIG = {"enabled": False, "line": [[0.2, 0.55], [0.8, 0.55]], + "direction": 1, "region": [0.1, 0.15, 0.9, 0.95]} + + +def validate_config(value): + if set(value) != set(DEFAULT_CONFIG) or type(value["enabled"]) is not bool: + raise ValueError("Ungültige Erkennungseinstellungen") + if type(value["direction"]) is not int or value["direction"] not in (-1, 1): + raise ValueError("Richtung muss -1 oder 1 sein") + line, region = value["line"], value["region"] + if len(line) != 2 or any(len(p) != 2 for p in line) or len(region) != 4: + raise ValueError("Linie und Bereich benötigen gültige Koordinaten") + coords = [*line[0], *line[1], *region] + if any(type(x) not in (int, float) or not math.isfinite(x) or not 0 <= x <= 1 for x in coords): + raise ValueError("Koordinaten müssen zwischen 0 und 1 liegen") + x1, y1, x2, y2 = region + if x2 - x1 < 0.05 or y2 - y1 < 0.05: + raise ValueError("Auslösebereich ist zu klein") + if math.dist(*line) < 0.05 or any(not (x1 <= x <= x2 and y1 <= y <= y2) for x, y in line): + raise ValueError("Linie muss im Auslösebereich liegen und ausreichend lang sein") + return json.loads(json.dumps(value)) + + +class NanoDet: + """ONNX-Decoder für OpenCV Zoo NanoDet-m-plus-1.5x_416 (November 2022).""" + + def __init__(self, path: Path): + manifest = json.loads((Path(__file__).parent / "nanodet-model.json").read_text()) + if not path.is_file(): + raise FileNotFoundError("NanoDet fehlt; tools/install_detection_model.py ausführen") + if hashlib.sha256(path.read_bytes()).hexdigest() != manifest["sha256"]: + raise ValueError("NanoDet-Prüfsumme stimmt nicht") + import cv2 + import numpy as np + self.cv, self.np = cv2, np + self.net = cv2.dnn.readNetFromONNX(str(path)) + self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) + self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) + + def detect(self, jpeg): + cv, np = self.cv, self.np + rgb = np.asarray(Image.open(BytesIO(jpeg)).convert("RGB")) + h, w = rgb.shape[:2] + scale = min(416 / w, 416 / h) + nw, nh = round(w * scale), round(h * scale) + left, top = (416 - nw) // 2, (416 - nh) // 2 + image = np.zeros((416, 416, 3), dtype=np.float32) + image[:] = [103.53, 116.28, 123.675] + image[top:top+nh, left:left+nw] = cv.resize(rgb[:, :, ::-1], (nw, nh)) + image = (image - np.array([103.53, 116.28, 123.675], np.float32)) / np.array([57.375, 57.12, 58.395], np.float32) + self.net.setInput(cv.dnn.blobFromImage(image)) + outputs = self.net.forward(self.net.getUnconnectedOutLayersNames()) + if len(outputs) != 6: + raise ValueError("Unerwartete NanoDet-Ausgänge") + boxes, scores = [], [] + for level, stride in enumerate((8, 16, 32)): + classes = outputs[2 * level].reshape(-1, 80) + logits = outputs[2 * level + 1].reshape(-1, 4, 8) + side = 416 // stride + if len(classes) != side * side or len(logits) != len(classes): + raise ValueError("Unerwartete NanoDet-Dimensionen") + candidates = np.flatnonzero((classes[:, 0] >= 0.6) & (classes.argmax(axis=1) == 0)) + for index in candidates: + values = logits[index] + weights = np.exp(values - values.max(axis=1, keepdims=True)) + distances = (weights / weights.sum(axis=1, keepdims=True)) @ np.arange(8) * stride + cx = (index % side) * stride + (stride - 1) / 2 + cy = (index // side) * stride + (stride - 1) / 2 + x1 = max(0., min(1., (cx - distances[0] - left) / (scale * w))) + y1 = max(0., min(1., (cy - distances[1] - top) / (scale * h))) + x2 = max(0., min(1., (cx + distances[2] - left) / (scale * w))) + y2 = max(0., min(1., (cy + distances[3] - top) / (scale * h))) + if x2 > x1 and y2 > y1: + boxes.append([float(x1), float(y1), float(x2 - x1), float(y2 - y1)]) + scores.append(float(classes[index, 0])) + indices = cv.dnn.NMSBoxes(boxes, scores, 0.6, 0.6) + return [{"box": [boxes[i][0], boxes[i][1], boxes[i][0]+boxes[i][2], boxes[i][1]+boxes[i][3]], + "score": scores[i]} for i in np.asarray(indices).reshape(-1)] + + +class LineTracker: + def __init__(self): + self.tracks = {} + self.next_id = 1 + + def update(self, people, config, now, aspect=4/3): + # Fehlende Frames unterbrechen die Bestätigung; kurze Lücken behalten + # lediglich die räumliche Identität, niemals einen alten Linienübergang. + old = {k: v for k, v in self.tracks.items() if now - v["seen"] <= 0.8} + available = set(old) + result, crossed = [], False + a, b = config["line"] + ax, ay, bx, by = a[0]*aspect, a[1], b[0]*aspect, b[1] + dx, dy = bx-ax, by-ay + length = math.hypot(dx, dy) + for person in people: + x1, y1, x2, y2 = person["box"] + point = ((x1+x2)/2, y2) + def distance(key): + p = old[key]["point"] + return math.hypot((point[0]-p[0])*aspect, point[1]-p[1]) + key = min(available, key=distance) if available else None + if key is None or distance(key) > 0.25: + key = self.next_id + self.next_id += 1 + track = {"count": 0, "side": None, "point": point} + else: + available.remove(key) + track = old[key] + previous = track["point"] + track["count"] += 1 + px, py = point[0]*aspect, point[1] + signed = ((dx*(py-ay)-dy*(px-ax))/length) * config["direction"] + side = -1 if signed < -0.03 else (1 if signed > 0.03 else 0) + rx1, ry1, rx2, ry2 = config["region"] + inside = rx1 <= point[0] <= rx2 and ry1 <= point[1] <= ry2 + if track["count"] >= 3 and inside: + if side == 1 and track["side"] == -1: + # Schnitt der Bewegung mit dem endlichen Liniensegment. + start = track.get("arrival", previous) + sx, sy = start[0]*aspect, start[1] + vx, vy = px-sx, py-sy + den = vx*dy-vy*dx + if abs(den) > 1e-9: + t = ((ax-sx)*dy-(ay-sy)*dx)/den + u = ((ax-sx)*vy-(ay-sy)*vx)/den + crossed |= 0 <= t <= 1 and 0 <= u <= 1 + if side: + track["side"] = side + if side == -1: + track["arrival"] = point + elif not inside: + track["side"] = None + track.update(point=point, seen=now) + result.append({**person, "track_id": key, "confirmed": track["count"] >= 3}) + old[key] = track + active = {p["track_id"] for p in result} + for key in set(old)-active: + old[key].update(count=0, side=None) + self.tracks = old + return result, crossed + + +class DetectionService: + def __init__(self, camera, controller, events, directory, detector_factory=NanoDet, clock=time.monotonic): + self.camera, self.controller, self.events = camera, controller, events + self.clock, self.factory = clock, detector_factory + self.path = directory / "detection.json" + self.model_path = directory / "models" / "object_detection_nanodet_2022nov.onnx" + self.lock = threading.RLock() + self.stop_event = threading.Event() + self.thread = None + self.error = None + self.fault = False + self.config_error = None + self.config = json.loads(json.dumps(DEFAULT_CONFIG)) + try: + if self.path.exists(): + self.config = validate_config(json.loads(self.path.read_text())) + except (ValueError, TypeError, KeyError, OSError) as exc: + self.config_error = f"Erkennungseinstellungen beschädigt: {exc}" + self.revision = 0 + self.reset_state() + self.last_valid = None + self.started = self.clock() + self.fps = 0. + self.result = {"frame_number": 0, "people": [], "image_base64": None} + + def reset_state(self): + self.revision += 1 + self.tracker = LineTracker() + self.free_since = None + self.armed = False + self.generation = self.controller.generation + + def configure(self, value): + config = validate_config(value) + with self.lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", dir=self.path.parent, delete=False) as file: + temp = Path(file.name) + try: + json.dump(config, file) + file.flush() + os.fsync(file.fileno()) + except Exception: + temp.unlink(missing_ok=True) + raise + try: + os.replace(temp, self.path) + finally: + temp.unlink(missing_ok=True) + self.config = config + self.config_error = None + self.reset_state() + self.started = self.clock() + self.last_valid = None + self.result = {"frame_number": 0, "people": [], "image_base64": None} + return self.get_config() + + def get_config(self): + with self.lock: + return json.loads(json.dumps(self.config)) + + def reset(self): + with self.lock: + self.fault = False + self.error = None + self.reset_state() + self.started = self.clock() + self.last_valid = None + + def fail(self, message): + with self.lock: + self.error = message + self.armed = False + self.free_since = None + if self.config["enabled"]: + self.fault = True + self.controller.stop() + + def watchdog(self): + with self.lock: + if self.config["enabled"] and not self.fault: + if self.camera.error: + self.fail(self.camera.error) + elif self.clock() - (self.last_valid if self.last_valid is not None else self.started) > 2: + self.fail("Keine gültige Erkennung seit mehr als zwei Sekunden; zurücksetzen") + + def process(self, people, number, jpeg, captured_at, version): + event_mode = None + with self.lock: + now = self.clock() + if self.stop_event.is_set(): + return + if self.controller.generation != self.generation: + self.reset_state() + return + if version != self.revision: + return + if now - captured_at > 2: + self.fail("Erkennungsbild ist veraltet; zurücksetzen") + return + self.last_valid = now + if not self.fault: + self.error = None + image = Image.open(BytesIO(jpeg)) + people, crossing = self.tracker.update(people, self.config, now, image.width/image.height) + self.result = {"frame_number": number, "people": people, + "image_base64": base64.b64encode(jpeg).decode("ascii")} + if not self.config["enabled"] or self.fault or self.controller.stopped: + self.armed = False + self.free_since = None + return + rx1, ry1, rx2, ry2 = self.config["region"] + # Auch unbestätigte Personenrahmen verhindern eine falsche Freigabe. + occupied = any(p["box"][0] <= rx2 and p["box"][2] >= rx1 and + p["box"][1] <= ry2 and p["box"][3] >= ry1 for p in people) + if occupied: + self.free_since = None + else: + if self.free_since is None: + self.free_since = now + if now-self.free_since >= 2 and self.controller.status()["cooldown_seconds"] == 0: + self.armed = True + if crossing and self.armed: + # Controller bleibt die gemeinsame letzte Schranke, unabhängig + # von manueller Bereichsfreigabe oder parallelem Browser-Stopp. + self.controller.zone_clear() + accepted, _ = self.controller.trigger() + self.armed = False + self.free_since = None + if accepted: + event_mode = self.controller.last_event["mode"] + # SQLite darf UI und unabhängigen Watchdog nicht blockieren. + if event_mode is not None: + try: + self.events.record_detection_trigger(event_mode) + except Exception as exc: + self.fail(f"Ereignis nicht gespeichert: {exc}") + + def status(self): + with self.lock: + age = None if self.last_valid is None else self.clock()-self.last_valid + return {"enabled": self.config["enabled"], "ready": age is not None and age <= 2 and not self.error, + "error": self.config_error or self.error, "reset_required": self.fault, + "armed": self.armed and not self.fault and not self.controller.stopped + and self.controller.generation == self.generation + and self.controller.status()["cooldown_seconds"] == 0, + "fps": round(self.fps, 2), "result_age_seconds": age} + + def snapshot(self): + with self.lock: + status = self.status() + fresh = status["ready"] + return {**self.result, "people": self.result["people"] if fresh else [], + "image_base64": self.result["image_base64"] if fresh else None, + "status": status} + + def start(self): + self.started = self.clock() + self.thread = threading.Thread(target=self._run, name="person-detection", daemon=True) + self.thread.start() + + def stop(self): + self.stop_event.set() + self.controller.stop() + if self.thread: + self.thread.join(timeout=3) + + def _run(self): + detector, last_number, last_completed = None, 0, None + while not self.stop_event.is_set(): + began = self.clock() + try: + if detector is None: + detector = self.factory(self.model_path) + with self.lock: + if self.controller.generation != self.generation: + self.reset_state() + version = self.revision + number, jpeg, captured_at = self.camera.latest_timed() + if jpeg is not None and number != last_number: + last_number = number + people = detector.detect(jpeg) + self.process(people, number, jpeg, captured_at, version) + completed = self.clock() + with self.lock: + self.fps = 0. if last_completed is None else 1/max(0.001, completed-last_completed) + last_completed = completed + except Exception as exc: + self.fail(f"Personenerkennung nicht verfügbar: {exc}") + detector = None + self.stop_event.wait(1) + self.stop_event.wait(max(0, 0.2-(self.clock()-began))) diff --git a/halloween/event_store.py b/halloween/event_store.py index 2cb2cd1..756e40a 100644 --- a/halloween/event_store.py +++ b/halloween/event_store.py @@ -96,12 +96,18 @@ class EventStore: return connection def record_test_trigger(self, mode: str) -> int: + return self._record_trigger(mode, "test_trigger") + + def record_detection_trigger(self, mode: str) -> int: + return self._record_trigger(mode, "person_line_crossing") + + def _record_trigger(self, mode: str, kind: str) -> int: occurred_at = _timestamp(_utc_now()) with closing(self._connect()) as connection: with connection: cursor = connection.execute( "INSERT INTO events (occurred_at, kind, mode) VALUES (?, ?, ?)", - (occurred_at, "test_trigger", mode), + (occurred_at, kind, mode), ) return cursor.lastrowid diff --git a/halloween/index.html b/halloween/index.html index cad1351..7b2e591 100644 --- a/halloween/index.html +++ b/halloween/index.html @@ -15,6 +15,9 @@ section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; } h2 { margin: 0 0 .7rem; font-size: 1.1rem; } .camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; } + .camera-wrap { position: relative; aspect-ratio: 4/3; } + .camera-wrap .camera { position: absolute; inset: 0; height: 100%; } + #detection-overlay { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; cursor: crosshair; } .row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; } button, select, input, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; } button, select, a.download { cursor: pointer; } @@ -46,10 +49,21 @@

Livebild

- Livebild der Kamera +
+ Livebild der Kamera + +
+

Personenerkennung

+

Endpunkte der orangefarbenen Linie und Ecken des grünen Bereichs ziehen. Der Pfeil zeigt zum Grab. Änderungen werden erst nach dem Speichern aktiv.

+
+ + + +
+

Erkennung lädt …

Kamera wird gestartet …

Aktuelles Bild herunterladen -

Aufnahmen und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.

+

Aufnahmen bleiben deaktiviert. Den Auslösebereich vor dem Test auf den privaten Bereich begrenzen. Personenmarkierungen sind keine Besucherprofile.

Betrieb

@@ -57,7 +71,7 @@
Modus
Lädt …
Zustand
Lädt …
Sperrzeit
–
-
Letzter Test
–
+
Letzte Auslösung
–
@@ -69,7 +83,7 @@
-

Aktoren nur simuliert. Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Es gibt noch keine automatische Personenauslösung.

+

Aktoren nur simuliert. Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Die Personenerkennung kann ausschließlich diesen simulierten Ablauf auslösen.

@@ -81,8 +95,8 @@

-

Gespeicherte Testereignisse

-

Erfolgreiche Testauslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.

+

Gespeicherte Ereignisse

+

Erfolgreiche manuelle und automatische Auslösungen bleiben nach einem Neustart erhalten. Eine Profilzuordnung kann hier zum Testen oder Korrigieren gesetzt werden.

  • Lädt …
@@ -171,13 +185,13 @@ list.replaceChildren(); if (!data.events.length) { const item = document.createElement('li'); - item.textContent = 'Noch keine Testauslösung gespeichert'; + item.textContent = 'Noch keine Auslösung gespeichert'; list.append(item); } for (const event of data.events) { const item = document.createElement('li'); const summary = document.createElement('span'); - summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : ''); + summary.textContent = '#' + event.id + ' · ' + new Date(event.occurred_at).toLocaleString('de-DE') + ' · ' + (event.kind === 'person_line_crossing' ? 'Linienüberquerung' : 'Test') + ' · ' + (names[event.mode] || event.mode) + (event.profile_label ? ' · ' + event.profile_label : ''); const tools = document.createElement('div'); tools.className = 'row event-tools'; const select = document.createElement('select'); @@ -220,6 +234,107 @@ await refreshDb(); } catch (error) { $('message').textContent = error.message; } }; + let detectionConfig = null, people = [], drag = null, processed = false, lastImageAt = 0; + let detectionEpoch = 0; + const overlay = $('detection-overlay'), context = overlay.getContext('2d'); + function drawDetection() { + context.clearRect(0, 0, 640, 480); + if (!detectionConfig) return; + const c = detectionConfig, [x1,y1,x2,y2] = c.region; + context.lineWidth = 3; context.strokeStyle = '#78d9a2'; + context.strokeRect(x1*640,y1*480,(x2-x1)*640,(y2-y1)*480); + const [a,b] = c.line, dx = (b[0]-a[0])*640, dy = (b[1]-a[1])*480; + context.strokeStyle = '#ffb44f'; context.beginPath(); + context.moveTo(a[0]*640,a[1]*480); context.lineTo(b[0]*640,b[1]*480); context.stroke(); + const length = Math.hypot(dx,dy), mx = (a[0]+b[0])*320, my = (a[1]+b[1])*240; + const nx = -dy/length*c.direction, ny = dx/length*c.direction; + context.beginPath(); context.moveTo(mx,my); context.lineTo(mx+nx*40,my+ny*40); + context.lineTo(mx+nx*27+ny*9,my+ny*27-nx*9); context.moveTo(mx+nx*40,my+ny*40); + context.lineTo(mx+nx*27-ny*9,my+ny*27+nx*9); context.stroke(); + for (const [x,y,color] of [[...a,'#ffb44f'],[...b,'#ffb44f'],[x1,y1,'#78d9a2'],[x2,y2,'#78d9a2']]) { + context.fillStyle = color; context.beginPath(); context.arc(x*640,y*480,8,0,Math.PI*2); context.fill(); + } + if (performance.now()-lastImageAt > 2000) return; + for (const person of people) { + const [l,t,r,bottom] = person.box; + context.strokeStyle = person.confirmed ? '#6ad8ff' : '#c6c6c6'; + context.strokeRect(l*640,t*480,(r-l)*640,(bottom-t)*480); + context.fillStyle = context.strokeStyle; context.font = '16px sans-serif'; + context.fillText('Person #'+person.track_id, l*640, Math.max(18,t*480-5)); + } + } + function pointer(event) { + const rect = overlay.getBoundingClientRect(); + return [Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)), + Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))]; + } + overlay.onpointerdown = event => { + if (!detectionConfig) return; + const p = pointer(event), c = detectionConfig; + const handles = [...c.line, c.region.slice(0,2), c.region.slice(2,4)]; + const distances = handles.map(q => Math.hypot((q[0]-p[0])*640,(q[1]-p[1])*480)); + const index = distances.indexOf(Math.min(...distances)); + if (distances[index] > 30) return; + drag = index; overlay.setPointerCapture(event.pointerId); + }; + overlay.onpointermove = event => { + if (drag === null) return; + const p = pointer(event), c = detectionConfig; + if (drag < 2) c.line[drag] = p; + else c.region.splice((drag-2)*2,2,...p); + drawDetection(); + }; + overlay.onpointerup = overlay.onpointercancel = () => { drag = null; }; + $('reverse-direction').onclick = () => { + if (detectionConfig) { detectionConfig.direction *= -1; drawDetection(); } + }; + $('save-detection').onclick = async () => { + if (!detectionConfig) return; + try { + detectionConfig.enabled = $('detection-enabled').checked; + detectionConfig = await api('/api/detection/config', detectionConfig); + detectionEpoch++; people = []; drawDetection(); + $('message').textContent = 'Gespeichert; freien Auslösebereich abwarten'; + } catch (error) { $('message').textContent = error.message; } + }; + async function refreshDetection() { + const epoch = detectionEpoch, requestedAt = performance.now(); + try { + const response = await fetch('/api/detection', {cache:'no-store'}); + if (!response.ok) throw new Error('Erkennung nicht erreichbar'); + const data = await response.json(), status = data.status; + if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; } + $('detection-status').textContent = status.error || + (!status.enabled ? 'Automatische Auslösung aus' : status.armed ? 'Bereit für Überquerung zum Grab' : 'Freigabe / Sperrzeit abwarten') + + ' · '+status.fps+' Auswertungen/s'; + if (data.image_base64) { + const image = new Image(); + image.src = 'data:image/jpeg;base64,'+data.image_base64; + await image.decode(); + if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; } + $('live-image').src = image.src; + await $('live-image').decode(); + if (epoch !== detectionEpoch) { setTimeout(refreshDetection, 200); return; } + processed = true; people = data.people; + // Alter des Ergebnisses berücksichtigen, auch bei wiederholten Antworten. + lastImageAt = requestedAt-(status.result_age_seconds || 0)*1000; + } else { + people = []; + if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; } + } + drawDetection(); + } catch (error) { + people = []; drawDetection(); + if (processed) { $('live-image').src = '/stream.mjpg'; processed = false; } + $('detection-status').textContent = error.message; + } + setTimeout(refreshDetection, 200); + } + fetch('/api/detection/config').then(r => {if (!r.ok) throw new Error('Konfiguration nicht verfügbar'); return r.json();}).then(c => { + detectionConfig = c; $('detection-enabled').checked = c.enabled; drawDetection(); + }).catch(error => { $('detection-status').textContent = error.message; }); + refreshDetection(); + setInterval(drawDetection, 250); refresh(); refreshDb(); setInterval(refresh, 1000); diff --git a/halloween/instructions.md b/halloween/instructions.md index dc76a73..a87f1c7 100644 --- a/halloween/instructions.md +++ b/halloween/instructions.md @@ -35,3 +35,21 @@ Hier liegt die erste Pi-WebUI. `run.py` im Projektstamm startet sie. Bei Änderungen an Zustandsübergängen oder Endpunkten die zugehörigen Tests unter `tests/` und die Bedienhinweise in `SOFTWARE.md` prüfen. + +## Personendetektion + +- `detection.py` wertet den gemeinsamen Kamerastream mit OpenCV DNN/NanoDet aus. + Optionales Modell und OpenCV fehlen im Demo-Grundsetup bewusst. Kein Download + im Server; geprüfte Modellmetadaten in `nanodet-model.json`. +- Nur neue, zeitlich gültige Frames auswerten. Hintergrundinferenz hält keine + UI-/Zustandssperre; separater Watchdog sperrt aktivierte Automatik nach zwei + Sekunden ohne gültiges Ergebnis. Fehler verlangen bewusstes Rücksetzen. +- Linienüberquerung nutzt bestätigte flüchtige Tracks und Fußpunkte, Richtung, + endliches Segment und 3-%-Hysterese. Gruppen verlangen freien Bereich und + Sperrzeit; Konfigurations-/Controllerwechsel verwerfen alte Beobachtungen. +- `/api/detection` liefert synchrones Bild und Rahmen; `/api/detection/config` + speichert normierte Geometrie und Aktivierung unter `data/`. Erste Aktivierung + ist bewusst, nach Neustart immer neue Bereichsfreigabe. UI zeichnet/editiert + mit Canvas; keine Tracking-ID als Besucherprofil verwenden. +- Automatische Ereignisse tragen `person_line_crossing`, ohne Profilzuordnung. + Die vorhandene Steuerung bleibt ausschließlich simuliert. diff --git a/halloween/nanodet-model.json b/halloween/nanodet-model.json new file mode 100644 index 0000000..c2a068b --- /dev/null +++ b/halloween/nanodet-model.json @@ -0,0 +1,9 @@ +{ + "name": "NanoDet-m-plus-1.5x_416", + "file": "object_detection_nanodet_2022nov.onnx", + "revision": "47534e27c9851bb1128ccc0102f1145e27f23f98", + "sha256": "4b82da9944b88577175ee23a459dce2e26e6e4be573def65b1055dc2d9720186", + "size": 3800954, + "url": "https://media.githubusercontent.com/media/opencv/opencv_zoo/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet/object_detection_nanodet_2022nov.onnx", + "source": "https://github.com/opencv/opencv_zoo/tree/47534e27c9851bb1128ccc0102f1145e27f23f98/models/object_detection_nanodet" +} diff --git a/halloween/web.py b/halloween/web.py index ab265e0..9d06abf 100644 --- a/halloween/web.py +++ b/halloween/web.py @@ -14,6 +14,7 @@ from pydantic import BaseModel, Field from .camera import CameraStream from .controller import Controller, MODES from .event_store import EventStore +from .detection import DetectionService PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8") @@ -43,6 +44,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: camera = CameraStream(demo=demo) controller = Controller(directory) events = EventStore(directory) + detection = DetectionService(camera, controller, events, directory) @asynccontextmanager async def lifespan(app: FastAPI): @@ -54,11 +56,23 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: except sqlite3.Error: LOGGER.exception("Abgelaufene Profile konnten nicht gelöscht werden") + async def detection_watchdog(): + while True: + detection.watchdog() + await asyncio.sleep(0.1) + camera.start() + detection.start() + watchdog_task = asyncio.create_task(detection_watchdog()) cleanup_task = asyncio.create_task(periodic_profile_cleanup()) try: yield finally: + controller.stop() + watchdog_task.cancel() + with suppress(asyncio.CancelledError): + await watchdog_task + detection.stop() cleanup_task.cancel() with suppress(asyncio.CancelledError): await cleanup_task @@ -68,6 +82,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: app.state.camera = camera app.state.controller = controller app.state.events = events + app.state.detection = detection @app.middleware("http") async def security_headers(request: Request, call_next): @@ -84,7 +99,25 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: @app.get("/api/status") async def status(): - return {"controller": controller.status(), "camera": camera.status()} + return {"controller": controller.status(), "camera": camera.status(), + "detection": detection.status()} + + @app.get("/api/detection") + async def detection_result(): + return detection.snapshot() + + @app.get("/api/detection/config") + async def detection_config(): + return detection.get_config() + + @app.post("/api/detection/config", dependencies=[Depends(require_ui)]) + async def configure_detection(payload: dict): + try: + return detection.configure(payload) + except (ValueError, TypeError, KeyError, IndexError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc @app.get("/api/events") async def recent_events(limit: int = Query(20, ge=1, le=100)): @@ -165,6 +198,7 @@ def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: @app.post("/api/reset", dependencies=[Depends(require_ui)]) async def reset(): controller.reset() + detection.reset() return controller.status() @app.get("/snapshot.jpg") diff --git a/requirements-detection.txt b/requirements-detection.txt new file mode 100644 index 0000000..1c03b15 --- /dev/null +++ b/requirements-detection.txt @@ -0,0 +1,6 @@ +# Separat installieren; Demo und manuelle Tests benötigen kein OpenCV. +# Desktop-Wheels vermeiden, damit Picamera2/Qt nicht beeinflusst wird. +# Ohne passende Wheels abbrechen statt auf dem Pi aus Quellen zu kompilieren. +--only-binary=:all: +opencv-python-headless>=4.10,<5 +numpy>=1.26,<3 diff --git a/tests/instructions.md b/tests/instructions.md index 058b7e7..d1e4c12 100644 --- a/tests/instructions.md +++ b/tests/instructions.md @@ -14,3 +14,13 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware. aus einem grünen Host-Test ableiten; diese Prüfungen getrennt berichten. - Tests für neue Sicherheits- oder Zustandsregeln an beobachtbarem Verhalten ausrichten, nicht bloß interne Implementierung spiegeln. + +- `test_detection.py` prüft Linienrichtung, Bestätigung, Flattern, Segmentgrenzen, + Gruppenfreigabe, Sperrzeit, Neustart/Änderungen, Fehler-Watchdog, synchrone + Frames und Konfigurations-API mit kontrollierter Uhr ohne Modell/Pi. +- Echte NanoDet-Inferenz zusätzlich mit ausdrücklich installiertem Modell + prüfen; ein grüner Geometrietest bestätigt keine Erkennungsqualität. + +- `test_detection_model.py` prüft atomare/identische Installation und Ablehnung + falscher Downloads ohne Netzwerk. Optional `HALLOWEEN_TEST_MODEL` auf eine + geprüfte ONNX-Datei setzen: Dann wird die echte Inferenz auf leerem Bild geprüft. diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 0000000..0b0fd3b --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,300 @@ +import asyncio +from io import BytesIO +import json +from pathlib import Path +import tempfile +import threading +import unittest + +from PIL import Image +import httpx + +from halloween.controller import Controller +from halloween.detection import DEFAULT_CONFIG, DetectionService, LineTracker, NanoDet, validate_config +from halloween.event_store import EventStore +from halloween.web import create_app + + +def person(x=0.5, y=0.4): + return {"box": [x-0.03, y-0.1, x+0.03, y], "score": 0.9} + + +class TrackerTest(unittest.TestCase): + def setUp(self): + self.tracker = LineTracker() + self.config = json.loads(json.dumps(DEFAULT_CONFIG)) + self.time = 0 + + def step(self, people): + self.time += 0.2 + return self.tracker.update(people, self.config, self.time)[1] + + def test_forward_and_reverse(self): + for _ in range(3): + self.assertFalse(self.step([person()])) + self.assertTrue(self.step([person(y=0.65)])) + self.assertFalse(self.step([person(y=0.4)])) + self.assertFalse(self.step([person(y=0.4)])) + + def test_reverse_direction_configuration(self): + self.config['direction'] = -1 + for _ in range(3): + self.assertFalse(self.step([person(y=0.65)])) + self.assertTrue(self.step([person(y=0.4)])) + + def test_appearance_behind_line(self): + for _ in range(5): + self.assertFalse(self.step([person(y=0.65)])) + + def test_jitter_and_parallel_motion(self): + for y in [0.54, 0.56, 0.54, 0.56, 0.54, 0.56]: + self.assertFalse(self.step([person(y=y)])) + for x in [0.45, 0.5, 0.55, 0.6]: + self.assertFalse(self.step([person(x=x, y=0.4)])) + + def test_flicker_and_outside_segment(self): + for _ in range(3): + self.step([person()]) + self.step([]) + self.assertFalse(self.step([person(y=0.65)])) + self.setUp() + for _ in range(3): + self.step([person(x=0.15)]) + self.assertFalse(self.step([person(x=0.15, y=0.65)])) + + +class ServiceTest(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.directory = Path(self.folder.name) + self.time = 0 + self.controller = Controller(self.directory, clock=lambda: self.time) + self.events = EventStore(self.directory) + self.camera = type('Camera', (), {'error': None})() + self.service = DetectionService(self.camera, self.controller, self.events, + self.directory, clock=lambda: self.time) + self.service.configure({**DEFAULT_CONFIG, 'enabled': True}) + buffer = BytesIO() + Image.new('RGB', (640, 480)).save(buffer, format='JPEG') + self.jpeg = buffer.getvalue() + self.number = 0 + + def step(self, people=(), dt=0.2): + self.time += dt + self.number += 1 + self.service.process(list(people), self.number, self.jpeg, self.time, self.service.revision) + + def free(self): + for _ in range(12): + self.step() + + def cross(self, x=0.5): + for _ in range(3): + self.step([person(x=x)]) + self.step([person(x=x, y=0.65)]) + + def test_group_cooldown_and_new_group(self): + self.free() + self.assertTrue(self.service.status()['armed']) + self.cross() + self.assertEqual(len(self.events.recent()), 1) + event = self.events.recent()[0] + self.assertEqual(event['kind'], 'person_line_crossing') + self.assertIsNone(event['profile_id']) + self.cross(x=0.65) + self.assertEqual(len(self.events.recent()), 1) + for _ in range(60): + self.step([person(y=0.65)]) + self.cross(x=0.65) + self.assertEqual(len(self.events.recent()), 1) + self.free() + self.cross() + self.assertEqual(len(self.events.recent()), 2) + + def test_no_delayed_crossing(self): + self.free() + self.cross() + self.free() + self.cross(x=0.65) + self.assertEqual(len(self.events.recent()), 1) + for _ in range(60): + self.step([person(x=0.65, y=0.65)]) + self.assertEqual(len(self.events.recent()), 1) + + def test_start_stop_reset_and_configuration(self): + self.cross() + self.assertEqual(self.events.recent(), []) + self.free() + self.controller.stop() + self.cross() + self.assertEqual(self.events.recent(), []) + self.controller.reset() + self.service.reset() + self.cross() + self.assertEqual(self.events.recent(), []) + self.free() + old_revision = self.service.revision + self.service.configure(self.service.get_config()) + self.service.process([], 99, self.jpeg, self.time, old_revision) + self.assertIsNone(self.service.last_valid) + self.cross() + self.assertEqual(self.events.recent(), []) + self.free() + self.cross() + self.assertEqual(len(self.events.recent()), 1) + + def test_watchdog_and_manual_bypass(self): + self.free() + self.time += 2.01 + self.service.watchdog() + self.assertTrue(self.controller.stopped) + self.assertTrue(self.service.status()['reset_required']) + self.controller.zone_clear() + self.assertFalse(self.controller.trigger()[0]) + self.free() + self.assertTrue(self.service.status()['reset_required']) + self.controller.reset() + self.service.reset() + self.free() + self.cross() + self.assertEqual(len(self.events.recent()), 1) + + def test_camera_error_and_stale_results(self): + self.free() + self.camera.error = 'camera disconnected' + self.service.watchdog() + self.assertTrue(self.service.fault) + self.assertTrue(self.controller.stopped) + self.time += 3 + result = self.service.snapshot() + self.assertEqual(result['people'], []) + self.assertIsNone(result['image_base64']) + + def test_disabled_detection_does_not_stop_manual_tests(self): + self.service.configure(DEFAULT_CONFIG) + self.service.fail('missing model') + self.assertFalse(self.controller.stopped) + self.controller.zone_clear() + self.assertTrue(self.controller.trigger()[0]) + + def test_inference_stall_is_stopped_by_independent_watchdog(self): + entered, release = threading.Event(), threading.Event() + class BlockingDetector: + def detect(inner, jpeg): + entered.set() + release.wait(3) + return [] + self.camera.latest_timed = lambda: (1, self.jpeg, self.time) + self.service.factory = lambda path: BlockingDetector() + self.service.start() + try: + self.assertTrue(entered.wait(1)) + self.time += 2.1 + self.service.watchdog() + self.assertTrue(self.controller.stopped) + self.assertTrue(self.service.fault) + finally: + release.set() + self.service.stop() + + def test_database_stall_does_not_block_watchdog(self): + entered, release = threading.Event(), threading.Event() + def blocked_record(mode): + entered.set() + release.wait(3) + self.events.record_detection_trigger = blocked_record + self.free() + for _ in range(3): + self.step([person()]) + worker = threading.Thread(target=lambda: self.step([person(y=0.65)])) + worker.start() + try: + self.assertTrue(entered.wait(1)) + self.time += 2.1 + self.service.watchdog() + self.assertTrue(self.controller.stopped) + finally: + release.set() + worker.join(timeout=1) + self.assertFalse(worker.is_alive()) + + def test_database_failure_stops_simulation(self): + def broken_record(mode): + raise OSError('disk full') + self.events.record_detection_trigger = broken_record + self.free() + self.cross() + self.assertTrue(self.controller.stopped) + self.assertTrue(self.service.fault) + self.assertIn('disk full', self.service.status()['error']) + + def test_shutdown_drops_inflight_frame(self): + self.free() + version = self.service.revision + self.service.stop() + self.service.process([], 99, self.jpeg, self.time, version) + self.assertTrue(self.controller.stopped) + self.assertEqual(self.events.recent(), []) + + def test_persistence_and_corrupt_settings(self): + other = DetectionService(self.camera, self.controller, self.events, self.directory) + self.assertTrue(other.get_config()['enabled']) + self.assertFalse(other.status()['armed']) + self.service.path.write_text('{') + other = DetectionService(self.camera, self.controller, self.events, self.directory) + self.assertFalse(other.get_config()['enabled']) + self.assertIsNotNone(other.status()['error']) + + def test_unconfirmed_person_prevents_clear(self): + for _ in range(30): + self.step([person()]) + self.assertFalse(self.service.status()['armed']) + + def test_snapshot_matches_number_and_image(self): + self.step([person()]) + snapshot = self.service.snapshot() + self.assertEqual(snapshot['frame_number'], self.number) + self.assertTrue(snapshot['image_base64']) + self.assertEqual(len(snapshot['people']), 1) + + +class ApiTest(unittest.TestCase): + def test_config_validation_persistence_and_missing_model(self): + async def scenario(): + with tempfile.TemporaryDirectory() as folder: + app = create_app(demo=True, state_dir=Path(folder)) + async with app.router.lifespan_context(app): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + headers = {'X-Halloween-UI': '1'} + config = (await client.get('/api/detection/config')).json() + self.assertFalse(config['enabled']) + self.assertEqual((await client.post('/api/detection/config', json=config)).status_code, 403) + bad = {**config, 'region': [0.8,0.2,0.3,0.9]} + self.assertEqual((await client.post('/api/detection/config', json=bad, headers=headers)).status_code, 422) + config['enabled'] = True + self.assertEqual((await client.post('/api/detection/config', json=config, headers=headers)).status_code, 200) + for _ in range(30): + await asyncio.sleep(0.05) + status = (await client.get('/api/status')).json() + if status['detection']['reset_required']: + break + self.assertTrue(status['detection']['reset_required']) + self.assertTrue(status['controller']['stopped']) + self.assertIn('NanoDet fehlt', status['detection']['error']) + result = (await client.get('/api/detection')).json() + self.assertIsNone(result['image_base64']) + self.assertTrue(create_app(demo=True, state_dir=Path(folder)).state.detection.get_config()['enabled']) + asyncio.run(scenario()) + + def test_invalid_values(self): + for config in [ {**DEFAULT_CONFIG, 'direction': True}, + {**DEFAULT_CONFIG, 'line': [[0.5,0.5],[0.5,0.5]]}, + {**DEFAULT_CONFIG, 'region': [0,0,float('nan'),1]}, + {**DEFAULT_CONFIG, 'enabled': 'true'}]: + with self.assertRaises(ValueError): + validate_config(config) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_detection_model.py b/tests/test_detection_model.py new file mode 100644 index 0000000..2e4107b --- /dev/null +++ b/tests/test_detection_model.py @@ -0,0 +1,66 @@ +"""Installer ohne Netzwerk; optionaler Smoke-Test mit echten Modellgewichten.""" +from contextlib import redirect_stdout +import hashlib +import importlib.util +from io import BytesIO, StringIO +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from PIL import Image + +from halloween.detection import NanoDet + + +class InstallerTest(unittest.TestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location('model_installer', Path(__file__).parents[1] / 'tools/install_detection_model.py') + self.installer = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.installer) + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.data = b'test-model' + self.manifest = {'file': 'model.onnx', 'sha256': hashlib.sha256(self.data).hexdigest(), + 'size': len(self.data), 'url': 'https://example.invalid/model'} + self.target = Path(self.folder.name) / 'models/model.onnx' + + def install(self, data): + with patch.object(self.installer.json, 'loads', return_value=self.manifest), \ + patch.object(self.installer.urllib.request, 'urlopen', return_value=BytesIO(data)) as download, \ + patch('sys.argv', ['installer', '--state-dir', self.folder.name]), redirect_stdout(StringIO()): + self.installer.main() + return download + + def test_verified_download_is_atomic_and_idempotent(self): + self.assertEqual(self.install(self.data).call_count, 1) + self.assertEqual(self.target.read_bytes(), self.data) + self.assertEqual(self.install(self.data).call_count, 0) + self.assertEqual(list(self.target.parent.iterdir()), [self.target]) + + def test_bad_download_keeps_existing_file(self): + self.target.parent.mkdir() + self.target.write_bytes(b'old-corrupt-model') + with self.assertRaises(ValueError): + self.install(b'bad-model!') + self.assertEqual(self.target.read_bytes(), b'old-corrupt-model') + self.assertEqual(list(self.target.parent.iterdir()), [self.target]) + + def test_oversized_download_is_rejected_and_cleaned(self): + with self.assertRaises(ValueError): + self.install(self.data+b'extra') + self.assertEqual(list(self.target.parent.iterdir()), []) + + +@unittest.skipUnless(os.environ.get('HALLOWEEN_TEST_MODEL'), 'Optionales Modell nicht angegeben') +class ModelSmokeTest(unittest.TestCase): + def test_real_model_outputs_and_empty_frame(self): + model = NanoDet(Path(os.environ['HALLOWEEN_TEST_MODEL'])) + frame = BytesIO() + Image.new('RGB', (640,480)).save(frame, format='JPEG') + self.assertEqual(model.detect(frame.getvalue()), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/install_detection_model.py b/tools/install_detection_model.py new file mode 100644 index 0000000..6eb8a09 --- /dev/null +++ b/tools/install_detection_model.py @@ -0,0 +1,47 @@ +"""Expliziter, SHA-256-geprüfter Download des lokalen Erkennungsmodells.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import tempfile +import urllib.request + + +def main(): + root = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state-dir", type=Path, default=Path(os.environ.get("HALLOWEEN_STATE_DIR", root / "data"))) + args = parser.parse_args() + manifest = json.loads((root / "halloween/nanodet-model.json").read_text()) + target = args.state_dir / "models" / manifest["file"] + if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == manifest["sha256"]: + print(f"Modell bereits geprüft: {target}") + return + target.parent.mkdir(parents=True, exist_ok=True) + temp = None + try: + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as output: + temp = Path(output.name) + digest, size = hashlib.sha256(), 0 + with urllib.request.urlopen(manifest["url"], timeout=30) as response: + while block := response.read(65536): + size += len(block) + if size > manifest["size"]: + raise ValueError("Modell größer als erwartet") + digest.update(block) + output.write(block) + if size != manifest["size"] or digest.hexdigest() != manifest["sha256"]: + raise ValueError("Modell-Prüfsumme oder Größe stimmt nicht") + output.flush() + os.fsync(output.fileno()) + os.replace(temp, target) + print(f"Modell installiert und SHA-256 geprüft: {target}") + finally: + if temp is not None: + temp.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/tools/instructions.md b/tools/instructions.md index aa5a866..acb0765 100644 --- a/tools/instructions.md +++ b/tools/instructions.md @@ -23,3 +23,8 @@ unter `preview/` dokumentieren. Fiktive Scan-Videos nutzen nur synthetische Avatare und Demo-IDs. Kegelprojektion, Zeichnen der Bildsequenz und Videoschnitt sind getrennte Schritte; Reihenfolge im zugehörigen Vorschau-README dokumentieren. + +- `install_detection_model.py` lädt auf ausdrücklichen Aufruf das im Manifest + fixierte NanoDet-Modell unter `data/models/`. Größe und SHA-256 vor atomarem + Ersetzen prüfen, TLS-Prüfung beibehalten. `--state-dir` bzw. + `HALLOWEEN_STATE_DIR` muss zum Server passen. Keine Modellgewichte hochladen.