initial commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""_bench_index.py — 对比全量投影 locate() 与网格索引 TargetIndex 的耗时。"""
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
import target_math
|
||||
|
||||
|
||||
def bench(fn, n):
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(n):
|
||||
fn()
|
||||
return (time.perf_counter() - t0) / n * 1000.0
|
||||
|
||||
|
||||
def main(path='acc.npz'):
|
||||
data = np.load(path)
|
||||
xyz = data['xyz']
|
||||
conf = data['confidence'] if 'confidence' in data else None
|
||||
valid = np.isfinite(xyz).all(axis=1) & (np.abs(xyz).sum(axis=1) > 1e-6)
|
||||
print('frame: %s N=%d valid=%d (%.1f%%)'
|
||||
% (path, xyz.shape[0], int(valid.sum()), 100.0 * valid.mean()))
|
||||
|
||||
# 找一个确实能命中的像素
|
||||
query = None
|
||||
for u in range(700, 901, 20):
|
||||
for v in range(550, 751, 20):
|
||||
r = target_math.locate(xyz, u, v, conf=conf, radius=10)
|
||||
if r['found']:
|
||||
query = (float(u), float(v), r)
|
||||
break
|
||||
if query:
|
||||
break
|
||||
if query is None:
|
||||
raise SystemExit('没找到可命中的像素')
|
||||
u, v, r0 = query
|
||||
print('query pixel: (%.0f, %.0f) p_lidar=%s plane=%s cand=%d'
|
||||
% (u, v, np.round(r0['p_lidar'], 4), r0['plane_used'], r0['n_cand']))
|
||||
|
||||
n_click = 50
|
||||
t_full = bench(lambda: target_math.locate(xyz, u, v, conf=conf, radius=10), n_click)
|
||||
print('\n全量 locate(): %.3f ms/次(%d 次平均)' % (t_full, n_click))
|
||||
|
||||
for cell in (16, 32, 64):
|
||||
t0 = time.perf_counter()
|
||||
idx = target_math.TargetIndex(xyz, conf=conf, cell=cell)
|
||||
build = (time.perf_counter() - t0) * 1000.0
|
||||
r1 = idx.locate(u, v, radius=10)
|
||||
t_idx = bench(lambda: idx.locate(u, v, radius=10), n_click)
|
||||
same = (r1['found'] == r0['found'])
|
||||
dp = None
|
||||
if r1['found'] and r0['found']:
|
||||
dp = np.linalg.norm(np.asarray(r1['p_lidar']) - np.asarray(r0['p_lidar']))
|
||||
print('cell=%2d px 建索引 %6.2f ms | 每次 %6.3f ms | 桶=%4d | 候选=%d | 结果一致=%s | Δp_lidar=%s'
|
||||
% (cell, build, t_idx, len(idx.buckets), r1['n_cand'], same,
|
||||
'%.2e' % dp if dp is not None else '-'))
|
||||
|
||||
# 模拟同一帧点 10 次(不同像素)
|
||||
rng = np.random.default_rng(0)
|
||||
pixels = [(u + rng.uniform(-60, 60), v + rng.uniform(-60, 60)) for _ in range(10)]
|
||||
|
||||
def ten_full():
|
||||
for uu, vv in pixels:
|
||||
target_math.locate(xyz, uu, vv, conf=conf, radius=10)
|
||||
|
||||
idx = target_math.TargetIndex(xyz, conf=conf, cell=32)
|
||||
idx.locate(u, v) # 预热
|
||||
|
||||
def ten_idx():
|
||||
for uu, vv in pixels:
|
||||
idx.locate(uu, vv, radius=10)
|
||||
|
||||
t10_full = bench(ten_full, 10)
|
||||
t10_idx = bench(ten_idx, 10)
|
||||
print('\n同一帧点 10 次: 全量 %.2f ms | 索引 %.2f ms | 加速 %.1fx'
|
||||
% (t10_full, t10_idx, t10_full / max(t10_idx, 1e-9)))
|
||||
|
||||
# 半径 20 的情况
|
||||
t20_idx = bench(lambda: idx.locate(u, v, radius=20), n_click)
|
||||
print('索引 radius=20: %.3f ms/次' % t20_idx)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else 'acc.npz')
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
_mock_server.py — 仅用于在电脑上测试协议,不需要 ROS。
|
||||
|
||||
它模拟板子端:返回一张合成图像、一片合成点云(3 m 处、像素 800,648 附近),
|
||||
以及一个会随时间变化的位姿,用来验证 session_client 的抓帧、点选、回传、Δ 显示。
|
||||
"""
|
||||
import io
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import pynng
|
||||
|
||||
import calib
|
||||
import target_math
|
||||
import wire
|
||||
|
||||
CAL_W, CAL_H = calib.IMAGE_WIDTH, calib.IMAGE_HEIGHT
|
||||
U0, V0 = 800.0, 648.0
|
||||
DEPTH = 3.0
|
||||
|
||||
|
||||
def build_cloud():
|
||||
pts = []
|
||||
for du in range(-12, 13, 4):
|
||||
for dv in range(-12, 13, 4):
|
||||
ray = calib.pixel_to_ray(U0 + du, V0 + dv)
|
||||
p_cam = ray * DEPTH
|
||||
pts.append(calib.cam_to_lidar(p_cam)[0])
|
||||
pts = np.asarray(pts, dtype=np.float32)
|
||||
# 加一些无效点,模拟真实点云的零值
|
||||
zeros = np.zeros((97, 3), dtype=np.float32)
|
||||
return np.concatenate([pts, zeros], axis=0)
|
||||
|
||||
|
||||
def build_image():
|
||||
arr = np.zeros((CAL_H, CAL_W, 3), dtype=np.uint8)
|
||||
arr[:, :, 2] = 40
|
||||
u, v = int(round(U0)), int(round(V0))
|
||||
arr[v - 6:v + 6, u - 6:u + 6] = (255, 255, 255)
|
||||
b = io.BytesIO()
|
||||
Image.fromarray(arr).save(b, format='JPEG', quality=85)
|
||||
return b.getvalue()
|
||||
|
||||
|
||||
class MockServer:
|
||||
def __init__(self, port=5599):
|
||||
self.port = port
|
||||
self.xyz = build_cloud()
|
||||
self.image = build_image()
|
||||
self.target = None
|
||||
self.index = None
|
||||
self.sessions = {}
|
||||
self.stop_event = threading.Event()
|
||||
self.thread = None
|
||||
|
||||
def start(self):
|
||||
self.thread = threading.Thread(target=self._serve, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
def _odom(self):
|
||||
return {
|
||||
'stamp_ns': time.time_ns(),
|
||||
'position': [0.0, 0.0, 0.0],
|
||||
'orientation_xyzw': [0.0, 0.0, 0.0, 1.0],
|
||||
'frame_id': 'odom',
|
||||
'child_frame_id': 'imu',
|
||||
'delta_ms': 1.0,
|
||||
}
|
||||
|
||||
def _handle(self, hdr):
|
||||
typ = hdr.get('type')
|
||||
if typ == 'hello':
|
||||
return {'ok': True, 'type': 'hello', 'node': 'mock',
|
||||
'calib_width': CAL_W, 'calib_height': CAL_H,
|
||||
'board_can_compute': True,
|
||||
'compute_modes': ['board', 'pc'],
|
||||
'topics': {'image': 'mock', 'cloud': 'mock', 'odom': 'mock'}}, b''
|
||||
if typ == 'capture':
|
||||
sid = (hdr.get('session') or {}).get('id')
|
||||
self.sessions[sid] = {'target': None}
|
||||
return {'ok': True, 'type': 'frame', 'image_format': 'jpeg',
|
||||
'image_bytes': len(self.image),
|
||||
'image_width': CAL_W, 'image_height': CAL_H,
|
||||
'calib_width': CAL_W, 'calib_height': CAL_H,
|
||||
'image_stamp_ns': time.time_ns(),
|
||||
'cloud_stamp_ns': time.time_ns(),
|
||||
'stamp_delta_ms': 1.2,
|
||||
'n_points': int(self.xyz.shape[0]),
|
||||
'board_can_compute': True,
|
||||
'odom': self._odom()}, self.image
|
||||
if typ == 'click':
|
||||
sid = (hdr.get('session') or {}).get('id')
|
||||
s = self.sessions.get(sid)
|
||||
if s is None:
|
||||
return {'ok': False, 'type': 'target', 'error': '先 capture'}, b''
|
||||
if self.index is None:
|
||||
self.index = target_math.TargetIndex(self.xyz)
|
||||
u = float(hdr['u'])
|
||||
v = float(hdr['v'])
|
||||
res = self.index.locate(u, v,
|
||||
radius=float(hdr.get('radius', 10.0)),
|
||||
fit_plane=bool(hdr.get('fit_plane', True)))
|
||||
out = {'ok': True, 'type': 'target', 'found': bool(res['found']),
|
||||
'u': u, 'v': v, 'n_cand': int(res['n_cand']),
|
||||
'plane_used': bool(res['plane_used']),
|
||||
'multi_layer': bool(res['multi_layer'])}
|
||||
if res['found']:
|
||||
out.update({
|
||||
'p_cam': [float(x) for x in res['p_cam']],
|
||||
'p_lidar': [float(x) for x in res['p_lidar']],
|
||||
'hit_u': float(res['hit_u']), 'hit_v': float(res['hit_v']),
|
||||
'px_err': float(res['px_err']),
|
||||
'confidence': None,
|
||||
})
|
||||
pm = np.asarray(res['p_lidar'], dtype=np.float64)
|
||||
out['p_map'] = [float(x) for x in pm]
|
||||
out['odom'] = self._odom()
|
||||
s['target'] = pm
|
||||
return out, b''
|
||||
if typ == 'cloud':
|
||||
body = zlib.compress(self.xyz.astype('<f4').tobytes(), 1)
|
||||
return {'ok': True, 'type': 'cloud', 'encoding': 'zlib_f32',
|
||||
'n_points': int(self.xyz.shape[0]),
|
||||
'shape': [int(self.xyz.shape[0]), 3],
|
||||
'raw_bytes': int(self.xyz.nbytes),
|
||||
'zlib_bytes': len(body)}, body
|
||||
if typ == 'set_target':
|
||||
sid = (hdr.get('session') or {}).get('id')
|
||||
s = self.sessions.get(sid)
|
||||
if s is None:
|
||||
return {'ok': False, 'type': 'target', 'error': '先 capture'}, b''
|
||||
pm = hdr.get('p_map')
|
||||
if not pm or len(pm) != 3:
|
||||
return {'ok': False, 'type': 'target', 'error': 'p_map 需要 3 个数'}, b''
|
||||
s['target'] = np.asarray(pm, dtype=np.float64)
|
||||
return {'ok': True, 'type': 'target',
|
||||
'target': [float(v) for v in s['target']]}, b''
|
||||
if typ == 'status':
|
||||
sid = (hdr.get('session') or {}).get('id')
|
||||
s = self.sessions.get(sid)
|
||||
target = None if s is None else s.get('target')
|
||||
out = {'ok': True, 'type': 'status',
|
||||
'has_target': target is not None,
|
||||
'valid': True, 'yaw_deg': 0.0, 'odom_age_s': 0.01,
|
||||
'odom_frame': 'odom', 'odom_child': 'imu'}
|
||||
# 模拟:人把板子往前多走了 0.25 m
|
||||
pos = np.array([0.2507, -0.0209, 0.0144])
|
||||
out['pos'] = [float(v) for v in pos]
|
||||
if target is not None:
|
||||
d = pos - target
|
||||
out['target'] = [float(v) for v in target]
|
||||
out['delta'] = [float(v) for v in d]
|
||||
return out, b''
|
||||
if typ == 'reset':
|
||||
sid = (hdr.get('session') or {}).get('id')
|
||||
s = self.sessions.get(sid)
|
||||
if s is not None:
|
||||
s['target'] = None
|
||||
return {'ok': True, 'type': 'reset'}, b''
|
||||
return {'ok': False, 'type': typ, 'error': 'unknown type'}, b''
|
||||
|
||||
def _serve(self):
|
||||
with pynng.Rep0(listen='tcp://127.0.0.1:%d' % self.port,
|
||||
recv_timeout=200) as rep:
|
||||
rep.recv_max_size = 32 * 1024 * 1024
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
data = rep.recv()
|
||||
except pynng.Timeout:
|
||||
continue
|
||||
except pynng.Closed:
|
||||
break
|
||||
hdr, _ = wire.unpack(data)
|
||||
reply, blob = self._handle(hdr)
|
||||
reply['session'] = hdr.get('session') or {}
|
||||
reply['server_ns'] = time.time_ns()
|
||||
rep.send(wire.pack(reply, blob))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
srv = MockServer()
|
||||
srv.start()
|
||||
print('mock server on 127.0.0.1:5599 (Ctrl+C 退出)')
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
srv.stop()
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
calib.py — Odin1 的相机/雷达标定参数与 FishPoly 鱼眼相机模型。
|
||||
|
||||
公式来源:odin_ros_driver/src/pointcloud_depth_converter.cpp
|
||||
createDistortionMaps()(厂商逐像素实现),这里改写成向量化的连续函数。
|
||||
|
||||
坐标约定(已由驱动源码确认):
|
||||
Tcl_0 把【雷达系】的点变到【相机系】:p_cam = R · p_lidar + t
|
||||
(证据:驱动里 Kcl_ = K * Tcl,然后 transformPointCloud(cloud, cloud_in_cam, Kcl_))
|
||||
|
||||
相机模型(FishPoly):
|
||||
归一化针孔坐标 (x, y) -> r = sqrt(x²+y²) -> θ = atan(r)
|
||||
θ_d = θ + k2θ² + k3θ³ + k4θ⁴ + k5θ⁵ + k6θ⁶ + k7θ⁷
|
||||
畸变后的归一化坐标 = (x/r, y/r) * θ_d
|
||||
像素 = (A11·x_d + A12·y_d + u0, A22·y_d + v0)
|
||||
|
||||
注意:这里所有"像素"都指**原始(未去畸变)图像**的像素,也就是 /odin1/image。
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# ---------------- calib.yaml(设备 #O1-N090100083) ----------------
|
||||
A11 = 730.51652555414909
|
||||
A12 = -0.68917984297115186
|
||||
A22 = 730.30384140813805
|
||||
U0 = 785.10214573595919
|
||||
V0 = 670.33088344368650
|
||||
|
||||
K2 = -8.2510665959742261e-03
|
||||
K3 = 1.6324575756549702e-02
|
||||
K4 = -5.6837005957061821e-02
|
||||
K5 = 4.9991959834060670e-02
|
||||
K6 = -2.4463726125325536e-02
|
||||
K7 = 3.8528646040351232e-03
|
||||
|
||||
IMAGE_WIDTH = 1600
|
||||
IMAGE_HEIGHT = 1296
|
||||
MAX_INCIDENT_DEG = 120.0
|
||||
|
||||
# Tcl_0:雷达 -> 相机
|
||||
TCL = np.array([
|
||||
[-0.00177, -0.99998, 0.00586, 0.03809],
|
||||
[ 0.00744, -0.00588, -0.99996, -0.00189],
|
||||
[ 0.99997, -0.00173, 0.00745, -0.01966],
|
||||
[ 0.0, 0.0, 0.0, 1.0 ],
|
||||
], dtype=np.float64)
|
||||
R_CAM_LIDAR = TCL[:3, :3]
|
||||
T_CAM_LIDAR = TCL[:3, 3]
|
||||
|
||||
# 安全上限:θ 接近 90° 时 tan(θ) 发散。
|
||||
# 这颗镜头水平半视角约 63°,图像四角可到 ~87°,所以上限放到 89°。
|
||||
_THETA_MAX = np.deg2rad(89.0)
|
||||
|
||||
|
||||
def theta_d(theta):
|
||||
"""把入射角 θ 映射成畸变后的归一化半径。"""
|
||||
th = np.asarray(theta, dtype=np.float64)
|
||||
return (th + K2 * th**2 + K3 * th**3 + K4 * th**4
|
||||
+ K5 * th**5 + K6 * th**6 + K7 * th**7)
|
||||
|
||||
|
||||
def dtheta_d(theta):
|
||||
"""θ_d 对 θ 的导数(牛顿迭代用)。"""
|
||||
th = np.asarray(theta, dtype=np.float64)
|
||||
return (1.0 + 2 * K2 * th + 3 * K3 * th**2 + 4 * K4 * th**3
|
||||
+ 5 * K5 * th**4 + 6 * K6 * th**5 + 7 * K7 * th**6)
|
||||
|
||||
|
||||
def solve_theta(rho_d, iters=30):
|
||||
"""反解:给定畸变归一化半径 rho_d,求入射角 θ。"""
|
||||
rho_d = np.asarray(rho_d, dtype=np.float64)
|
||||
th = np.minimum(rho_d, _THETA_MAX).astype(np.float64)
|
||||
for _ in range(iters):
|
||||
f = theta_d(th) - rho_d
|
||||
fp = dtheta_d(th)
|
||||
fp = np.where(np.abs(fp) < 1e-12, 1e-12, fp)
|
||||
step = f / fp
|
||||
th = np.clip(th - step, 0.0, _THETA_MAX)
|
||||
if np.all(np.abs(step) < 1e-12):
|
||||
break
|
||||
return th
|
||||
|
||||
|
||||
def pixel_to_ray(u, v):
|
||||
"""原始(畸变)图像像素 -> 相机系下的单位方向向量。
|
||||
|
||||
标量输入返回 (3,),数组输入返回 (N,3)。
|
||||
"""
|
||||
u = np.asarray(u, dtype=np.float64)
|
||||
v = np.asarray(v, dtype=np.float64)
|
||||
yd = (v - V0) / A22
|
||||
xd = (u - U0 - A12 * yd) / A11
|
||||
|
||||
rho = np.hypot(xd, yd)
|
||||
small = rho < 1e-9
|
||||
rho_safe = np.where(small, 1.0, rho)
|
||||
|
||||
theta = solve_theta(rho)
|
||||
r = np.tan(theta)
|
||||
|
||||
x = xd * (r / rho_safe)
|
||||
y = yd * (r / rho_safe)
|
||||
d = np.stack([x, y, np.ones_like(x)], axis=-1)
|
||||
d = d / np.linalg.norm(d, axis=-1, keepdims=True)
|
||||
return d
|
||||
|
||||
|
||||
def project_cam_to_pixel(p_cam, margin=0.0):
|
||||
"""相机系点 (N,3) -> 原始图像像素 (u, v, valid)。"""
|
||||
p = np.atleast_2d(np.asarray(p_cam, dtype=np.float64))
|
||||
Z = p[:, 2]
|
||||
valid = np.isfinite(p).all(axis=1) & (Z > 1e-6)
|
||||
Zs = np.where(valid, Z, 1.0)
|
||||
|
||||
x = p[:, 0] / Zs
|
||||
y = p[:, 1] / Zs
|
||||
r = np.hypot(x, y)
|
||||
theta = np.arctan(r)
|
||||
thd = theta_d(theta)
|
||||
|
||||
s = np.where(r > 1e-9, thd / np.where(r > 1e-9, r, 1.0), 1.0)
|
||||
xd = x * s
|
||||
yd = y * s
|
||||
|
||||
u = A11 * xd + A12 * yd + U0
|
||||
v = A22 * yd + V0
|
||||
valid &= np.isfinite(u) & np.isfinite(v)
|
||||
valid &= (u >= -margin) & (u <= IMAGE_WIDTH - 1 + margin)
|
||||
valid &= (v >= -margin) & (v <= IMAGE_HEIGHT - 1 + margin)
|
||||
return u, v, valid
|
||||
|
||||
|
||||
def lidar_to_cam(pts):
|
||||
"""雷达系点 (N,3) -> 相机系点 (N,3)。"""
|
||||
pts = np.atleast_2d(np.asarray(pts, dtype=np.float64))
|
||||
return pts @ R_CAM_LIDAR.T + T_CAM_LIDAR
|
||||
|
||||
|
||||
def cam_to_lidar(pts):
|
||||
"""相机系点 (N,3) -> 雷达系点 (N,3)。"""
|
||||
pts = np.atleast_2d(np.asarray(pts, dtype=np.float64))
|
||||
return (pts - T_CAM_LIDAR) @ R_CAM_LIDAR
|
||||
|
||||
|
||||
def cam_to_base_link(pts, cam_xyz=(0.0, 0.0, 0.0), rpy_deg=(0.0, 0.0, 0.0)):
|
||||
"""相机系 -> 车体系。
|
||||
|
||||
坐标约定:
|
||||
相机系:x 右, y 下, z 前
|
||||
车体系:x 前, y 左, z 上(ROS REP-103, base_link)
|
||||
|
||||
参数:
|
||||
cam_xyz : 相机光心在车体系下的位置 (m),例如 (0.1, 0.0, 0.3)
|
||||
rpy_deg : 相机相对车体的姿态 (roll, pitch, yaw),单位度
|
||||
pitch > 0 表示镜头向下俯(常见安装)
|
||||
"""
|
||||
pts = np.atleast_2d(np.asarray(pts, dtype=np.float64))
|
||||
R = _base_from_cam_rotation(rpy_deg)
|
||||
return pts @ R.T + np.asarray(cam_xyz, dtype=np.float64)
|
||||
|
||||
|
||||
def base_link_to_cam(pts, cam_xyz=(0.0, 0.0, 0.0), rpy_deg=(0.0, 0.0, 0.0)):
|
||||
"""车体系 -> 相机系(cam_to_base_link 的逆)。"""
|
||||
pts = np.atleast_2d(np.asarray(pts, dtype=np.float64))
|
||||
R = _base_from_cam_rotation(rpy_deg)
|
||||
return (pts - np.asarray(cam_xyz, dtype=np.float64)) @ R
|
||||
|
||||
|
||||
def _base_from_cam_rotation(rpy_deg=(0.0, 0.0, 0.0)):
|
||||
"""构造 R_base_cam。零姿态时:相机 z(前)->车体 x(前),x(右)->车体 -y,y(下)->车体 -z。"""
|
||||
roll, pitch, yaw = np.deg2rad(rpy_deg)
|
||||
Rx = np.array([[1, 0, 0],
|
||||
[0, np.cos(roll), -np.sin(roll)],
|
||||
[0, np.sin(roll), np.cos(roll)]])
|
||||
Ry = np.array([[np.cos(pitch), 0, np.sin(pitch)],
|
||||
[0, 1, 0],
|
||||
[-np.sin(pitch), 0, np.cos(pitch)]])
|
||||
Rz = np.array([[np.cos(yaw), -np.sin(yaw), 0],
|
||||
[np.sin(yaw), np.cos(yaw), 0],
|
||||
[0, 0, 1]])
|
||||
R0 = np.array([[0.0, 0.0, 1.0],
|
||||
[-1.0, 0.0, 0.0],
|
||||
[0.0, -1.0, 0.0]])
|
||||
return Rz @ Ry @ Rx @ R0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 往返一致性自测:像素 -> 射线 -> 投回像素
|
||||
print('Tcl determinant (should be +1):', np.linalg.det(R_CAM_LIDAR))
|
||||
print('camera position in lidar frame (m):', cam_to_lidar(np.zeros((1, 3)))[0])
|
||||
print()
|
||||
for u, v in [(800, 648), (100, 100), (1500, 1200), (400, 900), (1599, 0)]:
|
||||
d = pixel_to_ray(u, v)
|
||||
p = d * 3.0
|
||||
u2, v2, ok = project_cam_to_pixel(p)
|
||||
print('pixel (%4d,%4d) -> ray %s -> back (%8.2f,%8.2f) valid=%s err=%.2e'
|
||||
% (u, v, np.round(d, 4), u2[0], v2[0], ok[0],
|
||||
np.hypot(u2[0] - u, v2[0] - v)))
|
||||
@@ -0,0 +1,7 @@
|
||||
# 电脑端(Windows / Linux)
|
||||
pynng>=0.9.0
|
||||
numpy>=1.26
|
||||
Pillow>=10.0
|
||||
|
||||
# Tkinter 随 Python 自带(Windows 安装包默认包含)。
|
||||
# Linux 如果缺:sudo apt install python3-tk
|
||||
@@ -0,0 +1,572 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
target_math.py — 纯 numpy 的"像素 -> 三维目标"定位核心(不依赖 ROS,方便离线测试)。
|
||||
|
||||
输入:一帧点云(雷达系)+ 查询像素
|
||||
输出:目标在相机系/雷达系下的坐标、距离、置信度、是否有多个深度层等
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
import calib
|
||||
|
||||
|
||||
def _fit_plane_ray(P, ray, max_residual=0.05):
|
||||
"""PCA 拟合平面,再让射线与平面求交。返回 (点, 残差)。"""
|
||||
if P.shape[0] < 6:
|
||||
return None, None
|
||||
c = P.mean(axis=0)
|
||||
_, _, vt = np.linalg.svd(P - c)
|
||||
n = vt[2]
|
||||
denom = float(n @ ray)
|
||||
if abs(denom) < 1e-6:
|
||||
return None, None
|
||||
lam = float(n @ c) / denom
|
||||
if lam <= 0:
|
||||
return None, None
|
||||
p = lam * ray
|
||||
residual = float(np.sqrt(np.mean(((P - c) @ n) ** 2)))
|
||||
if residual > max_residual:
|
||||
return None, residual
|
||||
return p, residual
|
||||
|
||||
|
||||
def locate(xyz, query_u, query_v, conf=None, radius=10.0, depth_band=0.6,
|
||||
fit_plane=True, max_residual=0.05, min_conf=None, min_z=0.05):
|
||||
"""在点云里找查询像素方向上最近的可见点。
|
||||
|
||||
参数
|
||||
xyz : (N,3) 雷达系点云
|
||||
query_u/v : 查询像素
|
||||
conf : (N,) 置信度,可选
|
||||
radius : 候选像素半径
|
||||
返回 dict(未找到时 found=False)
|
||||
"""
|
||||
xyz = np.asarray(xyz, dtype=np.float64)
|
||||
good = np.isfinite(xyz).all(axis=1) & (np.abs(xyz).sum(axis=1) > 1e-6)
|
||||
if conf is not None and min_conf is not None:
|
||||
good &= np.asarray(conf) >= min_conf
|
||||
idx = np.where(good)[0]
|
||||
pts = xyz[good]
|
||||
|
||||
p_cam = calib.lidar_to_cam(pts)
|
||||
u, v, ok = calib.project_cam_to_pixel(p_cam)
|
||||
|
||||
dpx = np.hypot(u - query_u, v - query_v)
|
||||
cand = ok & (dpx <= radius) & (p_cam[:, 2] > min_z)
|
||||
|
||||
rng = np.linalg.norm(p_cam, axis=1)
|
||||
res = {
|
||||
'found': False, 'n_valid': int(pts.shape[0]), 'n_cand': int(cand.sum()),
|
||||
'radius': float(radius), 'query_u': float(query_u), 'query_v': float(query_v),
|
||||
}
|
||||
if not cand.any():
|
||||
return res
|
||||
|
||||
cand_idx = np.where(cand)[0]
|
||||
order = cand_idx[np.argsort(rng[cand_idx])]
|
||||
sel = int(order[0])
|
||||
|
||||
ray = calib.pixel_to_ray(query_u, query_v)
|
||||
p_near = p_cam[sel]
|
||||
|
||||
# 深度分层:候选距离跨度大 => 可能擦过边缘
|
||||
r_sorted = np.sort(rng[cand_idx])
|
||||
multi = bool(len(r_sorted) > 3 and (r_sorted[-1] - r_sorted[0]) > 2.0)
|
||||
|
||||
p_final = p_near
|
||||
plane_used = False
|
||||
residual = None
|
||||
if fit_plane:
|
||||
band = p_cam[cand_idx][rng[cand_idx] <= rng[sel] + depth_band]
|
||||
p_fit, residual = _fit_plane_ray(band, ray, max_residual)
|
||||
if p_fit is not None:
|
||||
p_final = p_fit
|
||||
plane_used = True
|
||||
|
||||
res.update({
|
||||
'found': True,
|
||||
'p_cam': p_final,
|
||||
'range': float(np.linalg.norm(p_final)),
|
||||
'z': float(p_final[2]),
|
||||
'p_lidar': calib.cam_to_lidar(p_final)[0],
|
||||
'hit_u': float(u[sel]),
|
||||
'hit_v': float(v[sel]),
|
||||
'px_err': float(dpx[sel]),
|
||||
'lidar_index': int(idx[sel]),
|
||||
'plane_used': plane_used,
|
||||
'plane_residual': residual,
|
||||
'multi_layer': multi,
|
||||
'cand_ranges': rng[cand_idx],
|
||||
'confidence': float(conf[idx[sel]]) if conf is not None else None,
|
||||
})
|
||||
return res
|
||||
|
||||
|
||||
class TargetIndex:
|
||||
"""一帧点云的「像素格子 -> 点索引」倒排表。
|
||||
|
||||
为什么需要:
|
||||
locate() 每次点击都会把 N 个点全量投影一遍。N≈5 万时一次几毫秒到
|
||||
几十毫秒,点一次无所谓;但同一帧反复点、或在板子上跑时,这部分是
|
||||
重复劳动。这里在【每帧】只做一次全量投影 + 分桶,之后每次点击只
|
||||
检查查询像素周围几个格子里的点,平面拟合也只在局部候选里做。
|
||||
|
||||
用法:
|
||||
idx = TargetIndex(xyz, conf=conf) # 每帧建一次
|
||||
res = idx.locate(u, v, radius=10) # 每次点击,返回结构和 locate 一样
|
||||
|
||||
注意:
|
||||
- 桶按点的像素坐标分,格子越大,查询时扫到的点越多;默认 32 px。
|
||||
- 只索引落在图像内的点;图像外的点本来也不会被点到。
|
||||
"""
|
||||
|
||||
def __init__(self, xyz, conf=None, cell=32, min_z=0.05):
|
||||
xyz = np.asarray(xyz, dtype=np.float64)
|
||||
good = np.isfinite(xyz).all(axis=1) & (np.abs(xyz).sum(axis=1) > 1e-6)
|
||||
self.idx = np.where(good)[0]
|
||||
self.xyz = xyz[good]
|
||||
self.conf = None if conf is None else np.asarray(conf)[good]
|
||||
|
||||
self.p_cam = calib.lidar_to_cam(self.xyz)
|
||||
self.u, self.v, ok = calib.project_cam_to_pixel(self.p_cam)
|
||||
self.rng = np.linalg.norm(self.p_cam, axis=1)
|
||||
self.ok = ok & (self.p_cam[:, 2] > float(min_z))
|
||||
|
||||
self.cell = max(4, int(cell))
|
||||
self.nx = int(np.ceil(calib.IMAGE_WIDTH / float(self.cell)))
|
||||
self.ny = int(np.ceil(calib.IMAGE_HEIGHT / float(self.cell)))
|
||||
|
||||
sel = np.where(self.ok)[0]
|
||||
if sel.size:
|
||||
cx = np.clip((self.u[sel] // self.cell).astype(np.int64),
|
||||
0, self.nx - 1)
|
||||
cy = np.clip((self.v[sel] // self.cell).astype(np.int64),
|
||||
0, self.ny - 1)
|
||||
keys = cy * self.nx + cx
|
||||
order = np.argsort(keys, kind='stable')
|
||||
keys = keys[order]
|
||||
sel = sel[order]
|
||||
uniq, starts = np.unique(keys, return_index=True)
|
||||
ends = np.append(starts[1:], keys.size)
|
||||
self.buckets = {int(k): sel[s:e]
|
||||
for k, s, e in zip(uniq, starts, ends)}
|
||||
else:
|
||||
self.buckets = {}
|
||||
|
||||
def _gather(self, query_u, query_v, radius):
|
||||
c = self.cell
|
||||
x0 = max(0, int(np.floor((query_u - radius) / c)))
|
||||
x1 = min(self.nx - 1, int(np.floor((query_u + radius) / c)))
|
||||
y0 = max(0, int(np.floor((query_v - radius) / c)))
|
||||
y1 = min(self.ny - 1, int(np.floor((query_v + radius) / c)))
|
||||
|
||||
parts = []
|
||||
for cy in range(y0, y1 + 1):
|
||||
base = cy * self.nx
|
||||
for cx in range(x0, x1 + 1):
|
||||
arr = self.buckets.get(base + cx)
|
||||
if arr is not None and arr.size:
|
||||
parts.append(arr)
|
||||
if not parts:
|
||||
return np.empty(0, dtype=np.int64)
|
||||
return np.concatenate(parts)
|
||||
|
||||
def locate(self, query_u, query_v, radius=10.0, depth_band=0.6,
|
||||
fit_plane=True, max_residual=0.05, min_conf=None):
|
||||
"""局部查找,返回与 locate() 相同的字段。"""
|
||||
query_u = float(query_u)
|
||||
query_v = float(query_v)
|
||||
|
||||
res = {
|
||||
'found': False, 'n_valid': int(self.idx.size),
|
||||
'n_cand': 0, 'radius': float(radius),
|
||||
'query_u': query_u, 'query_v': query_v,
|
||||
'indexed': True,
|
||||
}
|
||||
|
||||
cand = self._gather(query_u, query_v, radius)
|
||||
if cand.size == 0:
|
||||
return res
|
||||
|
||||
dpx = np.hypot(self.u[cand] - query_u, self.v[cand] - query_v)
|
||||
keep = dpx <= radius
|
||||
if min_conf is not None and self.conf is not None:
|
||||
keep &= self.conf[cand] >= min_conf
|
||||
cand = cand[keep]
|
||||
dpx = dpx[keep]
|
||||
res['n_cand'] = int(cand.size)
|
||||
if cand.size == 0:
|
||||
return res
|
||||
|
||||
# 距离最近的点
|
||||
order = np.argsort(self.rng[cand])
|
||||
cand = cand[order]
|
||||
dpx = dpx[order]
|
||||
sel = int(cand[0])
|
||||
|
||||
ray = calib.pixel_to_ray(query_u, query_v)
|
||||
r_sorted = np.sort(self.rng[cand])
|
||||
multi = bool(r_sorted.size > 3 and (r_sorted[-1] - r_sorted[0]) > 2.0)
|
||||
|
||||
p_final = self.p_cam[sel]
|
||||
plane_used = False
|
||||
residual = None
|
||||
if fit_plane:
|
||||
band = self.p_cam[cand[self.rng[cand] <= self.rng[sel] + depth_band]]
|
||||
p_fit, residual = _fit_plane_ray(band, ray, max_residual)
|
||||
if p_fit is not None:
|
||||
p_final = p_fit
|
||||
plane_used = True
|
||||
|
||||
res.update({
|
||||
'found': True,
|
||||
'p_cam': p_final,
|
||||
'p_lidar': calib.cam_to_lidar(p_final)[0],
|
||||
'hit_u': float(self.u[sel]),
|
||||
'hit_v': float(self.v[sel]),
|
||||
'px_err': float(dpx[0]),
|
||||
'lidar_index': int(self.idx[sel]),
|
||||
'plane_used': plane_used,
|
||||
'plane_residual': residual,
|
||||
'multi_layer': multi,
|
||||
'confidence': None if self.conf is None else float(self.conf[sel]),
|
||||
})
|
||||
return res
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""test_session.py — 端到端测试:板子端算 / 电脑端算 / 会话隔离。"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
import _mock_server
|
||||
import session_client as sc
|
||||
|
||||
|
||||
def main():
|
||||
srv = _mock_server.MockServer(port=5599)
|
||||
srv.start()
|
||||
time.sleep(0.4)
|
||||
try:
|
||||
# ---------------- 会话 1:板子端算(点云不出板)
|
||||
c1 = sc.BoardClient('127.0.0.1', 5599)
|
||||
h, _ = c1.request('hello')
|
||||
assert h['ok'] and h['board_can_compute'], h
|
||||
|
||||
h, img = c1.request('capture')
|
||||
assert h['ok'] and len(img) > 1000, h
|
||||
|
||||
r, _ = c1.request('click', u=800.0, v=648.0, radius=10, fit_plane=True)
|
||||
assert r['ok'] and r['found'], r
|
||||
pm_board = np.asarray(r['p_map'], dtype=np.float64)
|
||||
print('board-mode p_map =', np.round(pm_board, 4))
|
||||
# 合成点放在 3 m 处
|
||||
assert abs(np.linalg.norm(pm_board) - 3.0) < 0.06, pm_board
|
||||
|
||||
st, _ = c1.request('status')
|
||||
assert st['ok'] and st['has_target'] and st['delta'], st
|
||||
print('board-mode status =', st['delta'])
|
||||
assert abs(st['delta'][0] - (0.2507 - pm_board[0])) < 1e-6
|
||||
|
||||
# ---------------- 会话 2:电脑端算(拉点云 + 回传 p_map)
|
||||
c2 = sc.BoardClient('127.0.0.1', 5599)
|
||||
c2.request('hello')
|
||||
h2, _ = c2.request('capture')
|
||||
hc, cblob = c2.request('cloud')
|
||||
xyz = sc.decode_cloud(hc, cblob)
|
||||
idx = sc.target_math.TargetIndex(xyz)
|
||||
res, pm_pc = sc.compute_target(xyz, h2['odom'], 800.0, 648.0,
|
||||
10.0, True, index=idx)
|
||||
assert res['found'], res
|
||||
print('pc-mode p_map =', np.round(pm_pc, 4))
|
||||
assert np.allclose(pm_pc, pm_board, atol=1e-6)
|
||||
|
||||
r2, _ = c2.request('set_target', u=800, v=648,
|
||||
p_cam=res['p_cam'].tolist(),
|
||||
p_lidar=res['p_lidar'].tolist(),
|
||||
p_map=pm_pc.tolist())
|
||||
assert r2['ok'], r2
|
||||
st2, _ = c2.request('status')
|
||||
assert st2['has_target'], st2
|
||||
|
||||
# ---------------- 会话隔离:c1 的目标还在,c2 的 reset 不影响 c1
|
||||
st1, _ = c1.request('status')
|
||||
assert st1['has_target'], st1
|
||||
c2.request('reset')
|
||||
st2b, _ = c2.request('status')
|
||||
assert not st2b['has_target'], st2b
|
||||
st1b, _ = c1.request('status')
|
||||
assert st1b['has_target'], st1b
|
||||
print('session isolation: OK')
|
||||
|
||||
# ---------------- 同一帧覆盖点选(索引复用)
|
||||
t0 = time.perf_counter()
|
||||
for du in (-8, -4, 0, 4, 8):
|
||||
rr, _ = c1.request('click', u=800.0 + du, v=648.0, radius=10, fit_plane=True)
|
||||
assert rr['ok']
|
||||
print('5 clicks on same frame: %.1f ms total' % ((time.perf_counter() - t0) * 1000))
|
||||
|
||||
c1.close()
|
||||
c2.close()
|
||||
print('PASS')
|
||||
finally:
|
||||
srv.stop()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
wire.py — 会话消息的打包 / 解包。
|
||||
|
||||
pynng 一条消息只能是单块二进制,所以这里做一层很简单的外壳:
|
||||
b"LS01" | u32 头长度 | JSON 头(UTF-8) | 二进制体
|
||||
|
||||
每个头里都带 session:
|
||||
{
|
||||
"id": "<一次运行一个 uuid>",
|
||||
"client_ip": "192.168.43.23", # 电脑自己的地址
|
||||
"board_ip": "192.168.43.50", # 板子的地址
|
||||
"started_ns": 1712345678901234567,
|
||||
"note": "..."
|
||||
}
|
||||
|
||||
这样每一条请求和应答都能看出属于哪个会话、从哪台机器来。
|
||||
"""
|
||||
import json
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
|
||||
MAGIC = b"LS01"
|
||||
_LEN = struct.Struct("<I")
|
||||
|
||||
|
||||
def new_session(board_ip, client_ip, note=""):
|
||||
"""新建一个会话(电脑端每次启动调一次)。"""
|
||||
return {
|
||||
"id": uuid.uuid4().hex,
|
||||
"client_ip": str(client_ip),
|
||||
"board_ip": str(board_ip),
|
||||
"started_ns": time.time_ns(),
|
||||
"note": str(note),
|
||||
}
|
||||
|
||||
|
||||
def pack(header, blob=b""):
|
||||
"""dict + 二进制体 -> bytes。"""
|
||||
h = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
return MAGIC + _LEN.pack(len(h)) + h + bytes(blob)
|
||||
|
||||
|
||||
def unpack(data):
|
||||
"""bytes -> (dict, 二进制体)。"""
|
||||
if len(data) < 8 or data[:4] != MAGIC:
|
||||
raise ValueError("bad frame magic")
|
||||
n = _LEN.unpack_from(data, 4)[0]
|
||||
if len(data) < 8 + n:
|
||||
raise ValueError("truncated header")
|
||||
header = json.loads(data[8:8 + n].decode("utf-8"))
|
||||
return header, data[8 + n:]
|
||||
|
||||
|
||||
def session_id(header):
|
||||
return (header.get("session") or {}).get("id")
|
||||
|
||||
|
||||
def check_session(header, session):
|
||||
"""应答必须属于当前会话,防止串台。"""
|
||||
got = session_id(header)
|
||||
if got != session["id"]:
|
||||
raise RuntimeError("session mismatch: got %r, want %r" % (got, session["id"]))
|
||||
Reference in New Issue
Block a user