diff --git a/docs/superpowers/plans/2026-06-03-cross-camera-tag-alignment.md b/docs/superpowers/plans/2026-06-03-cross-camera-tag-alignment.md new file mode 100644 index 0000000..05bf7c4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-cross-camera-tag-alignment.md @@ -0,0 +1,1059 @@ +# Cross-camera tag overlay + Nuwa→frontale alignment — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Detect ArUco tags on both the Nuwa (wrist) and D435i (front) cameras with per-camera colors, and add a persistent Umeyama similarity calibration that overlays the Nuwa point cloud onto the D435i cloud via a tag seen by both. + +**Architecture:** Backend gains a pure `umeyama_similarity` helper, a camera-agnostic `_detect_tags_on`, a combined `GET /tags` endpoint, and a `POST /align` endpoint that fits + persists the matrix to `config.yaml > pose_3d.cross_camera`. The vanilla-JS Pose 3D frontend generalizes the existing front-tag overlay to both cameras and renders the Nuwa cloud through the D435i transform pipeline when the calibration is enabled. + +**Tech Stack:** Python 3.10 / FastAPI / numpy / pytest (backend); vanilla JS + Three.js (frontend, no JS test runner — `web/` is served statically, no linter). + +**Spec:** `docs/superpowers/specs/2026-06-03-cross-camera-tag-alignment-design.md` + +**Note on JS testing:** there is no JavaScript test harness in this repo and the system `node` is too old to even parse the optional-chaining already used throughout `web/pose_3d.js`. Frontend tasks therefore use **manual browser verification** (reload `http://localhost:8890`, open the Pose 3D tab). Keep edits small and self-contained. + +--- + +## Task 1: `umeyama_similarity` helper (geometry.py) + +**Files:** +- Modify: `src/presa_mano/geometry.py` (append new function) +- Test: `tests/test_geometry_umeyama.py` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_geometry_umeyama.py`: + +```python +"""Unit test per umeyama_similarity (absolute orientation con scala).""" +from __future__ import annotations + +import numpy as np +import pytest + +from presa_mano.geometry import umeyama_similarity, rpy_deg_to_quaternion_xyzw, quat_xyzw_to_R + + +def _known_transform(): + # scala 1.5, rotazione nota, traslazione nota + R = quat_xyzw_to_R(rpy_deg_to_quaternion_xyzw(10.0, -20.0, 35.0)) + s = 1.5 + t = np.array([0.3, -0.1, 0.2]) + return s, R, t + + +def test_recovers_known_similarity(): + s, R, t = _known_transform() + rng = np.random.default_rng(0) + src = rng.normal(size=(8, 3)) + dst = (s * (R @ src.T)).T + t + + s_hat, R_hat, t_hat, rmse = umeyama_similarity(src, dst) + + assert abs(s_hat - s) < 1e-6 + assert np.allclose(R_hat, R, atol=1e-6) + assert np.allclose(t_hat, t, atol=1e-6) + assert rmse < 1e-6 + + +def test_forbids_reflection(): + # Anche con dati che potrebbero indurre una riflessione, det(R)=+1 + rng = np.random.default_rng(1) + src = rng.normal(size=(5, 3)) + dst = rng.normal(size=(5, 3)) + _, R_hat, _, _ = umeyama_similarity(src, dst) + assert np.linalg.det(R_hat) > 0 + + +def test_raises_on_too_few_points(): + with pytest.raises(ValueError): + umeyama_similarity(np.zeros((2, 3)), np.zeros((2, 3))) + + +def test_raises_on_degenerate_points(): + # tutti i punti coincidenti → varianza nulla + src = np.ones((4, 3)) + dst = np.ones((4, 3)) * 2.0 + with pytest.raises(ValueError): + umeyama_similarity(src, dst) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_geometry_umeyama.py -v` +Expected: FAIL with `ImportError: cannot import name 'umeyama_similarity'`. + +- [ ] **Step 3: Write minimal implementation** + +Append to `src/presa_mano/geometry.py`: + +```python +def umeyama_similarity(src: np.ndarray, dst: np.ndarray): + """Fit dst ≈ s·R·src + t (Umeyama 1991, con scala uniforme). + + src, dst: (N,3) array di punti corrispondenti. + Ritorna (scale: float, R: (3,3), t: (3,), rmse: float in unità di dst). + Solleva ValueError se N<3 o il set è degenere (varianza nulla). + """ + src = np.asarray(src, dtype=float).reshape(-1, 3) + dst = np.asarray(dst, dtype=float).reshape(-1, 3) + if src.shape != dst.shape: + raise ValueError(f"shape mismatch: {src.shape} vs {dst.shape}") + n = src.shape[0] + if n < 3: + raise ValueError(f"servono almeno 3 punti, ricevuti {n}") + + mu_src = src.mean(axis=0) + mu_dst = dst.mean(axis=0) + src_c = src - mu_src + dst_c = dst - mu_dst + + var_src = (src_c ** 2).sum() / n + if var_src < 1e-12: + raise ValueError("punti src degeneri (varianza nulla)") + + cov = (dst_c.T @ src_c) / n + U, D, Vt = np.linalg.svd(cov) + S = np.eye(3) + if np.linalg.det(U) * np.linalg.det(Vt) < 0: + S[2, 2] = -1.0 # vieta riflessioni + R = U @ S @ Vt + scale = float((D * np.diag(S)).sum() / var_src) + t = mu_dst - scale * (R @ mu_src) + + pred = (scale * (R @ src.T)).T + t + rmse = float(np.sqrt(((pred - dst) ** 2).sum(axis=1).mean())) + return scale, R, t, rmse +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_geometry_umeyama.py -v` +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/presa_mano/geometry.py tests/test_geometry_umeyama.py +git commit -m "feat(geometry): umeyama_similarity (rot+trasl+scala uniforme)" +``` + +--- + +## Task 2: Camera-agnostic tag detection + `GET /api/pose_3d/tags` + +**Files:** +- Modify: `src/presa_mano/api/pose_3d_routes.py` (extract helper from `get_front_tags`, add `/tags`) +- Test: `tests/test_pose_3d_tags.py` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_pose_3d_tags.py`: + +```python +"""Test del nuovo endpoint multi-camera GET /api/pose_3d/tags.""" +from __future__ import annotations + +from unittest.mock import patch, MagicMock + +from fastapi.testclient import TestClient + +from presa_mano.main import app + +client = TestClient(app) + + +def _fake_camera(corner_depth_m=0.32): + cam = MagicMock() + cam.intrinsics.return_value = { + "rgb": {"fx": 600.0, "fy": 600.0, "cx": 320.0, "cy": 240.0} + } + cam.rgb_url.return_value = "http://cam/rgb.png" + + def _measure(u, v, radius=2): + # punto 3D fittizio coerente; z costante → quad planare + return {"point_m": {"x": u / 1000.0, "y": v / 1000.0, "z": corner_depth_m}, + "depth_mm": int(corner_depth_m * 1000)} + cam.measure.side_effect = _measure + return cam + + +def _fake_marker(found_ids): + mk = MagicMock() + found = [{ + "id": i, + "corners": [[100, 100], [140, 100], [140, 140], [100, 140]], + } for i in found_ids] + mk.detect_url.return_value = {"found": found, "not_found": []} + return mk + + +def test_tags_returns_both_cameras(): + with patch("presa_mano.api.pose_3d_routes._deps.nuwa") as nuwa_f, \ + patch("presa_mano.api.pose_3d_routes._deps.front_camera") as front_f, \ + patch("presa_mano.api.pose_3d_routes._deps.marker") as mk_f: + nuwa_f.return_value = _fake_camera() + front_f.return_value = _fake_camera() + mk_f.return_value = _fake_marker([5]) + r = client.get("/api/pose_3d/tags?ids=0-9") + assert r.status_code == 200 + body = r.json() + assert set(body["cameras"].keys()) == {"nuwa", "d435"} + assert body["cameras"]["nuwa"]["frame"] == "nuwa_rgb_optical" + assert body["cameras"]["d435"]["frame"] == "d435_rgb_optical" + assert body["cameras"]["nuwa"]["tags"][0]["id"] == 5 + assert body["cameras"]["d435"]["tags"][0]["id"] == 5 + + +def test_tags_one_camera_offline_reports_error(): + with patch("presa_mano.api.pose_3d_routes._deps.nuwa") as nuwa_f, \ + patch("presa_mano.api.pose_3d_routes._deps.front_camera") as front_f, \ + patch("presa_mano.api.pose_3d_routes._deps.marker") as mk_f: + nuwa = MagicMock() + nuwa.intrinsics.side_effect = RuntimeError("nuwa down") + nuwa_f.return_value = nuwa + front_f.return_value = _fake_camera() + mk_f.return_value = _fake_marker([5]) + r = client.get("/api/pose_3d/tags?ids=0-9") + assert r.status_code == 200 + body = r.json() + assert body["cameras"]["nuwa"]["tags"] == [] + assert "error" in body["cameras"]["nuwa"] + assert body["cameras"]["d435"]["tags"][0]["id"] == 5 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_pose_3d_tags.py -v` +Expected: FAIL with 404 (endpoint `/tags` not registered yet). + +- [ ] **Step 3: Refactor `get_front_tags` into a helper + add `/tags`** + +In `src/presa_mano/api/pose_3d_routes.py`, replace the whole `get_front_tags` function (currently `@router.get("/front_tags") def get_front_tags(...)`, lines ~274-391) with the extracted helper + two endpoints. The body of the per-camera measurement logic is moved verbatim into `_detect_tags_on`: + +```python +def _detect_tags_on(client, frame_label: str, ids: str, + probe_size_mm: float = 40.0) -> list[dict]: + """Detect ArUco su `client` (qualsiasi NuwaClient) + misura 3D dei corner + via depth. Schema per-tag identico al vecchio front_tags.""" + mk = _deps.marker() + intr = client.intrinsics() + rgb = (intr or {}).get("rgb") or {} + if not rgb or "fx" not in rgb: + raise RuntimeError(f"intrinsics rgb mancanti: {intr!r}") + K = { + "camera_matrix": [ + [float(rgb["fx"]), 0.0, float(rgb["cx"])], + [0.0, float(rgb["fy"]), float(rgb["cy"])], + [0.0, 0.0, 1.0], + ], + "dist_coeffs": [ + float((rgb.get("distortion") or {}).get(k, 0.0)) + for k in ("k1", "k2", "p1", "p2", "k3") + ], + } + id_list = _parse_id_range(ids) + markers = [{"id": int(i), "size_mm": float(probe_size_mm)} for i in id_list] + det = mk.detect_url(markers=markers, image_url=client.rgb_url(), intrinsics=K) + + tags_out: list[dict[str, Any]] = [] + for m in det.get("found", []): + corners = m.get("corners") or [] + if len(corners) != 4: + continue + pts3d: list[list[float]] = [] + depths: list[Optional[int]] = [] + try: + for (u, v) in corners: + md = client.measure(int(round(u)), int(round(v)), radius=2) + pt = (md or {}).get("point_m") or {} + d_mm = (md or {}).get("depth_mm") + if not pt or d_mm in (None, 0): + pts3d.append([float("nan")] * 3) + depths.append(None) + else: + pts3d.append([float(pt["x"]), float(pt["y"]), float(pt["z"])]) + depths.append(int(d_mm)) + cu = sum(c[0] for c in corners) / 4.0 + cv = sum(c[1] for c in corners) / 4.0 + cmd = client.measure(int(round(cu)), int(round(cv)), radius=4) + cpt = (cmd or {}).get("point_m") or {} + center3d = ([float(cpt["x"]), float(cpt["y"]), float(cpt["z"])] + if cpt else [float("nan")] * 3) + except Exception as e: + log.warning("tags(%s): measure failed for id=%s: %s", + frame_label, m.get("id"), e) + continue + + valid_d = [d for d in depths if d is not None] + if len(valid_d) >= 3: + sd = sorted(valid_d) + median_d = (sd[len(sd)//2 - 1] + sd[len(sd)//2]) / 2 if len(sd) % 2 == 0 else sd[len(sd)//2] + for i, d in enumerate(depths): + if d is None: + continue + if abs(d - median_d) / median_d > 0.20: + pts3d[i] = [float("nan")] * 3 + + def _L(a: list[float], b: list[float]) -> Optional[float]: + if any(x != x for x in a + b): + return None + return (sum((a[i] - b[i]) ** 2 for i in range(3))) ** 0.5 * 1000.0 + + edges_mm = [_L(pts3d[i], pts3d[(i + 1) % 4]) for i in range(4)] + valid_edges = [e for e in edges_mm if e is not None] + edge_mean = sum(valid_edges) / len(valid_edges) if valid_edges else None + + distance_m: Optional[float] = None + if all(x == x for x in center3d): + distance_m = (sum(c ** 2 for c in center3d)) ** 0.5 + + tags_out.append({ + "id": int(m["id"]), + "corners_pixel": [[float(c[0]), float(c[1])] for c in corners], + "corners_3d_optical_m": pts3d, + "corner_depths_mm": depths, + "center_3d_optical_m": center3d, + "edge_mm_per_side": edges_mm, + "edge_mm": edge_mean, + "distance_m": distance_m, + }) + return tags_out + + +_TAG_CAMERAS = { + "nuwa": ("nuwa_rgb_optical", _deps.nuwa), + "d435": ("d435_rgb_optical", _deps.front_camera), +} + + +@router.get("/tags") +def get_tags(ids: str = "0-49", probe_size_mm: float = 40.0) -> dict[str, Any]: + """Detect ArUco su tutte le camere attive (Nuwa polso + D435i frontale). + Una camera offline riporta tags=[] + error, non fa fallire l'altra.""" + sw = Stopwatch() + cameras: dict[str, Any] = {} + for key, (frame_label, factory) in _TAG_CAMERAS.items(): + try: + with sw.step(key): + tags = _detect_tags_on(factory(), frame_label, ids, probe_size_mm) + cameras[key] = {"tags": tags, "frame": frame_label} + except Exception as e: + cameras[key] = {"tags": [], "frame": frame_label, + "error": f"{type(e).__name__}: {e}"} + return {"cameras": cameras, "timings_ms": sw.as_dict()} +``` + +Note: `_TAG_CAMERAS` references `_deps.nuwa`/`_deps.front_camera` as factory functions (called per-request, preserving the per-request-client convention). Keep `_parse_id_range` where it is. Delete the old `get_front_tags` (the `/front_tags` route is gone; the GUI is updated in Task 5). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_pose_3d_tags.py -v` +Expected: 2 passed. + +- [ ] **Step 5: Run the full pose_3d suite for regressions** + +Run: `uv run pytest tests/test_pose_3d_routes.py tests/test_pose_3d_tags.py -v` +Expected: all pass (no `/front_tags` test exists today, so nothing breaks). + +- [ ] **Step 6: Commit** + +```bash +git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_tags.py +git commit -m "feat(pose_3d): GET /tags multi-camera, rimuovi /front_tags" +``` + +--- + +## Task 3: `POST /api/pose_3d/align` — fit + persist calibration + +**Files:** +- Modify: `src/presa_mano/api/pose_3d_routes.py` (add `/align`, import `umeyama_similarity` + `update_config`) +- Test: `tests/test_pose_3d_align.py` (create) + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_pose_3d_align.py`: + +```python +"""Test POST /api/pose_3d/align: fit Umeyama + persistenza config.""" +from __future__ import annotations + +from unittest.mock import patch, MagicMock + +import numpy as np +from fastapi.testclient import TestClient + +from presa_mano.main import app +from presa_mano.config import get_config + +client = TestClient(app) + +# corner pixel del tag (quad) +_CORNERS_PX = [[100, 100], [140, 100], [140, 140], [100, 140]] + + +def _cam_with_points(points_by_pixel): + """points_by_pixel: dict (u,v)->(x,y,z) in metri. Center = media.""" + cam = MagicMock() + cam.intrinsics.return_value = {"rgb": {"fx": 600.0, "fy": 600.0, "cx": 320.0, "cy": 240.0}} + cam.rgb_url.return_value = "http://cam/rgb.png" + + def _measure(u, v, radius=2): + key = (int(u), int(v)) + if key in points_by_pixel: + x, y, z = points_by_pixel[key] + else: # center + xs = [p[0] for p in points_by_pixel.values()] + ys = [p[1] for p in points_by_pixel.values()] + zs = [p[2] for p in points_by_pixel.values()] + x, y, z = sum(xs)/len(xs), sum(ys)/len(ys), sum(zs)/len(zs) + return {"point_m": {"x": x, "y": y, "z": z}, "depth_mm": int(z * 1000)} + cam.measure.side_effect = _measure + return cam + + +def _marker(found_ids): + mk = MagicMock() + mk.detect_url.return_value = { + "found": [{"id": i, "corners": _CORNERS_PX} for i in found_ids], + "not_found": [], + } + return mk + + +def test_align_fits_and_persists(): + # nuwa corner 3D noti; d435 = 2·nuwa (scala 2, no rot, no trasl) + nuwa_pts = { + (100, 100): (0.10, 0.10, 0.30), + (140, 100): (0.14, 0.10, 0.30), + (140, 140): (0.14, 0.14, 0.30), + (100, 140): (0.10, 0.14, 0.30), + } + d435_pts = {k: (v[0]*2, v[1]*2, v[2]*2) for k, v in nuwa_pts.items()} + with patch("presa_mano.api.pose_3d_routes._deps.nuwa") as nuwa_f, \ + patch("presa_mano.api.pose_3d_routes._deps.front_camera") as front_f, \ + patch("presa_mano.api.pose_3d_routes._deps.marker") as mk_f: + nuwa_f.return_value = _cam_with_points(nuwa_pts) + front_f.return_value = _cam_with_points(d435_pts) + mk_f.return_value = _marker([7]) + r = client.post("/api/pose_3d/align?ids=0-9&align_id=7") + assert r.status_code == 200 + body = r.json() + assert body["found_in_both"] is True + assert abs(body["scale"] - 2.0) < 1e-3 + assert body["align_id"] == 7 + # persistito in config + cc = get_config()["pose_3d"]["cross_camera"] + assert abs(cc["scale"] - 2.0) < 1e-3 + assert cc["align_id"] == 7 + assert len(cc["R"]) == 3 and len(cc["t"]) == 3 + + +def test_align_tag_missing_in_one_camera_does_not_save(): + pts = {(100,100):(0.1,0.1,0.3),(140,100):(0.14,0.1,0.3), + (140,140):(0.14,0.14,0.3),(100,140):(0.1,0.14,0.3)} + with patch("presa_mano.api.pose_3d_routes._deps.nuwa") as nuwa_f, \ + patch("presa_mano.api.pose_3d_routes._deps.front_camera") as front_f, \ + patch("presa_mano.api.pose_3d_routes._deps.marker") as mk_f: + # marker server vede il tag solo su una "immagine" → ma qui detect_url è + # condiviso; simuliamo l'assenza usando align_id non presente + nuwa_f.return_value = _cam_with_points(pts) + front_f.return_value = _cam_with_points(pts) + mk_f.return_value = _marker([1]) # nessun tag id=99 + r = client.post("/api/pose_3d/align?ids=0-9&align_id=99") + assert r.status_code == 200 + body = r.json() + assert body["found_in_both"] is False + assert body.get("error") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_pose_3d_align.py -v` +Expected: FAIL with 404 (no `/align` route). + +- [ ] **Step 3: Add imports + the `/align` endpoint** + +In `src/presa_mano/api/pose_3d_routes.py`, extend the config import and add numpy + the geometry helper near the top: + +```python +import numpy as np +from ..config import get_config, set_config_value, update_config +from ..geometry import normalize_quaternion, umeyama_similarity +``` + +(Adjust the existing `from ..config import ...` and `from ..geometry import ...` lines rather than duplicating them.) + +Then add the endpoint after `get_tags`: + +```python +@router.post("/align") +def post_align(ids: str = "0-49", align_id: int = 0, + probe_size_mm: float = 40.0) -> dict[str, Any]: + """Calibrazione cross-camera: trova `align_id` su Nuwa e D435i, fitta una + similarità (rot+trasl+scala) Nuwa-optical → D435-optical e la PERSISTE in + config.yaml > pose_3d.cross_camera. Se il tag non è visto da entrambe, NON + sovrascrive la matrice salvata.""" + sw = Stopwatch() + found: dict[str, Optional[dict]] = {} + for key, (frame_label, factory) in _TAG_CAMERAS.items(): + try: + with sw.step(key): + tags = _detect_tags_on(factory(), frame_label, ids, probe_size_mm) + found[key] = next((t for t in tags if int(t["id"]) == int(align_id)), None) + except Exception as e: + log.warning("align: camera %s failed: %s", key, e) + found[key] = None + + missing = [k for k, v in found.items() if v is None] + if missing: + return {"found_in_both": False, "align_id": int(align_id), + "error": f"tag {align_id} non visto da: {', '.join(missing)}", + "timings_ms": sw.as_dict()} + + # corrispondenze corner, scartando i NaN su una delle due camere + src, dst = [], [] + for cn, cd in zip(found["nuwa"]["corners_3d_optical_m"], + found["d435"]["corners_3d_optical_m"]): + if any(x != x for x in cn) or any(x != x for x in cd): + continue + src.append(cn) + dst.append(cd) + if len(src) < 3: + return {"found_in_both": False, "align_id": int(align_id), + "error": f"corner validi insufficienti ({len(src)}/4)", + "timings_ms": sw.as_dict()} + + try: + with sw.step("umeyama"): + scale, R, t, rmse = umeyama_similarity(np.array(src), np.array(dst)) + except ValueError as e: + return {"found_in_both": False, "align_id": int(align_id), + "error": f"fit fallito: {e}", "timings_ms": sw.as_dict()} + + payload = { + "align_id": int(align_id), + "scale": float(scale), + "R": [[float(x) for x in row] for row in R], + "t": [float(x) for x in t], + "rmse_mm": float(rmse * 1000.0), + "n_corners": len(src), + } + update_config({"pose_3d": {"cross_camera": payload}}) + return {"found_in_both": True, **payload, "timings_ms": sw.as_dict()} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_pose_3d_align.py -v` +Expected: 2 passed. (The autouse `_restore_config_yaml` fixture in `tests/conftest.py` reverts the `config.yaml` write.) + +- [ ] **Step 5: Commit** + +```bash +git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_align.py +git commit -m "feat(pose_3d): POST /align fit Umeyama cross-camera + persistenza" +``` + +--- + +## Task 4: Expose `cross_camera` in `GET /state` + +**Files:** +- Modify: `src/presa_mano/api/pose_3d_routes.py:97-108` (the `get_state` return dict) +- Test: `tests/test_pose_3d_routes.py` (add one test) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_pose_3d_routes.py`: + +```python +def test_state_includes_cross_camera_block(): + with patch("presa_mano.api.pose_3d_routes._deps.g1") as g1_factory: + g1 = MagicMock() + g1.wrist.return_value = _fake_wrist_pair() + g1_factory.return_value = g1 + r = client.get("/api/pose_3d/state") + assert r.status_code == 200 + assert "cross_camera" in r.json() # {} se mai calibrato +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_pose_3d_routes.py::test_state_includes_cross_camera_block -v` +Expected: FAIL with `KeyError`/assert (no `cross_camera` key). + +- [ ] **Step 3: Add the key to the return dict** + +In `get_state`, add the line inside the returned dict (next to `"cloud_params"`): + +```python + "cloud_params": cfg.get("cloud_params") or {}, + "cross_camera": cfg.get("cross_camera") or {}, +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_pose_3d_routes.py -v` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/presa_mano/api/pose_3d_routes.py tests/test_pose_3d_routes.py +git commit -m "feat(pose_3d): /state espone blocco cross_camera" +``` + +--- + +## Task 5: Frontend — generalize tag overlay to both cameras + +**Files:** +- Modify: `web/pose_3d.js` (state, render/poll functions, colors, acquire/toggle wiring) + +No automated test (vanilla JS). Manual verification at the end. + +- [ ] **Step 1: Add the Nuwa tag color + generalize state** + +In `web/pose_3d.js`, near `const FRONT_TAG_COLOR = 0x4dd0e1;` (line ~1797) add: + +```javascript +const NUWA_TAG_COLOR = 0xff3b30; // rosso: tag visti dalla camera Nuwa (polso) +const TAG_COLORS = { nuwa: NUWA_TAG_COLOR, d435: FRONT_TAG_COLOR }; +``` + +Replace the `front_tags` state block (lines ~230-232) with: + +```javascript + tags: { // overlay tag per camera (gruppi THREE child del cloud node) + nuwa: { group: null }, + d435: { group: null }, + }, +``` + +- [ ] **Step 2: Replace the per-camera render/clear helpers** + +Replace `clearFrontTagsGroup` / `addOneFrontTag` / `renderFrontTags` (lines ~1858-1912) with parameterized versions: + +```javascript +function clearTagsGroup(camKey) { + const grp = state.tags[camKey].group; + if (!grp) return; + if (grp.parent) grp.parent.remove(grp); + grp.traverse(o => { + if (o.geometry) o.geometry.dispose(); + if (o.material) { + if (Array.isArray(o.material)) o.material.forEach(m => m.dispose()); + else o.material.dispose(); + } + }); + state.tags[camKey].group = null; +} + +function addOneTag(grp, t, color) { + const c = t.corners_3d_optical_m; + if (!Array.isArray(c) || c.length !== 4) return; + if (c.some(p => !p || p.some(v => !Number.isFinite(v)))) return; + const v = new Float32Array(5 * 3); + for (let i = 0; i < 4; i++) { v[3*i]=c[i][0]; v[3*i+1]=c[i][1]; v[3*i+2]=c[i][2]; } + v[12] = c[0][0]; v[13] = c[0][1]; v[14] = c[0][2]; + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(v, 3)); + grp.add(new THREE.Line(geom, new THREE.LineBasicMaterial({ color }))); + const edge_s = Number.isFinite(t.edge_mm) ? `${t.edge_mm.toFixed(1)}mm` : '?'; + const dist_s = Number.isFinite(t.distance_m) ? `${(t.distance_m * 1000).toFixed(0)}mm` : '?'; + const sprite = makeCompactLabel(`id=${t.id} · ${edge_s} · d=${dist_s}`); + const ctr = t.center_3d_optical_m; + if (ctr && ctr.every(Number.isFinite)) sprite.position.set(ctr[0], ctr[1] - 0.04, ctr[2]); + grp.add(sprite); +} + +function renderTags(camKey, tags, color) { + clearTagsGroup(camKey); + if (!tags || tags.length === 0) return; + const parent = getCloudParent(camKey); + if (!parent) { console.warn(`[pose_3d] tags: parent ${camKey} non disponibile`); return; } + const grp = new THREE.Group(); + grp.name = `${camKey}_tags_group`; + applyCloudLikeTransform(grp, camKey); + parent.add(grp); + state.tags[camKey].group = grp; + for (const t of tags) addOneTag(grp, t, color); +} +``` + +- [ ] **Step 3: Replace `pollFrontTags` with the multi-camera `pollTags`** + +Replace `pollFrontTags` (lines ~1914-1930) with: + +```javascript +async function pollTags() { + const ids = ($('p3FrontTagIds')?.value || '0-49').trim(); + const status = $('p3FrontTagStatus'); + try { + const r = await jsonFetch(`/api/pose_3d/tags?ids=${encodeURIComponent(ids)}`); + const cams = r.cameras || {}; + const parts = []; + for (const camKey of ['nuwa', 'd435']) { + const c = cams[camKey] || {}; + renderTags(camKey, c.tags || [], TAG_COLORS[camKey]); + if (c.error) parts.push(`${camKey}: err`); + else parts.push(`${camKey}: ${(c.tags || []).length}`); + } + if (status) status.textContent = parts.join(' · '); + } catch (e) { + if (status) status.textContent = `errore: ${e?.message || e}`; + } +} +``` + +- [ ] **Step 4: Update the toggle + acquire wiring** + +In `bindFrontTagFinder` (lines ~1932-1945) change the two `pollFrontTags()`→`pollTags()` calls and the clear branch: + +```javascript +function bindFrontTagFinder() { + const cb = $('p3ShowFrontTags'); + if (!cb) return; + cb.addEventListener('change', () => { + if (cb.checked) { + pollTags(); + } else { + clearTagsGroup('nuwa'); + clearTagsGroup('d435'); + const s = $('p3FrontTagStatus'); + if (s) s.textContent = '—'; + } + }); +} +``` + +In `acquireClouds` change the post-acquire line (added in the previous fix): + +```javascript + if ($('p3ShowFrontTags')?.checked) pollTags(); +``` + +- [ ] **Step 5: Manual verification** + +Reload `http://localhost:8890`, open Pose 3D → MISC tab → check "Mostra tag visibili". Then Acquisisci. Expect: D435i tags drawn cyan, Nuwa tags drawn red (when a tag is in each camera's view); status shows `nuwa: N · d435: M`. No repeated network calls between acquisitions (Network tab: `/tags` only fires on toggle + Acquisisci). + +- [ ] **Step 6: Commit** + +```bash +git add web/pose_3d.js +git commit -m "feat(pose_3d): overlay tag su entrambe le camere (Nuwa rosso, D435i ciano)" +``` + +--- + +## Task 6: Frontend — rename UI section + add alignment controls (index.html) + +**Files:** +- Modify: `web/index.html` (rename "Tag camera frontale"; add alignment row to "Cloud cross-camera") + +- [ ] **Step 1: Rename the tag section header** + +In `web/index.html` line ~514, change: + +```html +