factory_alignment_controller.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. #!/usr/bin/env python3
  2. """Align to a factory sign, then optionally navigate to a stand-off point on its wall."""
  3. from __future__ import annotations
  4. import json
  5. import math
  6. import time
  7. import actionlib
  8. import rospy
  9. import tf2_ros
  10. from actionlib_msgs.msg import GoalStatus, GoalStatusArray
  11. from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Quaternion, Twist
  12. from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal
  13. from nav_msgs.msg import OccupancyGrid, Odometry
  14. from std_msgs.msg import Bool, Float32, String
  15. from std_srvs.srv import SetBool
  16. class FactoryAlignmentDemo:
  17. _RUNNING_STATES = {
  18. GoalStatus.PENDING, GoalStatus.ACTIVE, GoalStatus.PREEMPTING, GoalStatus.RECALLING,
  19. }
  20. _FAILED_STATES = {
  21. GoalStatus.PREEMPTED, GoalStatus.ABORTED, GoalStatus.REJECTED,
  22. GoalStatus.RECALLED, GoalStatus.LOST,
  23. }
  24. def __init__(self):
  25. rospy.init_node("factory_alignment_demo")
  26. self._enabled = bool(rospy.get_param("~enabled", False))
  27. # In competition mode only an explicit task-2 order may start recognition.
  28. self._require_order = bool(rospy.get_param("~require_order", False))
  29. self._current_order = None
  30. self._completed_order_id = None
  31. self._pending_start_order_id = None
  32. self._armed_order_id = None
  33. self._angular_sign = float(rospy.get_param("~angular_sign", -1.0))
  34. self._kp = float(rospy.get_param("~kp", 0.25))
  35. self._min_speed = float(rospy.get_param("~min_angular_speed", 0.10))
  36. self._max_speed = float(rospy.get_param("~max_angular_speed", 0.12))
  37. self._tolerance = float(rospy.get_param("~center_tolerance", 0.18))
  38. self._center_frames_required = int(rospy.get_param("~center_confirm_frames", 3))
  39. self._max_angular_accel = float(rospy.get_param("~max_angular_acceleration", 0.15))
  40. self._detection_timeout = float(rospy.get_param("~detection_timeout_seconds", 0.35))
  41. self._alignment_timeout = float(rospy.get_param("~alignment_timeout_seconds", 30.0))
  42. self._scan_steps = int(rospy.get_param("~scan_steps", 6))
  43. self._scan_direction = 1.0 if float(rospy.get_param("~scan_direction", 1.0)) >= 0.0 else -1.0
  44. self._scan_speed = abs(float(rospy.get_param("~scan_angular_speed", 0.20)))
  45. self._scan_yaw_tolerance = float(rospy.get_param("~scan_yaw_tolerance", 0.03))
  46. self._scan_detection_window = float(rospy.get_param("~scan_detection_window_seconds", 1.0))
  47. self._odom_timeout = float(rospy.get_param("~odom_timeout_seconds", 0.50))
  48. rate = float(rospy.get_param("~control_rate", 20.0))
  49. # This is explicitly opt-in because it sends a real move_base goal after alignment.
  50. self._wall_approach_enabled = bool(rospy.get_param("~wall_approach_enabled", False))
  51. self._wall_standoff = float(rospy.get_param("~wall_standoff_distance", 0.30))
  52. self._wall_ray_max_distance = float(rospy.get_param("~wall_ray_max_distance", 5.0))
  53. self._wall_occupied_threshold = int(rospy.get_param("~wall_occupied_threshold", 65))
  54. self._wall_fit_radius = float(rospy.get_param("~wall_fit_radius", 0.60))
  55. self._wall_line_inlier_distance = float(rospy.get_param("~wall_line_inlier_distance", 0.05))
  56. self._wall_line_min_length = float(rospy.get_param("~wall_line_min_length", 0.25))
  57. self._wall_line_min_support = int(rospy.get_param("~wall_line_min_support", 8))
  58. self._wall_line_min_facing_alignment = float(
  59. rospy.get_param("~wall_line_min_facing_alignment", 0.50)
  60. )
  61. self._wall_line_max_points = int(rospy.get_param("~wall_line_max_points", 180))
  62. self._tf_map_frame = rospy.get_param("~tf_map_frame", "map")
  63. self._tf_base_frame = rospy.get_param("~tf_base_frame", "base_link")
  64. self._tf_lookup_timeout = float(rospy.get_param("~tf_lookup_timeout_seconds", 0.20))
  65. self._amcl_fallback_timeout = float(
  66. rospy.get_param("~amcl_pose_fallback_timeout_seconds", 5.0)
  67. )
  68. self._wall_goal_server_timeout = float(rospy.get_param("~wall_goal_server_timeout_seconds", 2.0))
  69. self._ocr_confirmation_enabled = bool(
  70. rospy.get_param("~ocr_confirmation_enabled", True)
  71. )
  72. self._ocr_timeout = float(rospy.get_param("~ocr_timeout_seconds", 15.0))
  73. self._ocr_service_name = rospy.get_param(
  74. "~ocr_enable_service", "/sign_recognition/set_enabled"
  75. )
  76. # Terminal white-box entry after OCR confirmation. The box touches the
  77. # wall and extends 0.50 m outward; base_link is at the vehicle centre.
  78. self._entry_goal_enabled = bool(rospy.get_param("~entry_goal_enabled", True))
  79. self._entry_standoff = float(rospy.get_param("~entry_standoff_distance", 0.28))
  80. self._entry_white_box_depth = float(rospy.get_param("~entry_white_box_depth", 0.50))
  81. self._entry_vehicle_length = float(rospy.get_param("~entry_vehicle_length", 0.335))
  82. if self._scan_steps <= 0:
  83. raise ValueError("scan_steps must be positive")
  84. if self._wall_standoff <= 0.0 or self._wall_ray_max_distance <= 0.0:
  85. raise ValueError("wall approach distances must be positive")
  86. half_vehicle_length = self._entry_vehicle_length / 2.0
  87. if (self._entry_standoff <= half_vehicle_length
  88. or self._entry_standoff + half_vehicle_length > self._entry_white_box_depth):
  89. raise ValueError("entry goal does not keep the vehicle inside the white box")
  90. self._error = 0.0
  91. self._visible = False
  92. self._latest_odom_yaw = None
  93. self._latest_odom_monotonic = 0.0
  94. self._latest_amcl_pose = None
  95. self._latest_amcl_monotonic = 0.0
  96. self._static_map = None
  97. self._scan_state = "IDLE"
  98. self._scan_completed_steps = 0
  99. self._scan_target_yaw = None
  100. self._scan_detection_deadline = None
  101. self._last_detection_monotonic = 0.0
  102. self._detection_sequence = 0
  103. self._processed_sequence = 0
  104. self._center_frame_count = 0
  105. self._target_angular_z = 0.0
  106. self._current_angular_z = 0.0
  107. self._last_control_monotonic = time.monotonic()
  108. self._navigation_seen_active = False
  109. self._navigation_succeeded = False
  110. self._alignment_started_monotonic = None
  111. self._has_control = False
  112. self._last_status = None
  113. self._aligned = False
  114. self._wall_goal_active = False
  115. self._wall_goal_finished = False
  116. self._entry_goal_active = False
  117. self._entry_goal_finished = False
  118. self._final_alignment_active = False
  119. self._ocr_active = False
  120. self._ocr_started_monotonic = None
  121. self._factory_type = None
  122. self._ocr_enabled = False
  123. self._recognition_enabled = None
  124. self._recognition_service_name = rospy.get_param(
  125. "~recognition_enable_service", "/factory_sign_recognition/set_enabled"
  126. )
  127. self._recognition_enable = rospy.ServiceProxy(self._recognition_service_name, SetBool)
  128. self._ocr_enable = rospy.ServiceProxy(self._ocr_service_name, SetBool)
  129. self._wall_goal_client = actionlib.SimpleActionClient("move_base", MoveBaseAction)
  130. self._tf_buffer = tf2_ros.Buffer()
  131. self._tf_listener = tf2_ros.TransformListener(self._tf_buffer)
  132. cmd_vel_topic = rospy.get_param("~cmd_vel_topic", "/factory_alignment_demo/cmd_vel")
  133. self._cmd_pub = rospy.Publisher(cmd_vel_topic, Twist, queue_size=1)
  134. self._status_pub = rospy.Publisher("/factory_alignment_demo/status", String, queue_size=1, latch=True)
  135. self._wall_goal_pub = rospy.Publisher(
  136. "/factory_alignment_demo/wall_goal", PoseStamped, queue_size=1, latch=True
  137. )
  138. self._entry_goal_pub = rospy.Publisher(
  139. "/factory_alignment_demo/entry_goal", PoseStamped, queue_size=1, latch=True
  140. )
  141. self._factory_type_pub = rospy.Publisher(
  142. "/factory_alignment_demo/factory_type", String, queue_size=1, latch=True
  143. )
  144. rospy.Subscriber("/competition_task2/current_order", String, self._order_callback, queue_size=1)
  145. rospy.Subscriber("/competition_task2/command", String, self._task_command_callback, queue_size=10)
  146. rospy.Subscriber("/factory_sign/target_center_error", Float32, self._error_callback, queue_size=1)
  147. rospy.Subscriber("/factory_sign/target_visible", Bool, self._visible_callback, queue_size=1)
  148. rospy.Subscriber("/sign_recognition", String, self._ocr_result_callback, queue_size=1)
  149. rospy.Subscriber("/move_base/status", GoalStatusArray, self._navigation_callback, queue_size=5)
  150. rospy.Subscriber("/odom", Odometry, self._odom_callback, queue_size=10)
  151. rospy.Subscriber("/amcl_pose", PoseWithCovarianceStamped, self._amcl_callback, queue_size=10)
  152. rospy.Subscriber("/map", OccupancyGrid, self._map_callback, queue_size=1)
  153. rospy.Timer(rospy.Duration(1.0 / rate), self._control_callback)
  154. rospy.on_shutdown(self._shutdown)
  155. self._set_recognition_enabled(False, required=False)
  156. self._publish_status("DISABLED" if not self._enabled else ("WAITING_FOR_ORDER" if self._require_order else "WAITING_FOR_NAV_GOAL"))
  157. rospy.loginfo(
  158. "factory alignment demo ready: enabled=%s wall_approach=%s output=%s",
  159. self._enabled, self._wall_approach_enabled, cmd_vel_topic,
  160. )
  161. def _publish_status(self, status):
  162. if status != self._last_status:
  163. self._status_pub.publish(String(data=status))
  164. self._last_status = status
  165. @staticmethod
  166. def _normalise_factory_category(value):
  167. return {"食品": "食品", "日用品": "日用品", "电子": "电子", "电子产品": "电子"}.get(str(value).strip())
  168. def _order_callback(self, message):
  169. try:
  170. order = json.loads(message.data)
  171. order_id = str(order["order_id"]).strip()
  172. expected = self._normalise_factory_category(order.get("category", order.get("factory_category", "")))
  173. product = str(order["product"]).strip()
  174. warehouse = str(order["warehouse"]).strip()
  175. if not order_id or expected is None or not product or not warehouse:
  176. raise ValueError("missing required order field")
  177. except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
  178. self._current_order = None
  179. self._publish_status("ORDER_INVALID %s" % error)
  180. return
  181. # A fresh order is the only permitted way to clear the terminal parking lock.
  182. self._current_order = {"order_id": order_id, "category": expected,
  183. "product": product, "warehouse": warehouse}
  184. self._armed_order_id = order_id if self._pending_start_order_id == order_id else None
  185. if self._armed_order_id is not None:
  186. self._pending_start_order_id = None
  187. self._completed_order_id = None
  188. self._navigation_seen_active = False
  189. self._navigation_succeeded = False
  190. self._wall_goal_finished = False
  191. self._entry_goal_finished = False
  192. self._publish_status("ORDER_READY order_id=%s expected=%s" % (order_id, expected))
  193. def _task_command_callback(self, message):
  194. command = message.data.strip()
  195. if command == "STOP":
  196. self._pending_start_order_id = None
  197. self._armed_order_id = None
  198. self._target_angular_z = 0.0
  199. self._current_angular_z = 0.0
  200. self._ocr_active = False
  201. self._ocr_started_monotonic = None
  202. self._publish_stop()
  203. self._has_control = True
  204. if self._wall_goal_active or self._entry_goal_active:
  205. self._wall_goal_client.cancel_goal()
  206. self._set_recognition_enabled(False, required=False)
  207. self._set_ocr_enabled(False, required=False)
  208. self._publish_status("TASK2_STOPPED")
  209. return
  210. if not command.startswith("START_ORDER order_id="):
  211. return
  212. order_id = command.split("=", 1)[1].strip()
  213. if not order_id:
  214. return
  215. self._pending_start_order_id = order_id
  216. if self._current_order is not None and self._current_order.get("order_id") == order_id:
  217. self._armed_order_id = order_id
  218. self._pending_start_order_id = None
  219. self._publish_status("ORDER_ARMED order_id=%s" % order_id)
  220. def _error_callback(self, message):
  221. self._error = max(-1.0, min(1.0, message.data))
  222. self._last_detection_monotonic = time.monotonic()
  223. self._detection_sequence += 1
  224. def _visible_callback(self, message):
  225. self._visible = message.data
  226. def _odom_callback(self, message):
  227. orientation = message.pose.pose.orientation
  228. self._latest_odom_yaw = self._yaw_from_quaternion(orientation)
  229. self._latest_odom_monotonic = time.monotonic()
  230. def _amcl_callback(self, message):
  231. orientation = message.pose.pose.orientation
  232. self._latest_amcl_pose = (
  233. message.pose.pose.position.x,
  234. message.pose.pose.position.y,
  235. self._yaw_from_quaternion(orientation),
  236. )
  237. self._latest_amcl_monotonic = time.monotonic()
  238. def _map_callback(self, message):
  239. if message.header.frame_id.lstrip("/") != "map":
  240. rospy.logwarn_throttle(5.0, "factory alignment ignored map frame %s", message.header.frame_id)
  241. return
  242. self._static_map = message
  243. @staticmethod
  244. def _yaw_from_quaternion(orientation):
  245. return math.atan2(
  246. 2.0 * (orientation.w * orientation.z + orientation.x * orientation.y),
  247. 1.0 - 2.0 * (orientation.y * orientation.y + orientation.z * orientation.z),
  248. )
  249. @staticmethod
  250. def _wrap_to_pi(angle):
  251. return math.atan2(math.sin(angle), math.cos(angle))
  252. def _set_recognition_enabled(self, enabled, required):
  253. if self._recognition_enabled is enabled:
  254. return True
  255. try:
  256. rospy.wait_for_service(self._recognition_service_name, timeout=2.0)
  257. response = self._recognition_enable(enabled)
  258. except (rospy.ROSException, rospy.ServiceException) as error:
  259. if required:
  260. self._publish_status("RECOGNITION_SERVICE_UNAVAILABLE")
  261. else:
  262. rospy.logwarn("factory recognition service unavailable: %s", error)
  263. return False
  264. if not response.success:
  265. rospy.logwarn("factory recognition switch failed: %s", response.message)
  266. return False
  267. self._recognition_enabled = enabled
  268. return True
  269. def _set_ocr_enabled(self, enabled, required):
  270. if not self._ocr_confirmation_enabled:
  271. return not enabled
  272. if self._ocr_enabled is enabled:
  273. return True
  274. try:
  275. rospy.wait_for_service(self._ocr_service_name, timeout=2.0)
  276. response = self._ocr_enable(enabled)
  277. except (rospy.ROSException, rospy.ServiceException) as error:
  278. if required:
  279. self._publish_status("OCR_SERVICE_UNAVAILABLE")
  280. else:
  281. rospy.logwarn("sign OCR service unavailable: %s", error)
  282. return False
  283. if not response.success:
  284. rospy.logwarn("sign OCR switch failed: %s", response.message)
  285. if required:
  286. self._publish_status("OCR_SERVICE_UNAVAILABLE")
  287. return False
  288. self._ocr_enabled = enabled
  289. return True
  290. def _start_ocr_confirmation(self):
  291. self._ocr_active = True
  292. self._ocr_started_monotonic = time.monotonic()
  293. self._factory_type = None
  294. if not self._set_ocr_enabled(True, required=True):
  295. self._ocr_active = False
  296. self._ocr_started_monotonic = None
  297. return False
  298. self._publish_status("OCR_READING")
  299. return True
  300. def _ocr_result_callback(self, message):
  301. if not self._ocr_active:
  302. return
  303. result = message.data.strip()
  304. self._ocr_active = False
  305. self._ocr_started_monotonic = None
  306. self._set_ocr_enabled(False, required=False)
  307. self._set_recognition_enabled(False, required=False)
  308. factory_type = self._normalise_factory_category(result)
  309. if factory_type is not None:
  310. self._factory_type = factory_type
  311. self._factory_type_pub.publish(String(data=factory_type))
  312. if self._require_order:
  313. expected = self._current_order["category"] if self._current_order else None
  314. if factory_type != expected:
  315. self._wall_goal_finished = True
  316. self._publish_status("FACTORY_MISMATCH order_id=%s detected=%s expected=%s" % (
  317. self._current_order["order_id"] if self._current_order else "NONE",
  318. factory_type, expected or "NONE"))
  319. return
  320. self._publish_status("FACTORY_MATCHED order_id=%s type=%s" % (
  321. self._current_order["order_id"], factory_type))
  322. else:
  323. self._publish_status("FACTORY_CONFIRMED type=%s" % factory_type)
  324. if self._entry_goal_enabled:
  325. self._wall_goal_finished = False
  326. self._start_entry_approach()
  327. return
  328. self._wall_goal_finished = True
  329. else:
  330. self._factory_type = None
  331. self._wall_goal_finished = True
  332. self._publish_status("FACTORY_OCR_FAILED result=%s" % (result or "EMPTY"))
  333. def _navigation_callback(self, message):
  334. if self._require_order and self._current_order is None:
  335. return
  336. if (self._require_order
  337. and self._armed_order_id != self._current_order.get("order_id")):
  338. return
  339. if self._completed_order_id is not None:
  340. return
  341. if not message.status_list:
  342. return
  343. latest = max(
  344. message.status_list,
  345. key=lambda status: (status.goal_id.stamp.to_nsec(), status.goal_id.id),
  346. )
  347. state = latest.status
  348. # move_base statuses generated by our own wall/entry goals must not reset this state machine.
  349. if self._wall_goal_active or self._entry_goal_active:
  350. return
  351. if (self._wall_goal_finished or self._entry_goal_finished) and state not in self._RUNNING_STATES:
  352. return
  353. if state in self._RUNNING_STATES:
  354. self._wall_goal_finished = False
  355. self._entry_goal_finished = False
  356. self._set_recognition_enabled(False, required=False)
  357. self._set_ocr_enabled(False, required=False)
  358. self._ocr_active = False
  359. self._ocr_started_monotonic = None
  360. self._entry_goal_active = False
  361. self._entry_goal_finished = False
  362. self._factory_type = None
  363. self._factory_type_pub.publish(String(data=""))
  364. self._navigation_seen_active = True
  365. self._navigation_succeeded = False
  366. self._aligned = False
  367. self._final_alignment_active = False
  368. self._alignment_started_monotonic = None
  369. if self._has_control:
  370. self._publish_stop()
  371. self._center_frame_count = 0
  372. self._scan_state = "IDLE"
  373. self._scan_completed_steps = 0
  374. self._scan_target_yaw = None
  375. self._scan_detection_deadline = None
  376. self._target_angular_z = 0.0
  377. self._current_angular_z = 0.0
  378. self._last_control_monotonic = time.monotonic()
  379. self._processed_sequence = self._detection_sequence
  380. self._has_control = False
  381. return
  382. if self._navigation_seen_active and state == GoalStatus.SUCCEEDED:
  383. self._navigation_succeeded = True
  384. if self._alignment_started_monotonic is None:
  385. self._alignment_started_monotonic = time.monotonic()
  386. self._scan_state = "INITIAL_DETECT"
  387. self._scan_completed_steps = 0
  388. self._scan_target_yaw = None
  389. self._scan_detection_deadline = self._alignment_started_monotonic + self._scan_detection_window
  390. self._set_recognition_enabled(True, required=True)
  391. self._publish_status("INITIAL_DETECTING")
  392. return
  393. if self._navigation_seen_active and state in self._FAILED_STATES:
  394. self._set_recognition_enabled(False, required=False)
  395. self._set_ocr_enabled(False, required=False)
  396. self._ocr_active = False
  397. self._navigation_succeeded = False
  398. self._aligned = False
  399. self._alignment_started_monotonic = None
  400. self._publish_status("NAVIGATION_NOT_SUCCEEDED")
  401. def _publish_stop(self):
  402. self._cmd_pub.publish(Twist())
  403. def _shutdown(self):
  404. self._wall_goal_client.cancel_goal()
  405. if self._has_control:
  406. self._publish_stop()
  407. def _publish_smooth_command(self, desired_angular_z, now):
  408. elapsed = max(0.0, min(0.2, now - self._last_control_monotonic))
  409. max_delta = self._max_angular_accel * elapsed
  410. delta = desired_angular_z - self._current_angular_z
  411. if abs(delta) <= max_delta:
  412. self._current_angular_z = desired_angular_z
  413. else:
  414. self._current_angular_z += math.copysign(max_delta, delta)
  415. self._last_control_monotonic = now
  416. command = Twist()
  417. command.angular.z = self._current_angular_z
  418. self._has_control = True
  419. self._cmd_pub.publish(command)
  420. return self._current_angular_z
  421. def _run_search_scan(self, now):
  422. if self._scan_state == "COMPLETE":
  423. self._target_angular_z = 0.0
  424. self._current_angular_z = 0.0
  425. self._publish_stop()
  426. self._has_control = True
  427. self._publish_status("FACTORY_NOT_FOUND_AFTER_360_DEG_SCAN")
  428. return
  429. odom_fresh = (
  430. self._latest_odom_yaw is not None
  431. and now - self._latest_odom_monotonic <= self._odom_timeout
  432. )
  433. if self._scan_state == "TURN":
  434. if not odom_fresh:
  435. self._target_angular_z = 0.0
  436. self._publish_smooth_command(0.0, now)
  437. self._publish_status("WAITING_FOR_ODOM")
  438. return
  439. if self._scan_target_yaw is None:
  440. self._scan_target_yaw = self._wrap_to_pi(
  441. self._latest_odom_yaw + self._scan_direction * 2.0 * math.pi / self._scan_steps
  442. )
  443. yaw_error = self._wrap_to_pi(self._scan_target_yaw - self._latest_odom_yaw)
  444. if abs(yaw_error) <= self._scan_yaw_tolerance:
  445. self._target_angular_z = 0.0
  446. self._scan_state = "SETTLE"
  447. command_z = self._publish_smooth_command(0.0, now)
  448. self._publish_status("SCAN_STEP_%d_SETTLING command_z=%+.3f" % (
  449. self._scan_completed_steps + 1, command_z
  450. ))
  451. return
  452. self._target_angular_z = math.copysign(self._scan_speed, yaw_error)
  453. command_z = self._publish_smooth_command(self._target_angular_z, now)
  454. self._publish_status("SCANNING_STEP_%d/%d yaw_error=%+.3f command_z=%+.3f" % (
  455. self._scan_completed_steps + 1, self._scan_steps, yaw_error, command_z
  456. ))
  457. return
  458. if self._scan_state == "SETTLE":
  459. command_z = self._publish_smooth_command(0.0, now)
  460. if abs(command_z) <= 0.005:
  461. self._scan_state = "DETECT"
  462. self._scan_detection_deadline = now + self._scan_detection_window
  463. self._set_recognition_enabled(True, required=True)
  464. self._publish_status("SCAN_STEP_%d_DETECTING" % (self._scan_completed_steps + 1))
  465. else:
  466. self._publish_status("SCAN_STEP_%d_SETTLING command_z=%+.3f" % (
  467. self._scan_completed_steps + 1, command_z
  468. ))
  469. return
  470. if self._scan_state in ("INITIAL_DETECT", "DETECT"):
  471. self._target_angular_z = 0.0
  472. self._current_angular_z = 0.0
  473. self._publish_stop()
  474. self._has_control = True
  475. detected = self._visible and now - self._last_detection_monotonic <= self._detection_timeout
  476. if detected:
  477. self._scan_state = "ALIGN"
  478. self._alignment_started_monotonic = now
  479. self._center_frame_count = 0
  480. self._processed_sequence = self._detection_sequence
  481. self._publish_status("FACTORY_FOUND_STARTING_ALIGNMENT")
  482. return
  483. if now < self._scan_detection_deadline:
  484. state = "INITIAL_DETECTING" if self._scan_state == "INITIAL_DETECT" else "SCAN_STEP_%d_DETECTING" % (self._scan_completed_steps + 1)
  485. self._publish_status(state)
  486. return
  487. self._set_recognition_enabled(False, required=False)
  488. if self._scan_state == "INITIAL_DETECT":
  489. self._scan_state = "TURN"
  490. self._scan_target_yaw = None
  491. else:
  492. self._scan_completed_steps += 1
  493. if self._scan_completed_steps >= self._scan_steps:
  494. self._scan_state = "COMPLETE"
  495. else:
  496. self._scan_state = "TURN"
  497. self._scan_target_yaw = None
  498. @staticmethod
  499. def _map_origin_yaw(grid):
  500. return FactoryAlignmentDemo._yaw_from_quaternion(grid.info.origin.orientation)
  501. @staticmethod
  502. def _occupied(grid, row, column, threshold):
  503. if row < 0 or column < 0 or row >= grid.info.height or column >= grid.info.width:
  504. return False
  505. return grid.data[row * grid.info.width + column] >= threshold
  506. @classmethod
  507. def _map_to_grid(cls, grid, x, y):
  508. resolution = grid.info.resolution
  509. if resolution <= 0.0:
  510. return None
  511. yaw = cls._map_origin_yaw(grid)
  512. dx = x - grid.info.origin.position.x
  513. dy = y - grid.info.origin.position.y
  514. column = int(math.floor((math.cos(yaw) * dx + math.sin(yaw) * dy) / resolution))
  515. row = int(math.floor((-math.sin(yaw) * dx + math.cos(yaw) * dy) / resolution))
  516. if row < 0 or column < 0 or row >= grid.info.height or column >= grid.info.width:
  517. return None
  518. return row, column
  519. @classmethod
  520. def _grid_to_map(cls, grid, row, column):
  521. resolution = grid.info.resolution
  522. yaw = cls._map_origin_yaw(grid)
  523. local_x = (column + 0.5) * resolution
  524. local_y = (row + 0.5) * resolution
  525. origin = grid.info.origin.position
  526. return (
  527. origin.x + math.cos(yaw) * local_x - math.sin(yaw) * local_y,
  528. origin.y + math.sin(yaw) * local_x + math.cos(yaw) * local_y,
  529. )
  530. def _raycast_wall(self, grid, pose):
  531. x, y, heading = pose
  532. step = max(grid.info.resolution * 0.5, 0.01)
  533. previous_cell = None
  534. samples = int(math.ceil(self._wall_ray_max_distance / step))
  535. for sample in range(1, samples + 1):
  536. distance = sample * step
  537. cell = self._map_to_grid(grid, x + distance * math.cos(heading), y + distance * math.sin(heading))
  538. if cell is None:
  539. break
  540. if cell == previous_cell:
  541. continue
  542. previous_cell = cell
  543. row, column = cell
  544. if self._occupied(grid, row, column, self._wall_occupied_threshold):
  545. hit_x, hit_y = self._grid_to_map(grid, row, column)
  546. return hit_x, hit_y, row, column
  547. return None
  548. def _wall_normal_toward_robot(self, grid, hit_row, hit_column, robot_x, robot_y, heading):
  549. radius_cells = max(1, int(math.ceil(self._wall_fit_radius / grid.info.resolution)))
  550. queue = [(hit_row, hit_column)]
  551. visited = set()
  552. points = []
  553. while queue:
  554. row, column = queue.pop()
  555. if (row, column) in visited:
  556. continue
  557. visited.add((row, column))
  558. if not self._occupied(grid, row, column, self._wall_occupied_threshold):
  559. continue
  560. if math.hypot(row - hit_row, column - hit_column) > radius_cells:
  561. continue
  562. points.append(self._grid_to_map(grid, row, column))
  563. for delta_row in (-1, 0, 1):
  564. for delta_column in (-1, 0, 1):
  565. if delta_row or delta_column:
  566. queue.append((row + delta_row, column + delta_column))
  567. if len(points) < self._wall_line_min_support:
  568. return None
  569. # A corner joins two wall segments. Fit several local lines, then retain
  570. # the one whose outward normal faces the visually aligned vehicle.
  571. if len(points) > self._wall_line_max_points:
  572. stride = float(len(points)) / self._wall_line_max_points
  573. points = [points[int(index * stride)] for index in range(self._wall_line_max_points)]
  574. hit_x, hit_y = self._grid_to_map(grid, hit_row, hit_column)
  575. desired_normal_x = -math.cos(heading)
  576. desired_normal_y = -math.sin(heading)
  577. best = None
  578. for first_index, first in enumerate(points):
  579. for second in points[first_index + 1:]:
  580. dx = second[0] - first[0]
  581. dy = second[1] - first[1]
  582. length = math.hypot(dx, dy)
  583. if length < self._wall_line_min_length:
  584. continue
  585. # The selected line must describe the actually struck wall cell.
  586. hit_distance = abs(dy * (hit_x - first[0]) - dx * (hit_y - first[1])) / length
  587. if hit_distance > self._wall_line_inlier_distance:
  588. continue
  589. normal = math.atan2(dy, dx) + math.pi / 2.0
  590. if math.cos(normal) * (robot_x - hit_x) + math.sin(normal) * (robot_y - hit_y) < 0.0:
  591. normal += math.pi
  592. facing = math.cos(normal) * desired_normal_x + math.sin(normal) * desired_normal_y
  593. if facing < self._wall_line_min_facing_alignment:
  594. continue
  595. support = 0
  596. for point in points:
  597. distance = abs(dy * (point[0] - first[0]) - dx * (point[1] - first[1])) / length
  598. if distance <= self._wall_line_inlier_distance:
  599. support += 1
  600. if support < self._wall_line_min_support:
  601. continue
  602. score = support * (0.5 + 0.5 * facing)
  603. if best is None or score > best[0]:
  604. best = (score, normal)
  605. if best is None:
  606. return None
  607. return self._wrap_to_pi(best[1])
  608. def _map_pose(self):
  609. try:
  610. transform = self._tf_buffer.lookup_transform(
  611. self._tf_map_frame, self._tf_base_frame, rospy.Time(0),
  612. rospy.Duration(self._tf_lookup_timeout),
  613. )
  614. translation = transform.transform.translation
  615. return (
  616. translation.x, translation.y,
  617. self._yaw_from_quaternion(transform.transform.rotation),
  618. ), None
  619. except (tf2_ros.LookupException, tf2_ros.ConnectivityException,
  620. tf2_ros.ExtrapolationException, tf2_ros.TimeoutException) as error:
  621. rospy.logwarn_throttle(5.0, "factory alignment TF pose unavailable: %s", error)
  622. if (self._latest_amcl_pose is not None
  623. and time.monotonic() - self._latest_amcl_monotonic <= self._amcl_fallback_timeout):
  624. return self._latest_amcl_pose, None
  625. return None, "MAP_POSE_UNAVAILABLE"
  626. def _compute_wall_goal(self, standoff_distance=None):
  627. standoff = self._wall_standoff if standoff_distance is None else standoff_distance
  628. if standoff <= 0.0:
  629. return None, "WALL_GOAL_INVALID_STANDOFF"
  630. if self._static_map is None:
  631. return None, "STATIC_MAP_UNAVAILABLE"
  632. pose, failure = self._map_pose()
  633. if failure is not None:
  634. return None, failure
  635. grid = self._static_map
  636. ray_hit = self._raycast_wall(grid, pose)
  637. if ray_hit is None:
  638. return None, "WALL_RAY_NO_HIT"
  639. hit_x, hit_y, hit_row, hit_column = ray_hit
  640. robot_x, robot_y, heading = pose
  641. normal = self._wall_normal_toward_robot(
  642. grid, hit_row, hit_column, robot_x, robot_y, heading
  643. )
  644. if normal is None:
  645. return None, "WALL_NORMAL_UNAVAILABLE"
  646. goal_x = hit_x + standoff * math.cos(normal)
  647. goal_y = hit_y + standoff * math.sin(normal)
  648. goal_cell = self._map_to_grid(grid, goal_x, goal_y)
  649. if goal_cell is None:
  650. return None, "WALL_GOAL_OUTSIDE_MAP"
  651. if self._occupied(grid, goal_cell[0], goal_cell[1], self._wall_occupied_threshold):
  652. return None, "WALL_GOAL_OCCUPIED"
  653. return (goal_x, goal_y, self._wrap_to_pi(normal + math.pi), hit_x, hit_y), None
  654. def _start_wall_approach(self):
  655. computed, failure = self._compute_wall_goal()
  656. if failure is not None:
  657. self._wall_goal_finished = True
  658. self._set_recognition_enabled(False, required=False)
  659. self._publish_stop()
  660. self._has_control = True
  661. self._publish_status(failure)
  662. return False
  663. if not self._wall_goal_client.wait_for_server(rospy.Duration(self._wall_goal_server_timeout)):
  664. self._wall_goal_finished = True
  665. self._set_recognition_enabled(False, required=False)
  666. self._publish_stop()
  667. self._has_control = True
  668. self._publish_status("WALL_GOAL_MOVE_BASE_UNAVAILABLE")
  669. return False
  670. goal_x, goal_y, goal_yaw, hit_x, hit_y = computed
  671. goal = MoveBaseGoal()
  672. goal.target_pose.header.frame_id = "map"
  673. goal.target_pose.header.stamp = rospy.Time.now()
  674. goal.target_pose.pose.position.x = goal_x
  675. goal.target_pose.pose.position.y = goal_y
  676. goal.target_pose.pose.orientation = Quaternion(
  677. z=math.sin(goal_yaw / 2.0), w=math.cos(goal_yaw / 2.0)
  678. )
  679. self._wall_goal_pub.publish(goal.target_pose)
  680. self._wall_goal_active = True
  681. self._has_control = False
  682. self._wall_goal_client.send_goal(goal, done_cb=self._wall_goal_done)
  683. self._publish_status(
  684. "WALL_GOAL_SENT x=%.3f y=%.3f yaw=%.3f wall_x=%.3f wall_y=%.3f" % (
  685. goal_x, goal_y, goal_yaw, hit_x, hit_y
  686. )
  687. )
  688. return True
  689. def _start_entry_approach(self):
  690. # Re-read map -> base_link and cast a fresh ray after OCR confirmation.
  691. computed, failure = self._compute_wall_goal(self._entry_standoff)
  692. if failure is not None:
  693. self._entry_goal_finished = True
  694. self._publish_stop()
  695. self._has_control = True
  696. self._publish_status("ENTRY_GOAL_%s" % failure)
  697. return False
  698. if not self._wall_goal_client.wait_for_server(rospy.Duration(self._wall_goal_server_timeout)):
  699. self._entry_goal_finished = True
  700. self._publish_stop()
  701. self._has_control = True
  702. self._publish_status("ENTRY_GOAL_MOVE_BASE_UNAVAILABLE")
  703. return False
  704. goal_x, goal_y, goal_yaw, hit_x, hit_y = computed
  705. goal = MoveBaseGoal()
  706. goal.target_pose.header.frame_id = "map"
  707. goal.target_pose.header.stamp = rospy.Time.now()
  708. goal.target_pose.pose.position.x = goal_x
  709. goal.target_pose.pose.position.y = goal_y
  710. goal.target_pose.pose.orientation = Quaternion(
  711. z=math.sin(goal_yaw / 2.0), w=math.cos(goal_yaw / 2.0)
  712. )
  713. self._entry_goal_pub.publish(goal.target_pose)
  714. self._entry_goal_active = True
  715. self._has_control = False
  716. self._wall_goal_client.send_goal(goal, done_cb=self._entry_goal_done)
  717. self._publish_status(
  718. "ENTRY_GOAL_SENT x=%.3f y=%.3f yaw=%.3f wall_x=%.3f wall_y=%.3f" % (
  719. goal_x, goal_y, goal_yaw, hit_x, hit_y
  720. )
  721. )
  722. return True
  723. def _entry_goal_done(self, state, _result):
  724. self._entry_goal_active = False
  725. self._entry_goal_finished = True
  726. self._target_angular_z = 0.0
  727. self._current_angular_z = 0.0
  728. self._publish_stop()
  729. self._has_control = True
  730. if state == GoalStatus.SUCCEEDED:
  731. self._publish_status("FACTORY_ENTRY_COMPLETE type=%s" % (self._factory_type or "UNKNOWN"))
  732. if self._require_order:
  733. self._completed_order_id = self._current_order["order_id"] if self._current_order else None
  734. else:
  735. reason = "move_base_state=%d" % state
  736. self._publish_status("FACTORY_ENTRY_FAILED %s" % reason)
  737. def _wall_goal_done(self, state, _result):
  738. self._wall_goal_active = False
  739. self._target_angular_z = 0.0
  740. self._current_angular_z = 0.0
  741. self._publish_stop()
  742. self._has_control = True
  743. if state != GoalStatus.SUCCEEDED:
  744. self._wall_goal_finished = True
  745. self._final_alignment_active = False
  746. self._set_recognition_enabled(False, required=False)
  747. self._aligned = False
  748. self._publish_status("WALL_APPROACH_FAILED move_base_state=%d" % state)
  749. return
  750. # The map-derived goal brings the vehicle to the wall stand-off point.
  751. # Re-enable vision there for one final heading correction only.
  752. self._wall_goal_finished = False
  753. self._final_alignment_active = True
  754. self._aligned = False
  755. self._scan_state = "ALIGN"
  756. self._alignment_started_monotonic = time.monotonic()
  757. self._center_frame_count = 0
  758. self._processed_sequence = self._detection_sequence
  759. self._set_recognition_enabled(True, required=False)
  760. self._publish_status("WALL_APPROACH_REFINING_ALIGNMENT")
  761. def _control_callback(self, _event):
  762. if not self._enabled:
  763. self._publish_status("DISABLED")
  764. return
  765. if self._require_order and self._current_order is None:
  766. self._publish_status("WAITING_FOR_ORDER")
  767. return
  768. if (self._require_order
  769. and self._armed_order_id != self._current_order.get("order_id")):
  770. self._publish_status("WAITING_FOR_START_ORDER")
  771. return
  772. if self._completed_order_id is not None:
  773. return
  774. if not self._navigation_seen_active:
  775. self._publish_status("WAITING_FOR_NAV_GOAL")
  776. return
  777. if not self._navigation_succeeded:
  778. self._publish_status("NAVIGATING")
  779. return
  780. now = time.monotonic()
  781. if self._ocr_active:
  782. if now - self._ocr_started_monotonic > self._ocr_timeout:
  783. self._ocr_active = False
  784. self._ocr_started_monotonic = None
  785. self._set_ocr_enabled(False, required=False)
  786. self._set_recognition_enabled(False, required=False)
  787. self._wall_goal_finished = True
  788. self._publish_status("FACTORY_OCR_FAILED timeout")
  789. else:
  790. self._publish_status("OCR_READING")
  791. return
  792. if self._wall_goal_active or self._entry_goal_active:
  793. return
  794. if self._wall_goal_finished or self._entry_goal_finished:
  795. return
  796. if self._scan_state not in ("ALIGN", "IDLE"):
  797. self._run_search_scan(now)
  798. return
  799. if self._aligned:
  800. self._set_recognition_enabled(False, required=False)
  801. self._target_angular_z = 0.0
  802. self._current_angular_z = 0.0
  803. self._publish_stop()
  804. self._has_control = True
  805. self._publish_status("ALIGNED")
  806. return
  807. if now - self._alignment_started_monotonic > self._alignment_timeout:
  808. self._set_recognition_enabled(False, required=False)
  809. self._target_angular_z = 0.0
  810. self._current_angular_z = 0.0
  811. self._publish_stop()
  812. self._has_control = True
  813. self._publish_status("ALIGNMENT_TIMEOUT")
  814. return
  815. target_fresh = self._visible and now - self._last_detection_monotonic <= self._detection_timeout
  816. if not target_fresh:
  817. self._center_frame_count = 0
  818. self._target_angular_z = 0.0
  819. command_z = self._publish_smooth_command(0.0, now)
  820. self._publish_status("SEARCHING_FACTORY command_z=%+.3f" % command_z)
  821. return
  822. if self._processed_sequence != self._detection_sequence:
  823. self._processed_sequence = self._detection_sequence
  824. if abs(self._error) <= self._tolerance:
  825. self._center_frame_count += 1
  826. self._target_angular_z = 0.0
  827. if self._center_frame_count >= self._center_frames_required:
  828. self._current_angular_z = 0.0
  829. self._publish_stop()
  830. self._has_control = True
  831. if self._wall_approach_enabled and not self._final_alignment_active:
  832. self._start_wall_approach()
  833. return
  834. self._aligned = True
  835. self._final_alignment_active = False
  836. if self._wall_approach_enabled and self._ocr_confirmation_enabled:
  837. self._publish_status("WALL_APPROACH_ALIGNED")
  838. # Full-frame OCR subscribes directly to the camera, so the
  839. # RKNN locator can be stopped before the OCR attempt.
  840. self._set_recognition_enabled(False, required=False)
  841. if self._start_ocr_confirmation():
  842. return
  843. self._wall_goal_finished = True
  844. self._set_recognition_enabled(False, required=False)
  845. return
  846. self._wall_goal_finished = self._wall_approach_enabled
  847. self._set_recognition_enabled(False, required=False)
  848. self._publish_status("WALL_APPROACH_ALIGNED" if self._wall_approach_enabled else "ALIGNED")
  849. return
  850. else:
  851. self._center_frame_count = 0
  852. target = self._angular_sign * self._kp * self._error
  853. target = max(-self._max_speed, min(self._max_speed, target))
  854. if abs(target) < self._min_speed:
  855. target = math.copysign(self._min_speed, target)
  856. self._target_angular_z = target
  857. command_z = self._publish_smooth_command(self._target_angular_z, now)
  858. if self._center_frame_count:
  859. label = "FINAL_CENTER_FRAME" if self._final_alignment_active else "CENTER_FRAME"
  860. self._publish_status("%s %d/%d command_z=%+.3f" % (
  861. label, self._center_frame_count, self._center_frames_required, command_z
  862. ))
  863. else:
  864. label = "FINAL_SMOOTH_ALIGN" if self._final_alignment_active else "SMOOTH_ALIGN"
  865. self._publish_status("%s error=%+.3f target_z=%+.3f command_z=%+.3f" % (
  866. label, self._error, self._target_angular_z, command_z
  867. ))
  868. if __name__ == "__main__":
  869. FactoryAlignmentDemo()
  870. rospy.spin()