Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@
- Expanded Training Workflows section in `docs/index.md` with 10 educational workflows including RGB/RYB color matching, titration, yeast growth optimization, vision-enabled 3D printing optimization, microscopy image stitching, and AprilTag robot path planning.
- Research Workflows section in `docs/index.md` documenting alkaline catalysis lifecycle testing and battery slurry viscosity optimization.
- Direct links from unit operations and workflows to relevant code locations in the repository for easier navigation.
- Resolution setting in `my_secrets_example.py` for YouTube-compatible streaming (144p, 240p, 360p, 480p, 720p, 1080p).
- Camera rotation setting (0, 90, 180, 270 degrees) for portrait mode streaming in `my_secrets_example.py`.
- Frame rate setting in `my_secrets_example.py` for adjustable stream frame rate.
- Timestamp overlay setting in `my_secrets_example.py` to display date/time with seconds on video stream.
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

The changelog entry mentions "display date/time with seconds" but the actual format is YYYY-MM-DD_HH-MM-SS, which uses dashes instead of colons for the time portion. This is a technical detail but could be misleading.

Consider updating to be more precise: "Timestamp overlay setting in my_secrets_example.py to display date/time (format: YYYY-MM-DD_HH-MM-SS) on video stream."

Suggested change
- Timestamp overlay setting in `my_secrets_example.py` to display date/time with seconds on video stream.
- Timestamp overlay setting in `my_secrets_example.py` to display date/time (format: YYYY-MM-DD_HH-MM-SS) on video stream.

Copilot uses AI. Check for mistakes.

### Fixed
- Ctrl+C interrupt handling in `src/ac_training_lab/picam/device.py` now properly exits the streaming loop instead of restarting.
- Fixed typo "reagants" → "reagents" in Conductivity workflow description.

### Changed
- Removed CLI/argparse code from `device.py`; resolution and rotation are now configured via `my_secrets.py`.

## [1.1.0] - 2024-06-11
### Added
- Imperial (10-32 thread) alternative design to SEM door automation bill of materials in `docs/sem-door-automation-components.md`.
Expand Down
158 changes: 126 additions & 32 deletions src/ac_training_lab/picam/device.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import argparse
import json
import subprocess
import shutil
Expand All @@ -7,12 +6,26 @@
from my_secrets import (
CAM_NAME,
CAMERA_HFLIP,
CAMERA_ROTATION,
CAMERA_VFLIP,
FRAME_RATE,
LAMBDA_FUNCTION_URL,
PRIVACY_STATUS,
RESOLUTION,
TIMESTAMP_OVERLAY,
WORKFLOW_NAME,
)

# Resolution mappings for YouTube-compatible resolutions
RESOLUTION_MAP = {
"144p": (256, 144),
"240p": (426, 240),
"360p": (640, 360),
"480p": (854, 480),
"720p": (1280, 720),
"1080p": (1920, 1080),
}


def get_camera_command():
"""
Expand All @@ -28,30 +41,49 @@ def get_camera_command():
)


def start_stream(ffmpeg_url, width=854, height=480):
def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, timestamp_overlay=False):
"""
Starts the libcamera -> ffmpeg pipeline and returns two Popen objects:
p1: camera process (rpicam-vid or libcamera-vid)
p2: ffmpeg process

Args:
ffmpeg_url: RTMP URL for streaming
width: Output width in pixels (final output after rotation)
height: Output height in pixels (final output after rotation)
rotation: Rotation angle (0, 90, 180, 270 degrees clockwise)
framerate: Frame rate in fps
timestamp_overlay: Whether to show timestamp on video
"""
# Get the available camera command
camera_cmd = get_camera_command()

# Camera always captures in landscape orientation using the full sensor.
# For portrait output (90/270 rotation), we capture landscape and rotate in ffmpeg.
# This preserves the full field of view instead of cropping.
#
# For 90/270 rotation: capture at height x width (landscape), rotate to width x height (portrait)
# For 0/180 rotation: capture at width x height (landscape), output same orientation
if rotation in (90, 270):
# For portrait output, capture in landscape (swap dimensions for camera)
# Camera captures height x width, then ffmpeg rotates to width x height
cam_width, cam_height = height, width
else:
cam_width, cam_height = width, height

# First: camera command with core parameters
libcamera_cmd = [
camera_cmd,
"--inline",
"--nopreview",
"-t",
"0",
"--mode",
"1536:864", # A known 16:9 sensor mode
"--width",
str(width), # Scale width
str(cam_width), # Scale width
"--height",
str(height), # Scale height
str(cam_height), # Scale height
"--framerate",
"15", # Frame rate
str(framerate), # Frame rate
"--codec",
"h264", # H.264 encoding
"--bitrate",
Expand All @@ -67,6 +99,37 @@ def start_stream(ffmpeg_url, width=854, height=480):
# Add output parameters last
libcamera_cmd.extend(["-o", "-"]) # Output to stdout (pipe)

# Build video filter chain for ffmpeg
video_filters = []

# Add rotation filter if needed
if rotation == 90:
video_filters.append("transpose=1") # 90 degrees clockwise
elif rotation == 180:
video_filters.append("hflip,vflip") # 180 degrees
elif rotation == 270:
video_filters.append("transpose=2") # 90 degrees counter-clockwise (270 clockwise)

# Add timestamp overlay if enabled
# Format: YYYY-MM-DD_HH-MM-SS (updates every second)
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

The comment mentions "Format: YYYY-MM-DD_HH-MM-SS (updates every second)" but doesn't explain what "updates every second" means in the context of ffmpeg's localtime function. This could be clearer for maintainers.

Consider clarifying: "Format: YYYY-MM-DD_HH-MM-SS (ffmpeg's localtime evaluates per-frame, effectively updating once per second at typical framerates)"

Suggested change
# Format: YYYY-MM-DD_HH-MM-SS (updates every second)
# Format: YYYY-MM-DD_HH-MM-SS (ffmpeg's localtime evaluates per-frame, so the overlay updates once per second at typical framerates)

Copilot uses AI. Check for mistakes.
if timestamp_overlay:
# drawtext filter with white text, black background box, in top-left corner
# fontsize scales with video height for consistent appearance
fontsize = max(16, height // 20)
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

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

The timestamp filter's fontsize calculation uses the original height parameter, but when rotation is 90 or 270 degrees, this refers to the pre-rotation dimension. After rotation, the actual video height will be different. This could result in incorrectly sized text.

For portrait mode (90/270 rotation), the actual video height after rotation will be width, so the fontsize should be calculated based on the final rotated dimensions, not the input parameters.

Consider calculating fontsize based on the actual output dimensions:

# Calculate fontsize based on actual output dimensions after rotation
if rotation in (90, 270):
    actual_height = width  # After rotation, width becomes height
else:
    actual_height = height
fontsize = max(16, actual_height // 20)
Suggested change
fontsize = max(16, height // 20)
if rotation in (90, 270):
actual_height = width
else:
actual_height = height
fontsize = max(16, actual_height // 20)

Copilot uses AI. Check for mistakes.
# Note: In ffmpeg drawtext filter, special characters need escaping:
# - The format separator after localtime uses \:
# - Colons in the time display (H:M:S) also need escaping
# - Using dashes instead of colons in time to avoid complex escaping issues
timestamp_filter = (
f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
f":fontsize={fontsize}"
f":fontcolor=white"
f":box=1:boxcolor=black@0.5:boxborderw=5"
f":x=10:y=10"
f":text='%{{localtime\\:%Y-%m-%d_%H-%M-%S}}'"
)
video_filters.append(timestamp_filter)

# Second: ffmpeg command
ffmpeg_cmd = [
"ffmpeg",
Expand All @@ -83,23 +146,37 @@ def start_stream(ffmpeg_url, width=854, height=480):
# Read H.264 video from pipe
"-i",
"pipe:0",
# Copy the H.264 video directly
"-c:v",
"copy",
]

# Add video filter and encoding settings
# Note: When filters are applied, libx264 encoding is required which increases
# CPU usage compared to the original H.264 passthrough. This is unavoidable
# since ffmpeg cannot apply filters without re-encoding the video stream.
if video_filters:
filter_chain = ",".join(video_filters)
ffmpeg_cmd.extend(["-vf", filter_chain, "-c:v", "libx264", "-preset", "ultrafast"])
else:
ffmpeg_cmd.extend(["-c:v", "copy"])

ffmpeg_cmd.extend([
# Encode audio as AAC
"-c:a",
"aac",
"-b:a",
"128k",
"-preset",
"fast",
"-strict",
"experimental",
# Fix non-monotonous DTS warnings by enabling audio synchronization
# and constant frame rate video output
"-async",
"1",
"-vsync",
"cfr",
# Output format is FLV, then final RTMP URL
"-f",
"flv",
ffmpeg_url,
]
])

# Start camera process, capturing its output in a pipe
p1 = subprocess.Popen(
Expand Down Expand Up @@ -151,24 +228,41 @@ def call_lambda(action, CAM_NAME, WORKFLOW_NAME, privacy_status="private"):


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Stream camera feed via Lambda")
parser.add_argument(
"--resolution",
type=str,
default="854x480",
help="Camera resolution as WIDTHxHEIGHT (default: 854x480)"
)
args = parser.parse_args()

# Parse resolution
try:
width, height = map(int, args.resolution.split('x'))
except ValueError:
print(f"Invalid resolution format: {args.resolution}. Use WIDTHxHEIGHT format.")
exit(1)

print(f"Using resolution: {width}x{height}")

# Validate and get resolution
if RESOLUTION not in RESOLUTION_MAP:
raise ValueError(
f"Invalid RESOLUTION '{RESOLUTION}'. "
f"Allowed options: {list(RESOLUTION_MAP.keys())}"
)
width, height = RESOLUTION_MAP[RESOLUTION]

# Validate rotation
if CAMERA_ROTATION not in (0, 90, 180, 270):
raise ValueError(
f"Invalid CAMERA_ROTATION '{CAMERA_ROTATION}'. "
f"Allowed options: 0, 90, 180, 270"
)

# Validate frame rate
if not isinstance(FRAME_RATE, int) or FRAME_RATE <= 0:
raise ValueError(
f"Invalid FRAME_RATE '{FRAME_RATE}'. "
f"Must be a positive integer (e.g., 15, 24, 30)"
)

# For 90/270 rotation, output is portrait (swapped dimensions)
if CAMERA_ROTATION in (90, 270):
output_width, output_height = height, width
orientation = "portrait"
else:
output_width, output_height = width, height
orientation = "landscape"

print(f"Using resolution: {RESOLUTION} ({output_width}x{output_height} {orientation})")
print(f"Using rotation: {CAMERA_ROTATION} degrees")
print(f"Using frame rate: {FRAME_RATE} fps")
print(f"Timestamp overlay: {'enabled' if TIMESTAMP_OVERLAY else 'disabled'}")

# End previous broadcast and start a new one via Lambda
call_lambda("end", CAM_NAME, WORKFLOW_NAME)
raw_body = call_lambda(
Expand All @@ -186,7 +280,7 @@ def call_lambda(action, CAM_NAME, WORKFLOW_NAME, privacy_status="private"):

while True:
print("Starting stream..")
p1, p2 = start_stream(ffmpeg_url, width, height)
p1, p2 = start_stream(ffmpeg_url, width, height, CAMERA_ROTATION, FRAME_RATE, TIMESTAMP_OVERLAY)
print("Stream started")
interrupted = False
try:
Expand Down
35 changes: 35 additions & 0 deletions src/ac_training_lab/picam/my_secrets_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,38 @@
CAMERA_VFLIP = True
# Set to True to flip the camera image horizontally (mirror image)
CAMERA_HFLIP = True

# Camera rotation setting (for portrait mode streaming)
# Allowed options: 0, 90, 180, 270 (degrees, clockwise)
# Default: 0 (no rotation / landscape mode)
# Use 90 or 270 for portrait mode streaming
CAMERA_ROTATION = 0

# Stream resolution setting
# Allowed options for YouTube: "144p", "240p", "360p", "480p", "720p", "1080p"
# Resolution mappings:
# "144p" = 256x144
# "240p" = 426x240
# "360p" = 640x360
# "480p" = 854x480
# "720p" = 1280x720
# "1080p" = 1920x1080
# Default: "480p"
# Note: Pi Zero 2W can comfortably handle 480p at 15fps. 720p at 15fps is pushing it.
# For 1080p or higher frame rates, use a Pi 4B or Pi 5.
RESOLUTION = "480p"

# Stream frame rate setting (frames per second)
# Common values: 15, 24, 30
# Default: 15
# Note: Pi Zero 2W can comfortably handle 15fps at 480p.
# For higher frame rates or resolutions, use a Pi 4B or Pi 5.
FRAME_RATE = 15

# Timestamp overlay setting
# Set to True to display current date/time on the video stream
# Format: YYYY-MM-DD_HH-MM-SS (e.g., 2024-12-01_21-38-37)
# The timestamp appears in the top-left corner (white text with black background)
# Note: Enabling timestamp requires video re-encoding which increases CPU usage.
# Default: False
TIMESTAMP_OVERLAY = False