sign_recognition_node.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Service-gated full-frame OCR confirmation for a factory sign."""
  4. import os
  5. import subprocess
  6. import cv2
  7. import rospy
  8. from cv_bridge import CvBridge, CvBridgeError
  9. from sensor_msgs.msg import Image
  10. from std_msgs.msg import String
  11. from std_srvs.srv import SetBool, SetBoolResponse
  12. class SignRecognitionNode:
  13. def __init__(self):
  14. rospy.init_node("sign_recognition_node")
  15. self._bridge = CvBridge()
  16. self._enabled = bool(rospy.get_param("~enabled", False))
  17. self._image_topic = rospy.get_param("~image_topic", "/usb_cam/image_raw")
  18. self._flip_horizontal = bool(rospy.get_param("~flip_horizontal", True))
  19. self._keywords = tuple(rospy.get_param("~keywords", ["食品", "日用品", "电子"]))
  20. self._interval = float(rospy.get_param("~interval_seconds", 0.50))
  21. self._max_frames = int(rospy.get_param("~max_frames", 10))
  22. self._scale = float(rospy.get_param("~scale", 2.0))
  23. self._language = rospy.get_param("~language", "chi_sim")
  24. self._psm = int(rospy.get_param("~psm", 6))
  25. self._tesseract_timeout = float(rospy.get_param("~tesseract_timeout_seconds", 4.0))
  26. self._debug_save = bool(rospy.get_param("~debug_save", False))
  27. self._debug_dir = rospy.get_param("~debug_dir", "/tmp/sign_debug")
  28. if self._max_frames <= 0:
  29. raise ValueError("max_frames must be positive")
  30. if self._tesseract_timeout <= 0.0:
  31. raise ValueError("tesseract_timeout_seconds must be positive")
  32. if self._debug_save:
  33. os.makedirs(self._debug_dir, exist_ok=True)
  34. self._last_process_time = 0.0
  35. self._frames_processed = 0
  36. self._result_pub = rospy.Publisher("/sign_recognition", String, queue_size=1)
  37. self._status_pub = rospy.Publisher(
  38. "/sign_recognition/status", String, queue_size=1, latch=True
  39. )
  40. self._debug_pub = rospy.Publisher(
  41. "/sign_recognition/debug_image", Image, queue_size=1
  42. )
  43. self._image_sub = rospy.Subscriber(
  44. self._image_topic, Image, self._image_callback, queue_size=1, buff_size=2 ** 24
  45. )
  46. self._enable_service = rospy.Service(
  47. "/sign_recognition/set_enabled", SetBool, self._set_enabled_callback
  48. )
  49. self._publish_status("OCR_WAITING_FOR_IMAGE" if self._enabled else "OCR_IDLE")
  50. rospy.loginfo(
  51. "sign OCR ready: enabled=%s image_topic=%s max_frames=%d",
  52. self._enabled, self._image_topic, self._max_frames,
  53. )
  54. def _publish_status(self, status):
  55. self._status_pub.publish(String(data=status))
  56. def _reset_attempt(self):
  57. self._last_process_time = 0.0
  58. self._frames_processed = 0
  59. def _set_enabled_callback(self, request):
  60. self._enabled = bool(request.data)
  61. self._reset_attempt()
  62. self._publish_status("OCR_WAITING_FOR_IMAGE" if self._enabled else "OCR_IDLE")
  63. state = "enabled" if self._enabled else "disabled"
  64. rospy.loginfo("sign OCR %s", state)
  65. return SetBoolResponse(success=True, message="sign OCR %s" % state)
  66. @staticmethod
  67. def _clean_text(raw_text):
  68. return raw_text.replace(" ", "").replace("\n", "").replace("\u3000", "")
  69. def _preprocess_variants(self, frame):
  70. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  71. enlarged = cv2.resize(
  72. gray, None, fx=self._scale, fy=self._scale, interpolation=cv2.INTER_CUBIC
  73. )
  74. equalized = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(enlarged)
  75. _threshold, binary = cv2.threshold(
  76. equalized, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
  77. )
  78. return (("gray", enlarged), ("binary", binary))
  79. def _run_tesseract(self, image):
  80. success, encoded = cv2.imencode(".png", image)
  81. if not success:
  82. raise RuntimeError("cannot encode OCR image")
  83. try:
  84. completed = subprocess.run(
  85. ["tesseract", "stdin", "stdout", "-l", self._language,
  86. "--psm", str(self._psm)],
  87. input=encoded.tobytes(), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  88. timeout=self._tesseract_timeout, check=False,
  89. )
  90. except (OSError, subprocess.TimeoutExpired) as error:
  91. raise RuntimeError("tesseract execution failed: %s" % error)
  92. if completed.returncode != 0:
  93. raise RuntimeError(completed.stderr.decode("utf-8", errors="replace").strip())
  94. return completed.stdout.decode("utf-8", errors="replace")
  95. def _match_category(self, cleaned):
  96. # OCR often splits Chinese characters or inserts unrelated characters.
  97. # A single full-frame OCR result is sufficient if it contains every character of one
  98. # factory category, regardless of order or intervening characters.
  99. for category in self._keywords:
  100. if all(character in cleaned for character in category):
  101. return category
  102. # The electronics factory sign may show "生产" rather than "电子".
  103. if "电子" in self._keywords and all(character in cleaned for character in "生产"):
  104. return "电子"
  105. return None
  106. def _extract_keyword(self, frame):
  107. last_variant = None
  108. for variant_name, variant in self._preprocess_variants(frame):
  109. last_variant = variant
  110. raw_text = self._run_tesseract(variant)
  111. cleaned = self._clean_text(raw_text)
  112. rospy.loginfo("[OCR %s] raw=%r cleaned=%r", variant_name, raw_text, cleaned)
  113. category = self._match_category(cleaned)
  114. if category is not None:
  115. return category, last_variant
  116. return None, last_variant
  117. def _publish_debug(self, image):
  118. if image is None:
  119. return
  120. try:
  121. self._debug_pub.publish(self._bridge.cv2_to_imgmsg(image, encoding="mono8"))
  122. except CvBridgeError as error:
  123. rospy.logerr_throttle(5.0, "OCR debug image conversion failed: %s", error)
  124. if self._debug_save:
  125. cv2.imwrite(
  126. os.path.join(self._debug_dir, "ocr_%03d.jpg" % self._frames_processed), image
  127. )
  128. def _finish(self, result, status):
  129. self._enabled = False
  130. self._result_pub.publish(String(data=result))
  131. self._publish_status(status)
  132. def _image_callback(self, message):
  133. if not self._enabled:
  134. return
  135. now = rospy.Time.now().to_sec()
  136. if now - self._last_process_time < self._interval:
  137. return
  138. self._last_process_time = now
  139. try:
  140. frame = self._bridge.imgmsg_to_cv2(message, desired_encoding="bgr8")
  141. except CvBridgeError as error:
  142. rospy.logerr_throttle(5.0, "OCR image conversion failed: %s", error)
  143. return
  144. if self._flip_horizontal:
  145. frame = cv2.flip(frame, 1)
  146. if frame.size == 0 or frame.shape[0] < 8 or frame.shape[1] < 8:
  147. rospy.logwarn_throttle(2.0, "OCR ignored an empty or too-small image")
  148. return
  149. self._frames_processed += 1
  150. try:
  151. keyword, debug_image = self._extract_keyword(frame)
  152. except (RuntimeError, OSError) as error:
  153. rospy.logerr_throttle(5.0, "OCR call failed: %s", error)
  154. keyword, debug_image = None, None
  155. self._publish_debug(debug_image)
  156. if keyword is not None:
  157. rospy.loginfo("factory OCR confirmed from one full image frame: %s", keyword)
  158. self._finish(keyword, "OCR_CONFIRMED type=%s" % keyword)
  159. return
  160. else:
  161. self._publish_status(
  162. "OCR_READING frame=%d/%d no_keyword" % (
  163. self._frames_processed, self._max_frames
  164. )
  165. )
  166. if self._frames_processed >= self._max_frames:
  167. rospy.logwarn("factory OCR failed after %d ROI frames", self._frames_processed)
  168. self._finish("UNKNOWN", "OCR_FAILED no_consensus")
  169. if __name__ == "__main__":
  170. try:
  171. SignRecognitionNode()
  172. rospy.spin()
  173. except (rospy.ROSInterruptException, ValueError):
  174. pass