initial commit

This commit is contained in:
2026-10-22 09:07:39 +08:00
commit 265f11aa38
15 changed files with 2924 additions and 0 deletions
+198
View File
@@ -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)))
+12
View File
@@ -0,0 +1,12 @@
# 板子端(Ubuntu 24.04 + ROS2 Jazzy)
#
# ROS2 本体用 apt 安装,不要写进 pip requirements:
# sudo apt install ros-jazzy-rclpy ros-jazzy-sensor-msgs \
# ros-jazzy-nav-msgs ros-jazzy-message-filters
#
pynng>=0.9.0
numpy>=1.26
# 仅当使用 --no-compressed(原始图需要编码)时,二选一:
# opencv-python>=4.8
# Pillow>=10.0
+611
View File
@@ -0,0 +1,611 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
session_server_node.py — 板子端:会话式「抓帧 / 回传图像 / 回传点云 / 误差跟踪」
一次运行 = 一个会话。电脑端每一条请求、板子每一条应答都带 session
(id / client_ip / board_ip / started_ns),会话之间状态隔离。
pynng REQ/REP 协议(默认 tcp://0.0.0.0:5555):
hello 握手;返回标定分辨率、是否支持板子端计算
capture 等一帧同步的(图像 + 点云 + 位姿),返回图像字节;
本会话记住这一帧(点云、位姿),并清掉旧目标
click 默认:电脑只发 (u, v),板子用本地点云算
p_cam / p_lidar / p_map,并把它记成目标(点云不出板)
cloud 可选(--compute pc):返回 capture 那一帧的点云
set_target 可选(--compute pc):电脑算好的 p_map 回传
status 返回当前 pos / yaw / target / Δ(只给坐标,不给距离)
reset 清除本会话的目标
投影优化:
click 第一次会建一个「像素格子 -> 点索引」倒排表(每帧一次),
之后同一帧的点击只查周围几个格子,不再全量投影,平面拟合也只在
局部候选里做。格子大小 --index-cell(默认 32 px)。
用法(板子):
source /opt/ros/jazzy/setup.bash
python3 session_server_node.py --port 5555
常用参数:
--image-topic /odin1/image/compressed (默认走压缩图,直接转发,不用编码)
--no-compressed (强制用原始 /odin1/image,需要 cv2 或 Pillow)
--cloud-topic /odin1/cloud_raw
--odom-topic /odin1/odometry
"""
import argparse
import threading
import time
import zlib
from collections import deque
import numpy as np
import rclpy
from rclpy.node import Node
from rclpy.qos import (QoSProfile, ReliabilityPolicy, HistoryPolicy,
qos_profile_sensor_data)
from sensor_msgs.msg import Image, CompressedImage, PointCloud2
from nav_msgs.msg import Odometry
from message_filters import Subscriber, ApproximateTimeSynchronizer
import pynng
import wire
try:
import calib
CALIB_W = int(calib.IMAGE_WIDTH)
CALIB_H = int(calib.IMAGE_HEIGHT)
except Exception: # 没放 calib.py 也能跑,只是分辨率用默认值
CALIB_W, CALIB_H = 1600, 1296
# 板子端本地算 p_cam/p_lidar/p_map(默认模式)。没有 target_math.py 时
# 自动降级为"电脑端算 + 点云回传"。
try:
import target_math
_HAS_TARGET_MATH = True
except Exception:
target_math = None
_HAS_TARGET_MATH = False
# sensor_msgs/PointField.datatype -> numpy dtype
_PF = {1: np.int8, 2: np.uint8, 3: np.int16, 4: np.uint16,
5: np.int32, 6: np.uint32, 7: np.float32, 8: np.float64}
_CH = {'bgr8': 3, 'rgb8': 3, 'bgra8': 4, 'rgba8': 4, 'mono8': 1}
def stamp_ns(h):
return int(h.stamp.sec) * 1_000_000_000 + int(h.stamp.nanosec)
def unpack_cloud(msg):
"""按字段名/偏移解析 PointCloud2(兼容 padding 与非对齐字段)。"""
names, formats, offsets = [], [], []
for f in msg.fields:
if f.datatype not in _PF:
continue
names.append(f.name)
formats.append(_PF[f.datatype])
offsets.append(f.offset)
dt = np.dtype({'names': names, 'formats': formats,
'offsets': offsets, 'itemsize': msg.point_step})
arr = np.frombuffer(msg.data, dtype=dt, count=msg.width * msg.height)
return arr, names
def image_to_array(msg):
ch = _CH.get(msg.encoding)
if ch is None:
return None
row = msg.step if msg.step else msg.width * ch
buf = np.frombuffer(msg.data, np.uint8).reshape(msg.height, row)
img = buf[:, :msg.width * ch].reshape(msg.height, msg.width, ch)
return img.copy()
def quat_to_yaw(x, y, z, w):
return float(np.arctan2(2.0 * (w * z + x * y),
1.0 - 2.0 * (y * y + z * z)))
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)
class SessionServer(Node):
def __init__(self, args):
super().__init__('session_server')
self.args = args
self._stop = False
self._rep = None
self._lock = threading.Lock()
self._cv = threading.Condition(self._lock)
self._latest = None
self._odom_hist = deque(maxlen=400)
self._sessions = {}
self._setup_subs()
self.get_logger().info(
'会话服务: image=%s cloud=%s odom=%s'
% (args.image_topic, args.cloud_topic, args.odom_topic))
self.get_logger().info(
'标定分辨率 %dx%d,等待 %s 的第一帧…' % (CALIB_W, CALIB_H, args.image_topic))
# ------------------------------------------------------------------ 订阅
def _setup_subs(self):
img_type = CompressedImage if self.args.image_topic.rstrip('/').endswith('/compressed') else Image
a = Subscriber(self, img_type, self.args.image_topic,
qos_profile=qos_profile_sensor_data)
b = Subscriber(self, PointCloud2, self.args.cloud_topic,
qos_profile=qos_profile_sensor_data)
self.sync = ApproximateTimeSynchronizer([a, b],
queue_size=self.args.queue,
slop=self.args.slop)
self.sync.registerCallback(self.on_pair)
# 里程计:best-effort 订阅既能接 reliable 也能接 best-effort 的发布者
odom_qos = QoSProfile(depth=100,
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST)
self.create_subscription(Odometry, self.args.odom_topic,
self.on_odom, odom_qos)
def on_odom(self, msg):
p = msg.pose.pose.position
q = msg.pose.pose.orientation
rec = {
'stamp_ns': stamp_ns(msg.header),
'recv_mono': time.monotonic(),
'pos': (float(p.x), float(p.y), float(p.z)),
'quat': (float(q.x), float(q.y), float(q.z), float(q.w)),
'frame_id': msg.header.frame_id,
'child_frame_id': msg.child_frame_id,
}
with self._lock:
self._odom_hist.append(rec)
def on_pair(self, img_msg, pc_msg):
"""图像 + 点云时间对齐后,把这一帧放进 latest。"""
arr, names = unpack_cloud(pc_msg)
if not all(k in names for k in ('x', 'y', 'z')):
self.get_logger().warn('点云没有 x/y/z 字段,实际:%s' % names)
return
xyz = np.stack([arr['x'], arr['y'], arr['z']], axis=-1).astype(np.float32)
conf = np.ascontiguousarray(arr['confidence']) if 'confidence' in names else None
cloud_stamp = stamp_ns(pc_msg.header)
img_stamp = stamp_ns(img_msg.header)
if isinstance(img_msg, CompressedImage):
frame = {
'kind': 'compressed',
'blob': bytes(img_msg.data),
'format': str(img_msg.format or 'jpeg'),
'width': None,
'height': None,
}
else:
img = image_to_array(img_msg)
if img is None:
self.get_logger().warn('不支持的图像编码:%s' % img_msg.encoding)
return
frame = {
'kind': 'raw',
'img': img,
'format': None,
'width': int(img_msg.width),
'height': int(img_msg.height),
'encoding': img_msg.encoding,
}
frame.update({
'xyz': xyz,
'conf': conf,
'cloud_stamp_ns': cloud_stamp,
'img_stamp_ns': img_stamp,
'cloud_frame_id': pc_msg.header.frame_id,
'recv_mono': time.monotonic(),
})
with self._cv:
self._latest = frame
self._cv.notify_all()
self.get_logger().debug(
'同步帧: 图像 %s, 点云 %d 点, 时间差 %.2f ms'
% (frame['kind'], xyz.shape[0], (cloud_stamp - img_stamp) / 1e6))
# ------------------------------------------------------------------ 帧工具
def wait_for_frame(self, after_mono, timeout):
"""等一帧 recv 时间 >= after_mono 的帧;超时返回当前最新帧。"""
deadline = time.monotonic() + timeout
with self._cv:
while True:
frame = self._latest
if frame is not None and frame['recv_mono'] >= after_mono:
return dict(frame)
left = deadline - time.monotonic()
if left <= 0:
return dict(frame) if frame is not None else None
self._cv.wait(min(left, 0.2))
def _nearest_odom_locked(self, stamp_ns_value):
if not self._odom_hist:
return None
return min(self._odom_hist, key=lambda r: abs(r['stamp_ns'] - stamp_ns_value))
def frame_odom(self, frame):
with self._lock:
od = self._nearest_odom_locked(frame['cloud_stamp_ns'])
if od is None:
return None
return {
'stamp_ns': int(od['stamp_ns']),
'position': [float(v) for v in od['pos']],
'orientation_xyzw': [float(v) for v in od['quat']],
'frame_id': od['frame_id'],
'child_frame_id': od['child_frame_id'],
'delta_ms': (od['stamp_ns'] - frame['cloud_stamp_ns']) / 1e6,
}
def encode_image(self, frame):
"""返回 (bytes, format)。压缩图直接转发;原图才编码。"""
if frame.get('kind') == 'compressed':
return frame['blob'], frame.get('format') or 'jpeg'
img = frame['img']
try:
import cv2
ok, buf = cv2.imencode(
'.jpg', img,
[int(cv2.IMWRITE_JPEG_QUALITY), int(self.args.jpeg_quality)])
if ok:
return buf.tobytes(), 'jpeg'
except Exception:
pass
try:
import io
from PIL import Image as PILImage
enc = frame.get('encoding', 'bgr8')
rgb = img[..., ::-1] if enc.startswith('bgr') else img[..., :3]
b = io.BytesIO()
PILImage.fromarray(np.ascontiguousarray(rgb)).save(
b, format='JPEG', quality=int(self.args.jpeg_quality))
return b.getvalue(), 'jpeg'
except Exception as exc:
raise RuntimeError('原图编码失败,需要 OpenCV 或 Pillow:%s' % exc)
# ------------------------------------------------------------------ 会话
def _prune(self):
now = time.monotonic()
for sid in list(self._sessions):
if now - self._sessions[sid]['last'] > self.args.session_ttl:
del self._sessions[sid]
def handle_hello(self, hdr):
sid = hdr['session']['id']
with self._lock:
self._sessions.setdefault(sid, {
'session': hdr['session'], 'frame': None,
'target': None, 'target_info': None,
'last': time.monotonic(),
})
self._sessions[sid]['last'] = time.monotonic()
return {
'ok': True, 'type': 'hello',
'node': self.get_name(),
'calib_width': CALIB_W, 'calib_height': CALIB_H,
'board_can_compute': bool(_HAS_TARGET_MATH),
'compute_modes': (['board', 'pc'] if _HAS_TARGET_MATH else ['pc']),
'topics': {'image': self.args.image_topic,
'cloud': self.args.cloud_topic,
'odom': self.args.odom_topic},
}
def handle_capture(self, hdr):
self._prune()
t_req = time.monotonic()
frame = self.wait_for_frame(t_req - self.args.max_frame_age,
self.args.frame_timeout)
if frame is None:
return {'ok': False, 'type': 'frame',
'error': '还没有收到同步的图像+点云'}, b''
blob, fmt = self.encode_image(frame)
sid = hdr['session']['id']
with self._lock:
self._sessions[sid] = {
'session': hdr['session'], 'frame': frame,
'target': None, 'target_info': None,
'last': time.monotonic(),
}
rep = {
'ok': True, 'type': 'frame',
'image_format': fmt,
'image_bytes': len(blob),
'image_width': frame.get('width'),
'image_height': frame.get('height'),
'calib_width': CALIB_W, 'calib_height': CALIB_H,
'image_stamp_ns': int(frame['img_stamp_ns']),
'cloud_stamp_ns': int(frame['cloud_stamp_ns']),
'stamp_delta_ms': (frame['cloud_stamp_ns'] - frame['img_stamp_ns']) / 1e6,
'n_points': int(frame['xyz'].shape[0]),
'board_can_compute': bool(_HAS_TARGET_MATH),
'odom': self.frame_odom(frame),
}
return rep, blob
def handle_click(self, hdr):
"""板子端算:像素 -> p_cam / p_lidar / p_map,并把它记成目标。
这是默认模式:点云不出板,电脑只发 (u, v)。
"""
if not _HAS_TARGET_MATH:
return {'ok': False, 'type': 'target',
'error': '板子没装 target_math.py,请电脑端用 --compute pc'}, b''
sid = hdr['session']['id']
with self._lock:
s = self._sessions.get(sid)
frame = None if s is None else s.get('frame')
if frame is None:
return {'ok': False, 'type': 'target', 'error': '先 capture'}, b''
u = float(hdr['u'])
v = float(hdr['v'])
radius = float(hdr.get('radius', self.args.radius))
fit = bool(hdr.get('fit_plane', True))
# 每帧只建一次索引;同一帧后续点击只查局部格子
with self._lock:
index = None if s is None else s.get('index')
if index is None:
index = target_math.TargetIndex(frame['xyz'], conf=frame.get('conf'),
cell=self.args.index_cell)
with self._lock:
if s is not None:
s['index'] = index
res = index.locate(u, v, radius=radius, fit_plane=fit)
out = {
'ok': True, 'type': 'target',
'found': bool(res['found']),
'u': u, 'v': v,
'n_valid': int(res['n_valid']),
'n_cand': int(res['n_cand']),
'plane_used': bool(res['plane_used']),
'multi_layer': bool(res['multi_layer']),
}
if not res['found']:
return out, b''
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 if res.get('confidence') is None
else float(res['confidence'])),
})
od = self.frame_odom(frame)
if od is None:
out['p_map'] = None
out['error'] = '本帧没有里程计位姿'
return out, b''
R = quat_to_rot(od['orientation_xyzw'])
t = np.asarray(od['position'], dtype=np.float64)
pm = R @ np.asarray(res['p_lidar'], dtype=np.float64) + t
out['p_map'] = [float(x) for x in pm]
out['odom'] = od
with self._lock:
s['target'] = pm
s['target_info'] = out
s['last'] = time.monotonic()
return out, b''
def handle_cloud(self, hdr):
sid = hdr['session']['id']
with self._lock:
s = self._sessions.get(sid)
frame = None if s is None else s.get('frame')
if frame is None:
return {'ok': False, 'type': 'cloud', 'error': '先 capture'}, b''
xyz = np.asarray(frame['xyz'], dtype='<f4')
body = zlib.compress(xyz.tobytes(), 1)
rep = {
'ok': True, 'type': 'cloud',
'encoding': 'zlib_f32',
'n_points': int(xyz.shape[0]),
'shape': [int(xyz.shape[0]), 3],
'raw_bytes': int(xyz.nbytes),
'zlib_bytes': int(len(body)),
'cloud_stamp_ns': int(frame['cloud_stamp_ns']),
}
return rep, body
def handle_set_target(self, hdr):
sid = hdr['session']['id']
with self._lock:
s = self._sessions.get(sid)
if s is None or s.get('frame') 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([float(pm[0]), float(pm[1]), float(pm[2])],
dtype=np.float64)
s['target_info'] = {k: hdr.get(k) for k in ('u', 'v', 'p_cam', 'p_lidar', 'p_map')}
s['last'] = time.monotonic()
target = [float(v) for v in s['target']]
return {'ok': True, 'type': 'target', 'target': target}, b''
def handle_status(self, hdr):
sid = hdr['session']['id']
with self._lock:
s = self._sessions.get(sid)
od = self._odom_hist[-1] if self._odom_hist else None
target = None if s is None else s.get('target')
out = {'ok': True, 'type': 'status', 'has_target': target is not None}
if od is None:
out.update({'valid': False, 'error': '还没有里程计'})
return out, b''
age = time.monotonic() - od['recv_mono']
pos = np.asarray(od['pos'], dtype=np.float64)
out.update({
'valid': bool(age <= self.args.odom_timeout),
'pos': [float(v) for v in pos],
'yaw_deg': float(np.rad2deg(quat_to_yaw(*od['quat']))),
'odom_age_s': float(age),
'odom_frame': od['frame_id'],
'odom_child': od['child_frame_id'],
})
if target is not None:
d = pos - np.asarray(target, dtype=np.float64)
out['target'] = [float(v) for v in target]
out['delta'] = [float(v) for v in d] # 只给坐标差,不给距离
return out, b''
def handle_reset(self, hdr):
sid = hdr['session']['id']
with self._lock:
s = self._sessions.get(sid)
if s is not None:
s['target'] = None
s['target_info'] = None
s['last'] = time.monotonic()
return {'ok': True, 'type': 'reset'}, b''
def dispatch(self, typ, hdr):
if typ == 'hello':
return self.handle_hello(hdr), b''
if typ == 'capture':
return self.handle_capture(hdr)
if typ == 'click':
return self.handle_click(hdr)
if typ == 'cloud':
return self.handle_cloud(hdr)
if typ == 'set_target':
return self.handle_set_target(hdr)
if typ == 'status':
return self.handle_status(hdr)
if typ == 'reset':
return self.handle_reset(hdr)
return {'ok': False, 'type': typ, 'error': 'unknown type'}, b''
# ------------------------------------------------------------------ 服务循环
def serve(self):
addr = 'tcp://0.0.0.0:%d' % self.args.port
self._rep = pynng.Rep0(listen=addr)
self._rep.recv_max_size = 32 * 1024 * 1024
self._rep.recv_timeout = 500
self.get_logger().info('pynng REP 监听 %s' % addr)
while not self._stop:
try:
data = self._rep.recv()
except pynng.Timeout:
continue
except pynng.Closed:
break
except Exception as exc:
self.get_logger().error('recv 失败: %s' % exc)
continue
hdr = {}
try:
hdr, _ = wire.unpack(data)
typ = hdr.get('type', '?')
sess = hdr.get('session') or {}
sid = sess.get('id', '?')
reply, out_blob = self.dispatch(typ, hdr)
reply['session'] = sess # 原样带回,含 IP
reply['server_ns'] = time.time_ns()
self._rep.send(wire.pack(reply, out_blob))
self.get_logger().info(
'[%s] %-10s from %s -> ok=%s'
% (sid[:8], typ, sess.get('client_ip', '?'), reply.get('ok')))
except Exception as exc:
try:
self._rep.send(wire.pack({
'ok': False, 'type': hdr.get('type', 'error'),
'error': str(exc),
'session': hdr.get('session') or {},
'server_ns': time.time_ns(),
}))
except Exception:
pass
self.get_logger().error('处理请求失败: %s' % exc)
def stop(self):
self._stop = True
if self._rep is not None:
try:
self._rep.close()
except Exception:
pass
def parse_args():
ap = argparse.ArgumentParser(description='会话式抓帧/定位/误差服务')
ap.add_argument('--port', type=int, default=5555)
ap.add_argument('--image-topic', default='/odin1/image/compressed')
ap.add_argument('--cloud-topic', default='/odin1/cloud_raw')
ap.add_argument('--odom-topic', default='/odin1/odometry')
ap.add_argument('--no-compressed', action='store_true',
help='强制用原始 /odin1/image(需要 cv2 或 Pillow)')
ap.add_argument('--slop', type=float, default=0.08, help='图像/点云允许时间差(秒)')
ap.add_argument('--queue', type=int, default=20)
ap.add_argument('--max-frame-age', type=float, default=0.35,
help='抓帧时允许复用多旧的帧(秒)')
ap.add_argument('--frame-timeout', type=float, default=2.0,
help='等新帧的最长时间(秒)')
ap.add_argument('--jpeg-quality', type=int, default=85)
ap.add_argument('--radius', type=float, default=10.0,
help='click 请求没带半径时的默认候选像素半径')
ap.add_argument('--index-cell', type=int, default=32,
help='像素格子大小(px),用于每帧的投影索引')
ap.add_argument('--odom-timeout', type=float, default=1.0,
help='里程计超过这个时间没更新就标记 invalid(秒)')
ap.add_argument('--session-ttl', type=float, default=600.0)
args = ap.parse_args()
if args.no_compressed:
args.image_topic = '/odin1/image'
return args
def main():
args = parse_args()
rclpy.init()
node = SessionServer(args)
th = threading.Thread(target=node.serve, daemon=True)
th.start()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.stop()
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
+234
View File
@@ -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
+65
View File
@@ -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"]))