diff --git a/CHANGELOG.md b/CHANGELOG.md index db43d22d..74cbad4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### 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`. diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 19995805..d667e074 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -1,4 +1,3 @@ -import argparse import json import subprocess import shutil @@ -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(): """ @@ -28,15 +41,36 @@ 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, @@ -44,14 +78,12 @@ def start_stream(ffmpeg_url, width=854, height=480): "--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", @@ -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) + 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) + # 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", @@ -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( @@ -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( @@ -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: diff --git a/src/ac_training_lab/picam/my_secrets_example.py b/src/ac_training_lab/picam/my_secrets_example.py index c5b8d645..0bbe541c 100644 --- a/src/ac_training_lab/picam/my_secrets_example.py +++ b/src/ac_training_lab/picam/my_secrets_example.py @@ -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