Skip to content

Commit 82df717

Browse files
authored
feat/h264-decoder (#4)
* feat(python_utils): Add H264Decoder class for handling H.264 video streams * test(h264-decoder): add unit test for H264Decoder * docs: add prerequisites for H264Decoder * Update README.md * fix(python_utils): H264Decoder now only stores last 3 frames in buffer * ci(industrial-ci): add script for installing dependencies not supported by rosdep * fix(scripts): ensure latest Meson version for PyCairo installation in ci_install_dependencies * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat(code-coverage): add ci_install_dependencies script to this workflow * docs(H264Decoder): update install guide --------- Co-authored-by: Andreas Kluge Svendsrud <[email protected]>
1 parent 24357ef commit 82df717

File tree

7 files changed

+180
-0
lines changed

7 files changed

+180
-0
lines changed

.github/workflows/code-coverage.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ on:
1010
jobs:
1111
call_reusable_workflow:
1212
uses: vortexntnu/vortex-ci/.github/workflows/reusable-code-coverage.yml@main
13+
with:
14+
before_install_target_dependencies: 'scripts/ci_install_dependencies.sh'
1315
secrets:
1416
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

.github/workflows/industrial-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ jobs:
1111
uses: vortexntnu/vortex-ci/.github/workflows/reusable-industrial-ci.yml@main
1212
with:
1313
ros_repo: '["main", "testing"]'
14+
before_install_target_dependencies: 'scripts/ci_install_dependencies.sh'

scripts/ci_install_dependencies.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
3+
# Script to install dependencies for H264Decoder
4+
# This script installs GStreamer and PyGObject dependencies required for running the tests
5+
6+
set -e # Exit on error
7+
8+
### GStreamer Installation ###
9+
echo "Installing GStreamer and related plugins..."
10+
sudo apt update
11+
sudo apt install -y gstreamer1.0-tools gstreamer1.0-plugins-base \
12+
gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
13+
gstreamer1.0-plugins-ugly gstreamer1.0-libav python3-gi \
14+
python3-gst-1.0
15+
16+
echo "GStreamer installation completed."
17+
18+
echo "If you experience display-related issues with the GUI, try running:"
19+
echo "export QT_QPA_PLATFORM=xcb"
20+
21+
### PyGObject Installation ###
22+
echo "Installing PyGObject dependencies..."
23+
sudo apt install -y libglib2.0-dev libcairo2-dev libgirepository1.0-dev \
24+
gir1.2-gtk-3.0 python3-dev ninja-build
25+
26+
echo "Ensuring latest Meson version is installed..."
27+
pip install --upgrade meson
28+
29+
echo "Installing PyGObject via pip..."
30+
pip install pycairo --no-cache-dir
31+
pip install pygobject --no-cache-dir
32+
33+
echo "Installation of all dependencies completed successfully."

tests/resources/test_video.h264

32.1 KB
Binary file not shown.

tests/test_utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import threading
2+
13
import numpy as np
24
import pytest
35
from geometry_msgs.msg import Pose, Twist
46

57
from vortex_utils.python_utils import (
8+
H264Decoder,
69
PoseData,
710
State,
811
TwistData,
@@ -211,6 +214,35 @@ def test_state_subtraction_twist():
211214
assert (state1 - state2).twist == TwistData(0.9, 1.8, 2.7, 0, 0, 0)
212215

213216

217+
def test_h264_decoder():
218+
test_file = "tests/resources/test_video.h264"
219+
220+
decoder = H264Decoder()
221+
222+
decoding_thread = threading.Thread(target=decoder.start, daemon=True)
223+
decoding_thread.start()
224+
225+
with open(test_file, "rb") as f:
226+
raw_data = f.read()
227+
228+
chunk_size = 64
229+
for i in range(0, len(raw_data), chunk_size):
230+
chunk = raw_data[i : i + chunk_size]
231+
decoder.push_data(chunk)
232+
233+
decoder.appsrc.emit("end-of-stream")
234+
235+
decoding_thread.join(timeout=5.0)
236+
237+
assert len(decoder.decoded_frames) > 0, (
238+
"No frames were decoded from the H.264 stream."
239+
)
240+
241+
frame = decoder.decoded_frames[0]
242+
assert isinstance(frame, np.ndarray), "Decoded frame is not a numpy array."
243+
assert frame.ndim == 3, f"Expected 3D array (H, W, Channels), got {frame.shape}"
244+
245+
214246
def test_pose_from_ros():
215247
pose_msg = Pose()
216248
pose_msg.position.x = 1.0

vortex_utils/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# H264Decoder
2+
Install the dependencies by running the following script: [ci_install_dependencies.sh](/scripts/ci_install_dependencies.sh)

vortex_utils/python_utils.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
from dataclasses import dataclass
22

3+
import gi
34
import numpy as np
45
from scipy.spatial.transform import Rotation
56

7+
gi.require_version('Gst', '1.0')
8+
gi.require_version('GstApp', '1.0')
9+
from gi.repository import GLib, Gst
10+
611

712
def ssa(angle: float) -> float:
813
return (angle + np.pi) % (2 * np.pi) - np.pi
@@ -120,3 +125,108 @@ def __add__(self, other: "State") -> "State":
120125

121126
def __sub__(self, other: "State") -> "State":
122127
return State(pose=self.pose - other.pose, twist=self.twist - other.twist)
128+
129+
130+
class H264Decoder:
131+
"""Decodes H.264 streams using GStreamer."""
132+
133+
_gst_initialized = False
134+
135+
def __init__(self):
136+
"""Initializes the H.264 decoder and sets up the GStreamer pipeline."""
137+
# Ensure GStreamer is initialized only once
138+
if not H264Decoder._gst_initialized:
139+
Gst.init(None)
140+
H264Decoder._gst_initialized = True
141+
142+
pipeline_desc = (
143+
"appsrc name=mysrc is-live=true ! "
144+
"h264parse ! "
145+
"avdec_h264 ! "
146+
"videoconvert ! video/x-raw,format=BGR ! "
147+
"appsink name=appsink"
148+
)
149+
150+
self._pipeline = Gst.parse_launch(pipeline_desc)
151+
self.appsrc = self._pipeline.get_by_name("mysrc")
152+
self._appsink = self._pipeline.get_by_name("appsink")
153+
154+
self._appsink.set_property("emit-signals", True)
155+
self._appsink.set_property("sync", False)
156+
self._appsink.connect("new-sample", self._on_new_sample)
157+
158+
self._bus = self._pipeline.get_bus()
159+
self._bus.add_signal_watch()
160+
self._bus.connect("message", self._on_bus_message)
161+
162+
self._main_loop = None
163+
164+
self.decoded_frames = []
165+
self.max_frames = 3 # Keep only the last 3 frames here
166+
167+
def start(self):
168+
"""Starts the GStreamer pipeline and runs the main event loop."""
169+
self._pipeline.set_state(Gst.State.PLAYING)
170+
self._main_loop = GLib.MainLoop()
171+
try:
172+
self._main_loop.run()
173+
except KeyboardInterrupt:
174+
pass
175+
finally:
176+
self.stop()
177+
178+
def stop(self):
179+
"""Stops the GStreamer pipeline and cleans up resources."""
180+
if self._pipeline:
181+
self._pipeline.set_state(Gst.State.NULL)
182+
if self._main_loop is not None:
183+
self._main_loop.quit()
184+
self._main_loop = None
185+
186+
def push_data(self, data: bytes):
187+
"""Pushes H.264 encoded data into the pipeline for decoding."""
188+
if not self.appsrc:
189+
raise RuntimeError(
190+
"The pipeline's appsrc element was not found or initialized."
191+
)
192+
gst_buffer = Gst.Buffer.new_allocate(None, len(data), None)
193+
gst_buffer.fill(0, data)
194+
self.appsrc.emit("push-buffer", gst_buffer)
195+
196+
def _on_bus_message(self, bus, message):
197+
"""Handles messages from the GStreamer bus."""
198+
msg_type = message.type
199+
if msg_type == Gst.MessageType.ERROR:
200+
err, debug = message.parse_error()
201+
print(f"GStreamer ERROR: {err}, debug={debug}")
202+
self.stop()
203+
elif msg_type == Gst.MessageType.EOS:
204+
print("End-Of-Stream reached.")
205+
self.stop()
206+
207+
def _on_new_sample(self, sink):
208+
"""Processes a new decoded video frame from the appsink."""
209+
sample = sink.emit("pull-sample")
210+
if not sample:
211+
return Gst.FlowReturn.ERROR
212+
213+
buf = sample.get_buffer()
214+
caps_format = sample.get_caps().get_structure(0)
215+
width = caps_format.get_value("width")
216+
height = caps_format.get_value("height")
217+
218+
success, map_info = buf.map(Gst.MapFlags.READ)
219+
if not success:
220+
return Gst.FlowReturn.ERROR
221+
222+
frame_data = np.frombuffer(map_info.data, dtype=np.uint8)
223+
channels = len(frame_data) // (width * height) # typically 3 (BGR) or 4 (BGRA)
224+
frame_data_reshaped = frame_data.reshape((height, width, channels))
225+
226+
self.decoded_frames.append(frame_data_reshaped.copy())
227+
228+
if len(self.decoded_frames) > self.max_frames:
229+
self.decoded_frames.pop(0)
230+
231+
buf.unmap(map_info)
232+
return Gst.FlowReturn.OK

0 commit comments

Comments
 (0)