-
Notifications
You must be signed in to change notification settings - Fork 1
One RTSP server, multiple streams #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b2f372
611fe50
e77a301
e81678a
fcc89dd
a3dd9e0
51acd36
a250a9d
bff5fe3
77598fd
1e0f75b
849514f
d0883c4
9bbc418
c700b8a
653ea8e
299a5d7
bf5283f
83580ab
789e36a
9d12f0a
561a4dd
ba0f1f3
9e942a3
94723d4
58c82a9
2a94046
2916044
53cf0fd
f2ec3c1
00831d0
3386dad
6db0570
2484e5e
7417fd7
ae9741b
3383ae0
94148b4
8ebb873
1140361
8a91999
296213f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
FROM ubuntu:22.04 | ||
|
||
# Install Python and minimal GStreamer/RTSP dependencies | ||
RUN apt-get update && apt-get install -y \ | ||
python3 \ | ||
python3-pip \ | ||
gstreamer1.0-tools \ | ||
gstreamer1.0-rtsp \ | ||
gstreamer1.0-plugins-base \ | ||
gstreamer1.0-plugins-good \ | ||
gstreamer1.0-plugins-bad \ | ||
gstreamer1.0-plugins-ugly \ | ||
libgstreamer1.0-dev \ | ||
libgirepository1.0-dev \ | ||
gir1.2-gst-rtsp-server-1.0 \ | ||
gir1.2-gstreamer-1.0 \ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should probably include these in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated the readme |
||
python3-gi \ | ||
&& rm -rf /var/lib/apt/lists/* | ||
|
||
# Install Poetry | ||
RUN pip install poetry | ||
|
||
# Set working directory | ||
WORKDIR /app | ||
|
||
# Copy everything | ||
COPY . . | ||
|
||
# Install Python dependencies only | ||
RUN poetry config virtualenvs.create false && \ | ||
poetry install --extras youtube --no-root | ||
|
||
# Install the project | ||
RUN pip install . |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,6 @@ | ||
[tool.poetry] | ||
name = "framegrab" | ||
version = "0.13.2" | ||
version = "0.14.0" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The changes to RTSPServer are breaking |
||
description = "Easily grab frames from cameras or streams" | ||
authors = ["Groundlight <[email protected]>"] | ||
license = "MIT" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,55 +1,92 @@ | ||
import argparse | ||
from framegrab import FrameGrabber | ||
from framegrab.config import FileStreamFrameGrabberConfig, GenericUSBFrameGrabberConfig | ||
import logging | ||
import time | ||
from framegrab.grabber import FileStreamFrameGrabber | ||
from framegrab.config import FileStreamFrameGrabberConfig | ||
|
||
from framegrab.rtsp_server import RTSPServer | ||
|
||
import cv2 | ||
import numpy as np | ||
import time | ||
|
||
def main(): | ||
parser = argparse.ArgumentParser(description='Stream a video file via RTSP') | ||
parser.add_argument('video_path', help='Path to the video file to stream') | ||
parser.add_argument('--port', type=int, default=8554, help='RTSP server port (default: 8554)') | ||
args = parser.parse_args() | ||
logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(name)s - %(message)s') | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
class VideoToRtspSampleApp: | ||
def __init__(self, video_paths: list[str], port: int): | ||
|
||
# Connect to the grabber | ||
config = FileStreamFrameGrabberConfig(filename=args.video_path) | ||
grabber = FrameGrabber.create_grabber(config) | ||
self.video_paths = video_paths | ||
self.port = port | ||
|
||
# Determine the resolution of the video | ||
test_frame = grabber.grab() | ||
height, width, _ = test_frame.shape | ||
self.server = RTSPServer(port=port) | ||
|
||
self.grabbers = [] | ||
for n, video_path in enumerate(video_paths): | ||
# Connect to the grabber | ||
config = FileStreamFrameGrabberConfig(filename=video_path) | ||
grabber = FileStreamFrameGrabber(config) | ||
self.grabbers.append(grabber) | ||
|
||
# Determine the FPS of the video | ||
fps = grabber.get_fps() | ||
|
||
# Reset to beginning after test frame | ||
grabber.seek_to_beginning() | ||
# Determine the resolution of the video | ||
test_frame = grabber.grab() | ||
height, width, _ = test_frame.shape | ||
|
||
def get_frame_callback() -> np.ndarray: | ||
# Determine the FPS of the video | ||
fps = grabber.get_fps() | ||
|
||
# Reset to beginning after test frame so that streaming starts from the beginning of the video | ||
grabber.seek_to_beginning() | ||
|
||
callback = lambda g=grabber: self.get_frame_callback(g) | ||
mount_point = f'/stream{n}' | ||
self.server.create_stream(callback, width=width, height=height, fps=fps, mount_point=mount_point) | ||
|
||
def get_frame_callback(self, grabber: FileStreamFrameGrabber) -> np.ndarray: | ||
try: | ||
return grabber.grab() | ||
except RuntimeWarning: | ||
last_frame_read_number = grabber.get_last_frame_read_number() | ||
print(f'Got to end of file. Read {last_frame_read_number + 1} frames. Seeking back to the beginning of the video...') | ||
video_path = grabber.config.filename | ||
logger.info(f'Reached the end of {video_path}. Read {last_frame_read_number + 1} frames. Restarting from the beginning of the video...') | ||
grabber.seek_to_beginning() | ||
print(f'Returned to the beginning of the file. Continuing to read the video...') | ||
return grabber.grab() | ||
|
||
def list_rtsp_urls(self) -> list[str]: | ||
return self.server.list_rtsp_urls() | ||
|
||
def run(self) -> None: | ||
self.server.start() | ||
|
||
def stop(self) -> None: | ||
self.server.stop() | ||
|
||
for g in self.grabbers: | ||
g.release() | ||
|
||
if __name__ == "__main__": | ||
parser = argparse.ArgumentParser(description='Stream multiple video files via RTSP') | ||
parser.add_argument('video_paths', nargs='+', help='Paths to video files to stream (one or more)') | ||
parser.add_argument('--port', type=int, default=8554, help='RTSP server port') | ||
args = parser.parse_args() | ||
|
||
app = VideoToRtspSampleApp(args.video_paths, args.port) | ||
|
||
try: | ||
with RTSPServer(get_frame_callback, width=width, height=height, fps=fps, port=args.port) as server: | ||
print(server) | ||
print("Press Ctrl+C to stop...") | ||
app.run() | ||
logger.info(f'RTSP Server started on port {app.port}') | ||
|
||
rtsp_urls = app.list_rtsp_urls() | ||
for url, path in zip(rtsp_urls, app.video_paths): | ||
logger.info(f'{path} available at {url}') | ||
logger.info("Press Ctrl+C to stop...") | ||
|
||
# Keep the program running | ||
while True: | ||
time.sleep(1) | ||
|
||
while True: | ||
time.sleep(1) # Keep alive, wake up periodically to check for KeyboardInterrupt | ||
|
||
except KeyboardInterrupt: | ||
print("\nShutting down gracefully...") | ||
logger.info("Keyboard interrupt detected.") | ||
finally: | ||
grabber.release() | ||
|
||
if __name__ == "__main__": | ||
main() | ||
logger.info("Stopping RTSP server...") | ||
app.stop() | ||
logger.info(f'RTSP server stopped.') |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import argparse | ||
from framegrab import FrameGrabber | ||
from framegrab.config import RTSPFrameGrabberConfig | ||
|
||
import cv2 | ||
import numpy as np | ||
|
||
def resize_frame(frame: np.ndarray, max_width: int = None, max_height: int = None) -> np.ndarray: | ||
""" | ||
Resizes an image to fit within a given height and/or width, without changing the aspect ratio. | ||
|
||
Args: | ||
frame: Input image as numpy array | ||
max_width: Maximum width (optional) | ||
max_height: Maximum height (optional) | ||
|
||
Returns: | ||
Resized image that fits within the specified dimensions | ||
""" | ||
if max_width is None and max_height is None: | ||
return frame | ||
|
||
height, width = frame.shape[:2] | ||
|
||
# Calculate scaling factors | ||
scale_w = scale_h = 1.0 | ||
|
||
if max_width is not None and width > max_width: | ||
scale_w = max_width / width | ||
|
||
if max_height is not None and height > max_height: | ||
scale_h = max_height / height | ||
|
||
# Use the smaller scaling factor to ensure image fits within both dimensions | ||
scale = min(scale_w, scale_h) | ||
|
||
# Only resize if scaling is needed | ||
if scale < 1.0: | ||
new_width = int(width * scale) | ||
new_height = int(height * scale) | ||
return cv2.resize(frame, (new_width, new_height)) | ||
|
||
return frame | ||
|
||
def main(): | ||
parser = argparse.ArgumentParser(description='Stream RTSP video using framegrab') | ||
parser.add_argument('rtsp_url', help='RTSP URL to stream (e.g., rtsp://localhost:8554/stream_fullsize)') | ||
args = parser.parse_args() | ||
|
||
config = RTSPFrameGrabberConfig(rtsp_url=args.rtsp_url) | ||
grabber = FrameGrabber.create_grabber(config) | ||
|
||
print(f"Streaming from: {args.rtsp_url}") | ||
print("Press 'q' to quit") | ||
|
||
try: | ||
while True: | ||
frame = grabber.grab() | ||
resized_frame = resize_frame(frame, 640, 480) # get a smaller frame so it's easier to view | ||
cv2.imshow(f'Streaming {args.rtsp_url}', resized_frame) | ||
key = cv2.waitKey(30) | ||
if key == ord('q'): | ||
break | ||
except KeyboardInterrupt: | ||
print("\nStopping...") | ||
finally: | ||
grabber.release() | ||
cv2.destroyAllWindows() | ||
|
||
if __name__ == "__main__": | ||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Factoring out our system dependencies for CICD into this dockerfile seemed like a good idea, but I'm curious what others think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense to me since we have additional dependencies to install now. Good call!