forked from zbw/yiliao2026
docs: add unified usb camera launch plan
This commit is contained in:
329
docs/superpowers/plans/2026-07-23-unified-usb-camera-launch.md
Normal file
329
docs/superpowers/plans/2026-07-23-unified-usb-camera-launch.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Unified USB Camera Launch Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make both USB camera launch files use one 1280x720 native-MJPEG YAML configuration, with WebSocket as their only behavioral difference.
|
||||
|
||||
**Architecture:** The base launch owns all camera behavior and loads a single ROS parameter YAML. The WebSocket launch includes the base launch unchanged and adds only the WebSocket include. A minimal CMake file installs both `launch/` and `config/`, replacing reliance on stale install-space symlinks.
|
||||
|
||||
**Tech Stack:** ROS 2 Humble launch, ROS 2 parameter YAML, ament_cmake, Python `ast`/`unittest`, D-Robotics `hobot_usb_cam` and `websocket` packages.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/car_usb_cam/config/usb_camera.yaml`: sole camera runtime configuration.
|
||||
- Keep `src/car_usb_cam/config/usb_camera_calibration.yaml`: calibration data only.
|
||||
- Modify `src/car_usb_cam/launch/hobot_usb_cam.launch.py`: load YAML and start one camera plus shared-memory environment.
|
||||
- Modify `src/car_usb_cam/launch/hobot_usb_cam_websocket.launch.py`: include the base launch and add WebSocket only.
|
||||
- Create `src/car_usb_cam/CMakeLists.txt`: install launch/config directories and register tests.
|
||||
- Create `src/car_usb_cam/test/test_unified_launch_config.py`: structural regression check.
|
||||
- Append `D:/Programme/agent/projects/yiliao/log.md`: timestamped implementation and verification record.
|
||||
|
||||
### Task 1: Add a failing structural regression test
|
||||
|
||||
**Files:**
|
||||
- Create: `src/car_usb_cam/test/test_unified_launch_config.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
import ast
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PACKAGE_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
BASE_LAUNCH = PACKAGE_ROOT / "launch" / "hobot_usb_cam.launch.py"
|
||||
WEB_LAUNCH = PACKAGE_ROOT / "launch" / "hobot_usb_cam_websocket.launch.py"
|
||||
CAMERA_CONFIG = PACKAGE_ROOT / "config" / "usb_camera.yaml"
|
||||
|
||||
|
||||
def string_constants(path):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
return {
|
||||
node.value
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
}
|
||||
|
||||
|
||||
def call_names(path):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
names = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if isinstance(node.func, ast.Name):
|
||||
names.append(node.func.id)
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
names.append(node.func.attr)
|
||||
return names
|
||||
|
||||
|
||||
class UnifiedLaunchConfigTest(unittest.TestCase):
|
||||
def test_yaml_defines_final_native_mjpeg_configuration(self):
|
||||
document = yaml.safe_load(CAMERA_CONFIG.read_text(encoding="utf-8"))
|
||||
params = document["hobot_usb_cam"]["ros__parameters"]
|
||||
self.assertEqual(params["video_device"], "/dev/video0")
|
||||
self.assertEqual(params["image_width"], 1280)
|
||||
self.assertEqual(params["image_height"], 720)
|
||||
self.assertEqual(params["framerate"], 30)
|
||||
self.assertEqual(params["pixel_format"], "mjpeg")
|
||||
self.assertEqual(params["io_method"], "mmap")
|
||||
self.assertFalse(params["zero_copy"])
|
||||
|
||||
def test_base_launch_owns_the_only_camera_node(self):
|
||||
constants = string_constants(BASE_LAUNCH)
|
||||
self.assertIn("usb_camera.yaml", constants)
|
||||
self.assertEqual(call_names(BASE_LAUNCH).count("Node"), 1)
|
||||
self.assertIn("hobot_usb_cam", constants)
|
||||
|
||||
def test_web_launch_only_adds_websocket(self):
|
||||
constants = string_constants(WEB_LAUNCH)
|
||||
self.assertIn("hobot_usb_cam.launch.py", constants)
|
||||
self.assertIn("websocket.launch.py", constants)
|
||||
self.assertEqual(call_names(WEB_LAUNCH).count("Node"), 0)
|
||||
for forbidden in (
|
||||
"hobot_codec",
|
||||
"/image_mjpeg",
|
||||
"yuyv2rgb",
|
||||
"usb_image_width",
|
||||
"usb_image_height",
|
||||
"usb_pixel_format",
|
||||
):
|
||||
self.assertNotIn(forbidden, constants)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd /home/sunrise/yiliao_ws
|
||||
python3 -m unittest src/car_usb_cam/test/test_unified_launch_config.py -v
|
||||
```
|
||||
|
||||
Expected: errors because `config/usb_camera.yaml` does not exist, plus failure because the current WebSocket launch contains codec and camera override literals.
|
||||
|
||||
### Task 2: Create the shared runtime YAML and install rules
|
||||
|
||||
**Files:**
|
||||
- Create: `src/car_usb_cam/config/usb_camera.yaml`
|
||||
- Create: `src/car_usb_cam/CMakeLists.txt`
|
||||
|
||||
- [ ] **Step 1: Add the shared camera parameter YAML**
|
||||
|
||||
```yaml
|
||||
hobot_usb_cam:
|
||||
ros__parameters:
|
||||
camera_calibration_file_path: /home/sunrise/yiliao_ws/src/car_usb_cam/config/usb_camera_calibration.yaml
|
||||
frame_id: default_usb_cam
|
||||
framerate: 30
|
||||
image_height: 720
|
||||
image_width: 1280
|
||||
io_method: mmap
|
||||
pixel_format: mjpeg
|
||||
video_device: /dev/video0
|
||||
zero_copy: false
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add reproducible package installation and test registration**
|
||||
|
||||
```cmake
|
||||
cmake_minimum_required(VERSION 3.8)
|
||||
project(car_usb_cam)
|
||||
|
||||
find_package(ament_cmake REQUIRED)
|
||||
|
||||
install(
|
||||
DIRECTORY config launch
|
||||
DESTINATION share/${PROJECT_NAME}
|
||||
)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
find_package(ament_cmake_pytest REQUIRED)
|
||||
ament_add_pytest_test(
|
||||
test_unified_launch_config
|
||||
test/test_unified_launch_config.py
|
||||
)
|
||||
endif()
|
||||
|
||||
ament_package()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test and confirm the remaining failure is the old launch structure**
|
||||
|
||||
Run the same unittest command. Expected: YAML test passes; WebSocket structure test still fails because the old file contains `hobot_codec` and camera overrides.
|
||||
|
||||
### Task 3: Refactor the launch files
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/car_usb_cam/launch/hobot_usb_cam.launch.py`
|
||||
- Modify: `src/car_usb_cam/launch/hobot_usb_cam_websocket.launch.py`
|
||||
|
||||
- [ ] **Step 1: Replace the base launch with the YAML-backed camera definition**
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
from launch.substitutions import LaunchConfiguration
|
||||
from launch_ros.actions import Node
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
package_share = get_package_share_directory("car_usb_cam")
|
||||
camera_config = os.path.join(package_share, "config", "usb_camera.yaml")
|
||||
|
||||
return LaunchDescription([
|
||||
DeclareLaunchArgument(
|
||||
"camera_config",
|
||||
default_value=camera_config,
|
||||
description="ROS parameter file for the USB camera",
|
||||
),
|
||||
IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(
|
||||
get_package_share_directory("hobot_shm"),
|
||||
"launch",
|
||||
"hobot_shm.launch.py",
|
||||
)
|
||||
)
|
||||
),
|
||||
Node(
|
||||
package="hobot_usb_cam",
|
||||
executable="hobot_usb_cam",
|
||||
name="hobot_usb_cam",
|
||||
parameters=[LaunchConfiguration("camera_config")],
|
||||
arguments=["--ros-args", "--log-level", "warn"],
|
||||
),
|
||||
])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the WebSocket launch with a base include plus WebSocket include**
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from ament_index_python.packages import get_package_share_directory
|
||||
from launch import LaunchDescription
|
||||
from launch.actions import IncludeLaunchDescription
|
||||
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
||||
|
||||
|
||||
def generate_launch_description():
|
||||
camera_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(
|
||||
get_package_share_directory("car_usb_cam"),
|
||||
"launch",
|
||||
"hobot_usb_cam.launch.py",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
websocket_launch = IncludeLaunchDescription(
|
||||
PythonLaunchDescriptionSource(
|
||||
os.path.join(
|
||||
get_package_share_directory("websocket"),
|
||||
"launch",
|
||||
"websocket.launch.py",
|
||||
)
|
||||
),
|
||||
launch_arguments={
|
||||
"websocket_image_topic": "/image",
|
||||
"websocket_image_type": "mjpeg",
|
||||
"websocket_only_show_image": "True",
|
||||
"websocket_output_fps": "30",
|
||||
}.items(),
|
||||
)
|
||||
|
||||
return LaunchDescription([camera_launch, websocket_launch])
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the structural test and verify GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest src/car_usb_cam/test/test_unified_launch_config.py -v
|
||||
```
|
||||
|
||||
Expected: all three tests pass.
|
||||
|
||||
- [ ] **Step 4: Build the package and run package tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
colcon build --symlink-install --packages-select car_usb_cam
|
||||
colcon test --packages-select car_usb_cam --event-handlers console_direct+
|
||||
colcon test-result --verbose
|
||||
```
|
||||
|
||||
Expected: build succeeds and `test_unified_launch_config` passes with zero failures.
|
||||
|
||||
- [ ] **Step 5: Commit implementation**
|
||||
|
||||
```bash
|
||||
git add src/car_usb_cam/CMakeLists.txt \
|
||||
src/car_usb_cam/config/usb_camera.yaml \
|
||||
src/car_usb_cam/launch/hobot_usb_cam.launch.py \
|
||||
src/car_usb_cam/launch/hobot_usb_cam_websocket.launch.py \
|
||||
src/car_usb_cam/test/test_unified_launch_config.py
|
||||
git commit -m "refactor: unify usb camera launch configuration"
|
||||
```
|
||||
|
||||
### Task 4: Sequential runtime verification
|
||||
|
||||
**Files:**
|
||||
- Append: `D:/Programme/agent/projects/yiliao/log.md`
|
||||
|
||||
- [ ] **Step 1: Stop current camera, codec, generic USB camera, and WebSocket processes**
|
||||
|
||||
Use targeted `pkill -TERM -f` patterns and verify `/dev/video0` is not held with `fuser -v /dev/video0`.
|
||||
|
||||
- [ ] **Step 2: Start the base launch and verify camera-only behavior**
|
||||
|
||||
Run `ros2 launch car_usb_cam hobot_usb_cam.launch.py`, then verify:
|
||||
|
||||
- one `/hobot_usb_cam` node;
|
||||
- `/image` type is `sensor_msgs/msg/CompressedImage`;
|
||||
- decoded image dimensions are 1280x720;
|
||||
- `ros2 topic hz /image` is approximately 30 FPS;
|
||||
- no `/websocket` node and no listener on port 8080.
|
||||
|
||||
- [ ] **Step 3: Stop the base launch and start the WebSocket launch**
|
||||
|
||||
Run `ros2 launch car_usb_cam hobot_usb_cam_websocket.launch.py`, then verify the same camera node parameters, image type, dimensions, and FPS. Confirm exactly one `/websocket` node and one listener on port 8080.
|
||||
|
||||
- [ ] **Step 4: Compare both captured camera parameter sets**
|
||||
|
||||
Capture `ros2 param dump /hobot_usb_cam` for both runs, normalize volatile output, and use `diff -u`. Expected: no differences.
|
||||
|
||||
- [ ] **Step 5: Leave the WebSocket launch running and verify `.164` routing**
|
||||
|
||||
Confirm the webpage receives a 1280x720 frame and `ip route get <client> from 192.168.10.164` returns `dev wlx200db0c3fca5 table 164`.
|
||||
|
||||
- [ ] **Step 6: Append the dated implementation and verification results to `log.md`**
|
||||
|
||||
Record changed files, test outputs, both launch measurements, parameter diff result, final process state, and the `.164` route result.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: shared YAML, base include architecture, removal of codec, native MJPEG flow, sequential runtime validation, and single WebSocket difference are all mapped to Tasks 1-4.
|
||||
- Scope: all changes are confined to `car_usb_cam`, its test/install metadata, and the requested local operation log.
|
||||
- Type consistency: both launch variants publish `/image` as `sensor_msgs/msg/CompressedImage`; WebSocket consumes `/image` as `mjpeg`.
|
||||
- Operational constraint: validation explicitly handles exclusive ownership of `/dev/video0`.
|
||||
Reference in New Issue
Block a user