From eea5af0a070c40167cc89d93a49003988394511e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:38:11 +0000 Subject: [PATCH 01/12] Initial plan From 9ef97d47e0aced99d4f042049706d068e8ca1e16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:43:23 +0000 Subject: [PATCH 02/12] Add Docker build/push workflow, update docker-compose with .env support, add .env.example, update README Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/780fdaf0-353c-492b-ad82-dee7b0de0d96 Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com> --- .env.example | 27 ++++++++++++ .github/workflows/docker-build-push.yml | 58 +++++++++++++++++++++++++ .gitignore | 3 ++ README.md | 58 +++++++++++++++++++++++-- docker-compose.yaml | 14 +++--- 5 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/docker-build-push.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5ed7b0 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# MDPCalib Docker Compose Environment Configuration +# Copy this file to .env and adjust the values to match your setup. +# +# Usage: +# cp .env.example .env +# # edit .env as needed +# docker compose up + +# Docker image to use. +# Use a specific version tag for reproducible deployments, e.g.: +# MDPCALIB_IMAGE=ghcr.io/lcas/mdpcalib:v1.0.0 +MDPCALIB_IMAGE=ghcr.io/lcas/mdpcalib:latest + +# Absolute path to your local data directory. +# This directory will be mounted to /data inside the container. +# It should contain: +# - kitti/ raw KITTI rosbags +# - cmrnext/ CMRNext model weights +# - calibration/ output directory (created automatically) +DATA_PATH=/CHANGE/ME/absolute/path/to/your/data + +# X11 display for GUI applications (e.g. RViz). +# On most Linux desktops this is already set in your shell environment. +DISPLAY=:0 + +# X11 authority file (optional — leave empty if not using xauth). +XAUTHORITY= diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..31d56f3 --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,58 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: ghcr.io + # github.repository is 'LCAS/MDPCalib'; ghcr.io automatically lowercases + # the path, so the published image is ghcr.io/lcas/mdpcalib. + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and push Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 054e123..9d34a60 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ src/CMRNet/visibility_pkg/build/ src/CMRNet/visibility_pkg/dist/ __pycache__ src/FAST_LO/Log/* + +# local Docker Compose environment — never commit real credentials +.env diff --git a/README.md b/README.md index 1b1c41c..88e306f 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,61 @@ Sensor setups of robotic platforms commonly include both camera and LiDAR as the Tested with `Docker version 28.0.1` and `Docker Compose version v2.33.1`. -- To build the image, run `docker compose build` in the root of this repository. -- Prepare using GUIs in the container: `xhost +local:docker`. -- Start container and mount rosbags: `docker compose run -v PATH_TO_DATA:/data -it mdpcalib` +Pre-built images are published automatically to the GitHub Container Registry on every push to `main` and on every version tag: + +``` +ghcr.io/lcas/mdpcalib:latest # latest build from main +ghcr.io/lcas/mdpcalib: # e.g. ghcr.io/lcas/mdpcalib:1.2.3 +``` + +##### Quick start (no source checkout required) + +1. Download the compose file: + ```bash + curl -O https://raw.githubusercontent.com/LCAS/MDPCalib/main/docker-compose.yaml + ``` +2. Create a `.env` file from the provided example: + ```bash + curl -O https://raw.githubusercontent.com/LCAS/MDPCalib/main/.env.example + cp .env.example .env + # Edit .env (not .env.example) and set DATA_PATH to your local data directory + ``` +3. Allow GUI applications (e.g. RViz) to connect to your display: + ```bash + xhost +local:docker + ``` +4. Start the container: + ```bash + docker compose run -it mdpcalib + ``` + +The `.env` file controls the following variables (see [`.env.example`](.env.example) for documentation): + +| Variable | Default | Description | +|---|---|---| +| `MDPCALIB_IMAGE` | `ghcr.io/lcas/mdpcalib:latest` | Docker image to use | +| `DATA_PATH` | `./data` | Host path mounted to `/data` inside the container | +| `DISPLAY` | `:0` | X11 display for GUI tools | +| `XAUTHORITY` | *(empty)* | X11 authority file (optional) | + +##### Building the image locally (for development) + +To build the image from source instead of pulling the pre-built one, create a +`docker-compose.override.yml` alongside `docker-compose.yaml`: + +```yaml +services: + mdpcalib: + build: + context: . + volumes: + # mount the local source tree so changes are reflected immediately + - ./src:/root/catkin_ws/src/mdpcalib +``` + +Then run `docker compose build` to build, or `docker compose run -it mdpcalib` to +start the container. Docker Compose automatically merges the override file. + - Connect to a running container: `docker compose exec -it mdpcalib bash` diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d3a9db..821ae26 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,20 +1,16 @@ services: mdpcalib: - build: - context: . + image: ${MDPCALIB_IMAGE:-ghcr.io/lcas/mdpcalib:latest} volumes: - # mount this code - - ./src:/root/catkin_ws/src/mdpcalib - - # your data volume goes here - + # your data volume (set DATA_PATH in your .env file) + - ${DATA_PATH:-./data}:/data # required x-forwarding - /tmp/.X11-unix:/tmp/.X11-unix environment: - - DISPLAY - - XAUTHORITY + - DISPLAY=${DISPLAY:-:0} + - XAUTHORITY=${XAUTHORITY:-} - NVIDIA_DRIVER_CAPABILITIES=all deploy: From 6e00795cba57970b6a69a18e46106150209fcb91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 14:35:40 +0000 Subject: [PATCH 03/12] Add VNC service, build context, fix AMENT_TRACE_SETUP_FILES, docker compose up support Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/63bb3bc5-6323-420a-8663-62a747fb3e71 Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- .env.example | 20 ++++---- README.md | 50 ++++++++----------- docker-compose.yaml | 45 ++++++++++++++--- .../scripts/start_ros2_calibration.sh | 4 ++ 4 files changed, 74 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index a5ed7b0..7d168bf 100644 --- a/.env.example +++ b/.env.example @@ -6,22 +6,20 @@ # # edit .env as needed # docker compose up -# Docker image to use. -# Use a specific version tag for reproducible deployments, e.g.: -# MDPCALIB_IMAGE=ghcr.io/lcas/mdpcalib:v1.0.0 -MDPCALIB_IMAGE=ghcr.io/lcas/mdpcalib:latest - # Absolute path to your local data directory. # This directory will be mounted to /data inside the container. # It should contain: -# - kitti/ raw KITTI rosbags # - cmrnext/ CMRNext model weights # - calibration/ output directory (created automatically) DATA_PATH=/CHANGE/ME/absolute/path/to/your/data -# X11 display for GUI applications (e.g. RViz). -# On most Linux desktops this is already set in your shell environment. -DISPLAY=:0 +# ROS 2 sensor topics published by the external robot. +# Adjust these to match the topic names on your platform. +ROS2_CAMERA_IMAGE_TOPIC=/camera/image_raw +ROS2_CAMERA_INFO_TOPIC=/camera/camera_info +ROS2_LIDAR_POINTS_TOPIC=/points_raw +ROS2_IMU_TOPIC=/imu/data -# X11 authority file (optional — leave empty if not using xauth). -XAUTHORITY= +# Coordinate frame IDs written into the exported calibration YAML. +LIDAR_FRAME_ID=lidar +CAMERA_FRAME_ID=camera diff --git a/README.md b/README.md index 88e306f..6617f9c 100644 --- a/README.md +++ b/README.md @@ -48,53 +48,47 @@ ghcr.io/lcas/mdpcalib:latest # latest build from main ghcr.io/lcas/mdpcalib: # e.g. ghcr.io/lcas/mdpcalib:1.2.3 ``` +The compose stack includes a VNC service ([`lcas.lincoln.ac.uk/vnc`](https://github.com/LCAS/ros2_pkg_template)) so no local X11 display or `xhost` configuration is required. + ##### Quick start (no source checkout required) -1. Download the compose file: +1. Download the compose file and example environment: ```bash curl -O https://raw.githubusercontent.com/LCAS/MDPCalib/main/docker-compose.yaml - ``` -2. Create a `.env` file from the provided example: - ```bash curl -O https://raw.githubusercontent.com/LCAS/MDPCalib/main/.env.example cp .env.example .env - # Edit .env (not .env.example) and set DATA_PATH to your local data directory - ``` -3. Allow GUI applications (e.g. RViz) to connect to your display: - ```bash - xhost +local:docker ``` -4. Start the container: +2. Edit `.env` and set at least `DATA_PATH` and the ROS 2 topic names for your robot. +3. Start the full stack (VNC + calibration): ```bash - docker compose run -it mdpcalib + docker compose up ``` +The calibration runs automatically on `docker compose up`. Logs are written to +`$DATA_PATH/runtime_logs/`. The final calibration result is written to +`$DATA_PATH/calibration/ros2/extrinsics.yaml`. + The `.env` file controls the following variables (see [`.env.example`](.env.example) for documentation): | Variable | Default | Description | |---|---|---| -| `MDPCALIB_IMAGE` | `ghcr.io/lcas/mdpcalib:latest` | Docker image to use | | `DATA_PATH` | `./data` | Host path mounted to `/data` inside the container | -| `DISPLAY` | `:0` | X11 display for GUI tools | -| `XAUTHORITY` | *(empty)* | X11 authority file (optional) | +| `ROS2_CAMERA_IMAGE_TOPIC` | `/camera/image_raw` | ROS 2 camera image topic | +| `ROS2_CAMERA_INFO_TOPIC` | `/camera/camera_info` | ROS 2 camera info topic | +| `ROS2_LIDAR_POINTS_TOPIC` | `/points_raw` | ROS 2 LiDAR point cloud topic | +| `ROS2_IMU_TOPIC` | `/imu/data` | ROS 2 IMU topic | +| `LIDAR_FRAME_ID` | `lidar` | LiDAR frame ID in the exported YAML | +| `CAMERA_FRAME_ID` | `camera` | Camera frame ID in the exported YAML | ##### Building the image locally (for development) -To build the image from source instead of pulling the pre-built one, create a -`docker-compose.override.yml` alongside `docker-compose.yaml`: - -```yaml -services: - mdpcalib: - build: - context: . - volumes: - # mount the local source tree so changes are reflected immediately - - ./src:/root/catkin_ws/src/mdpcalib -``` +The compose file includes a `build:` context pointing to the repository root, so you +can build the image directly without any additional override file: -Then run `docker compose build` to build, or `docker compose run -it mdpcalib` to -start the container. Docker Compose automatically merges the override file. +```bash +docker compose build +docker compose up +``` - Connect to a running container: `docker compose exec -it mdpcalib bash` diff --git a/docker-compose.yaml b/docker-compose.yaml index 821ae26..69207fe 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,25 +1,58 @@ services: + vnc: + image: lcas.lincoln.ac.uk/vnc + volumes: + - x11:/tmp/.X11-unix + networks: + - mdpcalib_net + stdin_open: true + tty: true + ipc: shareable + mdpcalib: - image: ${MDPCALIB_IMAGE:-ghcr.io/lcas/mdpcalib:latest} + build: + context: . + image: ghcr.io/lcas/mdpcalib:latest volumes: # your data volume (set DATA_PATH in your .env file) - ${DATA_PATH:-./data}:/data - # required x-forwarding - - /tmp/.X11-unix:/tmp/.X11-unix + # X11 socket shared with the VNC service + - x11:/tmp/.X11-unix environment: - - DISPLAY=${DISPLAY:-:0} - - XAUTHORITY=${XAUTHORITY:-} + - DISPLAY=:1.0 - NVIDIA_DRIVER_CAPABILITIES=all + # ROS 2 sensor topics — override in your .env file as needed + - ROS2_CAMERA_IMAGE_TOPIC=${ROS2_CAMERA_IMAGE_TOPIC:-/camera/image_raw} + - ROS2_CAMERA_INFO_TOPIC=${ROS2_CAMERA_INFO_TOPIC:-/camera/camera_info} + - ROS2_LIDAR_POINTS_TOPIC=${ROS2_LIDAR_POINTS_TOPIC:-/points_raw} + - ROS2_IMU_TOPIC=${ROS2_IMU_TOPIC:-/imu/data} + - LIDAR_FRAME_ID=${LIDAR_FRAME_ID:-lidar} + - CAMERA_FRAME_ID=${CAMERA_FRAME_ID:-camera} + deploy: resources: reservations: devices: - capabilities: [ gpu ] - network_mode: host + networks: + - mdpcalib_net + + depends_on: + - vnc + + ipc: "service:vnc" + + command: bash /root/catkin_ws/src/mdpcalib/pose_synchronizer/scripts/start_ros2_calibration.sh tty: true stdin_open: true + +volumes: + x11: + +networks: + mdpcalib_net: diff --git a/src/pose_synchronizer/scripts/start_ros2_calibration.sh b/src/pose_synchronizer/scripts/start_ros2_calibration.sh index 9e284c1..aac311e 100755 --- a/src/pose_synchronizer/scripts/start_ros2_calibration.sh +++ b/src/pose_synchronizer/scripts/start_ros2_calibration.sh @@ -63,7 +63,11 @@ if [[ "${ENABLE_ROS2_BRIDGE:-true}" != "false" ]]; then echo "[mdpcalib] Starting ros1_bridge dynamic bridge..." source /opt/ros/noetic/setup.bash + # ROS 2 setup scripts reference variables without defaults; temporarily + # disable nounset so sourcing them does not abort under set -euo pipefail. + set +u source "/opt/ros/${ROS2_DISTRO}/setup.bash" + set -u ros2 run ros1_bridge dynamic_bridge --bridge-all-topics >"${MDPCALIB_RUNTIME_LOG_DIR}/ros1_bridge.log" 2>&1 & BRIDGE_PID=$! sleep 2 From 708e35608719a74e5e4f417c6f64fb28254c5f25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 15:09:15 +0000 Subject: [PATCH 04/12] Add ros2bag player container, expose VNC port 5801, switch default ROS2 to humble Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/29191734-7895-4fc4-bc8c-4c6ab8c73871 Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- .env.example | 21 ++++++++++- .github/workflows/docker-build-push.yml | 32 +++++++++++++--- README.md | 37 ++++++++++++------- bag-player/Dockerfile | 24 ++++++++++++ bag-player/package.xml | 14 +++++++ bag-player/play_bag.sh | 36 ++++++++++++++++++ docker-compose.yaml | 22 +++++++++++ .../scripts/start_ros2_calibration.sh | 2 +- 8 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 bag-player/Dockerfile create mode 100644 bag-player/package.xml create mode 100644 bag-player/play_bag.sh diff --git a/.env.example b/.env.example index 7d168bf..1ac2f31 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,26 @@ # Absolute path to your local data directory. # This directory will be mounted to /data inside the container. # It should contain: +# - bag/ ros2bag folder to play (see BAG_PATH below) # - cmrnext/ CMRNext model weights # - calibration/ output directory (created automatically) DATA_PATH=/CHANGE/ME/absolute/path/to/your/data -# ROS 2 sensor topics published by the external robot. -# Adjust these to match the topic names on your platform. +# --- Bag player --- + +# Path inside the container to the rosbag2 folder. +# Defaults to /data/bag (i.e. $DATA_PATH/bag on the host). +# BAG_PATH=/data/bag + +# Set to "true" to loop the bag indefinitely. +# BAG_LOOP=false + +# Playback rate multiplier (e.g. 0.5 for half speed). +# BAG_RATE=1.0 + +# --- ROS 2 sensor topics --- +# Adjust to match the topic names recorded in your rosbag. + ROS2_CAMERA_IMAGE_TOPIC=/camera/image_raw ROS2_CAMERA_INFO_TOPIC=/camera/camera_info ROS2_LIDAR_POINTS_TOPIC=/points_raw @@ -23,3 +37,6 @@ ROS2_IMU_TOPIC=/imu/data # Coordinate frame IDs written into the exported calibration YAML. LIDAR_FRAME_ID=lidar CAMERA_FRAME_ID=camera + +# ROS 2 domain ID (must match across bag-player and mdpcalib containers). +# ROS_DOMAIN_ID=0 diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 31d56f3..edf79c1 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Image +name: Build and Push Docker Images on: push: @@ -36,7 +36,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker + - name: Extract metadata (tags, labels) for Docker — mdpcalib id: meta uses: docker/metadata-action@v5 with: @@ -47,12 +47,34 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image + - name: Build and push Docker image — mdpcalib uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=mdpcalib + cache-to: type=gha,mode=max,scope=mdpcalib + + - name: Extract metadata (tags, labels) for Docker — bag-player + id: meta-bag-player + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-bag-player + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image — bag-player + uses: docker/build-push-action@v6 + with: + context: ./bag-player + push: true + tags: ${{ steps.meta-bag-player.outputs.tags }} + labels: ${{ steps.meta-bag-player.outputs.labels }} + cache-from: type=gha,scope=bag-player + cache-to: type=gha,mode=max,scope=bag-player + diff --git a/README.md b/README.md index 6617f9c..f459492 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ ghcr.io/lcas/mdpcalib:latest # latest build from main ghcr.io/lcas/mdpcalib: # e.g. ghcr.io/lcas/mdpcalib:1.2.3 ``` -The compose stack includes a VNC service ([`lcas.lincoln.ac.uk/vnc`](https://github.com/LCAS/ros2_pkg_template)) so no local X11 display or `xhost` configuration is required. +The compose stack includes a VNC service ([`lcas.lincoln.ac.uk/vnc`](https://github.com/LCAS/ros2_pkg_template)) so no local X11 display or `xhost` configuration is required. The VNC viewer is accessible at **http://localhost:5801**. -##### Quick start (no source checkout required) +##### Quick start — calibrate from a ros2bag + +The `bag-player` service plays a ros2bag folder automatically. The calibration runs against the replayed data. 1. Download the compose file and example environment: ```bash @@ -58,32 +60,39 @@ The compose stack includes a VNC service ([`lcas.lincoln.ac.uk/vnc`](https://git curl -O https://raw.githubusercontent.com/LCAS/MDPCalib/main/.env.example cp .env.example .env ``` -2. Edit `.env` and set at least `DATA_PATH` and the ROS 2 topic names for your robot. -3. Start the full stack (VNC + calibration): +2. Edit `.env`: + - Set `DATA_PATH` to the parent of your data directory. + - Place your rosbag2 folder at `$DATA_PATH/bag/` (or override `BAG_PATH`). + - Set the ROS 2 topic names to match what is recorded in your bag. +3. Start the full stack (VNC + bag player + calibration): ```bash docker compose up ``` -The calibration runs automatically on `docker compose up`. Logs are written to +The calibration starts automatically once the bag begins playing. Logs are written to `$DATA_PATH/runtime_logs/`. The final calibration result is written to `$DATA_PATH/calibration/ros2/extrinsics.yaml`. -The `.env` file controls the following variables (see [`.env.example`](.env.example) for documentation): +Open **http://localhost:5801** in a browser to view the RViz / GUI output via VNC. + +The `.env` file controls the following variables (see [`.env.example`](.env.example) for full documentation): | Variable | Default | Description | |---|---|---| -| `DATA_PATH` | `./data` | Host path mounted to `/data` inside the container | -| `ROS2_CAMERA_IMAGE_TOPIC` | `/camera/image_raw` | ROS 2 camera image topic | -| `ROS2_CAMERA_INFO_TOPIC` | `/camera/camera_info` | ROS 2 camera info topic | -| `ROS2_LIDAR_POINTS_TOPIC` | `/points_raw` | ROS 2 LiDAR point cloud topic | -| `ROS2_IMU_TOPIC` | `/imu/data` | ROS 2 IMU topic | +| `DATA_PATH` | `./data` | Host path mounted to `/data` inside all containers | +| `BAG_PATH` | `/data/bag` | Path inside the container to the rosbag2 folder | +| `BAG_LOOP` | `false` | Set to `true` to loop the bag | +| `BAG_RATE` | `1.0` | Playback rate multiplier | +| `ROS2_CAMERA_IMAGE_TOPIC` | `/camera/image_raw` | Camera image topic in the bag | +| `ROS2_CAMERA_INFO_TOPIC` | `/camera/camera_info` | Camera info topic in the bag | +| `ROS2_LIDAR_POINTS_TOPIC` | `/points_raw` | LiDAR point cloud topic in the bag | +| `ROS2_IMU_TOPIC` | `/imu/data` | IMU topic in the bag | | `LIDAR_FRAME_ID` | `lidar` | LiDAR frame ID in the exported YAML | | `CAMERA_FRAME_ID` | `camera` | Camera frame ID in the exported YAML | -##### Building the image locally (for development) +##### Building the images locally (for development) -The compose file includes a `build:` context pointing to the repository root, so you -can build the image directly without any additional override file: +The compose file includes `build:` contexts for both the `mdpcalib` and `bag-player` services, so you can build everything from source: ```bash docker compose build diff --git a/bag-player/Dockerfile b/bag-player/Dockerfile new file mode 100644 index 0000000..56ea5fd --- /dev/null +++ b/bag-player/Dockerfile @@ -0,0 +1,24 @@ +ARG BASE_IMAGE=lcas.lincoln.ac.uk/ros:humble-latest +ARG ROS_DISTRO=humble + +FROM ${BASE_IMAGE} + +ARG ROS_DISTRO +ENV ROS_DISTRO=${ROS_DISTRO} + +USER root + +WORKDIR /workspace + +# Copy the package manifest so rosdep can install all declared dependencies. +COPY package.xml /workspace/src/mdpcalib_bag_player/package.xml + +RUN rosdep update --rosdistro "${ROS_DISTRO}" \ + && apt-get update \ + && rosdep install --from-paths /workspace/src --ignore-src -r -y \ + && rm -rf /var/lib/apt/lists/* + +COPY play_bag.sh /workspace/play_bag.sh +RUN chmod +x /workspace/play_bag.sh + +CMD ["/workspace/play_bag.sh"] diff --git a/bag-player/package.xml b/bag-player/package.xml new file mode 100644 index 0000000..dd2f516 --- /dev/null +++ b/bag-player/package.xml @@ -0,0 +1,14 @@ + + + mdpcalib_bag_player + 0.0.1 + ROS 2 bag player helper for MDPCalib — plays a rosbag2 folder and publishes sensor topics consumed by the mdpcalib calibration stack. + + MDPCalib + GPL-3.0 + + + ros2bag + rosbag2_storage_default_plugins + rosbag2_transport + diff --git a/bag-player/play_bag.sh b/bag-player/play_bag.sh new file mode 100644 index 0000000..5684e47 --- /dev/null +++ b/bag-player/play_bag.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Entry-point for the bag-player container. +# Plays a ros2bag folder and exits when playback finishes (or loops if BAG_LOOP=true). +# +# Environment variables: +# BAG_PATH — absolute path inside the container to the rosbag2 folder (default: /data/bag) +# BAG_LOOP — set to "true" to loop the bag indefinitely (default: false) +# BAG_RATE — playback rate multiplier, e.g. 0.5 for half speed (default: 1.0) + +set -euo pipefail + +# ROS 2 setup scripts may reference unset variables; disable nounset around them. +set +u +# shellcheck disable=SC1091 +source /opt/ros/humble/setup.bash +set -u + +BAG_PATH="${BAG_PATH:-/data/bag}" +BAG_LOOP="${BAG_LOOP:-false}" +BAG_RATE="${BAG_RATE:-1.0}" + +if [[ ! -d "${BAG_PATH}" ]]; then + echo "[bag-player] ERROR: BAG_PATH '${BAG_PATH}' does not exist or is not a directory." >&2 + exit 1 +fi + +EXTRA_ARGS=() +if [[ "${BAG_LOOP}" == "true" ]]; then + EXTRA_ARGS+=(--loop) +fi + +echo "[bag-player] Playing ros2bag '${BAG_PATH}' at rate ${BAG_RATE} (loop=${BAG_LOOP}) ..." +exec ros2 bag play "${BAG_PATH}" \ + --clock \ + --rate "${BAG_RATE}" \ + "${EXTRA_ARGS[@]}" diff --git a/docker-compose.yaml b/docker-compose.yaml index 69207fe..fadcd3c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,10 +5,30 @@ services: - x11:/tmp/.X11-unix networks: - mdpcalib_net + ports: + - "5801:5801" stdin_open: true tty: true ipc: shareable + bag-player: + build: + context: ./bag-player + image: ghcr.io/lcas/mdpcalib-bag-player:latest + volumes: + # your data volume (set DATA_PATH in your .env file); the rosbag2 folder + # is expected at $DATA_PATH/bag (override with BAG_PATH). + - ${DATA_PATH:-./data}:/data + environment: + - BAG_PATH=${BAG_PATH:-/data/bag} + - BAG_LOOP=${BAG_LOOP:-false} + - BAG_RATE=${BAG_RATE:-1.0} + - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} + networks: + - mdpcalib_net + depends_on: + - vnc + mdpcalib: build: context: . @@ -31,6 +51,7 @@ services: - ROS2_IMU_TOPIC=${ROS2_IMU_TOPIC:-/imu/data} - LIDAR_FRAME_ID=${LIDAR_FRAME_ID:-lidar} - CAMERA_FRAME_ID=${CAMERA_FRAME_ID:-camera} + - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} deploy: resources: @@ -43,6 +64,7 @@ services: depends_on: - vnc + - bag-player ipc: "service:vnc" diff --git a/src/pose_synchronizer/scripts/start_ros2_calibration.sh b/src/pose_synchronizer/scripts/start_ros2_calibration.sh index aac311e..bb95ec6 100755 --- a/src/pose_synchronizer/scripts/start_ros2_calibration.sh +++ b/src/pose_synchronizer/scripts/start_ros2_calibration.sh @@ -5,7 +5,7 @@ set -euo pipefail export ROS_MASTER_URI="${ROS_MASTER_URI:-http://127.0.0.1:11311}" export ROS_LOCALHOST_ONLY="${ROS_LOCALHOST_ONLY:-0}" export ROS_DOMAIN_ID="${ROS_DOMAIN_ID:-0}" -export ROS2_DISTRO="${ROS2_DISTRO:-foxy}" +export ROS2_DISTRO="${ROS2_DISTRO:-humble}" export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_fastrtps_cpp}" export MDPCALIB_RUNTIME_LOG_DIR="${MDPCALIB_RUNTIME_LOG_DIR:-/data/runtime_logs}" export MDPCALIB_CLEAN_PREVIOUS_RUNS="${MDPCALIB_CLEAN_PREVIOUS_RUNS:-true}" From 4ec41703035c500a4291bbe05ae413d8944bf28b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 15:23:23 +0000 Subject: [PATCH 05/12] Remove bag-player from CI, separate rosbag/calibration volumes, loop bags by default Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/2b19b082-2edb-4b67-97d0-d4930ea3a19a Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- .env.example | 26 +++++++++++++-------- .github/workflows/docker-build-push.yml | 31 ++++--------------------- README.md | 24 ++++++++++++++----- bag-player/play_bag.sh | 10 ++++---- docker-compose.yaml | 15 +++++++----- 5 files changed, 53 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index 1ac2f31..d5449f2 100644 --- a/.env.example +++ b/.env.example @@ -6,22 +6,28 @@ # # edit .env as needed # docker compose up -# Absolute path to your local data directory. -# This directory will be mounted to /data inside the container. +# --- Calibration data --- +# Absolute path to your local calibration data directory. +# This directory will be mounted to /data inside the mdpcalib container. # It should contain: -# - bag/ ros2bag folder to play (see BAG_PATH below) -# - cmrnext/ CMRNext model weights +# - cmrnext/ CMRNext model weights (*.tar files) # - calibration/ output directory (created automatically) -DATA_PATH=/CHANGE/ME/absolute/path/to/your/data +DATA_PATH=/CHANGE/ME/absolute/path/to/calibration/data # --- Bag player --- -# Path inside the container to the rosbag2 folder. -# Defaults to /data/bag (i.e. $DATA_PATH/bag on the host). -# BAG_PATH=/data/bag +# Host directory containing your rosbag2 folders. +# Defaults to $HOME/rosbags; override to point to a different location. +# The entire directory is mounted to /rosbags inside the bag-player container. +# ROSBAG_PATH=${HOME}/rosbags -# Set to "true" to loop the bag indefinitely. -# BAG_LOOP=false +# Subfolder or specific bag inside /rosbags to play. +# Defaults to /rosbags (plays the top-level folder). +# Set this if your bags are in a subdirectory, e.g. /rosbags/my_recording. +# BAG_PATH=/rosbags + +# Set to "false" to play once and exit (default is to loop indefinitely). +# BAG_LOOP=true # Playback rate multiplier (e.g. 0.5 for half speed). # BAG_RATE=1.0 diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index edf79c1..53c16db 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Images +name: Build and Push Docker Image on: push: @@ -36,7 +36,7 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker — mdpcalib + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: @@ -47,34 +47,13 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image — mdpcalib + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=mdpcalib - cache-to: type=gha,mode=max,scope=mdpcalib - - - name: Extract metadata (tags, labels) for Docker — bag-player - id: meta-bag-player - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-bag-player - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image — bag-player - uses: docker/build-push-action@v6 - with: - context: ./bag-player - push: true - tags: ${{ steps.meta-bag-player.outputs.tags }} - labels: ${{ steps.meta-bag-player.outputs.labels }} - cache-from: type=gha,scope=bag-player - cache-to: type=gha,mode=max,scope=bag-player + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index f459492..f901c97 100644 --- a/README.md +++ b/README.md @@ -50,9 +50,20 @@ ghcr.io/lcas/mdpcalib: # e.g. ghcr.io/lcas/mdpcalib:1.2.3 The compose stack includes a VNC service ([`lcas.lincoln.ac.uk/vnc`](https://github.com/LCAS/ros2_pkg_template)) so no local X11 display or `xhost` configuration is required. The VNC viewer is accessible at **http://localhost:5801**. +##### Data prerequisites + +Before running, ensure you have: +- **CMRNext model weights** — download the `.tar` files and place them in `$DATA_PATH/cmrnext/`: + - `cmrnext-calib-LEnc-iter1.tar` + - `cmrnext-calib-LEnc-iter5.tar` + - `cmrnext-calib-LEnc-iter6.tar` +- **ros2bag** — place your rosbag2 folder inside `$HOME/rosbags/` (or set `ROSBAG_PATH` in `.env`) + +Rosbag data and calibration data are kept in separate directories and volumes. + ##### Quick start — calibrate from a ros2bag -The `bag-player` service plays a ros2bag folder automatically. The calibration runs against the replayed data. +The `bag-player` service plays a ros2bag folder automatically. The bag loops by default so the calibration stack has enough time to complete. 1. Download the compose file and example environment: ```bash @@ -61,8 +72,8 @@ The `bag-player` service plays a ros2bag folder automatically. The calibration r cp .env.example .env ``` 2. Edit `.env`: - - Set `DATA_PATH` to the parent of your data directory. - - Place your rosbag2 folder at `$DATA_PATH/bag/` (or override `BAG_PATH`). + - Set `DATA_PATH` to the directory containing your CMRNext model weights and calibration output. + - Place your rosbag2 folder(s) in `$HOME/rosbags/` (or override `ROSBAG_PATH`). - Set the ROS 2 topic names to match what is recorded in your bag. 3. Start the full stack (VNC + bag player + calibration): ```bash @@ -79,9 +90,10 @@ The `.env` file controls the following variables (see [`.env.example`](.env.exam | Variable | Default | Description | |---|---|---| -| `DATA_PATH` | `./data` | Host path mounted to `/data` inside all containers | -| `BAG_PATH` | `/data/bag` | Path inside the container to the rosbag2 folder | -| `BAG_LOOP` | `false` | Set to `true` to loop the bag | +| `DATA_PATH` | `./data` | Host path for calibration data (model weights + output) | +| `ROSBAG_PATH` | `$HOME/rosbags` | Host directory mounted as `/rosbags` in the bag-player container | +| `BAG_PATH` | `/rosbags` | Path inside the container to the rosbag2 folder to play | +| `BAG_LOOP` | `true` | Set to `false` to play once and exit | | `BAG_RATE` | `1.0` | Playback rate multiplier | | `ROS2_CAMERA_IMAGE_TOPIC` | `/camera/image_raw` | Camera image topic in the bag | | `ROS2_CAMERA_INFO_TOPIC` | `/camera/camera_info` | Camera info topic in the bag | diff --git a/bag-player/play_bag.sh b/bag-player/play_bag.sh index 5684e47..7e606ae 100644 --- a/bag-player/play_bag.sh +++ b/bag-player/play_bag.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Entry-point for the bag-player container. -# Plays a ros2bag folder and exits when playback finishes (or loops if BAG_LOOP=true). +# Plays a ros2bag folder and loops until stopped (or until BAG_LOOP=false). # # Environment variables: -# BAG_PATH — absolute path inside the container to the rosbag2 folder (default: /data/bag) -# BAG_LOOP — set to "true" to loop the bag indefinitely (default: false) +# BAG_PATH — absolute path inside the container to the rosbag2 folder (default: /rosbags) +# BAG_LOOP — set to "false" to play once and exit (default: true) # BAG_RATE — playback rate multiplier, e.g. 0.5 for half speed (default: 1.0) set -euo pipefail @@ -15,8 +15,8 @@ set +u source /opt/ros/humble/setup.bash set -u -BAG_PATH="${BAG_PATH:-/data/bag}" -BAG_LOOP="${BAG_LOOP:-false}" +BAG_PATH="${BAG_PATH:-/rosbags}" +BAG_LOOP="${BAG_LOOP:-true}" BAG_RATE="${BAG_RATE:-1.0}" if [[ ! -d "${BAG_PATH}" ]]; then diff --git a/docker-compose.yaml b/docker-compose.yaml index fadcd3c..9513412 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,12 +16,14 @@ services: context: ./bag-player image: ghcr.io/lcas/mdpcalib-bag-player:latest volumes: - # your data volume (set DATA_PATH in your .env file); the rosbag2 folder - # is expected at $DATA_PATH/bag (override with BAG_PATH). - - ${DATA_PATH:-./data}:/data + # Host directory containing your rosbag2 folders. + # Defaults to $HOME/rosbags on the host; override with ROSBAG_PATH in .env. + - ${ROSBAG_PATH:-${HOME}/rosbags}:/rosbags environment: - - BAG_PATH=${BAG_PATH:-/data/bag} - - BAG_LOOP=${BAG_LOOP:-false} + # Path inside the container to the rosbag2 folder to play. + - BAG_PATH=${BAG_PATH:-/rosbags} + # Loop the bag by default so calibration has time to complete. + - BAG_LOOP=${BAG_LOOP:-true} - BAG_RATE=${BAG_RATE:-1.0} - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} networks: @@ -34,7 +36,8 @@ services: context: . image: ghcr.io/lcas/mdpcalib:latest volumes: - # your data volume (set DATA_PATH in your .env file) + # Calibration data: model weights (cmrnext/) and output (calibration/). + # This is separate from the rosbag data. - ${DATA_PATH:-./data}:/data # X11 socket shared with the VNC service From 5f48689f9b41edd7295e3b31044f3d91d81a98da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 16:20:46 +0000 Subject: [PATCH 06/12] Unambiguous path naming (CALIBRATION_DATA_PATH), auto-download CMRNext weights Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/2c60bd6e-018b-4246-af99-98bf732cc9f3 Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- .env.example | 14 ++++-- README.md | 33 ++++++++----- docker-compose.yaml | 4 +- .../scripts/start_ros2_calibration.sh | 47 +++++++++++++++++++ 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index d5449f2..6b4fa18 100644 --- a/.env.example +++ b/.env.example @@ -9,10 +9,18 @@ # --- Calibration data --- # Absolute path to your local calibration data directory. # This directory will be mounted to /data inside the mdpcalib container. -# It should contain: -# - cmrnext/ CMRNext model weights (*.tar files) +# It will contain: +# - cmrnext/ CMRNext model weights (*.tar files) — downloaded automatically if absent # - calibration/ output directory (created automatically) -DATA_PATH=/CHANGE/ME/absolute/path/to/calibration/data +# - runtime_logs/ log files +# +# NOTE: Keep this directory completely separate from your rosbag storage (ROSBAG_PATH). +CALIBRATION_DATA_PATH=/CHANGE/ME/absolute/path/to/calibration/data + +# Directory inside the container where CMRNext model weights are stored. +# Override only if you want to store weights elsewhere (e.g. a shared read-only volume). +# Defaults to /data/cmrnext — all three weights must reside in the same directory. +# CMRNEXT_WEIGHTS_DIR=/data/cmrnext # --- Bag player --- diff --git a/README.md b/README.md index f901c97..a3aec85 100644 --- a/README.md +++ b/README.md @@ -52,18 +52,21 @@ The compose stack includes a VNC service ([`lcas.lincoln.ac.uk/vnc`](https://git ##### Data prerequisites -Before running, ensure you have: -- **CMRNext model weights** — download the `.tar` files and place them in `$DATA_PATH/cmrnext/`: - - `cmrnext-calib-LEnc-iter1.tar` - - `cmrnext-calib-LEnc-iter5.tar` - - `cmrnext-calib-LEnc-iter6.tar` -- **ros2bag** — place your rosbag2 folder inside `$HOME/rosbags/` (or set `ROSBAG_PATH` in `.env`) +Two separate host directories are required — keep them distinct to avoid conflicts: -Rosbag data and calibration data are kept in separate directories and volumes. +| Directory | Configured by | Contents | +|---|---|---| +| Calibration data | `CALIBRATION_DATA_PATH` | CMRNext model weights (auto-downloaded if absent), calibration output | +| ROS bags | `ROSBAG_PATH` | Your ros2bag folder(s) | + +- **CMRNext model weights** are downloaded automatically on first run from + `https://calibration.cs.uni-freiburg.de/downloads/cmrnext_weights.zip` + and stored in `$CALIBRATION_DATA_PATH/cmrnext/`. No manual download needed. +- **ros2bag** — place your rosbag2 folder inside `$HOME/rosbags/` (or set `ROSBAG_PATH` in `.env`) ##### Quick start — calibrate from a ros2bag -The `bag-player` service plays a ros2bag folder automatically. The bag loops by default so the calibration stack has enough time to complete. +The `bag-player` service plays a ros2bag folder automatically. The bag loops by default so the calibration stack has enough time to complete. CMRNext model weights are downloaded automatically on first startup. 1. Download the compose file and example environment: ```bash @@ -72,7 +75,7 @@ The `bag-player` service plays a ros2bag folder automatically. The bag loops by cp .env.example .env ``` 2. Edit `.env`: - - Set `DATA_PATH` to the directory containing your CMRNext model weights and calibration output. + - Set `CALIBRATION_DATA_PATH` to an empty directory for calibration output (weights are downloaded there automatically). - Place your rosbag2 folder(s) in `$HOME/rosbags/` (or override `ROSBAG_PATH`). - Set the ROS 2 topic names to match what is recorded in your bag. 3. Start the full stack (VNC + bag player + calibration): @@ -81,8 +84,8 @@ The `bag-player` service plays a ros2bag folder automatically. The bag loops by ``` The calibration starts automatically once the bag begins playing. Logs are written to -`$DATA_PATH/runtime_logs/`. The final calibration result is written to -`$DATA_PATH/calibration/ros2/extrinsics.yaml`. +`$CALIBRATION_DATA_PATH/runtime_logs/`. The final calibration result is written to +`$CALIBRATION_DATA_PATH/calibration/ros2/extrinsics.yaml`. Open **http://localhost:5801** in a browser to view the RViz / GUI output via VNC. @@ -90,7 +93,7 @@ The `.env` file controls the following variables (see [`.env.example`](.env.exam | Variable | Default | Description | |---|---|---| -| `DATA_PATH` | `./data` | Host path for calibration data (model weights + output) | +| `CALIBRATION_DATA_PATH` | `./calibration-data` | Host path for calibration output + model weights (auto-downloaded) | | `ROSBAG_PATH` | `$HOME/rosbags` | Host directory mounted as `/rosbags` in the bag-player container | | `BAG_PATH` | `/rosbags` | Path inside the container to the rosbag2 folder to play | | `BAG_LOOP` | `true` | Set to `false` to play once and exit | @@ -157,7 +160,11 @@ In the public release of our MDPCalib, we provide instructions for running camer #### Downloading model weights 🏋️ -Please download the model weights of CMRNext from this link and store them under: `/data/cmrnext`. +When using the Docker compose stack, model weights are **downloaded automatically** on first run +from `https://calibration.cs.uni-freiburg.de/downloads/cmrnext_weights.zip` and stored in +`$CALIBRATION_DATA_PATH/cmrnext/`. + +For manual / non-Docker runs, download the weights and store them under `/data/cmrnext`: - Model weights: https://calibration.cs.uni-freiburg.de/downloads/cmrnext_weights.zip ### 🏃 Running the calibration diff --git a/docker-compose.yaml b/docker-compose.yaml index 9513412..f4bfa29 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,8 +37,8 @@ services: image: ghcr.io/lcas/mdpcalib:latest volumes: # Calibration data: model weights (cmrnext/) and output (calibration/). - # This is separate from the rosbag data. - - ${DATA_PATH:-./data}:/data + # Intentionally separate from the rosbag data (ROSBAG_PATH → /rosbags). + - ${CALIBRATION_DATA_PATH:-./calibration-data}:/data # X11 socket shared with the VNC service - x11:/tmp/.X11-unix diff --git a/src/pose_synchronizer/scripts/start_ros2_calibration.sh b/src/pose_synchronizer/scripts/start_ros2_calibration.sh index bb95ec6..2fc418b 100755 --- a/src/pose_synchronizer/scripts/start_ros2_calibration.sh +++ b/src/pose_synchronizer/scripts/start_ros2_calibration.sh @@ -74,6 +74,53 @@ if [[ "${ENABLE_ROS2_BRIDGE:-true}" != "false" ]]; then fi echo "[mdpcalib] Launching live ROS 2 calibration stack..." + +# --------------------------------------------------------------------------- +# CMRNext model weights — download automatically if not already present. +# All three weights must share the same directory (CMRNEXT_WEIGHTS_DIR). +# --------------------------------------------------------------------------- +CMRNEXT_WEIGHTS_DIR="${CMRNEXT_WEIGHTS_DIR:-/data/cmrnext}" +CMRNEXT_WEIGHTS_URL="${CMRNEXT_WEIGHTS_URL:-https://calibration.cs.uni-freiburg.de/downloads/cmrnext_weights.zip}" + +_weight1="${CMRNEXT_WEIGHT_1:-${CMRNEXT_WEIGHTS_DIR}/cmrnext-calib-LEnc-iter1.tar}" +_weight2="${CMRNEXT_WEIGHT_2:-${CMRNEXT_WEIGHTS_DIR}/cmrnext-calib-LEnc-iter5.tar}" +_weight3="${CMRNEXT_WEIGHT_3:-${CMRNEXT_WEIGHTS_DIR}/cmrnext-calib-LEnc-iter6.tar}" + +if [[ ! -f "${_weight1}" ]] || [[ ! -f "${_weight2}" ]] || [[ ! -f "${_weight3}" ]]; then + echo "[mdpcalib] CMRNext model weights not found in '${CMRNEXT_WEIGHTS_DIR}'." + echo "[mdpcalib] Downloading from ${CMRNEXT_WEIGHTS_URL} ..." + mkdir -p "${CMRNEXT_WEIGHTS_DIR}" + _tmp_zip="$(mktemp /tmp/cmrnext_weights_XXXXXX.zip)" + if curl -fSL --retry 3 --retry-delay 5 -o "${_tmp_zip}" "${CMRNEXT_WEIGHTS_URL}"; then + if ! unzip -o -j -d "${CMRNEXT_WEIGHTS_DIR}" "${_tmp_zip}" '*.tar'; then + rm -f "${_tmp_zip}" + echo "[mdpcalib] ERROR: Failed to unzip model weights archive from ${CMRNEXT_WEIGHTS_URL}." >&2 + exit 1 + fi + rm -f "${_tmp_zip}" + # Verify the expected files were actually extracted + _missing=() + [[ ! -f "${_weight1}" ]] && _missing+=("${_weight1}") + [[ ! -f "${_weight2}" ]] && _missing+=("${_weight2}") + [[ ! -f "${_weight3}" ]] && _missing+=("${_weight3}") + if [[ ${#_missing[@]} -gt 0 ]]; then + echo "[mdpcalib] ERROR: Download succeeded but the following weight files are missing after extraction:" >&2 + printf '[mdpcalib] %s\n' "${_missing[@]}" >&2 + echo "[mdpcalib] Expected files: cmrnext-calib-LEnc-iter1.tar, cmrnext-calib-LEnc-iter5.tar, cmrnext-calib-LEnc-iter6.tar" >&2 + echo "[mdpcalib] The archive may not contain the expected *.tar files." >&2 + exit 1 + fi + echo "[mdpcalib] CMRNext model weights downloaded to '${CMRNEXT_WEIGHTS_DIR}'." + else + rm -f "${_tmp_zip}" + echo "[mdpcalib] ERROR: Failed to download model weights from ${CMRNEXT_WEIGHTS_URL}." >&2 + echo "[mdpcalib] Please download manually and place *.tar files in '${CMRNEXT_WEIGHTS_DIR}'." >&2 + echo "[mdpcalib] Download URL: ${CMRNEXT_WEIGHTS_URL}" >&2 + exit 1 + fi +fi +# --------------------------------------------------------------------------- + source /opt/ros/noetic/setup.bash source /root/catkin_ws/devel/setup.bash roslaunch pose_synchronizer ros2_live_calibration.launch \ From 63ef951f5e52665e1135792594e03c85e14b3539 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 13:41:20 +0000 Subject: [PATCH 07/12] feat: add KITTI playback mode to bag-player container - Add kitti_to_ros2bag.py: converts KITTI raw_synced (camera, velodyne, IMU) to ros2bag using rosbag2_py + rclpy; uses heapq.merge generators for memory-efficient O(1)-per-stream processing; calendar.timegm for UTC-safe timestamp conversion - Add play_kitti.sh: downloads KITTI raw/calib/odometry velodyne (~12 GB), caches in named Docker volume, converts once, plays with ros2 bag play - Modify play_bag.sh: add PLAYBACK_MODE dispatch (ros2bag default / kitti) - Dockerfile: add pip pykitti/progressbar2; rosdep rosbag2_py/sensor_msgs - docker-compose.yaml: add kitti_data named volume + KITTI env vars - .env.example: document all KITTI mode variables - README.md: add KITTI Docker quick-start section with variable table Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/d8fe0d72-7ff6-4bfb-a045-0925a76e6b27 Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- .env.example | 24 ++- README.md | 39 +++- bag-player/Dockerfile | 7 +- bag-player/kitti_to_ros2bag.py | 362 +++++++++++++++++++++++++++++++++ bag-player/package.xml | 11 + bag-player/play_bag.sh | 14 +- bag-player/play_kitti.sh | 145 +++++++++++++ docker-compose.yaml | 29 ++- 8 files changed, 618 insertions(+), 13 deletions(-) create mode 100644 bag-player/kitti_to_ros2bag.py create mode 100644 bag-player/play_kitti.sh diff --git a/.env.example b/.env.example index 6b4fa18..690c129 100644 --- a/.env.example +++ b/.env.example @@ -24,12 +24,18 @@ CALIBRATION_DATA_PATH=/CHANGE/ME/absolute/path/to/calibration/data # --- Bag player --- -# Host directory containing your rosbag2 folders. +# Playback mode: 'ros2bag' (default) or 'kitti' +# ros2bag — play an existing ros2bag folder from ROSBAG_PATH / BAG_PATH +# kitti — download KITTI raw_synced data automatically, convert to ros2bag, +# and play it (data is cached in the 'kitti_data' named Docker volume) +# PLAYBACK_MODE=ros2bag + +# Host directory containing your rosbag2 folders (used when PLAYBACK_MODE=ros2bag). # Defaults to $HOME/rosbags; override to point to a different location. # The entire directory is mounted to /rosbags inside the bag-player container. # ROSBAG_PATH=${HOME}/rosbags -# Subfolder or specific bag inside /rosbags to play. +# Subfolder or specific bag inside /rosbags to play (PLAYBACK_MODE=ros2bag only). # Defaults to /rosbags (plays the top-level folder). # Set this if your bags are in a subdirectory, e.g. /rosbags/my_recording. # BAG_PATH=/rosbags @@ -40,6 +46,20 @@ CALIBRATION_DATA_PATH=/CHANGE/ME/absolute/path/to/calibration/data # Playback rate multiplier (e.g. 0.5 for half speed). # BAG_RATE=1.0 +# --- KITTI mode variables (only used when PLAYBACK_MODE=kitti) --- +# KITTI raw_synced recording to download and play. +# Default: sequence 00 (residential, 2011_10_03, drive 0027) — ~9 GB download. +# Cached permanently in the 'kitti_data' named Docker volume. +# KITTI_DATE=2011_10_03 +# KITTI_DRIVE=0027 +# Which colour camera to use for calibration: 'left' (default) or 'right' +# KITTI_CAMERA=left +# Odometry sequence whose motion-compensated velodyne scans replace the raw ones. +# KITTI_SEQUENCE=00 +# +# For KITTI mode, also set FAST-LO to the Velodyne HDL-64E configuration: +# FAST_LO_CONFIG_FILE=/root/catkin_ws/src/mdpcalib/FAST_LO/config/velodyne.yaml + # --- ROS 2 sensor topics --- # Adjust to match the topic names recorded in your rosbag. diff --git a/README.md b/README.md index a3aec85..f617473 100644 --- a/README.md +++ b/README.md @@ -89,19 +89,46 @@ The calibration starts automatically once the bag begins playing. Logs are writt Open **http://localhost:5801** in a browser to view the RViz / GUI output via VNC. +##### Quick start — calibrate from KITTI + +The `bag-player` service also supports a built-in KITTI mode that downloads the KITTI raw_synced dataset automatically, converts it to a ROS 2 bag, and plays it. All data is stored in the `kitti_data` named Docker volume — no host bind mount or manual download is required. + +> **⚠ Note:** The initial download is ~9 GB (raw sync + calibration + odometry velodyne). After the first run the data is cached in the Docker volume; subsequent `docker compose up` invocations skip both the download and the conversion step. + +1. Copy and edit `.env` as above, then add / uncomment: + ```ini + PLAYBACK_MODE=kitti + # Use the Velodyne HDL-64E FAST-LO config for KITTI: + FAST_LO_CONFIG_FILE=/root/catkin_ws/src/mdpcalib/FAST_LO/config/velodyne.yaml + ``` +2. Start the stack: + ```bash + docker compose up + ``` + The bag-player will download the KITTI data into the `kitti_data` volume, convert it to a + ROS 2 bag, and start playback. The calibration stack starts automatically in `mdpcalib`. + +To change the KITTI sequence, override `KITTI_DATE`, `KITTI_DRIVE`, and `KITTI_SEQUENCE` in `.env` +(see `.env.example` for details and the [KITTI mapping table](https://github.com/tomas789/kitti2bag/issues/10#issuecomment-352962278)). + The `.env` file controls the following variables (see [`.env.example`](.env.example) for full documentation): | Variable | Default | Description | |---|---|---| | `CALIBRATION_DATA_PATH` | `./calibration-data` | Host path for calibration output + model weights (auto-downloaded) | -| `ROSBAG_PATH` | `$HOME/rosbags` | Host directory mounted as `/rosbags` in the bag-player container | -| `BAG_PATH` | `/rosbags` | Path inside the container to the rosbag2 folder to play | +| `PLAYBACK_MODE` | `ros2bag` | `ros2bag` — play from file; `kitti` — download and play KITTI | +| `ROSBAG_PATH` | `$HOME/rosbags` | Host directory mounted as `/rosbags` in the bag-player (ros2bag mode) | +| `BAG_PATH` | `/rosbags` | Path inside container to the rosbag2 folder (ros2bag mode) | | `BAG_LOOP` | `true` | Set to `false` to play once and exit | | `BAG_RATE` | `1.0` | Playback rate multiplier | -| `ROS2_CAMERA_IMAGE_TOPIC` | `/camera/image_raw` | Camera image topic in the bag | -| `ROS2_CAMERA_INFO_TOPIC` | `/camera/camera_info` | Camera info topic in the bag | -| `ROS2_LIDAR_POINTS_TOPIC` | `/points_raw` | LiDAR point cloud topic in the bag | -| `ROS2_IMU_TOPIC` | `/imu/data` | IMU topic in the bag | +| `KITTI_DATE` | `2011_10_03` | KITTI recording date (kitti mode) | +| `KITTI_DRIVE` | `0027` | KITTI drive number, zero-padded (kitti mode) | +| `KITTI_CAMERA` | `left` | Colour camera: `left` or `right` (kitti mode) | +| `KITTI_SEQUENCE` | `00` | Odometry sequence for motion-compensated velodyne (kitti mode) | +| `ROS2_CAMERA_IMAGE_TOPIC` | `/camera/image_raw` | Camera image topic | +| `ROS2_CAMERA_INFO_TOPIC` | `/camera/camera_info` | Camera info topic | +| `ROS2_LIDAR_POINTS_TOPIC` | `/points_raw` | LiDAR point cloud topic | +| `ROS2_IMU_TOPIC` | `/imu/data` | IMU topic | | `LIDAR_FRAME_ID` | `lidar` | LiDAR frame ID in the exported YAML | | `CAMERA_FRAME_ID` | `camera` | Camera frame ID in the exported YAML | diff --git a/bag-player/Dockerfile b/bag-player/Dockerfile index 56ea5fd..aed4eb5 100644 --- a/bag-player/Dockerfile +++ b/bag-player/Dockerfile @@ -18,7 +18,10 @@ RUN rosdep update --rosdistro "${ROS_DISTRO}" \ && rosdep install --from-paths /workspace/src --ignore-src -r -y \ && rm -rf /var/lib/apt/lists/* -COPY play_bag.sh /workspace/play_bag.sh -RUN chmod +x /workspace/play_bag.sh +# Python packages required for KITTI → ros2bag conversion (no rosdep keys available) +RUN pip3 install --no-cache-dir pykitti progressbar2 + +COPY play_bag.sh play_kitti.sh kitti_to_ros2bag.py /workspace/ +RUN chmod +x /workspace/play_bag.sh /workspace/play_kitti.sh CMD ["/workspace/play_bag.sh"] diff --git a/bag-player/kitti_to_ros2bag.py b/bag-player/kitti_to_ros2bag.py new file mode 100644 index 0000000..32e9d91 --- /dev/null +++ b/bag-player/kitti_to_ros2bag.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +"""Convert KITTI raw_synced data to a ROS 2 bag (sqlite3 storage). + +All four sensor streams (color camera, velodyne, IMU) are written in +timestamp order so the resulting bag behaves identically to a ros2bag +created with ros2 bag record. + +Events are merged on-the-fly using heapq.merge so only one message +per stream is held in memory at a time, avoiding multi-GB buffers for +large sequences. + +Usage (called automatically by play_kitti.sh): + kitti_to_ros2bag.py \\ + --kitti-dir /kitti --date 2011_10_03 --drive 0027 \\ + --camera left --output-bag /kitti/ros2bag/kitti_seq00 \\ + --image-topic /camera/image_raw \\ + --camera-info-topic /camera/camera_info \\ + --lidar-topic /points_raw \\ + --imu-topic /imu/data + +The published topics match the default mdpcalib Docker compose configuration +so no extra remapping is needed. +""" + +import argparse +import calendar +import heapq +import os +from datetime import datetime +from typing import Generator, Tuple + +import cv2 +import numpy as np +import progressbar +import pykitti +import rosbag2_py +from builtin_interfaces.msg import Time +from rclpy.serialization import serialize_message +from sensor_msgs.msg import CameraInfo, Image, Imu, PointCloud2, PointField + +# KITTI colour camera indices: 2 = camera_color_left, 3 = camera_color_right +_CAMERA_IDX = {"left": 2, "right": 3} + +# Type alias for a bag event tuple +_Event = Tuple[int, str, bytes] + + +# --------------------------------------------------------------------------- +# Timestamp helpers +# --------------------------------------------------------------------------- + +def _dt_to_ns(dt: datetime) -> int: + """Return nanoseconds since Unix epoch for a naive KITTI UTC datetime. + + Uses calendar.timegm so the result is always interpreted as UTC, + regardless of the host system's local timezone. + """ + # calendar.timegm treats the input tuple as UTC (no local-time adjustment) + seconds = calendar.timegm(dt.timetuple()) + return seconds * 1_000_000_000 + dt.microsecond * 1_000 + + +def _make_stamp(ts_ns: int) -> Time: + t = Time() + t.sec = ts_ns // 1_000_000_000 + t.nanosec = ts_ns % 1_000_000_000 + return t + + +# --------------------------------------------------------------------------- +# Message builders +# --------------------------------------------------------------------------- + +def _make_image(path: str, frame_id: str, stamp: Time) -> Image: + cv_img = cv2.imread(path) + if cv_img is None: + raise RuntimeError( + f"[kitti2ros2bag] Could not read image: {path}\n" + " Verify the file exists, is a valid PNG/JPEG, and was fully " + "extracted (re-run with a clean /kitti volume if the download was interrupted)." + ) + msg = Image() + msg.header.frame_id = frame_id + msg.header.stamp = stamp + msg.height = cv_img.shape[0] + msg.width = cv_img.shape[1] + msg.encoding = "bgr8" + msg.is_bigendian = 0 + msg.step = cv_img.shape[1] * 3 + msg.data = cv_img.tobytes() + return msg + + +def _make_camera_info(util: dict, camera_pad: str, frame_id: str, stamp: Time) -> CameraInfo: + """Build a CameraInfo message from a parsed calib_cam_to_cam.txt util dict.""" + s = util[f"S_rect_{camera_pad}"] + msg = CameraInfo() + msg.header.frame_id = frame_id + msg.header.stamp = stamp + msg.width = int(s[0]) + msg.height = int(s[1]) + msg.distortion_model = "plumb_bob" + # ROS 2 CameraInfo uses lower-case field names: k, d, r, p + msg.k = util[f"K_{camera_pad}"].flatten().tolist() + msg.r = util[f"R_rect_{camera_pad}"].flatten().tolist() + msg.d = util[f"D_{camera_pad}"].flatten().tolist() + msg.p = util[f"P_rect_{camera_pad}"].flatten().tolist() + return msg + + +def _make_point_cloud(path: str, frame_id: str, stamp: Time) -> PointCloud2: + """Build a PointCloud2 from a KITTI velodyne .bin file. + + Layout (matches kitti2bag.py): x,y,z,intensity(f32), ring(u16), _pad(u16), time(f32) + → 24 bytes per point with ring at offset 16 and time at offset 20. + """ + raw = np.fromfile(path, dtype=np.float32).reshape(-1, 4) + + # Compute per-point ring channel (Velodyne HDL-64E geometry) + depth = np.linalg.norm(raw[:, :3], axis=1) + depth = np.maximum(depth, 1e-9) + pitch = np.arcsin(raw[:, 2] / depth) + fov_down = -24.8 / 180.0 * np.pi + fov = (24.8 + 2.0) / 180.0 * np.pi + ring = np.clip(np.floor((pitch + abs(fov_down)) / fov * 64.0), 0, 63).astype(np.uint16) + + n = raw.shape[0] + buf = np.zeros(n, dtype=[ + ("x", np.float32), + ("y", np.float32), + ("z", np.float32), + ("intensity", np.float32), + ("ring", np.uint16), + ("_pad", np.uint16), # keeps 'time' at offset 20 + ("time", np.float32), + ]) + buf["x"] = raw[:, 0] + buf["y"] = raw[:, 1] + buf["z"] = raw[:, 2] + buf["intensity"] = raw[:, 3] + buf["ring"] = ring + + fields = [ + PointField(name="x", offset=0, datatype=PointField.FLOAT32, count=1), + PointField(name="y", offset=4, datatype=PointField.FLOAT32, count=1), + PointField(name="z", offset=8, datatype=PointField.FLOAT32, count=1), + PointField(name="intensity", offset=12, datatype=PointField.FLOAT32, count=1), + PointField(name="ring", offset=16, datatype=PointField.UINT16, count=1), + PointField(name="time", offset=20, datatype=PointField.FLOAT32, count=1), + ] + + msg = PointCloud2() + msg.header.frame_id = frame_id + msg.header.stamp = stamp + msg.height = 1 + msg.width = n + msg.fields = fields + msg.is_bigendian = False + msg.point_step = 24 + msg.row_step = 24 * n + msg.data = buf.tobytes() + msg.is_dense = True + return msg + + +def _make_imu(path: str, frame_id: str, stamp: Time) -> Imu: + """Build an Imu message from a KITTI OXTS text file. + + pykitti OxtsPacket field indices (0-based): + 14=af, 15=al, 16=au (forward/left/up linear acceleration) + 20=wf, 21=wl, 22=wu (forward/left/up angular rate) + """ + with open(path, encoding="utf-8") as f: + vals = list(map(float, f.read().split())) + + msg = Imu() + msg.header.frame_id = frame_id + msg.header.stamp = stamp + msg.linear_acceleration.x = vals[14] # af + msg.linear_acceleration.y = vals[15] # al + msg.linear_acceleration.z = vals[16] # au + msg.angular_velocity.x = vals[20] # wf + msg.angular_velocity.y = vals[21] # wl + msg.angular_velocity.z = vals[22] # wu + # Covariance unknown + msg.linear_acceleration_covariance[0] = -1 + msg.angular_velocity_covariance[0] = -1 + msg.orientation_covariance[0] = -1 + return msg + + +# --------------------------------------------------------------------------- +# Timestamp file reader +# --------------------------------------------------------------------------- + +def _read_timestamps(ts_file: str) -> list: + """Read a KITTI timestamps.txt file into a list of datetime objects.""" + dts = [] + with open(ts_file, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + # KITTI timestamps are formatted as 'YYYY-MM-DD HH:MM:SS.ffffff' + # Slice to 26 characters to keep exactly 6 sub-second digits so + # strptime's %f directive (which requires 1-6 digits) always succeeds. + dts.append(datetime.strptime(line[:26], "%Y-%m-%d %H:%M:%S.%f")) + return dts + + +# --------------------------------------------------------------------------- +# Lazy event generators — one message at a time to keep memory bounded +# --------------------------------------------------------------------------- + +def _camera_stream( + img_dts, img_files, image_dir, util, camera_pad, + frame_camera, image_topic, camera_info_topic +) -> Generator[_Event, None, None]: + """Yield (ts_ns, topic, serialised_bytes) for camera images and infos.""" + for dt, fn in zip(img_dts, img_files): + ts_ns = _dt_to_ns(dt) + stamp = _make_stamp(ts_ns) + img_msg = _make_image(os.path.join(image_dir, fn), frame_camera, stamp) + ci_msg = _make_camera_info(util, camera_pad, frame_camera, stamp) + yield ts_ns, image_topic, serialize_message(img_msg) + yield ts_ns, camera_info_topic, serialize_message(ci_msg) + + +def _velo_stream( + velo_dts, velo_files, velo_dir, frame_lidar, lidar_topic +) -> Generator[_Event, None, None]: + """Yield (ts_ns, topic, serialised_bytes) for velodyne scans.""" + for dt, fn in zip(velo_dts, velo_files): + ts_ns = _dt_to_ns(dt) + stamp = _make_stamp(ts_ns) + pcl_msg = _make_point_cloud(os.path.join(velo_dir, fn), frame_lidar, stamp) + yield ts_ns, lidar_topic, serialize_message(pcl_msg) + + +def _imu_stream( + imu_dts, imu_files, imu_dir, frame_imu, imu_topic +) -> Generator[_Event, None, None]: + """Yield (ts_ns, topic, serialised_bytes) for IMU packets.""" + for dt, fn in zip(imu_dts, imu_files): + ts_ns = _dt_to_ns(dt) + stamp = _make_stamp(ts_ns) + imu_msg = _make_imu(os.path.join(imu_dir, fn), frame_imu, stamp) + yield ts_ns, imu_topic, serialize_message(imu_msg) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert KITTI raw_synced data to a ROS 2 bag (sqlite3)" + ) + parser.add_argument("--kitti-dir", required=True, + help="Base KITTI directory (contains the date subfolder)") + parser.add_argument("--date", required=True, + help="KITTI recording date, e.g. 2011_10_03") + parser.add_argument("--drive", required=True, + help="KITTI drive number (zero-padded), e.g. 0027") + parser.add_argument("--camera", choices=["left", "right"], default="left", + help="Which colour camera to include (default: left)") + parser.add_argument("--output-bag", required=True, + help="Destination directory for the ros2bag") + parser.add_argument("--image-topic", default="/camera/image_raw") + parser.add_argument("--camera-info-topic", default="/camera/camera_info") + parser.add_argument("--lidar-topic", default="/points_raw") + parser.add_argument("--imu-topic", default="/imu/data") + args = parser.parse_args() + + camera_idx = _CAMERA_IDX[args.camera] + camera_pad = f"{camera_idx:02}" + frame_camera = "camera" + frame_lidar = "lidar" + frame_imu = "imu_link" + + # Use pykitti only for path resolution and calibration parsing + kitti = pykitti.raw(args.kitti_dir, args.date, args.drive) + util = pykitti.utils.read_calib_file( + os.path.join(kitti.calib_path, "calib_cam_to_cam.txt") + ) + + data_path = kitti.data_path # …//_drive__sync + + image_dir = os.path.join(data_path, f"image_{camera_pad}", "data") + image_ts = os.path.join(data_path, f"image_{camera_pad}", "timestamps.txt") + velo_dir = os.path.join(data_path, "velodyne_points", "data") + velo_ts = os.path.join(data_path, "velodyne_points", "timestamps.txt") + imu_dir = os.path.join(data_path, "oxts", "data") + imu_ts = os.path.join(data_path, "oxts", "timestamps.txt") + + img_files = sorted(os.listdir(image_dir)) + velo_files = sorted(os.listdir(velo_dir)) + imu_files = sorted(os.listdir(imu_dir)) + + img_dts = _read_timestamps(image_ts) + velo_dts = _read_timestamps(velo_ts) + imu_dts = _read_timestamps(imu_ts) + + print(f"[kitti2ros2bag] Camera ({args.camera}) frames : {len(img_files)}") + print(f"[kitti2ros2bag] Velodyne scans : {len(velo_files)}") + print(f"[kitti2ros2bag] IMU packets : {len(imu_files)}") + + # ------------------------------------------------------------------ + # Open the ros2bag writer + # ------------------------------------------------------------------ + os.makedirs(args.output_bag, exist_ok=True) + + writer = rosbag2_py.SequentialWriter() + writer.open( + rosbag2_py.StorageOptions(uri=args.output_bag, storage_id="sqlite3"), + rosbag2_py.ConverterOptions( + input_serialization_format="cdr", + output_serialization_format="cdr", + ), + ) + + def _add_topic(name: str, msg_type: str) -> None: + writer.create_topic(rosbag2_py.TopicMetadata( + name=name, type=msg_type, serialization_format="cdr" + )) + + _add_topic(args.image_topic, "sensor_msgs/msg/Image") + _add_topic(args.camera_info_topic, "sensor_msgs/msg/CameraInfo") + _add_topic(args.lidar_topic, "sensor_msgs/msg/PointCloud2") + _add_topic(args.imu_topic, "sensor_msgs/msg/Imu") + + # ------------------------------------------------------------------ + # Merge all three streams in timestamp order and write on-the-fly. + # Using generators + heapq.merge keeps memory bounded to O(1) per + # stream — only one message per stream is held in memory at a time, + # regardless of sequence length. This avoids the >6 GB peak RAM that + # would result from buffering all serialised camera frames at once. + # ------------------------------------------------------------------ + total_events = len(img_files) * 2 + len(velo_files) + len(imu_files) + print(f"[kitti2ros2bag] Writing {total_events} events to '{args.output_bag}' ...") + + cam_gen = _camera_stream( + img_dts, img_files, image_dir, util, camera_pad, + frame_camera, args.image_topic, args.camera_info_topic, + ) + velo_gen = _velo_stream(velo_dts, velo_files, velo_dir, frame_lidar, args.lidar_topic) + imu_gen = _imu_stream(imu_dts, imu_files, imu_dir, frame_imu, args.imu_topic) + + pbar = progressbar.ProgressBar(max_value=total_events) + for count, (ts_ns, topic, data) in enumerate( + heapq.merge(cam_gen, velo_gen, imu_gen, key=lambda e: e[0]) + ): + writer.write(topic, data, ts_ns) + pbar.update(count) + pbar.finish() + + print(f"[kitti2ros2bag] Done → {args.output_bag}") + + +if __name__ == "__main__": + main() + diff --git a/bag-player/package.xml b/bag-player/package.xml index dd2f516..fb109a7 100644 --- a/bag-player/package.xml +++ b/bag-player/package.xml @@ -11,4 +11,15 @@ ros2bag rosbag2_storage_default_plugins rosbag2_transport + + + rosbag2_py + + + sensor_msgs + std_msgs + builtin_interfaces + + + python3-opencv diff --git a/bag-player/play_bag.sh b/bag-player/play_bag.sh index 7e606ae..45816f2 100644 --- a/bag-player/play_bag.sh +++ b/bag-player/play_bag.sh @@ -1,14 +1,24 @@ #!/usr/bin/env bash # Entry-point for the bag-player container. -# Plays a ros2bag folder and loops until stopped (or until BAG_LOOP=false). # -# Environment variables: +# PLAYBACK_MODE selects the data source: +# ros2bag (default) — play an existing ros2bag folder from BAG_PATH +# kitti — download KITTI raw_synced data, convert to ros2bag, +# and play it (see play_kitti.sh for full variable docs) +# +# ros2bag mode variables: # BAG_PATH — absolute path inside the container to the rosbag2 folder (default: /rosbags) # BAG_LOOP — set to "false" to play once and exit (default: true) # BAG_RATE — playback rate multiplier, e.g. 0.5 for half speed (default: 1.0) set -euo pipefail +PLAYBACK_MODE="${PLAYBACK_MODE:-ros2bag}" + +if [[ "${PLAYBACK_MODE}" == "kitti" ]]; then + exec /workspace/play_kitti.sh +fi + # ROS 2 setup scripts may reference unset variables; disable nounset around them. set +u # shellcheck disable=SC1091 diff --git a/bag-player/play_kitti.sh b/bag-player/play_kitti.sh new file mode 100644 index 0000000..02ae61e --- /dev/null +++ b/bag-player/play_kitti.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# KITTI mode for the bag-player container. +# +# Downloads the KITTI raw_synced dataset into a named Docker volume (/kitti), +# replaces the raw velodyne scans with the motion-compensated odometry scans, +# converts the data to a ROS 2 bag once, and then plays it indefinitely with +# ros2 bag play --clock. +# +# All downloaded archives are cached in /kitti/.download_tmp so subsequent +# container restarts skip the download step. +# +# Environment variables: +# KITTI_DIR — container path for the named kitti volume (default: /kitti) +# KITTI_DATE — recording date (default: 2011_10_03) +# KITTI_DRIVE — drive number (zero-padded 4 digits) (default: 0027) +# KITTI_CAMERA — 'left' or 'right' (default: left) +# KITTI_SEQUENCE — odometry sequence for velodyne download (default: 00) +# BAG_RATE — playback rate multiplier (default: 1.0) +# BAG_LOOP — set to "false" to play once (default: true) +# ROS2_CAMERA_IMAGE_TOPIC (default: /camera/image_raw) +# ROS2_CAMERA_INFO_TOPIC (default: /camera/camera_info) +# ROS2_LIDAR_POINTS_TOPIC (default: /points_raw) +# ROS2_IMU_TOPIC (default: /imu/data) + +set -euo pipefail + +# ROS 2 setup scripts may reference unset variables; disable nounset around them. +set +u +# shellcheck disable=SC1091 +source /opt/ros/humble/setup.bash +set -u + +KITTI_DIR="${KITTI_DIR:-/kitti}" +KITTI_DATE="${KITTI_DATE:-2011_10_03}" +KITTI_DRIVE="${KITTI_DRIVE:-0027}" +KITTI_CAMERA="${KITTI_CAMERA:-left}" +KITTI_SEQUENCE="${KITTI_SEQUENCE:-00}" +BAG_RATE="${BAG_RATE:-1.0}" +BAG_LOOP="${BAG_LOOP:-true}" +ROS2_CAMERA_IMAGE_TOPIC="${ROS2_CAMERA_IMAGE_TOPIC:-/camera/image_raw}" +ROS2_CAMERA_INFO_TOPIC="${ROS2_CAMERA_INFO_TOPIC:-/camera/camera_info}" +ROS2_LIDAR_POINTS_TOPIC="${ROS2_LIDAR_POINTS_TOPIC:-/points_raw}" +ROS2_IMU_TOPIC="${ROS2_IMU_TOPIC:-/imu/data}" + +DRIVE_DIR="${KITTI_DIR}/${KITTI_DATE}/${KITTI_DATE}_drive_${KITTI_DRIVE}_sync" +ROS2BAG_DIR="${KITTI_DIR}/ros2bag/kitti_${KITTI_DATE}_drive_${KITTI_DRIVE}_${KITTI_CAMERA}" +TMP_DIR="${KITTI_DIR}/.download_tmp" + +mkdir -p "${KITTI_DIR}" "${TMP_DIR}" + +# ------------------------------------------------------------------------- +# Helper: download a file only if not already present +# ------------------------------------------------------------------------- +_download() { + local url="$1" dest="$2" + if [[ -f "${dest}" ]]; then + echo "[bag-player/kitti] Cached: $(basename "${dest}")" + return + fi + echo "[bag-player/kitti] Downloading $(basename "${dest}") ..." + curl -fSL --retry 3 --retry-delay 10 -o "${dest}" "${url}" +} + +# ------------------------------------------------------------------------- +# 1. Download KITTI raw synced + calibration data +# ------------------------------------------------------------------------- +if [[ ! -d "${DRIVE_DIR}/image_02" ]] || [[ ! -d "${DRIVE_DIR}/image_03" ]]; then + echo "[bag-player/kitti] KITTI raw data not found — starting download (~4 GB)." + echo "[bag-player/kitti] Data will be cached in the 'kitti_data' Docker volume." + + _sync_url="https://s3.eu-central-1.amazonaws.com/avg-kitti/raw_data/${KITTI_DATE}_drive_${KITTI_DRIVE}/${KITTI_DATE}_drive_${KITTI_DRIVE}_sync.zip" + _sync_zip="${TMP_DIR}/${KITTI_DATE}_drive_${KITTI_DRIVE}_sync.zip" + _download "${_sync_url}" "${_sync_zip}" + + _calib_url="https://s3.eu-central-1.amazonaws.com/avg-kitti/raw_data/${KITTI_DATE}_calib.zip" + _calib_zip="${TMP_DIR}/${KITTI_DATE}_calib.zip" + _download "${_calib_url}" "${_calib_zip}" + + echo "[bag-player/kitti] Extracting raw synced data ..." + unzip -q -o "${_sync_zip}" -d "${KITTI_DIR}" + echo "[bag-player/kitti] Extracting calibration data ..." + unzip -q -o "${_calib_zip}" -d "${KITTI_DIR}" + echo "[bag-player/kitti] Raw KITTI data extracted." +fi + +# ------------------------------------------------------------------------- +# 2. Replace velodyne with motion-compensated odometry velodyne +# ------------------------------------------------------------------------- +_velo_flag="${KITTI_DIR}/.velo_seq${KITTI_SEQUENCE}_replaced" +if [[ ! -f "${_velo_flag}" ]]; then + echo "[bag-player/kitti] Downloading odometry velodyne data (~5 GB, motion-compensated)." + echo "[bag-player/kitti] This large download is cached and only runs once." + + _velo_url="https://s3.eu-central-1.amazonaws.com/avg-kitti/data_odometry_velodyne.zip" + _velo_zip="${TMP_DIR}/data_odometry_velodyne.zip" + _download "${_velo_url}" "${_velo_zip}" + + _velo_data_dir="${DRIVE_DIR}/velodyne_points/data" + echo "[bag-player/kitti] Replacing velodyne data with motion-compensated scans ..." + rm -rf "${_velo_data_dir}" + mkdir -p "${_velo_data_dir}" + if ! unzip -q -j -d "${_velo_data_dir}" "${_velo_zip}" \ + "dataset/sequences/${KITTI_SEQUENCE}/velodyne/*.bin"; then + echo "[bag-player/kitti] ERROR: Failed to extract odometry velodyne data." >&2 + echo "[bag-player/kitti] Verify KITTI_SEQUENCE='${KITTI_SEQUENCE}' is a valid sequence" >&2 + echo "[bag-player/kitti] (00-10 for training, 11-21 for test) and that the downloaded" >&2 + echo "[bag-player/kitti] archive is intact. Delete '${_velo_zip}' to re-download." >&2 + exit 1 + fi + touch "${_velo_flag}" + echo "[bag-player/kitti] Velodyne data replaced with motion-compensated version." +fi + +# ------------------------------------------------------------------------- +# 3. Convert KITTI raw data → ROS 2 bag (skip if already done) +# ------------------------------------------------------------------------- +if [[ ! -d "${ROS2BAG_DIR}" ]]; then + echo "[bag-player/kitti] Converting KITTI data to ROS 2 bag — this may take several minutes ..." + python3 /workspace/kitti_to_ros2bag.py \ + --kitti-dir "${KITTI_DIR}" \ + --date "${KITTI_DATE}" \ + --drive "${KITTI_DRIVE}" \ + --camera "${KITTI_CAMERA}" \ + --output-bag "${ROS2BAG_DIR}" \ + --image-topic "${ROS2_CAMERA_IMAGE_TOPIC}" \ + --camera-info-topic "${ROS2_CAMERA_INFO_TOPIC}" \ + --lidar-topic "${ROS2_LIDAR_POINTS_TOPIC}" \ + --imu-topic "${ROS2_IMU_TOPIC}" +else + echo "[bag-player/kitti] ROS 2 bag already exists at '${ROS2BAG_DIR}', skipping conversion." +fi + +# ------------------------------------------------------------------------- +# 4. Play the converted ROS 2 bag +# ------------------------------------------------------------------------- +EXTRA_ARGS=() +if [[ "${BAG_LOOP}" == "true" ]]; then + EXTRA_ARGS+=(--loop) +fi + +echo "[bag-player/kitti] Playing KITTI ros2bag '${ROS2BAG_DIR}' at rate ${BAG_RATE} (loop=${BAG_LOOP}) ..." +exec ros2 bag play "${ROS2BAG_DIR}" \ + --clock \ + --rate "${BAG_RATE}" \ + "${EXTRA_ARGS[@]}" diff --git a/docker-compose.yaml b/docker-compose.yaml index f4bfa29..1ffcb9a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,15 +16,39 @@ services: context: ./bag-player image: ghcr.io/lcas/mdpcalib-bag-player:latest volumes: - # Host directory containing your rosbag2 folders. + # Mode 'ros2bag' (default): host directory containing your rosbag2 folders. # Defaults to $HOME/rosbags on the host; override with ROSBAG_PATH in .env. - ${ROSBAG_PATH:-${HOME}/rosbags}:/rosbags + # Mode 'kitti': named Docker volume for KITTI raw data + converted ros2bag. + # Data is downloaded automatically on first run and cached here. + # Use a named volume (not a bind mount) so Docker manages the lifecycle. + - kitti_data:/kitti environment: + # Select playback mode: 'ros2bag' (default) or 'kitti' + - PLAYBACK_MODE=${PLAYBACK_MODE:-ros2bag} + + # --- ros2bag mode variables --- # Path inside the container to the rosbag2 folder to play. - BAG_PATH=${BAG_PATH:-/rosbags} # Loop the bag by default so calibration has time to complete. - BAG_LOOP=${BAG_LOOP:-true} - BAG_RATE=${BAG_RATE:-1.0} + + # --- kitti mode variables --- + # KITTI recording date and drive (default: sequence 00 = 2011_10_03 drive 0027) + - KITTI_DATE=${KITTI_DATE:-2011_10_03} + - KITTI_DRIVE=${KITTI_DRIVE:-0027} + # Which colour camera to use: 'left' or 'right' + - KITTI_CAMERA=${KITTI_CAMERA:-left} + # Odometry sequence number for motion-compensated velodyne (default: 00) + - KITTI_SEQUENCE=${KITTI_SEQUENCE:-00} + + # --- sensor topics (shared by both modes) --- + - ROS2_CAMERA_IMAGE_TOPIC=${ROS2_CAMERA_IMAGE_TOPIC:-/camera/image_raw} + - ROS2_CAMERA_INFO_TOPIC=${ROS2_CAMERA_INFO_TOPIC:-/camera/camera_info} + - ROS2_LIDAR_POINTS_TOPIC=${ROS2_LIDAR_POINTS_TOPIC:-/points_raw} + - ROS2_IMU_TOPIC=${ROS2_IMU_TOPIC:-/imu/data} + - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} networks: - mdpcalib_net @@ -55,6 +79,8 @@ services: - LIDAR_FRAME_ID=${LIDAR_FRAME_ID:-lidar} - CAMERA_FRAME_ID=${CAMERA_FRAME_ID:-camera} - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} + # FAST-LO sensor config — use velodyne.yaml for KITTI (HDL-64E), ouster.yaml (default) for Ouster + - FAST_LO_CONFIG_FILE=${FAST_LO_CONFIG_FILE:-} deploy: resources: @@ -78,6 +104,7 @@ services: volumes: x11: + kitti_data: networks: mdpcalib_net: From 9866f2ee8cc5148dd6b366127ccdda8280a79f49 Mon Sep 17 00:00:00 2001 From: Josh Cooper Date: Tue, 12 May 2026 17:02:32 +0100 Subject: [PATCH 08/12] fix: install pip to bag player --- bag-player/Dockerfile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bag-player/Dockerfile b/bag-player/Dockerfile index aed4eb5..5dc2d1b 100644 --- a/bag-player/Dockerfile +++ b/bag-player/Dockerfile @@ -6,8 +6,6 @@ FROM ${BASE_IMAGE} ARG ROS_DISTRO ENV ROS_DISTRO=${ROS_DISTRO} -USER root - WORKDIR /workspace # Copy the package manifest so rosdep can install all declared dependencies. @@ -19,6 +17,15 @@ RUN rosdep update --rosdistro "${ROS_DISTRO}" \ && rm -rf /var/lib/apt/lists/* # Python packages required for KITTI → ros2bag conversion (no rosdep keys available) +RUN rm -rf /var/lib/apt/lists/* \ + && apt-get update --fix-missing \ + && apt-get -y install --no-install-recommends --fix-missing \ + python3 \ + python3-pip \ + python3-colcon-common-extensions \ + python3-colcon-mixin \ + && rm -rf /var/lib/apt/lists/* + RUN pip3 install --no-cache-dir pykitti progressbar2 COPY play_bag.sh play_kitti.sh kitti_to_ros2bag.py /workspace/ From e3a064e98d6a6f61c50478f010f194149d10cb48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 16:06:46 +0000 Subject: [PATCH 09/12] fix: validate zip integrity in play_kitti.sh _download helper Previously a partial download (e.g. container stopped mid-curl) left an incomplete .zip file in the cache. On the next run the file existed so _download returned "Cached" without checking it, and unzip failed with "End-of-central-directory signature not found". Fix: run `unzip -t` to validate the existing file before trusting the cache. If validation fails, delete and re-download. Also download to a .tmp file and only rename on success + valid zip, so an interrupted download never leaves a corrupt cached file. Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/0f9e60e6-309a-4385-81ce-5dbedb33c09b Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- bag-player/play_kitti.sh | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/bag-player/play_kitti.sh b/bag-player/play_kitti.sh index 02ae61e..94fe963 100644 --- a/bag-player/play_kitti.sh +++ b/bag-player/play_kitti.sh @@ -49,16 +49,39 @@ TMP_DIR="${KITTI_DIR}/.download_tmp" mkdir -p "${KITTI_DIR}" "${TMP_DIR}" # ------------------------------------------------------------------------- -# Helper: download a file only if not already present +# Helper: download a file, verify it, and cache it. +# If the file already exists but fails integrity check (e.g. interrupted +# download), it is deleted and re-downloaded automatically. # ------------------------------------------------------------------------- _download() { local url="$1" dest="$2" if [[ -f "${dest}" ]]; then - echo "[bag-player/kitti] Cached: $(basename "${dest}")" - return + # Verify the existing file is a valid zip before trusting the cache. + if unzip -t "${dest}" >/dev/null 2>&1; then + echo "[bag-player/kitti] Cached (verified): $(basename "${dest}")" + return + else + echo "[bag-player/kitti] Cached file '$(basename "${dest}")' is corrupt or incomplete — re-downloading ..." + rm -f "${dest}" + fi fi echo "[bag-player/kitti] Downloading $(basename "${dest}") ..." - curl -fSL --retry 3 --retry-delay 10 -o "${dest}" "${url}" + # Download to a temp file first; only rename on success so a crash + # during download does not leave a partial file that looks valid-named. + local tmp_dest="${dest}.tmp" + rm -f "${tmp_dest}" + if ! curl -fSL --retry 3 --retry-delay 10 -o "${tmp_dest}" "${url}"; then + rm -f "${tmp_dest}" + echo "[bag-player/kitti] ERROR: curl download failed for '${url}'." >&2 + exit 1 + fi + # Validate the downloaded file before committing it to the cache. + if ! unzip -t "${tmp_dest}" >/dev/null 2>&1; then + rm -f "${tmp_dest}" + echo "[bag-player/kitti] ERROR: Downloaded file from '${url}' failed zip integrity check." >&2 + exit 1 + fi + mv "${tmp_dest}" "${dest}" } # ------------------------------------------------------------------------- From 1521a309cf22b5ece43ccd409aa720bd24a0bcae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 16:08:31 +0000 Subject: [PATCH 10/12] feat: hold mdpcalib until bag-player is ready (healthcheck) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both play_bag.sh and play_kitti.sh write /tmp/bag_player_ready just before exec-ing ros2 bag play — i.e. after all downloads, extraction, and bag conversion are complete. docker-compose.yaml adds a healthcheck on the bag-player service that tests for that flag file (interval 10s, start_period 4 h to cover the worst-case KITTI download). mdpcalib's depends_on is upgraded from service_started to condition: service_healthy so the calibration stack only launches once data is actually flowing. Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/0f9e60e6-309a-4385-81ce-5dbedb33c09b Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- bag-player/play_bag.sh | 2 ++ bag-player/play_kitti.sh | 2 ++ docker-compose.yaml | 17 +++++++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/bag-player/play_bag.sh b/bag-player/play_bag.sh index 45816f2..7df7855 100644 --- a/bag-player/play_bag.sh +++ b/bag-player/play_bag.sh @@ -40,6 +40,8 @@ if [[ "${BAG_LOOP}" == "true" ]]; then fi echo "[bag-player] Playing ros2bag '${BAG_PATH}' at rate ${BAG_RATE} (loop=${BAG_LOOP}) ..." +# Signal to docker-compose that preparation is done and playback is starting. +touch /tmp/bag_player_ready exec ros2 bag play "${BAG_PATH}" \ --clock \ --rate "${BAG_RATE}" \ diff --git a/bag-player/play_kitti.sh b/bag-player/play_kitti.sh index 94fe963..f84e903 100644 --- a/bag-player/play_kitti.sh +++ b/bag-player/play_kitti.sh @@ -162,6 +162,8 @@ if [[ "${BAG_LOOP}" == "true" ]]; then fi echo "[bag-player/kitti] Playing KITTI ros2bag '${ROS2BAG_DIR}' at rate ${BAG_RATE} (loop=${BAG_LOOP}) ..." +# Signal to docker-compose that all downloads/conversion are done and playback is starting. +touch /tmp/bag_player_ready exec ros2 bag play "${ROS2BAG_DIR}" \ --clock \ --rate "${BAG_RATE}" \ diff --git a/docker-compose.yaml b/docker-compose.yaml index 1ffcb9a..c7d7f47 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -50,6 +50,17 @@ services: - ROS2_IMU_TOPIC=${ROS2_IMU_TOPIC:-/imu/data} - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} + healthcheck: + # Becomes healthy only once all downloads/conversions are done and + # ros2 bag play is about to start. mdpcalib depends on this so the + # calibration stack never starts before data is flowing. + test: ["CMD", "test", "-f", "/tmp/bag_player_ready"] + interval: 10s + timeout: 5s + retries: 3 + # Allow up to 4 hours for large KITTI downloads before the healthcheck + # starts counting failures (ros2bag mode is typically ready in seconds). + start_period: 14400s networks: - mdpcalib_net depends_on: @@ -92,8 +103,10 @@ services: - mdpcalib_net depends_on: - - vnc - - bag-player + vnc: + condition: service_started + bag-player: + condition: service_healthy ipc: "service:vnc" From a1c9169ce984b3d88d6e8420dfdda67fc2f53586 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 17:19:43 +0000 Subject: [PATCH 11/12] fix: pin numpy<2 in bag-player Dockerfile pykitti (and several ROS 2 Python packages) are compiled against the NumPy 1.x ABI. With a fresh pip install the latest NumPy (2.2.6) is installed, causing: "A module that was compiled using NumPy 1.x cannot be run in NumPy 2.2.6 as it may crash." Adding "numpy<2" before pykitti/progressbar2 keeps NumPy on the 1.x release line so the ABI mismatch cannot occur. Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/fa211531-fa1a-461e-bf3d-0d6045f08527 Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- bag-player/Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bag-player/Dockerfile b/bag-player/Dockerfile index 5dc2d1b..87a40ff 100644 --- a/bag-player/Dockerfile +++ b/bag-player/Dockerfile @@ -26,7 +26,9 @@ RUN rm -rf /var/lib/apt/lists/* \ python3-colcon-mixin \ && rm -rf /var/lib/apt/lists/* -RUN pip3 install --no-cache-dir pykitti progressbar2 +# Pin numpy<2: pykitti (and several ros2 packages) are compiled against +# NumPy 1.x ABI and raise "cannot be run in NumPy 2.x" at runtime otherwise. +RUN pip3 install --no-cache-dir "numpy<2" pykitti progressbar2 COPY play_bag.sh play_kitti.sh kitti_to_ros2bag.py /workspace/ RUN chmod +x /workspace/play_bag.sh /workspace/play_kitti.sh From 210dde7d7c825d60902ce3d5829e99eb9d142f0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 17:42:03 +0000 Subject: [PATCH 12/12] fix: sentinel-based skip for KITTI bag conversion; add KITTI_FORCE_REBUILD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the bag output directory was created at the start of the Python conversion (os.makedirs). If the process crashed mid-way, a partial directory remained. The next container start saw the directory and either: - skipped conversion (leaving an unusable bag), or - crashed when rosbag2 tried to open an existing partial metadata.yaml Fix: track completion via a sentinel file ${ROS2BAG_DIR}/.conversion_done Only written after a fully-successful conversion. On startup: - sentinel exists + KITTI_FORCE_REBUILD != true → skip (bag is complete) - directory exists but no sentinel → partial; wipe and reconvert - KITTI_FORCE_REBUILD=true → wipe dir+sentinel, reconvert Also adds KITTI_FORCE_REBUILD env var to docker-compose.yaml and .env.example so it can be set without rebuilding the image. Agent-Logs-Url: https://github.com/LCAS/MDPCalib/sessions/02ead0b8-ed3f-4bb6-9e3b-cccb5028c4a2 Co-authored-by: cooperj <28831674+cooperj@users.noreply.github.com> --- .env.example | 3 +++ bag-player/play_kitti.sh | 37 ++++++++++++++++++++++++++++--------- docker-compose.yaml | 4 ++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 690c129..7beac24 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,9 @@ CALIBRATION_DATA_PATH=/CHANGE/ME/absolute/path/to/calibration/data # KITTI_CAMERA=left # Odometry sequence whose motion-compensated velodyne scans replace the raw ones. # KITTI_SEQUENCE=00 +# Set to "true" to delete the cached ros2bag and force re-conversion. +# Useful after changing KITTI_DATE/KITTI_DRIVE/KITTI_CAMERA. Leave unset for normal use. +# KITTI_FORCE_REBUILD=false # # For KITTI mode, also set FAST-LO to the Velodyne HDL-64E configuration: # FAST_LO_CONFIG_FILE=/root/catkin_ws/src/mdpcalib/FAST_LO/config/velodyne.yaml diff --git a/bag-player/play_kitti.sh b/bag-player/play_kitti.sh index f84e903..399c258 100644 --- a/bag-player/play_kitti.sh +++ b/bag-player/play_kitti.sh @@ -10,13 +10,14 @@ # container restarts skip the download step. # # Environment variables: -# KITTI_DIR — container path for the named kitti volume (default: /kitti) -# KITTI_DATE — recording date (default: 2011_10_03) -# KITTI_DRIVE — drive number (zero-padded 4 digits) (default: 0027) -# KITTI_CAMERA — 'left' or 'right' (default: left) -# KITTI_SEQUENCE — odometry sequence for velodyne download (default: 00) -# BAG_RATE — playback rate multiplier (default: 1.0) -# BAG_LOOP — set to "false" to play once (default: true) +# KITTI_DIR — container path for the named kitti volume (default: /kitti) +# KITTI_DATE — recording date (default: 2011_10_03) +# KITTI_DRIVE — drive number (zero-padded 4 digits) (default: 0027) +# KITTI_CAMERA — 'left' or 'right' (default: left) +# KITTI_SEQUENCE — odometry sequence for velodyne download (default: 00) +# KITTI_FORCE_REBUILD — set to "true" to force reconversion (default: false) +# BAG_RATE — playback rate multiplier (default: 1.0) +# BAG_LOOP — set to "false" to play once (default: true) # ROS2_CAMERA_IMAGE_TOPIC (default: /camera/image_raw) # ROS2_CAMERA_INFO_TOPIC (default: /camera/camera_info) # ROS2_LIDAR_POINTS_TOPIC (default: /points_raw) @@ -35,6 +36,7 @@ KITTI_DATE="${KITTI_DATE:-2011_10_03}" KITTI_DRIVE="${KITTI_DRIVE:-0027}" KITTI_CAMERA="${KITTI_CAMERA:-left}" KITTI_SEQUENCE="${KITTI_SEQUENCE:-00}" +KITTI_FORCE_REBUILD="${KITTI_FORCE_REBUILD:-false}" BAG_RATE="${BAG_RATE:-1.0}" BAG_LOOP="${BAG_LOOP:-true}" ROS2_CAMERA_IMAGE_TOPIC="${ROS2_CAMERA_IMAGE_TOPIC:-/camera/image_raw}" @@ -44,6 +46,9 @@ ROS2_IMU_TOPIC="${ROS2_IMU_TOPIC:-/imu/data}" DRIVE_DIR="${KITTI_DIR}/${KITTI_DATE}/${KITTI_DATE}_drive_${KITTI_DRIVE}_sync" ROS2BAG_DIR="${KITTI_DIR}/ros2bag/kitti_${KITTI_DATE}_drive_${KITTI_DRIVE}_${KITTI_CAMERA}" +# Sentinel file written only after a fully-successful conversion. +# Its presence is the authoritative signal that the bag is complete. +ROS2BAG_SENTINEL="${ROS2BAG_DIR}/.conversion_done" TMP_DIR="${KITTI_DIR}/.download_tmp" mkdir -p "${KITTI_DIR}" "${TMP_DIR}" @@ -137,7 +142,18 @@ fi # ------------------------------------------------------------------------- # 3. Convert KITTI raw data → ROS 2 bag (skip if already done) # ------------------------------------------------------------------------- -if [[ ! -d "${ROS2BAG_DIR}" ]]; then +# KITTI_FORCE_REBUILD=true wipes any existing output so conversion re-runs. +if [[ "${KITTI_FORCE_REBUILD}" == "true" ]]; then + echo "[bag-player/kitti] KITTI_FORCE_REBUILD=true — removing existing bag for rebuild." + rm -rf "${ROS2BAG_DIR}" +fi + +if [[ ! -f "${ROS2BAG_SENTINEL}" ]]; then + # Remove any partial/corrupt conversion output from a previous crashed run. + if [[ -d "${ROS2BAG_DIR}" ]]; then + echo "[bag-player/kitti] Partial bag detected (no sentinel) — removing and reconverting." + rm -rf "${ROS2BAG_DIR}" + fi echo "[bag-player/kitti] Converting KITTI data to ROS 2 bag — this may take several minutes ..." python3 /workspace/kitti_to_ros2bag.py \ --kitti-dir "${KITTI_DIR}" \ @@ -149,8 +165,11 @@ if [[ ! -d "${ROS2BAG_DIR}" ]]; then --camera-info-topic "${ROS2_CAMERA_INFO_TOPIC}" \ --lidar-topic "${ROS2_LIDAR_POINTS_TOPIC}" \ --imu-topic "${ROS2_IMU_TOPIC}" + # Only written on successful completion — guards against partial bags. + touch "${ROS2BAG_SENTINEL}" else - echo "[bag-player/kitti] ROS 2 bag already exists at '${ROS2BAG_DIR}', skipping conversion." + echo "[bag-player/kitti] Complete ROS 2 bag found at '${ROS2BAG_DIR}', skipping conversion." + echo "[bag-player/kitti] (Set KITTI_FORCE_REBUILD=true to force a rebuild.)" fi # ------------------------------------------------------------------------- diff --git a/docker-compose.yaml b/docker-compose.yaml index c7d7f47..b9231ca 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -42,6 +42,10 @@ services: - KITTI_CAMERA=${KITTI_CAMERA:-left} # Odometry sequence number for motion-compensated velodyne (default: 00) - KITTI_SEQUENCE=${KITTI_SEQUENCE:-00} + # Set to "true" to delete the cached ros2bag and re-convert from raw data. + # Useful when KITTI_DATE/KITTI_DRIVE/KITTI_CAMERA is changed. Leave + # unset (or "false") for the normal skip-if-complete behaviour. + - KITTI_FORCE_REBUILD=${KITTI_FORCE_REBUILD:-false} # --- sensor topics (shared by both modes) --- - ROS2_CAMERA_IMAGE_TOPIC=${ROS2_CAMERA_IMAGE_TOPIC:-/camera/image_raw}