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):
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 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 setSANAD_CAMERA_USB_INDEXto 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:
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:
- Gemini API key card → paste your key (ships keyless).
- Recognition tab → enroll faces (camera capture or upload), name them, flag VIP; capture places (zones). This builds the primer DB.
- Mask tab (optional) → confirm the LED mask connects for expressions on recognition.
- 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
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 P3restart: unless-stopped, so it returns after a reboot. (For plugged/BT audio to survive a reboot, also run the one-timeloginctl enable-linger unitree— see Configuration.) - No Docker? (dev mode) run P3 in the robot's
gemini_sdkconda env against the vendored engine in./vendor:
The conda env needscd ~/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 stopgoogle-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 theProject.Sanadnamespace, builds the perception subsystems (camera, face gallery, zone gallery, recognition state) + comms core (brain/audio/voice/live_sub) + mask, constructs the NEW package-localVisitorMemorystore, injects a P3-scopedProject.Sanad.mainshim, 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, linkedface_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; nosanad-base.docker-compose.yml— standalone build + run (context: ., imagesanad-p3:latest, containersanad-p3, servicep3).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— refreshvendor/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/goaction stays disabled here — it belongs to P4.) - Visitor memory (
visitor_memory.py, NEW) — persistent profiles with attributes / notes / tags / last-seen / linkedface_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):
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:
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.