camera_offset_calibrator.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. #!/usr/bin/env python3
  2. """Interactive metric ground-plane calibration for the line follower.
  3. Four tape crosses with known positions relative to the vehicle front centre are
  4. clicked in the live *raw* camera image. Their matching virtual ground-plane
  5. positions are used to calculate the homography consumed by line_follow_debug.
  6. The camera remains forward-facing; the bird's-eye view is the result of the
  7. calibration, not an assumption about where the camera is mounted.
  8. """
  9. import threading
  10. import cv2
  11. import numpy as np
  12. import rospy
  13. from cv_bridge import CvBridge, CvBridgeError
  14. from sensor_msgs.msg import Image
  15. class CameraOffsetCalibrator:
  16. """Subscriber-only four-point perspective calibration tool."""
  17. DISPLAY_SCALE = 2
  18. MARKER_NAMES = ("near-left", "near-right", "far-right", "far-left")
  19. def __init__(self):
  20. rospy.init_node("camera_offset_calibrator")
  21. self.image_topic = rospy.get_param("~image_topic", "/usb_cam/image_raw")
  22. self.reference_size = self._read_size("~perspective_reference_size", [640, 480])
  23. self.marker_positions_m = self._read_world_points(
  24. "~calibration_markers_m",
  25. [[0.30, 0.20], [0.30, -0.20], [0.90, -0.20], [0.90, 0.20]],
  26. )
  27. self.metric_scale = max(1.0, float(rospy.get_param("~metric_scale_px_per_m", 400.0)))
  28. self.metric_origin = self._read_point("~metric_origin_px", [320, 480])
  29. self.bridge = CvBridge()
  30. self.lock = threading.Lock()
  31. self.source_image = None
  32. self.clicked_points = []
  33. self.last_result = None
  34. self.subscriber = rospy.Subscriber(
  35. self.image_topic, Image, self.image_callback, queue_size=1
  36. )
  37. rospy.loginfo(
  38. "Metric perspective calibration subscribes to %s only; it never publishes /cmd_vel.",
  39. self.image_topic,
  40. )
  41. rospy.loginfo(
  42. "Click four marker centres in this order: near-left, near-right, far-right, far-left."
  43. )
  44. @staticmethod
  45. def _read_size(name, default):
  46. values = rospy.get_param(name, default)
  47. if not isinstance(values, (list, tuple)) or len(values) != 2:
  48. rospy.logwarn("%s must contain [width, height]; using %s", name, default)
  49. values = default
  50. return max(1, int(values[0])), max(1, int(values[1]))
  51. @staticmethod
  52. def _read_point(name, default):
  53. values = rospy.get_param(name, default)
  54. if not isinstance(values, (list, tuple)) or len(values) != 2:
  55. rospy.logwarn("%s must contain [x, y]; using %s", name, default)
  56. values = default
  57. return np.array(values, dtype=np.float32)
  58. @staticmethod
  59. def _read_world_points(name, default):
  60. values = rospy.get_param(name, default)
  61. valid = isinstance(values, (list, tuple)) and len(values) == 4
  62. if valid:
  63. valid = all(isinstance(point, (list, tuple)) and len(point) == 2 for point in values)
  64. if not valid:
  65. rospy.logwarn("%s must contain four [forward_m, left_m] points; using defaults", name)
  66. values = default
  67. return np.array(values, dtype=np.float32)
  68. def image_callback(self, message):
  69. try:
  70. raw = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
  71. except CvBridgeError as error:
  72. rospy.logwarn_throttle(2.0, "Cannot convert camera image: %s", error)
  73. return
  74. # Calibration values are deliberately recorded in the same 640x480
  75. # reference coordinate system as line_follow_debug.yaml.
  76. source = cv2.resize(raw, self.reference_size, interpolation=cv2.INTER_AREA)
  77. with self.lock:
  78. self.source_image = source
  79. def destination_points(self):
  80. """Map vehicle-ground coordinates (forward, left) to a virtual image."""
  81. forward = self.marker_positions_m[:, 0]
  82. left = self.marker_positions_m[:, 1]
  83. return np.column_stack(
  84. (
  85. self.metric_origin[0] - left * self.metric_scale,
  86. self.metric_origin[1] - forward * self.metric_scale,
  87. )
  88. ).astype(np.float32)
  89. def result_matrix(self):
  90. if len(self.clicked_points) != 4:
  91. return None
  92. return cv2.getPerspectiveTransform(
  93. np.array(self.clicked_points, dtype=np.float32), self.destination_points()
  94. )
  95. def log_result(self):
  96. matrix = self.result_matrix()
  97. if matrix is None:
  98. return
  99. source = [[int(x), int(y)] for x, y in self.clicked_points]
  100. destination = [[round(float(x), 1), round(float(y), 1)] for x, y in self.destination_points()]
  101. self.last_result = (source, destination)
  102. rospy.loginfo("=" * 64)
  103. rospy.loginfo("METRIC PERSPECTIVE CALIBRATION RESULT")
  104. rospy.loginfo("src_pts: %s", source)
  105. rospy.loginfo("dst_pts: %s", destination)
  106. rospy.loginfo("metric_scale_px_per_m: %.1f", self.metric_scale)
  107. rospy.loginfo("metric_origin_px: [%d, %d]", int(self.metric_origin[0]), int(self.metric_origin[1]))
  108. rospy.loginfo("Copy only src_pts and dst_pts into line_follow_debug.yaml.")
  109. rospy.loginfo("=" * 64)
  110. def mouse_callback(self, event, x, y, _flags, _userdata):
  111. if event == cv2.EVENT_RBUTTONDOWN:
  112. self.clicked_points = []
  113. self.last_result = None
  114. rospy.loginfo("Calibration points cleared.")
  115. return
  116. if event != cv2.EVENT_LBUTTONDOWN:
  117. return
  118. point = (int(x / self.DISPLAY_SCALE), int(y / self.DISPLAY_SCALE))
  119. with self.lock:
  120. source = self.source_image
  121. if source is None or not (0 <= point[0] < source.shape[1] and 0 <= point[1] < source.shape[0]):
  122. return
  123. if len(self.clicked_points) >= 4:
  124. rospy.logwarn("Four points are already selected. Press R or right-click to start again.")
  125. return
  126. self.clicked_points.append(point)
  127. index = len(self.clicked_points) - 1
  128. marker = self.marker_positions_m[index]
  129. rospy.loginfo(
  130. "Point %d/4 (%s): image=[%d, %d], ground=[forward %.3f m, left %.3f m]",
  131. index + 1,
  132. self.MARKER_NAMES[index],
  133. point[0], point[1], marker[0], marker[1],
  134. )
  135. if len(self.clicked_points) == 4:
  136. self.log_result()
  137. def source_display(self, source):
  138. view = source.copy()
  139. for index, point in enumerate(self.clicked_points):
  140. color = (0, 0, 255) if index < 2 else (0, 255, 255)
  141. cv2.circle(view, point, 5, color, -1)
  142. cv2.putText(
  143. view,
  144. "%d %s" % (index + 1, self.MARKER_NAMES[index]),
  145. (point[0] + 8, point[1] - 8),
  146. cv2.FONT_HERSHEY_SIMPLEX,
  147. 0.42,
  148. color,
  149. 1,
  150. )
  151. if len(self.clicked_points) == 4:
  152. cv2.polylines(view, [np.array(self.clicked_points, dtype=np.int32)], True, (0, 255, 255), 1)
  153. cv2.putText(view, "RAW CAMERA: CLICK 4 GROUND MARKERS", (10, 28),
  154. cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2)
  155. cv2.putText(view, "1 near-L 2 near-R 3 far-R 4 far-L", (10, 53),
  156. cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 255), 1)
  157. return cv2.resize(
  158. view, (view.shape[1] * self.DISPLAY_SCALE, view.shape[0] * self.DISPLAY_SCALE),
  159. interpolation=cv2.INTER_LINEAR,
  160. )
  161. def draw_ground_grid(self, image):
  162. """Draw a 10 cm vehicle-coordinate grid in the virtual view."""
  163. view = image.copy()
  164. height, width = view.shape[:2]
  165. max_forward = int((self.metric_origin[1] + 1) / self.metric_scale) + 1
  166. half_width = int(max(self.metric_origin[0], width - self.metric_origin[0]) / self.metric_scale) + 1
  167. for forward_cm in range(0, max_forward * 10 + 1, 10):
  168. forward = forward_cm / 10.0
  169. y = int(round(self.metric_origin[1] - forward * self.metric_scale))
  170. if 0 <= y < height:
  171. color = (55, 55, 55) if forward_cm % 50 else (100, 100, 100)
  172. cv2.line(view, (0, y), (width - 1, y), color, 1)
  173. for left_cm in range(-half_width * 10, half_width * 10 + 1, 10):
  174. left = left_cm / 10.0
  175. x = int(round(self.metric_origin[0] - left * self.metric_scale))
  176. if 0 <= x < width:
  177. color = (55, 55, 55) if left_cm % 50 else (100, 100, 100)
  178. cv2.line(view, (x, 0), (x, height - 1), color, 1)
  179. if 0 <= int(self.metric_origin[0]) < width and 0 <= int(self.metric_origin[1]) < height:
  180. cv2.drawMarker(
  181. view, tuple(self.metric_origin.astype(int)), (0, 255, 0), cv2.MARKER_CROSS, 14, 2
  182. )
  183. return view
  184. def bird_display(self, source):
  185. matrix = self.result_matrix()
  186. if matrix is None:
  187. bird = np.zeros_like(source)
  188. text = "Select %d more point(s)" % (4 - len(self.clicked_points))
  189. cv2.putText(bird, text, (20, 42), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
  190. else:
  191. bird = cv2.warpPerspective(source, matrix, self.reference_size, flags=cv2.INTER_LINEAR)
  192. bird = self.draw_ground_grid(bird)
  193. cv2.putText(bird, "METRIC BIRD VIEW (origin = vehicle front centre)", (10, 28),
  194. cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0, 255, 255), 1)
  195. cv2.putText(bird, "S: print result R/right-click: reset Q/Esc: quit", (10, 50),
  196. cv2.FONT_HERSHEY_SIMPLEX, 0.43, (0, 255, 255), 1)
  197. return cv2.resize(
  198. bird, (bird.shape[1] * self.DISPLAY_SCALE, bird.shape[0] * self.DISPLAY_SCALE),
  199. interpolation=cv2.INTER_LINEAR,
  200. )
  201. def run(self):
  202. source_window = "Metric perspective calibration: raw camera"
  203. bird_window = "Metric perspective calibration: calibrated ground view"
  204. cv2.namedWindow(source_window, cv2.WINDOW_AUTOSIZE)
  205. cv2.namedWindow(bird_window, cv2.WINDOW_AUTOSIZE)
  206. cv2.setMouseCallback(source_window, self.mouse_callback)
  207. rate = rospy.Rate(15)
  208. while not rospy.is_shutdown():
  209. with self.lock:
  210. source = None if self.source_image is None else self.source_image.copy()
  211. if source is not None:
  212. cv2.imshow(source_window, self.source_display(source))
  213. cv2.imshow(bird_window, self.bird_display(source))
  214. key = cv2.waitKey(1) & 0xFF
  215. if key in (ord("r"), ord("R")):
  216. self.clicked_points = []
  217. self.last_result = None
  218. rospy.loginfo("Calibration points cleared.")
  219. elif key in (ord("s"), ord("S")):
  220. self.log_result()
  221. elif key in (ord("q"), ord("Q"), 27):
  222. break
  223. rate.sleep()
  224. cv2.destroyAllWindows()
  225. if __name__ == "__main__":
  226. try:
  227. CameraOffsetCalibrator().run()
  228. except rospy.ROSInterruptException:
  229. pass