250 lines
13 KiB
Markdown
250 lines
13 KiB
Markdown
# Unitree G1 - Sanad Package 3 — Facial Recognition + Places + Memories
|
|
|
|
Identify **faces** (VIP database), recognize **places** (visual zones), and
|
|
**remember visitors** across visits — then greet them personally and drive the
|
|
LED mask on recognition. Perception + memory only; **no robot motion**. Dashboard
|
|
on **:8013**.
|
|
|
|
This package is **self-contained** — it vendors the Sanad engine under `vendor/`
|
|
and builds `FROM python:3.10-slim-bookworm`, so a clone of this folder builds and
|
|
runs with **no sibling folders and no shared base image**. Recognition is 100%
|
|
**Gemini-side / in-context** (primer images) — there is **no local ML model** and
|
|
no CUDA needed. The image ships **keyless**: the customer pastes their own Gemini
|
|
API key in the dashboard on first open.
|
|
|
|
---
|
|
|
|
## Install (per-robot, first run)
|
|
|
|
The workstation is canonical. You **rsync the folder to the robot, then build and
|
|
run Docker on the robot**.
|
|
|
|
### 1. Copy the package onto the robot
|
|
From the workstation (dev robot = `10.255.254.86`):
|
|
```bash
|
|
rsync -az --exclude __pycache__ --exclude data/faces --exclude data/zones \
|
|
Project/G1/Packages/Sanad_Package_3 unitree@<robot>:~/sanad_deploy/
|
|
```
|
|
Everything the container needs is inside the folder (`vendor/Sanad`,
|
|
`vendor/sanad_pkg`, `vendor/mask`) — no monorepo checkout on the robot.
|
|
|
|
### 2. License
|
|
Verification is a signed **Ed25519** `sanad.lic` (fully **offline** — the public
|
|
key is baked into the image). The folder ships a **demo** license
|
|
(`license/sanad.lic.example`, robot_id `G1-SN-DEMO-0001`, unbound, expires
|
|
`2030-01-01`) that entitles **P1 + P2 + P3**, so the package **builds and runs
|
|
as-is** for evaluation. A **production** robot needs a **signed per-robot
|
|
license** — see [Licensing](#licensing) below (P3 has no `NEW_ROBOT_SETUP.md`;
|
|
the fleet-level `../README.md` has the vendor signing flow too).
|
|
|
|
### 3. Keyless — add your own Gemini key
|
|
No Gemini key is baked into the image. On first open of the dashboard, paste your
|
|
key in the **Gemini API key** card (or `POST /api/p3/api-key`). It is persisted to
|
|
`data/motions/config.json` and hot-swapped into the live session.
|
|
|
|
### 4. Camera — the #1 gotcha (read this before `up`)
|
|
Recognition needs the camera, but the `/dev/video*` device mounts in
|
|
`docker-compose.yml` are **commented out by default** — because Docker
|
|
**hard-fails `up`** if you bind a device node that is absent. Before enabling
|
|
recognition, edit `docker-compose.yml` and **uncomment the V4L node your camera
|
|
actually exposes**:
|
|
- A plain USB webcam is usually `/dev/video0`.
|
|
- A **RealSense colour node is NOT `video0`** — a RealSense exposes ~6 nodes; pick
|
|
the colour one (or add several) **or** set `SANAD_CAMERA_USB_INDEX` to the right
|
|
OpenCV index.
|
|
|
|
Capture is plain **OpenCV** (`opencv-python-headless`, `cv2.VideoCapture`) — no
|
|
`pyrealsense2`. The container is granted `group_add: video` so it can open the
|
|
node. Faces/zones/memories persist under the bind-mounted `./data`.
|
|
|
|
---
|
|
|
|
## Run with Docker (quick start)
|
|
|
|
On the robot, **from this folder** (`Sanad_Package_3/`) — nothing else needed:
|
|
```bash
|
|
cp .env.example .env # optional: license / audio / camera / language
|
|
docker compose up -d --build # build (vendored engine) + run
|
|
# Jetson Docker without buildx: DOCKER_BUILDKIT=0 docker compose up -d --build
|
|
```
|
|
`network_mode: host` is **mandatory** (G1 DDS discovery on `eth0`) and is already
|
|
set in the compose file. Then open **http://<robot-ip>:8013** and:
|
|
1. **Gemini API key** card → paste your key (ships keyless).
|
|
2. **Recognition** tab → enroll faces (camera capture or upload), name them, flag
|
|
**VIP**; capture **places** (zones). This builds the primer DB.
|
|
3. **Mask** tab (optional) → confirm the LED mask connects for expressions on
|
|
recognition.
|
|
4. **Settings** tab → speaker profile (chest vs USB/BT), volume, persona/language.
|
|
|
|
If the camera devices are still commented out, the container boots fine but
|
|
recognition has no video source — uncomment the right V4L node (step 4 of Install)
|
|
and `docker compose up -d` again.
|
|
|
|
---
|
|
|
|
## Manage it
|
|
```bash
|
|
docker compose logs -f # live logs
|
|
docker compose down # stop
|
|
./test_p3.sh <robot-ip>:8013 # smoke test (expect 13/13 PASS + memory roundtrip)
|
|
```
|
|
- **Auto-start on boot:** `sudo systemctl enable docker` — compose already runs P3
|
|
`restart: unless-stopped`, so it returns after a reboot. (For plugged/BT audio to
|
|
survive a reboot, also run the one-time `loginctl enable-linger unitree` — see
|
|
[Configuration](#configuration-env-vars).)
|
|
- **No Docker? (dev mode)** run P3 in the robot's `gemini_sdk` conda env against the
|
|
vendored engine in `./vendor`:
|
|
```bash
|
|
cd ~/sanad_deploy/Sanad_Package_3
|
|
./p3ctl.sh start # launch on :8013 (runs against ./vendor)
|
|
./p3ctl.sh status # process + /api/health
|
|
./p3ctl.sh logs 80 # tail the P3 log
|
|
./p3ctl.sh restart
|
|
./p3ctl.sh stop
|
|
```
|
|
The conda env needs `google-genai`, `opencv-python-headless`, and (for the LED
|
|
mask) `bleak==0.22.3` + `Pillow`.
|
|
|
|
**What happens on `up`** (from `entrypoint.sh`): (1) **license gate** —
|
|
`python3 -m sanad_pkg.license_check P3`; if the robot is **not** entitled the
|
|
container **exits cleanly (code 0)** so the restart policy will not crash-loop;
|
|
(2) **resolve config** with precedence **env var > license feature >
|
|
`config/p3_config.json`**; (3) a **preflight** that prints clear diagnostics
|
|
(python / google-genai / pyaudio / bleak / Pillow / mask lib / Unitree SDK /
|
|
audio profile); (4) `exec app_p3.py`.
|
|
|
|
---
|
|
|
|
## What it ships
|
|
- `app_p3.py` — the launcher: bootstraps the `Project.Sanad` namespace, builds the
|
|
perception subsystems (camera, face gallery, zone gallery, recognition state) +
|
|
comms core (brain/audio/voice/live_sub) + mask, constructs the **NEW**
|
|
package-local `VisitorMemory` store, injects a P3-scoped `Project.Sanad.main`
|
|
shim, and mounts only the P3 routers + the filtered SPA (non-P3 tabs hidden).
|
|
- `routes_p3.py` — P3 convenience routes (`/api/p3/*`: api-key, persona, say,
|
|
settings, logs) that also restart the live Gemini session so a new key/persona
|
|
applies immediately.
|
|
- `routes_memory.py` — the **NEW** visitor-memory CRUD router (`/api/memory/*`).
|
|
- `visitor_memory.py` — the persistent visitor-profile store (attributes, notes,
|
|
tags, last-seen, linked `face_id`; feeds personalized-greeting primers).
|
|
- `vendor/Sanad` + `vendor/sanad_pkg` + `vendor/mask` — the vendored Sanad engine,
|
|
license/bus lib, and Shining-Mask BLE lib. Refresh with `./sync_vendor.sh`.
|
|
- `Dockerfile` / `requirements.txt` — **SELF-CONTAINED** build (`FROM
|
|
python:3.10-slim-bookworm`), bakes the vendored engine + pubkey; no `sanad-base`.
|
|
- `docker-compose.yml` — standalone build + run (`context: .`, image
|
|
`sanad-p3:latest`, container `sanad-p3`, service `p3`).
|
|
- `entrypoint.sh` — license gate → config resolve → preflight → launch.
|
|
- `config/` — `p3_config.json` (defaults: port, audio, tab set) +
|
|
`mask_config.json`.
|
|
- `license/` — `pubkey.ed25519` (public verify key) + `sanad.lic.example` (demo).
|
|
- `sync_vendor.sh` — refresh `vendor/` from a full monorepo checkout.
|
|
- `test_p3.sh` — 13-check smoke test + a memory create→list→delete roundtrip.
|
|
- `p3ctl.sh` — no-Docker dev-mode control (`start|stop|restart|status|logs`).
|
|
- `strip_key.py` — build step that guarantees the image ships keyless.
|
|
- `data/` — bind-mounted persistence (`faces` / `zones` / `memories` / recordings).
|
|
|
|
---
|
|
|
|
## Configuration (env vars)
|
|
|
|
Copy `.env.example` → `.env` (compose reads it automatically). The most-used knobs:
|
|
|
|
| Var | Default | What it does |
|
|
|---|---|---|
|
|
| `SANAD_LICENSE_FILE` | `./license/sanad.lic.example` | Signed license mounted read-only at `/etc/sanad/sanad.lic`. Point at your real per-robot `sanad.lic`. |
|
|
| `SANAD_LICENSE_BIND` | `0` | `1` enforces machine-fingerprint binding (also uncomment the `/etc/machine-id` mount in `docker-compose.yml`). |
|
|
| `SANAD_CAMERA_USB_INDEX` | *(empty)* | Pin the OpenCV colour-camera index (a RealSense colour node is **not** `video0`). Alternative to uncommenting a specific `/dev/video*` bind. |
|
|
| `SANAD_AUDIO_PROFILE` | `builtin` | `builtin` = G1 chest over DDS; `plugged` = USB (Anker) / Bluetooth (JBL) via host PulseAudio. |
|
|
| `SANAD_DDS_INTERFACE` | `eth0` | NIC used for G1 DDS discovery. |
|
|
| `SANAD_LANGUAGE` | *(empty)* | Empty = **multilingual auto-detect**; set `ar`/`en` to pin one language. |
|
|
| `SANAD_MASK_ADDRESS` | *(empty)* | Pin the LED mask BLE MAC; empty = auto-discover by name prefix. |
|
|
| `SANAD_MEMORIES_DIR` | `/app/Sanad/data/memories` | Where visitor profiles are stored (persisted via the `./data` bind). |
|
|
| `SANAD_PULSE_DIR` / `PULSE_SERVER` / `PULSE_COOKIE` | `/run/user/1000/pulse…` | Host PulseAudio socket + auth cookie the root container needs to set **plugged/BT** volume. Chest audio needs none of this. |
|
|
| `WITH_UNITREE_SDK` | `1` | Build arg: `1` bundles CycloneDDS (pinned `0.10.2`, built full) + `unitree_sdk2_python` so chest audio works out of the box; `0` = leaner USB-only image. |
|
|
| `BASE_OS_IMAGE` | `python:3.10-slim-bookworm` | Build arg: override only for a GPU build. |
|
|
| `SANAD_IMAGE` | `sanad-p3:latest` | Image name/tag (e.g. a registry path for pull-and-run). |
|
|
|
|
**Audio note (like SanadV3):** the volume slider drives **all** speaker types. The
|
|
unified control `POST /api/audio/g1-speaker/volume` sets the G1 chest (DDS
|
|
`SetVolume`) **and** the active PulseAudio sink (plugged/BT). For the plugged/BT
|
|
half, the root container needs the host PulseAudio socket + cookie — already mounted
|
|
by compose. One-time host setup for a stable boot-time socket:
|
|
`loginctl enable-linger unitree`.
|
|
|
|
---
|
|
|
|
## Dashboard & features (http://<robot>:8013)
|
|
|
|
The dashboard is the SanadV3 SPA with only the **P3 tabs** shown:
|
|
**operations · voice · recognition · mask · recordings · settings**
|
|
(motion / controller / navigation / livemap / mapeditor / temp / terminal are
|
|
hidden — they belong to other packages).
|
|
|
|
What you can do:
|
|
- **Faces / VIP DB** (`vision/face_gallery.py`) — enroll via camera capture or
|
|
upload, rename, describe, flag **VIP**.
|
|
- **Places** (`vision/zone_gallery.py`) — capture and recognize visual zones. (The
|
|
nav `/go` action stays disabled here — it belongs to P4.)
|
|
- **Visitor memory** (`visitor_memory.py`, **NEW**) — persistent profiles with
|
|
attributes / notes / tags / last-seen / linked `face_id`, feeding
|
|
personalized-greeting primers.
|
|
- **Mask** — drives the LED "Shining Mask" for expressions on recognition (same
|
|
in-container BLE stack as P2: `bleak==0.22.3`, host `/var/run/dbus`, `NET_ADMIN`,
|
|
`/dev/bus/usb`).
|
|
|
|
### API endpoint groups
|
|
| Group | Prefix | Purpose |
|
|
|---|---|---|
|
|
| **Recognition** | `/api/recognition/*` | Face identification + VIP gallery state. |
|
|
| **Places** | `/api/zones/*` | Visual zone / place recognition. |
|
|
| **Visitor memory** (NEW) | `/api/memory/*` | CRUD: `GET /` list · `GET /status` · `GET /primer` · `GET /by-face/{id}` · `GET/PUT/DELETE /{id}` · `POST /` create · `POST /{id}/touch`. |
|
|
| **Mask** | `/api/mask/*` | LED mask status + expressions. |
|
|
| **P3 settings** | `/api/p3/*` | `api-key`, `persona`, `say`, `settings`, `logs/delete`. |
|
|
| **Comms** | `/api/voice/*` `/api/audio/*` `/api/prompt/*` `/api/typed-replay/*` `/api/records/*` `/api/live-subprocess/*` | Reused Sanad conversation + audio subset. |
|
|
| **Ops** | `/api/package` `/api/health` `/api/system/info` `/api/logs/*` `/ws/logs` | Manifest (+ license + features), health, system info, logs. |
|
|
|
|
---
|
|
|
|
## Licensing
|
|
|
|
A signed **Ed25519** `sanad.lic` decides which packages a robot may run.
|
|
`pubkey.ed25519` is baked into every image at `/etc/sanad/pubkey.ed25519`;
|
|
verification is fully **offline**. Compose mounts the license read-only:
|
|
`${SANAD_LICENSE_FILE:-./license/sanad.lic.example}:/etc/sanad/sanad.lic:ro`.
|
|
|
|
**The bundled demo** (`license/sanad.lic.example`) entitles **P1 + P2 + P3** with
|
|
features `face_rec`, `places`, `memory`, `mask` (among others), robot_id
|
|
`G1-SN-DEMO-0001`, unbound, expiring `2030-01-01` — enough to build and evaluate P3.
|
|
|
|
**Features that gate P3:** `face_rec`, `places`, `memory`, `mask`.
|
|
|
|
**Sign a real per-robot license** (vendor side — keep the private key **OFF** the
|
|
robot):
|
|
```bash
|
|
python licensing/sign_license.py gen-keys --out-dir . # once
|
|
# edit claims.json: set packages{P1..P4}, features{face_rec,places,memory,mask,…},
|
|
# robot_id, and (optionally) machine_fingerprint
|
|
python licensing/sign_license.py sign --key privkey.ed25519 \
|
|
--in claims.json --out sanad.lic
|
|
```
|
|
Put `sanad.lic` on the robot and point `SANAD_LICENSE_FILE` at it (or replace
|
|
`license/sanad.lic.example`). Optional **hardware binding**: set
|
|
`SANAD_LICENSE_BIND=1` (also uncomment the `/etc/machine-id` mount). Verify
|
|
entitlement exactly as the entrypoint does:
|
|
```bash
|
|
SANAD_LICENSE=license/sanad.lic python -m sanad_pkg.license_check P3
|
|
```
|
|
|
|
---
|
|
|
|
## Status
|
|
|
|
**Built + validated locally** — the package compiles, `license_check P3` reports
|
|
entitled against the demo license, and the compose YAML is valid. P3 is a real,
|
|
shipped package (not a scaffold).
|
|
|
|
**Remaining gate:** the on-robot Docker build + `./test_p3.sh` smoke test on the G1
|
|
(P1 is already robot-tested; P2/P3/P4 have been validated locally but not yet
|
|
built + smoke-tested on hardware). The camera V4L node must be uncommented on the
|
|
target robot before recognition will have video.
|