line_follow_debug.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. #!/usr/bin/env python3
  2. """Visualise white-line candidates without ever commanding the vehicle."""
  3. import math
  4. import threading
  5. import cv2
  6. import numpy as np
  7. import rospy
  8. from cv_bridge import CvBridge, CvBridgeError
  9. from geometry_msgs.msg import PointStamped
  10. from sensor_msgs.msg import Image
  11. from std_msgs.msg import Bool, Float32, String
  12. class LineFollowDebug:
  13. """Subscriber-only line visualiser used to tune the real route safely."""
  14. def __init__(self):
  15. rospy.init_node("line_follow_debug")
  16. self.image_topic = rospy.get_param("~image_topic", "/usb_cam/image_raw")
  17. self.lower = self._read_hsv("~hsv_lower", [0, 0, 180])
  18. self.upper = self._read_hsv("~hsv_upper", [180, 60, 255])
  19. self.use_perspective_transform = bool(
  20. rospy.get_param("~use_perspective_transform", True)
  21. )
  22. self.perspective_reference_size = self._read_size(
  23. "~perspective_reference_size", [640, 480]
  24. )
  25. self.src_points = self._read_points(
  26. "~src_pts", [[120, 205], [520, 205], [639, 479], [0, 479]]
  27. )
  28. self.dst_points = self._read_points(
  29. "~dst_pts", [[100, 200], [540, 200], [540, 479], [100, 479]]
  30. )
  31. self.processing_scale = self._read_ratio("~processing_scale", 0.5)
  32. self.processing_scale = max(0.25, self.processing_scale)
  33. self.display_rate = max(1.0, float(rospy.get_param("~display_rate", 10.0)))
  34. self.use_camera_front_offset = self._read_bool("~use_camera_front_offset", True)
  35. self.camera_to_front_offset_m = max(
  36. 0.0, float(rospy.get_param("~camera_to_front_offset_m", 0.125))
  37. )
  38. self.metric_scale_px_per_m = max(
  39. 1.0, float(rospy.get_param("~metric_scale_px_per_m", 400.0))
  40. )
  41. self.metric_origin_px = self._read_metric_origin(
  42. "~metric_origin_px", [320.0, 480.0]
  43. )
  44. self.lookahead_distance_m = max(
  45. 0.05, float(rospy.get_param("~lookahead_distance_m", 0.50))
  46. )
  47. self.normal_lookahead_distance_m = max(
  48. 0.05,
  49. float(
  50. rospy.get_param(
  51. "~normal_lookahead_distance_m", self.lookahead_distance_m
  52. )
  53. ),
  54. )
  55. self.after_second_lookahead_distance_m = max(
  56. 0.05,
  57. float(
  58. rospy.get_param(
  59. "~after_second_lookahead_distance_m",
  60. self.normal_lookahead_distance_m,
  61. )
  62. ),
  63. )
  64. self.after_second_inner_offset_m = max(
  65. 0.0, float(rospy.get_param("~after_second_inner_offset_m", 0.025))
  66. )
  67. self.inner_offset_activation_m = max(
  68. 0.0, float(rospy.get_param("~inner_offset_activation_m", 0.015))
  69. )
  70. self.task_state_topic = str(
  71. rospy.get_param("~task_state_topic", "/traffic_line_task/state")
  72. )
  73. self.ipm_origin_ahead_of_control_m = max(
  74. 0.0, float(rospy.get_param("~ipm_origin_ahead_of_control_m", 0.125))
  75. )
  76. self.max_near_target_extrapolation_m = max(
  77. 0.0,
  78. float(rospy.get_param("~max_near_target_extrapolation_m", 0.25)),
  79. )
  80. self.lookahead_frame_id = str(
  81. rospy.get_param("~lookahead_frame_id", "base_link")
  82. )
  83. self.camera_front_offset_px = max(
  84. 0, int(rospy.get_param("~camera_front_offset_px", 0))
  85. )
  86. self.roi_top_ratio = self._read_ratio("~roi_top_ratio", 0.52)
  87. self.scanline_ratios = self._read_ratios(
  88. "~scanline_ratios", [0.70, 0.66, 0.62, 0.58, 0.54, 0.50, 0.46]
  89. )
  90. self.scanline_half_height = max(1, int(rospy.get_param("~scanline_half_height", 5)))
  91. self.min_run_width = max(1, int(rospy.get_param("~min_run_width", 3)))
  92. self.min_component_area = max(1, int(rospy.get_param("~min_component_area", 30)))
  93. self.use_component_filter = self._read_bool("~use_component_filter", False)
  94. self.center_reflection_half_width_ratio = self._read_ratio(
  95. "~center_reflection_half_width_ratio", 0.12
  96. )
  97. self.min_lane_width_ratio = self._read_ratio("~min_lane_width_ratio", 0.25)
  98. self.max_lane_width_ratio = self._read_ratio("~max_lane_width_ratio", 0.98)
  99. self.use_single_boundary_fallback = self._read_bool(
  100. "~use_single_boundary_fallback", True
  101. )
  102. self.single_boundary_timeout = max(
  103. 0.1, float(rospy.get_param("~single_boundary_timeout", 1.5))
  104. )
  105. self.single_boundary_side_memory_timeout = max(
  106. 0.1,
  107. float(rospy.get_param("~single_boundary_side_memory_timeout", 0.75)),
  108. )
  109. self.lane_width_alpha = min(
  110. 1.0, max(0.01, float(rospy.get_param("~lane_width_alpha", 0.2)))
  111. )
  112. self.max_centerline_heading_rad = max(
  113. 0.05, float(rospy.get_param("~max_centerline_heading_rad", 0.70))
  114. )
  115. kernel_size = max(1, int(rospy.get_param("~morphology_kernel", 3)))
  116. if kernel_size % 2 == 0:
  117. kernel_size += 1
  118. self.kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
  119. self.bridge = CvBridge()
  120. self.lock = threading.Lock()
  121. self.display_image = None
  122. self.filtered_lane_width_px = None
  123. self.last_two_boundary_time = None
  124. self.single_boundary_side = None
  125. self.last_single_boundary_time = None
  126. self.after_second_active = False
  127. self.lane_error_pub = rospy.Publisher("~lane_error", Float32, queue_size=1)
  128. self.lane_heading_pub = rospy.Publisher("~lane_heading_error", Float32, queue_size=1)
  129. self.lane_valid_pub = rospy.Publisher("~lane_valid", Bool, queue_size=1)
  130. self.lookahead_target_pub = rospy.Publisher(
  131. "~lookahead_target", PointStamped, queue_size=1
  132. )
  133. self.subscriber = rospy.Subscriber(
  134. self.image_topic, Image, self.image_callback, queue_size=1
  135. )
  136. self.task_state_subscriber = rospy.Subscriber(
  137. self.task_state_topic, String, self.task_state_callback, queue_size=1
  138. )
  139. rospy.loginfo(
  140. "Line-follow debug is visualisation only; IPM=%s, subscribed to %s and will not publish /cmd_vel.",
  141. self.use_perspective_transform,
  142. self.image_topic,
  143. )
  144. def task_state_callback(self, message):
  145. after_second = message.data == "FOLLOW_AFTER_SECOND"
  146. requested = (
  147. self.after_second_lookahead_distance_m
  148. if after_second
  149. else self.normal_lookahead_distance_m
  150. )
  151. with self.lock:
  152. previous = self.lookahead_distance_m
  153. self.lookahead_distance_m = requested
  154. self.after_second_active = after_second
  155. if abs(previous - requested) > 1e-6:
  156. rospy.loginfo(
  157. "Line-follow lookahead changed to %.2f m for task state %s.",
  158. requested,
  159. message.data,
  160. )
  161. def apply_after_second_inner_offset(
  162. self, target_left, target_x, metric_scale
  163. ):
  164. """Move a curved-route target toward its inside only after turn two."""
  165. if (
  166. not self.after_second_active
  167. or abs(target_left) < self.inner_offset_activation_m
  168. or self.after_second_inner_offset_m <= 0.0
  169. ):
  170. return target_left, target_x
  171. direction = 1.0 if target_left > 0.0 else -1.0
  172. offset = direction * self.after_second_inner_offset_m
  173. # base_link +left maps to decreasing IPM image x.
  174. return target_left + offset, target_x - offset * metric_scale
  175. @staticmethod
  176. def _read_ratio(name, default):
  177. return float(max(0.0, min(1.0, rospy.get_param(name, default))))
  178. @staticmethod
  179. def _read_ratios(name, default):
  180. values = rospy.get_param(name, default)
  181. if not isinstance(values, (list, tuple)) or not values:
  182. rospy.logwarn("%s must be a non-empty list; using %s", name, default)
  183. values = default
  184. return sorted(
  185. [float(max(0.0, min(1.0, value))) for value in values], reverse=True
  186. )
  187. @staticmethod
  188. def _read_hsv(name, default):
  189. values = rospy.get_param(name, default)
  190. if not isinstance(values, (list, tuple)) or len(values) != 3:
  191. rospy.logwarn("%s must have three values; using %s", name, default)
  192. values = default
  193. values = [int(max(0, min(255, value))) for value in values]
  194. values[0] = min(180, values[0])
  195. return np.array(values, dtype=np.uint8)
  196. @staticmethod
  197. def _read_size(name, default):
  198. values = rospy.get_param(name, default)
  199. if not isinstance(values, (list, tuple)) or len(values) != 2:
  200. rospy.logwarn("%s must contain [width, height]; using %s", name, default)
  201. values = default
  202. return max(1, int(values[0])), max(1, int(values[1]))
  203. @staticmethod
  204. def _read_metric_origin(name, default):
  205. values = rospy.get_param(name, default)
  206. if not isinstance(values, (list, tuple)) or len(values) != 2:
  207. rospy.logwarn("%s must contain [x, y]; using %s", name, default)
  208. values = default
  209. return float(values[0]), float(values[1])
  210. @staticmethod
  211. def _read_bool(name, default):
  212. value = rospy.get_param(name, default)
  213. if isinstance(value, str):
  214. return value.strip().lower() in ("1", "true", "yes", "on")
  215. return bool(value)
  216. @staticmethod
  217. def _read_points(name, default):
  218. values = rospy.get_param(name, default)
  219. valid = isinstance(values, (list, tuple)) and len(values) == 4
  220. if valid:
  221. valid = all(isinstance(point, (list, tuple)) and len(point) == 2 for point in values)
  222. if not valid:
  223. rospy.logwarn("%s must contain four [x, y] points; using defaults", name)
  224. values = default
  225. return np.array(values, dtype=np.float32)
  226. def image_callback(self, message):
  227. try:
  228. image = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
  229. except CvBridgeError as error:
  230. rospy.logwarn_throttle(2.0, "Cannot convert camera image: %s", error)
  231. return
  232. # Perception and lane publication run here at camera speed. OpenCV GUI
  233. # display is deliberately kept out of this callback.
  234. display_image = self.make_display(image)
  235. with self.lock:
  236. self.display_image = display_image
  237. @staticmethod
  238. def white_runs(scanline, min_width):
  239. """Return contiguous white x-ranges from a horizontal mask scanline."""
  240. active = scanline > 0
  241. padded = np.pad(active.astype(np.int8), (1, 1), mode="constant")
  242. changes = np.flatnonzero(np.diff(padded))
  243. runs = []
  244. for start, end in zip(changes[0::2], changes[1::2]):
  245. if end - start >= min_width:
  246. runs.append((int(start), int(end - 1)))
  247. return runs
  248. def scan_data_at(self, mask, roi_top, scan_y):
  249. """Return white runs split around the vehicle centre for one band."""
  250. height, width = mask.shape
  251. centre_x = width // 2
  252. exclusion_half_width = int(width * self.center_reflection_half_width_ratio)
  253. exclusion_left = centre_x - exclusion_half_width
  254. exclusion_right = centre_x + exclusion_half_width
  255. scan_y = min(height - 1, max(roi_top, int(scan_y)))
  256. band_top = max(roi_top, scan_y - self.scanline_half_height)
  257. band_bottom = min(height, scan_y + self.scanline_half_height + 1)
  258. scanline = np.max(mask[band_top:band_bottom, :], axis=0)
  259. runs = self.white_runs(scanline, self.min_run_width)
  260. left_runs = [run for run in runs if run[1] < exclusion_left]
  261. right_runs = [run for run in runs if run[0] > exclusion_right]
  262. return (
  263. scan_y, band_top, band_bottom, runs, left_runs, right_runs,
  264. exclusion_left, exclusion_right,
  265. )
  266. def lane_pair_at(self, mask, roi_top, scan_y):
  267. """Find one valid left/right boundary pair in a given horizontal band."""
  268. width = mask.shape[1]
  269. min_lane_width = int(width * self.min_lane_width_ratio)
  270. max_lane_width = int(width * self.max_lane_width_ratio)
  271. data = self.scan_data_at(mask, roi_top, scan_y)
  272. (
  273. scan_y, band_top, band_bottom, runs, left_runs, right_runs,
  274. exclusion_left, exclusion_right,
  275. ) = data
  276. # A central highlight may be white in HSV, but it cannot become a
  277. # lane boundary. Keep only runs completely outside its band.
  278. if not left_runs or not right_runs:
  279. return None
  280. left = max(left_runs, key=lambda run: run[1])
  281. right = min(right_runs, key=lambda run: run[0])
  282. left_x = (left[0] + left[1]) // 2
  283. right_x = (right[0] + right[1]) // 2
  284. lane_width = right_x - left_x
  285. if min_lane_width <= lane_width <= max_lane_width:
  286. return scan_y, band_top, band_bottom, runs, left, right, exclusion_left, exclusion_right
  287. return None
  288. def single_boundary_at(self, mask, roi_top, scan_y):
  289. """Infer the lane midpoint from one boundary and recent measured width."""
  290. if self.filtered_lane_width_px is None or self.last_two_boundary_time is None:
  291. return None
  292. if rospy.get_time() - self.last_two_boundary_time > self.single_boundary_timeout:
  293. return None
  294. width = mask.shape[1]
  295. data = self.scan_data_at(mask, roi_top, scan_y)
  296. (
  297. scan_y, band_top, band_bottom, runs, left_runs, right_runs,
  298. _exclusion_left, _exclusion_right,
  299. ) = data
  300. lane_width = float(self.filtered_lane_width_px)
  301. now = rospy.get_time()
  302. remembered_side = None
  303. if (
  304. self.single_boundary_side in ("LEFT", "RIGHT")
  305. and self.last_single_boundary_time is not None
  306. and now - self.last_single_boundary_time
  307. <= self.single_boundary_side_memory_timeout
  308. ):
  309. remembered_side = self.single_boundary_side
  310. if remembered_side is not None:
  311. # During a bend the same physical outer boundary can cross the
  312. # image centre. Do not relabel it merely because x changed sides.
  313. if not runs:
  314. return None
  315. if left_runs and right_runs:
  316. # Two distinct sides that fail the lane-width check are still
  317. # ambiguous; side memory must not turn them into one boundary.
  318. return None
  319. if remembered_side == "LEFT":
  320. boundary = min(runs, key=lambda run: run[0] + run[1])
  321. visible_side = "LEFT"
  322. else:
  323. boundary = max(runs, key=lambda run: run[0] + run[1])
  324. visible_side = "RIGHT"
  325. else:
  326. # At fallback entry exactly one image side must be visible. This
  327. # establishes the physical side identity used by later frames.
  328. if bool(left_runs) == bool(right_runs):
  329. return None
  330. if left_runs:
  331. boundary = max(left_runs, key=lambda run: run[1])
  332. visible_side = "LEFT"
  333. else:
  334. boundary = min(right_runs, key=lambda run: run[0])
  335. visible_side = "RIGHT"
  336. boundary_x = (boundary[0] + boundary[1]) // 2
  337. if visible_side == "LEFT":
  338. target_x = int(round(boundary_x + 0.5 * lane_width))
  339. else:
  340. target_x = int(round(boundary_x - 0.5 * lane_width))
  341. if not (0 <= target_x < width):
  342. return None
  343. return scan_y, band_top, band_bottom, runs, boundary, boundary_x, target_x, visible_side
  344. def select_lane_pair(self, mask, roi_top):
  345. """Find the nearest valid left/right pair, rejecting LED reflections."""
  346. height, _ = mask.shape
  347. for ratio in self.scanline_ratios:
  348. selection = self.lane_pair_at(mask, roi_top, int(height * ratio))
  349. if selection is not None:
  350. return selection
  351. return None
  352. def select_single_boundary(self, mask, roi_top, front_offset_px):
  353. """Prefer the compensated near band, then retry the ordinary bands."""
  354. height, _ = mask.shape
  355. offsets = [front_offset_px]
  356. if front_offset_px != 0:
  357. offsets.append(0)
  358. for offset in offsets:
  359. for ratio in self.scanline_ratios:
  360. selection = self.single_boundary_at(
  361. mask, roi_top, int(height * ratio) + offset
  362. )
  363. if selection is not None:
  364. return selection
  365. return None
  366. def front_offset_for_width(self, width):
  367. """Convert the configured physical nose margin to processing pixels."""
  368. if not self.use_camera_front_offset:
  369. return 0
  370. reference_width = float(self.perspective_reference_size[0])
  371. if self.camera_front_offset_px > 0:
  372. reference_offset_px = self.camera_front_offset_px
  373. else:
  374. reference_offset_px = self.camera_to_front_offset_m * self.metric_scale_px_per_m
  375. return int(round(reference_offset_px * width / reference_width))
  376. def centerline_points(self, mask, roi_top, front_offset_px):
  377. """Collect lane-centre samples from several near/far ground bands."""
  378. height, _ = mask.shape
  379. points_by_y = {}
  380. offsets = [front_offset_px]
  381. if front_offset_px != 0:
  382. offsets.append(0)
  383. for offset in offsets:
  384. for ratio in self.scanline_ratios:
  385. requested_y = int(height * ratio) + offset
  386. pair = self.lane_pair_at(mask, roi_top, requested_y)
  387. if pair is not None:
  388. scan_y, _top, _bottom, _runs, left, right, _el, _er = pair
  389. left_x = (left[0] + left[1]) // 2
  390. right_x = (right[0] + right[1]) // 2
  391. points_by_y[scan_y] = (left_x + right_x) // 2
  392. continue
  393. if self.use_single_boundary_fallback:
  394. single = self.single_boundary_at(mask, roi_top, requested_y)
  395. if single is not None:
  396. scan_y = single[0]
  397. points_by_y[scan_y] = single[6]
  398. # Compensated samples are preferred. Use ordinary bands only if
  399. # the near set alone cannot describe a curve.
  400. if len(points_by_y) >= 3:
  401. break
  402. return sorted(
  403. [(int(x), int(y)) for y, x in points_by_y.items()],
  404. key=lambda point: point[1],
  405. )
  406. def fit_centerline(self, points):
  407. """Fit x(y), returning drawable curve, heading, and polynomial."""
  408. if len(points) < 2:
  409. return points, 0.0, None
  410. xs = np.array([point[0] for point in points], dtype=np.float64)
  411. ys = np.array([point[1] for point in points], dtype=np.float64)
  412. degree = 2 if len(points) >= 3 else 1
  413. coefficients = np.polyfit(ys, xs, degree)
  414. sample_ys = np.linspace(float(np.min(ys)), float(np.max(ys)), 30)
  415. sample_xs = np.polyval(coefficients, sample_ys)
  416. curve = [
  417. (int(round(x)), int(round(y)))
  418. for x, y in zip(sample_xs, sample_ys)
  419. if np.isfinite(x) and np.isfinite(y)
  420. ]
  421. heading_y = 0.5 * (float(np.min(ys)) + float(np.max(ys)))
  422. derivative = np.polyval(np.polyder(coefficients), heading_y)
  423. # Image x grows to physical right, while forward grows toward smaller
  424. # image y. Positive heading therefore means a right-hand curve.
  425. heading = float(np.arctan(-derivative))
  426. heading = max(
  427. -self.max_centerline_heading_rad,
  428. min(self.max_centerline_heading_rad, heading),
  429. )
  430. return curve, heading, coefficients
  431. def lookahead_target_from_fit(self, coefficients, center_samples, frame_shape):
  432. """Convert a fitted IPM centreline into a metric base-frame target."""
  433. if coefficients is None or len(center_samples) < 2:
  434. return None
  435. height, width = frame_shape[:2]
  436. reference_width, reference_height = self.perspective_reference_size
  437. scale_x = width / float(reference_width)
  438. scale_y = height / float(reference_height)
  439. # The metric IPM calibration uses the same scale in both directions.
  440. metric_scale = self.metric_scale_px_per_m * scale_x
  441. if metric_scale <= 0.0:
  442. return None
  443. origin_x = self.metric_origin_px[0] * scale_x
  444. origin_y = self.metric_origin_px[1] * scale_y
  445. forward_from_ipm_origin = max(
  446. 0.0, self.lookahead_distance_m - self.ipm_origin_ahead_of_control_m
  447. )
  448. requested_y = origin_y - forward_from_ipm_origin * metric_scale
  449. sample_ys = [float(point[1]) for point in center_samples]
  450. far_y = min(sample_ys)
  451. near_y = max(sample_ys)
  452. max_near_y = min(
  453. float(height - 1),
  454. near_y + self.max_near_target_extrapolation_m * metric_scale,
  455. )
  456. target_y = min(max(requested_y, far_y), max_near_y)
  457. if target_y > near_y:
  458. # Continue only the nearest fitted tangent toward the vehicle.
  459. # Direct quadratic extrapolation can grow rapidly and select a
  460. # false branch at a junction.
  461. near_x = float(np.polyval(coefficients, near_y))
  462. near_slope = float(np.polyval(np.polyder(coefficients), near_y))
  463. max_slope = math.tan(self.max_centerline_heading_rad)
  464. near_slope = max(-max_slope, min(max_slope, near_slope))
  465. target_x = near_x + near_slope * (target_y - near_y)
  466. else:
  467. target_x = float(np.polyval(coefficients, target_y))
  468. target_forward = (
  469. self.ipm_origin_ahead_of_control_m
  470. + (origin_y - target_y) / metric_scale
  471. )
  472. target_left = -(target_x - origin_x) / metric_scale
  473. target_left, target_x = self.apply_after_second_inner_offset(
  474. target_left, target_x, metric_scale
  475. )
  476. values = (target_forward, target_left, target_x, target_y)
  477. if not all(np.isfinite(value) for value in values) or target_forward <= 0.0:
  478. return None
  479. return values
  480. def remove_small_components(self, mask):
  481. """Keep only connected white regions that can plausibly be route lines."""
  482. count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
  483. # Vectorised lookup: the old per-component loop compared every label
  484. # against the full image and reduced the real-time rate to ~2 Hz.
  485. keep = stats[:, cv2.CC_STAT_AREA] >= self.min_component_area
  486. keep[0] = False # Label 0 is the black background.
  487. return (keep[labels].astype(np.uint8) * 255)
  488. def scaled_perspective_points(self, frame_shape):
  489. """Return source and destination IPM points for the current resolution."""
  490. height, width = frame_shape[:2]
  491. reference_width, reference_height = self.perspective_reference_size
  492. scale = np.array([width / float(reference_width), height / float(reference_height)], dtype=np.float32)
  493. src = self.src_points * scale
  494. dst = self.dst_points * scale
  495. return src, dst
  496. def perspective_warp(self, frame):
  497. """Warp the configured ground trapezoid to a bird's-eye rectangle."""
  498. height, width = frame.shape[:2]
  499. src, dst = self.scaled_perspective_points(frame.shape)
  500. matrix = cv2.getPerspectiveTransform(src, dst)
  501. bird = cv2.warpPerspective(frame, matrix, (width, height), flags=cv2.INTER_LINEAR)
  502. return bird, src.astype(np.int32)
  503. def make_line_views(self, frame):
  504. height, width = frame.shape[:2]
  505. roi_top = int(height * self.roi_top_ratio)
  506. hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
  507. mask = cv2.inRange(hsv, self.lower, self.upper)
  508. mask[:roi_top, :] = 0
  509. mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, self.kernel)
  510. mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, self.kernel)
  511. if self.use_component_filter:
  512. mask = self.remove_small_components(mask)
  513. centre_x = width // 2
  514. exclusion_half_width = int(width * self.center_reflection_half_width_ratio)
  515. exclusion_left = centre_x - exclusion_half_width
  516. exclusion_right = centre_x + exclusion_half_width
  517. # LED glare is not a route marking. It is removed from the binary
  518. # image, rather than merely being rejected after candidate selection.
  519. mask[roi_top:, exclusion_left:exclusion_right + 1] = 0
  520. selection = self.select_lane_pair(mask, roi_top)
  521. front_offset_px = self.front_offset_for_width(width)
  522. if selection is not None and self.use_camera_front_offset:
  523. initial_scan_y = selection[0]
  524. # Larger bird-view y is closer to the vehicle. Move the control
  525. # band toward the nose by the requested physical distance.
  526. compensated = self.lane_pair_at(mask, roi_top, initial_scan_y + front_offset_px)
  527. if compensated is not None:
  528. selection = compensated
  529. single_selection = None
  530. if selection is None and self.use_single_boundary_fallback:
  531. single_selection = self.select_single_boundary(mask, roi_top, front_offset_px)
  532. annotated = frame.copy()
  533. cv2.rectangle(annotated, (0, roi_top), (width - 1, height - 1), (0, 255, 255), 2)
  534. cv2.line(annotated, (centre_x, roi_top), (centre_x, height - 1), (120, 120, 120), 1)
  535. overlay = annotated.copy()
  536. cv2.rectangle(overlay, (exclusion_left, roi_top), (exclusion_right, height - 1), (0, 0, 0), -1)
  537. annotated = cv2.addWeighted(overlay, 0.25, annotated, 0.75, 0)
  538. cv2.rectangle(annotated, (exclusion_left, roi_top), (exclusion_right, height - 1), (90, 90, 90), 1)
  539. state = "NO LANE BOUNDARIES"
  540. scan_y = None
  541. lane_result_valid = False
  542. if selection is not None:
  543. scan_y, band_top, band_bottom, runs, left, right, exclusion_left, exclusion_right = selection
  544. cv2.rectangle(annotated, (0, band_top), (width - 1, band_bottom - 1), (255, 255, 0), 1)
  545. for start, end in runs:
  546. cv2.line(annotated, (start, scan_y), (end, scan_y), (0, 165, 255), 4)
  547. cv2.circle(annotated, ((left[0] + left[1]) // 2, scan_y), 7, (255, 0, 0), -1)
  548. cv2.circle(annotated, ((right[0] + right[1]) // 2, scan_y), 7, (0, 0, 255), -1)
  549. left_x = (left[0] + left[1]) // 2
  550. right_x = (right[0] + right[1]) // 2
  551. measured_lane_width = float(right_x - left_x)
  552. if self.filtered_lane_width_px is None:
  553. self.filtered_lane_width_px = measured_lane_width
  554. else:
  555. self.filtered_lane_width_px = (
  556. self.lane_width_alpha * measured_lane_width
  557. + (1.0 - self.lane_width_alpha) * self.filtered_lane_width_px
  558. )
  559. self.last_two_boundary_time = rospy.get_time()
  560. self.single_boundary_side = None
  561. self.last_single_boundary_time = None
  562. target_x = (left_x + right_x) // 2
  563. # The controller keeps the 640-pixel error convention even while
  564. # this node processes a smaller real-time image.
  565. error = (target_x - centre_x) * (self.perspective_reference_size[0] / float(width))
  566. state = "LANE MIDPOINT x=%d error=%+.0f px scan=%d offset=%d" % (
  567. target_x, error, scan_y, front_offset_px
  568. )
  569. lane_result_valid = True
  570. self.lane_valid_pub.publish(Bool(data=True))
  571. self.lane_error_pub.publish(Float32(data=float(error)))
  572. elif single_selection is not None:
  573. (
  574. scan_y, band_top, band_bottom, runs, boundary, boundary_x,
  575. target_x, visible_side,
  576. ) = single_selection
  577. cv2.rectangle(annotated, (0, band_top), (width - 1, band_bottom - 1), (255, 0, 255), 1)
  578. cv2.line(annotated, (boundary[0], scan_y), (boundary[1], scan_y), (0, 165, 255), 4)
  579. cv2.circle(annotated, (boundary_x, scan_y), 7, (0, 0, 255), -1)
  580. cv2.circle(annotated, (target_x, scan_y), 7, (255, 0, 255), -1)
  581. error = (target_x - centre_x) * (
  582. self.perspective_reference_size[0] / float(width)
  583. )
  584. age = rospy.get_time() - self.last_two_boundary_time
  585. self.single_boundary_side = visible_side
  586. self.last_single_boundary_time = rospy.get_time()
  587. state = "ONE %s BOUNDARY midpoint x=%d error=%+.0f px age=%.1fs" % (
  588. visible_side, target_x, error, age
  589. )
  590. lane_result_valid = True
  591. self.lane_valid_pub.publish(Bool(data=True))
  592. self.lane_error_pub.publish(Float32(data=float(error)))
  593. else:
  594. if (
  595. self.last_single_boundary_time is not None
  596. and rospy.get_time() - self.last_single_boundary_time
  597. > self.single_boundary_side_memory_timeout
  598. ):
  599. self.single_boundary_side = None
  600. self.last_single_boundary_time = None
  601. self.lane_valid_pub.publish(Bool(data=False))
  602. if lane_result_valid:
  603. center_samples = self.centerline_points(mask, roi_top, front_offset_px)
  604. center_curve, heading_error, coefficients = self.fit_centerline(center_samples)
  605. if len(center_curve) >= 2:
  606. cv2.polylines(
  607. annotated,
  608. [np.array(center_curve, dtype=np.int32)],
  609. False,
  610. (0, 255, 0),
  611. 2,
  612. )
  613. for sample_x, sample_y in center_samples:
  614. cv2.circle(annotated, (sample_x, sample_y), 3, (0, 255, 0), -1)
  615. self.lane_heading_pub.publish(Float32(data=heading_error))
  616. state += " heading=%+.1fdeg" % np.degrees(heading_error)
  617. target = self.lookahead_target_from_fit(
  618. coefficients, center_samples, frame.shape
  619. )
  620. if target is not None:
  621. target_forward, target_left, target_x, target_y = target
  622. message = PointStamped()
  623. message.header.stamp = rospy.Time.now()
  624. message.header.frame_id = self.lookahead_frame_id
  625. message.point.x = target_forward
  626. message.point.y = target_left
  627. message.point.z = 0.0
  628. self.lookahead_target_pub.publish(message)
  629. cv2.circle(
  630. annotated,
  631. (int(round(target_x)), int(round(target_y))),
  632. 8,
  633. (255, 0, 255),
  634. -1,
  635. )
  636. cv2.line(
  637. annotated,
  638. (centre_x, height - 1),
  639. (int(round(target_x)), int(round(target_y))),
  640. (255, 0, 255),
  641. 2,
  642. )
  643. state += " target=(%.2fm,%+.2fm left)" % (
  644. target_forward,
  645. target_left,
  646. )
  647. cv2.putText(annotated, "DEBUG ONLY - NO /cmd_vel", (12, 28),
  648. cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
  649. cv2.putText(annotated, state, (12, 56),
  650. cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 0), 2)
  651. scan_label = "none" if scan_y is None else str(scan_y)
  652. cv2.putText(annotated, "ROI y=%d..%d selected scan y=%s" % (roi_top, height - 1, scan_label),
  653. (12, height - 14), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 2)
  654. mask_view = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
  655. cv2.putText(mask_view, "WHITE-LINE MASK (ground ROI only)", (12, 28),
  656. cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 0), 2)
  657. if scan_y is not None:
  658. cv2.line(mask_view, (0, scan_y), (width - 1, scan_y), (255, 255, 0), 1)
  659. cv2.rectangle(mask_view, (exclusion_left, roi_top), (exclusion_right, height - 1), (90, 90, 90), 1)
  660. return annotated, mask_view
  661. def make_display(self, raw_frame):
  662. display_height, display_width = raw_frame.shape[:2]
  663. processing_frame = raw_frame
  664. if self.processing_scale < 1.0:
  665. processing_size = (
  666. max(1, int(display_width * self.processing_scale)),
  667. max(1, int(display_height * self.processing_scale)),
  668. )
  669. processing_frame = cv2.resize(raw_frame, processing_size, interpolation=cv2.INTER_AREA)
  670. if not self.use_perspective_transform:
  671. annotated, mask_view = self.make_line_views(processing_frame)
  672. if processing_frame.shape != raw_frame.shape:
  673. annotated = cv2.resize(annotated, (display_width, display_height), interpolation=cv2.INTER_LINEAR)
  674. mask_view = cv2.resize(mask_view, (display_width, display_height), interpolation=cv2.INTER_NEAREST)
  675. return np.hstack((annotated, mask_view))
  676. bird_frame, _ = self.perspective_warp(processing_frame)
  677. source_points, _ = self.scaled_perspective_points(raw_frame.shape)
  678. source_points = source_points.astype(np.int32)
  679. source_view = raw_frame.copy()
  680. cv2.polylines(source_view, [source_points], True, (0, 255, 255), 2)
  681. for index, point in enumerate(source_points):
  682. point_xy = tuple(int(value) for value in point)
  683. cv2.circle(source_view, point_xy, 5, (0, 0, 255), -1)
  684. cv2.putText(source_view, str(index + 1), (point_xy[0] + 6, point_xy[1] - 6),
  685. cv2.FONT_HERSHEY_SIMPLEX, 0.55, (0, 255, 255), 2)
  686. cv2.putText(source_view, "SOURCE: IPM GROUND TRAPEZOID", (12, 28),
  687. cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2)
  688. annotated, mask_view = self.make_line_views(bird_frame)
  689. if processing_frame.shape != raw_frame.shape:
  690. annotated = cv2.resize(annotated, (display_width, display_height), interpolation=cv2.INTER_LINEAR)
  691. mask_view = cv2.resize(mask_view, (display_width, display_height), interpolation=cv2.INTER_NEAREST)
  692. cv2.putText(annotated, "BIRD'S-EYE VIEW", (12, 82),
  693. cv2.FONT_HERSHEY_SIMPLEX, 0.65, (0, 255, 255), 2)
  694. return np.hstack((source_view, annotated, mask_view))
  695. def run(self):
  696. window = "Line following debug (Q/Esc: quit)"
  697. cv2.namedWindow(window, cv2.WINDOW_NORMAL)
  698. cv2.resizeWindow(window, 1920, 480)
  699. rate = rospy.Rate(self.display_rate)
  700. while not rospy.is_shutdown():
  701. with self.lock:
  702. display_image = self.display_image
  703. if display_image is not None:
  704. cv2.imshow(window, display_image)
  705. key = cv2.waitKey(1) & 0xFF
  706. if key in (ord("q"), ord("Q"), 27):
  707. break
  708. rate.sleep()
  709. cv2.destroyAllWindows()
  710. if __name__ == "__main__":
  711. try:
  712. LineFollowDebug().run()
  713. except rospy.ROSInterruptException:
  714. pass