Spaces:
Running
Running
Commit
·
3e090ab
1
Parent(s):
858f68f
Initial commit
Browse files- .DS_Store +0 -0
- .hfignore +4 -0
- README.md +36 -0
- index.html +41 -0
- pyproject.toml +13 -0
- reachy_phone_home.egg-info/PKG-INFO +45 -0
- reachy_phone_home.egg-info/SOURCES.txt +13 -0
- reachy_phone_home.egg-info/dependency_links.txt +1 -0
- reachy_phone_home.egg-info/entry_points.txt +2 -0
- reachy_phone_home.egg-info/requires.txt +1 -0
- reachy_phone_home.egg-info/top_level.txt +1 -0
- reachy_phone_home/__init__.py +0 -0
- reachy_phone_home/__pycache__/__init__.cpython-310.pyc +0 -0
- reachy_phone_home/__pycache__/expressions.cpython-310.pyc +0 -0
- reachy_phone_home/__pycache__/gestures.cpython-310.pyc +0 -0
- reachy_phone_home/__pycache__/main.cpython-310.pyc +0 -0
- reachy_phone_home/__pycache__/movements.cpython-310.pyc +0 -0
- reachy_phone_home/main.py +382 -0
- reachy_phone_home_app.py +456 -0
- setup.cfg +12 -0
- setup.py +3 -0
- style.css +98 -0
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
.hfignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
reachy_mini_demos/
|
| 3 |
+
utils/
|
| 4 |
+
yolo26l.pt
|
README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Reachy Phone Home
|
| 3 |
+
emoji: 📱
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: static
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: Phone focus companion for Reachy Mini
|
| 9 |
+
tags:
|
| 10 |
+
- reachy_mini
|
| 11 |
+
- reachy_mini_python_app
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# reachy_phone_home
|
| 15 |
+
|
| 16 |
+
Phone focus companion for Reachy Mini. Reachy watches for your phone, encourages focus, and responds with movements.
|
| 17 |
+
|
| 18 |
+
## How it works
|
| 19 |
+
- Uses Reachy Mini camera + YOLO phone/person detection.
|
| 20 |
+
- Tracks phone use and triggers movements after configured heartbeats.
|
| 21 |
+
- Includes a manual simulation app for development.
|
| 22 |
+
|
| 23 |
+
## Run locally
|
| 24 |
+
```bash
|
| 25 |
+
python reachy_phone_home_app.py --weights yolo26l
|
| 26 |
+
```
|
| 27 |
+
The YOLO26 weights are downloaded automatically on first run.
|
| 28 |
+
|
| 29 |
+
## Configure
|
| 30 |
+
Key flags (examples):
|
| 31 |
+
```bash
|
| 32 |
+
python reachy_phone_home_app.py --weights yolo26l --conf 0.15 --process-every 1
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
## Publishing
|
| 36 |
+
Use the Reachy Mini App Assistant to check and publish this app.
|
index.html
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Reachy Phone Home</title>
|
| 7 |
+
<link rel="stylesheet" href="style.css" />
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<main class="page">
|
| 11 |
+
<header class="hero">
|
| 12 |
+
<div class="badge">Reachy Mini App</div>
|
| 13 |
+
<h1>Reachy Phone Home</h1>
|
| 14 |
+
<p>
|
| 15 |
+
A focus companion for Reachy Mini. Place your phone in front of Reachy and keep
|
| 16 |
+
it there while you work. Reachy watches for the phone, reacts to use, and
|
| 17 |
+
nudges you back to focus.
|
| 18 |
+
</p>
|
| 19 |
+
<div class="cta">
|
| 20 |
+
<span>Run locally:</span>
|
| 21 |
+
<code>python reachy_phone_home_app.py --weights yolo26l</code>
|
| 22 |
+
</div>
|
| 23 |
+
</header>
|
| 24 |
+
|
| 25 |
+
<section class="grid">
|
| 26 |
+
<div class="card">
|
| 27 |
+
<h2>Phone Watch</h2>
|
| 28 |
+
<p>Detects phone and person, then tracks phone use with stable timing.</p>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="card">
|
| 31 |
+
<h2>Reachy Motions</h2>
|
| 32 |
+
<p>Triggers “good job” movements after steady focus and “bad” movements after use.</p>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="card">
|
| 35 |
+
<h2>Customizable</h2>
|
| 36 |
+
<p>Tune thresholds and timing via CLI flags to fit your space and camera setup.</p>
|
| 37 |
+
</div>
|
| 38 |
+
</section>
|
| 39 |
+
</main>
|
| 40 |
+
</body>
|
| 41 |
+
</html>
|
pyproject.toml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "reachy_phone_home"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.10"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"reachy-mini"
|
| 9 |
+
]
|
| 10 |
+
keywords = ["reachy-mini-app"]
|
| 11 |
+
|
| 12 |
+
[project.entry-points."reachy_mini_apps"]
|
| 13 |
+
reachy_phone_home = "reachy_phone_home.main:ReachyPhoneHome"
|
reachy_phone_home.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: reachy_phone_home
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Add your description here
|
| 5 |
+
Keywords: reachy-mini-app
|
| 6 |
+
Requires-Python: >=3.10
|
| 7 |
+
Description-Content-Type: text/markdown
|
| 8 |
+
Requires-Dist: reachy-mini
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
title: Reachy Phone Home
|
| 12 |
+
emoji: 📱
|
| 13 |
+
colorFrom: yellow
|
| 14 |
+
colorTo: blue
|
| 15 |
+
sdk: static
|
| 16 |
+
pinned: false
|
| 17 |
+
short_description: Phone focus companion for Reachy Mini
|
| 18 |
+
tags:
|
| 19 |
+
- reachy_mini
|
| 20 |
+
- reachy_mini_python_app
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
# reachy_phone_home
|
| 24 |
+
|
| 25 |
+
Phone focus companion for Reachy Mini. Reachy watches for your phone, encourages focus, and responds with movements.
|
| 26 |
+
|
| 27 |
+
## How it works
|
| 28 |
+
- Uses Reachy Mini camera + YOLO phone/person detection.
|
| 29 |
+
- Tracks phone use and triggers movements after configured heartbeats.
|
| 30 |
+
- Includes a manual simulation app for development.
|
| 31 |
+
|
| 32 |
+
## Run locally
|
| 33 |
+
```bash
|
| 34 |
+
python reachy_phone_home_app.py --weights yolo26l
|
| 35 |
+
```
|
| 36 |
+
The YOLO26 weights are downloaded automatically on first run.
|
| 37 |
+
|
| 38 |
+
## Configure
|
| 39 |
+
Key flags (examples):
|
| 40 |
+
```bash
|
| 41 |
+
python reachy_phone_home_app.py --weights yolo26l --conf 0.15 --process-every 1
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Publishing
|
| 45 |
+
Use the Reachy Mini App Assistant to check and publish this app.
|
reachy_phone_home.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
README.md
|
| 2 |
+
pyproject.toml
|
| 3 |
+
setup.cfg
|
| 4 |
+
setup.py
|
| 5 |
+
reachy_phone_home/__init__.py
|
| 6 |
+
reachy_phone_home/main.py
|
| 7 |
+
reachy_phone_home/movements.py
|
| 8 |
+
reachy_phone_home.egg-info/PKG-INFO
|
| 9 |
+
reachy_phone_home.egg-info/SOURCES.txt
|
| 10 |
+
reachy_phone_home.egg-info/dependency_links.txt
|
| 11 |
+
reachy_phone_home.egg-info/entry_points.txt
|
| 12 |
+
reachy_phone_home.egg-info/requires.txt
|
| 13 |
+
reachy_phone_home.egg-info/top_level.txt
|
reachy_phone_home.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
reachy_phone_home.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[reachy_mini_apps]
|
| 2 |
+
reachy_phone_home = reachy_phone_home.main:ReachyPhoneHome
|
reachy_phone_home.egg-info/requires.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
reachy-mini
|
reachy_phone_home.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
reachy_phone_home
|
reachy_phone_home/__init__.py
ADDED
|
File without changes
|
reachy_phone_home/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (182 Bytes). View file
|
|
|
reachy_phone_home/__pycache__/expressions.cpython-310.pyc
ADDED
|
Binary file (2.5 kB). View file
|
|
|
reachy_phone_home/__pycache__/gestures.cpython-310.pyc
ADDED
|
Binary file (6.77 kB). View file
|
|
|
reachy_phone_home/__pycache__/main.cpython-310.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
reachy_phone_home/__pycache__/movements.cpython-310.pyc
ADDED
|
Binary file (6.07 kB). View file
|
|
|
reachy_phone_home/main.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import itertools
|
| 3 |
+
import os
|
| 4 |
+
import queue
|
| 5 |
+
import random
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from hashlib import sha1
|
| 13 |
+
from typing import Iterable, List, Optional, Tuple
|
| 14 |
+
|
| 15 |
+
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
from .movements import Movement, SituationMovements
|
| 19 |
+
except ImportError: # allow running as a script
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
| 24 |
+
from reachy_phone_home.movements import Movement, SituationMovements
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class SimulationConfig:
|
| 29 |
+
ambient_interval: float = 5.0
|
| 30 |
+
good_job_interval: float = 10.0
|
| 31 |
+
bad_interval: float = 5.0
|
| 32 |
+
min_gap_seconds: float = 5.0
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ElevenLabsSpeaker:
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
reachy: ReachyMini,
|
| 39 |
+
voice_id: str,
|
| 40 |
+
model_id: str = "eleven_multilingual_v2",
|
| 41 |
+
output_format: str = "pcm_22050",
|
| 42 |
+
cache_dir: Optional[Path] = None,
|
| 43 |
+
) -> None:
|
| 44 |
+
self.reachy = reachy
|
| 45 |
+
self.voice_id = voice_id
|
| 46 |
+
self.model_id = model_id
|
| 47 |
+
self.output_format = output_format
|
| 48 |
+
self.cache_dir = cache_dir
|
| 49 |
+
self._client = None
|
| 50 |
+
|
| 51 |
+
def _ensure_client(self) -> None:
|
| 52 |
+
if self._client is not None:
|
| 53 |
+
return
|
| 54 |
+
try:
|
| 55 |
+
from elevenlabs.client import ElevenLabs
|
| 56 |
+
except ImportError as exc:
|
| 57 |
+
raise SystemExit("Missing dependency: pip install elevenlabs") from exc
|
| 58 |
+
api_key = os.getenv("ELEVENLABS_API_KEY")
|
| 59 |
+
if not api_key:
|
| 60 |
+
raise SystemExit("ELEVENLABS_API_KEY is not set.")
|
| 61 |
+
self._client = ElevenLabs(api_key=api_key)
|
| 62 |
+
|
| 63 |
+
def _cache_path(self, text: str) -> Optional[Path]:
|
| 64 |
+
if self.cache_dir is None:
|
| 65 |
+
return None
|
| 66 |
+
key = f"{self.voice_id}|{self.model_id}|{self.output_format}|{text}"
|
| 67 |
+
digest = sha1(key.encode("utf-8")).hexdigest()[:12]
|
| 68 |
+
safe = "".join(ch for ch in text.lower() if ch.isalnum() or ch in (" ", "-"))
|
| 69 |
+
safe = "_".join(safe.split())[:32]
|
| 70 |
+
filename = f"{safe or 'quip'}_{digest}.wav"
|
| 71 |
+
return self.cache_dir / filename
|
| 72 |
+
|
| 73 |
+
def say(self, text: str) -> None:
|
| 74 |
+
if self.output_format.startswith("pcm_"):
|
| 75 |
+
sample_rate = int(self.output_format.split("_", 1)[1])
|
| 76 |
+
else:
|
| 77 |
+
raise SystemExit("Only PCM output is supported for now.")
|
| 78 |
+
|
| 79 |
+
cache_path = self._cache_path(text)
|
| 80 |
+
if cache_path is not None and cache_path.exists():
|
| 81 |
+
self.reachy.media.play_sound(str(cache_path))
|
| 82 |
+
return
|
| 83 |
+
|
| 84 |
+
self._ensure_client()
|
| 85 |
+
assert self._client is not None
|
| 86 |
+
audio = self._client.text_to_speech.convert(
|
| 87 |
+
text=text,
|
| 88 |
+
voice_id=self.voice_id,
|
| 89 |
+
model_id=self.model_id,
|
| 90 |
+
output_format=self.output_format,
|
| 91 |
+
)
|
| 92 |
+
audio_bytes = audio if isinstance(audio, (bytes, bytearray)) else b"".join(audio)
|
| 93 |
+
if cache_path is not None:
|
| 94 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 95 |
+
_write_pcm_wav(cache_path, audio_bytes, sample_rate)
|
| 96 |
+
self.reachy.media.play_sound(str(cache_path))
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
| 100 |
+
wav_path = Path(tmp_dir) / "tts.wav"
|
| 101 |
+
_write_pcm_wav(wav_path, audio_bytes, sample_rate)
|
| 102 |
+
self.reachy.media.play_sound(str(wav_path))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _write_pcm_wav(path: Path, pcm_bytes: bytes, sample_rate: int) -> None:
|
| 106 |
+
import wave
|
| 107 |
+
|
| 108 |
+
with wave.open(str(path), "wb") as wav_file:
|
| 109 |
+
wav_file.setnchannels(1)
|
| 110 |
+
wav_file.setsampwidth(2)
|
| 111 |
+
wav_file.setframerate(sample_rate)
|
| 112 |
+
wav_file.writeframes(pcm_bytes)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _start_key_listener(stop_event: threading.Event) -> queue.Queue[str]:
|
| 116 |
+
q: queue.Queue[str] = queue.Queue()
|
| 117 |
+
|
| 118 |
+
def _reader() -> None:
|
| 119 |
+
if not sys.stdin.isatty():
|
| 120 |
+
return
|
| 121 |
+
import select
|
| 122 |
+
import termios
|
| 123 |
+
import tty
|
| 124 |
+
|
| 125 |
+
fd = sys.stdin.fileno()
|
| 126 |
+
old = termios.tcgetattr(fd)
|
| 127 |
+
try:
|
| 128 |
+
tty.setcbreak(fd)
|
| 129 |
+
while not stop_event.is_set():
|
| 130 |
+
r, _, _ = select.select([sys.stdin], [], [], 0.1)
|
| 131 |
+
if r:
|
| 132 |
+
ch = sys.stdin.read(1)
|
| 133 |
+
q.put(ch)
|
| 134 |
+
finally:
|
| 135 |
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
| 136 |
+
|
| 137 |
+
t = threading.Thread(target=_reader, daemon=True)
|
| 138 |
+
t.start()
|
| 139 |
+
return q
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class ReachyPhoneHome(ReachyMiniApp):
|
| 143 |
+
def __init__(self, config: SimulationConfig | None = None) -> None:
|
| 144 |
+
super().__init__()
|
| 145 |
+
self.config = config or SimulationConfig()
|
| 146 |
+
self.enable_tts = True
|
| 147 |
+
self.voice_id = "KiWrnassWh6wLlOzZhGd"
|
| 148 |
+
self.model_id = "eleven_multilingual_v2"
|
| 149 |
+
self.output_format = "pcm_22050"
|
| 150 |
+
self.cache_dir: Optional[Path] = None
|
| 151 |
+
|
| 152 |
+
def _cycle_movements(self, moves: Iterable[Movement]) -> Iterable[Movement]:
|
| 153 |
+
return itertools.cycle(list(moves))
|
| 154 |
+
|
| 155 |
+
def _play_next(
|
| 156 |
+
self,
|
| 157 |
+
cycle_iter: Iterable[Movement],
|
| 158 |
+
label: str,
|
| 159 |
+
speaker: Optional[ElevenLabsSpeaker] = None,
|
| 160 |
+
quips: Optional[List[str]] = None,
|
| 161 |
+
) -> None:
|
| 162 |
+
move = next(cycle_iter)
|
| 163 |
+
print(f"[{label}] {move.name}")
|
| 164 |
+
move.run()
|
| 165 |
+
if speaker and quips:
|
| 166 |
+
speaker.say(random.choice(quips))
|
| 167 |
+
|
| 168 |
+
def _run_state_loop(
|
| 169 |
+
self,
|
| 170 |
+
stop_event: threading.Event,
|
| 171 |
+
ambient: Iterable[Movement],
|
| 172 |
+
good_job: Iterable[Movement],
|
| 173 |
+
bad: Iterable[Movement],
|
| 174 |
+
speaker: Optional[ElevenLabsSpeaker],
|
| 175 |
+
placed_quips: List[str],
|
| 176 |
+
taken_quips: List[str],
|
| 177 |
+
good_quips: List[str],
|
| 178 |
+
bad_quips: List[str],
|
| 179 |
+
) -> None:
|
| 180 |
+
phone_present = False
|
| 181 |
+
print("\nPress 1 = phone placed, 2 = phone taken, Ctrl+C to stop.")
|
| 182 |
+
|
| 183 |
+
key_queue = _start_key_listener(stop_event)
|
| 184 |
+
last_ambient = 0.0
|
| 185 |
+
last_good = 0.0
|
| 186 |
+
last_bad = 0.0
|
| 187 |
+
last_move = 0.0
|
| 188 |
+
last_speech = 0.0
|
| 189 |
+
|
| 190 |
+
while not stop_event.is_set():
|
| 191 |
+
try:
|
| 192 |
+
cmd = key_queue.get_nowait()
|
| 193 |
+
except queue.Empty:
|
| 194 |
+
cmd = None
|
| 195 |
+
|
| 196 |
+
if cmd == "1":
|
| 197 |
+
phone_present = True
|
| 198 |
+
print("[state] PHONE PRESENT")
|
| 199 |
+
if time.time() - last_move >= self.config.min_gap_seconds:
|
| 200 |
+
self._play_next(good_job, "good_job", speaker, good_quips)
|
| 201 |
+
last_move = time.time()
|
| 202 |
+
last_good = last_move
|
| 203 |
+
if speaker and time.time() - last_speech >= self.config.min_gap_seconds:
|
| 204 |
+
speaker.say(random.choice(placed_quips))
|
| 205 |
+
last_speech = time.time()
|
| 206 |
+
elif cmd == "2":
|
| 207 |
+
phone_present = False
|
| 208 |
+
print("[state] PHONE ABSENT")
|
| 209 |
+
if time.time() - last_move >= self.config.min_gap_seconds:
|
| 210 |
+
self._play_next(bad, "bad", speaker, bad_quips)
|
| 211 |
+
last_move = time.time()
|
| 212 |
+
last_bad = last_move
|
| 213 |
+
if speaker and time.time() - last_speech >= self.config.min_gap_seconds:
|
| 214 |
+
speaker.say(random.choice(taken_quips))
|
| 215 |
+
last_speech = time.time()
|
| 216 |
+
elif cmd is not None:
|
| 217 |
+
print("Use 1 (present) or 2 (absent).")
|
| 218 |
+
|
| 219 |
+
now = time.time()
|
| 220 |
+
if phone_present:
|
| 221 |
+
if (
|
| 222 |
+
now - last_good >= self.config.good_job_interval
|
| 223 |
+
and now - last_move >= self.config.min_gap_seconds
|
| 224 |
+
):
|
| 225 |
+
self._play_next(good_job, "good_job", speaker, good_quips)
|
| 226 |
+
last_good = time.time()
|
| 227 |
+
last_move = last_good
|
| 228 |
+
elif (
|
| 229 |
+
now - last_ambient >= self.config.ambient_interval
|
| 230 |
+
and now - last_move >= self.config.min_gap_seconds
|
| 231 |
+
):
|
| 232 |
+
self._play_next(ambient, "ambient")
|
| 233 |
+
last_ambient = time.time()
|
| 234 |
+
last_move = last_ambient
|
| 235 |
+
else:
|
| 236 |
+
if (
|
| 237 |
+
now - last_bad >= self.config.bad_interval
|
| 238 |
+
and now - last_move >= self.config.min_gap_seconds
|
| 239 |
+
):
|
| 240 |
+
self._play_next(bad, "bad", speaker, bad_quips)
|
| 241 |
+
last_bad = time.time()
|
| 242 |
+
last_move = last_bad
|
| 243 |
+
time.sleep(0.1)
|
| 244 |
+
|
| 245 |
+
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
| 246 |
+
moves = SituationMovements(reachy_mini)
|
| 247 |
+
|
| 248 |
+
ambient_cycle = self._cycle_movements(moves.ambient())
|
| 249 |
+
good_cycle = self._cycle_movements(moves.good_job())
|
| 250 |
+
bad_cycle = self._cycle_movements(moves.bad())
|
| 251 |
+
|
| 252 |
+
placed_quips = [
|
| 253 |
+
"Nice. Phone is parked. Let's focus.",
|
| 254 |
+
"Great. Phone down. You've got this.",
|
| 255 |
+
"All set. Phone is home.",
|
| 256 |
+
"Perfect. Phone secured.",
|
| 257 |
+
]
|
| 258 |
+
taken_quips = [
|
| 259 |
+
"Hey, the phone left its spot.",
|
| 260 |
+
"Phone's gone. Want to put it back?",
|
| 261 |
+
"Uh oh. Phone off the desk.",
|
| 262 |
+
"Phone is away. Let's bring it back.",
|
| 263 |
+
]
|
| 264 |
+
good_quips = [
|
| 265 |
+
"Nice work keeping the phone in place.",
|
| 266 |
+
"Focus mode: still on.",
|
| 267 |
+
"Great job staying on track.",
|
| 268 |
+
]
|
| 269 |
+
bad_quips = [
|
| 270 |
+
"The phone's tempting you again.",
|
| 271 |
+
"Let's put it back and refocus.",
|
| 272 |
+
"Phone away, focus goes stronger.",
|
| 273 |
+
]
|
| 274 |
+
|
| 275 |
+
speaker = None
|
| 276 |
+
if self.enable_tts and not stop_event.is_set():
|
| 277 |
+
try:
|
| 278 |
+
speaker = ElevenLabsSpeaker(
|
| 279 |
+
reachy=reachy_mini,
|
| 280 |
+
voice_id=self.voice_id,
|
| 281 |
+
model_id=self.model_id,
|
| 282 |
+
output_format=self.output_format,
|
| 283 |
+
cache_dir=self.cache_dir,
|
| 284 |
+
)
|
| 285 |
+
except Exception:
|
| 286 |
+
speaker = None
|
| 287 |
+
|
| 288 |
+
print("Starting Reachy Phone Home (manual state simulation).")
|
| 289 |
+
self._run_state_loop(
|
| 290 |
+
stop_event=stop_event,
|
| 291 |
+
ambient=ambient_cycle,
|
| 292 |
+
good_job=good_cycle,
|
| 293 |
+
bad=bad_cycle,
|
| 294 |
+
speaker=speaker,
|
| 295 |
+
placed_quips=placed_quips,
|
| 296 |
+
taken_quips=taken_quips,
|
| 297 |
+
good_quips=good_quips,
|
| 298 |
+
bad_quips=bad_quips,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
if __name__ == "__main__":
|
| 303 |
+
# You can run the app directly from this script
|
| 304 |
+
parser = argparse.ArgumentParser(description="Reachy Phone Home app (simulated)")
|
| 305 |
+
parser.add_argument("--ambient-interval", type=float, default=5.0)
|
| 306 |
+
parser.add_argument("--good-job-interval", type=float, default=10.0)
|
| 307 |
+
parser.add_argument("--bad-interval", type=float, default=5.0)
|
| 308 |
+
parser.add_argument("--min-gap", type=float, default=5.0)
|
| 309 |
+
parser.add_argument("--no-media", action="store_true", help="Disable media backend")
|
| 310 |
+
parser.add_argument("--tts", action="store_true", help="Enable TTS")
|
| 311 |
+
parser.add_argument("--voice-id", type=str, default="KiWrnassWh6wLlOzZhGd")
|
| 312 |
+
parser.add_argument("--model-id", type=str, default="eleven_multilingual_v2")
|
| 313 |
+
parser.add_argument("--output-format", type=str, default="pcm_22050")
|
| 314 |
+
parser.add_argument(
|
| 315 |
+
"--cache-dir",
|
| 316 |
+
type=str,
|
| 317 |
+
default=str(Path(__file__).resolve().parent / "assets" / "quips"),
|
| 318 |
+
help="Directory to cache ElevenLabs quips",
|
| 319 |
+
)
|
| 320 |
+
parser.add_argument(
|
| 321 |
+
"--prewarm-quips",
|
| 322 |
+
action="store_true",
|
| 323 |
+
help="Download and cache all quips, then exit",
|
| 324 |
+
)
|
| 325 |
+
args = parser.parse_args()
|
| 326 |
+
|
| 327 |
+
config = SimulationConfig(
|
| 328 |
+
ambient_interval=args.ambient_interval,
|
| 329 |
+
good_job_interval=args.good_job_interval,
|
| 330 |
+
bad_interval=args.bad_interval,
|
| 331 |
+
min_gap_seconds=args.min_gap,
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
with ReachyMini(media_backend="no_media" if args.no_media else "default") as mini:
|
| 335 |
+
app = ReachyPhoneHome(config=config)
|
| 336 |
+
app.enable_tts = args.tts and not args.no_media
|
| 337 |
+
app.voice_id = args.voice_id
|
| 338 |
+
app.model_id = args.model_id
|
| 339 |
+
app.output_format = args.output_format
|
| 340 |
+
app.cache_dir = Path(args.cache_dir)
|
| 341 |
+
|
| 342 |
+
if args.prewarm_quips:
|
| 343 |
+
app.enable_tts = True
|
| 344 |
+
speaker = ElevenLabsSpeaker(
|
| 345 |
+
reachy=mini,
|
| 346 |
+
voice_id=app.voice_id,
|
| 347 |
+
model_id=app.model_id,
|
| 348 |
+
output_format=app.output_format,
|
| 349 |
+
cache_dir=app.cache_dir,
|
| 350 |
+
)
|
| 351 |
+
all_quips = [
|
| 352 |
+
"Nice. Phone is parked. Let's focus.",
|
| 353 |
+
"Great. Phone down. You've got this.",
|
| 354 |
+
"All set. Phone is home.",
|
| 355 |
+
"Perfect. Phone secured.",
|
| 356 |
+
"Hey, the phone left its spot.",
|
| 357 |
+
"Phone's gone. Want to put it back?",
|
| 358 |
+
"Uh oh. Phone off the desk.",
|
| 359 |
+
"Phone is away. Let's bring it back.",
|
| 360 |
+
"Nice work keeping the phone in place.",
|
| 361 |
+
"Focus mode: still on.",
|
| 362 |
+
"Great job staying on track.",
|
| 363 |
+
"The phone's tempting you again.",
|
| 364 |
+
"Let's put it back and refocus.",
|
| 365 |
+
"Phone away, focus goes stronger.",
|
| 366 |
+
]
|
| 367 |
+
for quip in all_quips:
|
| 368 |
+
speaker.say(quip)
|
| 369 |
+
print(f"Cached {len(all_quips)} quips in {app.cache_dir}")
|
| 370 |
+
sys.exit(0)
|
| 371 |
+
|
| 372 |
+
stop = threading.Event()
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
print("Running 'reachy_phone_home' a ReachyMiniApp...")
|
| 376 |
+
print("Press Ctrl+C to stop the app.")
|
| 377 |
+
app.run(mini, stop)
|
| 378 |
+
print("App has stopped.")
|
| 379 |
+
|
| 380 |
+
except KeyboardInterrupt:
|
| 381 |
+
print("Stopping the app...")
|
| 382 |
+
stop.set()
|
reachy_phone_home_app.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reachy phone tracking + phone-use detection using YOLO26l (no look-down logic)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import logging
|
| 7 |
+
import math
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
from ultralytics import YOLO
|
| 14 |
+
|
| 15 |
+
from reachy_mini import ReachyMini
|
| 16 |
+
from reachy_mini.utils import create_head_pose
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from reachy_phone_home.movements import MovementScheduler, SituationMovements
|
| 20 |
+
except ModuleNotFoundError:
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
import sys
|
| 23 |
+
|
| 24 |
+
repo_root = Path(__file__).resolve().parents[1]
|
| 25 |
+
sys.path.insert(0, str(repo_root))
|
| 26 |
+
from reachy_phone_home.movements import MovementScheduler, SituationMovements
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
PHONE_CLASS_ID = 67 # COCO "cell phone"
|
| 30 |
+
PERSON_CLASS_ID = 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PhoneFollower:
|
| 34 |
+
def __init__(self, move_threshold_px: int = 60, head_duration: float = 0.6) -> None:
|
| 35 |
+
self.move_threshold_px = int(move_threshold_px)
|
| 36 |
+
self.head_duration = float(head_duration)
|
| 37 |
+
self.last_x = None
|
| 38 |
+
self.last_y = None
|
| 39 |
+
|
| 40 |
+
def update_box(self, reachy: ReachyMini, box, y_ratio: float = 0.5) -> None:
|
| 41 |
+
x1, y1, x2, y2 = _box_xyxy(box)
|
| 42 |
+
cx = int((x1 + x2) / 2)
|
| 43 |
+
cy = int(y1 + (y2 - y1) * y_ratio)
|
| 44 |
+
self.update_xy(reachy, cx, cy)
|
| 45 |
+
|
| 46 |
+
def update_xy(self, reachy: ReachyMini, cx: int, cy: int) -> None:
|
| 47 |
+
if self.last_x is not None and self.last_y is not None:
|
| 48 |
+
if (
|
| 49 |
+
abs(cx - self.last_x) < self.move_threshold_px
|
| 50 |
+
and abs(cy - self.last_y) < self.move_threshold_px
|
| 51 |
+
):
|
| 52 |
+
return
|
| 53 |
+
reachy.look_at_image(cx, cy, duration=self.head_duration, perform_movement=True)
|
| 54 |
+
self.last_x = cx
|
| 55 |
+
self.last_y = cy
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _box_xyxy(box) -> tuple[int, int, int, int]:
|
| 59 |
+
xyxy = box.xyxy[0].tolist()
|
| 60 |
+
return int(xyxy[0]), int(xyxy[1]), int(xyxy[2]), int(xyxy[3])
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _overlaps(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool:
|
| 64 |
+
ax1, ay1, ax2, ay2 = a
|
| 65 |
+
bx1, by1, bx2, by2 = b
|
| 66 |
+
return not (ax2 < bx1 or ax1 > bx2 or ay2 < by1 or ay1 > by2)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _pad_box(box: tuple[int, int, int, int], pad_ratio: float) -> tuple[int, int, int, int]:
|
| 70 |
+
x1, y1, x2, y2 = box
|
| 71 |
+
bw = max(1, x2 - x1)
|
| 72 |
+
bh = max(1, y2 - y1)
|
| 73 |
+
px = int(bw * pad_ratio)
|
| 74 |
+
py = int(bh * pad_ratio)
|
| 75 |
+
return x1 - px, y1 - py, x2 + px, y2 + py
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _clamp_box(
|
| 79 |
+
box: tuple[int, int, int, int], frame_w: int, frame_h: int
|
| 80 |
+
) -> tuple[int, int, int, int]:
|
| 81 |
+
x1, y1, x2, y2 = box
|
| 82 |
+
x1 = max(0, min(frame_w - 1, x1))
|
| 83 |
+
x2 = max(0, min(frame_w - 1, x2))
|
| 84 |
+
y1 = max(0, min(frame_h - 1, y1))
|
| 85 |
+
y2 = max(0, min(frame_h - 1, y2))
|
| 86 |
+
if x2 < x1:
|
| 87 |
+
x1, x2 = x2, x1
|
| 88 |
+
if y2 < y1:
|
| 89 |
+
y1, y2 = y2, y1
|
| 90 |
+
return x1, y1, x2, y2
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _resolve_weights(weights: str, logger: logging.Logger) -> str:
|
| 94 |
+
if weights in ("yolo26l", "yolo26m", "yolo26s", "yolo26n"):
|
| 95 |
+
filename = f"{weights}.pt"
|
| 96 |
+
cache_root = os.getenv("HF_HOME") or str(Path.home() / ".cache" / "reachy_phone_home")
|
| 97 |
+
cache_dir = Path(cache_root) / "models"
|
| 98 |
+
cache_path = cache_dir / filename
|
| 99 |
+
if not cache_path.exists():
|
| 100 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
url = f"https://huggingface.co/Ultralytics/YOLO26/resolve/main/{filename}"
|
| 102 |
+
logger.info("Downloading %s to %s", url, cache_path)
|
| 103 |
+
_download_file(url, cache_path)
|
| 104 |
+
return str(cache_path)
|
| 105 |
+
return weights
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _download_file(url: str, dest: Path) -> None:
|
| 109 |
+
import urllib.request
|
| 110 |
+
|
| 111 |
+
with urllib.request.urlopen(url) as response, open(dest, "wb") as out_file:
|
| 112 |
+
out_file.write(response.read())
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def main() -> None:
|
| 116 |
+
parser = argparse.ArgumentParser(description="Reachy phone use tracker (YOLO26l)")
|
| 117 |
+
parser.add_argument("--weights", type=str, default="yolo26l")
|
| 118 |
+
parser.add_argument("--conf", type=float, default=0.15)
|
| 119 |
+
parser.add_argument("--process-every", type=int, default=1)
|
| 120 |
+
parser.add_argument("--imgsz", type=int, default=640)
|
| 121 |
+
parser.add_argument("--head-duration", type=float, default=1.2)
|
| 122 |
+
parser.add_argument("--move-threshold-px", type=int, default=60)
|
| 123 |
+
parser.add_argument("--no-head", action="store_true")
|
| 124 |
+
parser.add_argument("--phone-use-confirm-sec", type=float, default=0.5)
|
| 125 |
+
parser.add_argument("--phone-use-clear-sec", type=float, default=0.5)
|
| 126 |
+
parser.add_argument("--phone-not-seen-clear-sec", type=float, default=1.0)
|
| 127 |
+
parser.add_argument("--pad", type=float, default=0.1)
|
| 128 |
+
parser.add_argument("--missing-neutral-sec", type=float, default=0.5)
|
| 129 |
+
parser.add_argument("--neutral-duration", type=float, default=1.2)
|
| 130 |
+
parser.add_argument("--person-y-ratio", type=float, default=0.3)
|
| 131 |
+
parser.add_argument("--look-down-after-sec", type=float, default=5.0)
|
| 132 |
+
parser.add_argument("--look-down-duration", type=float, default=1.2)
|
| 133 |
+
parser.add_argument("--look-down-z-mm", type=float, default=8.0)
|
| 134 |
+
parser.add_argument("--look-down-pitch-deg", type=float, default=30.0)
|
| 135 |
+
parser.add_argument("--look-down-window-sec", type=float, default=5.0)
|
| 136 |
+
parser.add_argument("--person-search-window-sec", type=float, default=5.0)
|
| 137 |
+
parser.add_argument("--no-antenna", action="store_true")
|
| 138 |
+
parser.add_argument("--antenna-angry-left", type=float, default=-2.6)
|
| 139 |
+
parser.add_argument("--antenna-angry-right", type=float, default=2.6)
|
| 140 |
+
parser.add_argument("--antenna-neutral-left", type=float, default=0.0)
|
| 141 |
+
parser.add_argument("--antenna-neutral-right", type=float, default=0.0)
|
| 142 |
+
parser.add_argument("--antenna-transition-sec", type=float, default=0.5)
|
| 143 |
+
parser.add_argument("--antenna-relax-sec", type=float, default=1.0)
|
| 144 |
+
parser.add_argument("--antenna-happy-amp", type=float, default=0.2)
|
| 145 |
+
parser.add_argument("--antenna-happy-duration", type=float, default=0.5)
|
| 146 |
+
parser.add_argument("--good-job-heartbeats", type=int, default=3)
|
| 147 |
+
parser.add_argument("--phone-use-bad-sec", type=float, default=10.0)
|
| 148 |
+
parser.add_argument("--movement-restore-sec", type=float, default=0.6)
|
| 149 |
+
parser.add_argument("--no-display", action="store_true")
|
| 150 |
+
args = parser.parse_args()
|
| 151 |
+
|
| 152 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 153 |
+
logger = logging.getLogger("yolo26l_phone_use_tracker")
|
| 154 |
+
|
| 155 |
+
weights = _resolve_weights(args.weights, logger)
|
| 156 |
+
model = YOLO(weights)
|
| 157 |
+
|
| 158 |
+
with ReachyMini() as reachy:
|
| 159 |
+
follower = PhoneFollower(
|
| 160 |
+
move_threshold_px=args.move_threshold_px,
|
| 161 |
+
head_duration=args.head_duration,
|
| 162 |
+
)
|
| 163 |
+
movements = SituationMovements(reachy)
|
| 164 |
+
scheduler = MovementScheduler(
|
| 165 |
+
movements,
|
| 166 |
+
good_job_heartbeats=args.good_job_heartbeats,
|
| 167 |
+
phone_use_bad_sec=args.phone_use_bad_sec,
|
| 168 |
+
phone_use_clear_sec=args.phone_use_clear_sec,
|
| 169 |
+
restore_head_duration=args.movement_restore_sec,
|
| 170 |
+
)
|
| 171 |
+
if not args.no_display:
|
| 172 |
+
cv2.namedWindow("YOLO26L Phone Use")
|
| 173 |
+
|
| 174 |
+
last_phone_use = 0.0
|
| 175 |
+
last_phone_use_state = False
|
| 176 |
+
phone_use_start = None
|
| 177 |
+
phone_use_stop = None
|
| 178 |
+
frame_count = 0
|
| 179 |
+
last_phone_label = None
|
| 180 |
+
phone_label_miss = 0
|
| 181 |
+
last_antenna_mode = "neutral"
|
| 182 |
+
last_phone_seen = time.time()
|
| 183 |
+
neutral_active = False
|
| 184 |
+
look_down_active = False
|
| 185 |
+
person_track_start = None
|
| 186 |
+
missing_start = None
|
| 187 |
+
last_heartbeat = time.time()
|
| 188 |
+
last_track_state = None
|
| 189 |
+
oscillate_start = None
|
| 190 |
+
last_oscillate_update = 0.0
|
| 191 |
+
look_down_cycles = 0
|
| 192 |
+
last_prompt = 0.0
|
| 193 |
+
mode = "tracking_phone"
|
| 194 |
+
mode_start = time.time()
|
| 195 |
+
|
| 196 |
+
while True:
|
| 197 |
+
frame = reachy.media.get_frame()
|
| 198 |
+
if frame is None:
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
frame_count += 1
|
| 202 |
+
if args.process_every > 1 and frame_count % max(1, args.process_every) != 0:
|
| 203 |
+
if not args.no_display:
|
| 204 |
+
cv2.imshow("YOLO26L Phone Use", frame)
|
| 205 |
+
if cv2.waitKey(1) & 0xFF == ord("q"):
|
| 206 |
+
break
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
results = model(
|
| 210 |
+
frame,
|
| 211 |
+
verbose=False,
|
| 212 |
+
classes=[PERSON_CLASS_ID, PHONE_CLASS_ID],
|
| 213 |
+
conf=args.conf,
|
| 214 |
+
imgsz=args.imgsz,
|
| 215 |
+
)
|
| 216 |
+
boxes = results[0].boxes if results else None
|
| 217 |
+
person_boxes = []
|
| 218 |
+
phone_boxes = []
|
| 219 |
+
if boxes is not None and hasattr(boxes, "cls"):
|
| 220 |
+
for i in range(len(boxes)):
|
| 221 |
+
cls = int(boxes.cls[i].item())
|
| 222 |
+
if cls == PERSON_CLASS_ID:
|
| 223 |
+
person_boxes.append(boxes[i])
|
| 224 |
+
elif cls == PHONE_CLASS_ID:
|
| 225 |
+
phone_boxes.append(boxes[i])
|
| 226 |
+
|
| 227 |
+
phone_seen = len(phone_boxes) > 0
|
| 228 |
+
phone_use = False
|
| 229 |
+
best_phone = None
|
| 230 |
+
if person_boxes and phone_boxes:
|
| 231 |
+
best_person = max(
|
| 232 |
+
person_boxes,
|
| 233 |
+
key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
|
| 234 |
+
)
|
| 235 |
+
pxyxy = _pad_box(_box_xyxy(best_person), args.pad)
|
| 236 |
+
for phbox in phone_boxes:
|
| 237 |
+
phxyxy = _pad_box(_box_xyxy(phbox), args.pad)
|
| 238 |
+
if _overlaps(pxyxy, phxyxy):
|
| 239 |
+
phone_use = True
|
| 240 |
+
break
|
| 241 |
+
if phone_boxes:
|
| 242 |
+
best_phone = max(
|
| 243 |
+
phone_boxes,
|
| 244 |
+
key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
|
| 245 |
+
)
|
| 246 |
+
if not args.no_head:
|
| 247 |
+
follower.update_box(reachy, best_phone, y_ratio=0.5)
|
| 248 |
+
last_phone_seen = time.time()
|
| 249 |
+
neutral_active = False
|
| 250 |
+
look_down_active = False
|
| 251 |
+
person_track_start = None
|
| 252 |
+
missing_start = None
|
| 253 |
+
look_down_cycles = 0
|
| 254 |
+
last_prompt = 0.0
|
| 255 |
+
mode = "tracking_phone"
|
| 256 |
+
mode_start = time.time()
|
| 257 |
+
|
| 258 |
+
if not phone_seen and time.time() - last_phone_seen >= args.missing_neutral_sec:
|
| 259 |
+
if missing_start is None:
|
| 260 |
+
missing_start = time.time()
|
| 261 |
+
if not neutral_active:
|
| 262 |
+
reachy.goto_target(head=create_head_pose(), duration=args.neutral_duration)
|
| 263 |
+
neutral_active = True
|
| 264 |
+
if mode == "tracking_phone":
|
| 265 |
+
mode = "tracking_person"
|
| 266 |
+
mode_start = time.time()
|
| 267 |
+
look_down_active = False
|
| 268 |
+
|
| 269 |
+
if mode == "tracking_person":
|
| 270 |
+
if person_boxes and not args.no_head:
|
| 271 |
+
best_person = max(
|
| 272 |
+
person_boxes,
|
| 273 |
+
key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
|
| 274 |
+
)
|
| 275 |
+
follower.update_box(reachy, best_person, y_ratio=args.person_y_ratio)
|
| 276 |
+
if time.time() - mode_start >= args.person_search_window_sec:
|
| 277 |
+
mode = "looking_down"
|
| 278 |
+
mode_start = time.time()
|
| 279 |
+
look_down_active = False
|
| 280 |
+
|
| 281 |
+
if mode == "looking_down":
|
| 282 |
+
if not look_down_active:
|
| 283 |
+
reachy.goto_target(
|
| 284 |
+
head=create_head_pose(
|
| 285 |
+
z=args.look_down_z_mm,
|
| 286 |
+
pitch=args.look_down_pitch_deg,
|
| 287 |
+
mm=True,
|
| 288 |
+
degrees=True,
|
| 289 |
+
),
|
| 290 |
+
duration=args.look_down_duration,
|
| 291 |
+
)
|
| 292 |
+
look_down_active = True
|
| 293 |
+
look_down_cycles += 1
|
| 294 |
+
if look_down_cycles >= 2 and time.time() - last_prompt >= 10.0:
|
| 295 |
+
logger.info("Can you please put the phone in front of me?")
|
| 296 |
+
last_prompt = time.time()
|
| 297 |
+
if time.time() - mode_start >= args.look_down_window_sec:
|
| 298 |
+
mode = "tracking_person"
|
| 299 |
+
mode_start = time.time()
|
| 300 |
+
|
| 301 |
+
if phone_seen:
|
| 302 |
+
track_state = "tracking_phone"
|
| 303 |
+
elif mode == "looking_down":
|
| 304 |
+
track_state = "looking_down"
|
| 305 |
+
elif mode == "tracking_person":
|
| 306 |
+
track_state = "tracking_person"
|
| 307 |
+
else:
|
| 308 |
+
track_state = "searching_phone"
|
| 309 |
+
if track_state != last_track_state:
|
| 310 |
+
logger.info("[state] %s", track_state)
|
| 311 |
+
last_track_state = track_state
|
| 312 |
+
|
| 313 |
+
if phone_use:
|
| 314 |
+
last_phone_use = time.time()
|
| 315 |
+
phone_use_stop = None
|
| 316 |
+
if phone_use_start is None:
|
| 317 |
+
phone_use_start = time.time()
|
| 318 |
+
if not last_phone_use_state and time.time() - phone_use_start >= args.phone_use_confirm_sec:
|
| 319 |
+
logger.info("[state] phone use detected")
|
| 320 |
+
last_phone_use_state = True
|
| 321 |
+
else:
|
| 322 |
+
phone_use_start = None
|
| 323 |
+
if phone_seen:
|
| 324 |
+
if phone_use_stop is None:
|
| 325 |
+
phone_use_stop = time.time()
|
| 326 |
+
if last_phone_use_state and time.time() - phone_use_stop >= args.phone_use_clear_sec:
|
| 327 |
+
logger.info("[state] phone use stopped")
|
| 328 |
+
last_phone_use_state = False
|
| 329 |
+
else:
|
| 330 |
+
phone_use_stop = None
|
| 331 |
+
if last_phone_use_state and time.time() - last_phone_use >= args.phone_not_seen_clear_sec:
|
| 332 |
+
logger.info("[state] phone use stopped")
|
| 333 |
+
last_phone_use_state = False
|
| 334 |
+
|
| 335 |
+
if time.time() - last_heartbeat >= 10.0:
|
| 336 |
+
logger.info("[heartbeat] phone_detected=%s", "yes" if phone_seen else "no")
|
| 337 |
+
if (
|
| 338 |
+
not args.no_antenna
|
| 339 |
+
and not last_phone_use_state
|
| 340 |
+
and last_track_state == "tracking_phone"
|
| 341 |
+
and phone_seen
|
| 342 |
+
):
|
| 343 |
+
oscillate_start = time.time()
|
| 344 |
+
last_heartbeat = time.time()
|
| 345 |
+
scheduler.on_heartbeat(
|
| 346 |
+
phone_tracked=(last_track_state == "tracking_phone"),
|
| 347 |
+
phone_use=last_phone_use_state,
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
if not args.no_antenna:
|
| 351 |
+
if last_phone_use_state:
|
| 352 |
+
antenna_mode = "angry"
|
| 353 |
+
elif phone_seen:
|
| 354 |
+
antenna_mode = "tracking"
|
| 355 |
+
else:
|
| 356 |
+
antenna_mode = "neutral"
|
| 357 |
+
|
| 358 |
+
if antenna_mode != last_antenna_mode:
|
| 359 |
+
if antenna_mode == "angry":
|
| 360 |
+
reachy.goto_target(
|
| 361 |
+
antennas=[args.antenna_angry_left, args.antenna_angry_right],
|
| 362 |
+
duration=args.antenna_transition_sec,
|
| 363 |
+
)
|
| 364 |
+
else:
|
| 365 |
+
duration = (
|
| 366 |
+
args.antenna_relax_sec
|
| 367 |
+
if last_antenna_mode == "angry"
|
| 368 |
+
else args.antenna_transition_sec
|
| 369 |
+
)
|
| 370 |
+
reachy.goto_target(
|
| 371 |
+
antennas=[args.antenna_neutral_left, args.antenna_neutral_right],
|
| 372 |
+
duration=duration,
|
| 373 |
+
)
|
| 374 |
+
last_antenna_mode = antenna_mode
|
| 375 |
+
|
| 376 |
+
if oscillate_start is not None and antenna_mode == "tracking":
|
| 377 |
+
elapsed = time.time() - oscillate_start
|
| 378 |
+
if elapsed <= args.antenna_happy_duration:
|
| 379 |
+
now = time.time()
|
| 380 |
+
if now - last_oscillate_update >= 0.05:
|
| 381 |
+
t = elapsed / args.antenna_happy_duration
|
| 382 |
+
val = args.antenna_happy_amp * math.sin(-math.pi / 2 + math.pi * t)
|
| 383 |
+
reachy.set_target(antennas=(val, -val))
|
| 384 |
+
last_oscillate_update = now
|
| 385 |
+
else:
|
| 386 |
+
oscillate_start = None
|
| 387 |
+
|
| 388 |
+
if not args.no_display:
|
| 389 |
+
display = frame.copy()
|
| 390 |
+
frame_h, frame_w = display.shape[:2]
|
| 391 |
+
if person_boxes:
|
| 392 |
+
best_person = max(
|
| 393 |
+
person_boxes,
|
| 394 |
+
key=lambda b: float(b.conf[0].item()) if hasattr(b, "conf") else 0.0,
|
| 395 |
+
)
|
| 396 |
+
x1, y1, x2, y2 = _clamp_box(
|
| 397 |
+
_pad_box(_box_xyxy(best_person), args.pad), frame_w, frame_h
|
| 398 |
+
)
|
| 399 |
+
cv2.rectangle(display, (x1, y1), (x2, y2), (0, 165, 255), 2)
|
| 400 |
+
if hasattr(best_person, "conf"):
|
| 401 |
+
conf = float(best_person.conf[0].item())
|
| 402 |
+
cv2.putText(
|
| 403 |
+
display,
|
| 404 |
+
f"{conf:.2f}",
|
| 405 |
+
(x1, max(20, y1 - 6)),
|
| 406 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 407 |
+
0.5,
|
| 408 |
+
(0, 165, 255),
|
| 409 |
+
2,
|
| 410 |
+
)
|
| 411 |
+
if best_phone is not None:
|
| 412 |
+
x1, y1, x2, y2 = _clamp_box(
|
| 413 |
+
_pad_box(_box_xyxy(best_phone), args.pad), frame_w, frame_h
|
| 414 |
+
)
|
| 415 |
+
cv2.rectangle(display, (x1, y1), (x2, y2), (0, 255, 0), 2)
|
| 416 |
+
conf = float(best_phone.conf[0].item()) if hasattr(best_phone, "conf") else None
|
| 417 |
+
label = f"phone {conf:.2f}" if conf is not None else "phone"
|
| 418 |
+
last_phone_label = (x1, y1, label)
|
| 419 |
+
phone_label_miss = 0
|
| 420 |
+
else:
|
| 421 |
+
phone_label_miss += 1
|
| 422 |
+
if phone_label_miss >= 5:
|
| 423 |
+
last_phone_label = None
|
| 424 |
+
if last_phone_label is not None:
|
| 425 |
+
x1, y1, label = last_phone_label
|
| 426 |
+
cv2.putText(
|
| 427 |
+
display,
|
| 428 |
+
label,
|
| 429 |
+
(x1, max(20, y1 - 6)),
|
| 430 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 431 |
+
0.6,
|
| 432 |
+
(0, 255, 0),
|
| 433 |
+
2,
|
| 434 |
+
cv2.LINE_AA,
|
| 435 |
+
)
|
| 436 |
+
if last_phone_use_state:
|
| 437 |
+
cv2.rectangle(display, (5, 5), (230, 55), (0, 0, 255), -1)
|
| 438 |
+
cv2.putText(
|
| 439 |
+
display,
|
| 440 |
+
"PHONE USE",
|
| 441 |
+
(12, 42),
|
| 442 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 443 |
+
1.2,
|
| 444 |
+
(255, 255, 255),
|
| 445 |
+
3,
|
| 446 |
+
)
|
| 447 |
+
cv2.imshow("YOLO26L Phone Use", display)
|
| 448 |
+
if cv2.waitKey(1) & 0xFF == ord("q"):
|
| 449 |
+
break
|
| 450 |
+
|
| 451 |
+
if not args.no_display:
|
| 452 |
+
cv2.destroyAllWindows()
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
if __name__ == "__main__":
|
| 456 |
+
main()
|
setup.cfg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[metadata]
|
| 2 |
+
name = reachy_phone_home
|
| 3 |
+
version = 0.1.0
|
| 4 |
+
description = Phone focus companion for Reachy Mini
|
| 5 |
+
|
| 6 |
+
[options]
|
| 7 |
+
packages = find:
|
| 8 |
+
python_requires = >=3.10
|
| 9 |
+
|
| 10 |
+
[options.entry_points]
|
| 11 |
+
reachy_mini_apps =
|
| 12 |
+
reachy_phone_home = reachy_phone_home.main:ReachyPhoneHome
|
setup.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from distutils.core import setup
|
| 2 |
+
|
| 3 |
+
setup()
|
style.css
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg: #f7f4ed;
|
| 3 |
+
--ink: #1f1d1a;
|
| 4 |
+
--muted: #5e5a52;
|
| 5 |
+
--accent: #ffcc4d;
|
| 6 |
+
--card: #ffffff;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
* {
|
| 10 |
+
box-sizing: border-box;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
body {
|
| 14 |
+
margin: 0;
|
| 15 |
+
font-family: "IBM Plex Sans", "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
| 16 |
+
background: radial-gradient(circle at top, #fff5d6, var(--bg) 45%, #eef0f2 100%);
|
| 17 |
+
color: var(--ink);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.page {
|
| 21 |
+
max-width: 960px;
|
| 22 |
+
margin: 0 auto;
|
| 23 |
+
padding: 48px 24px 72px;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.hero {
|
| 27 |
+
background: var(--card);
|
| 28 |
+
border-radius: 20px;
|
| 29 |
+
padding: 32px;
|
| 30 |
+
box-shadow: 0 18px 40px rgba(31, 29, 26, 0.08);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.badge {
|
| 34 |
+
display: inline-block;
|
| 35 |
+
padding: 6px 12px;
|
| 36 |
+
border-radius: 999px;
|
| 37 |
+
background: var(--accent);
|
| 38 |
+
font-weight: 600;
|
| 39 |
+
font-size: 12px;
|
| 40 |
+
letter-spacing: 0.08em;
|
| 41 |
+
text-transform: uppercase;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.hero h1 {
|
| 45 |
+
margin: 16px 0 12px;
|
| 46 |
+
font-size: 40px;
|
| 47 |
+
letter-spacing: -0.02em;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.hero p {
|
| 51 |
+
margin: 0;
|
| 52 |
+
color: var(--muted);
|
| 53 |
+
font-size: 18px;
|
| 54 |
+
line-height: 1.6;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.cta {
|
| 58 |
+
margin-top: 24px;
|
| 59 |
+
padding: 14px 18px;
|
| 60 |
+
background: #fff2c2;
|
| 61 |
+
border-radius: 12px;
|
| 62 |
+
font-size: 14px;
|
| 63 |
+
display: inline-flex;
|
| 64 |
+
gap: 8px;
|
| 65 |
+
align-items: center;
|
| 66 |
+
flex-wrap: wrap;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.cta code {
|
| 70 |
+
background: #fff;
|
| 71 |
+
padding: 6px 10px;
|
| 72 |
+
border-radius: 8px;
|
| 73 |
+
font-size: 13px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.grid {
|
| 77 |
+
margin-top: 28px;
|
| 78 |
+
display: grid;
|
| 79 |
+
gap: 18px;
|
| 80 |
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.card {
|
| 84 |
+
background: var(--card);
|
| 85 |
+
border-radius: 16px;
|
| 86 |
+
padding: 20px;
|
| 87 |
+
box-shadow: 0 10px 24px rgba(31, 29, 26, 0.06);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.card h2 {
|
| 91 |
+
margin-top: 0;
|
| 92 |
+
font-size: 18px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.card p {
|
| 96 |
+
margin-bottom: 0;
|
| 97 |
+
color: var(--muted);
|
| 98 |
+
}
|