Просмотр исходного кода

feat: add competition task 1 QR workflow

ucar 1 месяц назад
Сommit
c7402170b2

+ 7 - 0
qr_code_recognition/.gitignore

@@ -0,0 +1,7 @@
+# Deployed models are local build artifacts, not source code.
+models/*.rknn
+models/*.rknn.*
+
+# Python runtime cache.
+scripts/__pycache__/
+*.py[cod]

+ 207 - 0
qr_code_recognition/CMakeLists.txt

@@ -0,0 +1,207 @@
+cmake_minimum_required(VERSION 3.0.2)
+project(qr_code_recognition)
+
+## Compile as C++11, supported in ROS Kinetic and newer
+# add_compile_options(-std=c++11)
+
+## Find catkin macros and libraries
+## if COMPONENTS list like find_package(catkin REQUIRED COMPONENTS xyz)
+## is used, also find other catkin packages
+find_package(catkin REQUIRED COMPONENTS
+  cv_bridge
+  rospy
+  sensor_msgs
+  std_msgs
+)
+
+## System dependencies are found with CMake's conventions
+# find_package(Boost REQUIRED COMPONENTS system)
+
+
+## Uncomment this if the package has a setup.py. This macro ensures
+## modules and global scripts declared therein get installed
+## See http://ros.org/doc/api/catkin/html/user_guide/setup_dot_py.html
+# catkin_python_setup()
+
+################################################
+## Declare ROS messages, services and actions ##
+################################################
+
+## To declare and build messages, services or actions from within this
+## package, follow these steps:
+## * Let MSG_DEP_SET be the set of packages whose message types you use in
+##   your messages/services/actions (e.g. std_msgs, actionlib_msgs, ...).
+## * In the file package.xml:
+##   * add a build_depend tag for "message_generation"
+##   * add a build_depend and a exec_depend tag for each package in MSG_DEP_SET
+##   * If MSG_DEP_SET isn't empty the following dependency has been pulled in
+##     but can be declared for certainty nonetheless:
+##     * add a exec_depend tag for "message_runtime"
+## * In this file (CMakeLists.txt):
+##   * add "message_generation" and every package in MSG_DEP_SET to
+##     find_package(catkin REQUIRED COMPONENTS ...)
+##   * add "message_runtime" and every package in MSG_DEP_SET to
+##     catkin_package(CATKIN_DEPENDS ...)
+##   * uncomment the add_*_files sections below as needed
+##     and list every .msg/.srv/.action file to be processed
+##   * uncomment the generate_messages entry below
+##   * add every package in MSG_DEP_SET to generate_messages(DEPENDENCIES ...)
+
+## Generate messages in the 'msg' folder
+# add_message_files(
+#   FILES
+#   Message1.msg
+#   Message2.msg
+# )
+
+## Generate services in the 'srv' folder
+# add_service_files(
+#   FILES
+#   Service1.srv
+#   Service2.srv
+# )
+
+## Generate actions in the 'action' folder
+# add_action_files(
+#   FILES
+#   Action1.action
+#   Action2.action
+# )
+
+## Generate added messages and services with any dependencies listed here
+# generate_messages(
+#   DEPENDENCIES
+#   sensor_msgs#   std_msgs
+# )
+
+################################################
+## Declare ROS dynamic reconfigure parameters ##
+################################################
+
+## To declare and build dynamic reconfigure parameters within this
+## package, follow these steps:
+## * In the file package.xml:
+##   * add a build_depend and a exec_depend tag for "dynamic_reconfigure"
+## * In this file (CMakeLists.txt):
+##   * add "dynamic_reconfigure" to
+##     find_package(catkin REQUIRED COMPONENTS ...)
+##   * uncomment the "generate_dynamic_reconfigure_options" section below
+##     and list every .cfg file to be processed
+
+## Generate dynamic reconfigure parameters in the 'cfg' folder
+# generate_dynamic_reconfigure_options(
+#   cfg/DynReconf1.cfg
+#   cfg/DynReconf2.cfg
+# )
+
+###################################
+## catkin specific configuration ##
+###################################
+## The catkin_package macro generates cmake config files for your package
+## Declare things to be passed to dependent projects
+## INCLUDE_DIRS: uncomment this if your package contains header files
+## LIBRARIES: libraries you create in this project that dependent projects also need
+## CATKIN_DEPENDS: catkin_packages dependent projects also need
+## DEPENDS: system dependencies of this project that dependent projects also need
+catkin_package(
+#  INCLUDE_DIRS include
+#  LIBRARIES qr_code_recognition
+#  CATKIN_DEPENDS cv_bridge rospy sensor_msgs std_msgs
+#  DEPENDS system_lib
+)
+
+###########
+## Build ##
+###########
+
+## Specify additional locations of header files
+## Your package locations should be listed before other locations
+include_directories(
+# include
+  ${catkin_INCLUDE_DIRS}
+)
+
+## Declare a C++ library
+# add_library(${PROJECT_NAME}
+#   src/${PROJECT_NAME}/qr_code_recognition.cpp
+# )
+
+## Add cmake target dependencies of the library
+## as an example, code may need to be generated before libraries
+## either from message generation or dynamic reconfigure
+# add_dependencies(${PROJECT_NAME} ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})
+
+## Declare a C++ executable
+## With catkin_make all packages are built within a single CMake context
+## The recommended prefix ensures that target names across packages don't collide
+# add_executable(${PROJECT_NAME}_node src/qr_code_recognition_node.cpp)
+
+## Rename C++ executable without prefix
+## The above recommended prefix causes long target names, the following renames the
+## target back to the shorter version for ease of user use
+## e.g. "rosrun someones_pkg node" instead of "rosrun someones_pkg someones_pkg_node"
+# set_target_properties(${PROJECT_NAME}_node PROPERTIES OUTPUT_NAME node PREFIX "")
+
+## Add cmake target dependencies of the executable
+## same as for the library above
+# add_dependencies(${PROJECT_NAME}_node ${${PROJECT_NAME}_EXPORTED_TARGETS} ${catkin_EXPORTED_TARGETS})
+
+## Specify libraries to link a library or executable target against
+# target_link_libraries(${PROJECT_NAME}_node
+#   ${catkin_LIBRARIES}
+# )
+
+#############
+## Install ##
+#############
+
+# all install targets should use catkin DESTINATION variables
+# See http://ros.org/doc/api/catkin/html/adv_user_guide/variables.html
+
+## Mark executable scripts (Python etc.) for installation
+## in contrast to setup.py, you can choose the destination
+# catkin_install_python(PROGRAMS
+#   scripts/my_python_script
+#   DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
+# )
+
+## Mark executables for installation
+## See http://docs.ros.org/melodic/api/catkin/html/howto/format1/building_executables.html
+# install(TARGETS ${PROJECT_NAME}_node
+#   RUNTIME DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
+# )
+
+## Mark libraries for installation
+## See http://docs.ros.org/melodic/api/catkin/html/howto/format1/building_libraries.html
+# install(TARGETS ${PROJECT_NAME}
+#   ARCHIVE DESTINATION ${CATKIN_PACKAGE_LIB_DESTINATION}
+#   LIBRARY DESTINATION ${CATKIN_PACKAGE_LIB_DESTINATION}
+#   RUNTIME DESTINATION ${CATKIN_GLOBAL_BIN_DESTINATION}
+# )
+
+## Mark cpp header files for installation
+# install(DIRECTORY include/${PROJECT_NAME}/
+#   DESTINATION ${CATKIN_PACKAGE_INCLUDE_DESTINATION}
+#   FILES_MATCHING PATTERN "*.h"
+#   PATTERN ".svn" EXCLUDE
+# )
+
+## Mark other files for installation (e.g. launch and bag files, etc.)
+# install(FILES
+#   # myfile1
+#   # myfile2
+#   DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
+# )
+
+#############
+## Testing ##
+#############
+
+## Add gtest based cpp test target and link libraries
+# catkin_add_gtest(${PROJECT_NAME}-test test/test_qr_code_recognition.cpp)
+# if(TARGET ${PROJECT_NAME}-test)
+#   target_link_libraries(${PROJECT_NAME}-test ${PROJECT_NAME})
+# endif()
+
+## Add folders to be run by python nosetests
+# catkin_add_nosetests(test)

+ 20 - 0
qr_code_recognition/config/head_camera.yaml

@@ -0,0 +1,20 @@
+image_width: 640
+image_height: 480
+camera_name: head_camera
+camera_matrix:
+  rows: 3
+  cols: 3
+  data: [461.2953681310527, 0, 294.6839234091084, 0, 463.0881041569399, 243.8141717345016, 0, 0, 1]
+distortion_model: plumb_bob
+distortion_coefficients:
+  rows: 1
+  cols: 5
+  data: [-0.3382641806472924, 0.09294924180863037, -0.003794221327541872, 0.005385955042203554, 0]
+rectification_matrix:
+  rows: 3
+  cols: 3
+  data: [1, 0, 0, 0, 1, 0, 0, 0, 1]
+projection_matrix:
+  rows: 3
+  cols: 4
+  data: [368.5670471191406, 0, 288.7868961422464, 0, 0, 415.5361328125, 242.5790295740589, 0, 0, 0, 1, 0]

+ 71 - 0
qr_code_recognition/package.xml

@@ -0,0 +1,71 @@
+<?xml version="1.0"?>
+<package format="2">
+  <name>qr_code_recognition</name>
+  <version>0.0.0</version>
+  <description>The qr_code_recognition package</description>
+
+  <!-- One maintainer tag required, multiple allowed, one person per tag -->
+  <!-- Example:  -->
+  <!-- <maintainer email="jane.doe@example.com">Jane Doe</maintainer> -->
+  <maintainer email="ucar@todo.todo">ucar</maintainer>
+
+
+  <!-- One license tag required, multiple allowed, one license per tag -->
+  <!-- Commonly used license strings: -->
+  <!--   BSD, MIT, Boost Software License, GPLv2, GPLv3, LGPLv2.1, LGPLv3 -->
+  <license>TODO</license>
+
+
+  <!-- Url tags are optional, but multiple are allowed, one per tag -->
+  <!-- Optional attribute type can be: website, bugtracker, or repository -->
+  <!-- Example: -->
+  <!-- <url type="website">http://wiki.ros.org/qr_code_recognition</url> -->
+
+
+  <!-- Author tags are optional, multiple are allowed, one per tag -->
+  <!-- Authors do not have to be maintainers, but could be -->
+  <!-- Example: -->
+  <!-- <author email="jane.doe@example.com">Jane Doe</author> -->
+
+
+  <!-- The *depend tags are used to specify dependencies -->
+  <!-- Dependencies can be catkin packages or system dependencies -->
+  <!-- Examples: -->
+  <!-- Use depend as a shortcut for packages that are both build and exec dependencies -->
+  <!--   <depend>roscpp</depend> -->
+  <!--   Note that this is equivalent to the following: -->
+  <!--   <build_depend>roscpp</build_depend> -->
+  <!--   <exec_depend>roscpp</exec_depend> -->
+  <!-- Use build_depend for packages you need at compile time: -->
+  <!--   <build_depend>message_generation</build_depend> -->
+  <!-- Use build_export_depend for packages you need in order to build against this package: -->
+  <!--   <build_export_depend>message_generation</build_export_depend> -->
+  <!-- Use buildtool_depend for build tool packages: -->
+  <!--   <buildtool_depend>catkin</buildtool_depend> -->
+  <!-- Use exec_depend for packages you need at runtime: -->
+  <!--   <exec_depend>message_runtime</exec_depend> -->
+  <!-- Use test_depend for packages you need only for testing: -->
+  <!--   <test_depend>gtest</test_depend> -->
+  <!-- Use doc_depend for packages you need only for building documentation: -->
+  <!--   <doc_depend>doxygen</doc_depend> -->
+  <buildtool_depend>catkin</buildtool_depend>
+  <build_depend>cv_bridge</build_depend>
+  <build_depend>rospy</build_depend>
+  <build_depend>sensor_msgs</build_depend>
+  <build_depend>std_msgs</build_depend>
+  <build_export_depend>cv_bridge</build_export_depend>
+  <build_export_depend>rospy</build_export_depend>
+  <build_export_depend>sensor_msgs</build_export_depend>
+  <build_export_depend>std_msgs</build_export_depend>
+  <exec_depend>cv_bridge</exec_depend>
+  <exec_depend>rospy</exec_depend>
+  <exec_depend>sensor_msgs</exec_depend>
+  <exec_depend>std_msgs</exec_depend>
+
+
+  <!-- The export tag contains other, unspecified, tags -->
+  <export>
+    <!-- Other tools can request additional information be placed here -->
+
+  </export>
+</package>

+ 210 - 0
qr_code_recognition/scripts/qr_npu_detector.py

@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+"""RK3588 NPU QR-code box detector used by qr_scan_node.py.
+
+The model is a single-class YOLOv5 export with a 640x640 RGB input and a
+25200x6 output.  Detection is explicitly disabled by default so navigation
+never spends NPU time on QR detection.
+"""
+
+from __future__ import print_function
+
+import logging
+import time
+
+import cv2
+import numpy as np
+
+
+class RKNNQrDetector(object):
+    """Run a single-class YOLOv5 QR detector through RKNNLite."""
+
+    def __init__(
+        self,
+        model_file,
+        input_size=640,
+        confidence_threshold=0.35,
+        nms_threshold=0.45,
+        min_interval_seconds=0.20,
+    ):
+        self.model_file = str(model_file)
+        self.input_size = int(input_size)
+        self.confidence_threshold = float(confidence_threshold)
+        self.nms_threshold = float(nms_threshold)
+        self.min_interval_seconds = float(min_interval_seconds)
+        self.enabled = False
+        self._rknn = None
+        self._last_inference_time = float("-inf")
+
+    @property
+    def loaded(self):
+        return self._rknn is not None
+
+    def prepare(self):
+        """Initialize RKNN from the node's main thread before rospy.spin()."""
+        self._ensure_loaded()
+
+    def set_enabled(self, enabled):
+        # Runtime creation is deliberately not done from a rospy service
+        # callback thread: RKNNLite.init_runtime can block there on RK3588.
+        if enabled and not self.loaded:
+            raise RuntimeError("二维码NPU运行时尚未在主线程初始化")
+        if enabled:
+            self._last_inference_time = float("-inf")
+        self.enabled = bool(enabled)
+
+    def close(self):
+        self.enabled = False
+        if self._rknn is not None:
+            self._rknn.release()
+            self._rknn = None
+
+    def _ensure_loaded(self):
+        if self._rknn is not None:
+            return
+        try:
+            # RKNNLite 1.5 changes logging level names during import.
+            # Delay it until rospy has finished configuring its log handlers.
+            from rknnlite.api import RKNNLite
+            # RKNNLite 1.5 renames standard logging levels (for example
+            # INFO -> I). rospy's roslogging only accepts standard names,
+            # therefore restore both name-to-level and level-to-name maps.
+            for level, name in (
+                (logging.CRITICAL, "CRITICAL"),
+                (logging.ERROR, "ERROR"),
+                (logging.WARNING, "WARNING"),
+                (logging.INFO, "INFO"),
+                (logging.DEBUG, "DEBUG"),
+                (logging.NOTSET, "NOTSET"),
+            ):
+                logging.addLevelName(level, name)
+            logging._nameToLevel.update({
+                "FATAL": logging.FATAL,
+                "WARN": logging.WARNING,
+            })
+        except ImportError:
+            raise RuntimeError(
+                "未找到rknnlite;请使用venv3.9启动二维码NPU节点"
+            )
+        rknn = RKNNLite()
+        result = rknn.load_rknn(self.model_file)
+        if result != 0:
+            raise RuntimeError("加载RKNN二维码模型失败,错误码%d" % result)
+        result = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0)
+        if result != 0:
+            rknn.release()
+            raise RuntimeError("初始化RK3588 NPU失败,错误码%d" % result)
+        self._rknn = rknn
+
+    def _letterbox(self, image):
+        height, width = image.shape[:2]
+        size = self.input_size
+        scale = min(float(size) / float(width), float(size) / float(height))
+        resized_width = int(round(width * scale))
+        resized_height = int(round(height * scale))
+        resized = cv2.resize(
+            image, (resized_width, resized_height), interpolation=cv2.INTER_LINEAR
+        )
+        pad_x = (size - resized_width) // 2
+        pad_y = (size - resized_height) // 2
+        padded = cv2.copyMakeBorder(
+            resized,
+            pad_y,
+            size - resized_height - pad_y,
+            pad_x,
+            size - resized_width - pad_x,
+            cv2.BORDER_CONSTANT,
+            value=(114, 114, 114),
+        )
+        return cv2.cvtColor(padded, cv2.COLOR_BGR2RGB), scale, pad_x, pad_y
+
+    def _nms_indices(self, boxes, scores):
+        if not boxes:
+            return []
+        xywh_boxes = []
+        for x1, y1, x2, y2 in boxes:
+            xywh_boxes.append([
+                int(round(x1)), int(round(y1)),
+                int(round(max(0.0, x2 - x1))),
+                int(round(max(0.0, y2 - y1))),
+            ])
+        indices = cv2.dnn.NMSBoxes(
+            xywh_boxes,
+            scores,
+            self.confidence_threshold,
+            self.nms_threshold,
+        )
+        if len(indices) == 0:
+            return []
+        return np.asarray(indices).reshape(-1).tolist()
+
+    def detect(self, image):
+        """Return (x1, y1, x2, y2, score) boxes in original-image pixels."""
+        if not self.enabled or self._rknn is None:
+            return []
+        now = time.monotonic()
+        if now - self._last_inference_time < self.min_interval_seconds:
+            return []
+        self._last_inference_time = now
+
+        model_image, scale, pad_x, pad_y = self._letterbox(image)
+        outputs = self._rknn.inference(
+            inputs=[model_image],
+            data_format="nhwc",
+        )
+        if len(outputs) != 1:
+            raise RuntimeError("二维码NPU输出数量异常: %d" % len(outputs))
+        prediction = np.asarray(outputs[0]).squeeze()
+        if prediction.ndim != 2 or prediction.shape[1] != 6:
+            raise RuntimeError(
+                "二维码NPU输出形状异常: %s" % (np.asarray(outputs[0]).shape,)
+            )
+
+        scores = prediction[:, 4] * prediction[:, 5]
+        selected = np.where(scores >= self.confidence_threshold)[0]
+        boxes = []
+        candidate_scores = []
+        for index in selected:
+            center_x, center_y, box_width, box_height = prediction[index, :4]
+            boxes.append((
+                center_x - box_width / 2.0,
+                center_y - box_height / 2.0,
+                center_x + box_width / 2.0,
+                center_y + box_height / 2.0,
+            ))
+            candidate_scores.append(float(scores[index]))
+
+        image_height, image_width = image.shape[:2]
+        detections = []
+        for index in self._nms_indices(boxes, candidate_scores):
+            x1, y1, x2, y2 = boxes[index]
+            x1 = max(0.0, min(float(image_width), (x1 - pad_x) / scale))
+            y1 = max(0.0, min(float(image_height), (y1 - pad_y) / scale))
+            x2 = max(0.0, min(float(image_width), (x2 - pad_x) / scale))
+            y2 = max(0.0, min(float(image_height), (y2 - pad_y) / scale))
+            if x2 - x1 >= 8.0 and y2 - y1 >= 8.0:
+                detections.append((x1, y1, x2, y2, candidate_scores[index]))
+        return detections
+
+    @staticmethod
+    def expanded_crop(image, detection, expand_ratio, scale):
+        """Return an expanded, optionally enlarged crop for pyzbar decoding."""
+        x1, y1, x2, y2 = detection[:4]
+        image_height, image_width = image.shape[:2]
+        expand_x = (x2 - x1) * float(expand_ratio)
+        expand_y = (y2 - y1) * float(expand_ratio)
+        left = max(0, int(round(x1 - expand_x)))
+        top = max(0, int(round(y1 - expand_y)))
+        right = min(image_width, int(round(x2 + expand_x)))
+        bottom = min(image_height, int(round(y2 + expand_y)))
+        if right <= left or bottom <= top:
+            return None
+        crop = image[top:bottom, left:right]
+        if float(scale) > 1.0:
+            crop = cv2.resize(
+                crop,
+                None,
+                fx=float(scale),
+                fy=float(scale),
+                interpolation=cv2.INTER_CUBIC,
+            )
+        return crop

+ 220 - 0
qr_code_recognition/scripts/qr_scan_node.py

@@ -0,0 +1,220 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Undistort camera frames, detect QR boxes on RK3588 NPU, and decode on demand."""
+
+from pathlib import Path
+
+import cv2
+import numpy as np
+import requests
+import rospy
+import yaml
+from cv_bridge import CvBridge
+from pyzbar.pyzbar import decode
+from sensor_msgs.msg import Image
+from std_msgs.msg import Float32MultiArray, String
+from std_srvs.srv import SetBool, SetBoolResponse, Trigger, TriggerResponse
+
+from qr_npu_detector import RKNNQrDetector
+
+
+class QRCodeScanner(object):
+    """Three explicit task modes: idle, discovery, and decode."""
+
+    def __init__(self):
+        rospy.init_node("qr_code_scanner", anonymous=True)
+        self.bridge = CvBridge()
+        default_calibration = Path(__file__).resolve().parent.parent / "config" / "head_camera.yaml"
+        self.calibration_file = Path(rospy.get_param("~calibration_file", str(default_calibration)))
+        self.camera_matrix = None
+        self.distortion_coefficients = None
+        self.calibration_width = None
+        self.calibration_height = None
+        self._load_calibration()
+
+        self.scanned_results = []
+        self.scanned_urls = set()
+        self.decode_enabled = False
+        default_model = Path(__file__).resolve().parent.parent / "models" / "qr_detector.rknn"
+        self.npu_crop_expand_ratio = float(rospy.get_param("~npu_crop_expand_ratio", 0.30))
+        self.npu_crop_scale = float(rospy.get_param("~npu_crop_scale", 2.0))
+        self.npu_detector = RKNNQrDetector(
+            model_file=rospy.get_param("~npu_model_file", str(default_model)),
+            input_size=rospy.get_param("~npu_input_size", 640),
+            confidence_threshold=rospy.get_param("~npu_confidence_threshold", 0.35),
+            nms_threshold=rospy.get_param("~npu_nms_threshold", 0.45),
+            min_interval_seconds=rospy.get_param("~npu_min_interval_seconds", 0.20),
+        )
+        # RKNNLite must initialize on this main thread, not in the SetBool
+        # service callback that task1 invokes at center C.
+        self.npu_detector.prepare()
+        rospy.loginfo("二维码RKNN运行时已在主线程初始化,当前仍为idle模式")
+        rospy.on_shutdown(self.npu_detector.close)
+
+        self.image_sub = rospy.Subscriber("/usb_cam/image_raw", Image, self.image_callback, queue_size=1)
+        self.result_pub = rospy.Publisher("/qr_scan_result", String, queue_size=10)
+        self.detection_pub = rospy.Publisher("/qr_scan/npu_detection", Float32MultiArray, queue_size=1)
+        self.debug_image_pub = rospy.Publisher("/qr_scan/debug_image", Image, queue_size=1)
+        self.reset_service = rospy.Service("/qr_scan/reset", Trigger, self.reset_callback)
+        self.npu_enable_service = rospy.Service(
+            "/qr_scan/set_npu_enabled", SetBool, self.npu_enable_callback
+        )
+        self.decode_enable_service = rospy.Service(
+            "/qr_scan/set_decode_enabled", SetBool, self.decode_enable_callback
+        )
+        rospy.loginfo("QR Code Scanner节点已启动(idle模式)")
+
+    def _load_calibration(self):
+        try:
+            with self.calibration_file.open("r", encoding="utf-8") as stream:
+                calibration = yaml.safe_load(stream)
+            self.calibration_width = int(calibration["image_width"])
+            self.calibration_height = int(calibration["image_height"])
+            self.camera_matrix = np.asarray(
+                calibration["camera_matrix"]["data"], dtype=np.float64
+            ).reshape(3, 3)
+            self.distortion_coefficients = np.asarray(
+                calibration["distortion_coefficients"]["data"], dtype=np.float64
+            ).reshape(-1, 1)
+        except (OSError, KeyError, TypeError, ValueError, yaml.YAMLError) as error:
+            rospy.logfatal("无法加载相机标定文件 %s: %s", self.calibration_file, error)
+            raise
+        rospy.loginfo("已加载相机标定: %s (%dx%d, %s)", self.calibration_file,
+                      self.calibration_width, self.calibration_height,
+                      calibration.get("distortion_model", "unknown"))
+
+    def reset_callback(self, _request):
+        self.scanned_results = []
+        self.scanned_urls.clear()
+        self.decode_enabled = False
+        self.npu_detector.set_enabled(False)
+        rospy.loginfo("二维码扫描记录已重置,已切换到idle模式")
+        return TriggerResponse(success=True, message="qr scan state reset")
+
+    def npu_enable_callback(self, request):
+        try:
+            self.npu_detector.set_enabled(request.data)
+        except Exception as error:
+            rospy.logerr("无法切换二维码NPU检测: %s", error)
+            return SetBoolResponse(success=False, message=str(error))
+        state = "开启" if request.data else "关闭"
+        rospy.loginfo("二维码NPU检测已%s", state)
+        return SetBoolResponse(success=True, message="NPU QR detection %s" % state)
+
+    def decode_enable_callback(self, request):
+        self.decode_enabled = bool(request.data)
+        state = "decode" if self.decode_enabled else "discovery/idle"
+        rospy.loginfo("二维码URL解码已%s(当前模式%s)", "开启" if self.decode_enabled else "关闭", state)
+        return SetBoolResponse(success=True, message="QR decode %s" % state)
+
+    def _handle_decoded_url(self, url, source):
+        if url in self.scanned_urls:
+            return
+        self.scanned_urls.add(url)
+        rospy.loginfo("%s识别到二维码URL: %s", source, url)
+        try:
+            response = requests.get(url, timeout=5, proxies={"http": None, "https": None})
+            json_data = response.json()
+            if json_data.get("code") == 200:
+                product_name = json_data.get("result", "未知")
+                rospy.loginfo("识别到货品: %s", product_name)
+                self.scanned_results.append(product_name)
+                self.result_pub.publish(String(data=product_name))
+            else:
+                rospy.logwarn("JSON返回错误: %s", json_data)
+        except Exception as error:
+            rospy.logerr("请求URL失败: %s", error)
+
+    @staticmethod
+    def _draw_pyzbar_polygon(frame, obj):
+        points = obj.polygon
+        if len(points) > 4:
+            hull = cv2.convexHull(np.array(points, dtype=np.float32))
+            hull = list(map(tuple, np.squeeze(hull)))
+        else:
+            hull = points
+        for index in range(len(hull)):
+            cv2.line(frame, hull[index], hull[(index + 1) % len(hull)], (0, 255, 0), 3)
+        if hull:
+            cv2.putText(frame, "QR", tuple(hull[0]), cv2.FONT_HERSHEY_SIMPLEX,
+                        0.7, (0, 255, 0), 2)
+
+    def _publish_best_detection(self, detections):
+        if not detections:
+            return
+        x1, y1, x2, y2, score = max(detections, key=lambda item: item[4])
+        message = Float32MultiArray()
+        message.data = [
+            (x1 + x2) / 2.0, (y1 + y2) / 2.0,
+            x1, y1, x2, y2, score,
+        ]
+        self.detection_pub.publish(message)
+
+    def image_callback(self, data):
+        try:
+            frame = self.bridge.imgmsg_to_cv2(data, "bgr8")
+        except Exception as error:
+            rospy.logerr("图像转换失败: %s", error)
+            return
+        # Idle must be genuinely lightweight: do not undistort, decode, run
+        # inference, or publish debug frames until task1 explicitly enables
+        # discovery or decode mode.  This also keeps service callbacks
+        # responsive when the manager turns NPU detection on at center C.
+        if not self.decode_enabled and not self.npu_detector.enabled:
+            return
+
+        if (frame.shape[1], frame.shape[0]) != (self.calibration_width, self.calibration_height):
+            rospy.logwarn_throttle(5.0, "图像分辨率%dx%d与标定%dx%d不一致,跳过该帧",
+                                   frame.shape[1], frame.shape[0],
+                                   self.calibration_width, self.calibration_height)
+            return
+
+        frame = cv2.undistort(frame, self.camera_matrix, self.distortion_coefficients)
+        decoded_objects = decode(frame) if self.decode_enabled else []
+        for obj in decoded_objects:
+            self._draw_pyzbar_polygon(frame, obj)
+            try:
+                self._handle_decoded_url(obj.data.decode("utf-8"), "pyzbar全图")
+            except UnicodeDecodeError:
+                continue
+
+        detections = []
+        if self.npu_detector.enabled:
+            try:
+                detections = self.npu_detector.detect(frame)
+            except Exception as error:
+                rospy.logerr_throttle(5.0, "二维码NPU推理失败: %s", error)
+            self._publish_best_detection(detections)
+
+        for detection in detections:
+            x1, y1, x2, y2, score = detection
+            crop_objects = []
+            if self.decode_enabled:
+                crop = self.npu_detector.expanded_crop(
+                    frame, detection, self.npu_crop_expand_ratio, self.npu_crop_scale
+                )
+                crop_objects = decode(crop) if crop is not None else []
+            color = (0, 255, 0) if crop_objects else (0, 165, 255)
+            cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), color, 2)
+            cv2.putText(frame, "QR NPU %.2f" % score,
+                        (int(x1), max(20, int(y1) - 6)),
+                        cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
+            for obj in crop_objects:
+                try:
+                    self._handle_decoded_url(obj.data.decode("utf-8"), "NPU裁剪pyzbar")
+                except UnicodeDecodeError:
+                    continue
+
+        self.debug_image_pub.publish(self.bridge.cv2_to_imgmsg(frame, "bgr8"))
+        if len(self.scanned_results) >= 3:
+            rospy.loginfo_throttle(2.0, "===== 已识别商品: %s =====", self.scanned_results)
+
+    def run(self):
+        rospy.spin()
+
+
+if __name__ == "__main__":
+    try:
+        QRCodeScanner().run()
+    except rospy.ROSInterruptException:
+        pass

+ 7 - 0
qr_code_recognition/scripts/qr_scan_npu.sh

@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Run the QR ROS node in the existing RKNNLite Python 3.9 environment.
+set -euo pipefail
+
+exec /home/ucar/venv3.9/bin/python \
+  /home/ucar/ucar_ws/src/qr_code_recognition/scripts/qr_scan_node.py \
+  "$@"

+ 71 - 0
task1/config/task1_scan_points.json

@@ -0,0 +1,71 @@
+{
+  "frame_id": "map",
+  "start_pose": {
+    "x": 0.0,
+    "y": 0.0,
+    "yaw": 0.0
+  },
+  "initial_pose_settle_seconds": 2.0,
+  "initial_pose_position_tolerance_m": 0.3,
+  "initial_pose_yaw_tolerance_rad": 0.2,
+  "move_base_server_timeout_seconds": 10.0,
+  "initial_navigation_timeout_seconds": 180.0,
+  "scan_total_timeout_seconds": 120.0,
+  "scan_window_seconds": 2.0,
+  "scan_settle_seconds": 0.5,
+  "center_rotation_speed_rad_s": 0.25,
+  "center_rotation_yaw_tolerance_rad": 0.08,
+  "center_rotation_timeout_seconds": 35.0,
+  "points": {
+    "wall1_p1": {
+      "x": -1.496,
+      "y": -0.981,
+      "yaw": -3.131
+    },
+    "center_wall1": {
+      "x": -1.46,
+      "y": -0.526,
+      "yaw": 3.141593
+    },
+    "center_wall2": {
+      "x": -1.46,
+      "y": -0.526,
+      "yaw": 1.570796
+    },
+    "center_wall3": {
+      "x": -1.46,
+      "y": -0.526,
+      "yaw": 0.0
+    },
+    "wall3_p1": {
+      "x": -1.401,
+      "y": -0.18,
+      "yaw": 0.017
+    },
+    "wall2_p3": {
+      "x": -1.065,
+      "y": -0.478,
+      "yaw": 1.583
+    },
+    "wall2_p1": {
+      "x": -1.916,
+      "y": -0.479,
+      "yaw": 1.59
+    },
+    "wall1_p3": {
+      "x": -1.533,
+      "y": 0.011,
+      "yaw": -3.131
+    }
+  },
+  "task2_entry": {
+    "x": -1.003,
+    "y": -1.24,
+    "yaw": -1.591
+  },
+  "task3_entry": {
+    "x": 0.350,
+    "y": -2.898,
+    "yaw": -1.591
+  }
+}

+ 473 - 0
task1/scripts/competition_task1_manager.py

@@ -0,0 +1,473 @@
+#!/usr/bin/env python3
+"""Run competition task 1 after a valid voice command."""
+
+import json
+import math
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+import actionlib
+import rospy
+import rospkg
+from actionlib_msgs.msg import GoalStatus
+from geometry_msgs.msg import PoseWithCovarianceStamped, Quaternion, Twist
+from move_base_msgs.msg import MoveBaseAction, MoveBaseGoal
+from std_msgs.msg import String
+from std_srvs.srv import SetBool, Trigger
+
+
+class TaskFailure(Exception):
+    """A controlled task failure that must not use stale TTS output."""
+
+
+class CompetitionTask1Manager:
+    WALLS = ("wall1", "wall2", "wall3")
+
+    def __init__(self):
+        self._lock = threading.Lock()
+        self._status_pub = rospy.Publisher(
+            "/competition_task1/status", String, queue_size=10, latch=True
+        )
+        self._orders_pub = rospy.Publisher(
+            "/competition_task1/orders", String, queue_size=1, latch=True
+        )
+        self._initial_pose_pub = rospy.Publisher(
+            "/initialpose", PoseWithCovarianceStamped, queue_size=1
+        )
+        self._client = actionlib.SimpleActionClient("move_base", MoveBaseAction)
+        self._cmd_vel_pub = rospy.Publisher("/cmd_vel", Twist, queue_size=1)
+        self._latest_amcl_yaw = None
+        self._latest_amcl_x = None
+        self._latest_amcl_y = None
+        self._latest_amcl_time = 0.0
+        self._amcl_sub = rospy.Subscriber(
+            "/amcl_pose", PoseWithCovarianceStamped, self._amcl_callback, queue_size=10
+        )
+        self._qr_sub = rospy.Subscriber(
+            "/qr_scan_result", String, self._qr_callback, queue_size=1
+        )
+        self._active_wall = None
+        self._products = {wall: None for wall in self.WALLS}
+        self._seen_products = set()
+        self._product_order = []
+        self._scan_deadline = None
+        self._config = self._load_config()
+        self._frame_id = self._config["frame_id"]
+        self._start_pose = self._config["start_pose"]
+        self._initial_pose_settle = float(self._config["initial_pose_settle_seconds"])
+        self._initial_pose_position_tolerance = float(
+            self._config["initial_pose_position_tolerance_m"]
+        )
+        self._initial_pose_yaw_tolerance = float(
+            self._config["initial_pose_yaw_tolerance_rad"]
+        )
+        self._points = self._config["points"]
+        self._scan_window = float(self._config["scan_window_seconds"])
+        self._settle = float(self._config["scan_settle_seconds"])
+        self._center_rotation_speed = abs(float(
+            self._config["center_rotation_speed_rad_s"]
+        ))
+        self._center_rotation_tolerance = float(
+            self._config["center_rotation_yaw_tolerance_rad"]
+        )
+        self._center_rotation_timeout = float(
+            self._config["center_rotation_timeout_seconds"]
+        )
+        self._scan_timeout = float(self._config["scan_total_timeout_seconds"])
+        self._initial_navigation_timeout = float(
+            self._config["initial_navigation_timeout_seconds"]
+        )
+        self._server_timeout = float(
+            self._config["move_base_server_timeout_seconds"]
+        )
+        rospy.on_shutdown(self._shutdown)
+
+    @staticmethod
+    def _load_config():
+        package_path = Path(rospkg.RosPack().get_path("ucar_nav"))
+        config_path = package_path / "config" / "competition" / "task1_scan_points.json"
+        with config_path.open("r", encoding="utf-8") as stream:
+            config = json.load(stream)
+        if config.get("frame_id") != "map":
+            raise TaskFailure("任务1扫码点必须使用 map 坐标系")
+        return config
+
+    def _publish(self, state, detail):
+        message = "%s: %s" % (state, detail)
+        rospy.loginfo("competition task1 %s", message)
+        self._status_pub.publish(String(data=message))
+
+    def _amcl_callback(self, message):
+        orientation = message.pose.pose.orientation
+        yaw = math.atan2(
+            2.0 * (orientation.w * orientation.z + orientation.x * orientation.y),
+            1.0 - 2.0 * (orientation.y * orientation.y + orientation.z * orientation.z),
+        )
+        with self._lock:
+            self._latest_amcl_x = message.pose.pose.position.x
+            self._latest_amcl_y = message.pose.pose.position.y
+            self._latest_amcl_yaw = yaw
+            self._latest_amcl_time = time.monotonic()
+
+    def _set_initial_pose(self):
+        try:
+            x = float(self._start_pose["x"])
+            y = float(self._start_pose["y"])
+            yaw = float(self._start_pose["yaw"])
+        except (KeyError, TypeError, ValueError) as error:
+            raise TaskFailure("初始位姿配置无效:%s" % error)
+        if not all(math.isfinite(value) for value in (x, y, yaw)):
+            raise TaskFailure("初始位姿包含非有限数值")
+
+        message = PoseWithCovarianceStamped()
+        message.header.frame_id = self._frame_id
+        message.pose.pose.position.x = x
+        message.pose.pose.position.y = y
+        message.pose.pose.orientation.z = math.sin(yaw / 2.0)
+        message.pose.pose.orientation.w = math.cos(yaw / 2.0)
+        message.pose.covariance[0] = 0.25
+        message.pose.covariance[7] = 0.25
+        message.pose.covariance[35] = 0.0685
+        self._publish("INITIALIZING", "设置AMCL初始位姿 x=%.3f y=%.3f yaw=%.3f" % (x, y, yaw))
+        for _ in range(3):
+            message.header.stamp = rospy.Time.now()
+            self._initial_pose_pub.publish(message)
+            rospy.sleep(0.1)
+
+        deadline = time.monotonic() + self._initial_pose_settle
+        while not rospy.is_shutdown() and time.monotonic() < deadline:
+            with self._lock:
+                current_x = self._latest_amcl_x
+                current_y = self._latest_amcl_y
+                current_yaw = self._latest_amcl_yaw
+            if current_x is not None and current_y is not None and current_yaw is not None:
+                position_error = math.hypot(current_x - x, current_y - y)
+                yaw_error = abs(self._wrap_to_pi(current_yaw - yaw))
+                if (position_error <= self._initial_pose_position_tolerance
+                        and yaw_error <= self._initial_pose_yaw_tolerance):
+                    self._publish("INITIALIZED", "AMCL已确认初始位姿")
+                    return
+            rospy.sleep(0.05)
+        raise TaskFailure("AMCL未确认自动设置的初始位姿")
+
+    def _shutdown(self):
+        self._client.cancel_all_goals()
+        self._stop_robot()
+        self._set_scanner_mode(False, False, required=False)
+
+    def _qr_callback(self, message):
+        product = message.data.strip()
+        if not product:
+            return
+        with self._lock:
+            if product in self._seen_products:
+                rospy.logwarn("task1 ignored duplicate product: %s", product)
+                return
+            self._seen_products.add(product)
+            self._product_order.append(product)
+            wall = self._active_wall
+            if wall is not None and self._products[wall] is None:
+                self._products[wall] = product
+                self._active_wall = None
+        location = wall if wall is not None else "scan_area"
+        self._publish("QR_FOUND", "%s识别到%s(第%d个商品)" % (
+            location, product, len(self._product_order)
+        ))
+
+    def _reset_qr_scanner(self):
+        try:
+            rospy.wait_for_service("/qr_scan/reset", timeout=3.0)
+            response = rospy.ServiceProxy("/qr_scan/reset", Trigger)()
+        except (rospy.ROSException, rospy.ServiceException) as error:
+            raise TaskFailure("二维码节点未就绪或不支持重置:%s" % error)
+        if not response.success:
+            raise TaskFailure("二维码节点重置失败:%s" % response.message)
+
+    @staticmethod
+    def _set_bool_service(name, value, required):
+        try:
+            rospy.wait_for_service(name, timeout=3.0)
+            response = rospy.ServiceProxy(name, SetBool)(value)
+        except (rospy.ROSException, rospy.ServiceException) as error:
+            if required:
+                raise TaskFailure("二维码服务%s不可用:%s" % (name, error))
+            rospy.logwarn("二维码服务%s关闭失败:%s", name, error)
+            return
+        if not response.success:
+            if required:
+                raise TaskFailure("二维码服务%s切换失败:%s" % (name, response.message))
+            rospy.logwarn("二维码服务%s关闭失败:%s", name, response.message)
+
+    def _set_scanner_mode(self, npu_enabled, decode_enabled, required=True):
+        # Decode is toggled first so idle mode never fetches QR URLs.
+        self._set_bool_service("/qr_scan/set_decode_enabled", decode_enabled, required)
+        self._set_bool_service("/qr_scan/set_npu_enabled", npu_enabled, required)
+
+    def _remaining_scan_time(self):
+        if self._scan_deadline is None:
+            return None
+        return self._scan_deadline - time.monotonic()
+
+    def _require_scan_time(self):
+        remaining = self._remaining_scan_time()
+        if remaining is not None and remaining <= 0.0:
+            raise TaskFailure("扫码总超时(%.0f 秒)" % self._scan_timeout)
+        return remaining
+
+    def _goal_from_point(self, name):
+        try:
+            point = self._points[name]
+            x = float(point["x"])
+            y = float(point["y"])
+            yaw = float(point["yaw"])
+        except (KeyError, TypeError, ValueError) as error:
+            raise TaskFailure("扫码点%s配置无效:%s" % (name, error))
+        if not all(math.isfinite(value) for value in (x, y, yaw)):
+            raise TaskFailure("扫码点%s包含非有限数值" % name)
+        goal = MoveBaseGoal()
+        goal.target_pose.header.frame_id = self._frame_id
+        goal.target_pose.header.stamp = rospy.Time.now()
+        goal.target_pose.pose.position.x = x
+        goal.target_pose.pose.position.y = y
+        goal.target_pose.pose.orientation = Quaternion(
+            z=math.sin(yaw / 2.0), w=math.cos(yaw / 2.0)
+        )
+        return goal
+
+    def _navigate(self, point_name, initial=False):
+        remaining = None if initial else self._require_scan_time()
+        timeout = self._initial_navigation_timeout if initial else remaining
+        goal = self._goal_from_point(point_name)
+        self._publish(
+            "NAVIGATING",
+            "%s x=%.3f y=%.3f yaw=%.3f" % (
+                point_name,
+                goal.target_pose.pose.position.x,
+                goal.target_pose.pose.position.y,
+                2.0 * math.atan2(
+                    goal.target_pose.pose.orientation.z,
+                    goal.target_pose.pose.orientation.w,
+                ),
+            ),
+        )
+        self._client.send_goal(goal)
+        deadline = time.monotonic() + timeout
+        terminal_states = {
+            GoalStatus.PREEMPTED, GoalStatus.ABORTED, GoalStatus.REJECTED,
+            GoalStatus.RECALLED, GoalStatus.LOST,
+        }
+        while not rospy.is_shutdown() and time.monotonic() < deadline:
+            state = self._client.get_state()
+            if state == GoalStatus.SUCCEEDED:
+                return
+            if state in terminal_states:
+                raise TaskFailure("未到达%s,move_base状态码%d" % (point_name, state))
+            rospy.sleep(0.05)
+        self._client.cancel_goal()
+        if initial:
+            raise TaskFailure("前往领取区初始点超时")
+        raise TaskFailure("前往%s时扫码总超时" % point_name)
+
+    def _wrap_to_pi(self, angle):
+        return math.atan2(math.sin(angle), math.cos(angle))
+
+    def _stop_robot(self):
+        command = Twist()
+        for _ in range(3):
+            self._cmd_vel_pub.publish(command)
+            rospy.sleep(0.03)
+
+    def _turn_in_place(self, target_point):
+        target = float(self._points[target_point]["yaw"])
+        remaining = self._require_scan_time()
+        timeout = min(self._center_rotation_timeout, remaining)
+        deadline = time.monotonic() + timeout
+        self._client.cancel_all_goals()
+        self._stop_robot()
+        self._publish(
+            "ROTATING",
+            "%s slow in-place turn to %.3f rad at %.2f rad/s" % (
+                target_point, target, self._center_rotation_speed
+            ),
+        )
+        try:
+            while not rospy.is_shutdown() and time.monotonic() < deadline:
+                with self._lock:
+                    yaw = self._latest_amcl_yaw
+                if yaw is None:
+                    rospy.sleep(0.05)
+                    continue
+                error = self._wrap_to_pi(target - yaw)
+                if abs(error) <= self._center_rotation_tolerance:
+                    return
+                command = Twist()
+                command.angular.z = (
+                    self._center_rotation_speed if error > 0.0
+                    else -self._center_rotation_speed
+                )
+                self._cmd_vel_pub.publish(command)
+                rospy.sleep(0.05)
+        finally:
+            self._stop_robot()
+        raise TaskFailure("中央原地转向%s超时" % target_point)
+
+    def _scan_wall(self, wall, point_name):
+        if self._all_walls_found():
+            return True
+        if self._products[wall] is not None:
+            self._publish("SKIPPED", "%s已经识别,跳过%s" % (wall, point_name))
+            return True
+        self._require_scan_time()
+        self._set_scanner_mode(True, True)
+        try:
+            rospy.sleep(min(self._settle, self._require_scan_time()))
+            with self._lock:
+                self._active_wall = wall
+            self._publish("SCANNING", "%s在%s等待二维码" % (wall, point_name))
+            deadline = time.monotonic() + min(
+                self._scan_window, self._require_scan_time()
+            )
+            while not rospy.is_shutdown() and time.monotonic() < deadline:
+                with self._lock:
+                    if self._products[wall] is not None or self._all_walls_found():
+                        return True
+                rospy.sleep(0.05)
+            with self._lock:
+                return self._products[wall] is not None or self._all_walls_found()
+        finally:
+            with self._lock:
+                if self._active_wall == wall:
+                    self._active_wall = None
+            self._set_scanner_mode(False, False, required=False)
+
+    def _all_walls_found(self):
+        return len(self._product_order) >= len(self.WALLS)
+
+    def _clear_previous_files(self):
+        spark_dir = self._spark_dir()
+        for filename in ("tts_output.txt", "llm_result.json", "qr_products_input.json"):
+            path = spark_dir / "audio" / filename
+            if path.exists():
+                path.unlink()
+
+    @staticmethod
+    def _spark_dir():
+        speech_path = Path(rospkg.RosPack().get_path("speech_command"))
+        return speech_path.parent / "SparkTalk"
+
+    def _write_products_for_spark(self):
+        products = list(self._product_order)
+        if len(products) != len(self.WALLS):
+            raise TaskFailure("未收集到三个不同商品")
+        output = self._spark_dir() / "audio" / "qr_products_input.json"
+        payload = {"products": products, "wall_products": self._products}
+        with output.open("w", encoding="utf-8") as stream:
+            json.dump(payload, stream, ensure_ascii=False, indent=2)
+        self._publish("QR_COMPLETE", "已收集%s" % "、".join(products))
+
+    @staticmethod
+    def _factory_category(category):
+        return {"食品": "食品", "日用品": "日用品", "电子产品": "电子"}.get(str(category).strip())
+
+    @staticmethod
+    def _warehouse_for(category):
+        return {"食品": "食品加工车间", "日用品": "日用品加工车间",
+                "电子产品": "电子产品生产车间"}.get(category)
+
+    def _publish_orders(self, spark_dir):
+        path = spark_dir / "audio" / "llm_result.json"
+        try:
+            with path.open("r", encoding="utf-8") as stream:
+                result = json.load(stream)
+            raw_tasks = [("real_task", result["real_task"]),
+                         ("simulation_task", result["simulation_task"])]
+            orders = []
+            for order_id, task in raw_tasks:
+                if task.get("success") is not True:
+                    raise ValueError("%s未成功分类" % order_id)
+                product = str(task["matched_product"]).strip()
+                category = str(task["category"]).strip()
+                factory_category = self._factory_category(category)
+                warehouse = self._warehouse_for(category)
+                if not product or factory_category is None or warehouse is None:
+                    raise ValueError("%s字段或类别无效" % order_id)
+                orders.append({"order_id": order_id, "product": product,
+                               "category": category, "factory_category": factory_category,
+                               "warehouse": warehouse})
+            if orders[0]["factory_category"] == orders[1]["factory_category"]:
+                raise ValueError("两份订单类别相同")
+        except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
+            raise TaskFailure("星火订单结果无效:%s" % error)
+        self._orders_pub.publish(String(data=json.dumps({"orders": orders}, ensure_ascii=False, sort_keys=True)))
+        self._publish("ORDERS_READY", "已发布真实与仿真两份订单")
+
+    def _run_spark(self):
+        spark_dir = self._spark_dir()
+        result = subprocess.run([sys.executable, "SparkMain.py"], cwd=str(spark_dir))
+        if result.returncode != 0:
+            raise TaskFailure("星火分类或JSON校验失败,退出码%d" % result.returncode)
+        self._publish_orders(spark_dir)
+        self._publish("COMPLETE", "任务1完成,等待语音播报")
+
+    def run(self):
+        self._clear_previous_files()
+        self._publish("WAITING_FOR_NAV", "等待move_base")
+        if not self._client.wait_for_server(rospy.Duration(self._server_timeout)):
+            raise TaskFailure("%s秒内未连接到move_base" % self._server_timeout)
+        self._set_initial_pose()
+        self._reset_qr_scanner()
+
+        self._navigate("wall1_p1", initial=True)
+        self._scan_deadline = time.monotonic() + self._scan_timeout
+        self._scan_wall("wall1", "wall1_p1")
+
+        # 在中央C仅导航一次;其余两个墙面朝向由低速原地旋转完成。
+        self._navigate("center_wall1")
+        self._turn_in_place("center_wall1")
+        self._scan_wall("wall1", "center_wall1")
+        if not self._all_walls_found():
+            self._turn_in_place("center_wall2")
+            self._scan_wall("wall2", "center_wall2")
+        if not self._all_walls_found():
+            self._turn_in_place("center_wall3")
+            self._scan_wall("wall3", "center_wall3")
+
+        for wall, point in (
+            ("wall3", "wall3_p1"),
+            ("wall2", "wall2_p3"),
+            ("wall2", "wall2_p1"),
+            ("wall1", "wall1_p3"),
+        ):
+            if self._all_walls_found():
+                break
+            if self._products[wall] is None:
+                self._navigate(point)
+                self._scan_wall(wall, point)
+
+        if not self._all_walls_found():
+            missing = [wall for wall in self.WALLS if self._products[wall] is None]
+            raise TaskFailure("扫码结束,未识别%s" % "、".join(missing))
+        self._write_products_for_spark()
+        self._run_spark()
+
+
+def main():
+    rospy.init_node("competition_task1_manager")
+    manager = CompetitionTask1Manager()
+    try:
+        manager.run()
+        return 0
+    except TaskFailure as error:
+        manager._publish("FAILED", str(error))
+        return 1
+    except Exception as error:
+        rospy.logerr("competition task1 crashed: %s", error)
+        manager._publish("FAILED", "任务执行器异常:%s" % error)
+        return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())