traffic_sign_node.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. #!/usr/bin/env python3
  2. """Recognise LED direction signs with an RKNN YOLOv5 model.
  3. The node deliberately consumes the shared camera topic instead of opening a
  4. camera device. Camera exposure, gain and white balance remain owned by the
  5. single camera-driver node.
  6. """
  7. from __future__ import annotations
  8. import atexit
  9. from collections import Counter, deque
  10. import logging
  11. from pathlib import Path
  12. import subprocess
  13. import threading
  14. from typing import Iterable, Optional, Sequence, Tuple
  15. import cv2
  16. import numpy as np
  17. import rospy
  18. import yaml
  19. from cv_bridge import CvBridge, CvBridgeError
  20. from sensor_msgs.msg import Image
  21. from std_msgs.msg import Float32, String
  22. from std_srvs.srv import SetBool, SetBoolResponse
  23. try:
  24. from rknnlite.api import RKNNLite
  25. except ImportError as error: # pragma: no cover - depends on target hardware
  26. raise RuntimeError(
  27. "Unable to import RKNNLite. Start this node through the "
  28. "traffic_sign_node wrapper so it uses /home/ucar/venv3.9."
  29. ) from error
  30. # rknnlite 1.5.2 replaces Python's normal log level names (``DEBUG``,
  31. # ``INFO``...) with one-character variants. ROS Noetic's logging config uses
  32. # the normal names, so restore them before rospy.init_node() configures logs.
  33. for _level, _name in (
  34. (logging.CRITICAL, "CRITICAL"),
  35. (logging.ERROR, "ERROR"),
  36. (logging.WARNING, "WARNING"),
  37. (logging.INFO, "INFO"),
  38. (logging.DEBUG, "DEBUG"),
  39. (logging.NOTSET, "NOTSET"),
  40. ):
  41. logging.addLevelName(_level, _name)
  42. DEFAULT_CLASSES = ("left", "right", "straight", "stop")
  43. DEFAULT_ANCHORS = np.array(
  44. [[10, 13], [16, 30], [33, 23], [30, 61], [62, 45], [59, 119],
  45. [116, 90], [156, 198], [373, 326]],
  46. dtype=np.float32,
  47. )
  48. DEFAULT_MASKS = ((0, 1, 2), (3, 4, 5), (6, 7, 8))
  49. def sigmoid(values: np.ndarray) -> np.ndarray:
  50. return 1.0 / (1.0 + np.exp(-values))
  51. def letterbox(image: np.ndarray, size: int) -> Tuple[np.ndarray, float, Tuple[float, float]]:
  52. """Resize without stretching and return scale/padding for box restoration."""
  53. height, width = image.shape[:2]
  54. scale = min(float(size) / height, float(size) / width)
  55. resized_width, resized_height = int(round(width * scale)), int(round(height * scale))
  56. resized = cv2.resize(image, (resized_width, resized_height), interpolation=cv2.INTER_LINEAR)
  57. pad_x = (size - resized_width) / 2.0
  58. pad_y = (size - resized_height) / 2.0
  59. bordered = cv2.copyMakeBorder(
  60. resized,
  61. int(round(pad_y - 0.1)),
  62. int(round(pad_y + 0.1)),
  63. int(round(pad_x - 0.1)),
  64. int(round(pad_x + 0.1)),
  65. cv2.BORDER_CONSTANT,
  66. value=(114, 114, 114),
  67. )
  68. return bordered, scale, (pad_x, pad_y)
  69. def nms_boxes(boxes: np.ndarray, scores: np.ndarray, threshold: float) -> np.ndarray:
  70. x1, y1, x2, y2 = boxes.T
  71. areas = (x2 - x1) * (y2 - y1)
  72. order = scores.argsort()[::-1]
  73. keep = []
  74. while order.size:
  75. current = order[0]
  76. keep.append(current)
  77. if order.size == 1:
  78. break
  79. remaining = order[1:]
  80. xx1 = np.maximum(x1[current], x1[remaining])
  81. yy1 = np.maximum(y1[current], y1[remaining])
  82. xx2 = np.minimum(x2[current], x2[remaining])
  83. yy2 = np.minimum(y2[current], y2[remaining])
  84. intersection = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
  85. union = areas[current] + areas[remaining] - intersection
  86. iou = intersection / np.maximum(union, 1e-6)
  87. order = remaining[iou <= threshold]
  88. return np.asarray(keep, dtype=np.int32)
  89. class TrafficSignRecognizer:
  90. def __init__(self) -> None:
  91. rospy.init_node("traffic_sign_recognition")
  92. self._bridge = CvBridge()
  93. self._state_lock = threading.RLock()
  94. self._package_dir = Path(__file__).resolve().parent.parent
  95. default_calibration = self._package_dir / "config" / "head_camera.yaml"
  96. self._calibration_file = Path(
  97. rospy.get_param("~calibration_file", str(default_calibration))
  98. ).expanduser()
  99. self._camera_matrix, self._distortion_coefficients, self._calibration_size = (
  100. self._load_camera_calibration(self._calibration_file)
  101. )
  102. self._classes = tuple(rospy.get_param("~classes", list(DEFAULT_CLASSES)))
  103. self._input_size = int(rospy.get_param("~input_size", 640))
  104. self._object_threshold = float(rospy.get_param("~object_threshold", 0.70))
  105. self._nms_threshold = float(rospy.get_param("~nms_threshold", 0.45))
  106. self._flip_horizontal = bool(rospy.get_param("~flip_horizontal", True))
  107. self._brightness = float(rospy.get_param("~brightness", 0.0))
  108. self._contrast = float(rospy.get_param("~contrast", 1.0))
  109. self._saturation = float(rospy.get_param("~saturation", 1.0))
  110. self._lab_clahe = bool(rospy.get_param("~lab_clahe", True))
  111. self._clahe = cv2.createCLAHE(
  112. clipLimit=float(rospy.get_param("~clahe_clip_limit", 1.5)),
  113. tileGridSize=tuple(rospy.get_param("~clahe_tile_grid", [8, 8])),
  114. )
  115. self._gamma = float(rospy.get_param("~gamma", 1.0))
  116. self._stability_window = int(rospy.get_param("~stability_window", 5))
  117. self._stable_count = int(rospy.get_param("~stable_count", 4))
  118. if self._stable_count > self._stability_window:
  119. raise ValueError("stable_count must not exceed stability_window")
  120. self._history: deque[Optional[str]] = deque(maxlen=self._stability_window)
  121. self._last_direction = "NONE"
  122. default_model = self._package_dir / "models" / "traffic_sign_direction.rknn"
  123. configured_model = Path(rospy.get_param("~model_path", str(default_model))).expanduser()
  124. self._model_path = configured_model
  125. if not self._model_path.is_file():
  126. raise FileNotFoundError(
  127. "RKNN model is missing: %s. Train and convert the LED sign model, then place "
  128. "traffic_sign_direction.rknn in this package's models directory or set ~model_path."
  129. % self._model_path
  130. )
  131. self._rknn = RKNNLite()
  132. result = self._rknn.load_rknn(str(self._model_path))
  133. if result != 0:
  134. raise RuntimeError("RKNN model load failed with code %s: %s" % (result, self._model_path))
  135. result = self._rknn.init_runtime()
  136. if result != 0:
  137. raise RuntimeError("RKNN runtime initialisation failed with code %s" % result)
  138. self._camera_profile_enabled = bool(rospy.get_param("~camera_profile_enabled", False))
  139. self._camera_device = str(rospy.get_param("~camera_device", "/dev/video0"))
  140. self._restore_camera_on_shutdown = bool(
  141. rospy.get_param("~restore_camera_on_shutdown", True)
  142. )
  143. # Cache controls now: ROS parameter access is not reliable once the
  144. # ROS shutdown sequence has begun.
  145. self._led_camera_controls = (
  146. ("exposure_auto", rospy.get_param("~led_exposure_auto", 1)),
  147. ("exposure_auto_priority", rospy.get_param("~led_exposure_auto_priority", 0)),
  148. ("exposure_absolute", rospy.get_param("~led_exposure_absolute", 50)),
  149. ("white_balance_temperature_auto", rospy.get_param("~led_white_balance_auto", True)),
  150. )
  151. self._restore_camera_controls = (
  152. ("exposure_auto", rospy.get_param("~restore_exposure_auto", 3)),
  153. ("exposure_auto_priority", rospy.get_param("~restore_exposure_auto_priority", 0)),
  154. ("white_balance_temperature_auto", rospy.get_param("~restore_white_balance_auto", True)),
  155. ("brightness", rospy.get_param("~restore_brightness", 0)),
  156. ("contrast", rospy.get_param("~restore_contrast", 50)),
  157. ("saturation", rospy.get_param("~restore_saturation", 50)),
  158. ("gamma", rospy.get_param("~restore_gamma", 300)),
  159. )
  160. self._camera_profile_active = False
  161. self._enabled = bool(rospy.get_param("~enabled", True))
  162. if self._camera_profile_enabled and self._enabled:
  163. self._apply_led_camera_profile()
  164. rospy.on_shutdown(self._release_runtime)
  165. rospy.on_shutdown(self._restore_camera_profile)
  166. atexit.register(self._restore_camera_profile)
  167. self._direction_pub = rospy.Publisher("/traffic_sign/direction", String, queue_size=1)
  168. self._confidence_pub = rospy.Publisher("/traffic_sign/confidence", Float32, queue_size=1)
  169. self._debug_pub = rospy.Publisher("/traffic_sign/debug_image", Image, queue_size=1)
  170. self._enable_service = rospy.Service(
  171. "~set_enabled", SetBool, self._set_enabled
  172. )
  173. image_topic = rospy.get_param("~image_topic", "/usb_cam/image_raw")
  174. self._image_sub = rospy.Subscriber(image_topic, Image, self._image_callback, queue_size=1)
  175. rospy.loginfo(
  176. "traffic_sign_recognition ready: model=%s image_topic=%s calibration=%s classes=%s enabled=%s",
  177. self._model_path,
  178. image_topic,
  179. self._calibration_file,
  180. ",".join(self._classes),
  181. self._enabled,
  182. )
  183. def _set_enabled(self, request) -> SetBoolResponse:
  184. """Enable inference/LED exposure or restore the shared camera profile."""
  185. requested = bool(request.data)
  186. with self._state_lock:
  187. if requested == self._enabled:
  188. # A previous disable request may have stopped inference but
  189. # failed midway through restoring V4L2 controls. Allow a
  190. # repeated disable request (for example from FAULT handling)
  191. # to retry that safety-critical restoration.
  192. if not requested and self._camera_profile_active:
  193. try:
  194. self._restore_camera_profile(raise_on_error=True)
  195. except RuntimeError as error:
  196. return SetBoolResponse(success=False, message=str(error))
  197. return SetBoolResponse(
  198. success=True,
  199. message="traffic sign recognition already %s"
  200. % ("enabled" if requested else "disabled"),
  201. )
  202. if requested:
  203. try:
  204. if self._camera_profile_enabled:
  205. self._apply_led_camera_profile()
  206. except RuntimeError as error:
  207. return SetBoolResponse(success=False, message=str(error))
  208. self._history.clear()
  209. self._last_direction = "NONE"
  210. self._enabled = True
  211. rospy.set_param("~enabled", True)
  212. self._direction_pub.publish(String(data="NONE"))
  213. self._confidence_pub.publish(Float32(data=0.0))
  214. rospy.loginfo("Traffic sign recognition enabled.")
  215. return SetBoolResponse(success=True, message="recognition enabled")
  216. # Mark disabled before restoring exposure so no new callback can
  217. # start inference using a half-restored camera frame.
  218. self._enabled = False
  219. self._history.clear()
  220. self._last_direction = "NONE"
  221. self._direction_pub.publish(String(data="NONE"))
  222. self._confidence_pub.publish(Float32(data=0.0))
  223. try:
  224. self._restore_camera_profile(raise_on_error=True)
  225. except RuntimeError as error:
  226. return SetBoolResponse(success=False, message=str(error))
  227. rospy.set_param("~enabled", False)
  228. rospy.loginfo("Traffic sign recognition disabled; camera profile restored.")
  229. return SetBoolResponse(success=True, message="recognition disabled")
  230. def _release_runtime(self) -> None:
  231. if getattr(self, "_rknn", None) is not None:
  232. self._rknn.release()
  233. self._rknn = None
  234. def _set_camera_controls(self, controls: Sequence[Tuple[str, object]]) -> None:
  235. """Set the shared USB camera controls without opening a second camera node."""
  236. for name, value in controls:
  237. command = [
  238. "v4l2-ctl",
  239. "-d",
  240. self._camera_device,
  241. "--set-ctrl=%s=%s" % (name, int(value) if isinstance(value, bool) else value),
  242. ]
  243. try:
  244. subprocess.run(command, check=True, capture_output=True, text=True, timeout=3)
  245. except (OSError, subprocess.SubprocessError) as error:
  246. raise RuntimeError("Cannot set camera control %s: %s" % (name, error))
  247. def _apply_led_camera_profile(self) -> None:
  248. # Mark active first so a partially-applied V4L2 sequence can still be
  249. # restored by a subsequent disable/fault request.
  250. self._camera_profile_active = True
  251. self._set_camera_controls(self._led_camera_controls)
  252. rospy.loginfo("LED camera profile enabled on %s: manual exposure=%s", self._camera_device,
  253. dict(self._led_camera_controls)["exposure_absolute"])
  254. def _restore_camera_profile(self, raise_on_error: bool = False) -> None:
  255. if not getattr(self, "_camera_profile_active", False) or not self._restore_camera_on_shutdown:
  256. return
  257. try:
  258. self._set_camera_controls(self._restore_camera_controls)
  259. self._camera_profile_active = False
  260. rospy.loginfo("Automatic USB camera profile restored on %s", self._camera_device)
  261. except RuntimeError as error:
  262. rospy.logerr("Unable to restore automatic camera profile: %s", error)
  263. if raise_on_error:
  264. raise
  265. @staticmethod
  266. def _load_camera_calibration(
  267. calibration_file: Path,
  268. ) -> Tuple[np.ndarray, np.ndarray, Tuple[int, int]]:
  269. """Load the USB camera calibration and reject incomplete files early."""
  270. try:
  271. with calibration_file.open("r", encoding="utf-8") as stream:
  272. calibration = yaml.safe_load(stream)
  273. width = int(calibration["image_width"])
  274. height = int(calibration["image_height"])
  275. matrix = np.asarray(calibration["camera_matrix"]["data"], dtype=np.float64).reshape(3, 3)
  276. coefficients = np.asarray(
  277. calibration["distortion_coefficients"]["data"], dtype=np.float64
  278. ).reshape(-1, 1)
  279. except (OSError, KeyError, TypeError, ValueError, yaml.YAMLError) as error:
  280. raise RuntimeError("Unable to load camera calibration %s: %s" % (calibration_file, error))
  281. return matrix, coefficients, (width, height)
  282. def _preprocess_camera_frame(self, frame: np.ndarray) -> np.ndarray:
  283. """Preserve LED colour while applying the shared, versioned LED preprocessing."""
  284. processed = cv2.flip(frame, 1) if self._flip_horizontal else frame.copy()
  285. if self._contrast != 1.0 or self._brightness != 0.0:
  286. processed = cv2.convertScaleAbs(
  287. processed, alpha=self._contrast, beta=self._brightness
  288. )
  289. if self._saturation != 1.0:
  290. hsv = cv2.cvtColor(processed, cv2.COLOR_BGR2HSV)
  291. hsv[:, :, 1] = np.clip(
  292. hsv[:, :, 1].astype(np.float32) * self._saturation, 0, 255
  293. ).astype(np.uint8)
  294. processed = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
  295. if self._lab_clahe:
  296. lab = cv2.cvtColor(processed, cv2.COLOR_BGR2LAB)
  297. lab[:, :, 0] = self._clahe.apply(lab[:, :, 0])
  298. processed = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
  299. if self._gamma != 1.0:
  300. lookup = np.array(
  301. [((value / 255.0) ** self._gamma) * 255.0 for value in range(256)], dtype=np.uint8
  302. )
  303. processed = cv2.LUT(processed, lookup)
  304. return processed
  305. def _decode_output(self, outputs: Sequence[np.ndarray]) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[np.ndarray]]:
  306. # ``export.py --include onnx`` creates YOLOv5's decoded output
  307. # [batch, 25200, 5 + class_count]. Some RKNN-export pipelines expose
  308. # the three raw detection heads instead, so keep support for both.
  309. if len(outputs) == 1:
  310. # RKNN Lite may retain a singleton batch dimension and/or append
  311. # a singleton stride-alignment dimension: (1, 25200, 9, 1).
  312. # Remove only dimensions of length one, yielding (25200, 9).
  313. values = np.squeeze(np.asarray(outputs[0]))
  314. if values.ndim == 2 and values.shape[0] == 5 + len(self._classes):
  315. values = values.T
  316. if values.ndim != 2 or values.shape[1] != 5 + len(self._classes):
  317. raise RuntimeError(
  318. "Unsupported decoded YOLOv5 RKNN output shape: %s" % (values.shape,)
  319. )
  320. objectness = values[:, 4:5]
  321. class_scores = values[:, 5:] * objectness
  322. classes = np.argmax(class_scores, axis=1)
  323. scores = np.max(class_scores, axis=1)
  324. selected = scores >= self._object_threshold
  325. if not np.any(selected):
  326. return None, None, None
  327. xywh = values[selected, :4]
  328. boxes = np.concatenate((xywh[:, :2] - xywh[:, 2:] / 2.0,
  329. xywh[:, :2] + xywh[:, 2:] / 2.0), axis=1)
  330. return self._classwise_nms(boxes, classes[selected], scores[selected])
  331. if len(outputs) != 3:
  332. raise RuntimeError(
  333. "Expected one decoded or three raw YOLOv5 RKNN outputs, got %d" % len(outputs)
  334. )
  335. all_boxes, all_classes, all_scores = [], [], []
  336. for output, mask in zip(outputs, DEFAULT_MASKS):
  337. values = np.asarray(output)
  338. if values.ndim == 4 and values.shape[0] == 1:
  339. values = values[0]
  340. if values.ndim != 3:
  341. raise RuntimeError("Unsupported RKNN output shape: %s" % (values.shape,))
  342. if values.shape[0] % 3 == 0:
  343. values = values.reshape(3, -1, values.shape[1], values.shape[2]).transpose(2, 3, 0, 1)
  344. elif values.shape[-1] % 3 == 0:
  345. values = values.reshape(values.shape[0], values.shape[1], 3, -1)
  346. else:
  347. raise RuntimeError("Cannot interpret RKNN output shape: %s" % (values.shape,))
  348. grid_height, grid_width = values.shape[:2]
  349. anchors = DEFAULT_ANCHORS[list(mask)]
  350. confidence = sigmoid(values[..., 4:5])
  351. class_probabilities = sigmoid(values[..., 5:])
  352. class_scores = class_probabilities * confidence
  353. classes = np.argmax(class_scores, axis=-1)
  354. scores = np.max(class_scores, axis=-1)
  355. selected = scores >= self._object_threshold
  356. if not np.any(selected):
  357. continue
  358. grid_x, grid_y = np.meshgrid(np.arange(grid_width), np.arange(grid_height))
  359. grid = np.stack((grid_x, grid_y), axis=-1)[..., None, :]
  360. xy = (sigmoid(values[..., :2]) * 2.0 - 0.5 + grid) * (self._input_size / grid_height)
  361. wh = (sigmoid(values[..., 2:4]) * 2.0) ** 2 * anchors[None, None, :, :]
  362. xyxy = np.concatenate((xy - wh / 2.0, xy + wh / 2.0), axis=-1)
  363. all_boxes.append(xyxy[selected])
  364. all_classes.append(classes[selected])
  365. all_scores.append(scores[selected])
  366. if not all_boxes:
  367. return None, None, None
  368. boxes = np.concatenate(all_boxes)
  369. classes = np.concatenate(all_classes)
  370. scores = np.concatenate(all_scores)
  371. return self._classwise_nms(boxes, classes, scores)
  372. def _classwise_nms(
  373. self,
  374. boxes: np.ndarray,
  375. classes: np.ndarray,
  376. scores: np.ndarray,
  377. ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
  378. """Suppress overlapping candidates independently for each class."""
  379. kept_boxes, kept_classes, kept_scores = [], [], []
  380. for class_index in np.unique(classes):
  381. class_indices = np.where(classes == class_index)[0]
  382. keep = nms_boxes(boxes[class_indices], scores[class_indices], self._nms_threshold)
  383. kept_boxes.append(boxes[class_indices][keep])
  384. kept_classes.append(classes[class_indices][keep])
  385. kept_scores.append(scores[class_indices][keep])
  386. return np.concatenate(kept_boxes), np.concatenate(kept_classes), np.concatenate(kept_scores)
  387. def _stable_direction(self, candidate: Optional[str]) -> str:
  388. self._history.append(candidate)
  389. votes = Counter(value for value in self._history if value is not None)
  390. if not votes:
  391. return "NONE"
  392. direction, count = votes.most_common(1)[0]
  393. return direction.upper() if count >= self._stable_count else "NONE"
  394. @staticmethod
  395. def _restore_boxes(boxes: np.ndarray, scale: float, padding: Tuple[float, float], width: int, height: int) -> np.ndarray:
  396. restored = boxes.copy()
  397. restored[:, [0, 2]] = (restored[:, [0, 2]] - padding[0]) / scale
  398. restored[:, [1, 3]] = (restored[:, [1, 3]] - padding[1]) / scale
  399. restored[:, [0, 2]] = np.clip(restored[:, [0, 2]], 0, width - 1)
  400. restored[:, [1, 3]] = np.clip(restored[:, [1, 3]], 0, height - 1)
  401. return restored
  402. def _image_callback(self, message: Image) -> None:
  403. with self._state_lock:
  404. if not self._enabled:
  405. return
  406. self._process_enabled_image(message)
  407. def _process_enabled_image(self, message: Image) -> None:
  408. try:
  409. camera_frame = self._bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
  410. except CvBridgeError as error:
  411. rospy.logerr_throttle(5.0, "traffic sign image conversion failed: %s", error)
  412. return
  413. expected_width, expected_height = self._calibration_size
  414. if camera_frame.shape[:2] != (expected_height, expected_width):
  415. rospy.logwarn_throttle(
  416. 5.0,
  417. "traffic sign image size %dx%d differs from calibration %dx%d; frame skipped",
  418. camera_frame.shape[1],
  419. camera_frame.shape[0],
  420. expected_width,
  421. expected_height,
  422. )
  423. return
  424. undistorted = cv2.undistort(camera_frame, self._camera_matrix, self._distortion_coefficients)
  425. processed = self._preprocess_camera_frame(undistorted)
  426. model_input, scale, padding = letterbox(processed, self._input_size)
  427. model_input = cv2.cvtColor(model_input, cv2.COLOR_BGR2RGB)
  428. try:
  429. # The OpenCV image is HWC RGB. State this explicitly instead of
  430. # relying on RKNN Lite's default (which can be NCHW for ONNX
  431. # models and yields near-zero detections with an HWC buffer).
  432. outputs = self._rknn.inference(inputs=[model_input], data_format="nhwc")
  433. boxes, classes, scores = self._decode_output(outputs)
  434. except Exception as error:
  435. rospy.logerr_throttle(5.0, "traffic sign inference failed: %s", error)
  436. return
  437. candidate, candidate_score = None, 0.0
  438. debug = processed.copy()
  439. if boxes is not None:
  440. boxes = self._restore_boxes(boxes, scale, padding, debug.shape[1], debug.shape[0])
  441. best_index = int(np.argmax(scores))
  442. candidate = self._classes[int(classes[best_index])]
  443. candidate_score = float(scores[best_index])
  444. for box, class_index, score in zip(boxes, classes, scores):
  445. label = self._classes[int(class_index)].upper()
  446. x1, y1, x2, y2 = box.astype(int)
  447. cv2.rectangle(debug, (x1, y1), (x2, y2), (0, 255, 0), 2)
  448. cv2.putText(debug, "%s %.2f" % (label, score), (x1, max(20, y1 - 6)),
  449. cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
  450. stable = self._stable_direction(candidate)
  451. self._last_direction = stable
  452. self._direction_pub.publish(String(data=stable))
  453. self._confidence_pub.publish(Float32(data=candidate_score if stable != "NONE" else 0.0))
  454. cv2.putText(debug, "STABLE: %s" % stable, (12, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8,
  455. (0, 255, 255), 2)
  456. try:
  457. self._debug_pub.publish(self._bridge.cv2_to_imgmsg(debug, encoding="bgr8"))
  458. except CvBridgeError as error:
  459. rospy.logerr_throttle(5.0, "traffic sign debug image publish failed: %s", error)
  460. def run(self) -> None:
  461. rospy.spin()
  462. if __name__ == "__main__":
  463. try:
  464. TrafficSignRecognizer().run()
  465. except (RuntimeError, FileNotFoundError, ValueError) as error:
  466. rospy.logfatal("traffic_sign_recognition did not start: %s", error)
  467. raise