fix(macos-rssi-bridge): make `make start` show data flow + figure actually move

Two debuggability fixes:

* `make start` now passes `--verbose` to both the bridge and the presence
  injector so you can see `[bridge] seq=… max_var=…` and `[presence] aps=…
  weight=… motion=… posted=…` lines while running. Previously start was
  silent and you couldn't tell whether data was flowing or where the
  pipeline was stuck.

* `presence_to_pose.py` sway amplitudes bumped ~3x (e.g. lateral sway
  6 cm → 20 cm at full motion, knee bend 5 cm → 18 cm, head turn 4 cm →
  12 cm). The old values were tuned for a close-up viewer; at the
  Observatory's orbit camera distance (~5–10 m) they were imperceptible,
  so a real-motion figure looked frozen even though the data was live.
  Still seeded purely by RSSI variance — same intent as before, just
  visible.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
griffin 2026-05-26 09:54:05 -07:00
parent 672be201f4
commit 1d2fc51790
No known key found for this signature in database
GPG Key ID: E2CBC5B01EFA35EB
2 changed files with 46 additions and 117 deletions

View File

@ -73,12 +73,12 @@ start: build build-server
echo "[start] sensing-server pid=$$SERVER_PID (source=esp32) — waiting 3s for UDP bind…"; \
sleep 3; \
./$(BRIDGE) --helper ./$(HELPER) --target-host $(TARGET_HOST) \
--target-port $(TARGET_PORT) --interval $(INTERVAL) & \
--target-port $(TARGET_PORT) --interval $(INTERVAL) --verbose & \
BRIDGE_PID=$$!; \
echo "[start] bridge pid=$$BRIDGE_PID — dashboard: $(BRIDGE_DASHBOARD) — waiting 2s for /aps…"; \
sleep 2; \
python3 presence_to_pose.py --bridge-url http://127.0.0.1:9090/aps \
--server-url $(SENSING_UI_URL)/api/v1/pose/external & \
--server-url $(SENSING_UI_URL)/api/v1/pose/external --verbose & \
PRESENCE_PID=$$!; \
echo "[start] presence injector pid=$$PRESENCE_PID — feeding /api/v1/pose/external @ 10 Hz"; \
( sleep 2; open $(SENSING_UI_URL) 2>/dev/null; open $(BRIDGE_DASHBOARD) 2>/dev/null ) & \

View File

@ -1,23 +1,23 @@
#!/usr/bin/env python3
"""
presence_to_pose.py RSSI tomography centroid sensing-server pose injector.
presence_to_pose.py RSSI motion-energy sensing-server presence injector.
Polls the macos-rssi-bridge HTTP endpoint at /aps for live per-AP state,
computes the multistatic tomography centroid the same way dashboard.html
does (Gaussian perturbation strips along each APlaptop link, weighted by
per-AP RSSI variance), and POSTs the result as a single PersonDetection
to the sensing-server's /api/v1/pose/external endpoint.
The sensing-server bypasses its heuristic skeleton when external pose is
fresh (within EXTERNAL_POSE_TTL = 500ms), so the 3D Observatory renders
one honest figure standing where the heatmap localizes you instead of
five hallucinated skeletons.
Polls the macos-rssi-bridge HTTP endpoint at /aps for live per-AP RSSI
variance, and POSTs a single PersonDetection to the sensing-server's
/api/v1/pose/external endpoint so the 3D Observatory renders one honest
figure (instead of the heuristic five-skeleton fallback).
Honest about what's real:
- Position: derived from real RSSI variance + Fresnel-zone geometry.
- Pose: STATIC standing skeleton (no joint estimation from RSSI; that
needs CSI hardware).
- Count: capped at 1 (single sensor, single observed environment).
- Position: PINNED at the laptop (origin). With one macOS RSSI receiver
and unknown AP locations, RSSI variance can only detect that *something*
moved in the environment it cannot tell us where. Earlier versions
fabricated a centroid from hash-positioned APs; that produced wildly
wrong jumps and is gone.
- Confidence + motion: derived from total per-AP RSSI variance. Higher
variance higher confidence and a more animated standing pose.
- Pose: STATIC standing skeleton. No joint estimation from RSSI; that
needs CSI hardware.
- Count: capped at 1.
"""
from __future__ import annotations
@ -29,35 +29,26 @@ import urllib.error
import urllib.request
from typing import Any
# Coordinate frame: observatory uses meters with the laptop near origin.
# Standing keypoints in poseStanding() (figure-pool's pose-system.js) sit
# at y ≈ 0..1.72; we put the figure at the room's floor level (y=0) and
# let the pose-system raise the joints. Width of the playable area is
# roughly ±4m.
ROOM_HALF_X = 3.5
ROOM_HALF_Z = 3.5
# 17-point COCO standing-pose offsets, anchored at the figure's foot
# centroid (px, 0, pz). We don't actually need to send these — the
# observatory regenerates keypoints from `pose='standing'` + `position`.
# We include a sane skeleton anyway so the basic /ui/index.html viewer
# also renders something coherent.
def standing_keypoints(
px: float, pz: float, conf: float, motion: float, t: float
) -> list[dict[str, Any]]:
# In meters above floor. Mirrors PoseSystem.poseStanding() in
# ui/observatory/js/pose-system.js so both viewers agree.
#
# `motion` ∈ [0..1] modulates a subtle weight-shift sway so the figure
# looks alive when there's real RSSI motion. We're explicit that this
# is *not* derived pose — it's animation seeded by total motion energy
# so a frozen-looking figure doesn't suggest a frozen sensor.
sway_x = math.sin(t * 1.4) * 0.06 * motion
sway_z = math.cos(t * 0.9) * 0.04 * motion
head_turn = math.sin(t * 0.5) * 0.04 * (0.3 + motion)
shoulder_dip = math.sin(t * 1.4) * 0.025 * motion
knee_bend_l = max(0.0, math.sin(t * 1.4)) * 0.05 * motion
knee_bend_r = max(0.0, -math.sin(t * 1.4)) * 0.05 * motion
"""17-point COCO standing skeleton anchored at (px, 0, pz).
Mirrors PoseSystem.poseStanding() in ui/observatory/js/pose-system.js
so the basic /ui/index.html viewer also renders something coherent.
`motion` [0..1] modulates a weight-shift sway so the figure looks
alive when there's real RSSI activity — explicitly *not* derived
pose, just animation seeded by motion energy so a frozen-looking
figure doesn't suggest a frozen sensor. Amplitudes are tuned for
visibility at the Observatory's orbit camera distance (~510 m).
"""
sway_x = math.sin(t * 1.4) * 0.20 * motion
sway_z = math.cos(t * 0.9) * 0.14 * motion
head_turn = math.sin(t * 0.5) * 0.12 * (0.3 + motion)
shoulder_dip = math.sin(t * 1.4) * 0.08 * motion
knee_bend_l = max(0.0, math.sin(t * 1.4)) * 0.18 * motion
knee_bend_r = max(0.0, -math.sin(t * 1.4)) * 0.18 * motion
layout = [
("nose", (sway_x + head_turn, 1.72, sway_z)),
@ -84,52 +75,9 @@ def standing_keypoints(
]
def stable_hash_angle(s: str) -> float:
"""FNV-1a → angle in [0, 2π). Same as dashboard.html so AP layouts agree."""
h = 0x811c9dc5
for c in s.encode("utf-8"):
h ^= c
h = (h * 0x01000193) & 0xFFFFFFFF
return (h / 0x100000000) * math.tau
def rssi_to_radius(rssi: float, max_r: float) -> float:
"""Linear in dB. -30 dBm → close, -100 dBm → max_r away."""
t = max(0.0, min(1.0, (-rssi - 30.0) / 70.0))
return 0.4 + t * (max_r - 0.4) # 0.4m floor so the figure isn't on top of the laptop
def compute_centroid(aps: list[dict[str, Any]]) -> tuple[float, float, float]:
"""Variance-weighted centroid in (x_m, z_m) plus a 'localization weight'."""
if not aps:
return 0.0, 0.0, 0.0
# 1. Place each AP in 2D using the same scheme as dashboard.html.
placed = []
for ap in aps:
angle = stable_hash_angle(ap["id"])
radius = rssi_to_radius(ap["rssi_dbm"], min(ROOM_HALF_X, ROOM_HALF_Z))
ax = math.cos(angle) * radius
az = math.sin(angle) * radius
placed.append((ax, az, ap.get("variance", 0.0)))
# 2. Centroid: along each AP↔laptop line, the most informative point is
# the midpoint (Fresnel zone widest there). Weight each midpoint by
# variance.
sum_w = 0.0
sum_x = 0.0
sum_z = 0.0
for ax, az, var in placed:
if var <= 0.01:
continue
# Midpoint between (0,0) (laptop) and (ax, az) (AP).
mx, mz = ax * 0.5, az * 0.5
sum_w += var
sum_x += var * mx
sum_z += var * mz
if sum_w < 1e-6:
# No motion — figure parks at the laptop.
return 0.0, 0.0, 0.0
return sum_x / sum_w, sum_z / sum_w, sum_w
def total_variance(aps: list[dict[str, Any]]) -> float:
"""Sum of per-AP RSSI variance — the only honest motion signal we have."""
return sum(max(0.0, ap.get("variance", 0.0)) for ap in aps)
def main() -> int:
@ -140,15 +88,10 @@ def main() -> int:
help="sensing-server pose ingestion endpoint")
ap.add_argument("--rate-hz", type=float, default=10.0,
help="how often to POST a pose (Hz)")
ap.add_argument("--smooth", type=float, default=0.6,
help="EMA factor for centroid smoothing (0..1, higher = snappier)")
ap.add_argument("--amplify", type=float, default=4.0,
help="multiplier on the centroid coordinates so small RSSI shifts produce visible figure motion in the room")
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
period = 1.0 / args.rate_hz
sx, sz = 0.0, 0.0
last_log = 0.0
n_posted = 0
n_fail = 0
@ -169,38 +112,24 @@ def main() -> int:
continue
aps = snap.get("aps", [])
cx, cz, weight = compute_centroid(aps)
# Amplify the centroid so small RSSI shifts (a few cm of raw
# variance-weighted midpoint motion) produce visible motion in
# the room view. Without amplification the centroid only spans
# ~0.5m which is invisible at 7m room scale.
cx *= args.amplify
cz *= args.amplify
# Clamp to room half-extents so the figure never walks through walls.
cx = max(-ROOM_HALF_X + 0.5, min(ROOM_HALF_X - 0.5, cx))
cz = max(-ROOM_HALF_Z + 0.5, min(ROOM_HALF_Z - 0.5, cz))
# EMA smoothing — sensing-server runs at 10 Hz tick, so we want
# the figure to glide rather than jitter on every micro-fluctuation.
sx = sx * (1 - args.smooth) + cx * args.smooth
sz = sz * (1 - args.smooth) + cz * args.smooth
weight = total_variance(aps)
# Confidence ~ how much variance is present. Caps at ~1.0 once
# there's a few dB² of cumulative motion energy across APs.
conf = max(0.3, min(1.0, 0.3 + weight / 10.0))
# Normalize motion intensity from cumulative variance.
motion = max(0.0, min(1.0, weight / 5.0))
t = time.time()
# Position is pinned at the laptop. RSSI variance from one receiver
# cannot localize — it can only flag that something moved.
px, pz = 0.0, 0.0
person = {
"id": 1,
"confidence": conf,
"keypoints": standing_keypoints(sx, sz, conf, motion, t),
"bbox": {"x": sx - 0.4, "y": 0.0, "width": 0.8, "height": 1.85},
"zone": "rssi_localized",
"keypoints": standing_keypoints(px, pz, conf, motion, t),
"bbox": {"x": px - 0.4, "y": 0.0, "width": 0.8, "height": 1.85},
"zone": "rssi_presence",
}
body = json.dumps([person]).encode("utf-8")
@ -222,8 +151,8 @@ def main() -> int:
now = time.time()
if args.verbose and now - last_log > 1.0:
print(
f"[presence] aps={len(aps):>2} centroid=({sx:+.2f}, {sz:+.2f})m "
f"weight={weight:>5.2f} conf={conf:.2f} posted={n_posted} fail={n_fail}",
f"[presence] aps={len(aps):>2} weight={weight:>5.2f} "
f"conf={conf:.2f} motion={motion:.2f} posted={n_posted} fail={n_fail}",
flush=True,
)
last_log = now