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);