feat(right_arm): perm assi cam->polso, precise=true default, verify_tag UI

Lavoro accumulato nella sessione 2026-05-21 prima del brainstorming
della tab Pose 3D:

- compute_delta_wrist generalizzato con cam_to_wrist_perm: la mappa
  cam->polso non e' solo sign-flip ma una permutazione (default
  identita' per backward compat). DeltaOversize ora espone X/Y/Z
  nella stringa d'errore. Test aggiornati.

- G1Client.wrist_absolute / wrist_relative: precise=True di default
  (era False). Stessa cosa per wrist_routes.AbsoluteReq Pydantic
  e per agent.tools move_wrist_*. Movimenti reali ora chiedono
  controllo accurato salvo override esplicito.

- Tab Braccio DX:
  - /verify_tag endpoint: snapshot wrist FK + tag id=1 dalla front
    camera. Per verifica relativa moveto vs marker (no calibrazione).
  - GUI: pannello "Verifica tag id=1" + campi camera_offset_wrist_mm.
  - markUserTouchedOnInput esteso a raOff/raSgn/raCamOff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-21 15:44:48 +02:00
1 parent 8d40ebaf89
commit 57954b5c8d
8 files changed
+238 -23

No files matched your search

+5 -5
View File
@@ -121,7 +121,7 @@ def _h_move_wrist_absolute(args: dict[str, Any]) -> dict[str, Any]:
return _err("position deve avere 3 float (m), quaternion 4 float (xyzw)") return _err("position deve avere 3 float (m), quaternion 4 float (xyzw)")
dry_run = bool(args.get("dry_run", True)) dry_run = bool(args.get("dry_run", True))
timeout_s = float(args.get("timeout_s", 5.0)) timeout_s = float(args.get("timeout_s", 5.0))
precise = bool(args.get("precise", False)) precise = bool(args.get("precise", True))
result = _deps.g1(dry_run=dry_run).wrist_absolute( result = _deps.g1(dry_run=dry_run).wrist_absolute(
side=side, position=position, quaternion=quaternion, side=side, position=position, quaternion=quaternion,
wait=True, timeout_s=timeout_s, precise=precise, wait=True, timeout_s=timeout_s, precise=precise,
@@ -144,7 +144,7 @@ def _h_move_wrist_relative(args: dict[str, Any]) -> dict[str, Any]:
return _err("delta_quaternion deve avere 4 float (xyzw) o essere null") return _err("delta_quaternion deve avere 4 float (xyzw) o essere null")
dry_run = bool(args.get("dry_run", True)) dry_run = bool(args.get("dry_run", True))
timeout_s = float(args.get("timeout_s", 5.0)) timeout_s = float(args.get("timeout_s", 5.0))
precise = bool(args.get("precise", False)) precise = bool(args.get("precise", True))
result = _deps.g1(dry_run=dry_run).wrist_relative( result = _deps.g1(dry_run=dry_run).wrist_relative(
side=side, side=side,
delta_position=list(dp) if dp is not None else None, delta_position=list(dp) if dp is not None else None,
@@ -516,8 +516,8 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"quaternion": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4, "quaternion": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4,
"description": "x,y,z,w"}, "description": "x,y,z,w"},
"dry_run": {"type": "boolean", "default": True}, "dry_run": {"type": "boolean", "default": True},
"precise": {"type": "boolean", "default": False, "precise": {"type": "boolean", "default": True,
"description": "true = controllo accurato (più lento)"}, "description": "true = controllo accurato (più lento, default)"},
"timeout_s": {"type": "number", "default": 5.0}, "timeout_s": {"type": "number", "default": 5.0},
}, },
}, },
@@ -540,7 +540,7 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"delta_position": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, "delta_position": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
"delta_quaternion": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "delta_quaternion": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4},
"dry_run": {"type": "boolean", "default": True}, "dry_run": {"type": "boolean", "default": True},
"precise": {"type": "boolean", "default": False}, "precise": {"type": "boolean", "default": True},
"timeout_s": {"type": "number", "default": 5.0}, "timeout_s": {"type": "number", "default": 5.0},
}, },
}, },
+70
View File
@@ -42,6 +42,7 @@ def _state_payload(sw: Stopwatch) -> dict[str, Any]:
"offset_camera_mm": cfg.get("move_offset_camera_mm"), "offset_camera_mm": cfg.get("move_offset_camera_mm"),
"axis_signs": cfg.get("axis_sign_cam_to_wrist"), "axis_signs": cfg.get("axis_sign_cam_to_wrist"),
"camera_offset_wrist_mm": cfg.get("camera_offset_wrist_mm"), "camera_offset_wrist_mm": cfg.get("camera_offset_wrist_mm"),
"cam_to_wrist_perm": cfg.get("cam_to_wrist_perm"),
"cluster_depth_window_m": cfg.get("cluster_depth_window_m"), "cluster_depth_window_m": cfg.get("cluster_depth_window_m"),
"max_step_m": cfg.get("max_step_m"), "max_step_m": cfg.get("max_step_m"),
"z_floor_m": cfg.get("z_floor_m"), "z_floor_m": cfg.get("z_floor_m"),
@@ -211,6 +212,7 @@ def move_toward(req: MoveTowardReq) -> dict[str, Any]:
off_mm = cfg.get("move_offset_camera_mm", {"x": 0, "y": 0, "z": 0}) off_mm = cfg.get("move_offset_camera_mm", {"x": 0, "y": 0, "z": 0})
sign = cfg.get("axis_sign_cam_to_wrist", {"x": 1, "y": 1, "z": 1}) sign = cfg.get("axis_sign_cam_to_wrist", {"x": 1, "y": 1, "z": 1})
cam_off_mm = cfg.get("camera_offset_wrist_mm", {"x": 0, "y": 0, "z": 0}) cam_off_mm = cfg.get("camera_offset_wrist_mm", {"x": 0, "y": 0, "z": 0})
perm_cfg = cfg.get("cam_to_wrist_perm", ["x", "y", "z"])
max_step_m = float(cfg.get("max_step_m", 0.15)) max_step_m = float(cfg.get("max_step_m", 0.15))
z_floor = cfg.get("z_floor_m") z_floor = cfg.get("z_floor_m")
z_floor_m = float(z_floor) if z_floor is not None else None z_floor_m = float(z_floor) if z_floor is not None else None
@@ -221,9 +223,16 @@ def move_toward(req: MoveTowardReq) -> dict[str, Any]:
cam_offset_wrist_m = np.array( cam_offset_wrist_m = np.array(
[cam_off_mm["x"], cam_off_mm["y"], cam_off_mm["z"]], dtype=np.float64) / 1000.0 [cam_off_mm["x"], cam_off_mm["y"], cam_off_mm["z"]], dtype=np.float64) / 1000.0
signs = (int(sign["x"]), int(sign["y"]), int(sign["z"])) signs = (int(sign["x"]), int(sign["y"]), int(sign["z"]))
_AXIS_IDX = {"x": 0, "y": 1, "z": 2}
try:
perm = tuple(_AXIS_IDX[str(p).lower()] for p in perm_cfg)
except KeyError as e:
raise HTTPException(status_code=422,
detail=f"cam_to_wrist_perm: asse sconosciuto {e}")
try: try:
delta = compute_delta_wrist(centroid, offset_m, signs, delta = compute_delta_wrist(centroid, offset_m, signs,
cam_to_wrist_perm=perm,
camera_offset_wrist_m=cam_offset_wrist_m, camera_offset_wrist_m=cam_offset_wrist_m,
max_step_m=max_step_m, force=req.force) max_step_m=max_step_m, force=req.force)
except DeltaOversize as e: except DeltaOversize as e:
@@ -340,6 +349,67 @@ def put_custom_pose(req: PoseUpdateReq) -> dict[str, Any]:
return {"custom_pose": _deps.tab_right_arm_cfg().get("custom_pose")} return {"custom_pose": _deps.tab_right_arm_cfg().get("custom_pose")}
def _front_cam_marker_intrinsics() -> dict[str, Any]:
"""Estrae le intrinsics RGB della front camera nel formato marker-finder
(camera_matrix + dist_coeffs). marker-finder ha intrinsics 'baked-in'
della D435i che non combaciano con la Nuwa: passarle per-call evita
errori di scala sistematici sulla posa del tag."""
intr = _deps.front_camera().intrinsics()
rgb = (intr or {}).get("rgb") or {}
K = [[float(rgb.get("fx", 0)), 0.0, float(rgb.get("cx", 0))],
[0.0, float(rgb.get("fy", 0)), float(rgb.get("cy", 0))],
[0.0, 0.0, 1.0]]
dist = rgb.get("distortion") or {}
d = [float(dist.get("k1", 0.0)), float(dist.get("k2", 0.0)),
float(dist.get("p1", 0.0)), float(dist.get("p2", 0.0)),
float(dist.get("k3", 0.0))]
return {"camera_matrix": K, "dist_coeffs": d}
@router.post("/verify_tag")
def post_verify_tag() -> dict[str, Any]:
"""Snapshot per verifica relativa: posa polso (G1 FK) + posa tag (marker
finder sulla front camera). Da chiamare ripetutamente con il polso in
posizioni diverse; il client confronta i moduli dei delta consecutivi.
Niente calibrazione assoluta richiesta: il tag e' rigidamente attaccato
al polso, quindi ||Δtag|| dovrebbe approssimare ||Δwrist|| (a meno di
qualche % di errore intrinsics)."""
sw = Stopwatch()
cfg = _deps.tab_right_arm_cfg()
vm = cfg.get("verify_marker") or {"id": 1, "size_mm": 40.0}
try:
with sw.step("g1_wrist"):
wrist = _deps.g1().wrist()["right_wrist"]
except G1Unavailable as e:
raise HTTPException(status_code=502, detail=f"G1 unavailable: {e}")
try:
with sw.step("front_intr"):
intr = _front_cam_marker_intrinsics()
with sw.step("marker_detect"):
image_url = _deps.front_camera().rgb_url()
res = _deps.marker().detect_url(
markers=[{"id": int(vm["id"]), "size_mm": float(vm["size_mm"])}],
image_url=image_url,
intrinsics=intr,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"marker-finder: {e}")
found = (res or {}).get("found") or []
if not found:
raise HTTPException(status_code=404,
detail=f"tag id={vm['id']} non rilevato dalla front camera")
tag = found[0]
return {
"marker_id": int(vm["id"]),
"wrist_position_m": list(wrist["position"]),
"wrist_quaternion": list(wrist["quaternion"]),
"tag_position_m_in_front_cam": list(tag["position_m"]),
"tag_quaternion": list(tag.get("quaternion_xyzw") or []),
"timings_ms": sw.as_dict(),
}
@router.put("/offset") @router.put("/offset")
def put_offset(req: OffsetUpdateReq) -> dict[str, Any]: def put_offset(req: OffsetUpdateReq) -> dict[str, Any]:
patch: dict[str, Any] = {} patch: dict[str, Any] = {}
+1 -1
View File
@@ -19,7 +19,7 @@ class AbsoluteReq(BaseModel):
quaternion: list[float] = Field(..., min_length=4, max_length=4) quaternion: list[float] = Field(..., min_length=4, max_length=4)
wait: bool = True wait: bool = True
timeout_s: float = 5.0 timeout_s: float = 5.0
precise: bool = False precise: bool = True
dry_run: bool = False dry_run: bool = False
+2 -2
View File
@@ -61,7 +61,7 @@ class G1Client:
# ----- mutating ----- # ----- mutating -----
def wrist_absolute(self, side: str, position: list[float], quaternion: list[float], def wrist_absolute(self, side: str, position: list[float], quaternion: list[float],
wait: bool = True, timeout_s: float = 5.0, precise: bool = False) -> dict[str, Any]: wait: bool = True, timeout_s: float = 5.0, precise: bool = True) -> dict[str, Any]:
body = { body = {
"side": side, "position": position, "quaternion": quaternion, "side": side, "position": position, "quaternion": quaternion,
"wait": wait, "timeout_s": timeout_s, "precise": precise, "wait": wait, "timeout_s": timeout_s, "precise": precise,
@@ -70,7 +70,7 @@ class G1Client:
def wrist_relative(self, side: str, delta_position: list[float] | None = None, def wrist_relative(self, side: str, delta_position: list[float] | None = None,
delta_quaternion: list[float] | None = None, wait: bool = True, delta_quaternion: list[float] | None = None, wait: bool = True,
timeout_s: float = 5.0, precise: bool = False) -> dict[str, Any]: timeout_s: float = 5.0, precise: bool = True) -> dict[str, Any]:
# `precise` is forwarded but the bridge silently ignores it on # `precise` is forwarded but the bridge silently ignores it on
# /wrist/relative if not supported — safer to always send it. # /wrist/relative if not supported — safer to always send it.
body: dict[str, Any] = {"side": side, "wait": wait, "timeout_s": timeout_s, body: dict[str, Any] = {"side": side, "wait": wait, "timeout_s": timeout_s,
+35 -15
View File
@@ -5,11 +5,23 @@ import numpy as np
class DeltaOversize(ValueError): class DeltaOversize(ValueError):
"""Sollevata quando ||delta|| supera max_step_m e force=False.""" """Sollevata quando ||delta|| supera max_step_m e force=False.
def __init__(self, delta_norm_m: float, max_step_m: float): Espone la scomposizione X/Y/Z del delta richiesto sia come attributo
super().__init__(f"delta {delta_norm_m*1000:.1f}mm > max {max_step_m*1000:.1f}mm") `delta_m` sia nel messaggio, cosi' chi riceve l'errore (GUI o CLI) puo'
self.delta_norm_m = float(delta_norm_m) vedere subito su quali assi sta lavorando senza dover rifare il calcolo
con force=true."""
def __init__(self, delta_m: np.ndarray, max_step_m: float):
delta = np.asarray(delta_m, dtype=np.float64).reshape(3)
n = float(np.linalg.norm(delta))
mm = delta * 1000.0
super().__init__(
f"delta {n*1000:.1f}mm > max {max_step_m*1000:.1f}mm "
f"[X={mm[0]:.1f} Y={mm[1]:.1f} Z={mm[2]:.1f}]"
)
self.delta_m = delta
self.delta_norm_m = n
self.max_step_m = float(max_step_m) self.max_step_m = float(max_step_m)
@@ -17,6 +29,7 @@ def compute_delta_wrist(
centroid_camera_m: np.ndarray, centroid_camera_m: np.ndarray,
offset_camera_m: np.ndarray, offset_camera_m: np.ndarray,
axis_signs: tuple[int, int, int], axis_signs: tuple[int, int, int],
cam_to_wrist_perm: tuple[int, int, int] = (0, 1, 2),
camera_offset_wrist_m: np.ndarray | None = None, camera_offset_wrist_m: np.ndarray | None = None,
max_step_m: float | None = None, max_step_m: float | None = None,
force: bool = False, force: bool = False,
@@ -24,29 +37,36 @@ def compute_delta_wrist(
"""Delta da applicare al polso (frame polso) per portare il TCP sull'offset """Delta da applicare al polso (frame polso) per portare il TCP sull'offset
di presa dall'oggetto osservato dalla camera. di presa dall'oggetto osservato dalla camera.
Modello: assi camera ≈ assi polso a meno dei segni; la camera è montata sul Modello: la camera e' montata sul polso con orientamento che e' una
polso con traslazione costante `camera_offset_wrist_m` (origine polso → permutazione (+/-) degli assi del polso. `cam_to_wrist_perm = (i_x, i_y, i_z)`
origine camera, espressa nel frame polso). indica da quale componente del frame camera proviene ciascun asse polso:
`wrist_X = sign_X * cam[i_x]`, etc. `axis_signs` applica il segno DOPO la
permutazione. La traslazione costante `camera_offset_wrist_m` e' l'origine
della camera nel frame polso.
delta_wrist = sign * (centroid_camera - offset_camera) + t_wrist_cam delta_wrist = sign * (centroid_cam[perm] - offset_cam[perm]) + t_wrist_cam
Il termine `+ t_wrist_cam` compensa il fatto che camera e TCP non coincidono: Esempio (Nuwa eye-in-hand che guarda avanti come la mano, polso destro G1):
spostare il polso di `sign * (centroid - offset)` porterebbe la CAMERA sul cam_to_wrist_perm = (2, 0, 1) # wrist_X<-cam_Z, _Y<-cam_X, _Z<-cam_Y
punto desiderato, lasciando il TCP indietro di t_wrist_cam. axis_signs = (+1, -1, -1)
Se `max_step_m` è settato e ||delta|| > max_step_m e `force` è False, solleva Default `perm = (0, 1, 2)`: nessuna permutazione, modello vecchio sign-only.
DeltaOversize. Se `max_step_m` e' settato e ||delta|| > max_step_m e `force` e' False,
solleva DeltaOversize.
""" """
centroid = np.asarray(centroid_camera_m, dtype=np.float64).reshape(3) centroid = np.asarray(centroid_camera_m, dtype=np.float64).reshape(3)
offset = np.asarray(offset_camera_m, dtype=np.float64).reshape(3) offset = np.asarray(offset_camera_m, dtype=np.float64).reshape(3)
sign = np.asarray(axis_signs, dtype=np.float64).reshape(3) sign = np.asarray(axis_signs, dtype=np.float64).reshape(3)
perm = np.asarray(cam_to_wrist_perm, dtype=np.intp).reshape(3)
if not (np.all((perm >= 0) & (perm <= 2)) and len(set(perm.tolist())) == 3):
raise ValueError(f"cam_to_wrist_perm must be a permutation of (0,1,2), got {perm.tolist()}")
t_wc = (np.zeros(3, dtype=np.float64) if camera_offset_wrist_m is None t_wc = (np.zeros(3, dtype=np.float64) if camera_offset_wrist_m is None
else np.asarray(camera_offset_wrist_m, dtype=np.float64).reshape(3)) else np.asarray(camera_offset_wrist_m, dtype=np.float64).reshape(3))
delta = sign * (centroid - offset) + t_wc delta = sign * (centroid[perm] - offset[perm]) + t_wc
if max_step_m is not None and not force: if max_step_m is not None and not force:
n = float(np.linalg.norm(delta)) n = float(np.linalg.norm(delta))
if n > max_step_m: if n > max_step_m:
raise DeltaOversize(n, max_step_m) raise DeltaOversize(delta, max_step_m)
return delta return delta
+53
View File
@@ -39,6 +39,40 @@ def test_signs_applied_componentwise():
assert np.allclose(delta, [0.05, -0.10, -0.20]) assert np.allclose(delta, [0.05, -0.10, -0.20])
def test_permutation_maps_cam_axes_to_wrist_axes():
# Mounting: cam guarda avanti = wrist_X. perm=(2,0,1): wrist_X<-cam_Z,
# wrist_Y<-cam_X, wrist_Z<-cam_Y. signs=(+1,-1,-1).
# centroid=(0.10, 0.05, 0.30) → wrist=(+0.30, -0.10, -0.05) prima offset/t_wc
delta = compute_delta_wrist(
centroid_camera_m=np.array([0.10, 0.05, 0.30]),
offset_camera_m=np.array([0.0, 0.0, 0.0]),
axis_signs=(1, -1, -1),
cam_to_wrist_perm=(2, 0, 1),
)
assert np.allclose(delta, [0.30, -0.10, -0.05])
def test_permutation_default_is_identity_back_compat():
# Senza perm esplicito, comportamento = sign-only (modello vecchio).
delta_new = compute_delta_wrist(
centroid_camera_m=np.array([0.10, 0.20, 0.30]),
offset_camera_m=np.array([0.0, 0.0, 0.0]),
axis_signs=(1, -1, -1),
)
assert np.allclose(delta_new, [0.10, -0.20, -0.30])
def test_invalid_permutation_raises():
import pytest as _pt
with _pt.raises(ValueError, match="permutation"):
compute_delta_wrist(
centroid_camera_m=np.zeros(3),
offset_camera_m=np.zeros(3),
axis_signs=(1, 1, 1),
cam_to_wrist_perm=(0, 0, 1),
)
def test_camera_offset_wrist_added_after_sign_flip(): def test_camera_offset_wrist_added_after_sign_flip():
# centroid=(0,0,1) offset=(0,0,0) sign=(1,1,1) t_wc=(0.05,-0.02,0.08) # centroid=(0,0,1) offset=(0,0,0) sign=(1,1,1) t_wc=(0.05,-0.02,0.08)
# → sign*(centroid-offset) = (0,0,1) # → sign*(centroid-offset) = (0,0,1)
@@ -73,6 +107,25 @@ def test_oversize_raises_when_not_forced():
) )
def test_oversize_message_includes_per_axis_breakdown():
# delta = (1,-1,-1) * ((0.10, -0.02, 0.50) - 0) = (0.10, 0.02, -0.50)
# ||delta|| = sqrt(0.01+0.0004+0.25) = ~0.510m, max=0.15
with pytest.raises(DeltaOversize) as exc_info:
compute_delta_wrist(
centroid_camera_m=np.array([0.10, -0.02, 0.50]),
offset_camera_m=np.array([0.0, 0.0, 0.0]),
axis_signs=(1, -1, -1),
max_step_m=0.15,
force=False,
)
msg = str(exc_info.value)
assert "X=100.0" in msg
assert "Y=20.0" in msg
assert "Z=-500.0" in msg
# E l'oggetto espone il vettore strutturato
assert np.allclose(exc_info.value.delta_m, [0.10, 0.02, -0.50])
def test_oversize_passes_when_forced(): def test_oversize_passes_when_forced():
delta = compute_delta_wrist( delta = compute_delta_wrist(
centroid_camera_m=np.array([0.0, 0.0, 0.5]), centroid_camera_m=np.array([0.0, 0.0, 0.5]),
+16
View File
@@ -415,6 +415,22 @@
<div id="raMoveResult" class="ra-result dim">— nessun move</div> <div id="raMoveResult" class="ra-result dim">— nessun move</div>
</div> </div>
<!-- VERIFICA TAG (relativa) -->
<div class="ra-card ra-wide">
<h3>Verifica tag id=1 (relativa, no calibrazione)</h3>
<p class="ra-help">
Premi <em>Snapshot</em> con il polso fermo, muovi il polso, premi ancora.
Per ogni coppia consecutiva confronto ‖Δpolso‖ (FK) con ‖Δtag‖ (front camera).
Se combaciano (errore &lt; ~10%) FK e detection sono coerenti.
</p>
<div class="ra-row">
<button id="raVerifySnapBtn" type="button">Snapshot</button>
<button id="raVerifyResetBtn" type="button">Reset</button>
<span id="raVerifyCount" class="dim">0 snapshot</span>
</div>
<div id="raVerifyResult" class="ra-result dim">— nessuno snapshot</div>
</div>
<!-- HOME + CUSTOM --> <!-- HOME + CUSTOM -->
<div class="ra-row ra-wide-grid"> <div class="ra-row ra-wide-grid">
<div class="ra-card"> <div class="ra-card">
+56
View File
@@ -211,6 +211,60 @@ function readPoseFields(prefix) {
}; };
} }
// ---------------- Verifica tag (relativa) ----------------
let verifySnapshots = [];
function norm3(a) { return Math.sqrt(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]); }
function sub3(a, b) { return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]; }
function renderVerifyTable() {
const n = verifySnapshots.length;
$('raVerifyCount').textContent = `${n} snapshot`;
if (n === 0) {
$('raVerifyResult').textContent = '— nessuno snapshot';
$('raVerifyResult').classList.add('dim');
return;
}
$('raVerifyResult').classList.remove('dim');
let out = '';
for (let i = 0; i < n; i++) {
const s = verifySnapshots[i];
out += `#${i+1} wrist=(${s.wrist_position_m.map(v=>(v*1000).toFixed(0)).join(',')})mm ` +
`tag=(${s.tag_position_m_in_front_cam.map(v=>(v*1000).toFixed(0)).join(',')})mm\n`;
}
if (n >= 2) {
out += '\nConfronti consecutivi (Δ in mm):\n';
for (let i = 1; i < n; i++) {
const dw = sub3(verifySnapshots[i].wrist_position_m,
verifySnapshots[i-1].wrist_position_m);
const dt = sub3(verifySnapshots[i].tag_position_m_in_front_cam,
verifySnapshots[i-1].tag_position_m_in_front_cam);
const nw = norm3(dw) * 1000, nt = norm3(dt) * 1000;
const err = Math.abs(nw - nt);
const pct = nw > 0 ? (err / nw * 100) : 0;
out += ` #${i}→#${i+1} ‖Δpolso‖=${nw.toFixed(1)} ‖Δtag‖=${nt.toFixed(1)} ` +
`err=${err.toFixed(1)} (${pct.toFixed(1)}%)\n`;
}
}
$('raVerifyResult').textContent = out;
}
async function onVerifySnap() {
try {
const res = await jsonFetch('/api/right_arm/verify_tag', { method: 'POST' });
verifySnapshots.push(res);
renderVerifyTable();
} catch (e) {
alert(`Verifica fallita: ${e.message}`);
}
}
function onVerifyReset() {
verifySnapshots = [];
renderVerifyTable();
}
// ---------------- Offset save ---------------- // ---------------- Offset save ----------------
async function onSaveOffset() { async function onSaveOffset() {
@@ -348,6 +402,8 @@ function init() {
$('raWristDetectBtn').addEventListener('click', onDetectWrist); $('raWristDetectBtn').addEventListener('click', onDetectWrist);
$('raMoveBtn').addEventListener('click', onMove); $('raMoveBtn').addEventListener('click', onMove);
$('raSaveOffsetBtn').addEventListener('click', onSaveOffset); $('raSaveOffsetBtn').addEventListener('click', onSaveOffset);
$('raVerifySnapBtn').addEventListener('click', onVerifySnap);
$('raVerifyResetBtn').addEventListener('click', onVerifyReset);
$('raHomeBtn').addEventListener('click', onHome); $('raHomeBtn').addEventListener('click', onHome);
$('raCustomGoBtn').addEventListener('click', onCustomGo); $('raCustomGoBtn').addEventListener('click', onCustomGo);
$('raCustomSaveBtn').addEventListener('click', onCustomSave); $('raCustomSaveBtn').addEventListener('click', onCustomSave);