工位操作改成桌面窗口:夹爪位置、力度、速度和旋转角可以调,阶段能分步或整体跑,并能软拖拽记点后回到该点。相机预览按窗口一次铺满,不再跟着每帧慢慢放大。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 10:23:56 +08:00
parent 5c7966d8cd
commit ab73399426
11 changed files with 3745 additions and 1 deletions
@@ -220,6 +220,13 @@ class RdkBridge:
return self._last_q
return self._last_q
def move_joints(self, joints: List[float], speed: float = 0.15) -> dict:
"""按关节阻抗把臂缓慢送到已记录的 7 个关节角。speed 单位 rad/s。"""
return self._ask(
{'cmd': 'move_joints', 'q': [float(v) for v in joints[:7]], 'speed': float(speed)},
timeout=180.0,
)
def close(self) -> None:
try:
self._ask({'cmd': 'quit'}, timeout=3.0)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
"""工位程序里的相机预览。
只订彩色和已对齐深度,不订左右红外。画面留在本进程,不另开查看器窗口。
"""
from __future__ import annotations
import subprocess
import threading
import time
import numpy as np
_LOCK = threading.Lock()
_STOP = threading.Event()
_THREAD: threading.Thread | None = None
_JPEG: dict[str, bytes] = {}
_STATE = {"on": False, "error": "", "frames": {}, "stamp": 0.0}
def targets() -> list[dict]:
"""真机腕部相机和手外相机。仿真相机不进预览。"""
from arm_application.operator_console import _arms, _site
from arm_application.site_io import overhead_camera
found: list[dict] = []
seen: set[str] = set()
for arm in _arms():
name = str(arm.get("camera") or "").strip().strip("/")
if not name or name.startswith("sim") or name in seen:
continue
seen.add(name)
found.append({"id": name, "label": f"{arm.get('label') or name} 腕部"})
cam, _others = overhead_camera(_site(), None)
name = str((cam or {}).get("name") or "").strip().strip("/")
if name and not name.startswith("sim") and name not in seen:
found.append({"id": name, "label": str((cam or {}).get("label") or "手外相机")})
return found
def state() -> dict:
with _LOCK:
return {
"on": _STATE["on"],
"error": _STATE["error"],
"stamp": _STATE["stamp"],
"frames": dict(_STATE["frames"]),
}
def frame(name: str, kind: str) -> bytes | None:
with _LOCK:
return _JPEG.get(f"{name}_{kind}")
def _close_external_viewers() -> None:
"""关掉独立的四窗格查看器。它会把左右红外挂出来,本程序不再用它。"""
subprocess.run(["pkill", "-f", "lib/arm_application/realsense_view"], check=False)
subprocess.run(["pkill", "-f", "arm_application.realsense_view"], check=False)
def _jpeg(img: np.ndarray) -> bytes | None:
import cv2
if img.size == 0:
return None
view = img
if view.shape[1] > 640:
scale = 640.0 / float(view.shape[1])
view = cv2.resize(view, (640, max(1, int(view.shape[0] * scale))))
ok, buf = cv2.imencode(".jpg", view, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if not ok:
return None
return buf.tobytes()
def _depth_bgr(depth: np.ndarray) -> np.ndarray:
import cv2
vis = np.zeros((depth.shape[0], depth.shape[1], 3), np.uint8)
valid = depth > 0
if not bool(valid.any()):
return vis
values = depth.astype(np.float32)
lo, hi = np.percentile(values[valid], [5, 95])
hi = max(float(hi), float(lo) + 1.0)
gray = (np.clip((values - lo) / (hi - lo), 0.0, 1.0) * 255.0).astype(np.uint8)
gray[~valid] = 0
colored = cv2.applyColorMap(gray, cv2.COLORMAP_JET)
colored[~valid] = 0
return colored
def _color_array(msg) -> np.ndarray | None:
if msg.encoding not in ("bgr8", "rgb8") or msg.height <= 0 or msg.width <= 0:
return None
arr = np.frombuffer(bytes(msg.data), dtype=np.uint8)
need = int(msg.height) * int(msg.width) * 3
if arr.size < need:
return None
arr = arr[:need].reshape(int(msg.height), int(msg.width), 3)
if msg.encoding == "rgb8":
arr = arr[:, :, ::-1]
return np.ascontiguousarray(arr)
def _depth_array(msg) -> np.ndarray | None:
if msg.encoding not in ("16UC1", "mono16") or msg.height <= 0 or msg.width <= 0:
return None
arr = np.frombuffer(bytes(msg.data), dtype=np.uint16)
need = int(msg.height) * int(msg.width)
if arr.size < need:
return None
return np.ascontiguousarray(arr[:need].reshape(int(msg.height), int(msg.width)))
def _loop(cams: list[dict]) -> None:
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import Image
latest: dict[str, np.ndarray] = {}
lock = threading.Lock()
class _Node(Node):
def __init__(self) -> None:
super().__init__("operator_camera_view")
for cam in cams:
name = cam["id"]
self.create_subscription(
Image,
f"/{name}/color/image_raw",
self._color(name),
qos_profile_sensor_data,
)
self.create_subscription(
Image,
f"/{name}/aligned_depth_to_color/image_raw",
self._depth(name),
qos_profile_sensor_data,
)
def _color(self, name: str):
def _cb(msg: Image) -> None:
arr = _color_array(msg)
if arr is None:
return
with lock:
latest[f"{name}_color"] = arr
return _cb
def _depth(self, name: str):
def _cb(msg: Image) -> None:
arr = _depth_array(msg)
if arr is None:
return
with lock:
latest[f"{name}_depth"] = arr
return _cb
rclpy.init()
node = _Node()
next_encode = 0.0
try:
while not _STOP.is_set():
rclpy.spin_once(node, timeout_sec=0.05)
now = time.monotonic()
if now < next_encode:
continue
next_encode = now + 0.12
with lock:
snap = dict(latest)
latest.clear()
frames = {}
blobs = {}
for key, arr in snap.items():
name, _, kind = key.rpartition("_")
img = _depth_bgr(arr) if kind == "depth" else arr
blob = _jpeg(img)
if not blob:
continue
blobs[key] = blob
frames[key] = True
if blobs:
with _LOCK:
_JPEG.update(blobs)
_STATE["frames"].update(frames)
_STATE["stamp"] = time.time()
_STATE["error"] = ""
except Exception as exc:
with _LOCK:
_STATE["error"] = str(exc)
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
with _LOCK:
_STATE["on"] = False
def start() -> str:
global _THREAD
cams = targets()
if not cams:
with _LOCK:
_STATE["error"] = "site 里没有可预览的真机相机"
return _STATE["error"]
_close_external_viewers()
with _LOCK:
if _STATE["on"]:
return "相机预览已经在程序里"
_STATE["on"] = True
_STATE["error"] = ""
_STOP.clear()
_THREAD = threading.Thread(target=_loop, args=(cams,), daemon=True, name="cam-view")
_THREAD.start()
names = "、".join(cam["label"] for cam in cams)
return f"预览订了 {names} 的彩色和深度,不含左右红外。"
def stop() -> None:
_STOP.set()
thread = _THREAD
if thread is not None:
thread.join(timeout=1.5)
@@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ORISYS 通止规工位</title>
<style>
:root {
--bg: #070708;
--panel: #101216;
--panel-2: #171a20;
--line: #2a2e36;
--text: #f3f4f6;
--muted: #8b929d;
--blue: #12315d;
--blue-2: #1c4f98;
--ok: #3f9d6e;
--warn: #c8963e;
--bad: #c45c5c;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); }
body {
font-family: "Noto Sans SC", "Source Han Sans SC", "PingFang SC", "Segoe UI", sans-serif;
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: 72px 1fr;
height: 100vh;
overflow: hidden;
}
header {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 16px;
padding: 0 18px;
border-bottom: 1px solid var(--line);
background: #0b0c0e;
}
header img { height: 46px; width: auto; }
header .name { display: flex; flex-direction: column; gap: 2px; }
header .name b { font-size: 15px; font-weight: 560; letter-spacing: 0.08em; }
header .name span { color: var(--muted); font-size: 12px; }
header .grow { flex: 1; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.arms { display: flex; gap: 6px; }
.arms button, .btn {
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.arms button.on { background: var(--blue); border-color: #1a4580; }
.btn.primary { background: var(--blue-2); border-color: #2a62b0; }
.btn.on { background: var(--blue); border-color: #1a4580; }
.btn.danger { background: #6e2a2a; border-color: #8a3a3a; }
.clock { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 13px; min-width: 72px; text-align: right; }
aside {
border-right: 1px solid var(--line);
background: #0b0c0e;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
nav { display: flex; flex-direction: column; gap: 6px; }
nav button {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: 8px;
padding: 11px 12px;
font-size: 14px;
cursor: pointer;
}
nav button small { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; }
nav button.active { background: var(--blue); border-color: #1a4580; }
nav button.active small { color: #c5d4ea; }
nav button:hover { border-color: var(--line); }
.side-note { margin-top: auto; color: var(--muted); font-size: 12px; line-height: 1.55; padding: 0 4px; }
main { min-width: 0; min-height: 0; overflow: auto; }
section { display: none; height: 100%; padding: 16px 18px; }
section.on { display: flex; flex-direction: column; gap: 12px; }
h2 { margin: 0; font-size: 18px; font-weight: 560; }
.lead { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
.dash {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1.35fr 0.95fr;
grid-template-rows: minmax(0, 1fr) 168px;
gap: 12px;
}
.devices, .mini-forces { display: grid; gap: 12px; min-height: 0; }
.devices { grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.mini-forces { grid-template-rows: 1fr 1fr; }
.flow { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 14px;
min-height: 0;
}
.card h3 {
margin: 0 0 8px;
font-size: 12px;
color: #9aa3b2;
font-weight: 500;
letter-spacing: 0.12em;
}
.stat { font-size: 32px; font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
.sub { color: var(--muted); font-size: 12px; margin-top: 6px; line-height: 1.5; white-space: pre-line; }
.pill {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
border: 1px solid var(--line);
margin-right: 6px;
}
.pill.ok { color: var(--ok); border-color: #245c40; }
.pill.warn { color: var(--warn); border-color: #6d5424; }
.pill.bad { color: var(--bad); border-color: #6d3030; }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; flex: 1; min-height: 0; }
.split.three { grid-template-columns: 1fr 1fr 1fr; }
.instrument { display: flex; flex-direction: column; gap: 10px; height: 100%; }
.readout { display: flex; gap: 18px; }
.readout div { min-width: 88px; }
.readout em { display: block; color: var(--muted); font-style: normal; font-size: 12px; }
.readout strong { font-size: 28px; font-weight: 520; font-variant-numeric: tabular-nums; }
.meter { display: grid; grid-template-columns: 52px 1fr 72px; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); }
.track { height: 8px; background: #1c212b; border-radius: 99px; overflow: hidden; }
.track i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, #12315d, #4c86d4); }
.finder {
flex: 1;
min-height: 180px;
border: 1px solid #243044;
border-radius: 10px;
background:
linear-gradient(#4c86d4, #4c86d4) left top / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) left top / 2px 18px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right top / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right top / 2px 18px no-repeat,
linear-gradient(#4c86d4, #4c86d4) left bottom / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) left bottom / 2px 18px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right bottom / 18px 2px no-repeat,
linear-gradient(#4c86d4, #4c86d4) right bottom / 2px 18px no-repeat,
#0c0e12;
display: flex;
align-items: flex-end;
padding: 14px;
color: var(--muted);
font-size: 13px;
}
.rail { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.step { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px; min-height: 78px; }
.step b { display: block; color: #7f93b5; font-size: 12px; letter-spacing: 0.08em; }
.step span { display: block; margin-top: 8px; font-size: 14px; }
.ops { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.pair img:not([src]) { visibility: hidden; }
.pair img {
width: 100%;
height: 200px;
object-fit: contain;
background: #0c0e12;
border-radius: 8px;
border: 1px solid #243044;
}
.charts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
canvas.chart { width: 100%; height: 210px; background: #0c0e12; border-radius: 8px; display: block; }
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
.legend i { display: inline-block; width: 18px; height: 3px; margin-right: 6px; vertical-align: middle; }
pre.out {
margin: 0;
min-height: 140px;
max-height: 260px;
overflow: auto;
background: #0c0e12;
border-radius: 8px;
padding: 10px;
color: #c5d4ea;
font-size: 12px;
white-space: pre-wrap;
}
dialog {
border: 1px solid var(--line);
background: #12141a;
color: var(--text);
border-radius: 12px;
padding: 18px;
width: min(480px, calc(100vw - 32px));
}
dialog::backdrop { background: rgba(0, 0, 0, 0.55); }
dialog h3 { margin: 0; }
dialog code {
display: block;
margin: 12px 0;
padding: 10px;
background: #0b0c0e;
border-radius: 8px;
color: #c5d4ea;
font-size: 12px;
white-space: pre-wrap;
}
.toast {
position: fixed; right: 18px; bottom: 18px;
background: #151922; border: 1px solid var(--line);
border-radius: 10px; padding: 12px 14px; max-width: 420px;
display: none;
}
@media (max-width: 1100px) {
body { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr; height: auto; overflow: auto; }
header { flex-wrap: wrap; height: auto; padding: 12px; }
.dash, .split, .split.three, .ops, .rail, .flow { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header>
<img src="/logo.png" alt="ORISYS 叠动科技" />
<div class="name">
<b>通止规工位</b>
<span id="arm-line">左臂 89</span>
</div>
<div class="grow"></div>
<div class="chips" id="chips"></div>
<div class="arms" id="arms"></div>
<div class="clock" id="clock">--:--:--</div>
</header>
<aside>
<nav>
<button class="active" data-page="overview">总览<small>设备与流程</small></button>
<button data-page="tactile">视触觉<small>切向、热成像、曲线</small></button>
<button data-page="handeye">手眼<small>眼在手上 / 手外</small></button>
<button data-page="camera">相机<small>D405 与 D435</small></button>
<button data-page="gripper">夹爪<small>平行爪与旋转爪</small></button>
<button data-page="assembly">装配<small>S00 到 S11</small></button>
<button data-page="operate">操作<small>启动与确认</small></button>
</nav>
<div class="side-note">读的是本机 USB、进程和话题。动臂、张合夹爪都要再确认一次。</div>
</aside>
<main>
<section id="overview" class="on">
<div>
<h2>现场</h2>
<p class="lead" id="arm-lead"></p>
</div>
<div class="dash">
<div class="devices" id="usb-cards"></div>
<div class="mini-forces" id="mini-forces"></div>
<div class="flow" id="flow"></div>
</div>
</section>
<section id="tactile">
<div>
<h2>视触觉</h2>
<p class="lead">切向力是 SDK 的剪切场箭头,热成像是接触散度的伪彩。下面两条曲线是法向、切向 X、切向 Y,同一时间轴。单位是形变读数,不是牛顿。</p>
</div>
<div class="row">
<button class="btn primary" id="view-on" type="button">开始成像</button>
<button class="btn" id="view-off" type="button">停止成像</button>
<button class="btn" data-act="tactile_up">启动三力节点</button>
<button class="btn" data-act="tactile_down">停止触觉节点</button>
</div>
<p class="sub" id="view-note"></p>
<div class="split" id="views"></div>
<div class="charts">
<div class="card">
<h3>左指</h3>
<div class="legend"><span><i style="background:#e8e8e8"></i>法向</span><span><i style="background:#4c86d4"></i>切向 X</span><span><i style="background:#d7a15a"></i>切向 Y</span></div>
<canvas class="chart" id="chart-left"></canvas>
</div>
<div class="card">
<h3>右指</h3>
<div class="legend"><span><i style="background:#e8e8e8"></i>法向</span><span><i style="background:#4c86d4"></i>切向 X</span><span><i style="background:#d7a15a"></i>切向 Y</span></div>
<canvas class="chart" id="chart-right"></canvas>
</div>
</div>
<p class="sub" id="tac-note"></p>
</section>
<section id="handeye">
<div>
<h2>手眼标定</h2>
<p class="lead">眼在手上用腕部 D405 和 A5 板,结果是相机到法兰。眼在手外用顶置 D435 和 A4 板,结果是相机到基座。两份文件不能混用。示教和求解会动臂。</p>
</div>
<div class="row" id="he-modes">
<button class="btn on" type="button" data-mode="eye_in_hand">眼在手上</button>
<button class="btn" type="button" data-mode="eye_to_hand">眼在手外</button>
</div>
<div class="card" id="he-card"></div>
<div class="row">
<button class="btn" data-act="handeye_show">查看配置</button>
<button class="btn danger" data-act="handeye_teach">示教采点</button>
<button class="btn" data-act="handeye_merge">合并示教点</button>
<button class="btn danger" data-act="handeye_calibrate">求解手眼</button>
</div>
<pre class="out" id="he-out">查看配置后,脚本输出显示在这里。</pre>
</section>
<section id="camera">
<div>
<h2>相机</h2>
<p class="lead">腕部 D405 是 848×480 @ 30,手外 D435 是 1280×720 @ 30。预览开在桌面窗口,这一页只报链路,不造画面。</p>
</div>
<div class="split three" id="cams"></div>
<div class="row">
<button class="btn primary" data-act="camera_d405">本臂 D405 预览</button>
<button class="btn" data-act="camera_d435">D435 预览</button>
</div>
</section>
<section id="gripper">
<div>
<h2>夹爪</h2>
<p class="lead" id="grip-lead"></p>
</div>
<div class="split" id="grip-body"></div>
</section>
<section id="assembly">
<div>
<h2>通止规装配</h2>
<p class="lead">顺序与状态机一致。干跑只打印计划。真跑会动臂、会拧,而且这台机器上还没跑过完整真机装配。</p>
</div>
<div class="rail" id="rail"></div>
<div class="row">
<button class="btn" data-act="gauge_dry">干跑</button>
<button class="btn danger" data-act="gauge_run">真跑</button>
</div>
</section>
<section id="operate">
<div>
<h2>操作</h2>
<p class="lead" id="op-lead"></p>
</div>
<div class="ops" id="ops"></div>
</section>
</main>
<dialog id="ask">
<h3 id="ask-title">确认</h3>
<p class="sub" id="ask-text"></p>
<code id="ask-cmd"></code>
<div class="row">
<button class="btn" id="ask-no">取消</button>
<button class="btn primary" id="ask-yes">确认执行</button>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script>
const titles = {overview:"总览", tactile:"视触觉", camera:"相机", assembly:"装配", operate:"操作", gripper:"夹爪", handeye:"手眼"};
const boot = /*BOOT*/null;
let arm = "89";
let catalog = (boot && boot.catalog) || {actions:[], stages:[]};
let status = (boot && boot.status) || null;
let pending = null;
let heMode = "eye_in_hand";
function $(id){ return document.getElementById(id); }
function pill(ok, word, warn){
const s = document.createElement("span");
s.className = "pill " + (ok ? "ok" : warn ? "warn" : "bad");
s.textContent = word;
return s;
}
function toast(text){
const el = $("toast");
el.textContent = text;
el.style.display = "block";
clearTimeout(toast._t);
toast._t = setTimeout(() => { el.style.display = "none"; }, 4200);
}
async function api(path, body){
const res = await fetch(path, body ? {
method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)
} : undefined);
return res.json();
}
function cmdOf(action){
const spec = catalog.actions.find(a => a.id === action);
if (!spec) return action;
const args = (spec.args || []).map(a => a.replace("{arm}", arm).replace("{mode}", heMode)).join(" ");
return "bash scripts/" + ({
real_up:"run_real.sh", camera_d405:"run_camera_d405.sh", camera_d435:"run_camera_d435.sh",
tactile_up:"run_tactile.sh", gripper_up:"run_gripper.sh", gripper_probe:"run_gripper.sh",
gripper_open:"run_gripper.sh", gripper_close:"run_gripper.sh", gripper_rotate:"run_gripper.sh",
pipeline:"run_all.sh", gauge_dry:"run_gauge_assembly.sh", gauge_run:"run_gauge_assembly.sh",
handeye_show:"run_handeye.sh", handeye_teach:"run_handeye.sh",
handeye_merge:"run_handeye.sh", handeye_calibrate:"run_handeye.sh"
}[action] || "scripts") + (args ? " " + args : "");
}
async function fire(action, confirm){
const data = await api("/api/action", {action, arm, confirm: !!confirm, mode: heMode});
toast(data.message || (data.ok ? "已发出" : "失败"));
if (String(action).startsWith("handeye") && $("he-out")) {
$("he-out").textContent = data.detail || data.message || "";
}
}
function ask(action){
const spec = catalog.actions.find(a => a.id === action);
if (action === "gripper_rotate" && arm !== "91"){
toast("旋转只对右臂 91");
return;
}
if (spec && spec.moves){
pending = action;
$("ask-title").textContent = spec.title;
$("ask-text").textContent = spec.blurb + " 当前臂 " + arm + "。";
$("ask-cmd").textContent = cmdOf(action);
$("ask").showModal();
return;
}
fire(action, false);
}
function svc(id){ return (status?.services || []).find(s => s.id === id); }
function meters(card, data){
const keys = [["法向", data.fnormal], ["切向 X", data.fshearx], ["切向 Y", data.fsheary]];
const peak = Math.max(1, ...keys.map(([,v]) => Math.abs(typeof v === "number" ? v : 0)));
keys.forEach(([name, value]) => {
const row = document.createElement("div");
row.className = "meter";
const lab = document.createElement("span");
lab.textContent = name;
const track = document.createElement("div");
track.className = "track";
const bar = document.createElement("i");
const n = typeof value === "number" ? value : null;
bar.style.width = n == null ? "0%" : (Math.abs(n) / peak * 100).toFixed(1) + "%";
track.appendChild(bar);
const num = document.createElement("span");
num.textContent = n == null ? "—" : n.toFixed(1);
row.append(lab, track, num);
card.appendChild(row);
});
}
function forceCard(title, data, tall){
const card = document.createElement("div");
card.className = "card instrument";
const h = document.createElement("h3");
h.textContent = title;
card.appendChild(h);
const has = data && (data.connected !== undefined || data.fnormal !== undefined);
if (tall) {
const row = document.createElement("div");
row.className = "readout";
[["法向", data.fnormal], ["切向 X", data.fshearx], ["切向 Y", data.fsheary], ["fps", data.fps]].forEach(([k,v]) => {
const d = document.createElement("div");
const em = document.createElement("em");
em.textContent = k;
const st = document.createElement("strong");
st.textContent = typeof v === "number" ? (k === "fps" ? v.toFixed(1) : v.toFixed(1)) : "—";
d.append(em, st);
row.appendChild(d);
});
card.appendChild(row);
}
meters(card, data || {});
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = has
? ((data.contact ? "有接触" : "无接触") + (typeof data.fps === "number" ? " · " + data.fps.toFixed(1) + " fps" : "") + (data.connected === false ? " · 未连接" : ""))
: "话题上还没有这一侧";
card.appendChild(sub);
return card;
}
function renderHeader(){
const box = $("arms");
box.replaceChildren();
(status?.arms || [{id:"89", label:"左臂89"}, {id:"91", label:"右臂91"}]).forEach(a => {
const b = document.createElement("button");
b.textContent = a.label;
b.className = a.id === arm ? "on" : "";
b.onclick = () => { arm = a.id; render(); };
box.appendChild(b);
});
const chips = $("chips");
chips.replaceChildren();
(status?.services || []).forEach(s => {
chips.appendChild(pill(s.up, s.label + (s.up ? " 在跑" : " 未开")));
});
const cur = (status?.arms || []).find(a => a.id === arm);
$("arm-line").textContent = cur ? cur.label : ("臂 " + arm);
$("arm-lead").textContent = cur
? cur.label + " · " + (cur.sn || "") + " · 相机 " + (cur.camera || "—") + " · 夹爪 " + (cur.gripper || "—") + (cur.tactile ? " · 本臂有视触觉" : " · 本臂无视触觉")
: "";
$("op-lead").textContent = "当前臂 " + arm + "。红色按钮会动设备,先弹出命令再执行。";
$("grip-lead").textContent = arm === "91"
? "右臂 91 是旋转爪 RGI。张开、闭合、转动都要确认。探测只读状态。"
: "左臂 89 是平行爪 PGEA,串口以现场为准。张开和闭合要确认。探测只读状态。";
}
function renderUsb(){
const box = $("usb-cards");
box.replaceChildren();
const ports = status?.grippers || [];
const rows = [...(status?.usb || []), {id:"tty", label:"夹爪串口", expect:1, items: ports.map(p => ({port:p, speed:""}))}];
rows.forEach(dev => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = dev.label;
const n = document.createElement("div");
n.className = "stat";
n.textContent = dev.id === "tty" ? String(dev.items.length) : (dev.items.length + " / " + dev.expect);
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = dev.items.length
? dev.items.map(i => i.speed ? (i.port + " · " + i.speed + " Mbps") : i.port).join("\n")
: "未检测到";
const ok = dev.id === "tty" ? dev.items.length > 0 : dev.items.length >= dev.expect;
const partial = !ok && dev.items.length > 0;
card.append(h, n, pill(ok, ok ? "齐" : partial ? "缺一" : "缺失", partial), sub);
box.appendChild(card);
});
}
function renderMini(){
const box = $("mini-forces");
const t = status?.tactile || {};
box.replaceChildren(forceCard("左指", t.left, false), forceCard("右指", t.right, false));
}
function renderFlow(){
const box = $("flow");
box.replaceChildren();
const pipe = svc("pipeline");
[["跟踪", "D405 看住工件,锁住悬停"], ["对孔", "拍照找孔,移到孔上方"], ["搜孔轻压", "力闭环入孔,再轻压"]].forEach(([name, desc]) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = name;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = desc + (pipe && pipe.up ? "\n进程在跑,具体阶段看终端日志。" : "\n流程未启动。");
card.append(h, pill(false, pipe && pipe.up ? "进行中" : "待命", pipe && pipe.up), sub);
box.appendChild(card);
});
}
function renderFingers(){
const t = status?.tactile || {};
const node = svc("tactile");
const live = ["left", "right"].some(side => (t[side] || {}).fnormal !== undefined);
const note = $("tac-note");
if (note) note.textContent = node && !node.up && live && !status?.view?.on
? "正式触觉节点没开。曲线若在走,是话题上另一个发布者。"
: "";
}
function camCard(title, desc, dev){
const card = document.createElement("div");
card.className = "card instrument";
const h = document.createElement("h3");
h.textContent = title;
const frame = document.createElement("div");
frame.className = "finder";
frame.textContent = dev && dev.items.length ? "链路在线 · 预览用下面的按钮挂到桌面" : "未插入";
const sub = document.createElement("div");
sub.className = "sub";
const where = (dev?.items || []).map(i => i.port + " · " + i.speed + " Mbps").join("\n");
sub.textContent = desc + (where ? "\n" + where : "");
card.append(h, frame, pill(!!(dev && dev.items.length), dev && dev.items.length ? dev.items.length + " 台" : "缺失"), sub);
return card;
}
function renderCams(){
const usb = Object.fromEntries((status?.usb || []).map(d => [d.id, d]));
const d405 = usb.d405 || {items:[]};
const d435 = usb.d435 || {items:[]};
const other = d405.items.length >= 2 ? {items: d405.items.slice(1)} : {items: []};
$("cams").replaceChildren(
camCard(arm === "91" ? "右腕 D405" : "左腕 D405", "848×480 @ 30 · 彩色 + 深度", {items: d405.items.slice(0, 1)}),
camCard("另一只腕部", "现场期望两台。缺的那台插上后这里会亮。", other),
camCard("手外 D435", "1280×720 @ 30 · 顶视", d435),
);
}
function renderGripper(){
const ports = status?.grippers || [];
const up = svc("gripper");
const box = $("grip-body");
box.replaceChildren();
[
["89", "左臂平行爪", "PGEA-100-20-W", ["gripper_probe", "gripper_up", "gripper_open", "gripper_close"]],
["91", "右臂旋转爪", "RGI-100-30-O-S-M1", ["gripper_probe", "gripper_up", "gripper_open", "gripper_close", "gripper_rotate"]],
].forEach(([id, title, model, acts]) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = title;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = model + "\n串口 " + (ports.join(" ") || "没有 ttyUSB") + (up && up.up ? "\n驱动在跑" : "\n驱动未开");
card.append(h, pill(id === arm, id === arm ? "当前臂" : "切换顶栏", id !== arm), sub);
const row = document.createElement("div");
row.className = "row";
acts.forEach(act => {
const spec = catalog.actions.find(a => a.id === act);
const b = document.createElement("button");
b.className = "btn " + (spec && spec.moves ? "danger" : "primary");
b.textContent = spec ? spec.title : act;
b.onclick = () => { if (arm !== id) arm = id; ask(act); render(); };
row.appendChild(b);
});
card.appendChild(row);
box.appendChild(card);
});
}
function renderRail(){
const box = $("rail");
if (box.childElementCount) return;
catalog.stages.forEach(([id, name]) => {
const d = document.createElement("div");
d.className = "step";
const b = document.createElement("b");
b.textContent = id;
const s = document.createElement("span");
s.textContent = name;
d.append(b, s);
box.appendChild(d);
});
}
function renderOps(){
const box = $("ops");
box.replaceChildren();
const skip = new Set(["gripper_open", "gripper_close", "gripper_rotate", "gripper_probe", "handeye_show", "handeye_teach", "handeye_merge", "handeye_calibrate"]);
catalog.actions.filter(spec => !skip.has(spec.id)).forEach(spec => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = spec.title;
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = spec.blurb;
const b = document.createElement("button");
b.className = "btn " + (spec.moves ? "danger" : "primary");
b.textContent = spec.moves ? "确认后执行" : "执行";
b.style.marginTop = "10px";
b.onclick = () => ask(spec.id);
card.append(h, sub, b);
box.appendChild(card);
});
const stop = document.createElement("div");
stop.className = "card";
const h = document.createElement("h3");
h.textContent = "停止视触觉";
const sub = document.createElement("div");
sub.className = "sub";
sub.textContent = "关掉触觉节点和占着相机的 worker。";
const b = document.createElement("button");
b.className = "btn";
b.textContent = "停止";
b.style.marginTop = "10px";
b.onclick = () => ask("tactile_down");
stop.append(h, sub, b);
box.appendChild(stop);
}
function shot(side, kind, title){
const wrap = document.createElement("div");
const h = document.createElement("div");
h.className = "sub";
h.textContent = title;
const img = document.createElement("img");
img.alt = "";
img.dataset.side = side;
img.dataset.kind = kind;
const ready = status?.view?.frames?.[side + "_" + kind];
if (status?.view?.on && ready) {
img.src = "/api/tactile/frame/" + side + "/" + kind + ".jpg?t=" + Math.floor((status.view.stamp || 0) * 5);
}
wrap.append(h, img);
return wrap;
}
function renderViews(){
const box = $("views");
if (!box.childElementCount) {
[["left", "左指"], ["right", "右指"]].forEach(([side, name]) => {
const card = document.createElement("div");
card.className = "card";
const h = document.createElement("h3");
h.textContent = name;
const pair = document.createElement("div");
pair.className = "pair";
pair.append(shot(side, "shear", "切向力"), shot(side, "thermal", "热成像"));
card.append(h, pair);
box.appendChild(card);
});
}
const view = status?.view || {};
$("view-note").textContent = view.error || view.note || (view.on ? "成像已开,等第一帧。" : "成像没开。点开始成像后这里出切向力和热成像。");
}
function drawChart(canvas, samples){
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth || 360;
const h = canvas.clientHeight || 210;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#0c0e12";
ctx.fillRect(0, 0, w, h);
const rows = (samples || []).filter(p => ["fnormal", "fshearx", "fsheary"].some(k => typeof p[k] === "number"));
if (rows.length < 2) {
ctx.fillStyle = "#8b929d";
ctx.font = "13px sans-serif";
ctx.fillText("还没有足够的采样", 16, 28);
return;
}
const keys = [["fnormal", "#e8e8e8"], ["fshearx", "#4c86d4"], ["fsheary", "#d7a15a"]];
let lo = Infinity, hi = -Infinity;
rows.forEach(p => keys.forEach(([k]) => {
if (typeof p[k] === "number") { lo = Math.min(lo, p[k]); hi = Math.max(hi, p[k]); }
}));
if (!(hi > lo)) { hi = lo + 1; }
const pad = (hi - lo) * 0.08;
lo -= pad; hi += pad;
const t0 = rows[0].t, t1 = rows[rows.length - 1].t;
const span = Math.max(t1 - t0, 1e-3);
const left = 46, right = 10, top = 12, bottom = 22;
ctx.strokeStyle = "#2a2e36";
ctx.beginPath();
const y0 = top + (hi - 0) / (hi - lo) * (h - top - bottom);
if (y0 > top && y0 < h - bottom) {
ctx.moveTo(left, y0);
ctx.lineTo(w - right, y0);
ctx.stroke();
}
keys.forEach(([k, color]) => {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.6;
let started = false;
rows.forEach(p => {
if (typeof p[k] !== "number") return;
const x = left + (p.t - t0) / span * (w - left - right);
const y = top + (hi - p[k]) / (hi - lo) * (h - top - bottom);
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
});
ctx.stroke();
});
ctx.fillStyle = "#8b929d";
ctx.font = "11px sans-serif";
ctx.fillText(hi.toFixed(0), 4, top + 10);
ctx.fillText(lo.toFixed(0), 4, h - bottom);
ctx.fillText((span).toFixed(0) + " 秒", w - 48, h - 6);
}
function mm(v){
if (!Array.isArray(v) || v.length < 3) return "—";
return v.slice(0, 3).map(x => (Number(x) * 1000).toFixed(1) + " mm").join(" ");
}
function renderHandeye(){
document.querySelectorAll("#he-modes button").forEach(b => b.classList.toggle("on", b.dataset.mode === heMode));
const card = (status?.handeye || {})[arm]?.[heMode];
const box = $("he-card");
if (!box) return;
box.replaceChildren();
if (!card) {
box.textContent = "没有这一臂的标定记录";
return;
}
const h = document.createElement("h3");
h.textContent = heMode === "eye_in_hand" ? "眼在手上" : "眼在手外";
const sub = document.createElement("div");
sub.className = "sub";
const board = card.board || {};
sub.textContent = [
card.exists ? "已有结果" : "还没有结果文件",
card.meaning || "",
"文件 " + card.file,
"视角 " + (card.views || "—") + " 时间 " + (card.utc || "—"),
"相机 " + (card.camera || "—") + " 工具 " + (card.tool || "—"),
"平移 X / Y / Z " + mm(card.translation_m),
"示教点 " + (card.poses == null ? "没有位姿文件" : card.poses + " 个"),
"板 " + (board.cols || "—") + "×" + (board.rows || "—") + " 方格 " + (board.square_m == null ? "—" : board.square_m + " m"),
].filter(Boolean).join("\n");
box.append(h, pill(!!card.exists, card.exists ? "有结果" : "无结果"), sub);
}
function render(){
renderHeader();
renderUsb();
renderMini();
renderFlow();
renderFingers();
renderViews();
drawChart($("chart-left"), status?.history?.left);
drawChart($("chart-right"), status?.history?.right);
renderCams();
renderGripper();
renderHandeye();
renderRail();
renderOps();
}
function show(page){
if (!titles[page]) page = "overview";
document.querySelectorAll("nav button").forEach(b => b.classList.toggle("active", b.dataset.page === page));
document.querySelectorAll("main section").forEach(s => s.classList.remove("on"));
$(page).classList.add("on");
if (location.hash !== "#" + page) history.replaceState(null, "", "#" + page);
}
document.querySelectorAll("nav button").forEach(btn => { btn.onclick = () => show(btn.dataset.page); });
window.addEventListener("hashchange", () => show(location.hash.slice(1)));
document.body.addEventListener("click", ev => {
const act = ev.target.closest("[data-act]");
if (act) ask(act.dataset.act);
});
document.querySelectorAll("#he-modes button").forEach(btn => {
btn.onclick = () => { heMode = btn.dataset.mode; renderHandeye(); };
});
$("view-on").onclick = async () => {
const data = await api("/api/tactile/view", {on: true});
toast(data.message || "已请求");
};
$("view-off").onclick = async () => {
const data = await api("/api/tactile/view", {on: false});
toast(data.message || "已停止");
};
setInterval(() => {
if (!$("tactile").classList.contains("on") || !status?.view?.on) return;
document.querySelectorAll("#views img").forEach(img => {
const side = img.dataset.side, kind = img.dataset.kind;
if (!status.view.frames?.[side + "_" + kind]) return;
img.src = "/api/tactile/frame/" + side + "/" + kind + ".jpg?t=" + Date.now();
});
}, 200);
$("ask-no").onclick = () => $("ask").close();
$("ask-yes").onclick = () => { const action = pending; $("ask").close(); if (action) fire(action, true); };
async function tick(){
try { status = await api("/api/status"); render(); }
catch (err) { toast("状态刷新失败"); }
paintClock();
}
function paintClock(){
$("clock").textContent = new Date().toLocaleTimeString("zh-CN", {hour12:false});
}
async function start(){
if (!boot) {
catalog = await api("/api/catalog");
await tick();
} else {
render();
paintClock();
}
show(location.hash.slice(1) || "overview");
setInterval(tick, 2000);
}
start();
</script>
</body>
</html>
@@ -0,0 +1,725 @@
#!/usr/bin/env python3
"""Orisys 工位界面。
只做两件事:把本机真实状态摊开,以及按白名单启动仓库里已有的脚本。
会动臂的动作必须带 ``confirm=true``,避免页面上误点一下就上电。
bash ~/ros2/scripts/run_console.sh
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
import yaml
from arm_application import operator_tactile_view as tactile_view
from arm_application.site_io import eye_to_hand_result_name, overhead_camera
HOST = "127.0.0.1"
PORT = 8766
_ROOT = Path(__file__).resolve()
for _p in _ROOT.parents:
if (_p / "scripts" / "run_real.sh").is_file():
ROOT = _p
break
else:
ROOT = Path.home() / "ros2"
LOG_DIR = Path("/tmp/orisys_console")
ASSETS = Path(__file__).resolve().parent
_LOCK = threading.Lock()
_TACTILE = {"left": {}, "right": {}, "stamp": 0.0}
_HIST = {"left": deque(maxlen=360), "right": deque(maxlen=360)}
_PROCS: dict[str, int] = {}
def _push_force(side: str, sample: dict) -> None:
if side not in _HIST:
return
point = {
"t": float(sample.get("t") or time.time()),
"fnormal": sample.get("fnormal"),
"fshearx": sample.get("fshearx"),
"fsheary": sample.get("fsheary"),
}
if not any(isinstance(point[k], (int, float)) for k in ("fnormal", "fshearx", "fsheary")):
return
with _LOCK:
_HIST[side].append(point)
_TACTILE[side] = {
"fnormal": point["fnormal"],
"fshearx": point["fshearx"],
"fsheary": point["fsheary"],
"connected": True,
}
tactile_view.set_sample_hook(_push_force)
def _site() -> dict:
path = ROOT / "config" / "site.yaml"
if not path.is_file():
return {}
data = yaml.safe_load(path.read_text()) or {}
return data if isinstance(data, dict) else {}
def _arms() -> list[dict]:
robots = (_site().get("robot") or {}).get("robots") or []
out = []
for robot in robots:
if not isinstance(robot, dict):
continue
sn = str(robot.get("sn") or "")
arm_no = "89" if sn.endswith("063989") else "91" if sn.endswith("063991") else ""
if not arm_no:
continue
out.append({
"id": arm_no,
"label": str(robot.get("label") or f"臂{arm_no}"),
"sn": sn,
"tactile": bool(robot.get("tactile")),
"camera": str((robot.get("camera") or {}).get("name") or ""),
"gripper": str((robot.get("gripper") or {}).get("model") or ""),
})
return out or [
{"id": "89", "label": "左臂89", "sn": "", "tactile": True, "camera": "d405_left", "gripper": ""},
{"id": "91", "label": "右臂91", "sn": "", "tactile": False, "camera": "d405_right", "gripper": ""},
]
def _usb() -> list[dict]:
found = {"d405": [], "d435": [], "orisys": []}
want = {
("8086", "0b5b"): "d405",
("8086", "0b07"): "d435",
("1bcf", "2281"): "orisys",
}
base = Path("/sys/bus/usb/devices")
if not base.is_dir():
return []
for dev in base.iterdir():
vid = dev / "idVendor"
pid = dev / "idProduct"
if not vid.is_file() or not pid.is_file():
continue
key = want.get((vid.read_text().strip(), pid.read_text().strip()))
if not key:
continue
speed = (dev / "speed").read_text().strip() if (dev / "speed").is_file() else "?"
found[key].append({"port": dev.name, "speed": speed})
return [
{"id": "d405", "label": "腕部 D405", "expect": 2, "items": found["d405"]},
{"id": "d435", "label": "手外 D435", "expect": 1, "items": found["d435"]},
{"id": "orisys", "label": "视触觉", "expect": 2, "items": found["orisys"]},
]
def _listeners(pattern: str) -> bool:
try:
out = subprocess.run(
["pgrep", "-af", pattern],
capture_output=True, text=True, timeout=2,
).stdout
except Exception:
return False
for line in out.splitlines():
if "pgrep" in line or "operator_console" in line:
continue
return True
return False
def _services() -> list[dict]:
rows = [
("real", "控制栈", "ros2_control_node"),
("tactile", "视触觉节点", "lib/arm_application/tactile"),
("camera", "相机", "realsense_color_pub"),
("gripper", "夹爪", "dh_pgea|dual_gripper_driver"),
("pipeline", "三段流程", "assembly_pipeline"),
("gauge", "通止规装配", "gauge_assembly"),
]
out = []
for sid, label, pattern in rows:
if "|" in pattern:
up = any(_listeners(part) for part in pattern.split("|"))
else:
up = _listeners(pattern)
out.append({"id": sid, "label": label, "up": up})
return out
def _grippers() -> list[str]:
return sorted(p.name for p in Path("/dev").glob("ttyUSB*"))
def _parse_tactile(text: str) -> dict:
got: dict[str, object] = {}
for line in text.splitlines():
if ":" not in line:
continue
key, _, raw = line.partition(":")
key = key.strip()
raw = raw.strip()
if key in ("fnormal", "fshearx", "fsheary", "fps"):
try:
got[key] = float(raw)
except ValueError:
continue
elif key in ("connected", "contact"):
got[key] = raw.lower() == "true"
return got
def _poll_tactile() -> None:
while True:
snap = {}
for side in ("left", "right"):
try:
proc = subprocess.run(
["timeout", "1.2", "ros2", "topic", "echo", f"/tactile/{side}/status", "--once"],
capture_output=True, text=True, timeout=2.5,
)
snap[side] = _parse_tactile(proc.stdout) if proc.returncode == 0 else {}
except Exception:
snap[side] = {}
view_on = bool(tactile_view.state().get("on"))
with _LOCK:
_TACTILE["stamp"] = time.time()
if not view_on:
_TACTILE["left"] = snap.get("left") or {}
_TACTILE["right"] = snap.get("right") or {}
now = time.time()
for side in ("left", "right"):
row = snap.get(side) or {}
if row:
_HIST[side].append({
"t": now,
"fnormal": row.get("fnormal"),
"fshearx": row.get("fshearx"),
"fsheary": row.get("fsheary"),
})
time.sleep(0.35)
def _robot(arm: str) -> dict:
robots = (_site().get("robot") or {}).get("robots") or []
for robot in robots:
if not isinstance(robot, dict):
continue
sn = str(robot.get("sn") or "")
short = "89" if sn.endswith("063989") else "91" if sn.endswith("063991") else ""
if short == arm:
return robot
return {}
def _comment(text: str, key: str) -> str:
match = re.search(rf"^#\s*{re.escape(key)}:\s*(.+)$", text, re.M)
return match.group(1).strip() if match else ""
def _pose_count(path: Path) -> int | None:
if not path.is_file():
return None
match = re.search(r"num_poses:\s*(\d+)", path.read_text(errors="replace"))
return int(match.group(1)) if match else None
def _result_card(path: Path, pose_path: Path, board: dict) -> dict:
text = path.read_text(errors="replace") if path.is_file() else ""
trans = None
tool = ""
if text:
try:
data = yaml.safe_load(text) or {}
except yaml.YAMLError:
data = {}
if isinstance(data, dict):
raw = data.get("translation_m")
if isinstance(raw, list) and len(raw) >= 3:
trans = [float(raw[0]), float(raw[1]), float(raw[2])]
tool = str(data.get("gripper_tool") or "")
meaning = ""
for line in text.splitlines():
if line.startswith("# T_4x4"):
meaning = line[2:].strip()
break
return {
"file": str(path),
"exists": path.is_file(),
"views": _comment(text, "views"),
"utc": _comment(text, "utc"),
"board_note": _comment(text, "board"),
"camera": _comment(text, "camera_source"),
"tool": tool,
"meaning": meaning,
"translation_m": trans,
"poses": _pose_count(pose_path),
"pose_file": str(pose_path),
"board": {
"cols": board.get("cols"),
"rows": board.get("rows"),
"square_m": board.get("square_size_m"),
},
}
def _handeye(arm: str) -> dict:
site = _site()
robot = _robot(arm)
paths = robot.get("paths") if isinstance(robot.get("paths"), dict) else {}
shared = site.get("paths") if isinstance(site.get("paths"), dict) else {}
boards = ((site.get("handeye") or {}).get("boards") or {})
wrist_file = Path(str(
paths.get("handeye_file")
or shared.get("handeye_file")
or (ROOT / "handeye" / "results" / f"{arm}_eye_in_hand.yaml")
))
wrist_pose = Path(str(
paths.get("handeye_waypoints")
or shared.get("handeye_waypoints")
or (ROOT / "handeye" / "poses" / f"{arm}_eye_in_hand.yaml")
))
cam, _others = overhead_camera(site, robot or None)
cam_name = str((cam or {}).get("name") or "")
e2h_name = eye_to_hand_result_name(arm, cam_name)
e2h_file = Path(str(
paths.get("handeye_file_eye_to_hand")
or shared.get("handeye_file_eye_to_hand")
or (ROOT / "handeye" / "results" / e2h_name)
))
e2h_pose = Path(str(
paths.get("handeye_waypoints_eye_to_hand")
or shared.get("handeye_waypoints_eye_to_hand")
or (ROOT / "handeye" / "poses" / f"{arm}_eye_to_hand.yaml")
))
return {
"eye_in_hand": _result_card(wrist_file, wrist_pose, boards.get("eye_in_hand") or {}),
"eye_to_hand": _result_card(e2h_file, e2h_pose, boards.get("eye_to_hand") or {}),
}
def status() -> dict:
with _LOCK:
tactile = {
"left": dict(_TACTILE["left"]),
"right": dict(_TACTILE["right"]),
"stamp": _TACTILE["stamp"],
}
history = {
"left": list(_HIST["left"]),
"right": list(_HIST["right"]),
}
return {
"arms": _arms(),
"usb": _usb(),
"services": _services(),
"grippers": _grippers(),
"tactile": tactile,
"history": history,
"view": tactile_view.state(),
"handeye": {"89": _handeye("89"), "91": _handeye("91")},
"now": time.time(),
}
# 会动真机的动作必须 confirm。argv 里的 {arm} 只接受 89 或 91。
_ACTIONS = {
"real_up": {
"title": "上电控制栈",
"script": "run_real.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "连接机械臂并拉起 ros2_control。",
},
"camera_d405": {
"title": "本臂腕部相机",
"script": "run_camera_d405.sh",
"args": ["--arm", "{arm}", "--no-preview"],
"moves": False,
"blurb": "只拉起本臂腕部相机。彩色和深度显示在程序里,不看左右红外,不另开窗口。",
},
"camera_d435": {
"title": "手外 D435",
"script": "run_camera_d435.sh",
"args": ["--no-preview"],
"moves": False,
"blurb": "只拉起顶视 D435。彩色和深度显示在程序里,不另开窗口。",
},
"tactile_up": {
"title": "视触觉三力",
"script": "run_tactile.sh",
"args": [],
"moves": False,
"blurb": "发布左右指法向力与两路切向力。",
},
"gripper_up": {
"title": "夹爪节点",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}"],
"moves": False,
"blurb": "只拉起夹爪驱动,不主动张合。",
},
"gripper_probe": {
"title": "夹爪探测",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "probe"],
"moves": False,
"blurb": "读夹爪是否在线,不动手指。",
},
"gripper_open": {
"title": "夹爪张开",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "open"],
"moves": True,
"blurb": "张开当前臂夹爪。",
},
"gripper_close": {
"title": "夹爪闭合",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "close"],
"moves": True,
"blurb": "闭合当前臂夹爪。",
},
"gripper_rotate": {
"title": "右爪转 90°",
"script": "run_gripper.sh",
"args": ["--arm", "{arm}", "--cli", "rotate", "90"],
"moves": True,
"blurb": "右臂旋转爪转到 90°。左臂平行爪没有这一轴。",
},
"pipeline": {
"title": "整体三段",
"script": "run_all.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "跟踪、对孔、搜孔轻压一次跑完。会动臂。",
},
"stage_track": {
"title": "只测跟踪",
"script": "run_visual_servo_rt.sh",
"args": ["--arm", "{arm}", "--control"],
"moves": True,
"blurb": "只跑视觉跟踪。从当前位姿开始,会动臂。要先有控制栈和相机。",
},
"stage_locate": {
"title": "只测对孔",
"script": "run_stage2_locate.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "只跑二阶段:朝下、悬停、拍照、对准孔。会动臂。",
},
"stage_search": {
"title": "只测搜孔",
"script": "run_stage3_search.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "只跑三阶段:触面、搜孔、轻压。会动臂。臂应已停在孔上方。",
},
"reset_home": {
"title": "复位到 home",
"script": "go_home.sh",
"args": ["--arm", "{arm}", "--pose", "home"],
"moves": True,
"sync": True,
"timeout": 120,
"blurb": "先停跟踪,再回到命名位姿 home。要控制栈已经起来。会动臂。",
},
"gauge_dry": {
"title": "装配干跑",
"script": "run_gauge_assembly.sh",
"args": ["--arm", "{arm}", "--dry-run"],
"moves": False,
"blurb": "S00 到 S11 只打印计划,不发运动指令。",
},
"gauge_run": {
"title": "装配真跑",
"script": "run_gauge_assembly.sh",
"args": ["--arm", "{arm}"],
"moves": True,
"blurb": "通止规状态机。会动臂、会拧。",
},
"handeye_show": {
"title": "查看标定配置",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "show", "--mode", "{mode}"],
"moves": False,
"sync": True,
"blurb": "打印这块板、结果文件和已有点数,不动臂。",
},
"handeye_teach": {
"title": "示教采点",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "teach", "--mode", "{mode}", "--points", "12"],
"moves": True,
"blurb": "软拖拽示教,目标 12 个姿态。要先停控制栈,会动臂。",
},
"handeye_merge": {
"title": "合并示教点",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "merge", "--mode", "{mode}"],
"moves": False,
"sync": True,
"blurb": "把刚采的点写入该模式的位姿文件,不动臂。",
},
"handeye_calibrate": {
"title": "求解手眼",
"script": "run_handeye.sh",
"args": ["--arm", "{arm}", "calibrate", "--mode", "{mode}"],
"moves": True,
"blurb": "按点位走一遍并写结果。需要控制栈和对应相机,会动臂。",
},
}
def catalog() -> dict:
return {
"actions": [
{"id": key, **{k: v for k, v in spec.items() if k not in ("script", "sync")}}
for key, spec in _ACTIONS.items()
],
"stages": [
("S00", "自检"),
("S01", "回零预绕"),
("S02", "夹持确认"),
("S03", "手外粗定位"),
("S04", "视觉跟踪"),
("S05", "精拍"),
("S06", "正悬停"),
("S07", "柔顺接触"),
("S08", "棘轮拧入"),
("S09", "反向拧出"),
("S10", "换孔"),
("S11", "回零结束"),
],
}
def _stop_tactile() -> str:
subprocess.run(["pkill", "-f", "lib/arm_application/tactile"], check=False)
subprocess.run(["pkill", "-f", "arm_application/tactile_node.py"], check=False)
subprocess.run(["pkill", "orisys_tac_w"], check=False)
return "已发送停止视触觉节点"
def run_action(action: str, arm: str, confirm: bool, mode: str = "eye_in_hand") -> dict:
if action == "tactile_down":
tactile_view.stop()
return {"ok": True, "message": _stop_tactile()}
if action == "tactile_view_on":
with _LOCK:
_HIST["left"].clear()
_HIST["right"].clear()
return {"ok": True, "message": tactile_view.start(_site())}
if action == "tactile_view_off":
return {"ok": True, "message": tactile_view.stop()}
spec = _ACTIONS.get(action)
if spec is None:
return {"ok": False, "message": "没有这个动作"}
if arm not in ("89", "91"):
return {"ok": False, "message": "臂号只接受 89 或 91"}
if mode not in ("eye_in_hand", "eye_to_hand"):
return {"ok": False, "message": "手眼模式只接受 eye_in_hand 或 eye_to_hand"}
if spec["moves"] and not confirm:
return {"ok": False, "message": "这个动作会动真机,需要确认"}
script = ROOT / "scripts" / spec["script"]
if not script.is_file():
return {"ok": False, "message": f"找不到 {script}"}
args = [a.replace("{arm}", arm).replace("{mode}", mode) for a in spec["args"]]
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{action}_{arm}.log"
shown = " ".join(["bash", f"scripts/{spec['script']}", *args])
if spec.get("sync"):
try:
proc = subprocess.run(
["bash", str(script), *args],
cwd=str(ROOT),
capture_output=True,
text=True,
timeout=float(spec.get("timeout") or 40),
)
except subprocess.TimeoutExpired:
return {"ok": False, "message": "命令超时还没返回", "detail": shown}
detail = ((proc.stdout or "") + (proc.stderr or "")).strip()
log_path.write_text(detail)
ok = proc.returncode == 0
return {
"ok": ok,
"message": shown if ok else f"退出码 {proc.returncode}",
"detail": detail[-4000:],
"log": str(log_path),
}
log_f = log_path.open("ab")
proc = subprocess.Popen(
["bash", str(script), *args],
cwd=str(ROOT),
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
_PROCS[action] = proc.pid
return {"ok": True, "message": shown, "pid": proc.pid, "log": str(log_path)}
def _bounded(name: str, raw, low: float, high: float) -> float:
try:
value = float(raw)
except (TypeError, ValueError):
raise ValueError(f"{name} 不是数字") from None
if value != value or value < low or value > high:
raise ValueError(f"{name} 只接受 {low:g}–{high:g}")
return value
def run_gripper_tune(arm: str, kind: str, numbers: dict) -> dict:
"""按界面上的位置、力度、速度下发夹爪。右臂另有角度、圈数。"""
if arm not in ("89", "91"):
return {"ok": False, "message": "臂号只接受 89 或 91"}
if kind not in ("pos", "rotate", "rotate_by", "revolutions"):
return {"ok": False, "message": "没有这种夹爪调节"}
if kind != "pos" and arm != "91":
return {"ok": False, "message": "左臂平行爪没有旋转轴"}
try:
if kind == "pos":
pos = int(round(_bounded("位置", numbers.get("pos"), 0, 1000)))
force = int(round(_bounded("力度", numbers.get("force"), 20, 100)))
speed = int(round(_bounded("速度", numbers.get("speed"), 1, 100)))
cli = ["pos", str(pos)]
summary = f"位置 {pos}(0 合,1000 开) 力度 {force}% 速度 {speed}%"
else:
force = int(round(_bounded("力矩", numbers.get("torque"), 20, 100)))
speed = int(round(_bounded("旋转速度", numbers.get("rot_speed"), 1, 100)))
if kind == "rotate":
angle = _bounded("绝对角", numbers.get("angle"), -32767, 32767)
cli = ["rotate", f"{angle:.3f}"]
summary = f"绝对角 {angle:.3f}° 力矩 {force}% 速度 {speed}%"
elif kind == "rotate_by":
angle = _bounded("相对角", numbers.get("delta"), -32767, 32767)
cli = ["rotate_by", f"{angle:.3f}"]
summary = f"相对转 {angle:.3f}° 力矩 {force}% 速度 {speed}%"
else:
turns = _bounded("圈数", numbers.get("revolutions"), -50, 50)
cli = ["revolutions", f"{turns:.3f}"]
summary = f"转 {turns:.3f} 圈 力矩 {force}% 速度 {speed}%"
except ValueError as exc:
return {"ok": False, "message": str(exc)}
script = ROOT / "scripts" / "run_gripper.sh"
argv = [
"bash", str(script),
"--arm", arm,
"--force", str(force),
"--speed", str(speed),
"--cli", *cli,
]
shown = " ".join(argv)
try:
proc = subprocess.run(argv, cwd=str(ROOT), capture_output=True, text=True, timeout=90)
except subprocess.TimeoutExpired:
return {"ok": False, "message": "夹爪 90 秒还没到位", "detail": shown}
detail = ((proc.stdout or "") + (proc.stderr or "")).strip()
ok = proc.returncode == 0
return {
"ok": ok,
"message": summary if ok else f"夹爪退出码 {proc.returncode}",
"detail": (summary + "\n" + shown + "\n" + detail)[-4000:],
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
return
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, code: int, payload: dict) -> None:
self._send(code, json.dumps(payload, ensure_ascii=False).encode(), "application/json; charset=utf-8")
def do_GET(self) -> None:
path = urlparse(self.path).path
if path in ("/", "/index.html"):
page = (ASSETS / "operator_console.html").read_text(encoding="utf-8")
payload = json.dumps(
{"status": status(), "catalog": catalog()},
ensure_ascii=False,
).replace("<", "\\u003c")
page = page.replace("/*BOOT*/null", payload, 1)
self._send(200, page.encode(), "text/html; charset=utf-8")
return
if path == "/logo.png":
self._send(200, (ASSETS / "operator_console_logo.png").read_bytes(), "image/png")
return
if path == "/api/status":
self._json(200, status())
return
if path == "/api/catalog":
self._json(200, catalog())
return
parts = path.strip("/").split("/")
if len(parts) == 5 and parts[:3] == ["api", "tactile", "frame"] and parts[4].endswith(".jpg"):
side, kind = parts[3], parts[4][:-4]
blob = tactile_view.frame(side, kind) if side in ("left", "right") and kind in ("shear", "thermal") else None
if not blob:
self._json(404, {"ok": False, "message": "还没有这一帧"})
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(blob)))
self.end_headers()
self.wfile.write(blob)
return
self._json(404, {"ok": False, "message": "没有这个路径"})
def do_POST(self) -> None:
path = urlparse(self.path).path
if path not in ("/api/action", "/api/tactile/view"):
self._json(404, {"ok": False, "message": "没有这个路径"})
return
length = int(self.headers.get("Content-Length") or 0)
try:
body = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self._json(400, {"ok": False, "message": "请求不是 JSON"})
return
if path == "/api/tactile/view":
action = "tactile_view_on" if body.get("on") else "tactile_view_off"
result = run_action(action, "89", False)
self._json(200 if result.get("ok") else 400, result)
return
result = run_action(
str(body.get("action") or ""),
str(body.get("arm") or "89"),
bool(body.get("confirm")),
str(body.get("mode") or "eye_in_hand"),
)
self._json(200 if result.get("ok") else 400, result)
def main() -> None:
import atexit
atexit.register(tactile_view.stop)
threading.Thread(target=_poll_tactile, daemon=True).start()
server = ThreadingHTTPServer((HOST, PORT), Handler)
print(f"Orisys 工位界面 http://{HOST}:{PORT}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,353 @@
"""工位界面用的视触觉成像。
切向力是 SDK 的 ``VSHEAR`` 箭头,热成像是 ``DEPTH`` 的 JET 伪彩。
相机独占,所以只有页面点了「开始成像」才打开,失败把原因交回去,不造图。
"""
from __future__ import annotations
import os
import re
import threading
import time
from pathlib import Path
import numpy as np
_LOCK = threading.Lock()
_STOP = threading.Event()
_THREADS: list[threading.Thread] = []
_ON_SAMPLE = None
_STATE = {
"on": False,
"error": "",
"stamp": 0.0,
"note": "",
"frames": {},
}
_JPEG: dict[str, bytes] = {}
def set_sample_hook(fn) -> None:
global _ON_SAMPLE
_ON_SAMPLE = fn
def state() -> dict:
with _LOCK:
return {
"on": _STATE["on"],
"error": _STATE["error"],
"stamp": _STATE["stamp"],
"note": _STATE["note"],
"frames": dict(_STATE["frames"]),
}
def frame(side: str, kind: str) -> bytes | None:
with _LOCK:
return _JPEG.get(f"{side}_{kind}")
def _videos() -> list[str]:
found = []
by_path = Path("/dev/v4l/by-path")
if not by_path.is_dir():
return found
for link in sorted(by_path.iterdir()):
if not link.name.endswith("-video-index0"):
continue
real = os.path.realpath(link)
base = os.path.basename(real)
sysdev = os.path.realpath(f"/sys/class/video4linux/{base}/device")
dev = ""
for part in reversed(sysdev.split("/")):
if re.fullmatch(r"\d+-\d+(\.\d+)*", part):
dev = part
break
if not dev:
continue
vid = Path(f"/sys/bus/usb/devices/{dev}/idVendor")
pid = Path(f"/sys/bus/usb/devices/{dev}/idProduct")
if (
vid.is_file()
and pid.is_file()
and vid.read_text().strip() == "1bcf"
and pid.read_text().strip() == "2281"
):
found.append(str(link))
return found
def _resolve_cfg(site: dict) -> dict:
tactile = site.get("tactile") if isinstance(site.get("tactile"), dict) else {}
left = str(tactile.get("left_video") or "")
right = str(tactile.get("right_video") or "")
if not Path(left).exists() or not Path(right).exists():
found = _videos()
if len(found) >= 2:
left, right = found[0], found[1]
elif len(found) == 1:
left = right = found[0]
return {
"cfg": str(tactile.get("config_file") or ""),
"backend": str(tactile.get("infer_backend") or "cpu"),
"cuda": bool(tactile.get("cuda", False)),
"isstitch": bool(tactile.get("isstitch", True)),
"baseline": float(tactile.get("baseline_seconds") or 1.5),
"left": left,
"right": right,
}
def _as_bgr(img):
if not isinstance(img, np.ndarray) or img.size == 0:
return None
import cv2
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] >= 3:
return np.ascontiguousarray(img[:, :, :3])
return None
def _flat(arr) -> bool:
if not isinstance(arr, np.ndarray) or arr.size == 0 or arr.ndim < 2:
return True
a = arr.reshape(-1, arr.shape[-1]) if arr.ndim == 3 else arr.reshape(-1)
finite = np.isfinite(a)
if not bool(finite.any()):
return True
vals = a[finite]
return float(np.max(vals) - np.min(vals)) < 1e-6
def _thermal(depth):
import cv2
if depth is None:
return None
a = np.asarray(depth)
if a.size == 0:
return None
if a.ndim == 3 and a.shape[2] == 2:
a = np.linalg.norm(a.astype(np.float32), axis=2)
if a.ndim != 2:
return None
finite = np.isfinite(a)
if not finite.any():
return None
vals = a[finite]
lo, hi = float(vals.min()), float(vals.max())
if hi <= lo:
norm = np.zeros(a.shape, np.uint8)
else:
norm = np.clip((a - lo) / (hi - lo) * 255.0, 0, 255).astype(np.uint8)
return cv2.applyColorMap(norm, cv2.COLORMAP_JET)
def _views(sensor, gray):
import cv2
import orisys
bgr = _as_bgr(gray)
h, w = (bgr.shape[0], bgr.shape[1]) if bgr is not None else (450, 420)
try:
sensor.compute_deformation(decompose=True, check_motion=False)
except Exception:
pass
try:
sensor.compute_contact()
except Exception:
pass
try:
vshear, vnormal, depth = sensor.read_info(
sensor.info.VSHEAR, sensor.info.VNORMAL, sensor.info.DEPTH
)
except Exception:
vshear, vnormal, depth = None, None, None
canvas = np.zeros((h, w, 3), np.uint8)
shear = canvas
if isinstance(vshear, np.ndarray) and vshear.ndim == 3 and vshear.shape[2] >= 2 and vshear.size:
field = np.ascontiguousarray(vshear[:, :, :2], dtype=np.float32)
if field.shape[0] != h or field.shape[1] != w:
field = cv2.resize(field, (w, h))
mag = np.linalg.norm(field, axis=2)
peak = float(np.percentile(mag, 99)) if mag.size else 0.0
arrow_scale = (2.0 * peak / 12.0) if peak > 1e-6 else 1.0
try:
shear = orisys.util.draw_arrows(
canvas,
field,
threshold=peak * 0.15 if peak > 1e-6 else 1.0,
grid_spacing=16,
arrow_scale=max(arrow_scale, 1e-6),
below_threshold_color=(40, 40, 40),
)
except Exception:
shear = canvas
heat = None if _flat(depth) else _thermal(depth)
if heat is None:
heat = _thermal(vnormal)
if not isinstance(heat, np.ndarray) or heat.ndim != 3:
heat = np.zeros((h, w, 3), np.uint8)
elif heat.shape[0] != h or heat.shape[1] != w:
heat = cv2.resize(heat, (w, h))
return shear, heat
def _jpeg(img) -> bytes | None:
import cv2
if not isinstance(img, np.ndarray) or img.size == 0:
return None
ok, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 70])
if not ok:
return None
return buf.tobytes()
def _store(side: str, shear, heat, sample: dict | None) -> None:
frames = {}
blobs = {}
for kind, img in (("shear", shear), ("thermal", heat)):
blob = _jpeg(img)
if blob:
blobs[f"{side}_{kind}"] = blob
frames[f"{side}_{kind}"] = True
with _LOCK:
_JPEG.update(blobs)
_STATE["frames"].update(frames)
_STATE["stamp"] = time.time()
if _STATE["error"].startswith(side):
_STATE["error"] = ""
if sample and _ON_SAMPLE is not None:
_ON_SAMPLE(side, sample)
def _open_sensor(cfg: dict, side: str):
import orisys
return orisys.Sensor(
cfg[side],
cuda=cfg["cuda"],
isstitch=cfg["isstitch"],
verbose=False,
config_name=cfg["cfg"],
backend=cfg["backend"],
)
def _worker(side: str, cfg: dict) -> None:
if not cfg.get(side):
with _LOCK:
_STATE["error"] = f"{side} 没有视频节点"
return
sensor = None
try:
sensor = _open_sensor(cfg, side)
except Exception as exc:
with _LOCK:
prev = _STATE["error"]
_STATE["error"] = (prev + ";" if prev else "") + f"{side} 打开失败:{exc}"
return
buckets = {"n": [], "x": [], "y": []}
deadline = time.perf_counter() + float(cfg["baseline"])
while time.perf_counter() < deadline and not _STOP.is_set():
try:
img = sensor.get_img()
sensor.compute_deformation(check_motion=False)
n, x, y = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
buckets["n"].append(float(n))
buckets["x"].append(float(x))
buckets["y"].append(float(y))
except Exception:
pass
time.sleep(0.02)
base = {
"n": float(np.mean(buckets["n"])) if buckets["n"] else 0.0,
"x": float(np.mean(buckets["x"])) if buckets["x"] else 0.0,
"y": float(np.mean(buckets["y"])) if buckets["y"] else 0.0,
}
with _LOCK:
base_note = "曲线已减去打开后短暂基线,单位仍是形变读数。"
if "右指" in str(_STATE["note"]):
_STATE["note"] = _STATE["note"] + " " + base_note
else:
_STATE["note"] = base_note
last_sample = 0.0
while not _STOP.is_set():
try:
gray = sensor.get_img()
except Exception:
gray = None
if gray is None:
time.sleep(0.05)
continue
shear, heat = _views(sensor, gray)
sample = None
now = time.time()
if now - last_sample >= 0.1:
try:
n, x, y = sensor.read_info(
sensor.info.FNORMAL, sensor.info.FSHEARX, sensor.info.FSHEARY
)
sample = {
"t": now,
"fnormal": float(n) - base["n"],
"fshearx": float(x) - base["x"],
"fsheary": float(y) - base["y"],
}
last_sample = now
except Exception:
sample = None
_store(side, shear, heat, sample)
time.sleep(0.04)
try:
sensor.disconnect()
except Exception:
pass
def start(site: dict) -> str:
global _THREADS
with _LOCK:
if _STATE["on"]:
return "成像已经开着"
_STATE["on"] = True
_STATE["error"] = ""
_STATE["frames"] = {}
_STOP.clear()
cfg = _resolve_cfg(site)
if not cfg["left"] and not cfg["right"]:
with _LOCK:
_STATE["on"] = False
_STATE["error"] = "没有找到 Orisys 相机"
return _STATE["error"]
sides = ["left"]
if cfg.get("right") and cfg.get("right") != cfg.get("left"):
sides.append("right")
elif not cfg.get("right") or cfg.get("right") == cfg.get("left"):
with _LOCK:
_STATE["note"] = "只找到一路 Orisys,右指没有画面。插上后重新开始成像。"
_THREADS = [
threading.Thread(target=_worker, args=(side, cfg), daemon=True, name=f"tac-{side}")
for side in sides
]
for thread in _THREADS:
thread.start()
return "正在打开两只视触觉。切向力和热成像出来之前会先采一小段基线。"
def stop() -> str:
_STOP.set()
for thread in list(_THREADS):
thread.join(timeout=2.0)
with _LOCK:
_STATE["on"] = False
_STATE["frames"] = {}
_JPEG.clear()
return "已停止视触觉成像"
@@ -0,0 +1,203 @@
"""软拖拽示教:开拖拽、记下当前关节角、再回到该点。
点位写在 ``handeye/tmp/<臂号>/operator_teach.yaml``。关节角单位是弧度,
直接存桥接返回的 7 个数,不另做姿态换算。
软拖拽占用 RDK,和 ``run_real.sh`` 不能同时连同一台臂。
"""
from __future__ import annotations
import threading
from datetime import datetime, timezone
from pathlib import Path
import yaml
_LOCK = threading.Lock()
_BRIDGE = None
_SN = ""
def _file(arm: str) -> Path:
from arm_application.operator_console import ROOT
if arm not in ("89", "91"):
raise ValueError("臂号只接受 89 或 91")
return ROOT / "handeye" / "tmp" / arm / "operator_teach.yaml"
def _bridge_for(sn: str):
global _BRIDGE, _SN
sn = str(sn or "").strip()
if not sn:
raise RuntimeError("site.yaml 里这台臂没有序列号,拒绝连接")
if _BRIDGE is not None and _SN == sn and _BRIDGE._proc.poll() is None:
return _BRIDGE
close()
from arm_application.handeye_teach import RdkBridge
_BRIDGE = RdkBridge(sn)
_SN = sn
return _BRIDGE
def close() -> None:
global _BRIDGE, _SN
bridge = _BRIDGE
_BRIDGE = None
_SN = ""
if bridge is None:
return
try:
bridge.close()
except Exception:
pass
def drag_on(sn: str) -> dict:
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge.drag_on(mode="soft")
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
return {
"ok": True,
"message": "软拖拽已开启。轻推机械臂,再记点。这一步要先停掉控制栈。",
}
def drag_off(sn: str) -> dict:
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge.drag_off()
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
return {"ok": True, "message": "软拖拽已关闭,臂保持当前位置。"}
def list_points(arm: str) -> dict:
try:
path = _file(arm)
except ValueError as exc:
return {"ok": False, "message": str(exc), "points": []}
if not path.is_file():
return {"ok": True, "points": [], "file": str(path)}
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
return {"ok": False, "message": f"示教文件读不了:{exc}", "points": [], "file": str(path)}
points = []
for row in data.get("points") or []:
if not isinstance(row, dict):
continue
joints = row.get("joints")
if not isinstance(joints, list) or len(joints) < 7:
continue
try:
values = [float(v) for v in joints[:7]]
except (TypeError, ValueError):
continue
if any(v != v for v in values):
continue
points.append({
"name": str(row.get("name") or "").strip(),
"utc": str(row.get("utc") or ""),
"joints": values,
})
points = [row for row in points if row["name"]]
return {"ok": True, "points": points, "file": str(path)}
def _write(arm: str, points: list[dict]) -> Path:
path = _file(arm)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
yaml.safe_dump({"points": points}, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
return path
def record(sn: str, arm: str, name: str) -> dict:
title = str(name or "").strip()
if not title or "/" in title or "\\" in title or title in (".", ".."):
return {"ok": False, "message": "点名不能为空,也不能带路径"}
with _LOCK:
try:
bridge = _bridge_for(sn)
result = bridge._ask({"cmd": "joints"}, timeout=2.0)
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or "没有读到关节,点位没写下去")}
raw = result.get("q") or []
if len(raw) < 7:
return {"ok": False, "message": "关节数不够 7 个,点位没写下去"}
try:
values = [round(float(v), 6) for v in raw[:7]]
except (TypeError, ValueError):
return {"ok": False, "message": "关节角不是数字,点位没写下去"}
if any(v != v for v in values):
return {"ok": False, "message": "关节角不是有限值,点位没写下去"}
listed = list_points(arm)
if not listed.get("ok"):
return listed
points = [row for row in listed["points"] if row["name"] != title]
points.append({
"name": title,
"utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"joints": values,
})
path = _write(arm, points)
text = " ".join(f"{v:.4f}" for v in values)
return {"ok": True, "message": f"已记录 {title}", "detail": f"{path}\n关节 rad:{text}", "points": points}
def delete_point(arm: str, name: str) -> dict:
title = str(name or "").strip()
listed = list_points(arm)
if not listed.get("ok"):
return listed
kept = [row for row in listed["points"] if row["name"] != title]
if len(kept) == len(listed["points"]):
return {"ok": False, "message": f"没有名为 {title} 的点"}
path = _write(arm, kept)
return {"ok": True, "message": f"已删除 {title}", "detail": str(path), "points": kept}
def goto(sn: str, arm: str, name: str, speed: float) -> dict:
title = str(name or "").strip()
listed = list_points(arm)
if not listed.get("ok"):
return listed
hit = next((row for row in listed["points"] if row["name"] == title), None)
if hit is None:
return {"ok": False, "message": f"没有名为 {title} 的点"}
try:
speed_f = float(speed)
except (TypeError, ValueError):
return {"ok": False, "message": "关节速度不是数字"}
if not 0.05 <= speed_f <= 0.35:
return {"ok": False, "message": "关节速度只接受 0.05–0.35 rad/s"}
with _LOCK:
try:
bridge = _bridge_for(sn)
bridge.drag_off()
result = bridge.move_joints(hit["joints"], speed_f)
except Exception as exc:
return {"ok": False, "message": str(exc)}
if not result.get("ok"):
return {"ok": False, "message": str(result.get("error") or result)}
text = " ".join(f"{float(v):.4f}" for v in hit["joints"])
return {
"ok": True,
"message": f"已回到 {title}",
"detail": f"速度 {speed_f:.2f} rad/s\n关节 rad:{text}",
}