From 7886c26d6ce800d6d6a4b72fd9948c2b2f5ce142 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:43:08 +0000 Subject: [PATCH 01/11] Initial plan From 225738bab0af30e8240336132d407904ba88efc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:49:11 +0000 Subject: [PATCH 02/11] Move resolution and rotation settings to my_secrets.py, remove CLI code Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- CHANGELOG.md | 5 ++ src/ac_training_lab/picam/device.py | 90 +++++++++++++------ .../picam/my_secrets_example.py | 16 ++++ 3 files changed, 84 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db43d22d..f95d75fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,16 @@ - 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 (240p, 360p, 480p, 720p). +- Camera rotation setting (0, 90, 180, 270 degrees) for portrait mode streaming in `my_secrets_example.py`. ### 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..44a1af79 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,22 @@ from my_secrets import ( CAM_NAME, CAMERA_HFLIP, + CAMERA_ROTATION, CAMERA_VFLIP, LAMBDA_FUNCTION_URL, PRIVACY_STATUS, + RESOLUTION, WORKFLOW_NAME, ) +# Resolution mappings for YouTube-compatible resolutions +RESOLUTION_MAP = { + "240p": (426, 240), + "360p": (640, 360), + "480p": (854, 480), + "720p": (1280, 720), +} + def get_camera_command(): """ @@ -28,15 +37,27 @@ def get_camera_command(): ) -def start_stream(ffmpeg_url, width=854, height=480): +def start_stream(ffmpeg_url, width=854, height=480, rotation=0): """ 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 + height: Output height in pixels + rotation: Rotation angle (0, 90, 180, 270 degrees clockwise) """ # Get the available camera command camera_cmd = get_camera_command() + # For 90 or 270 degree rotation, swap width/height for camera capture + # since rotation happens in ffmpeg + cam_width, cam_height = width, height + if rotation in (90, 270): + cam_width, cam_height = height, width + # First: camera command with core parameters libcamera_cmd = [ camera_cmd, @@ -47,9 +68,9 @@ def start_stream(ffmpeg_url, width=854, height=480): "--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 "--codec", @@ -67,6 +88,15 @@ def start_stream(ffmpeg_url, width=854, height=480): # Add output parameters last libcamera_cmd.extend(["-o", "-"]) # Output to stdout (pipe) + # Build rotation filter for ffmpeg if needed + rotation_filter = None + if rotation == 90: + rotation_filter = "transpose=1" # 90 degrees clockwise + elif rotation == 180: + rotation_filter = "transpose=1,transpose=1" # 180 degrees + elif rotation == 270: + rotation_filter = "transpose=2" # 90 degrees counter-clockwise (270 clockwise) + # Second: ffmpeg command ffmpeg_cmd = [ "ffmpeg", @@ -83,9 +113,15 @@ 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 for rotation if needed, otherwise copy video directly + if rotation_filter: + ffmpeg_cmd.extend(["-vf", rotation_filter, "-c:v", "libx264"]) + else: + ffmpeg_cmd.extend(["-c:v", "copy"]) + + ffmpeg_cmd.extend([ # Encode audio as AAC "-c:a", "aac", @@ -99,7 +135,7 @@ def start_stream(ffmpeg_url, width=854, height=480): "-f", "flv", ffmpeg_url, - ] + ]) # Start camera process, capturing its output in a pipe p1 = subprocess.Popen( @@ -151,24 +187,24 @@ 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" + ) + + print(f"Using resolution: {RESOLUTION} ({width}x{height})") + print(f"Using rotation: {CAMERA_ROTATION} degrees") + # End previous broadcast and start a new one via Lambda call_lambda("end", CAM_NAME, WORKFLOW_NAME) raw_body = call_lambda( @@ -186,7 +222,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) 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..d21d9414 100644 --- a/src/ac_training_lab/picam/my_secrets_example.py +++ b/src/ac_training_lab/picam/my_secrets_example.py @@ -30,3 +30,19 @@ 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: "240p", "360p", "480p", "720p" +# Resolution mappings: +# "240p" = 426x240 +# "360p" = 640x360 +# "480p" = 854x480 +# "720p" = 1280x720 +# Default: "480p" +RESOLUTION = "480p" From e4fc134bcc0ae94fd874cd264dd3cf5def3e1017 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:50:27 +0000 Subject: [PATCH 03/11] Address code review feedback for rotation implementation Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 44a1af79..83fa06f7 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -52,8 +52,9 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0): # Get the available camera command camera_cmd = get_camera_command() - # For 90 or 270 degree rotation, swap width/height for camera capture - # since rotation happens in ffmpeg + # For 90 or 270 degree rotation, swap width/height for camera capture. + # The camera captures with swapped dimensions so that after ffmpeg rotation, + # the final output has the intended width x height (e.g., 480x854 for portrait). cam_width, cam_height = width, height if rotation in (90, 270): cam_width, cam_height = height, width @@ -93,7 +94,7 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0): if rotation == 90: rotation_filter = "transpose=1" # 90 degrees clockwise elif rotation == 180: - rotation_filter = "transpose=1,transpose=1" # 180 degrees + rotation_filter = "hflip,vflip" # 180 degrees (more efficient than double transpose) elif rotation == 270: rotation_filter = "transpose=2" # 90 degrees counter-clockwise (270 clockwise) @@ -116,6 +117,9 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0): ] # Add video filter for rotation if needed, otherwise copy video directly + # Note: When rotation is 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 rotation_filter: ffmpeg_cmd.extend(["-vf", rotation_filter, "-c:v", "libx264"]) else: From 0ad81a247dfaab311682186d3e7e90723c96009d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:14:51 +0000 Subject: [PATCH 04/11] Add 144p/1080p resolutions, FRAME_RATE setting, and Pi hardware notes Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- CHANGELOG.md | 3 ++- src/ac_training_lab/picam/device.py | 18 +++++++++++++++--- .../picam/my_secrets_example.py | 13 ++++++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f95d75fa..0ccf0718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ - 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 (240p, 360p, 480p, 720p). +- 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. ### Fixed - Ctrl+C interrupt handling in `src/ac_training_lab/picam/device.py` now properly exits the streaming loop instead of restarting. diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 83fa06f7..8d01a76b 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -8,6 +8,7 @@ CAMERA_HFLIP, CAMERA_ROTATION, CAMERA_VFLIP, + FRAME_RATE, LAMBDA_FUNCTION_URL, PRIVACY_STATUS, RESOLUTION, @@ -16,10 +17,12 @@ # 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), } @@ -37,7 +40,7 @@ def get_camera_command(): ) -def start_stream(ffmpeg_url, width=854, height=480, rotation=0): +def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15): """ Starts the libcamera -> ffmpeg pipeline and returns two Popen objects: p1: camera process (rpicam-vid or libcamera-vid) @@ -48,6 +51,7 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0): width: Output width in pixels height: Output height in pixels rotation: Rotation angle (0, 90, 180, 270 degrees clockwise) + framerate: Frame rate in fps """ # Get the available camera command camera_cmd = get_camera_command() @@ -73,7 +77,7 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0): "--height", str(cam_height), # Scale height "--framerate", - "15", # Frame rate + str(framerate), # Frame rate "--codec", "h264", # H.264 encoding "--bitrate", @@ -206,8 +210,16 @@ def call_lambda(action, CAM_NAME, WORKFLOW_NAME, privacy_status="private"): 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)" + ) + print(f"Using resolution: {RESOLUTION} ({width}x{height})") print(f"Using rotation: {CAMERA_ROTATION} degrees") + print(f"Using frame rate: {FRAME_RATE} fps") # End previous broadcast and start a new one via Lambda call_lambda("end", CAM_NAME, WORKFLOW_NAME) @@ -226,7 +238,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, CAMERA_ROTATION) + p1, p2 = start_stream(ffmpeg_url, width, height, CAMERA_ROTATION, FRAME_RATE) 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 d21d9414..8e165999 100644 --- a/src/ac_training_lab/picam/my_secrets_example.py +++ b/src/ac_training_lab/picam/my_secrets_example.py @@ -38,11 +38,22 @@ CAMERA_ROTATION = 0 # Stream resolution setting -# Allowed options for YouTube: "240p", "360p", "480p", "720p" +# 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 From c2a71bd8dc521e0dc2ee6b4710fb41d612715057 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:23:46 +0000 Subject: [PATCH 05/11] Add timestamp overlay setting and improve rotation/filter handling Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- CHANGELOG.md | 1 + src/ac_training_lab/picam/device.py | 54 +++++++++++++------ .../picam/my_secrets_example.py | 7 +++ 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ccf0718..74cbad4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - 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. diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 8d01a76b..967d88f4 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -12,6 +12,7 @@ LAMBDA_FUNCTION_URL, PRIVACY_STATUS, RESOLUTION, + TIMESTAMP_OVERLAY, WORKFLOW_NAME, ) @@ -40,7 +41,7 @@ def get_camera_command(): ) -def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15): +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) @@ -52,16 +53,18 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15): height: Output height in pixels 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() - # For 90 or 270 degree rotation, swap width/height for camera capture. - # The camera captures with swapped dimensions so that after ffmpeg rotation, - # the final output has the intended width x height (e.g., 480x854 for portrait). - cam_width, cam_height = width, height + # For 90 or 270 degree rotation, the camera captures in landscape orientation + # (swapped dimensions), then ffmpeg rotates to get the final portrait output. + # E.g., for 480x854 portrait output, camera captures 854x480, then rotates. if rotation in (90, 270): cam_width, cam_height = height, width + else: + cam_width, cam_height = width, height # First: camera command with core parameters libcamera_cmd = [ @@ -93,14 +96,31 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15): # Add output parameters last libcamera_cmd.extend(["-o", "-"]) # Output to stdout (pipe) - # Build rotation filter for ffmpeg if needed - rotation_filter = None + # Build video filter chain for ffmpeg + video_filters = [] + + # Add rotation filter if needed if rotation == 90: - rotation_filter = "transpose=1" # 90 degrees clockwise + video_filters.append("transpose=1") # 90 degrees clockwise elif rotation == 180: - rotation_filter = "hflip,vflip" # 180 degrees (more efficient than double transpose) + video_filters.append("hflip,vflip") # 180 degrees elif rotation == 270: - rotation_filter = "transpose=2" # 90 degrees counter-clockwise (270 clockwise) + 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) + timestamp_filter = ( + f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:" + f"text='%{{localtime\\:%Y-%m-%d %H\\\\:%M\\\\:%S}}':" + f"fontcolor=white:fontsize={fontsize}:" + f"box=1:boxcolor=black@0.5:boxborderw=5:" + f"x=10:y=10" + ) + video_filters.append(timestamp_filter) # Second: ffmpeg command ffmpeg_cmd = [ @@ -120,12 +140,13 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15): "pipe:0", ] - # Add video filter for rotation if needed, otherwise copy video directly - # Note: When rotation is applied, libx264 encoding is required which increases + # 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 rotation_filter: - ffmpeg_cmd.extend(["-vf", rotation_filter, "-c:v", "libx264"]) + 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"]) @@ -135,8 +156,6 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15): "aac", "-b:a", "128k", - "-preset", - "fast", "-strict", "experimental", # Output format is FLV, then final RTMP URL @@ -220,6 +239,7 @@ def call_lambda(action, CAM_NAME, WORKFLOW_NAME, privacy_status="private"): print(f"Using resolution: {RESOLUTION} ({width}x{height})") 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) @@ -238,7 +258,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, CAMERA_ROTATION, FRAME_RATE) + 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 8e165999..64065270 100644 --- a/src/ac_training_lab/picam/my_secrets_example.py +++ b/src/ac_training_lab/picam/my_secrets_example.py @@ -57,3 +57,10 @@ # 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 with seconds on the video stream +# 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 From e96f0af972a99e9fd85a4f0f4d33fe4f8312f1f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 23:32:28 +0000 Subject: [PATCH 06/11] Fix rotation cropping issue by removing fixed sensor mode Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 967d88f4..7421edab 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -49,8 +49,8 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti Args: ffmpeg_url: RTMP URL for streaming - width: Output width in pixels - height: Output height in pixels + 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 @@ -58,10 +58,15 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti # Get the available camera command camera_cmd = get_camera_command() - # For 90 or 270 degree rotation, the camera captures in landscape orientation - # (swapped dimensions), then ffmpeg rotates to get the final portrait output. - # E.g., for 480x854 portrait output, camera captures 854x480, then rotates. + # 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 @@ -73,8 +78,6 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti "--nopreview", "-t", "0", - "--mode", - "1536:864", # A known 16:9 sensor mode "--width", str(cam_width), # Scale width "--height", @@ -236,7 +239,15 @@ def call_lambda(action, CAM_NAME, WORKFLOW_NAME, privacy_status="private"): f"Must be a positive integer (e.g., 15, 24, 30)" ) - print(f"Using resolution: {RESOLUTION} ({width}x{height})") + # 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'}") From 496a2d7d57bc0f73050ce73927361fe6a536cbda Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:16:06 +0000 Subject: [PATCH 07/11] Fix timestamp overlay drawtext filter escaping Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 7421edab..74fbcca6 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -116,12 +116,16 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti # 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 must be escaped: + # - Colons in text values must be escaped as \: + # - The %{localtime} function needs the strftime format after a colon timestamp_filter = ( - f"drawtext=fontfile=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf:" - f"text='%{{localtime\\:%Y-%m-%d %H\\\\:%M\\\\:%S}}':" - f"fontcolor=white:fontsize={fontsize}:" - f"box=1:boxcolor=black@0.5:boxborderw=5:" - f"x=10:y=10" + 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) From 29357c753dac57d9396212f8799ba5ecf7fbc507 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:26:23 +0000 Subject: [PATCH 08/11] Add ffmpeg timestamp correction options to fix non-monotonous DTS warnings Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 74fbcca6..aee41640 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -165,6 +165,12 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti "128k", "-strict", "experimental", + # Fix non-monotonous DTS warnings by using async audio filter + # and allowing ffmpeg to correct timestamps + "-async", + "1", + "-vsync", + "cfr", # Output format is FLV, then final RTMP URL "-f", "flv", From a4df250f8d03162d581cf644e5cb37245ad9b93a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:27:37 +0000 Subject: [PATCH 09/11] Fix code review comment: update async/vsync option comment Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index aee41640..4002ad1c 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -165,8 +165,8 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti "128k", "-strict", "experimental", - # Fix non-monotonous DTS warnings by using async audio filter - # and allowing ffmpeg to correct timestamps + # Fix non-monotonous DTS warnings by enabling audio synchronization + # and constant frame rate video output "-async", "1", "-vsync", From 61dcb3a5f391c4eb528fe05159c118630e71be82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 02:35:09 +0000 Subject: [PATCH 10/11] Fix timestamp overlay by replacing space with underscore in strftime format Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 4002ad1c..5d60105d 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -117,15 +117,16 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti # fontsize scales with video height for consistent appearance fontsize = max(16, height // 20) # Note: In ffmpeg drawtext filter, special characters must be escaped: - # - Colons in text values must be escaped as \: - # - The %{localtime} function needs the strftime format after a colon + # - Colons in the strftime format must be escaped as \: + # - Spaces must be escaped or avoided + # Using underscore instead of space to avoid parsing 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}}'" + f":text='%{{localtime\\:%Y-%m-%d_%H\\:%M\\:%S}}'" ) video_filters.append(timestamp_filter) From d359359f0cdaaf03c263fbea9779c2b385220824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 02:47:11 +0000 Subject: [PATCH 11/11] Fix timestamp format - use dashes instead of colons to avoid ffmpeg escaping issues Co-authored-by: sgbaird <45469701+sgbaird@users.noreply.github.com> --- src/ac_training_lab/picam/device.py | 12 ++++++------ src/ac_training_lab/picam/my_secrets_example.py | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ac_training_lab/picam/device.py b/src/ac_training_lab/picam/device.py index 5d60105d..d667e074 100644 --- a/src/ac_training_lab/picam/device.py +++ b/src/ac_training_lab/picam/device.py @@ -111,22 +111,22 @@ def start_stream(ffmpeg_url, width=854, height=480, rotation=0, framerate=15, ti 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) + # 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 must be escaped: - # - Colons in the strftime format must be escaped as \: - # - Spaces must be escaped or avoided - # Using underscore instead of space to avoid parsing issues + # 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}}'" + f":text='%{{localtime\\:%Y-%m-%d_%H-%M-%S}}'" ) video_filters.append(timestamp_filter) diff --git a/src/ac_training_lab/picam/my_secrets_example.py b/src/ac_training_lab/picam/my_secrets_example.py index 64065270..0bbe541c 100644 --- a/src/ac_training_lab/picam/my_secrets_example.py +++ b/src/ac_training_lab/picam/my_secrets_example.py @@ -59,7 +59,8 @@ FRAME_RATE = 15 # Timestamp overlay setting -# Set to True to display current date/time with seconds on the video stream +# 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