mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
fix(camera): apply camera_rotation to layer-timelapse frames too
Same gap as the finish-photo fix: camera_rotation was only ever wired into the notification-snapshot path, so a layer-timelapse video came out upside-down whenever a rotation was configured - every frame (fresh or reused from the live view's buffer) was written to disk raw. Extracts the rotation logic out of main.py into a shared apply_camera_rotation(image_data, rotation, logger) in services/camera.py (taking the rotation value directly rather than a printer object, so both main.py's printer-shaped callers and layer_timelapse's plain int field can use it). main.py's _apply_camera_rotation becomes a thin compatibility wrapper so its existing call sites are unchanged. Threads a `rotation` field through TimelapseSession/start_session, set from printer.camera_rotation in _maybe_start_layer_timelapse, and applies it (via asyncio.to_thread, since PIL rotation is CPU-bound) in capture_layer before each frame is written - after #2707's live_frame_for_capture() resolves the frame, regardless of whether it came fresh or from the live view's buffer. Rebased onto #2707's landed implementation: capture_layer now calls live_frame_for_capture() instead of the older is_stream_active/ try_get_active_buffered_frame pair this was originally written against, so the layer-timelapse tests are updated to match, and the now-redundant TestCaptureLayerAvoidsCompetingWithLiveViewer class (superseded by #2707's own test_external_camera_live_frame_reuse.py) is dropped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+3
-19
@@ -1052,6 +1052,7 @@ def _maybe_start_layer_timelapse(printer, printer_id: int, archive_id: int) -> b
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
rotation=getattr(printer, "camera_rotation", 0),
|
||||
)
|
||||
logging.getLogger(__name__).info("Started layer timelapse for printer %s, archive %s", printer_id, archive_id)
|
||||
return True
|
||||
@@ -2321,26 +2322,9 @@ async def _maybe_bank_inprint_frame(printer_id: int, layer_num: int) -> None:
|
||||
|
||||
def _apply_camera_rotation(image_data: bytes, printer, logger) -> bytes:
|
||||
"""Apply camera rotation to snapshot image if configured."""
|
||||
rotation = getattr(printer, "camera_rotation", 0)
|
||||
if not rotation or rotation == 0:
|
||||
return image_data
|
||||
from backend.app.services.camera import apply_camera_rotation
|
||||
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(image_data))
|
||||
# PIL rotate is counter-clockwise, so negate for clockwise rotation
|
||||
img = img.rotate(-rotation, expand=True)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
rotated = buf.getvalue()
|
||||
logger.info("[SNAPSHOT] Applied %d° rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
|
||||
return rotated
|
||||
except Exception as e:
|
||||
logger.warning("[SNAPSHOT] Failed to apply rotation: %s", e)
|
||||
return image_data
|
||||
return apply_camera_rotation(image_data, getattr(printer, "camera_rotation", 0), logger)
|
||||
|
||||
|
||||
async def _send_print_start_notification(
|
||||
|
||||
@@ -836,6 +836,35 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:
|
||||
"""Apply a camera_rotation value (degrees clockwise) to a captured JPEG.
|
||||
|
||||
Shared by every capture path that saves a still image (notification
|
||||
snapshots, finish photos, layer-timelapse frames) - previously only
|
||||
wired into the notification-snapshot path, which left finish photos
|
||||
and timelapse videos upside-down whenever camera_rotation was set.
|
||||
"""
|
||||
if not rotation:
|
||||
return image_data
|
||||
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(image_data))
|
||||
# PIL rotate is counter-clockwise, so negate for clockwise rotation
|
||||
img = img.rotate(-rotation, expand=True)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
rotated = buf.getvalue()
|
||||
logger.info("Applied %d° camera rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
|
||||
return rotated
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply camera rotation: %s", e)
|
||||
return image_data
|
||||
|
||||
|
||||
async def capture_finish_photo(
|
||||
printer_id: int,
|
||||
ip_address: str,
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.camera import apply_camera_rotation
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,6 +42,7 @@ class TimelapseSession:
|
||||
camera_url: str
|
||||
camera_type: str
|
||||
snapshot_url: str | None = None # Optional single-frame override; #1177
|
||||
rotation: int = 0 # Printer's configured camera_rotation, degrees clockwise
|
||||
last_layer: int = -1
|
||||
frame_count: int = 0
|
||||
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
@@ -88,6 +90,8 @@ class TimelapseSession:
|
||||
else:
|
||||
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
|
||||
if frame_data:
|
||||
if self.rotation:
|
||||
frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
|
||||
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
|
||||
await asyncio.to_thread(frame_path.write_bytes, frame_data)
|
||||
self.frame_count += 1
|
||||
@@ -206,6 +210,7 @@ def start_session(
|
||||
url: str,
|
||||
cam_type: str,
|
||||
snapshot_url: str | None = None,
|
||||
rotation: int = 0,
|
||||
) -> TimelapseSession:
|
||||
"""Start new timelapse session for a printer.
|
||||
|
||||
@@ -216,6 +221,8 @@ def start_session(
|
||||
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
|
||||
snapshot_url: Optional single-frame URL override; when set, layer captures
|
||||
fetch from it directly instead of opening the live stream. #1177.
|
||||
rotation: Printer's configured camera_rotation (degrees clockwise),
|
||||
applied to every captured frame before it's saved.
|
||||
|
||||
Returns:
|
||||
The new TimelapseSession
|
||||
@@ -229,6 +236,7 @@ def start_session(
|
||||
camera_url=url,
|
||||
camera_type=cam_type,
|
||||
snapshot_url=snapshot_url,
|
||||
rotation=rotation,
|
||||
)
|
||||
_active_sessions[printer_id] = session
|
||||
logger.info("Started timelapse session for printer %s", printer_id)
|
||||
|
||||
@@ -6,7 +6,7 @@ These tests cover session management and pure logic functions.
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -246,6 +246,99 @@ class TestLayerChangeLogic:
|
||||
assert session.frame_count == 0 # But frame count not incremented
|
||||
|
||||
|
||||
class TestCaptureLayerAppliesRotation:
|
||||
"""camera_rotation was previously only wired into the notification-
|
||||
snapshot path, so a layer-timelapse video came out upside-down whenever
|
||||
the printer had a rotation configured. capture_layer now applies it to
|
||||
every captured frame, whether fresh or reused from the live view's
|
||||
buffer, before writing to disk."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotates_fresh_capture_when_configured(self):
|
||||
from backend.app.services.layer_timelapse import TimelapseSession
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=180)
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)),
|
||||
patch(
|
||||
"backend.app.services.layer_timelapse.capture_frame",
|
||||
new_callable=AsyncMock,
|
||||
return_value=b"\xff\xd8unrotated\xff\xd9",
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.layer_timelapse.apply_camera_rotation",
|
||||
return_value=b"\xff\xd8rotated\xff\xd9",
|
||||
) as mock_rotate,
|
||||
patch.object(Path, "write_bytes") as mock_write,
|
||||
):
|
||||
result = await session.capture_layer(1)
|
||||
|
||||
assert result is True
|
||||
mock_rotate.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9", 180, ANY)
|
||||
mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotates_buffered_frame_when_configured(self):
|
||||
from backend.app.services.layer_timelapse import TimelapseSession
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
session = TimelapseSession(1, 100, "/dev/video1", "usb", rotation=90)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.camera.live_frame_for_capture",
|
||||
return_value=(True, b"\xff\xd8buffered\xff\xd9"),
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.layer_timelapse.apply_camera_rotation",
|
||||
return_value=b"\xff\xd8rotated\xff\xd9",
|
||||
) as mock_rotate,
|
||||
patch.object(Path, "write_bytes") as mock_write,
|
||||
):
|
||||
result = await session.capture_layer(1)
|
||||
|
||||
assert result is True
|
||||
mock_rotate.assert_called_once_with(b"\xff\xd8buffered\xff\xd9", 90, ANY)
|
||||
mock_write.assert_called_once_with(b"\xff\xd8rotated\xff\xd9")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_rotation_when_not_configured(self):
|
||||
"""Default rotation=0 - no-op, and must not even call apply_camera_rotation
|
||||
(avoids the PIL decode/re-encode round trip for the common case)."""
|
||||
from backend.app.services.layer_timelapse import TimelapseSession
|
||||
|
||||
with patch("backend.app.services.layer_timelapse.settings") as mock_settings:
|
||||
mock_settings.base_dir = Path("/tmp/test")
|
||||
|
||||
with patch.object(Path, "mkdir"):
|
||||
session = TimelapseSession(1, 100, "/dev/video1", "usb")
|
||||
assert session.rotation == 0
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.camera.live_frame_for_capture", return_value=(False, None)),
|
||||
patch(
|
||||
"backend.app.services.layer_timelapse.capture_frame",
|
||||
new_callable=AsyncMock,
|
||||
return_value=b"\xff\xd8unrotated\xff\xd9",
|
||||
),
|
||||
patch("backend.app.services.layer_timelapse.apply_camera_rotation") as mock_rotate,
|
||||
patch.object(Path, "write_bytes") as mock_write,
|
||||
):
|
||||
result = await session.capture_layer(1)
|
||||
|
||||
assert result is True
|
||||
mock_rotate.assert_not_called()
|
||||
mock_write.assert_called_once_with(b"\xff\xd8unrotated\xff\xd9")
|
||||
|
||||
|
||||
class TestOnLayerChange:
|
||||
"""Tests for the on_layer_change callback."""
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ def test_starts_timelapse_when_external_camera_enabled():
|
||||
"http://camera.local:5000/snapshot.jpg",
|
||||
"snapshot",
|
||||
snapshot_url="http://camera.local:5000/snapshot.jpg",
|
||||
rotation=0,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user