Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
1b2f372
got it sort of working
timmarkhuff Oct 8, 2025
611fe50
fixed buffer issue
timmarkhuff Oct 8, 2025
e77a301
Automatically reformatting code with black and isort
Oct 8, 2025
e81678a
fixing some typos
timmarkhuff Oct 8, 2025
fcc89dd
Merge branch 'tim/one-server-multiple-streams' of github.com:groundli…
timmarkhuff Oct 8, 2025
a3dd9e0
trivial change
timmarkhuff Oct 8, 2025
51acd36
refactoring to properly handle multiple clients
timmarkhuff Oct 9, 2025
a250a9d
Automatically reformatting code with black and isort
Oct 9, 2025
bff5fe3
removing an unneeded import
timmarkhuff Oct 9, 2025
77598fd
Merge branch 'tim/one-server-multiple-streams' of github.com:groundli…
timmarkhuff Oct 9, 2025
1e0f75b
got a new approach working
timmarkhuff Oct 9, 2025
849514f
Automatically reformatting code with black and isort
Oct 9, 2025
d0883c4
got it working better
timmarkhuff Oct 9, 2025
9bbc418
resolving merge conflicts
timmarkhuff Oct 9, 2025
c700b8a
Automatically reformatting code with black and isort
Oct 9, 2025
653ea8e
cleaning up some scripts
timmarkhuff Oct 9, 2025
299a5d7
Merge branch 'tim/one-server-multiple-streams' of github.com:groundli…
timmarkhuff Oct 10, 2025
bf5283f
refactoring
timmarkhuff Oct 10, 2025
83580ab
Automatically reformatting code with black and isort
Oct 10, 2025
789e36a
removing an unneeded import
timmarkhuff Oct 10, 2025
9d12f0a
removing unneeded import
timmarkhuff Oct 10, 2025
561a4dd
removing unneeded import
timmarkhuff Oct 10, 2025
ba0f1f3
removing unnecessary code
timmarkhuff Oct 10, 2025
9e942a3
simplying the sample script
timmarkhuff Oct 10, 2025
94723d4
renaming class and removing unnecessary class attribute
timmarkhuff Oct 10, 2025
58c82a9
fixing typo
timmarkhuff Oct 10, 2025
2a94046
adding a test
timmarkhuff Oct 10, 2025
2916044
trying to fix test
timmarkhuff Oct 10, 2025
53cf0fd
fixing dockerfile
timmarkhuff Oct 10, 2025
f2ec3c1
fixing dockerfile again
timmarkhuff Oct 10, 2025
00831d0
fixing dockerfile again
timmarkhuff Oct 10, 2025
3386dad
fixing dockerfile again
timmarkhuff Oct 10, 2025
6db0570
fixing dockerfile again
timmarkhuff Oct 10, 2025
2484e5e
fixing dockerfile again
timmarkhuff Oct 10, 2025
7417fd7
fixing CICD
timmarkhuff Oct 10, 2025
ae9741b
fixing CICD
timmarkhuff Oct 10, 2025
3383ae0
fixed the test
timmarkhuff Oct 10, 2025
94148b4
Automatically reformatting code with black and isort
Oct 10, 2025
8ebb873
bumping version
timmarkhuff Oct 10, 2025
1140361
cleaning up cicd
timmarkhuff Oct 10, 2025
8a91999
update readme
timmarkhuff Oct 13, 2025
296213f
fix typo
timmarkhuff Oct 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Display Python version
run: python -c "import sys; print(sys.version)"
- name: install poetry and build poetry environment
run: |
pip install -U pip
pip install poetry
poetry install --extras youtube
- name: run tests
run: poetry run pytest
- name: Build Docker image with dependencies
run: docker build -f docker/Dockerfile -t framegrab-test .
- name: Run tests in Docker
run: docker run --rm framegrab-test poetry run pytest
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,40 @@ if m.motion_detected(frame):
print("Motion detected!")
```

### RTSP Server
Framegrab provides tools for RTSP stream generation, which can be useful for testing applications.

Basic usage looks like this:
```
server = RTSPServer(port=port)
server.create_stream(get_frame_callback1, width, height, fps, mount_point='/stream0')
server.create_stream(get_frame_callback2, width, height, fps, mount_point='/stream1')
server.start()
time.sleep(n) # keep the server up
server.stop()
```

Using these tools requires a number of system dependencies, which are listed below:

```
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
```
We test RTSP server functionality on Ubuntu. It may also work on Mac. It will _not_ work on Windows natively, but you may be able to get it to work with Docker or WSL.

We provide a [Dockerfile](docker/Dockerfile) that contains the necessary packages.

For inspiration on how to implement an RTSP server, see [sample_scripts/video_to_rtsp.py](sample_scripts/video_to_rtsp.py), which shows can you can convert multiple videos into RTSP streams with a single RTSP server.


## Examples

### Generic USB
Expand Down
34 changes: 34 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
FROM ubuntu:22.04
Copy link
Collaborator Author

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.

Copy link
Member

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!


# 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 \
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably include these in the README.md

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 .
2 changes: 1 addition & 1 deletion pyproject.toml
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"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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"
Expand Down
105 changes: 71 additions & 34 deletions sample_scripts/video_to_rtsp.py
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.')
71 changes: 71 additions & 0 deletions sample_scripts/view_rtsp_stream.py
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()
3 changes: 2 additions & 1 deletion src/framegrab/grabber.py
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,7 @@ def _initialize_grabber_implementation(self):
raise ValueError(f"Could not read first frame of file {self.config.filename}. Is it a valid video file?")

# Reset frame position back to the first frame after validation
self.capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
self.seek_to_beginning()

self.fps_source = round(self.capture.get(cv2.CAP_PROP_FPS), 2)
if self.fps_source <= 0.1:
Expand Down Expand Up @@ -1444,6 +1444,7 @@ def seek_to_frame(self, frame_number: int) -> None:
frame_number: Frame number to seek to (0-based)
"""
if frame_number < 0:
# OpenCV fails silently when you try this, so we raise an exception
raise ValueError(f"Frame number must be non-negative, got {frame_number}")

self.capture.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
Expand Down
Loading
Loading