Files
zhidian/client/session_client.py
T
2026-10-22 09:07:39 +08:00

573 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
session_client.py — 电脑端:一次运行 = 一个会话。
流程全自动,不再手动 scp 图片/坐标:
1. 启动 -> hello(握手)-> capture(板子抓帧,只回传图像)
2. 图像显示在窗口里;鼠标点一个像素
3. 只把 (u, v) 发给板子;板子用本地缓存的同帧点云算
p_cam / p_lidar / p_map,并把 p_map 记成目标(点云不出板)
4. 你移动板子,界面每 200 ms 问一次 status,实时显示 Δ 坐标(只给坐标,不给距离)
需要电脑端算(要看候选点/平面拟合细节、离线复盘)时:
python session_client.py --board 192.168.43.50 --compute pc
这时才会把同帧点云拉回电脑,算完把 p_map 发回板子。
用法(电脑):
python session_client.py --board 192.168.43.50
python session_client.py --board 192.168.43.50 --radius 12
python session_client.py --board 192.168.43.50 --no-gui --u 800 --v 648 --watch
依赖:pynng、numpy、Pillow(Tkinter 随 Python 自带)
"""
import argparse
import csv
import io
import os
import socket
import time
import zlib
import numpy as np
from PIL import Image, ImageTk
import tkinter as tk
import pynng
import target_math
import wire
MAX_W, MAX_H = 1100, 800
# --------------------------------------------------------------------- 网络
def detect_local_ip(board_ip, port):
"""问内核:去板子的路由走哪张网卡,返回本机在这个网段上的 IP。"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect((board_ip, port)) # UDP connect 不发包,只选路
return s.getsockname()[0]
finally:
s.close()
class BoardClient:
def __init__(self, board_ip, port, timeout_ms=8000):
self.board_ip = board_ip
self.port = int(port)
self.client_ip = detect_local_ip(board_ip, self.port)
self.session = wire.new_session(board_ip, self.client_ip)
self.sock = pynng.Req0(dial='tcp://%s:%d' % (board_ip, self.port))
self.sock.recv_max_size = 32 * 1024 * 1024
self.sock.send_timeout = 5000
self.sock.recv_timeout = int(timeout_ms)
def request(self, typ, blob=b'', **fields):
msg = {'session': self.session, 'type': typ, 'ts_ns': time.time_ns()}
msg.update(fields)
self.sock.send(wire.pack(msg, blob))
hdr, out_blob = wire.unpack(self.sock.recv())
wire.check_session(hdr, self.session)
return hdr, out_blob
def new_session(self, note=''):
"""开始一个新会话(新的 id,仍然带同一个 client_ip / board_ip)。"""
self.session = wire.new_session(self.board_ip, self.client_ip, note)
return self.session
def close(self):
try:
self.sock.close()
except Exception:
pass
# --------------------------------------------------------------------- 计算
def quat_to_rot(q):
x, y, z, w = [float(v) for v in q]
return np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
], dtype=np.float64)
def decode_cloud(hdr, blob):
if not hdr.get('ok'):
raise RuntimeError(hdr.get('error', 'cloud 请求失败'))
if hdr.get('encoding') != 'zlib_f32':
raise RuntimeError('未知点云编码: %s' % hdr.get('encoding'))
raw = zlib.decompress(blob)
n = int(hdr.get('n_points', 0))
return np.frombuffer(raw, dtype='<f4').reshape(n, 3)
def compute_target(xyz, odom, u, v, radius, fit_plane, index=None):
"""像素 -> (locate 结果, map 系坐标)。u/v 用标定分辨率(1600x1296)。"""
if index is not None:
res = index.locate(u, v, radius=radius, fit_plane=fit_plane)
else:
res = target_math.locate(xyz, u, v, radius=radius, fit_plane=fit_plane)
p_map = None
if res['found'] and odom:
R = quat_to_rot(odom['orientation_xyzw'])
t = np.asarray(odom['position'], dtype=np.float64)
p_map = R @ np.asarray(res['p_lidar'], dtype=np.float64) + t
return res, p_map
def fmt3(p):
return '(%.4f, %.4f, %.4f)' % (float(p[0]), float(p[1]), float(p[2]))
def result_text(res, p_map, u, v):
if not res['found']:
return ('像素(标定系): (%.1f, %.1f)\n\n'
'未找到点云(候选 %d)\n'
'可能:无回波 / 超出视场 / 半径太小\n'
'试试把半径调大(如 15~20),或点平整、点云密的区域'
% (u, v, res.get('n_cand', 0)))
lines = [
'像素(标定系): (%.1f, %.1f)' % (u, v),
'命中像素: (%.1f, %.1f) 偏离 %.2f px' % (res['hit_u'], res['hit_v'], res['px_err']),
'候选数: %d' % res['n_cand'],
'平面拟合: %s' % ('是' if res['plane_used'] else '否'),
]
if res.get('multi_layer'):
lines.append('! 候选跨多个深度层,可能擦过边缘')
if res.get('confidence') is not None:
lines.append('置信度: %.0f' % res['confidence'])
lines += [
'',
'p_cam(相机系) : %s' % fmt3(res['p_cam']),
'p_lidar(雷达系): %s' % fmt3(res['p_lidar']),
]
if p_map is not None:
lines.append('★ p_map(地图系): %s' % fmt3(p_map))
lines.append(' (map 原点 = 建图初始位置)')
else:
lines.append('p_map: 不可用(本帧没有 SLAM 位姿)')
return '\n'.join(lines)
# --------------------------------------------------------------------- 界面
class App:
def __init__(self, root, cli, args):
self.root = root
self.cli = cli
self.args = args
self.image = None
self.img_w = self.img_h = 0
self.calib_w = self.calib_h = 0
self.scale = 1.0
self.xyz = None
self.index = None
self.capture_odom = None
self.target = None
self.poll_job = None
self.compute_mode = args.compute
try:
hello, _ = self.cli.request('hello')
if not hello.get('board_can_compute'):
if self.compute_mode == 'board':
print('板子不能本地计算,自动切到 --compute pc')
self.compute_mode = 'pc'
except Exception as exc:
print('hello 失败: %s' % exc)
self._build_ui()
self.capture()
# ---------------------------------------------------------------- UI
def _build_ui(self):
self.root.title('灵枢 · 会话式精度测试')
left = tk.Frame(self.root)
left.pack(side=tk.LEFT, padx=8, pady=8)
right = tk.Frame(self.root)
right.pack(side=tk.RIGHT, fill=tk.Y, padx=8, pady=8)
self.canvas = tk.Canvas(left, width=MAX_W, height=MAX_H,
cursor='crosshair', bg='#111')
self.canvas.pack()
self.canvas.bind('<Button-1>', self.on_click)
bar = tk.Frame(right)
bar.pack(anchor='w', fill=tk.X)
tk.Button(bar, text='新会话', command=self.new_session).pack(side=tk.LEFT)
tk.Button(bar, text='重新抓帧', command=self.capture).pack(side=tk.LEFT, padx=6)
tk.Button(bar, text='清除目标', command=self.reset_target).pack(side=tk.LEFT)
pf = tk.Frame(right)
pf.pack(anchor='w', pady=6)
tk.Label(pf, text='候选半径(px):').pack(side=tk.LEFT)
self.radius_var = tk.StringVar(value=str(self.args.radius))
tk.Entry(pf, textvariable=self.radius_var, width=6).pack(side=tk.LEFT)
self.fit_var = tk.BooleanVar(value=not self.args.no_fit)
tk.Checkbutton(pf, text='平面拟合', variable=self.fit_var).pack(side=tk.LEFT, padx=8)
self.text = tk.Text(right, width=58, height=18, font=('Consolas', 10))
self.text.pack(fill=tk.BOTH, expand=True)
tk.Label(right, text='实时位姿 / 误差(只给坐标)',
anchor='w').pack(fill=tk.X, pady=(8, 0))
self.status_var = tk.StringVar(value='等待抓帧…')
tk.Label(right, textvariable=self.status_var, justify='left', anchor='w',
font=('Consolas', 12), fg='#0a7').pack(fill=tk.X)
self.info_var = tk.StringVar(value='')
tk.Label(right, textvariable=self.info_var, justify='left', anchor='w',
font=('Consolas', 9), fg='#666').pack(fill=tk.X, pady=(6, 0))
def set_text(self, s):
self.text.configure(state=tk.NORMAL)
self.text.delete('1.0', tk.END)
self.text.insert(tk.END, s)
self.text.configure(state=tk.DISABLED)
def set_status(self, s):
self.status_var.set(s)
# ---------------------------------------------------------------- 抓帧
def capture(self):
self.cancel_poll()
self.target = None
self.set_status('正在抓帧…')
self.root.update_idletasks()
try:
h, img_blob = self.cli.request('capture')
except Exception as exc:
self.set_status('抓帧失败: %s' % exc)
return
if not h.get('ok'):
self.set_status('抓帧失败: %s' % h.get('error'))
return
try:
im = Image.open(io.BytesIO(img_blob)).convert('RGB')
except Exception as exc:
self.set_status('图像解码失败: %s' % exc)
return
self.image = im
self.img_w, self.img_h = im.size
self.calib_w = int(h.get('calib_width') or self.img_w)
self.calib_h = int(h.get('calib_height') or self.img_h)
# 默认板子端算,点云不出板;只有 --compute pc 才拉同帧点云
self.xyz = None
self.index = None
cloud_err = None
if self.compute_mode == 'pc':
try:
hc, cblob = self.cli.request('cloud')
self.xyz = decode_cloud(hc, cblob)
except Exception as exc:
cloud_err = str(exc)
self.capture_odom = h.get('odom')
# 显示
self.scale = min(MAX_W / self.img_w, MAX_H / self.img_h, 1.0)
disp = im.resize((max(1, int(self.img_w * self.scale)),
max(1, int(self.img_h * self.scale))), Image.LANCZOS)
self.tkimg = ImageTk.PhotoImage(disp)
self.canvas.config(width=disp.width, height=disp.height)
self.canvas.delete('all')
self.canvas.create_image(0, 0, anchor='nw', image=self.tkimg)
odom = self.capture_odom
info = [
'会话 : %s' % self.cli.session['id'][:8],
'链路 : %s -> %s:%d' % (self.cli.client_ip, self.cli.board_ip, self.cli.port),
'图像 : %dx%d(标定 %dx%d)' % (self.img_w, self.img_h,
self.calib_w, self.calib_h),
'计算 : %s' % ('板子端(点云不出板)' if self.compute_mode == 'board'
else '电脑端(已拉回点云)'),
'点云 : %s' % (('%d 点(已拉回)' % self.xyz.shape[0])
if self.xyz is not None
else ('留在板子' if self.compute_mode == 'board'
else '获取失败: %s' % cloud_err)),
'图像/点云: %.2f ms' % float(h.get('stamp_delta_ms') or 0.0),
]
if odom:
info.append('抓帧位姿 : %s' % fmt3(odom['position']))
else:
info.append('抓帧位姿 : 无(板子没给 odom)')
info.append('')
info.append('点击图像选择目标点…')
self.set_text('\n'.join(info))
self.info_var.set('会话 %s | %s -> %s' % (
self.cli.session['id'][:8], self.cli.client_ip, self.cli.board_ip))
self.set_status('已抓帧,请点选目标')
# ---------------------------------------------------------------- 点选
def on_click(self, event):
if self.image is None:
self.set_status('还没有图像,先抓帧')
return
if self.compute_mode == 'pc' and self.xyz is None:
self.set_status('点云没拉到,无法本地计算;可改用默认的板子端计算')
return
u_img = event.x / self.scale
v_img = event.y / self.scale
u_cal = u_img * self.calib_w / self.img_w
v_cal = v_img * self.calib_h / self.img_h
try:
radius = float(self.radius_var.get())
except ValueError:
radius = self.args.radius
fit_plane = bool(self.fit_var.get())
if self.compute_mode == 'board':
# 点云不出板:只发像素,板子算完把 p_map 记成目标
try:
res, _ = self.cli.request('click', u=u_cal, v=v_cal,
radius=radius, fit_plane=fit_plane)
except Exception as exc:
self.set_status('点选失败: %s' % exc)
return
p_map = res.get('p_map')
else:
# 电脑端算:先拉点云,本地算,再把 p_map 回传板子
try:
if self.index is None:
self.index = target_math.TargetIndex(self.xyz)
res, p_map = compute_target(self.xyz, self.capture_odom,
u_cal, v_cal, radius, fit_plane,
index=self.index)
except Exception as exc:
self.set_status('计算失败: %s' % exc)
return
if res['found'] and p_map is not None:
try:
h, _ = self.cli.request(
'set_target', u=u_cal, v=v_cal,
p_cam=[float(x) for x in res['p_cam']],
p_lidar=[float(x) for x in res['p_lidar']],
p_map=[float(x) for x in p_map])
except Exception as exc:
self.set_status('回传目标失败: %s' % exc)
return
if not h.get('ok'):
self.set_status('板子拒绝目标: %s' % h.get('error'))
return
self.draw_marker(event.x, event.y)
self.set_text(result_text(res, p_map, u_cal, v_cal))
if not res.get('found'):
self.set_status('没找到点,换个位置或调大半径')
return
if p_map is None:
self.set_status('找到了点,但本帧没有位姿,无法跟踪')
return
self.target = p_map
self.set_status('目标已锁定,移动板子…')
self.log_row(u_cal, v_cal, res, p_map)
self.schedule_poll()
def draw_marker(self, x, y):
self.canvas.delete('marker')
self.canvas.create_oval(x - 7, y - 7, x + 7, y + 7,
outline='#00ff66', width=2, tags='marker')
self.canvas.create_line(x - 15, y, x + 15, y, fill='#00ff66', tags='marker')
self.canvas.create_line(x, y - 15, x, y + 15, fill='#00ff66', tags='marker')
# ---------------------------------------------------------------- 轮询
def schedule_poll(self):
self.cancel_poll()
self.poll_job = self.root.after(self.args.poll_ms, self.poll_status)
def cancel_poll(self):
if self.poll_job is not None:
try:
self.root.after_cancel(self.poll_job)
except Exception:
pass
self.poll_job = None
def poll_status(self):
self.poll_job = None
if self.target is None:
return
try:
h, _ = self.cli.request('status')
except Exception as exc:
self.set_status('status 失败: %s' % exc)
self.schedule_poll()
return
if not h.get('ok') or not h.get('valid'):
self.set_status('等待里程计…(%s)' % h.get('error', '位姿停更'))
else:
pos = h['pos']
lines = ['pos=(%+.4f, %+.4f, %+.4f) yaw=%+.1f°'
% (pos[0], pos[1], pos[2], h.get('yaw_deg', 0.0))]
d = h.get('delta')
if d:
lines.append('Δ=(%+.4f, %+.4f, %+.4f)' % (d[0], d[1], d[2]))
self.set_status('\n'.join(lines))
self.schedule_poll()
# ---------------------------------------------------------------- 其它
def reset_target(self):
self.cancel_poll()
self.target = None
self.set_status('已清除目标')
try:
self.cli.request('reset')
except Exception:
pass
def new_session(self):
"""开一个新会话:新 id + 重新抓帧(每次测试互不干扰)。"""
self.cancel_poll()
self.target = None
self.cli.new_session()
self.capture()
def log_row(self, u, v, res, p_map):
if self.args.no_log:
return
path = self.args.log
new = not os.path.exists(path)
with open(path, 'a', newline='', encoding='utf-8-sig') as f:
w = csv.writer(f)
if new:
w.writerow(['session_id', 'client_ip', 'board_ip',
'u_cal', 'v_cal',
'p_cam_x', 'p_cam_y', 'p_cam_z',
'p_lidar_x', 'p_lidar_y', 'p_lidar_z',
'p_map_x', 'p_map_y', 'p_map_z',
'plane_used', 'n_cand', 'px_err', 'ts_ns'])
w.writerow([
self.cli.session['id'], self.cli.client_ip, self.cli.board_ip,
round(u, 2), round(v, 2)]
+ [round(float(res['p_cam'][i]), 4) for i in range(3)]
+ [round(float(res['p_lidar'][i]), 4) for i in range(3)]
+ [round(float(p_map[i]), 4) for i in range(3)]
+ [int(res['plane_used']), int(res['n_cand']),
round(float(res['px_err']), 2), time.time_ns()])
# --------------------------------------------------------------------- 无界面
def print_status(h):
if not h.get('ok'):
print('status: %s' % h.get('error'))
return
pos = h.get('pos')
line = ('pos=(%+.4f, %+.4f, %+.4f) yaw=%+.1f°'
% (pos[0], pos[1], pos[2], h.get('yaw_deg', 0.0))) if pos else 'pos=无'
if not h.get('valid'):
line += ' [位姿无效]'
d = h.get('delta')
if d:
line += '\nΔ=(%+.4f, %+.4f, %+.4f)' % (d[0], d[1], d[2])
print(line, flush=True)
def run_cli(cli, args):
hello, _ = cli.request('hello')
mode = args.compute
if mode == 'board' and not hello.get('board_can_compute'):
print('板子不能本地计算,自动切到 --compute pc', flush=True)
mode = 'pc'
print('hello: ok=%s node=%s calib=%sx%s compute=%s'
% (hello.get('ok'), hello.get('node'), hello.get('calib_width'),
hello.get('calib_height'), mode), flush=True)
h, img_blob = cli.request('capture')
if not h.get('ok'):
raise SystemExit('capture 失败: %s' % h.get('error'))
print('capture: %d B, %sx%s, 点云 %d 点, 时间差 %.2f ms'
% (len(img_blob), h.get('image_width'), h.get('image_height'),
h.get('n_points', 0), h.get('stamp_delta_ms', 0.0)), flush=True)
if h.get('odom'):
print('抓帧位姿: %s' % fmt3(h['odom']['position']), flush=True)
if mode == 'board':
res, _ = cli.request('click', u=args.u, v=args.v,
radius=args.radius, fit_plane=not args.no_fit)
p_map = res.get('p_map')
print('\n' + result_text(res, p_map, args.u, args.v) + '\n', flush=True)
if not (res.get('found') and p_map is not None):
return
else:
hc, cblob = cli.request('cloud')
xyz = decode_cloud(hc, cblob)
print('cloud: %d 点 (%.0f KB -> %.0f KB)'
% (xyz.shape[0], hc.get('raw_bytes', 0) / 1024.0,
hc.get('zlib_bytes', 0) / 1024.0), flush=True)
idx = target_math.TargetIndex(xyz)
res, p_map = compute_target(xyz, h.get('odom'), args.u, args.v,
args.radius, not args.no_fit, index=idx)
print('\n' + result_text(res, p_map, args.u, args.v) + '\n', flush=True)
if not (res['found'] and p_map is not None):
return
r, _ = cli.request('set_target', u=args.u, v=args.v,
p_cam=[float(x) for x in res['p_cam']],
p_lidar=[float(x) for x in res['p_lidar']],
p_map=[float(x) for x in p_map])
print('set_target: ok=%s target=%s' % (r.get('ok'), r.get('target')), flush=True)
if args.watch:
n = 0
while args.watch_count is None or n < args.watch_count:
st, _ = cli.request('status')
print_status(st)
n += 1
if args.watch_count is None or n < args.watch_count:
time.sleep(args.poll_ms / 1000.0)
# --------------------------------------------------------------------- 入口
def parse_args():
ap = argparse.ArgumentParser(description='会话式精度测试客户端')
ap.add_argument('--board', required=True, help='板子 IP,例如 192.168.43.50')
ap.add_argument('--port', type=int, default=5555)
ap.add_argument('--radius', type=float, default=10.0, help='候选像素半径')
ap.add_argument('--compute', choices=['board', 'pc'], default='board',
help='board=点云不出板(默认);pc=拉点云到电脑算')
ap.add_argument('--no-fit', action='store_true', help='关闭平面拟合')
ap.add_argument('--no-gui', action='store_true')
ap.add_argument('--u', type=float, default=None, help='标定系像素 u(配合 --no-gui)')
ap.add_argument('--v', type=float, default=None, help='标定系像素 v(配合 --no-gui)')
ap.add_argument('--watch', action='store_true', help='点选后持续打印 Δ')
ap.add_argument('--watch-count', type=int, default=None)
ap.add_argument('--poll-ms', type=int, default=200)
ap.add_argument('--timeout-ms', type=int, default=8000)
ap.add_argument('--log', default='session_log.csv')
ap.add_argument('--no-log', action='store_true')
return ap.parse_args()
def main():
args = parse_args()
cli = BoardClient(args.board, args.port, args.timeout_ms)
try:
if args.no_gui:
if args.u is None or args.v is None:
raise SystemExit('--no-gui 需要同时给 --u 和 --v(标定系像素)')
run_cli(cli, args)
else:
root = tk.Tk()
App(root, cli, args)
try:
root.mainloop()
finally:
cli.close()
finally:
cli.close()
if __name__ == '__main__':
main()