diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6036ce2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +build +install +log +.git +*.pyc +__pycache__ diff --git a/.gitignore b/.gitignore index 881e002..8433776 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ build/ # VSC stuff .vscode +# Local python virtualenv +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26903d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM ros:humble-ros-base AS build +SHELL ["/bin/bash", "-lc"] +WORKDIR /ws + +# Install build dependencies +COPY apt-requirements.txt /tmp/apt-build.txt +RUN apt-get update \ + && grep -Ev '^[[:space:]]*($|#)' /tmp/apt-build.txt | xargs apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* /tmp/apt-build.txt + +# Copy package manifest and install rosdeps +COPY package.xml ./package.xml +RUN add-apt-repository multiverse || true +RUN source /opt/ros/humble/setup.bash \ + && rosdep update || true \ + && rosdep install -i --from-paths . --rosdistro humble -y || true + +# Copy sources and build +COPY . . +RUN source /opt/ros/humble/setup.bash \ + && CCACHE_DIR=/ccache mkdir -p /ccache \ + && chmod 777 /ccache \ + && colcon build --parallel-workers $(nproc) \ + --cmake-args -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache + +FROM ros:humble-ros-base +WORKDIR /ws +## Install minimal runtime packages required by the built binaries +COPY apt-runtime.txt /tmp/apt-runtime.txt +RUN apt-get update \ + && grep -Ev '^[[:space:]]*($|#)' /tmp/apt-runtime.txt | xargs apt-get install -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* /tmp/apt-runtime.txt + +COPY --from=build /ws/install /ws/install +COPY docker-entrypoint.sh /ros_entrypoint.sh +RUN chmod +x /ros_entrypoint.sh +# Bake FastDDS UDP-only profile into the image so the shared-memory transport +# fix applies regardless of how the container is started (compose or docker run). +COPY fastdds_no_shm.xml /ws/fastdds_no_shm.xml +ENV ROS_DISTRO=humble +ENV FASTRTPS_DEFAULT_PROFILES_FILE=/ws/fastdds_no_shm.xml + +## Make ROS and workspace overlays available for interactive shells +RUN echo "source /opt/ros/${ROS_DISTRO}/setup.bash || true" > /etc/profile.d/ros2.sh \ + && echo "[ -f /ws/install/setup.bash ] && source /ws/install/setup.bash" >> /etc/profile.d/ros2.sh \ + && chmod +x /etc/profile.d/ros2.sh + +# Also source in non-login interactive bash shells +RUN echo "source /opt/ros/${ROS_DISTRO}/setup.bash || true" >> /etc/bash.bashrc \ + && echo "[ -f /ws/install/setup.bash ] && source /ws/install/setup.bash" >> /etc/bash.bashrc + +ENTRYPOINT ["/ros_entrypoint.sh"] +CMD ["image2rtsp.launch.py"] diff --git a/README.md b/README.md index a921d63..23f0157 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,34 @@ You are reading now the README for a **default** ROS2 package. If you want to us ## Dependencies - ROS2 Humble +- Dependency files in this repository: + - `apt-requirements.txt`: build-only system dependencies. + - `apt-runtime.txt`: runtime system dependencies (minimal default set). + - `requirements.txt`: pip dependencies (currently none required). +- OpenCV policy: use APT package `python3-opencv` (do not install `opencv-python` via pip by default). + +- Optional debug-only packages: + - `ros-humble-ros2cli` + - `net-tools` + - `iputils-ping` + - These are listed as commented entries in `apt-runtime.txt` and are not installed by default. + - gstreamer libs: ```bash sudo apt-get install libgstreamer-plugins-base1.0-dev libgstreamer-plugins-good1.0-dev libgstreamer-plugins-bad1.0-dev libgstrtspserver-1.0-dev gstreamer1.0-plugins-ugly gstreamer1.0-plugins-bad ``` + +For a minimal host setup using repository-managed lists: +```bash +sudo apt-get update +grep -Ev '^[[:space:]]*($|#)' apt-requirements.txt | xargs sudo apt-get install -y --no-install-recommends +grep -Ev '^[[:space:]]*($|#)' apt-runtime.txt | xargs sudo apt-get install -y --no-install-recommends +``` + +Install debug tools only when needed: +```bash +sudo apt-get install -y --no-install-recommends ros-humble-ros2cli net-tools iputils-ping +``` ## Install - Navigate to the root directory, create a new directory named `ros2_ws/src`, and then change the current working directory to `ros2_ws/src`: ```bashrc diff --git a/apt-requirements.txt b/apt-requirements.txt new file mode 100644 index 0000000..86eb89a --- /dev/null +++ b/apt-requirements.txt @@ -0,0 +1,16 @@ +python3-colcon-common-extensions +build-essential +cmake +pkg-config +libopencv-dev +python3-rosdep +python3-rosdistro +software-properties-common +libgstreamer1.0-dev +libgstreamer-plugins-base1.0-dev +libgstreamer-plugins-bad1.0-dev +libgstreamer-plugins-good1.0-dev +libgstrtspserver-1.0-dev +ninja-build +ccache + diff --git a/apt-runtime.txt b/apt-runtime.txt new file mode 100644 index 0000000..e6ff5be --- /dev/null +++ b/apt-runtime.txt @@ -0,0 +1,14 @@ +libgstreamer1.0-0 +gstreamer1.0-plugins-base +gstreamer1.0-plugins-good +gstreamer1.0-plugins-bad +gstreamer1.0-plugins-ugly +libgstrtspserver-1.0-0 +python3-opencv +ros-humble-rclpy + +# Optional debug-only tools (not required for normal runtime) +# Uncomment/install manually only when troubleshooting: +# ros-humble-ros2cli +# net-tools +# iputils-ping diff --git a/config/parameters.yaml b/config/parameters.yaml index 2ca4cb0..eb4b3ff 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -3,12 +3,12 @@ # If the source is a ros2 topic (default case) compressed: False - topic: "color/image_raw" + topic: "camera/image" default_pipeline: | ( appsrc name=imagesrc do-timestamp=true min-latency=0 max-latency=0 max-bytes=1000 is-live=true ! videoconvert ! videoscale ! - video/x-raw, framerate=30/1, width=640, height=480 ! + video/x-raw, framerate=8/1 ! x264enc tune=zerolatency bitrate=500 key-int-max=30 ! video/x-h264, profile=baseline ! rtph264pay name=pay0 pt=96 ) @@ -31,9 +31,9 @@ # Notice: Here the framerate might be set to the camera framerate, otherwise "503 Service Unavailable" error will appear. # RTSP setup - mountpoint: "/back" + mountpoint: "/live" port: "8554" - local_only: True # True = rtsp://127.0.0.1:portAndMountpoint (The stream is accessible only from the local machine) + local_only: False # True = rtsp://127.0.0.1:portAndMountpoint (The stream is accessible only from the local machine) # False = rtsp://0.0.0.0:portAndMountpoint (The stream is accessible from the outside) # For example, to access the stream running on the machine with IP = 192.168.20.20, # use rtsp://192.186.20.20:portAndMountpoint diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f0d3c10 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + image2rtsp: + build: . + image: image2rtsp:latest + # Use host networking so the container binds to the host interfaces directly + # (Linux only). Remove port mapping when using host networking. + network_mode: "host" + environment: + - ROS_DISTRO=humble + # Propagate host ROS environment so DDS/discovery match (set on host if needed) + - ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0} + - RMW_IMPLEMENTATION=${RMW_IMPLEMENTATION:-} + # GStreamer debug level (uncomment for verbose GStreamer logging) + # - GST_DEBUG=${GST_DEBUG:-} + # FASTRTPS_DEFAULT_PROFILES_FILE is set in the Dockerfile (baked into image) + # Ensure the node binds to non-localhost interface (disable local_only) + # and subscribe to the actual image topic available on the host + + #log_level:= info, debug, warning, error, fatal + command: ["image2rtsp.launch.py", "local_only:=false", "topic:=/camera/image", "log_level:=info"] + + + restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..6c9d899 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -e + +# Source ROS and workspace overlays then exec ros2 launch with provided args +if [ -n "${ROS_DISTRO:-}" ]; then + source "/opt/ros/${ROS_DISTRO}/setup.bash" || true +else + source /opt/ros/humble/setup.bash || true +fi + +if [ -f /ws/install/setup.bash ]; then + source /ws/install/setup.bash +fi + +exec ros2 launch image2rtsp "$@" diff --git a/fastdds_no_shm.xml b/fastdds_no_shm.xml new file mode 100644 index 0000000..a67988c --- /dev/null +++ b/fastdds_no_shm.xml @@ -0,0 +1,21 @@ + + + + + + UDPv4Transport + UDPv4 + + + + + + UDPv4Transport + + false + + + diff --git a/include/image2rtsp.hpp b/include/image2rtsp.hpp index b5b7693..69b26ea 100644 --- a/include/image2rtsp.hpp +++ b/include/image2rtsp.hpp @@ -15,6 +15,8 @@ class Image2rtsp : public rclcpp::Node{ public: Image2rtsp(); GstRTSPServer *rtsp_server; + uint framerate; + GstAppSrc *appsrc; private: string topic; @@ -23,14 +25,12 @@ private: string pipeline; string default_pipeline; string camera_pipeline; - uint framerate; bool local_only; bool camera; bool compressed; - GstAppSrc *appsrc; void video_mainloop_start(); - void rtsp_server_add_url(const char *url, const char *sPipeline, GstElement **appsrc); + void rtsp_server_add_url(const char *url, const char *sPipeline); void topic_callback(const sensor_msgs::msg::Image::SharedPtr msg); void compressed_topic_callback(const sensor_msgs::msg::CompressedImage::SharedPtr msg); uint extract_framerate(const std::string& pipeline, uint default_framerate); @@ -40,7 +40,7 @@ private: rclcpp::Subscription::SharedPtr subscription_compressed_; }; -static void media_configure(GstRTSPMediaFactory *factory, GstRTSPMedia *media, GstElement **appsrc); +static void media_configure(GstRTSPMediaFactory *factory, GstRTSPMedia *media, gpointer user_data); static void *mainloop(void *arg); static gboolean session_cleanup(Image2rtsp *node, rclcpp::Logger logger, gboolean ignored); diff --git a/launch/image2rtsp.launch.py b/launch/image2rtsp.launch.py index ffa0a25..7e45101 100644 --- a/launch/image2rtsp.launch.py +++ b/launch/image2rtsp.launch.py @@ -1,6 +1,8 @@ import os from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node def generate_launch_description(): @@ -10,11 +12,19 @@ def generate_launch_description(): 'parameters.yaml' ) + log_level = LaunchConfiguration('log_level') + return LaunchDescription([ + DeclareLaunchArgument( + 'log_level', + default_value='warn', + description='ROS logger level (debug, info, warn, error, fatal)' + ), Node( package='image2rtsp', executable='image2rtsp', name='image2rtsp', - parameters=[config] + parameters=[config], + arguments=['--ros-args', '--log-level', log_level] ) ]) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..15662a4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# No required pip dependencies. +# OpenCV is provided via apt package: python3-opencv. diff --git a/run_image2rtsp.sh b/run_image2rtsp.sh new file mode 100755 index 0000000..ec59528 --- /dev/null +++ b/run_image2rtsp.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +# Minimal launcher: runs a package launch with optional args. +exec ros2 launch image2rtsp "${1:-image2rtsp.launch.py}" "${@:2}" diff --git a/src/image2rtsp.cpp b/src/image2rtsp.cpp index 58d101f..7d78eda 100644 --- a/src/image2rtsp.cpp +++ b/src/image2rtsp.cpp @@ -49,15 +49,15 @@ Image2rtsp::Image2rtsp() : Node("image2rtsp"){ if (camera == false){ if (compressed == false){ subscription_ = this->create_subscription(topic, 10, std::bind(&Image2rtsp::topic_callback, this, _1)); - RCLCPP_INFO(this->get_logger(), "Subscribing to sensor_msgs::msg::Image"); + RCLCPP_DEBUG(this->get_logger(), "Subscribing to sensor_msgs::msg::Image"); } else { subscription_compressed_ = this->create_subscription(topic, 10, std::bind(&Image2rtsp::compressed_topic_callback, this, _1)); - RCLCPP_INFO(this->get_logger(), "Subscribing to sensor_msgs::msg::CompressedImage"); + RCLCPP_DEBUG(this->get_logger(), "Subscribing to sensor_msgs::msg::CompressedImage"); } } else { - RCLCPP_INFO(this->get_logger(), "Trying to access camera device"); + RCLCPP_DEBUG(this->get_logger(), "Trying to access camera device"); } // Start the RTSP server @@ -67,9 +67,15 @@ Image2rtsp::Image2rtsp() : Node("image2rtsp"){ pipeline = camera ? camera_pipeline : default_pipeline; framerate = extract_framerate(pipeline, 30); - rtsp_server_add_url(mountpoint.c_str(), pipeline.c_str(), camera ? nullptr : (GstElement **)&appsrc); + rtsp_server_add_url(mountpoint.c_str(), pipeline.c_str()); - RCLCPP_INFO(this->get_logger(), "Stream available at rtsp://%s:%s%s", gst_rtsp_server_get_address(rtsp_server), port.c_str(), mountpoint.c_str()); + const char *server_address = gst_rtsp_server_get_address(rtsp_server); + if (local_only) { + RCLCPP_DEBUG(this->get_logger(), "Stream available at rtsp://%s:%s%s", server_address, port.c_str(), mountpoint.c_str()); + } else { + RCLCPP_DEBUG(this->get_logger(), "RTSP server bound to %s:%s%s", server_address, port.c_str(), mountpoint.c_str()); + RCLCPP_DEBUG(this->get_logger(), "Connect clients using rtsp://:%s%s (0.0.0.0 is bind-only)", port.c_str(), mountpoint.c_str()); + } } uint Image2rtsp::extract_framerate(const std::string& pipeline, uint default_framerate = 30) { @@ -99,7 +105,7 @@ uint Image2rtsp::extract_framerate(const std::string& pipeline, uint default_fra RCLCPP_WARN(this->get_logger(), "Invalid framerate value %d, using default: %d", framerate, default_framerate); return default_framerate; } - RCLCPP_INFO(this->get_logger(), "Using set framerate %d", framerate); + RCLCPP_DEBUG(this->get_logger(), "Using set framerate %d", framerate); return framerate; } catch (const std::exception& e) { RCLCPP_WARN(this->get_logger(), "Failed to parse framerate '%s', using default: %d", framerate_str.c_str(), default_framerate); diff --git a/src/video.cpp b/src/video.cpp index bde96c5..cd37e2c 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -38,7 +38,7 @@ GstRTSPServer *Image2rtsp::rtsp_server_create(const std::string &port, const boo return server; } -void Image2rtsp::rtsp_server_add_url(const char *url, const char *sPipeline, GstElement **appsrc){ +void Image2rtsp::rtsp_server_add_url(const char *url, const char *sPipeline){ GstRTSPMountPoints *mounts; GstRTSPMediaFactory *factory; @@ -55,9 +55,12 @@ void Image2rtsp::rtsp_server_add_url(const char *url, const char *sPipeline, Gst /* notify when our media is ready, This is called whenever someone asks for * the media and a new pipeline is created */ - g_signal_connect(factory, "media-configure", (GCallback)media_configure, appsrc); + // Pass `this` as user_data so media_configure can access node state and push a preroll frame + g_signal_connect(factory, "media-configure", (GCallback)media_configure, this); - gst_rtsp_media_factory_set_shared(factory, TRUE); + // Use non-shared media factory so each client gets its own pipeline + // This avoids prerolling a shared pipeline without available appsrc data + gst_rtsp_media_factory_set_shared(factory, FALSE); /* attach the factory to the url */ gst_rtsp_mount_points_add_factory(mounts, url, factory); @@ -66,17 +69,30 @@ void Image2rtsp::rtsp_server_add_url(const char *url, const char *sPipeline, Gst g_object_unref(mounts); } -static void media_configure(GstRTSPMediaFactory *factory, GstRTSPMedia *media, GstElement **appsrc){ - if(appsrc){ - GstElement *pipeline = gst_rtsp_media_get_element(media); +static void media_configure(GstRTSPMediaFactory *factory, GstRTSPMedia *media, gpointer user_data){ + Image2rtsp *node = static_cast(user_data); + GstElement *pipeline = gst_rtsp_media_get_element(media); + GstElement *imagesrc = gst_bin_get_by_name(GST_BIN(pipeline), "imagesrc"); - *appsrc = gst_bin_get_by_name(GST_BIN(pipeline), "imagesrc"); + if (imagesrc){ + /* store appsrc in node for later pushes */ + node->appsrc = GST_APP_SRC(imagesrc); - /* this instructs appsrc that we will be dealing with timed buffer */ - gst_util_set_object_arg(G_OBJECT(*appsrc), "format", "time"); + /* instruct appsrc that we will be dealing with timed buffers */ + gst_util_set_object_arg(G_OBJECT(node->appsrc), "format", "time"); + /* mark stream-type to not require preroll and reduce buffering */ + gst_app_src_set_stream_type(node->appsrc, GST_APP_STREAM_TYPE_STREAM); + gst_app_src_set_max_buffers(node->appsrc, 0); + gst_app_src_set_max_bytes(node->appsrc, 0); + gst_app_src_set_max_time(node->appsrc, 0); + + /* caps and first buffer are set by topic_callback from the real image + * so that caps always match the actual camera resolution/format. + * Pushing a dummy preroll with wrong caps breaks x264enc mid-stream. */ gst_object_unref(pipeline); - }else{ + return; + } else { guint i, n_streams; n_streams = gst_rtsp_media_n_streams(media); @@ -149,7 +165,7 @@ static gboolean session_cleanup(Image2rtsp *node, rclcpp::Logger logger, gboolea { char s[32]; snprintf(s, 32, (char *)"Sessions cleaned: %d", num); - RCLCPP_INFO(node->get_logger(), s); + RCLCPP_DEBUG(node->get_logger(), s); } return TRUE; } @@ -159,9 +175,11 @@ void Image2rtsp::topic_callback(const sensor_msgs::msg::Image::SharedPtr msg){ GstCaps *caps; // image properties. see return of Image2rtsp::gst_caps_new_from_image char *gst_type, *gst_format = (char *)""; if (appsrc != NULL){ + RCLCPP_DEBUG(this->get_logger(), "Received image %dx%d, encoding=%s", msg->width, msg->height, msg->encoding.c_str()); // Set caps from message caps = gst_caps_new_from_image(msg); gst_app_src_set_caps(appsrc, caps); + gst_caps_unref(caps); buf = gst_buffer_new_allocate(nullptr, msg->data.size(), nullptr); gst_buffer_fill(buf, 0, msg->data.data(), msg->data.size()); GST_BUFFER_FLAG_SET(buf, GST_BUFFER_FLAG_LIVE);