commit e071fbad2a1c1345186198d3eb201c0f31d31f85 Author: Marcinator2 Date: Thu Oct 1 03:19:17 2026 +0200 Initiales Halloween-Projekt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d49cec1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Lokale Python-Umgebung und Laufzeitdaten +.venv/ +__pycache__/ +*.py[cod] +data/ +.env +.env.* +*.pem +*.key +*.p12 +*.pfx + +# Referenzclip und gerenderte Vorschauen bleiben lokal. +references/ +preview/**/*.blend1 +preview/**/*.png +preview/**/*.jpg +preview/**/*.jpeg +preview/**/*.mp4 +preview/**/*.webm +preview/**/*.wav diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5271d31 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,93 @@ +# 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. +- 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. +- 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. + +## 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. Weitere Teile dürfen vorgeschlagen werden. +- 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. +- Mechanik erst nach Klärung von Abmessungen, Gewicht, Hub und Bewegungsraum + auswählen. Atembewegung und kurzer Schreckimpuls getrennt bewerten. + +## 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, + Zugentlastung und geeignete Stromversorgung vorsehen. +- Bei Start, Verbindungsverlust oder Fehler keine neue Schrecksequenz + auslösen; definierte sichere Zustände und lokalen Stopp vorsehen. +- 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 + +- Personen als Ereignisse erkennen, ohne Identitäten zu bestimmen. +- Keine Gesichtserkennung oder automatische Wiedererkennung konkreter + Personen über getrennte Videos hinweg implementieren. +- 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. +- 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. +- Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben. +- 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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8e333b4 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,395 @@ +# Halloween: interaktives atmendes Grab + +Stand: 1. 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; sie identifiziert keine Personen. + +## 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 + Hostname und Erreichbarkeit sind noch nicht geprüft. +- „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. +- 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. +- Wunsch nach Prüfung verdächtiger Rückkehr: Ereignisübersicht und manuelles + Vergleichen der Clips; keine automatische personenübergreifende Identifizierung. + +## 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. + +**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. + +## Vorgeschlagene Architektur + +| Komponente | Aufgabe | +|---|---| +| Pi 5 | Kamera, anonyme Personenerkennung, 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` – anonyme Personen-/Bewegungserkennung +- `events.py` – Ereignisverwaltung und Clip-Speicherung +- `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. +- [ ] Livebild und geschützten lokalen Zugriff bereitstellen. +- [ ] Person-/Bereichserkennung ohne Identitätsbestimmung einrichten. +- [ ] 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? + +## Fortschritt + +- [x] Projektregeln und erster Plan angelegt. +- [x] Allgemeines Uploadskript und Anleitung für Projektdateien vorbereitet. +- [x] Erste Kamera-WebUI mit Demo-Feed und simulierter Steuerung erstellt; + fünf lokale Softwaretests bestanden. +- [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. +- [ ] 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. Pi-Kamera, SSH-Tunnel, +Netz, Verdrahtung, Aktoren und Außeneinsatz sind nicht praktisch verifiziert. +Das Upload- und Autostartskript wurde nur lokal geprüft; eine Verbindung zum +Pi, die Unit-Installation und ein Neustart wurden noch nicht getestet. +Einkaufslisten mit konkreten Modellen folgen nach Phase 1. + + +## 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. diff --git a/SOFTWARE.md b/SOFTWARE.md new file mode 100644 index 0000000..984b9cf --- /dev/null +++ b/SOFTWARE.md @@ -0,0 +1,89 @@ +# Erste Pi-Softwareversion + +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. Automatische Personenerkennung, Clips, +Maskierung und Löschfristen sind noch nicht umgesetzt. Daher vorerst nur +im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen +richten. + +## Auf dem Raspberry Pi 5 vorbereiten + +Die Kamera mit passendem Kabel anschließen und mit `rpicam-hello --list-cameras` +prüfen. Danach im Projektordner: + +```bash +sudo apt update +sudo apt install python3-picamera2 python3-venv +python3 -m venv --system-site-packages .venv +.venv/bin/python -m pip install -r requirements.txt +``` + +Picamera2 wird über die Raspberry-Pi-Pakete installiert; die virtuelle +Umgebung kann dieses Systempaket durch `--system-site-packages` verwenden. +Der Server bindet nur an `127.0.0.1` auf dem Pi. So ist das Livebild nicht +direkt im WLAN sichtbar. + +### WebUI beim Hochfahren starten + +Nach der Vorbereitung kann `tools/install_pi_service.sh` auf dem Pi einen +systemd-Dienst einrichten. Erst eine Vorschau ansehen, dann anwenden: + +```bash +bash tools/install_pi_service.sh +bash tools/install_pi_service.sh --apply +systemctl status halloween-grab.service +``` + +Der Dienst läuft unter dem aktuellen Pi-Benutzer, startet `run.py` beim +Hochfahren und wird bei einem Fehler neu gestartet. Das Skript benötigt für +die Einrichtung `sudo` und überschreibt keine fremde Unit gleichen Namens. +Nach Code-Updates kann derselbe `--apply`-Aufruf den Dienst neu starten. +Zum Deaktivieren: `sudo systemctl disable --now halloween-grab.service`. +Bei Startproblemen: `sudo journalctl -u halloween-grab.service -n 80 --no-pager`. +Ein Browserfenster auf dem Laptop öffnet sich dadurch nicht automatisch. + +## Vom Laptop aus ansehen + +Mit dem geprüften Pi-Benutzer und Hostnamen ein SSH-Terminal offen lassen: + +```bash +ssh -L 8000:127.0.0.1:8000 BENUTZER@HOST +``` + +Dann auf dem Laptop `http://127.0.0.1:8000` öffnen. Ein SSH-Passwort reicht; +ein Schlüssel ist nicht nötig. Ist Port 8000 auf dem Laptop belegt, links +vom ersten Doppelpunkt einen anderen freien Port wählen; rechts bleibt der +Pi-Port 8000, solange `run.py` dort mit dem Standardport läuft. + +## Ohne Kamera testen + +Auf einem Rechner mit den Paketen aus `requirements.txt`: + +```bash +python3 run.py --demo +``` + +Die Bildfläche trägt deutlich „DEMO – KEINE ECHTE KAMERA“. Über „Bereich +frei (Test)“ und danach „Testauslösung“ lässt sich die Zustandsfolge ohne +Aktoren durchspielen. „Stopp“ sperrt weitere Auslösungen bis „Zurücksetzen“ +und erneuter Freigabe des Bereichs. Der Modus „voller Schreck“ ist nur bei +noch nicht vorhandenen Einstellungen der Standard. Ein WebUI-Wechsel wird +unter `data/settings.json` gespeichert und bleibt bei normalem Neustart +erhalten. Das Uploadskript überträgt diesen Laufzeitordner nicht. + +## Lokal prüfen + +Für die Webtests zusätzlich `httpx` installieren: + +```bash +.venv/bin/python -m pip install -r requirements-dev.txt +.venv/bin/python -m unittest discover -s tests -v +``` + +Der Host-Test prüft Demo-Bild und Webendpunkte. Kamera, Nachtbild, WLAN, +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/). diff --git a/UPLOAD.md b/UPLOAD.md new file mode 100644 index 0000000..85fd7e1 --- /dev/null +++ b/UPLOAD.md @@ -0,0 +1,66 @@ +# Projektdateien auf einen Raspberry Pi kopieren + +Das Skript `tools/upload_to_pi.sh` kopiert den gewählten lokalen Ordner per +SSH und `rsync` auf **einen** Pi. Ohne `--source` ist das Projektverzeichnis +die Quelle. Es löscht keine Dateien auf dem Pi. Die Pi-Adresse, der Benutzer +und der Zielpfad sind noch nicht am Gerät geprüft und werden beim Aufruf +angegeben. + +## Voraussetzungen + +- Auf dem Laptop: `ssh` und `rsync`. +- Auf dem Pi: aktivierter SSH-Zugang und `rsync`. +- Der Benutzer muss im Zielpfad schreiben dürfen. +- Für den optionalen Autostart: Pi-Software laut `SOFTWARE.md` vorbereiten, + insbesondere `.venv` im Zielordner; `sudo` muss auf dem Pi nutzbar sein. +- Vor dem ersten Upload den Pi per `ssh BENUTZER@HOST` erreichen und den + angezeigten Hostschlüssel anhand des eigenen Geräts prüfen. Bei einem + geänderten Hostschlüssel die Ursache klären, nicht die Prüfung abschalten. + +## Ablauf + +`BENUTZER`, `HOST` und `ZIEL` durch die geprüften Werte ersetzen. `ZIEL` +muss ein absoluter Pfad ohne Leerzeichen sein. + +```bash +BENUTZER='BENUTZER' +HOST='HOST' +ZIEL='/home/BENUTZER/halloween' + +bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" +bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --apply +``` + +Der erste Aufruf zeigt nur die geplanten Befehle. Der zweite legt den +Zielordner an und überträgt die Dateien. Ein anderer Quellordner kann mit +`--source /absoluter/pfad` angegeben werden. Anschließend auf dem Pi prüfen: + +```bash +ssh "$BENUTZER@$HOST" "ls -la '$ZIEL'" +``` + +Soll die WebUI auf dem Pi nach jedem Neustart automatisch laufen, beim +Upload `--install-service` ergänzen: + +```bash +bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --install-service +bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --install-service --apply +``` + +Der erste Befehl ist wieder nur eine lokale Vorschau. Der zweite überträgt +die Dateien, installiert/aktualisiert die systemd-Unit mit `sudo` auf dem Pi +und startet den Dienst. Bei späteren Updates startet der Dienst so mit dem +neuen Code neu. Er bindet weiterhin nur an `127.0.0.1` des Pi; vom Laptop +aus ist der SSH-Tunnel aus `SOFTWARE.md` nötig. Es öffnet sich kein +Browserfenster von selbst. + +Das Skript lässt `.git`, `.venv`, `data`, `preview`, `references`, +`__pycache__`, `.env` und übliche Schlüsseldateien aus. So bleiben +Laufzeiteinstellungen auf dem Pi und die großen Konzeptdateien lokal. +Es synchronisiert vorhandene Dateien, löscht aber +keine alten Dateien auf dem Pi. Zugangsdaten und andere Geheimnisse gehören +nicht in den Quellordner. Für zwei Pis den Upload mit den jeweils geprüften +Zielwerten separat ausführen. + +Es wurde bisher keine Verbindung zu einem Pi, keine Übertragung und keine +Installation der systemd-Unit am Gerät geprüft. diff --git a/halloween/__init__.py b/halloween/__init__.py new file mode 100644 index 0000000..6fbf258 --- /dev/null +++ b/halloween/__init__.py @@ -0,0 +1 @@ +"""Lokale Steueroberfläche für das Halloween-Grab.""" diff --git a/halloween/camera.py b/halloween/camera.py new file mode 100644 index 0000000..d372223 --- /dev/null +++ b/halloween/camera.py @@ -0,0 +1,118 @@ +"""Ein Kameraproduzent für mehrere Livebild-Betrachter.""" + +from io import BytesIO +import threading +import time +from datetime import datetime + +from PIL import Image, ImageDraw + + +class CameraStream: + def __init__(self, demo: bool = False): + self.demo = demo + self._condition = threading.Condition() + self._thread = None + self._stop = threading.Event() + self._frame = None + self._number = 0 + self._time = None + self.error = None + + def start(self): + if self._thread is not None: + return + self._thread = threading.Thread(target=self._run, daemon=True, name="camera") + self._thread.start() + + def stop(self): + self._stop.set() + with self._condition: + self._condition.notify_all() + if self._thread is not None: + self._thread.join(timeout=3) + + @staticmethod + def _jpeg(image: Image.Image) -> bytes: + buffer = BytesIO() + image.save(buffer, format="JPEG", quality=75) + return buffer.getvalue() + + def _publish(self, frame: bytes): + with self._condition: + self._frame = frame + self._number += 1 + self._time = time.monotonic() + self._condition.notify_all() + + def _run(self): + try: + if self.demo: + self._run_demo() + else: + self._run_pi() + except Exception as exc: + with self._condition: + self.error = f"Kamera nicht verfügbar: {type(exc).__name__}: {exc}" + self._condition.notify_all() + + def _run_demo(self): + while not self._stop.is_set(): + image = Image.new("RGB", (640, 480), (21, 28, 32)) + draw = ImageDraw.Draw(image) + draw.rectangle((40, 105, 600, 400), outline=(144, 111, 65), width=8) + draw.ellipse((165, 205, 475, 340), fill=(45, 52, 36), outline=(110, 91, 59), width=4) + draw.text((42, 30), "DEMO - KEINE ECHTE KAMERA", fill=(255, 172, 72)) + draw.text((42, 440), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill="white") + self._publish(self._jpeg(image)) + self._stop.wait(0.2) + + def _run_pi(self): + from picamera2 import Picamera2 + + camera = Picamera2() + started = False + try: + # BGR888 liefert laut Picamera2-Dokumentation RGB-Arrays für PIL. + config = camera.create_video_configuration( + main={"size": (640, 480), "format": "BGR888"} + ) + camera.configure(config) + camera.start() + started = True + while not self._stop.is_set(): + pixels = camera.capture_array("main") + self._publish(self._jpeg(Image.fromarray(pixels, "RGB"))) + self._stop.wait(0.2) + finally: + try: + if started: + camera.stop() + finally: + camera.close() + + def latest(self): + with self._condition: + return self._frame + + def latest_numbered(self): + with self._condition: + return self._number, self._frame + + def wait_next(self, number: int, timeout: float = 10): + with self._condition: + self._condition.wait_for( + lambda: self._number > number or self.error or self._stop.is_set(), + timeout=timeout, + ) + return self._number, self._frame + + def status(self): + with self._condition: + age = None if self._time is None else round(time.monotonic() - self._time, 1) + return { + "source": "Demo" if self.demo else "Pi-Kamera", + "ready": self._frame is not None and age is not None and age < 3, + "frame_age_seconds": age, + "error": self.error, + } diff --git a/halloween/controller.py b/halloween/controller.py new file mode 100644 index 0000000..69b1467 --- /dev/null +++ b/halloween/controller.py @@ -0,0 +1,133 @@ +"""Sichere Zustandslogik für den simulierten Effektablauf.""" + +import json +import os +import tempfile +import threading +import time +from pathlib import Path + + +MODES = ("aus", "atmosphaere", "sanft", "voll") +SEQUENCE_SECONDS = 3.0 +COOLDOWN_SECONDS = 10.0 + + +class Controller: + def __init__(self, state_dir: Path, clock=time.monotonic): + self._lock = threading.RLock() + self._clock = clock + self._path = state_dir / "settings.json" + self._path.parent.mkdir(parents=True, exist_ok=True) + self.mode, self.config_error = self._load_mode() + self.stopped = False + self.zone_clear_seen = False + self.started_at = None + self.cooldown_until = 0.0 + self.last_event = None + + def _load_mode(self): + if not self._path.exists(): + return "voll", None + try: + mode = json.loads(self._path.read_text(encoding="utf-8"))["mode"] + if mode not in MODES: + raise ValueError("ungültiger Modus") + return mode, None + except (OSError, ValueError, KeyError, TypeError) as exc: + return "aus", f"Einstellungen fehlerhaft: {exc}" + + def _save_mode(self, mode: str): + temp_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", dir=self._path.parent, + prefix="settings-", suffix=".tmp", delete=False, + ) as stream: + temp_path = Path(stream.name) + json.dump({"mode": mode}, stream) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp_path, self._path) + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + def set_mode(self, mode: str): + if mode not in MODES: + raise ValueError("unbekannter Modus") + with self._lock: + self._save_mode(mode) + if mode != self.mode: + self.started_at = None + self.zone_clear_seen = False + self.mode = mode + self.config_error = None + + def zone_clear(self): + with self._lock: + if not self.stopped: + self.zone_clear_seen = True + + def trigger(self): + """Nur neuer Trigger nach freiem Bereich; bislang ohne Aktorausgabe.""" + with self._lock: + now = self._clock() + if self.stopped: + return False, "Stopp aktiv" + if self.config_error: + return False, "Einstellungen fehlerhaft" + if self.mode not in ("sanft", "voll"): + return False, "Modus löst keinen Schreck aus" + if not self.zone_clear_seen: + return False, "Auslösebereich zuerst freigeben" + if self.started_at is not None and now - self.started_at < SEQUENCE_SECONDS: + return False, "Sequenz läuft" + if now < self.cooldown_until: + return False, "Sperrzeit läuft" + self.started_at = now + self.cooldown_until = now + COOLDOWN_SECONDS + self.zone_clear_seen = False + self.last_event = {"mode": self.mode, "time": time.strftime("%Y-%m-%d %H:%M:%S")} + return True, "Simulation gestartet" + + def stop(self): + with self._lock: + self.stopped = True + self.started_at = None + self.zone_clear_seen = False + + def reset(self): + with self._lock: + self.stopped = False + self.started_at = None + self.zone_clear_seen = False + + def status(self): + with self._lock: + now = self._clock() + elapsed = None if self.started_at is None else now - self.started_at + if self.stopped: + phase = "Stopp" + elif elapsed is not None and elapsed < 0.8: + phase = "Stille" + elif elapsed is not None and elapsed < 1.7: + phase = "Kratzen (simuliert)" + elif elapsed is not None and elapsed < 2.4: + phase = "Schreck (simuliert)" if self.mode == "voll" else "Sanft (simuliert)" + elif elapsed is not None and elapsed < SEQUENCE_SECONDS: + phase = "Rückkehr (simuliert)" + elif now < self.cooldown_until: + phase = "Sperrzeit" + else: + phase = "Bereit" if self.zone_clear_seen else "Bereich frei abwarten" + return { + "mode": self.mode, + "phase": phase, + "stopped": self.stopped, + "zone_clear_seen": self.zone_clear_seen, + "cooldown_seconds": round(max(0, self.cooldown_until - now), 1), + "last_event": self.last_event, + "config_error": self.config_error, + "actuators": "nur Simulation", + } diff --git a/halloween/index.html b/halloween/index.html new file mode 100644 index 0000000..3a91bf4 --- /dev/null +++ b/halloween/index.html @@ -0,0 +1,123 @@ + + + + + + Halloween-Grab · Steuerung + + + +
+

Halloween-Grab

+

Lokale Kameraansicht und Steuerung

+
+
+
+

Livebild

+ Livebild der Kamera +

Kamera wird gestartet …

+ Aktuelles Bild herunterladen +

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

+
+
+

Betrieb

+
+
Modus
Lädt …
+
Zustand
Lädt …
+
Sperrzeit
–
+
Letzter Test
–
+
+ +
+ + +
+

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

+
+ + +
+
+ + +
+

+
+
+ + + diff --git a/halloween/web.py b/halloween/web.py new file mode 100644 index 0000000..fc9e572 --- /dev/null +++ b/halloween/web.py @@ -0,0 +1,127 @@ +"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert.""" + +from contextlib import asynccontextmanager +from pathlib import Path +import asyncio +import os + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, Response, StreamingResponse +from pydantic import BaseModel + +from .camera import CameraStream +from .controller import Controller, MODES + + +PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8") + + +class ModeInput(BaseModel): + mode: str + + +async def require_ui(request: Request): + if request.headers.get("X-Halloween-UI") != "1": + raise HTTPException(status_code=403, detail="UI-Anfrage erforderlich") + + +def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI: + directory = state_dir or Path(os.environ.get("HALLOWEEN_STATE_DIR", "data")) + camera = CameraStream(demo=demo) + controller = Controller(directory) + + @asynccontextmanager + async def lifespan(app: FastAPI): + camera.start() + yield + camera.stop() + + app = FastAPI(title="Halloween-Grab", lifespan=lifespan) + app.state.camera = camera + app.state.controller = controller + + @app.middleware("http") + async def security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["Cache-Control"] = "no-store" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + return response + + @app.get("/", response_class=HTMLResponse) + async def index(): + return PAGE + + @app.get("/api/status") + async def status(): + return {"controller": controller.status(), "camera": camera.status()} + + @app.post("/api/mode", dependencies=[Depends(require_ui)]) + async def set_mode(payload: ModeInput): + if payload.mode not in MODES: + raise HTTPException(status_code=422, detail="Unbekannter Modus") + try: + controller.set_mode(payload.mode) + except OSError as exc: + raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc + return controller.status() + + @app.post("/api/zone-clear", dependencies=[Depends(require_ui)]) + async def zone_clear(): + controller.zone_clear() + return controller.status() + + @app.post("/api/test-trigger", dependencies=[Depends(require_ui)]) + async def test_trigger(): + accepted, reason = controller.trigger() + return {"accepted": accepted, "reason": reason, "controller": controller.status()} + + @app.post("/api/stop", dependencies=[Depends(require_ui)]) + async def stop(): + controller.stop() + return controller.status() + + @app.post("/api/reset", dependencies=[Depends(require_ui)]) + async def reset(): + controller.reset() + return controller.status() + + @app.get("/snapshot.jpg") + async def snapshot(): + frame = camera.latest() + if frame is None: + raise HTTPException(status_code=503, detail="Noch kein Kamerabild") + return Response( + frame, + media_type="image/jpeg", + headers={"Content-Disposition": 'attachment; filename="halloween-snapshot.jpg"'}, + ) + + @app.get("/stream.mjpg") + async def stream(): + if camera.error: + raise HTTPException(status_code=503, detail=camera.error) + + async def frames(): + last_number = 0 + while True: + number, frame = camera.latest_numbered() + if camera.error: + return + if frame is None or number == last_number: + await asyncio.sleep(0.1) + continue + last_number = number + yield ( + b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + + str(len(frame)).encode("ascii") + + b"\r\n\r\n" + frame + b"\r\n" + ) + await asyncio.sleep(0.1) + + return StreamingResponse( + frames(), media_type="multipart/x-mixed-replace; boundary=frame" + ) + + return app diff --git a/preview/README.md b/preview/README.md new file mode 100644 index 0000000..ca20f1c --- /dev/null +++ b/preview/README.md @@ -0,0 +1,44 @@ +# Halloween – animierte Konzeptvorschau + +Die Vorschau zeigt einen beispielhaften Vorgarten, das atmende Grab, +einen sanften Familienbesuch und einen späteren Schreckeffekt für Erwachsene. +Die Besucher sind vereinfachte, fiktive 3D-Figuren in Kapuzenkostümen; +Materialien und Beleuchtung orientieren sich an einer realen Nachtszene. +Es handelt sich nicht um fotorealistische Menschen oder eine Simulation +zuverlässiger Kindererkennung. Alle Reaktionen sind voranimiert. + +## Dateien + +- `halloween_preview.mp4`: 20 Sekunden, 1280 × 720, 24 fps, mit Ton. +- `halloween_preview.blend`: bearbeitbare Szene einschließlich Animation + und gepackter, selbst erzeugter Tonspur. +- `preview_080.png`, `preview_185.png`, `preview_320.png`: drei Vorschaubilder. +- `effects.wav`: selbst erzeugte Atem-, Kratz- und Knurrgeräusche. + +## Bearbeiten und neu rendern + +Die Blender-Datei in Blender 4.0.2 oder einer kompatiblen Version öffnen. +Die Zeitleiste umfasst Frames 1–480. Atembewegung liegt im Shape Key +`Breath` der Grabfläche; Licht, Hand, Kamera und Besucher sind animiert. +Die Tonspur liegt im Video Sequencer. Das Skript `tools/render_preview.py` +rekonstruiert die Szene; Änderungen direkt in der Blender-Datei werden +beim erneuten Ausführen dieses Skripts nicht übernommen. + +Reproduzieren aus dem Projektverzeichnis: + +```sh +blender -b -t 12 --python tools/render_preview.py -- previews +blender -b -t 12 --python tools/render_preview.py -- video +``` + +Die Szene verwendet EEVEE. Maße, Garten und Bewegungen sind gestalterische +Annahmen; die Datei ist kein mechanischer Bauplan. + +## Abschlussprüfung + +MP4-Metadaten bestätigen 480 Videobilder, exakt 20 Sekunden, 1280 × 720 +Pixel und 24 fps. H.264-Videospur und AAC-Tonspur sind enthalten. Blender +hat das fertige Video erneut eingelesen; zwölf dekodierte Bilder wurden +visuell geprüft (auch Schnitt, Lichtimpuls, Hand und Schlussansicht). +`review_contact.png` zeigt die geprüften Stellen. Eine Prüfung des Tons +über Lautsprecher wurde nicht durchgeführt. diff --git a/preview/halloween_preview.blend b/preview/halloween_preview.blend new file mode 100644 index 0000000..af521cb Binary files /dev/null and b/preview/halloween_preview.blend differ diff --git a/preview/v2/README.md b/preview/v2/README.md new file mode 100644 index 0000000..92fa507 --- /dev/null +++ b/preview/v2/README.md @@ -0,0 +1,24 @@ +# Beispielbilder – zweite Fassung + +Nur drei Standbilder gerendert; das bisherige Video ist unverändert. + +- `example_140.png`: Familie kommt mit animierten Schritten näher. +- `example_200.png`: Familie vor dem laubbedeckten Grab. +- `example_320.png`: inszenierter Schreck mit Kindern, Licht und Hand. +- `halloween_examples.blend`: bearbeitbare Szene mit Laufbewegungen. + +Die Laubschicht besitzt eine zur Grabfläche synchronisierte Atemanimation. +Die Figuren besitzen bewegte Oberschenkel, Knie, Schuhe und Ärmel sowie +animiertes Zurückweichen. Dunklere Beleuchtung, rotes Grablicht, kahle +Bäume und leichter Bodennebel ergänzen die Atmosphäre. + +Die Figuren bleiben vereinfachte Kostümmodelle. Die Laufbewegung ist in der +Szene angelegt; ihre Wirkung in einem vollständigen Video ist noch nicht +geprüft. Die fiktive Schreckszene mit Kindern ist eine gestalterische +Vorschau und ändert nicht die Betriebsregeln für die echte Installation. + +Neu erzeugen aus dem Projektverzeichnis: + +```sh +blender -b -t 12 --python tools/render_examples_v2.py +``` diff --git a/preview/v2/halloween_examples.blend b/preview/v2/halloween_examples.blend new file mode 100644 index 0000000..afa3f49 Binary files /dev/null and b/preview/v2/halloween_examples.blend differ diff --git a/preview/v3/README.md b/preview/v3/README.md new file mode 100644 index 0000000..898151d --- /dev/null +++ b/preview/v3/README.md @@ -0,0 +1,15 @@ +# Mittige Hand und aufwirbelndes Laub + +Nur Beispielbilder, kein neuer Videorender: + +- `example_310.png`: unmittelbar vor der Auslösung. +- `example_322.png`: mittige Hand kommt hoch, Blätter fliegen auf. +- `example_334.png`: Blätter verteilen sich und sinken zurück. +- `halloween_examples.blend`: bearbeitbare Szene mit Animation. + +110 einzeln animierte Blätter verdecken zunächst die mittige Öffnung. +Beim Hochkommen der Hand werden sie gestaffelt nach oben und außen bewegt, +rotieren und fallen wieder herunter. Dies ist eine gestaltete Animation, +keine physikalische Simulation einer echten Mechanik. + +Reproduzieren: `blender -b -t 12 --python tools/render_examples_v3.py`. diff --git a/preview/v3/halloween_examples.blend b/preview/v3/halloween_examples.blend new file mode 100644 index 0000000..20e2a71 Binary files /dev/null and b/preview/v3/halloween_examples.blend differ diff --git a/preview/v4/README.md b/preview/v4/README.md new file mode 100644 index 0000000..4ba4ee6 --- /dev/null +++ b/preview/v4/README.md @@ -0,0 +1,43 @@ +# Dunklere Beleuchtung – Beispielbilder + +Drei Standbilder und ein vollständiges Video der dunklen Fassung. + +- `example_310.png`: dunkle Ruhephase. +- `example_322.png`: gezielter Lichtimpuls auf Hand und Laub. +- `example_334.png`: abklingender Impuls, fallende Blätter. +- `halloween_examples.blend`: bearbeitbare Szene mit bestehenden Bewegungen. + +Beleuchtung: dunkler Nachthimmel, schwaches kühles Mondlicht, warme +Lichtquellen an Fenstern und sichtbarer Hauslaterne, dezentes blaues und +rotes Grablicht. Die Laterne flackert leicht. Ein gerichteter Strahler +beleuchtet beim Schreck vor allem das Grab. Kontaktschatten, dezente +Lichtstreuung im Nebel und Reflexionen auf dem Weg ergänzen die Wirkung. + +Rendering in EEVEE, 1280 × 720. Die Figuren bleiben vereinfachte +Kostümmodelle; dies ist keine fotorealistische Personenaufnahme. + +Reproduzieren: `blender -b -t 12 --python tools/render_examples_v4.py`. + +## Video + +- `halloween_dark.mp4`: 20 Sekunden, 1280 × 720, 24 fps, H.264 und AAC-Ton. +- `halloween_dark.blend`: bearbeitbare Szene mit Videoexport-Einstellungen. +- `review_contact.png`: zwölf aus dem fertigen Video dekodierte Prüfbilder. + +Alle vier Besucher besitzen animierte Oberschenkel, Knie und Ärmel. +Die Bewegungen sind einfach gehalten und durch Kostüme und Dunkelheit +teilweise verdeckt. Bewegte Gelenke wurden an zwei Zeitpunkten geprüft; +Gehsequenz, Laubwirbel, Hand und Schlussbild anhand exportierter Bilder +kontrolliert. Die Tonspur ist enthalten, wurde nicht über Lautsprecher geprüft. + +Videorender: `blender -b -t 12 --python tools/render_video_v4.py`. +Mit Grafikkartenzugriff rendern; ohne ihn ist diese Szene deutlich langsamer. + +## Stöhnen beim Handimpuls + +`halloween_dark_groan.mp4` ergänzt ein tiefes, selbst synthetisiertes +Stöhnen ab 13,04 Sekunden mit kurzem Nachhall. Die Bilder wurden aus dem +bestehenden Video übernommen und erneut codiert; kein neuer 3D-Render. +`halloween_dark_groan.blend` enthält die neue gepackte Tonspur, +`effects_with_groan.wav` die Mischung als separate Audiodatei. +Die Mischung bleibt ohne digitale Übersteuerung. diff --git a/preview/v4/halloween_dark.blend b/preview/v4/halloween_dark.blend new file mode 100644 index 0000000..3ced571 Binary files /dev/null and b/preview/v4/halloween_dark.blend differ diff --git a/preview/v4/halloween_dark_groan.blend b/preview/v4/halloween_dark_groan.blend new file mode 100644 index 0000000..c3e711e Binary files /dev/null and b/preview/v4/halloween_dark_groan.blend differ diff --git a/preview/v4/halloween_examples.blend b/preview/v4/halloween_examples.blend new file mode 100644 index 0000000..d3934e2 Binary files /dev/null and b/preview/v4/halloween_examples.blend differ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..921f2f8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +httpx>=0.27,<1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5d9ffe5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115,<1 +uvicorn>=0.30,<1 +Pillow>=10,<13 diff --git a/run.py b/run.py new file mode 100644 index 0000000..60ab5df --- /dev/null +++ b/run.py @@ -0,0 +1,19 @@ +"""Startet die lokale Halloween-WebUI auf dem Pi oder im Demo-Modus.""" + +import argparse + +import uvicorn + +from halloween.web import create_app + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--demo", action="store_true", help="synthetisches Bild statt Pi-Kamera") + parser.add_argument("--port", type=int, default=8000) + args = parser.parse_args() + uvicorn.run(create_app(demo=args.demo), host="127.0.0.1", port=args.port) + + +if __name__ == "__main__": + main() diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100644 index 0000000..72b8d66 --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,58 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from halloween.controller import Controller + + +class ControllerTest(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.now = 100.0 + self.path = Path(self.folder.name) + self.controller = Controller(self.path, clock=lambda: self.now) + + def test_start_needs_clear_zone_and_fresh_trigger(self): + self.assertEqual(self.controller.status()["mode"], "voll") + self.assertFalse(self.controller.trigger()[0]) + self.controller.zone_clear() + self.assertTrue(self.controller.trigger()[0]) + self.assertFalse(self.controller.trigger()[0]) + self.now += 11 + self.assertFalse(self.controller.trigger()[0]) + self.controller.zone_clear() + self.assertTrue(self.controller.trigger()[0]) + + def test_stop_latches_until_reset_and_new_clear_zone(self): + self.controller.zone_clear() + self.controller.stop() + self.assertFalse(self.controller.trigger()[0]) + self.controller.reset() + self.assertFalse(self.controller.trigger()[0]) + self.controller.zone_clear() + self.assertTrue(self.controller.trigger()[0]) + + def test_mode_persists_and_corrupt_file_fails_closed(self): + self.controller.set_mode("sanft") + self.assertEqual(Controller(self.path).mode, "sanft") + (self.path / "settings.json").write_text("{kaputt", encoding="utf-8") + broken = Controller(self.path) + self.assertEqual(broken.mode, "aus") + self.assertIsNotNone(broken.config_error) + broken.zone_clear() + self.assertFalse(broken.trigger()[0]) + broken.set_mode("voll") + self.assertEqual(json.loads((self.path / "settings.json").read_text())["mode"], "voll") + + def test_mode_change_cancels_running_sequence(self): + self.controller.zone_clear() + self.assertTrue(self.controller.trigger()[0]) + self.controller.set_mode("aus") + self.assertFalse(self.controller.trigger()[0]) + self.assertFalse(self.controller.status()["zone_clear_seen"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..3325e11 --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,54 @@ +import asyncio +import tempfile +import unittest +from pathlib import Path + +import httpx + +from halloween.web import create_app + + +class WebTest(unittest.TestCase): + def test_demo_live_image_and_controls(self): + async def scenario(): + with tempfile.TemporaryDirectory() as folder: + app = create_app(demo=True, state_dir=Path(folder)) + app.state.camera.start() + try: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://local") as client: + page = await client.get("/") + self.assertEqual(page.status_code, 200) + self.assertIn("/stream.mjpg", page.text) + self.assertIn("Aktoren nur simuliert", page.text) + for _ in range(30): + if (await client.get("/api/status")).json()["camera"]["ready"]: + break + await asyncio.sleep(0.05) + image = await client.get("/snapshot.jpg") + self.assertEqual(image.status_code, 200) + self.assertTrue(image.content.startswith(b"\xff\xd8")) + self.assertEqual((await client.post("/api/stop")).status_code, 403) + headers = {"X-Halloween-UI": "1"} + self.assertEqual((await client.post("/api/stop", headers=headers)).status_code, 200) + self.assertTrue((await client.get("/api/status")).json()["controller"]["stopped"]) + self.assertEqual((await client.post("/api/reset", headers=headers)).status_code, 200) + result = await client.post( + "/api/mode", headers=headers, json={"mode": "sanft"} + ) + self.assertEqual(result.status_code, 200) + self.assertEqual((await client.get("/api/status")).json()["controller"]["mode"], "sanft") + + route = next(route for route in app.routes if route.path == "/stream.mjpg") + response = await route.endpoint() + first = await response.body_iterator.__anext__() + self.assertTrue(first.startswith(b"--frame\r\nContent-Type: image/jpeg")) + await response.body_iterator.aclose() + finally: + app.state.camera.stop() + + asyncio.run(scenario()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/add_dark_groan.py b/tools/add_dark_groan.py new file mode 100644 index 0000000..d88eabf --- /dev/null +++ b/tools/add_dark_groan.py @@ -0,0 +1,50 @@ +"""Add a low synthetic vocal moan at the hand emergence and export via VSE.""" +import os, math, wave, array, random +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview','v4');random.seed(49) +with wave.open(os.path.join(ROOT,'preview','effects.wav'),'rb') as w: + rate=w.getframerate();samples=array.array('h',w.readframes(w.getnframes())) +moan=[0.0]*len(samples);phase=0;filtered=0;start=13.04;length=3.15 +for i in range(int(length*rate)): + t=i/rate;pitch=92-25*(t/length)+2.2*math.sin(t*6.5) + phase+=math.tau*pitch/rate + # Harmonic vowel with resonances, descending pitch, and breath roughness. + value=0 + for h in range(1,15): + freq=h*pitch;weight=.35*math.exp(-((freq-330)/160)**2)+.16*math.exp(-((freq-720)/210)**2) + value+=weight*math.sin(h*phase)/math.sqrt(h) + value+=.17*math.sin(phase)+.06*math.sin(phase*.5) + filtered=.92*filtered+.08*random.uniform(-1,1) + envelope=min(1,t/.16)*min(1,(length-t)/.65)*(.7+.3*math.sin(math.pi*t/length)) + idx=int(start*rate)+i + if idx=0:value+=gain*moan[index] + mixed=old/32768+value + output.append(int(max(-.98,min(.98,mixed))*32767)) +assert max(abs(x) for x in output)<32767 +path=os.path.join(OUT,'effects_with_groan.wav') +with wave.open(path,'wb') as w:w.setparams((1,2,rate,len(output),'NONE','not compressed'));w.writeframes(array.array('h',output).tobytes()) +print('GROAN: 13.04 to 16.19 seconds; peak',max(abs(x) for x in output)/32768) +# When invoked with Blender, update a separate editable copy and export video. +try:import bpy +except ImportError:raise SystemExit(0) +bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend')) +s=bpy.context.scene +for strip in list(s.sequence_editor.sequences): + if strip.type=='SOUND':s.sequence_editor.sequences.remove(strip) +s.sequence_editor.sequences.new_sound('Dark moan and ambience',path,channel=1,frame_start=1) +bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_dark_groan.blend')) +# Reuse the already rendered movie; do not render the 3D scene again. +bpy.ops.wm.read_factory_settings(use_empty=True);s=bpy.context.scene +s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100;s.render.fps=24;s.frame_start=1;s.frame_end=480 +s.view_settings.view_transform='Standard';s.view_settings.look='Medium High Contrast' if False else 'None' +s.sequence_editor_create();s.sequence_editor.sequences.new_movie('Existing rendered picture',os.path.join(OUT,'halloween_dark.mp4'),channel=1,frame_start=1) +s.sequence_editor.sequences.new_sound('New mixed sound',path,channel=2,frame_start=1) +s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='HIGH';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192 +s.render.filepath=os.path.join(OUT,'halloween_dark_groan.mp4');bpy.ops.render.render(animation=True) diff --git a/tools/check_preview.py b/tools/check_preview.py new file mode 100644 index 0000000..eaa10b2 --- /dev/null +++ b/tools/check_preview.py @@ -0,0 +1,35 @@ +"""Inspect MP4 track metadata without external tools.""" +import json, struct, sys +from pathlib import Path + +def boxes(data): + offset=0 + while offset+8<=len(data): + size,kind=struct.unpack_from('>I4s',data,offset);header=8 + if size==1:size=struct.unpack_from('>Q',data,offset+8)[0];header=16 + if size==0:size=len(data)-offset + if size
len(data):raise ValueError('Invalid MP4 box') + yield kind.decode('ascii'), data[offset+header:offset+size] + offset+=size + +def child(data,name):return next(body for kind,body in boxes(data) if kind==name) + +def inspect(path): + moov=child(Path(path).read_bytes(),'moov');result=[] + for kind,trak in boxes(moov): + if kind!='trak':continue + mdia=child(trak,'mdia');hdlr=child(mdia,'hdlr');kind=hdlr[8:12].decode() + mdhd=child(mdia,'mdhd') + if mdhd[0]==0:scale,duration=struct.unpack_from('>II',mdhd,12) + else:scale=struct.unpack_from('>I',mdhd,20)[0];duration=struct.unpack_from('>Q',mdhd,24)[0] + stbl=child(child(mdia,'minf'),'stbl');stsz=child(stbl,'stsz');count=struct.unpack_from('>I',stsz,8)[0] + desc=child(stbl,'stsd');entry=next(boxes(desc[8:]));seconds=duration/scale + item={'type':kind,'codec':entry[0],'duration_seconds':seconds,'samples':count} + if kind=='vide': + width,height=struct.unpack_from('>HH',entry[1],24);item.update(width=width,height=height,fps=count/seconds) + assert (width,height,count)==(1280,720,480),item + assert abs(seconds-20)<.01,item + result.append(item) + assert any(t['type']=='soun' for t in result),'Missing audio track' + return result +if __name__=='__main__':print(json.dumps(inspect(sys.argv[1]),indent=2)) diff --git a/tools/install_pi_service.sh b/tools/install_pi_service.sh new file mode 100644 index 0000000..b07c42d --- /dev/null +++ b/tools/install_pi_service.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +service_name=halloween-grab.service +unit_path="/etc/systemd/system/$service_name" +apply=false + +usage() { + cat <<'EOF' +Richtet die Halloween-WebUI als systemd-Dienst auf dem Raspberry Pi ein. + +Aufruf: bash tools/install_pi_service.sh [--apply] + +Ohne --apply wird nur die geplante Unit gezeigt. --apply installiert sie mit +sudo, aktiviert den Autostart und startet die WebUI sofort neu. +EOF +} + +die() { + printf 'Fehler: %s\n' "$*" >&2 + exit 2 +} + +while (($#)); do + case $1 in + --apply) apply=true; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Unbekanntes Argument: $1" ;; + esac +done + +service_user=$(id -un) +[[ $service_user =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || die 'Ungeeigneter Benutzername für systemd' +[[ $service_user != root ]] || die 'Skript als normaler Pi-Benutzer ausführen, nicht als root' +[[ $project_dir =~ ^/[a-zA-Z0-9_./-]+$ ]] || die 'Projektpfad enthält für die Unit ungeeignete Zeichen' +[[ /$project_dir/ != *'/../'* && /$project_dir/ != *'/./'* ]] || die 'Projektpfad enthält . oder .. Segmente' + +unit_contents=$(cat </dev/null || die 'sudo fehlt' +command -v systemctl >/dev/null || die 'systemctl fehlt' + +if sudo test -e "$unit_path" && + ! sudo grep -qF '# Managed by Halloween project: tools/install_pi_service.sh' "$unit_path"; then + die "Vorhandene fremde Unit wird nicht überschrieben: $unit_path" +fi + +temp_unit=$(mktemp) +trap 'rm -f -- "$temp_unit"' EXIT +printf '%s\n' "$unit_contents" > "$temp_unit" +sudo install -m 0644 -- "$temp_unit" "$unit_path" +sudo systemctl daemon-reload +sudo systemctl enable "$service_name" +sudo systemctl restart "$service_name" +if ! systemctl is-active --quiet "$service_name"; then + die "Dienst läuft nicht; Diagnose: sudo journalctl -u $service_name -n 80 --no-pager" +fi +printf 'Autostart aktiviert und Dienst gestartet. Status: systemctl status %s\n' "$service_name" diff --git a/tools/render_examples_v2.py b/tools/render_examples_v2.py new file mode 100644 index 0000000..7c6143e --- /dev/null +++ b/tools/render_examples_v2.py @@ -0,0 +1,106 @@ +"""Improve the existing concept scene; render still examples only.""" +import bpy, math, random, os +from mathutils import Vector +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview','v2');os.makedirs(OUT,exist_ok=True) +bpy.ops.wm.open_mainfile(filepath=os.path.join(ROOT,'preview','halloween_preview.blend')) +s=bpy.context.scene;s.eevee.taa_render_samples=32 +random.seed(104) +def material(name,c,rough=.8): + m=bpy.data.materials.new(name);m.use_nodes=True + p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*c,1);p.inputs['Roughness'].default_value=rough + return m +leafm=[material('Dry oak leaves '+str(i),c) for i,c in enumerate([(.20,.055,.012),(.32,.14,.032),(.10,.042,.013),(.40,.23,.075),(.16,.08,.025)])] +shroud=bpy.data.objects['Breathing burial shroud'];base=shroud.data.shape_keys.key_blocks['Rest'];breath=shroud.data.shape_keys.key_blocks['Breath'] +# A real opening at the hand's emergence point, rather than intersecting cloth. +import bmesh +bm=bmesh.new();bm.from_mesh(shroud.data) +for face in list(bm.faces): + c=face.calc_center_median() + if .48.43 and y<-.91:continue + angle=random.random()*math.tau;size=random.uniform(.045,.105);start=len(verts) + z=surface(x,y)+random.uniform(.012,.045) + coords=[(0,0,.010)] + for k in range(14): + a=k*math.tau/14;r=1 if k%2==0 else .65 + coords.append((math.cos(a)*size*r,math.sin(a)*size*.55*r,random.uniform(-.004,.014))) + lift=.23*math.exp(-((x/.50)**2+(y/.85)**2)) + for xx,yy,zz in coords: + verts.append((x+xx*math.cos(angle)-yy*math.sin(angle),y+xx*math.sin(angle)+yy*math.cos(angle),z+zz));lifts.append(lift) + idx=random.randrange(len(leafm)) + for k in range(14):faces.append((start,start+1+k,start+1+(k+1)%14));indices.append(idx) +me=bpy.data.meshes.new('Curled oak leaf litter');me.from_pydata(verts,[],faces);me.update() +leaves=bpy.data.objects.new('Leaves following breathing surface',me);s.collection.objects.link(leaves) +for m in leafm:me.materials.append(m) +for p,idx in zip(me.polygons,indices):p.material_index=idx +leaves.shape_key_add(name='Rest');lk=leaves.shape_key_add(name='Breath') +for v,lift in zip(lk.data,lifts):v.co.z+=lift +for frame in range(1,481): + s.frame_set(frame);lk.value=breath.value;lk.keyframe_insert('value',frame=frame) +# Dimmer porch and blue moonlight, red glow beneath the grave. +bpy.data.lights['Moon'].energy=220;bpy.data.lights['Porch'].energy=95;bpy.data.lights['Grave blue'].energy=95 +red=bpy.data.lights.new('Hidden red glow','POINT');red.energy=32;red.color=(1,.018,.004);red.shadow_soft_size=.28 +ob=bpy.data.objects.new('Hidden red glow',red);s.collection.objects.link(ob);ob.location=(0,-.3,.6) +# Bare branches create recognisable long, eerie shadows. +bark=material('Dark tree bark',(.025,.018,.014)) +def rod(name,a,b,r,m,parent=None): + d=Vector(b)-Vector(a);bpy.ops.mesh.primitive_cylinder_add(vertices=10,radius=r,depth=d.length,location=(Vector(a)+Vector(b))/2);o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();o.data.materials.append(m) + if parent:o.parent=parent + return o +for x,y in [(-3.3,2.5),(-4,-1.5)]: + rod('Bare tree trunk',(x,y,0),(x+.1,y,3.8),.11,bark) + for i in range(8): + h=random.uniform(1.7,3.4);tip=(x+random.uniform(-1.5,1.5),y+random.uniform(-.8,.8),h+random.uniform(.3,1)) + rod('Bare branch',(x,y,h),tip,.035,bark) + rod('Branch twig',tip,(tip[0]+.4,tip[1]-.2,tip[2]+.4),.012,bark) +# Thin ground mist; faces remain readable. +fog=bpy.data.materials.new('Thin ground mist');fog.use_nodes=True;nodes=fog.node_tree.nodes;nodes.clear();out=nodes.new('ShaderNodeOutputMaterial');vol=nodes.new('ShaderNodeVolumePrincipled');vol.inputs['Density'].default_value=.025;vol.inputs['Color'].default_value=(.45,.55,.65,1);fog.node_tree.links.new(vol.outputs['Volume'],out.inputs['Volume']) +bpy.ops.mesh.primitive_cube_add(size=1,location=(0,0,.22));ob=bpy.context.object;ob.name='Low garden mist';ob.scale=(10,10,.40);ob.data.materials.append(fog) +black=bpy.data.materials['Dark fabric'] +# Replace floating boots with jointed, stepping legs and swinging sleeves. +roots=[bpy.data.objects[n] for n in ['Adult A','Adult B','Child witch','Child vampire']] +for root,h in zip(roots,[1.78,1.68,1.05,1.2]): + for ob in list(root.children): + if ob.name.startswith('Boot'):bpy.data.objects.remove(ob,do_unlink=True) + gate='((frame>=95 and frame<165) or (frame>=311 and frame<=344))' + for side in [-1,1]: + phase=0 if side<0 else math.pi + hip=bpy.data.objects.new(root.name+' leg pivot '+str(side),None);s.collection.objects.link(hip);hip.parent=root;hip.location=(side*h*.075,0,h*.40) + upper=rod('Trouser thigh',(0,0,0),(0,0,-h*.17),h*.041,black,hip) + knee=bpy.data.objects.new(root.name+' knee '+str(side),None);s.collection.objects.link(knee);knee.parent=hip;knee.location=(0,0,-h*.17) + rod('Trouser shin',(0,0,0),(0,0,-h*.16),h*.036,black,knee) + bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(0,.025,-h*.19));boot=bpy.context.object;boot.name='Animated boot';boot.parent=knee;boot.scale=(h*.055,h*.10,h*.045);boot.data.materials.append(black) + hip.driver_add('rotation_euler',0).driver.expression=f'0.40*sin(frame*0.34+{phase}) if {gate} else 0' + knee.driver_add('rotation_euler',0).driver.expression=f'-0.40*max(0,-sin(frame*0.34+{phase})) if {gate} else 0' + for idx,ob in enumerate([o for o in root.children if o.name.startswith('Sleeve')]): + ob.driver_add('rotation_euler',0).driver.expression=f'0.20*sin(frame*0.34+{idx*math.pi}) if {gate} else 0.1' + # Keep the family at the grave for the fictional fright scene. + root.animation_data_clear() + child=root.name.startswith('Child');idx=roots.index(root) + x=[3.25,3.9,2.10,2.82][idx];y=[.05,.8,-.55,-1.0][idx] + positions=[(1,(x,-9-idx*.35,0)),(95,(x,-5-idx*.25,0)),(165,(x,y,0)),(310,(x,y,0)),(325,(x+.20,y-.30,0)),(345,(x+.22,y-.40,0)),(480,(x+.22,y-.4,0))] + for f,pos in positions:root.location=(pos[0],pos[1],pos[2]+.065);root.keyframe_insert('location',frame=f) + for f,ang in [(1,0),(165,-.15),(310,-.15),(321,.18 if child else .07),(345,0),(480,0)]:root.rotation_euler.z=ang;root.keyframe_insert('rotation_euler',frame=f) + root.driver_add('rotation_euler',0).driver.expression='-0.14 if 316<=frame<=328 else 0' +# Camera remains with the family; no later jump to an adults-only sequence. +cam=s.camera;cam.animation_data_clear();cam.data.lens=40 +for f,loc,target in [(1,(7.5,-9,4),(.7,0,.6)),(165,(6.8,-7.6,3.3),(.7,0,.65)),(310,(6.8,-7.6,3.3),(.7,0,.65)),(320,(6.6,-7.5,3.15),(.7,0,.65)),(480,(7.5,-9,4),(.7,0,.6))]: + cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.keyframe_insert('location',frame=f);cam.keyframe_insert('rotation_euler',frame=f) +# Warm reveal is brief and darker than the previous white flash. +flash=bpy.data.lights['Brief reveal'];flash.animation_data_clear();flash.color=(1,.24,.07) +for f,power in [(1,0),(310,0),(316,350),(322,65),(332,140),(340,0),(480,0)]:flash.energy=power;flash.keyframe_insert('energy',frame=f) +s.frame_set(200);s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'example_') +bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend')) +for frame in [140,200,320]: + s.frame_set(frame);s.render.filepath=os.path.join(OUT,'example_%03d.png'%frame);bpy.ops.render.render(write_still=True) +print('V2 EXAMPLES COMPLETE — no video rendered') diff --git a/tools/render_examples_v3.py b/tools/render_examples_v3.py new file mode 100644 index 0000000..4c7e017 --- /dev/null +++ b/tools/render_examples_v3.py @@ -0,0 +1,132 @@ +"""Improve the existing concept scene; render still examples only.""" +import bpy, math, random, os +from mathutils import Vector +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview','v3');os.makedirs(OUT,exist_ok=True) +bpy.ops.wm.open_mainfile(filepath=os.path.join(ROOT,'preview','halloween_preview.blend')) +s=bpy.context.scene;s.eevee.taa_render_samples=32 +random.seed(104) +def material(name,c,rough=.8): + m=bpy.data.materials.new(name);m.use_nodes=True + p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*c,1);p.inputs['Roughness'].default_value=rough + return m +leafm=[material('Dry oak leaves '+str(i),c) for i,c in enumerate([(.20,.055,.012),(.32,.14,.032),(.10,.042,.013),(.40,.23,.075),(.16,.08,.025)])] +shroud=bpy.data.objects['Breathing burial shroud'];base=shroud.data.shape_keys.key_blocks['Rest'];breath=shroud.data.shape_keys.key_blocks['Breath'] +# A real opening at the hand's emergence point, rather than intersecting cloth. +import bmesh +bm=bmesh.new();bm.from_mesh(shroud.data) +for face in list(bm.faces): + c=face.calc_center_median() + if abs(c.x)<.16 and abs(c.y)<.18:bm.faces.remove(face) +bm.to_mesh(shroud.data);bm.free() +# Leaf litter follows the animated cloth with its own synchronized shape key. +verts=[];faces=[];indices=[];lifts=[] +def surface(x,y): + u=(x+.8)/1.6*28;v=(y+1.35)/2.7*44;i=min(27,max(0,int(u)));j=min(43,max(0,int(v)));a=u-i;b=v-j + zs=[base.data[k].co.z for k in [j*29+i,j*29+i+1,(j+1)*29+i,(j+1)*29+i+1]] + return zs[0]*(1-a)*(1-b)+zs[1]*a*(1-b)+zs[2]*(1-a)*b+zs[3]*a*b +for n in range(950): + x=random.uniform(-.77,.77);y=random.uniform(-1.32,1.32) + if x*x+y*y<.24**2:continue + angle=random.random()*math.tau;size=random.uniform(.045,.105);start=len(verts) + z=surface(x,y)+random.uniform(.012,.045) + coords=[(0,0,.010)] + for k in range(14): + a=k*math.tau/14;r=1 if k%2==0 else .65 + coords.append((math.cos(a)*size*r,math.sin(a)*size*.55*r,random.uniform(-.004,.014))) + lift=.23*math.exp(-((x/.50)**2+(y/.85)**2)) + for xx,yy,zz in coords: + verts.append((x+xx*math.cos(angle)-yy*math.sin(angle),y+xx*math.sin(angle)+yy*math.cos(angle),z+zz));lifts.append(lift) + idx=random.randrange(len(leafm)) + for k in range(14):faces.append((start,start+1+k,start+1+(k+1)%14));indices.append(idx) +me=bpy.data.meshes.new('Curled oak leaf litter');me.from_pydata(verts,[],faces);me.update() +leaves=bpy.data.objects.new('Leaves following breathing surface',me);s.collection.objects.link(leaves) +for m in leafm:me.materials.append(m) +for p,idx in zip(me.polygons,indices):p.material_index=idx +leaves.shape_key_add(name='Rest');lk=leaves.shape_key_add(name='Breath') +for v,lift in zip(lk.data,lifts):v.co.z+=lift +for frame in range(1,481): + s.frame_set(frame);lk.value=breath.value;lk.keyframe_insert('value',frame=frame) +# A layer covering the middle opening bursts upward with the emerging hand. +for n in range(110): + angle=random.uniform(0,math.tau);radius=.23*math.sqrt(random.random()) + x=radius*math.cos(angle);y=radius*math.sin(angle);z0=surface(x,y)+.035 + size=random.uniform(.055,.10);vs=[(0,0,.009)] + for k in range(14): + theta=k*math.tau/14;r=1 if k%2==0 else .65 + vs.append((size*r*math.cos(theta),size*.55*r*math.sin(theta),random.uniform(-.004,.012))) + me=bpy.data.meshes.new('Flying curled leaf');me.from_pydata(vs,[],[(0,k+1,(k+1)%14+1) for k in range(14)]);me.update() + obj=bpy.data.objects.new('Burst leaf %03d'%n,me);s.collection.objects.link(obj);obj.data.materials.append(random.choice(leafm)) + delay=random.uniform(0,.11);speed=random.uniform(.35,.85);up=random.uniform(2.0,3.0);spin=random.uniform(-7,7);initial=random.uniform(0,math.tau) + for frame in [1,200,310]+list(range(311,371))+[480]: + s.frame_set(frame);t=max(0,(frame-313)/24-delay) + flight=min(t,2*up/5.4);direction=angle+.8*flight + xx=x+speed*flight*math.cos(direction);yy=y+speed*flight*math.sin(direction) + zz=z0+up*flight-2.7*flight*flight + if t==0:zz+=.23*math.exp(-((x/.50)**2+(y/.85)**2))*breath.value + else:zz=max(.025,zz) + obj.location=(xx,yy,zz);obj.rotation_euler=(spin*flight,.7*spin*flight,initial+spin*flight) + obj.keyframe_insert('location',frame=frame);obj.keyframe_insert('rotation_euler',frame=frame) + for fc in obj.animation_data.action.fcurves: + for key in fc.keyframe_points:key.interpolation='LINEAR' +hand=bpy.data.objects['Emerging hand'];hand.animation_data_clear();hand.location=(0,0,-.65) +for frame,z in [(1,-.65),(310,-.65),(314,-.35),(322,.15),(340,.15),(365,-.65),(480,-.65)]: + hand.location.z=z;hand.keyframe_insert('location',frame=frame) + +# Dimmer porch and blue moonlight, red glow beneath the grave. +bpy.data.lights['Moon'].energy=220;bpy.data.lights['Porch'].energy=95;bpy.data.lights['Grave blue'].energy=95 +red=bpy.data.lights.new('Hidden red glow','POINT');red.energy=32;red.color=(1,.018,.004);red.shadow_soft_size=.28 +ob=bpy.data.objects.new('Hidden red glow',red);s.collection.objects.link(ob);ob.location=(0,-.3,.6) +# Bare branches create recognisable long, eerie shadows. +bark=material('Dark tree bark',(.025,.018,.014)) +def rod(name,a,b,r,m,parent=None): + d=Vector(b)-Vector(a);bpy.ops.mesh.primitive_cylinder_add(vertices=10,radius=r,depth=d.length,location=(Vector(a)+Vector(b))/2);o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();o.data.materials.append(m) + if parent:o.parent=parent + return o +for x,y in [(-3.3,2.5),(-4,-1.5)]: + rod('Bare tree trunk',(x,y,0),(x+.1,y,3.8),.11,bark) + for i in range(8): + h=random.uniform(1.7,3.4);tip=(x+random.uniform(-1.5,1.5),y+random.uniform(-.8,.8),h+random.uniform(.3,1)) + rod('Bare branch',(x,y,h),tip,.035,bark) + rod('Branch twig',tip,(tip[0]+.4,tip[1]-.2,tip[2]+.4),.012,bark) +# Thin ground mist; faces remain readable. +fog=bpy.data.materials.new('Thin ground mist');fog.use_nodes=True;nodes=fog.node_tree.nodes;nodes.clear();out=nodes.new('ShaderNodeOutputMaterial');vol=nodes.new('ShaderNodeVolumePrincipled');vol.inputs['Density'].default_value=.025;vol.inputs['Color'].default_value=(.45,.55,.65,1);fog.node_tree.links.new(vol.outputs['Volume'],out.inputs['Volume']) +bpy.ops.mesh.primitive_cube_add(size=1,location=(0,0,.22));ob=bpy.context.object;ob.name='Low garden mist';ob.scale=(10,10,.40);ob.data.materials.append(fog) +black=bpy.data.materials['Dark fabric'] +# Replace floating boots with jointed, stepping legs and swinging sleeves. +roots=[bpy.data.objects[n] for n in ['Adult A','Adult B','Child witch','Child vampire']] +for root,h in zip(roots,[1.78,1.68,1.05,1.2]): + for ob in list(root.children): + if ob.name.startswith('Boot'):bpy.data.objects.remove(ob,do_unlink=True) + gate='((frame>=95 and frame<165) or (frame>=311 and frame<=344))' + for side in [-1,1]: + phase=0 if side<0 else math.pi + hip=bpy.data.objects.new(root.name+' leg pivot '+str(side),None);s.collection.objects.link(hip);hip.parent=root;hip.location=(side*h*.075,0,h*.40) + upper=rod('Trouser thigh',(0,0,0),(0,0,-h*.17),h*.041,black,hip) + knee=bpy.data.objects.new(root.name+' knee '+str(side),None);s.collection.objects.link(knee);knee.parent=hip;knee.location=(0,0,-h*.17) + rod('Trouser shin',(0,0,0),(0,0,-h*.16),h*.036,black,knee) + bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(0,.025,-h*.19));boot=bpy.context.object;boot.name='Animated boot';boot.parent=knee;boot.scale=(h*.055,h*.10,h*.045);boot.data.materials.append(black) + hip.driver_add('rotation_euler',0).driver.expression=f'0.40*sin(frame*0.34+{phase}) if {gate} else 0' + knee.driver_add('rotation_euler',0).driver.expression=f'-0.40*max(0,-sin(frame*0.34+{phase})) if {gate} else 0' + for idx,ob in enumerate([o for o in root.children if o.name.startswith('Sleeve')]): + ob.driver_add('rotation_euler',0).driver.expression=f'0.20*sin(frame*0.34+{idx*math.pi}) if {gate} else 0.1' + # Keep the family at the grave for the fictional fright scene. + root.animation_data_clear() + child=root.name.startswith('Child');idx=roots.index(root) + x=[3.25,3.9,2.10,2.82][idx];y=[.05,.8,-.55,-1.0][idx] + positions=[(1,(x,-9-idx*.35,0)),(95,(x,-5-idx*.25,0)),(165,(x,y,0)),(310,(x,y,0)),(325,(x+.20,y-.30,0)),(345,(x+.22,y-.40,0)),(480,(x+.22,y-.4,0))] + for f,pos in positions:root.location=(pos[0],pos[1],pos[2]+.065);root.keyframe_insert('location',frame=f) + for f,ang in [(1,0),(165,-.15),(310,-.15),(321,.18 if child else .07),(345,0),(480,0)]:root.rotation_euler.z=ang;root.keyframe_insert('rotation_euler',frame=f) + root.driver_add('rotation_euler',0).driver.expression='-0.14 if 316<=frame<=328 else 0' +# Camera remains with the family; no later jump to an adults-only sequence. +cam=s.camera;cam.animation_data_clear();cam.data.lens=40 +for f,loc,target in [(1,(7.5,-9,4),(.7,0,.6)),(165,(6.8,-7.6,3.3),(.7,0,.65)),(310,(6.8,-7.6,3.3),(.7,0,.65)),(320,(6.6,-7.5,3.15),(.7,0,.65)),(480,(7.5,-9,4),(.7,0,.6))]: + cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.keyframe_insert('location',frame=f);cam.keyframe_insert('rotation_euler',frame=f) +# Warm reveal is brief and darker than the previous white flash. +flash=bpy.data.lights['Brief reveal'];flash.animation_data_clear();flash.color=(1,.24,.07) +for f,power in [(1,0),(310,0),(316,350),(322,65),(332,140),(340,0),(480,0)]:flash.energy=power;flash.keyframe_insert('energy',frame=f) +s.frame_set(200);s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'example_') +bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend')) +for frame in [310,322,334]: + s.frame_set(frame);s.render.filepath=os.path.join(OUT,'example_%03d.png'%frame);bpy.ops.render.render(write_still=True) +print('V3 EXAMPLES COMPLETE — no video rendered') diff --git a/tools/render_examples_v4.py b/tools/render_examples_v4.py new file mode 100644 index 0000000..e0c0812 --- /dev/null +++ b/tools/render_examples_v4.py @@ -0,0 +1,44 @@ +"""Darker night lighting with visible practical sources; still renders only.""" +import bpy, os, math +from mathutils import Vector +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview','v4');os.makedirs(OUT,exist_ok=True) +bpy.ops.wm.open_mainfile(filepath=os.path.join(ROOT,'preview','v3','halloween_examples.blend')) +s=bpy.context.scene;s.render.engine='BLENDER_EEVEE';s.eevee.taa_render_samples=64 +s.eevee.use_gtao=True;s.eevee.gtao_distance=2;s.eevee.use_ssr=True;s.eevee.use_ssr_refraction=True +s.eevee.use_soft_shadows=True;s.eevee.shadow_cube_size='2048';s.eevee.use_bloom=True;s.eevee.bloom_intensity=.045;s.eevee.bloom_threshold=1.2 +s.view_settings.exposure=-.6 +s.world.use_nodes=True;bg=s.world.node_tree.nodes.get('Background');bg.inputs['Color'].default_value=(.002,.004,.008,1);bg.inputs['Strength'].default_value=.15 +for l in bpy.data.lights: + l.use_shadow=True;l.use_contact_shadow=True;l.contact_shadow_distance=.4;l.contact_shadow_bias=.015 +moon=bpy.data.lights['Moon'];moon.energy=95;moon.color=(.55,.65,1);moon.size=5 +bpy.data.lights['Porch'].energy=0 +blue=bpy.data.lights['Grave blue'];blue.energy=30;blue.color=(.12,.24,1);blue.size=.7 +red=bpy.data.lights['Hidden red glow'];red.energy=5;red.color=(1,.055,.008);red.shadow_soft_size=.12 +bpy.data.objects['Hidden red glow'].location=(-.72,.7,.31) +# Light actually emanates from the lit windows, casting warm patches on the path. +for x in [-3,0,3]: + l=bpy.data.lights.new('Window spill '+str(x),'AREA');l.energy=24;l.color=(1,.59,.28);l.shape='RECTANGLE';l.size=1.3;l.size_y=1.5 + l.use_contact_shadow=True + o=bpy.data.objects.new(l.name,l);s.collection.objects.link(o);o.location=(x,5.55,2.2);o.rotation_euler=(Vector((x,1,.2))-o.location).to_track_quat('-Z','Y').to_euler() + m=bpy.data.materials['Warm window '+str(x)];p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(1,.52,.19,1);p.inputs['Emission Strength'].default_value=1.5 +# Small visible porch lantern with warm light and subtle irregular flicker. +em=bpy.data.materials.new('Lantern glowing glass');em.use_nodes=True;p=em.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(.6,.18,.02,1);p.inputs['Emission Color'].default_value=(1,.45,.12,1);p.inputs['Emission Strength'].default_value=4 +bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(2,5.35,2.15));o=bpy.context.object;o.name='Porch lantern glass';o.scale=(.065,.065,.12);o.data.materials.append(em) +for z in [2.02,2.28]: + bpy.ops.mesh.primitive_cube_add(size=1,location=(2,5.35,z));o=bpy.context.object;o.name='Lantern metal cap';o.scale=(.19,.16,.035);o.data.materials.append(bpy.data.materials['Dark fabric']) +l=bpy.data.lights.new('Porch lantern light','POINT');l.color=(1,.56,.23);l.shadow_soft_size=.09;l.use_contact_shadow=True +ob=bpy.data.objects.new(l.name,l);s.collection.objects.link(ob);ob.location=(2,5.20,2.15) +for frame in range(1,481,3):l.energy=17+1.8*math.sin(frame*.17)+.8*math.sin(frame*.71);l.keyframe_insert('energy',frame=frame) +# Damp pavers catch localized highlights; leave soil and cloth matte. +p=bpy.data.materials['Weathered limestone'].node_tree.nodes.get('Principled BSDF');p.inputs['Roughness'].default_value=.48 +# Directed flash briefly reveals hand and airborne leaves, rather than lighting the whole garden. +ob=bpy.data.objects['Brief reveal'];old=ob.data;flash=bpy.data.lights.new('Directed grave reveal','SPOT');ob.data=flash;flash.use_contact_shadow=True;flash.spot_size=math.radians(65);flash.spot_blend=.65;flash.shadow_soft_size=.13;flash.color=(1,.67,.36) +for frame,power in [(1,0),(310,0),(316,700),(320,450),(324,180),(332,380),(340,0),(480,0)]:flash.energy=power;flash.keyframe_insert('energy',frame=frame) +# Hint of light scattering in the low mist, without a luminous fog blanket. +vol=bpy.data.materials['Thin ground mist'].node_tree.nodes.get('Principled Volume');vol.inputs['Density'].default_value=.035;vol.inputs['Anisotropy'].default_value=.25 +s.frame_set(310);s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'example_') +bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend')) +for frame in [310,322,334]: + s.frame_set(frame);s.render.filepath=os.path.join(OUT,'example_%03d.png'%frame);bpy.ops.render.render(write_still=True) +print('DARKER EXAMPLES COMPLETE — still images only') diff --git a/tools/render_preview.py b/tools/render_preview.py new file mode 100644 index 0000000..12b6768 --- /dev/null +++ b/tools/render_preview.py @@ -0,0 +1,175 @@ +import bpy, math, random, os, sys, wave, struct +from mathutils import Vector +random.seed(18) +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview') +bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False) +s=bpy.context.scene +s.render.engine='BLENDER_EEVEE'; s.eevee.taa_render_samples=8; s.eevee.use_gtao=True; s.eevee.gtao_distance=3; s.eevee.use_soft_shadows=True +s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100 +s.render.fps=24;s.frame_start=1;s.frame_end=480 +s.world.color=(.003,.003,.003) +s.view_settings.view_transform='AgX' +def mat(name,color,rough=.7,noise=False): + m=bpy.data.materials.new(name);m.diffuse_color=(*color,1);m.use_nodes=True + p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*color,1);p.inputs['Roughness'].default_value=rough + if noise: + n=m.node_tree.nodes.new('ShaderNodeTexNoise');n.inputs['Scale'].default_value=24 + b=m.node_tree.nodes.new('ShaderNodeBump');b.inputs['Strength'].default_value=.3;b.inputs['Distance'].default_value=.08 + m.node_tree.links.new(n.outputs['Fac'],b.inputs['Height']);m.node_tree.links.new(b.outputs['Normal'],p.inputs['Normal']) + return m +soil=mat('Damp garden soil',(.07,.045,.025),noise=True);stone=mat('Weathered limestone',(.28,.29,.27),noise=True) +cloth=mat('Burial cloth',(.15,.12,.09),noise=True);brick=mat('Rendered house wall',(.24,.19,.14),noise=True) +bone=mat('Old ivory',(.55,.48,.32));black=mat('Dark fabric',(.015,.02,.025),noise=True) +leafm=[mat('Autumn leaf '+str(i),c) for i,c in enumerate([(.21,.065,.014),(.30,.15,.03),(.12,.055,.018)])] +def cube(name,loc,scale,m,bevel=0): + bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale + bpy.ops.object.transform_apply(location=False,rotation=False,scale=True) + if m:o.data.materials.append(m) + if bevel: + mod=o.modifiers.new('Soft edges','BEVEL');mod.width=bevel;mod.segments=3;o.data.use_auto_smooth=True;o.modifiers.new('Normals','WEIGHTED_NORMAL') + return o +def uv(name,loc,scale,m): + bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=10,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;o.data.materials.append(m) + for p in o.data.polygons:p.use_smooth=True + return o +def rod(name,a,b,r,m,parent=None): + d=Vector(b)-Vector(a);bpy.ops.mesh.primitive_cylinder_add(vertices=10,radius=r,depth=d.length,location=(Vector(a)+Vector(b))/2) + o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();o.data.materials.append(m) + if parent:o.parent=parent + return o +cube('Ground',(0,0,-.12),(16,16,.2),soil) +cube('Walkway',(2.6,0,.01),(1.5,12,.09),stone,.03) +for y in range(-6,7):cube('Paving joint',(2.6,y,.065),(1.5,.017,.005),black) +cube('House',(0,6,2),(12,.3,4),brick) +for x in [-3,0,3]: + cube('Window frame',(x,5.80,2.2),(1.6,.15,1.8),black,.04) + glass=mat('Warm window '+str(x),(.5,.22,.045));p=glass.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(1,.35,.08,1);p.inputs['Emission Strength'].default_value=.8 + cube('Lit window',(x,5.70,2.2),(1.4,.04,1.6),glass) + cube('Window divider',(x,5.65,2.2),(.05,.06,1.7),black) +for x in [-5,5]: + for y in range(-4,6):cube('Fence post',(x,y,.65),(.09,.09,1.3),stone,.015) + cube('Fence rail',(x,.5,.9),(.07,10,.08),stone) +# Grave border and headstone +for x in [-.9,.9]:cube('Grave side',(x,0,.14),(.15,2.9,.28),stone,.04) +for y in [-1.45,1.45]:cube('Grave end',(0,y,.14),(1.95,.15,.28),stone,.04) +cube('Headstone base',(0,1.65,.25),(1.1,.45,.3),stone,.06) +cube('Headstone',(0,1.65,.85),(.85,.22,1.1),stone,.10) +uv('Rounded headstone',(0,1.65,1.35),(.425,.11,.3),stone) +def text(body,loc,size): + bpy.ops.object.text_add(location=loc,rotation=(math.pi/2,0,0));o=bpy.context.object;o.data.body=body;o.data.align_x='CENTER';o.data.size=size;o.data.extrude=.002;o.data.materials.append(black) +text('R I P',(0,1.525,1.05),.19);text('STILL BREATHING',(0,1.525,.72),.065) +# Fabric mesh with animated breathing shape +verts=[];faces=[];nx=28;ny=44 +for j in range(ny+1): + y=-1.35+2.7*j/ny + for i in range(nx+1): + x=-.80+1.6*i/nx;z=.19+.035*math.sin(i*1.3+j*.7)*math.sin(math.pi*i/nx)*math.sin(math.pi*j/ny);verts.append((x,y,z)) +for j in range(ny): + for i in range(nx): + a=j*(nx+1)+i;faces.append((a,a+1,a+nx+2,a+nx+1)) +mesh=bpy.data.meshes.new('Cloth grid');mesh.from_pydata(verts,[],faces);mesh.update() +o=bpy.data.objects.new('Breathing burial shroud',mesh);s.collection.objects.link(o);o.data.materials.append(cloth) +for p in mesh.polygons:p.use_smooth=True +o.shape_key_add(name='Rest');breath=o.shape_key_add(name='Breath') +for v in breath.data:v.co.z+=.23*math.exp(-((v.co.x/.50)**2+(v.co.y/.85)**2)) +for f,val in [(1,.1),(30,1),(65,0),(100,.85),(120,.15),(150,0),(175,.45),(210,0),(240,.1),(241,0),(310,0),(316,1.8),(322,.1),(332,1.2),(345,0),(380,.1),(415,.8),(450,0),(480,.35)]:breath.value=val;breath.keyframe_insert('value',frame=f) +for i in range(220): + x=random.uniform(-4,4);y=random.uniform(-4,4) + if abs(x)<1 and abs(y)<1.5:continue + me=bpy.data.meshes.new('Leaf');me.from_pydata([(-.10,0,0),(-.045,-.04,0),(0,-.065,.01),(.07,-.025,0),(.11,0,0),(.04,.045,0),(-.02,.055,.008)],[],[(0,1,2,3,4,5,6)]);me.update() + l=bpy.data.objects.new('Fallen leaf',me);s.collection.objects.link(l);l.location=(x,y,.025);l.data.materials.append(random.choice(leafm));l.rotation_euler.z=random.random()*6.28 +# Grass tufts, shrubs, and Halloween pumpkins +grassmat=mat('Dry grass',(.055,.065,.024)) +for i in range(180): + x=random.uniform(-4.5,1.6);y=random.uniform(-4,4) + if abs(x)<1.1 and abs(y)<1.8:continue + rod('Grass blade',(x,y,0),(x+.025,y+.015,random.uniform(.07,.17)),.003,grassmat) +for x,y in [(-2,1),(-3,3),(4,4)]: + for j in range(8):uv('Garden shrub',(x+random.uniform(-.3,.3),y+random.uniform(-.3,.3),random.uniform(.2,.5)),(.3,.3,.3),grassmat) +for x,y in [(-1.5,-1.7),(-1.8,-1.4),(.8,2.1)]: + pumpkin=uv('Ribbed pumpkin',(x,y,.18),(.23,.23,.18),leafm[0]) + for v in pumpkin.data.vertices: + ang=math.atan2(v.co.y,v.co.x);fac=1+.055*math.cos(ang*10);v.co.x*=fac;v.co.y*=fac + rod('Pumpkin stem',(x,y,.32),(x+.02,y,.42),.024,soil) +# Cloaked costumed visitors, seen from behind/side +purple=mat('Purple wool costume',(.11,.025,.13),noise=True);red=mat('Burgundy costume',(.18,.018,.022),noise=True) +def person(name,h,color,start,end,child=False): + root=bpy.data.objects.new(name,None);s.collection.objects.link(root) + # Folded tapered cloak + vs=[];fs=[];rings=14;seg=32 + for j in range(rings): + z=h*(.18+.60*j/(rings-1));r=h*(.24-.12*j/(rings-1)) + for k in range(seg): + ang=k*2*math.pi/seg;rr=r*(1+.085*math.sin(ang*9+j*.25));vs.append((rr*math.cos(ang),rr*.65*math.sin(ang),z)) + for j in range(rings-1): + for k in range(seg):a=j*seg+k;b=j*seg+(k+1)%seg;fs.append((a,b,b+seg,a+seg)) + me=bpy.data.meshes.new(name+' costume');me.from_pydata(vs,[],fs);me.update();ob=bpy.data.objects.new(name+' cloak',me);s.collection.objects.link(ob);ob.parent=root;ob.data.materials.append(color) + for p in me.polygons:p.use_smooth=True + for x in [-h*.08,h*.08]: + ob=uv('Boot',(x,-.04,h*.08),(h*.065,h*.10,h*.09),black);ob.parent=root + drv=ob.driver_add('location',2).driver;drv.expression=f'{h*.08} + (max(0,sin(frame*0.32+{0 if x<0 else math.pi}))*{h*.035} if 12017 else .006*lp + for begin,end in [(7.1,8.3),(13,13.45)]: + if begin.01 for x,y in zip(a,b)) +print('VERIFIED: eight moving leg joints') +s.frame_set(1);bpy.ops.file.pack_all() +bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend')) +bpy.ops.render.render(animation=True) diff --git a/tools/review_preview.py b/tools/review_preview.py new file mode 100644 index 0000000..8949710 --- /dev/null +++ b/tools/review_preview.py @@ -0,0 +1,15 @@ +"""Decode selected video frames and save the editable scene with MP4 settings.""" +import bpy, os +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview') +bpy.ops.wm.read_factory_settings(use_empty=True) +s=bpy.context.scene;s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100;s.render.fps=24 +s.sequence_editor_create();strip=s.sequence_editor.sequences.new_movie('Finished video',os.path.join(OUT,'halloween_preview.mp4'),channel=1,frame_start=1) +print('DECODED MOVIE',strip.frame_duration,strip.elements[0].orig_width,strip.elements[0].orig_height) +s.render.image_settings.file_format='PNG' +for frame in [1,80,120,185,239,241,310,316,320,360,440,480]: + s.frame_set(frame);s.render.filepath=os.path.join(OUT,'frames','review_%03d.png'%frame);bpy.ops.render.render(write_still=True) +bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_preview.blend')) +s=bpy.context.scene;s.frame_set(1) +s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='MEDIUM';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192;s.render.filepath='//halloween_preview.mp4' +bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_preview.blend')) diff --git a/tools/review_video_v4.py b/tools/review_video_v4.py new file mode 100644 index 0000000..21eeb64 --- /dev/null +++ b/tools/review_video_v4.py @@ -0,0 +1,16 @@ +"""Decode selected video frames and save the editable scene with MP4 settings.""" +import bpy, os +ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT=os.path.join(ROOT,'preview','v4') +os.makedirs(os.path.join(OUT,'frames'),exist_ok=True) +bpy.ops.wm.read_factory_settings(use_empty=True) +s=bpy.context.scene;s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100;s.render.fps=24 +s.sequence_editor_create();strip=s.sequence_editor.sequences.new_movie('Finished video',os.path.join(OUT,'halloween_dark.mp4'),channel=1,frame_start=1) +print('DECODED MOVIE',strip.frame_duration,strip.elements[0].orig_width,strip.elements[0].orig_height) +s.render.image_settings.file_format='PNG' +for frame in [1,120,128,136,144,152,185,310,322,334,440,480]: + s.frame_set(frame);s.render.filepath=os.path.join(OUT,'frames','review_%03d.png'%frame);bpy.ops.render.render(write_still=True) +bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend')) +s=bpy.context.scene;s.frame_set(1) +s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='MEDIUM';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192;s.render.filepath='//halloween_dark.mp4' +bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend')) diff --git a/tools/upload_to_pi.sh b/tools/upload_to_pi.sh new file mode 100755 index 0000000..14abc2a --- /dev/null +++ b/tools/upload_to_pi.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +source_dir=$project_dir +pi_user= +pi_host= +remote_dir= +apply=false +install_service=false + +usage() { + cat <<'EOF' +Projektdateien per SSH/rsync auf einen Raspberry Pi kopieren. + +Aufruf: + bash tools/upload_to_pi.sh --user BENUTZER --host HOST \ + --remote-dir /home/BENUTZER/halloween [--source ORDNER] [--install-service] [--apply] + +Ohne --apply wird nur der geplante Aufruf angezeigt; es gibt keine Verbindung +zum Pi. --apply legt den Zielordner an und kopiert die Dateien. Alte Dateien +auf dem Pi werden nicht gelöscht. +--install-service richtet nach dem Upload einen systemd-Autostart ein. Dazu +muss die Python-Umgebung auf dem Pi bereits vorbereitet sein; sudo wird dort +benötigt. Ohne --apply wird auch der Dienst nicht installiert. +EOF +} + +die() { + printf 'Fehler: %s\n' "$*" >&2 + exit 2 +} + +while (($#)); do + case $1 in + --user|--host|--remote-dir|--source) + (($# >= 2)) || die "Wert für $1 fehlt" + case $1 in + --user) pi_user=$2 ;; + --host) pi_host=$2 ;; + --remote-dir) remote_dir=$2 ;; + --source) source_dir=$2 ;; + esac + shift 2 + ;; + --apply) apply=true; shift ;; + --install-service) install_service=true; shift ;; + -h|--help) usage; exit 0 ;; + *) die "Unbekanntes Argument: $1" ;; + esac +done + +[[ $pi_user =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || die 'Gültigen --user angeben' +[[ $pi_host =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || die 'Gültigen --host angeben (Name oder IPv4-Adresse)' +[[ $remote_dir =~ ^/[a-zA-Z0-9_./-]+$ && $remote_dir != / ]] || die 'Sicheren absoluten --remote-dir angeben' +[[ /$remote_dir/ != *'/../'* && /$remote_dir/ != *'/./'* ]] || die 'Zielpfad darf keine . oder .. Segmente enthalten' +[[ -d $source_dir ]] || die "Quellordner fehlt: $source_dir" +source_dir=$(cd -- "$source_dir" && pwd -P) +if $install_service; then + [[ -f "$source_dir/tools/install_pi_service.sh" ]] || die 'tools/install_pi_service.sh fehlt in der Quelle' +fi + +command -v ssh >/dev/null || die 'ssh fehlt lokal' +command -v rsync >/dev/null || die 'rsync fehlt lokal' + +target="$pi_user@$pi_host" +excludes=( + --exclude=.git/ + --exclude=.venv/ + --exclude=__pycache__/ + --exclude=data/ + --exclude=preview/ + --exclude=references/ + --exclude=.env + --exclude='*.pem' + --exclude='*.key' + --exclude='*.p12' + --exclude='*.pfx' +) +ssh_cmd=(ssh -o StrictHostKeyChecking=ask "$target" "mkdir -p -- '$remote_dir'") +rsync_cmd=( + rsync -a --delay-updates --human-readable --info=stats2 + "${excludes[@]}" + -e 'ssh -o StrictHostKeyChecking=ask' + -- "$source_dir/" "$target:$remote_dir/" +) +service_cmd=(ssh -tt -o StrictHostKeyChecking=ask "$target" "cd -- '$remote_dir' && bash tools/install_pi_service.sh --apply") + +printf 'Quelle: %s\nZiel: %s:%s/\n' "$source_dir" "$target" "$remote_dir" +printf 'Ausgelassen: .git, .venv, data, preview, references, __pycache__, .env, Schlüsseldateien\n' +if ! $apply; then + printf '\nVorschau, noch keine Verbindung und keine Übertragung:\n' + printf ' %q' "${ssh_cmd[@]}"; printf '\n' + printf ' %q' "${rsync_cmd[@]}"; printf '\n' + if $install_service; then + printf ' %q' "${service_cmd[@]}"; printf '\n' + fi + printf '\nMit --apply ausführen.\n' + exit 0 +fi + +"${ssh_cmd[@]}" +"${rsync_cmd[@]}" +printf 'Übertragung abgeschlossen.\n' +if $install_service; then + "${service_cmd[@]}" +fi