add face
This commit is contained in:
commit
04c5db785a
|
|
@ -0,0 +1,55 @@
|
|||
# Helva Roboter
|
||||
|
||||
We build a robot with raspberry and arduino.
|
||||
|
||||
## Checkout Repo
|
||||
cd /opt
|
||||
git checkout https://git.dyn.mcf.at/Max/helva-robot.git
|
||||
|
||||
## Raspberry Face
|
||||
|
||||
### Install Packages
|
||||
sudo apt update
|
||||
sudo apt install -y nginx python3 python3-venv
|
||||
|
||||
sudo mkdir -p /opt/face
|
||||
sudo chown -R $USER:$USER /opt/face
|
||||
|
||||
python3 -m venv /opt/face/venv
|
||||
/opt/face/venv/bin/pip install --upgrade pip
|
||||
/opt/face/venv/bin/pip install fastapi uvicorn
|
||||
|
||||
### Exchange Nginx Web-Folder
|
||||
sudo rm -r /var/www/html
|
||||
sudo ln -s /opt/helva-robot/face/var/www/html/ /var/www/
|
||||
|
||||
### Permissions of Webfolder
|
||||
sudo chown -R www-data:www-data /var/www/html
|
||||
|
||||
### Nginx Vhost
|
||||
sudo ln -s /opt/helva-robot/face/etc/nginx/sites-available/face /etc/nginx/sites-enabled/
|
||||
|
||||
### Restart nginx
|
||||
sudo systemctl restart nginx
|
||||
|
||||
### Test Face Server
|
||||
In Browser: http://<pi-ip>/
|
||||
|
||||
From Somewhere:
|
||||
curl -X POST http://<pi-ip>/api/emotion/happy
|
||||
curl -X POST http://<pi-ip>/api/emotion/angry
|
||||
curl -X POST http://<pi-ip>/api/emotion/sleepy
|
||||
|
||||
### systemd Service
|
||||
sudo ln -s /opt/helva-robot/face/etc/systemd/system/face.service /etc/systemd/system/
|
||||
|
||||
sudo chown -R www-data:www-data /opt/face
|
||||
sudo chmod -R 755 /opt/face
|
||||
|
||||
Start:
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now face.service
|
||||
sudo systemctl status face.service --no-pager
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /opt/helva-robot/face/var/www/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# API -> backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# SSE stream -> backend
|
||||
location /events {
|
||||
proxy_pass http://127.0.0.1:8001/events;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_buffering off; # IMPORTANT for SSE
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
|
||||
add_header Cache-Control no-cache;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
[Unit]
|
||||
Description=Robot Face SSE Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/face
|
||||
ExecStart=/opt/face/venv/bin/python -m uvicorn face_server:app --host 127.0.0.1 --port 8001 --app-dir /opt/face
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
|
||||
# Tipp: eigener User ist sauber, aber www-data geht auch.
|
||||
User=www-data
|
||||
Group=www-data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
Binary file not shown.
|
|
@ -0,0 +1,64 @@
|
|||
import asyncio
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
clients: set[asyncio.Queue[str]] = set()
|
||||
current_emotion = "neutral"
|
||||
|
||||
def sse(event: str, data: str) -> str:
|
||||
# SSE format: event + data + blank line
|
||||
return f"event: {event}\ndata: {data}\n\n"
|
||||
|
||||
@app.get("/events")
|
||||
async def events(request: Request):
|
||||
"""
|
||||
Browser connects here via EventSource. We stream emotion updates.
|
||||
"""
|
||||
q: asyncio.Queue[str] = asyncio.Queue()
|
||||
clients.add(q)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
# send current state immediately
|
||||
yield sse("emotion", current_emotion)
|
||||
|
||||
while True:
|
||||
# abort if client disconnected
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
|
||||
msg = await q.get()
|
||||
yield sse("emotion", msg)
|
||||
finally:
|
||||
clients.discard(q)
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # helpful behind nginx
|
||||
}
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
@app.post("/api/emotion/{name}")
|
||||
async def set_emotion(name: str):
|
||||
global current_emotion
|
||||
current_emotion = name
|
||||
|
||||
# broadcast to all connected clients
|
||||
dead = []
|
||||
for q in clients:
|
||||
try:
|
||||
q.put_nowait(name)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
clients.discard(q)
|
||||
|
||||
return JSONResponse({"ok": True, "emotion": current_emotion})
|
||||
|
||||
@app.get("/api/emotion")
|
||||
async def get_emotion():
|
||||
return {"emotion": current_emotion}
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
const label = document.getElementById("label");
|
||||
const eyes = Array.from(document.querySelectorAll(".eye"));
|
||||
const mouthShape = document.querySelector(".mouth-shape");
|
||||
|
||||
let current = "neutral";
|
||||
|
||||
function setEmotion(name) {
|
||||
const safe = String(name || "neutral")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "");
|
||||
|
||||
current = safe;
|
||||
document.body.className = `emotion-${safe}`;
|
||||
label.textContent = safe;
|
||||
|
||||
// Sonderfall: surprised -> O-Mund aktivieren
|
||||
if (safe === "surprised") document.body.classList.add("has-omouth");
|
||||
else document.body.classList.remove("has-omouth");
|
||||
|
||||
// sad -> frown pseudo aktivieren (eigene Klasse)
|
||||
if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown");
|
||||
else mouthShape.classList.remove("frown");
|
||||
}
|
||||
|
||||
/** Pupillen bewegen sich sanft, damit es "lebendig" wirkt */
|
||||
function startPupilWander() {
|
||||
const tick = () => {
|
||||
// kleine zufällige Offsets in Pixel (auf großen Displays reicht das)
|
||||
const x = Math.round((Math.random() * 2 - 1) * 10);
|
||||
const y = Math.round((Math.random() * 2 - 1) * 8);
|
||||
|
||||
document.documentElement.style.setProperty("--pupil-x", `${x}px`);
|
||||
document.documentElement.style.setProperty("--pupil-y", `${y}px`);
|
||||
|
||||
// alle 600-1400ms neu
|
||||
const next = 600 + Math.random() * 800;
|
||||
setTimeout(tick, next);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
/** Blinzeln: in zufälligen Abständen, manchmal doppelt */
|
||||
function blinkOnce() {
|
||||
eyes.forEach(e => e.classList.add("blink"));
|
||||
setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120);
|
||||
}
|
||||
|
||||
function startBlinking() {
|
||||
const loop = () => {
|
||||
// sleepy blinzelt öfter/langsamer, angry etwas "härter"
|
||||
let base = 3500;
|
||||
if (current === "sleepy") base = 2200;
|
||||
if (current === "surprised") base = 4200;
|
||||
|
||||
const next = base + Math.random() * 2200;
|
||||
setTimeout(() => {
|
||||
blinkOnce();
|
||||
|
||||
// 15% chance auf Doppelt-Blink
|
||||
if (Math.random() < 0.15) setTimeout(blinkOnce, 220);
|
||||
|
||||
loop();
|
||||
}, next);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const es = new EventSource("/events");
|
||||
es.addEventListener("emotion", (e) => setEmotion(e.data));
|
||||
es.onmessage = (e) => setEmotion(e.data); // fallback
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
startPupilWander();
|
||||
startBlinking();
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Robot Face</title>
|
||||
<link rel="stylesheet" href="/style.css?v=2" />
|
||||
</head>
|
||||
<body class="emotion-neutral">
|
||||
<div id="face" aria-label="Robot face">
|
||||
<div class="eyes">
|
||||
<div class="eye"></div>
|
||||
<div class="eye"></div>
|
||||
</div>
|
||||
|
||||
<div class="mouth">
|
||||
<div class="mouth-shape"></div>
|
||||
</div>
|
||||
|
||||
<div class="label" id="label">neutral</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121925;
|
||||
--fg: #d7e3f2;
|
||||
|
||||
/* Glow / Stimmung */
|
||||
--glow: rgba(0, 255, 180, 0.22);
|
||||
|
||||
/* Pupillen-Offset (wird via JS verändert) */
|
||||
--pupil-x: 0px;
|
||||
--pupil-y: 0px;
|
||||
|
||||
/* Mund-Parameter (Default = neutral) */
|
||||
--mouth-w: 38vw;
|
||||
--mouth-h: 10vh;
|
||||
--mouth-radius: 999px;
|
||||
--mouth-line-y: 50%;
|
||||
--mouth-line-h: 10px;
|
||||
--mouth-line-opacity: 0.85;
|
||||
|
||||
/* „Smile“-Bogen (0 = aus) */
|
||||
--smile: 0;
|
||||
/* „Frown“-Bogen (0 = aus) */
|
||||
--frown: 0;
|
||||
|
||||
/* „O“-Mund (0 = aus, sonst Größe) */
|
||||
--omouth: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#face {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3.5vh;
|
||||
}
|
||||
|
||||
.eyes {
|
||||
display: flex;
|
||||
gap: 8vw;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eye {
|
||||
width: 14vw;
|
||||
height: 14vw;
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 40px var(--glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-radius 220ms ease, transform 220ms ease, height 220ms ease;
|
||||
}
|
||||
|
||||
/* Pupille */
|
||||
.eye::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 999px;
|
||||
background: var(--fg);
|
||||
opacity: 0.9;
|
||||
transform: translate(var(--pupil-x), var(--pupil-y));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
/* Blinzeln: wir "quetschen" das Auge kurz */
|
||||
.eye.blink {
|
||||
transform: scaleY(0.12);
|
||||
}
|
||||
|
||||
/* Mund-Container */
|
||||
.mouth {
|
||||
width: var(--mouth-w);
|
||||
height: var(--mouth-h);
|
||||
max-width: 600px;
|
||||
max-height: 120px;
|
||||
border-radius: var(--mouth-radius);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 40px var(--glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: width 220ms ease, height 220ms ease, border-radius 220ms ease;
|
||||
}
|
||||
|
||||
/* Mund-Shape (Linie + Bögen + O-Mund) */
|
||||
.mouth-shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Mund-Linie */
|
||||
.mouth-shape::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12%;
|
||||
right: 12%;
|
||||
top: var(--mouth-line-y);
|
||||
height: var(--mouth-line-h);
|
||||
transform: translateY(-50%);
|
||||
background: var(--fg);
|
||||
border-radius: 999px;
|
||||
opacity: var(--mouth-line-opacity);
|
||||
transition: all 220ms ease;
|
||||
}
|
||||
|
||||
/* Smile-Bogen */
|
||||
.mouth-shape::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
right: 16%;
|
||||
top: 38%;
|
||||
height: 55%;
|
||||
border: calc(6px + 6px * var(--smile)) solid rgba(215,227,242,0.85);
|
||||
border-top: none;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-bottom-left-radius: 999px;
|
||||
border-bottom-right-radius: 999px;
|
||||
opacity: calc(0.10 + 0.60 * var(--smile));
|
||||
transition: opacity 220ms ease, border-width 220ms ease;
|
||||
}
|
||||
|
||||
/* Frown-Bogen als extra Element über box-shadow Trick */
|
||||
.mouth-shape {
|
||||
filter: drop-shadow(0 0 0 rgba(0,0,0,0));
|
||||
}
|
||||
.mouth-shape.frown::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
right: 16%;
|
||||
bottom: 38%;
|
||||
height: 55%;
|
||||
border: calc(6px + 6px * var(--frown)) solid rgba(215,227,242,0.85);
|
||||
border-bottom: none;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-top-left-radius: 999px;
|
||||
border-top-right-radius: 999px;
|
||||
opacity: calc(0.10 + 0.60 * var(--frown));
|
||||
}
|
||||
|
||||
/* O-Mund: wir machen aus dem Mund-Container einen Kreis und verstecken Linie */
|
||||
body.has-omouth .mouth {
|
||||
width: calc(18vw + 8vw * var(--omouth));
|
||||
height: calc(18vw + 8vw * var(--omouth));
|
||||
max-width: 260px;
|
||||
max-height: 260px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
body.has-omouth .mouth-shape::after {
|
||||
left: 28%;
|
||||
right: 28%;
|
||||
top: 50%;
|
||||
height: 42%;
|
||||
border-radius: 999px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
body.has-omouth .mouth-shape::before {
|
||||
opacity: 0; /* Smile aus */
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.label {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 18px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(18, 25, 37, 0.75);
|
||||
color: var(--fg);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ===== Emotionen über Variablen ===== */
|
||||
|
||||
body.emotion-neutral {
|
||||
--glow: rgba(0, 255, 180, 0.22);
|
||||
--smile: 0;
|
||||
--frown: 0;
|
||||
--omouth: 0;
|
||||
--mouth-line-opacity: 0.85;
|
||||
--mouth-line-h: 10px;
|
||||
}
|
||||
body.emotion-neutral .mouth-shape { }
|
||||
body.emotion-neutral .mouth-shape.frown { } /* no-op */
|
||||
|
||||
body.emotion-happy {
|
||||
--glow: rgba(0, 255, 120, 0.32);
|
||||
--smile: 1;
|
||||
--frown: 0;
|
||||
--omouth: 0;
|
||||
--mouth-line-y: 58%;
|
||||
--mouth-line-h: 12px;
|
||||
}
|
||||
body.emotion-happy .mouth-shape { }
|
||||
body.emotion-happy .mouth-shape.frown { } /* no-op */
|
||||
|
||||
body.emotion-sad {
|
||||
--glow: rgba(120, 180, 255, 0.32);
|
||||
--smile: 0;
|
||||
--frown: 1;
|
||||
--omouth: 0;
|
||||
--mouth-line-y: 42%;
|
||||
--mouth-line-h: 12px;
|
||||
}
|
||||
body.emotion-sad .mouth-shape { }
|
||||
body.emotion-sad .mouth-shape.frown { } /* no-op */
|
||||
|
||||
body.emotion-angry {
|
||||
--glow: rgba(255, 70, 70, 0.32);
|
||||
--smile: 0;
|
||||
--frown: 0.35;
|
||||
--omouth: 0;
|
||||
--mouth-line-opacity: 0.95;
|
||||
--mouth-line-h: 16px;
|
||||
}
|
||||
body.emotion-angry .eye {
|
||||
border-radius: 26% 74% 60% 40% / 55% 45% 55% 45%;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
body.emotion-surprised {
|
||||
--glow: rgba(255, 220, 90, 0.34);
|
||||
--smile: 0;
|
||||
--frown: 0;
|
||||
--omouth: 1;
|
||||
--mouth-line-opacity: 0.95;
|
||||
}
|
||||
body.emotion-surprised { }
|
||||
body.emotion-surprised.has-omouth { } /* marker in JS */
|
||||
|
||||
body.emotion-sleepy {
|
||||
--glow: rgba(180, 180, 255, 0.22);
|
||||
--smile: 0;
|
||||
--frown: 0;
|
||||
--omouth: 0;
|
||||
--mouth-line-opacity: 0.55;
|
||||
--mouth-line-h: 8px;
|
||||
}
|
||||
body.emotion-sleepy .eye {
|
||||
height: 6vw;
|
||||
max-height: 90px;
|
||||
}
|
||||
|
||||
/* Smooth transition for everything */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
Loading…
Reference in New Issue