feat: add PnP laser solving and update 0xA5 frame to 4-float protocol
- Integrate PnP rectangle pose estimation + laser world coordinate computation from scripts/calibrate/laser_pnp.py - Publish normalized measurement (laser/RECT) and reference (rect center) via Float32MultiArray - Update gimbal 0xA5 frame to 18-byte format: measure_x, ref_x, measure_y, ref_y - Add camera calibration, laser line params, and RECT_L/W constants - Add corner ordering (TL/BL/BR/TR), SOLVEPNP_IPPE, and pose/laser visualization overlay - Remove standalone vision_example.py (superseded by ROS2 nodes) - Add scripts/calibrate/ with calibration and PnP tools
This commit is contained in:
@@ -5,12 +5,36 @@ import cv2
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import Image
|
||||
from geometry_msgs.msg import Point
|
||||
from std_msgs.msg import Float32MultiArray
|
||||
from cv_bridge import CvBridge
|
||||
|
||||
# 配置项(与 vision_example.py 保持一致)
|
||||
# Camera calibration (from calibrate.py)
|
||||
K = np.array([
|
||||
[1247.0565667653996, 0.0, 753.8809622002652],
|
||||
[0.0, 1256.6168638051222, 528.9954978706462],
|
||||
[0.0, 0.0, 1.0]
|
||||
])
|
||||
DIST = np.array([[0.0, 0.0, 0.0, 0.0, 0.0]])
|
||||
|
||||
# Laser line parameters (from laser_calibrate.py)
|
||||
D_CAM = np.array([-0.00989344622939825, 0.015559548512101173, 0.9998300075531006])
|
||||
O_CAM = np.array([-26.1895751953125, -3.379796028137207, 0.0])
|
||||
|
||||
# Rectangle physical dimensions (mm)
|
||||
RECT_L = 305.0
|
||||
RECT_W = 215.0
|
||||
if RECT_W > RECT_L:
|
||||
RECT_L, RECT_W = RECT_W, RECT_L
|
||||
|
||||
# Image preprocessing
|
||||
IMAGE_SCALE = 0.5
|
||||
IMAGE_AREA = 1080 * 1440 / 4
|
||||
THRESHOLD_AREA = IMAGE_AREA * 0.01
|
||||
MIN_AREA_RATIO = 0.01
|
||||
THRESHOLD_AREA = IMAGE_AREA * MIN_AREA_RATIO
|
||||
|
||||
# Reference values (center of rectangle, normalized)
|
||||
REF_X = (RECT_L / 2.0) / RECT_L # 0.5
|
||||
REF_Y = (RECT_W / 2.0) / RECT_W # 0.5
|
||||
|
||||
|
||||
class VisionNode(Node):
|
||||
@@ -18,6 +42,13 @@ class VisionNode(Node):
|
||||
super().__init__('vision_node')
|
||||
self._bridge = CvBridge()
|
||||
|
||||
# Scale intrinsics to match IMAGE_SCALE
|
||||
self._K_scaled = K.copy()
|
||||
self._K_scaled[0, 0] *= IMAGE_SCALE
|
||||
self._K_scaled[1, 1] *= IMAGE_SCALE
|
||||
self._K_scaled[0, 2] *= IMAGE_SCALE
|
||||
self._K_scaled[1, 2] *= IMAGE_SCALE
|
||||
|
||||
self._subscription = self.create_subscription(
|
||||
Image, '/camera/image_raw', self._image_callback, 10
|
||||
)
|
||||
@@ -28,7 +59,7 @@ class VisionNode(Node):
|
||||
Image, '/vision/binary_image', 10
|
||||
)
|
||||
self._target_publisher = self.create_publisher(
|
||||
Point, '/vision/target', 10
|
||||
Float32MultiArray, '/vision/target', 10
|
||||
)
|
||||
|
||||
# GPU acceleration setup
|
||||
@@ -70,6 +101,93 @@ class VisionNode(Node):
|
||||
self._gaussian_filter.apply(gpu_gray, self._gpu_blurred)
|
||||
return self._gpu_blurred.download()
|
||||
|
||||
def _detect_rectangle(self, blurred):
|
||||
"""Otsu threshold + morphology close + contour fitting.
|
||||
|
||||
Returns ordered corners (4, 2) float32 or None.
|
||||
"""
|
||||
_, binary = cv2.threshold(
|
||||
blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
||||
|
||||
contours, _ = cv2.findContours(
|
||||
closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
best = None
|
||||
best_area = 0
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
if area < THRESHOLD_AREA:
|
||||
continue
|
||||
epsilon = 0.02 * cv2.arcLength(cnt, True)
|
||||
approx = cv2.approxPolyDP(cnt, epsilon, True)
|
||||
if len(approx) == 4 and cv2.isContourConvex(approx):
|
||||
if area > best_area:
|
||||
best_area = area
|
||||
best = approx.reshape(4, 2)
|
||||
|
||||
return best.astype(np.float32) if best is not None else None, closed
|
||||
|
||||
def _order_corners(self, image_corners):
|
||||
"""Order corners as TL, BL, BR, TR and build world points."""
|
||||
pts = image_corners.copy()
|
||||
|
||||
edge_info = []
|
||||
for i in range(4):
|
||||
j = (i + 1) % 4
|
||||
edge_info.append((i, j, np.linalg.norm(pts[i] - pts[j])))
|
||||
|
||||
edge_info.sort(key=lambda e: e[2])
|
||||
short_a, short_b = edge_info[0], edge_info[1]
|
||||
|
||||
def mid_x(edge):
|
||||
i, j, _ = edge
|
||||
return (pts[i][0] + pts[j][0]) / 2.0
|
||||
|
||||
if mid_x(short_a) < mid_x(short_b):
|
||||
left_edge, right_edge = short_a, short_b
|
||||
else:
|
||||
left_edge, right_edge = short_b, short_a
|
||||
|
||||
li, lj, _ = left_edge
|
||||
if pts[li][1] < pts[lj][1]:
|
||||
TL_idx, BL_idx = li, lj
|
||||
else:
|
||||
TL_idx, BL_idx = lj, li
|
||||
|
||||
ri, rj, _ = right_edge
|
||||
if pts[ri][1] < pts[rj][1]:
|
||||
TR_idx, BR_idx = ri, rj
|
||||
else:
|
||||
TR_idx, BR_idx = rj, ri
|
||||
|
||||
ordered = pts[[TL_idx, BL_idx, BR_idx, TR_idx]]
|
||||
world = np.array([
|
||||
[0, 0],
|
||||
[0, RECT_W],
|
||||
[RECT_L, RECT_W],
|
||||
[RECT_L, 0],
|
||||
], dtype=np.float32)
|
||||
|
||||
return ordered, world
|
||||
|
||||
def _compute_laser_world(self, rvec, tvec):
|
||||
"""Compute laser point world coordinates on Z=0 plane."""
|
||||
R, _ = cv2.Rodrigues(rvec)
|
||||
R_inv = R.T
|
||||
t = tvec.ravel()
|
||||
|
||||
d_world = R_inv @ D_CAM
|
||||
O_world = R_inv @ (O_CAM - t)
|
||||
|
||||
if abs(d_world[2]) < 1e-10:
|
||||
return None
|
||||
|
||||
s = -O_world[2] / d_world[2]
|
||||
P_world = O_world + s * d_world
|
||||
return P_world[:2]
|
||||
|
||||
def _image_callback(self, msg):
|
||||
try:
|
||||
encoding = msg.encoding
|
||||
@@ -81,87 +199,126 @@ class VisionNode(Node):
|
||||
|
||||
gauss_image = self._preprocess(cv_image)
|
||||
|
||||
# Otsu 二值化
|
||||
_, binary = cv2.threshold(
|
||||
gauss_image, 0, 255,
|
||||
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
corners, binary = self._detect_rectangle(gauss_image)
|
||||
if corners is None:
|
||||
# Publish binary image even when no rectangle found
|
||||
binary_color = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)
|
||||
binary_msg = self._bridge.cv2_to_imgmsg(
|
||||
binary_color, encoding='bgr8')
|
||||
binary_msg.header.stamp = msg.header.stamp
|
||||
binary_msg.header.frame_id = msg.header.frame_id
|
||||
self._binary_publisher.publish(binary_msg)
|
||||
|
||||
# 形态学闭运算
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
dilated = cv2.dilate(binary, kernel, iterations=1)
|
||||
eroded = cv2.erode(dilated, kernel, iterations=1)
|
||||
# Publish result image with FPS overlay
|
||||
result_image = cv_image.copy()
|
||||
fps = cv2.getTickFrequency() / max(
|
||||
cv2.getTickCount() - self.start_time, 1)
|
||||
cv2.putText(result_image, f'FPS: {fps:.2f}', (10, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||
self.start_time = cv2.getTickCount()
|
||||
result_msg = self._bridge.cv2_to_imgmsg(
|
||||
result_image, encoding='bgr8')
|
||||
result_msg.header.stamp = msg.header.stamp
|
||||
result_msg.header.frame_id = msg.header.frame_id
|
||||
self._result_publisher.publish(result_msg)
|
||||
return
|
||||
|
||||
# 查找轮廓
|
||||
contours, _ = cv2.findContours(
|
||||
eroded, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
|
||||
ordered_corners, world_pts = self._order_corners(corners)
|
||||
world_pts_3d = np.hstack(
|
||||
[world_pts, np.zeros((4, 1), dtype=np.float32)])
|
||||
|
||||
success, rvec, tvec = cv2.solvePnP(
|
||||
world_pts_3d, ordered_corners, self._K_scaled, DIST,
|
||||
flags=cv2.SOLVEPNP_IPPE
|
||||
)
|
||||
|
||||
result_image = cv_image.copy()
|
||||
best_target = None
|
||||
max_area = 0
|
||||
for cnt in contours:
|
||||
# 绘制原始轮廓,调试用
|
||||
cnt_scaled = (cnt / 0.5).astype(np.int32)
|
||||
cv2.drawContours(result_image, [cnt_scaled], -1, (0, 255, 0), 1)
|
||||
scale_up = 1.0 / IMAGE_SCALE
|
||||
|
||||
# 面积过滤
|
||||
area = cv2.contourArea(cnt)
|
||||
if area < THRESHOLD_AREA:
|
||||
continue
|
||||
if success:
|
||||
laser_world = self._compute_laser_world(rvec, tvec)
|
||||
|
||||
# 形状近似:四边形 + 凸多边形
|
||||
epsilon = 0.02 * cv2.arcLength(cnt, True)
|
||||
approx = cv2.approxPolyDP(cnt, epsilon, True)
|
||||
# Draw ordered corners (scaled up to original resolution)
|
||||
corners_scaled = (ordered_corners * scale_up).astype(np.int32)
|
||||
labels = ["TL", "BL", "BR", "TR"]
|
||||
colors = [(0, 0, 255), (255, 0, 0), (0, 255, 0), (255, 255, 0)]
|
||||
for i, (pt, label) in enumerate(zip(corners_scaled, labels)):
|
||||
cv2.circle(result_image, tuple(pt), 6, colors[i], -1)
|
||||
cv2.putText(result_image, f"{label}", (pt[0] + 10, pt[1] - 10),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, colors[i], 2)
|
||||
|
||||
if len(approx) == 4 and cv2.isContourConvex(approx):
|
||||
# 坐标映射回原始分辨率
|
||||
approx_scaled = (approx / 0.5).astype(np.int32)
|
||||
cv2.drawContours(result_image, [approx_scaled], -1,
|
||||
(0, 0, 255), 2)
|
||||
for point in approx_scaled:
|
||||
cv2.circle(result_image, tuple(point[0]), 5,
|
||||
(255, 0, 0), -1)
|
||||
for i in range(4):
|
||||
j = (i + 1) % 4
|
||||
cv2.line(result_image, tuple(corners_scaled[i]),
|
||||
tuple(corners_scaled[j]), (255, 255, 255), 2)
|
||||
|
||||
if area > max_area:
|
||||
max_area = area
|
||||
best_target = approx
|
||||
# Draw axis
|
||||
axis_len = 50
|
||||
axis_3d = np.float32(
|
||||
[[0, 0, 0], [axis_len, 0, 0], [0, axis_len, 0],
|
||||
[0, 0, axis_len]])
|
||||
imgpts, _ = cv2.projectPoints(
|
||||
axis_3d, rvec, tvec, self._K_scaled, DIST)
|
||||
origin = tuple(
|
||||
(imgpts[0].ravel() * scale_up).astype(int))
|
||||
cv2.line(result_image, origin,
|
||||
tuple((imgpts[1].ravel() * scale_up).astype(int)),
|
||||
(0, 0, 255), 3)
|
||||
cv2.line(result_image, origin,
|
||||
tuple((imgpts[2].ravel() * scale_up).astype(int)),
|
||||
(0, 255, 0), 3)
|
||||
cv2.line(result_image, origin,
|
||||
tuple((imgpts[3].ravel() * scale_up).astype(int)),
|
||||
(255, 0, 0), 3)
|
||||
|
||||
if best_target is not None:
|
||||
moments = cv2.moments(best_target)
|
||||
if moments['m00'] > 0:
|
||||
cx = moments['m10'] / moments['m00']
|
||||
cy = moments['m01'] / moments['m00']
|
||||
h, w = gauss_image.shape[:2]
|
||||
error_x = (cx - w / 2) / (w / 2)
|
||||
error_y = (cy - h / 2) / (h / 2)
|
||||
# Compute and publish normalized measurement + reference
|
||||
if laser_world is not None:
|
||||
measure_x = float(laser_world[0] / RECT_L)
|
||||
measure_y = float(laser_world[1] / RECT_W)
|
||||
|
||||
target_msg = Point()
|
||||
target_msg.x = error_x
|
||||
target_msg.y = -error_y
|
||||
target_msg.z = 0.0
|
||||
target_msg = Float32MultiArray()
|
||||
target_msg.data = [
|
||||
measure_x, REF_X, measure_y, REF_Y]
|
||||
self._target_publisher.publish(target_msg)
|
||||
|
||||
# FPS 叠加
|
||||
fps = cv2.getTickFrequency() / (cv2.getTickCount() - self.start_time)
|
||||
cv2.putText(
|
||||
result_image, f'FPS: {fps:.2f}',
|
||||
(10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2
|
||||
)
|
||||
# Draw laser point (project to image)
|
||||
P_world_3d = np.array(
|
||||
[[laser_world[0], laser_world[1], 0]],
|
||||
dtype=np.float32)
|
||||
px_proj, _ = cv2.projectPoints(
|
||||
P_world_3d, rvec, tvec, self._K_scaled, DIST)
|
||||
lx = int(px_proj.ravel()[0] * scale_up)
|
||||
ly = int(px_proj.ravel()[1] * scale_up)
|
||||
cv2.circle(result_image, (lx, ly), 10, (0, 0, 255), 2)
|
||||
cv2.circle(result_image, (lx, ly), 2, (0, 0, 255), -1)
|
||||
else:
|
||||
# PnP failed — still draw detected corners
|
||||
corners_scaled = (ordered_corners * scale_up).astype(np.int32)
|
||||
for pt in corners_scaled:
|
||||
cv2.circle(result_image, tuple(pt), 6, (0, 165, 255), -1)
|
||||
for i in range(4):
|
||||
j = (i + 1) % 4
|
||||
cv2.line(result_image, tuple(corners_scaled[i]),
|
||||
tuple(corners_scaled[j]), (0, 165, 255), 2)
|
||||
|
||||
# FPS overlay
|
||||
fps = cv2.getTickFrequency() / max(
|
||||
cv2.getTickCount() - self.start_time, 1)
|
||||
cv2.putText(result_image, f'FPS: {fps:.2f}', (10, 30),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
|
||||
self.start_time = cv2.getTickCount()
|
||||
|
||||
# 发布结果图像(带轮廓标注)
|
||||
# Publish result image
|
||||
result_msg = self._bridge.cv2_to_imgmsg(
|
||||
result_image, encoding='bgr8'
|
||||
)
|
||||
result_image, encoding='bgr8')
|
||||
result_msg.header.stamp = msg.header.stamp
|
||||
result_msg.header.frame_id = msg.header.frame_id
|
||||
self._result_publisher.publish(result_msg)
|
||||
|
||||
# 发布二值化图像(调试用)
|
||||
binary_color = cv2.cvtColor(eroded, cv2.COLOR_GRAY2BGR)
|
||||
# Publish binary image
|
||||
binary_color = cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR)
|
||||
binary_msg = self._bridge.cv2_to_imgmsg(
|
||||
binary_color, encoding='bgr8'
|
||||
)
|
||||
binary_color, encoding='bgr8')
|
||||
binary_msg.header.stamp = msg.header.stamp
|
||||
binary_msg.header.frame_id = msg.header.frame_id
|
||||
self._binary_publisher.publish(binary_msg)
|
||||
|
||||
@@ -5,8 +5,8 @@ import json
|
||||
import serial
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from geometry_msgs.msg import Twist, Point
|
||||
from std_msgs.msg import String
|
||||
from geometry_msgs.msg import Twist
|
||||
from std_msgs.msg import Float32MultiArray, String
|
||||
|
||||
CMD_AUTO = 0x01
|
||||
CMD_MANUAL = 0x02
|
||||
@@ -42,7 +42,7 @@ class GimbalNode(Node):
|
||||
Twist, '/cmd_gimbal', self._cmd_gimbal_callback, 10
|
||||
)
|
||||
self._target_sub = self.create_subscription(
|
||||
Point, '/vision/target', self._target_callback, 10
|
||||
Float32MultiArray, '/vision/target', self._target_callback, 10
|
||||
)
|
||||
self._pid_config_sub = self.create_subscription(
|
||||
String, '/pid_config', self._pid_config_callback, 10
|
||||
@@ -80,15 +80,22 @@ class GimbalNode(Node):
|
||||
data.append(_checksum(data))
|
||||
self._write_frame(data)
|
||||
|
||||
def _target_callback(self, msg: Point):
|
||||
def _target_callback(self, msg: Float32MultiArray):
|
||||
if self._current_mode != 'auto':
|
||||
return
|
||||
if len(msg.data) < 4:
|
||||
self.get_logger().error(
|
||||
f'Target data requires 4 values, got {len(msg.data)}')
|
||||
return
|
||||
measure_x, ref_x, measure_y, ref_y = msg.data[:4]
|
||||
self.get_logger().debug(
|
||||
f'Auto tracking: error_x={msg.x:.4f}, error_y={msg.y:.4f}'
|
||||
f'Auto tracking: measure=({measure_x:.4f},{measure_y:.4f}) '
|
||||
f'ref=({ref_x:.4f},{ref_y:.4f})'
|
||||
)
|
||||
data = bytearray()
|
||||
data.append(0xA5)
|
||||
data.extend(struct.pack('<ff', float(msg.x), float(msg.y)))
|
||||
data.extend(struct.pack(
|
||||
'<ffff', measure_x, ref_x, measure_y, ref_y))
|
||||
data.append(_checksum(data))
|
||||
self._write_frame(data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user