led_dataset_collector.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. """Interactive raw-frame collector for the LED direction-sign dataset.
  3. The collector never opens /dev/video0. It consumes the common ROS image
  4. topic, previews the same colour-preserving preprocessing used by the future
  5. recogniser, and saves the processed training frame for every capture.
  6. """
  7. from __future__ import print_function
  8. import csv
  9. import threading
  10. import time
  11. from pathlib import Path
  12. import cv2
  13. import numpy as np
  14. import rospy
  15. import yaml
  16. from cv_bridge import CvBridge, CvBridgeError
  17. from sensor_msgs.msg import Image
  18. CLASS_KEYS = {
  19. ord("0"): "negative",
  20. ord("1"): "left",
  21. ord("2"): "right",
  22. ord("3"): "straight",
  23. ord("4"): "stop",
  24. }
  25. class LedDatasetCollector(object):
  26. def __init__(self):
  27. rospy.init_node("led_dataset_collector")
  28. self._bridge = CvBridge()
  29. self._package_dir = Path(__file__).resolve().parent.parent
  30. calibration_default = self._package_dir / "config" / "head_camera.yaml"
  31. self._calibration_file = Path(
  32. rospy.get_param("~calibration_file", str(calibration_default))
  33. ).expanduser()
  34. self._camera_matrix, self._distortion, self._calibration_size = self._load_calibration()
  35. self._flip_horizontal = bool(rospy.get_param("~flip_horizontal", True))
  36. self._brightness = float(rospy.get_param("~brightness", 0.0))
  37. self._contrast = float(rospy.get_param("~contrast", 1.0))
  38. self._saturation = float(rospy.get_param("~saturation", 1.0))
  39. self._use_lab_clahe = bool(rospy.get_param("~lab_clahe", True))
  40. self._clahe = cv2.createCLAHE(
  41. clipLimit=float(rospy.get_param("~clahe_clip_limit", 1.5)),
  42. tileGridSize=tuple(rospy.get_param("~clahe_tile_grid", [8, 8])),
  43. )
  44. self._gamma = float(rospy.get_param("~gamma", 1.0))
  45. self._camera_profile = rospy.get_param("~camera_profile", "usb-cam-auto-exposure-auto-white-balance")
  46. self._data_root = Path(
  47. rospy.get_param("~data_root", str(Path.home() / "traffic_sign_data"))
  48. ).expanduser()
  49. self._processed_root = self._data_root / "processed"
  50. # Keep new processed-only records separate from the earlier raw-pair log.
  51. self._metadata_path = self._data_root / "processed_metadata.csv"
  52. for label in CLASS_KEYS.values():
  53. (self._processed_root / label).mkdir(parents=True, exist_ok=True)
  54. self._selected_label = "left"
  55. self._lock = threading.Lock()
  56. self._latest_raw = None
  57. self._latest_processed = None
  58. self._latest_stamp_ns = 0
  59. self._write_metadata_header()
  60. self._preview_pub = rospy.Publisher(
  61. "/traffic_sign/collector/processed_image", Image, queue_size=1
  62. )
  63. image_topic = rospy.get_param("~image_topic", "/usb_cam/image_raw")
  64. self._image_sub = rospy.Subscriber(image_topic, Image, self._image_callback, queue_size=1)
  65. rospy.loginfo(
  66. "LED collector ready: image_topic=%s processed_root=%s keys: 0=negative 1=left 2=right 3=straight 4=stop s=save q=quit",
  67. image_topic,
  68. self._processed_root,
  69. )
  70. def _load_calibration(self):
  71. try:
  72. with self._calibration_file.open("r", encoding="utf-8") as stream:
  73. calibration = yaml.safe_load(stream)
  74. width = int(calibration["image_width"])
  75. height = int(calibration["image_height"])
  76. matrix = np.asarray(calibration["camera_matrix"]["data"], dtype=np.float64).reshape(3, 3)
  77. distortion = np.asarray(
  78. calibration["distortion_coefficients"]["data"], dtype=np.float64
  79. ).reshape(-1, 1)
  80. except (OSError, KeyError, TypeError, ValueError, yaml.YAMLError) as error:
  81. raise RuntimeError("Unable to load camera calibration %s: %s" % (self._calibration_file, error))
  82. return matrix, distortion, (width, height)
  83. def _write_metadata_header(self):
  84. self._data_root.mkdir(parents=True, exist_ok=True)
  85. if self._metadata_path.exists():
  86. return
  87. with self._metadata_path.open("w", newline="", encoding="utf-8") as stream:
  88. csv.writer(stream).writerow(
  89. [
  90. "processed_filename",
  91. "label",
  92. "saved_at_utc",
  93. "camera_profile",
  94. "calibration_file",
  95. "flip_horizontal",
  96. "lab_clahe",
  97. "clahe_clip_limit",
  98. "gamma",
  99. ]
  100. )
  101. def _process(self, raw):
  102. undistorted = cv2.undistort(raw, self._camera_matrix, self._distortion)
  103. processed = cv2.flip(undistorted, 1) if self._flip_horizontal else undistorted
  104. if self._contrast != 1.0 or self._brightness != 0.0:
  105. processed = cv2.convertScaleAbs(
  106. processed, alpha=self._contrast, beta=self._brightness
  107. )
  108. if self._saturation != 1.0:
  109. hsv = cv2.cvtColor(processed, cv2.COLOR_BGR2HSV)
  110. hsv[:, :, 1] = np.clip(
  111. hsv[:, :, 1].astype(np.float32) * self._saturation, 0, 255
  112. ).astype(np.uint8)
  113. processed = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
  114. if self._use_lab_clahe:
  115. lab = cv2.cvtColor(processed, cv2.COLOR_BGR2LAB)
  116. lab[:, :, 0] = self._clahe.apply(lab[:, :, 0])
  117. processed = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
  118. if self._gamma != 1.0:
  119. lookup = np.array(
  120. [((value / 255.0) ** self._gamma) * 255.0 for value in range(256)], dtype=np.uint8
  121. )
  122. processed = cv2.LUT(processed, lookup)
  123. return processed
  124. def _image_callback(self, message):
  125. try:
  126. raw = self._bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
  127. except CvBridgeError as error:
  128. rospy.logerr_throttle(5.0, "collector image conversion failed: %s", error)
  129. return
  130. width, height = self._calibration_size
  131. if raw.shape[:2] != (height, width):
  132. rospy.logwarn_throttle(
  133. 5.0,
  134. "collector image size %dx%d differs from calibration %dx%d; frame skipped",
  135. raw.shape[1], raw.shape[0], width, height,
  136. )
  137. return
  138. processed = self._process(raw)
  139. with self._lock:
  140. self._latest_raw = raw.copy()
  141. self._latest_processed = processed
  142. self._latest_stamp_ns = message.header.stamp.to_nsec()
  143. try:
  144. self._preview_pub.publish(self._bridge.cv2_to_imgmsg(processed, encoding="bgr8"))
  145. except CvBridgeError as error:
  146. rospy.logerr_throttle(5.0, "collector preview publish failed: %s", error)
  147. def _save_current_frame(self):
  148. with self._lock:
  149. if self._latest_processed is None:
  150. rospy.logwarn("No camera frame received; nothing saved")
  151. return
  152. processed = self._latest_processed.copy()
  153. stamp_ns = self._latest_stamp_ns or time.time_ns()
  154. filename = "%s_%019d.png" % (self._selected_label, stamp_ns)
  155. processed_destination = self._processed_root / self._selected_label / filename
  156. if not cv2.imwrite(str(processed_destination), processed):
  157. rospy.logerr("Failed to save processed frame: %s", processed_destination)
  158. return
  159. with self._metadata_path.open("a", newline="", encoding="utf-8") as stream:
  160. csv.writer(stream).writerow(
  161. [
  162. str(processed_destination.relative_to(self._data_root)),
  163. self._selected_label,
  164. time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
  165. self._camera_profile,
  166. str(self._calibration_file),
  167. self._flip_horizontal,
  168. self._use_lab_clahe,
  169. self._clahe.getClipLimit(),
  170. self._gamma,
  171. ]
  172. )
  173. rospy.loginfo(
  174. "Saved processed %s frame: %s",
  175. self._selected_label,
  176. processed_destination,
  177. )
  178. def _display(self, raw, processed):
  179. preview = np.hstack((raw, processed))
  180. cv2.putText(preview, "RAW", (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
  181. offset = raw.shape[1]
  182. cv2.putText(preview, "LED PREPROCESS", (offset + 12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
  183. cv2.putText(
  184. preview,
  185. "label=%s | 0:negative 1:left 2:right 3:straight 4:stop | s:save q:quit" % self._selected_label,
  186. (12, preview.shape[0] - 14),
  187. cv2.FONT_HERSHEY_SIMPLEX,
  188. 0.55,
  189. (0, 255, 255),
  190. 2,
  191. )
  192. cv2.imshow("LED dataset collector", preview)
  193. def run(self):
  194. cv2.namedWindow("LED dataset collector", cv2.WINDOW_NORMAL)
  195. while not rospy.is_shutdown():
  196. with self._lock:
  197. raw = None if self._latest_raw is None else self._latest_raw.copy()
  198. processed = None if self._latest_processed is None else self._latest_processed.copy()
  199. if raw is not None:
  200. self._display(raw, processed)
  201. key = cv2.waitKey(20) & 0xFF
  202. if key in CLASS_KEYS:
  203. self._selected_label = CLASS_KEYS[key]
  204. rospy.loginfo("Collector label selected: %s", self._selected_label)
  205. elif key in (ord("s"), ord("S")):
  206. self._save_current_frame()
  207. elif key in (ord("q"), ord("Q"), 27):
  208. break
  209. cv2.destroyAllWindows()
  210. if __name__ == "__main__":
  211. try:
  212. LedDatasetCollector().run()
  213. except (RuntimeError, ValueError) as error:
  214. rospy.logfatal("LED collector did not start: %s", error)
  215. raise