mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Review follow-ups on applying camera_rotation to finish photos and layer-timelapse frames. Rotating the frame popped from _stage22_finish_frames rotated one of its sources twice. The cache has two kinds of feeder: live grabs, which are raw, and the #1867 in-print bank, whose bytes come from _capture_snapshot_for_notification and have already been rotated on the way in. The consumer cannot tell them apart, so on the finish_state trigger - the path the bank exists to serve, on firmware that never emits stg_cur=22 - a 180 degree rotation cancelled itself out and the photo was upside-down again, which is the reported symptom exactly; 90 and 270 landed 180 out. Rotation now happens where each frame is captured, so every entry in the cache carries one rotation whatever produced it, and the invariant is stated both where the cache is declared and where it is consumed. Two finish-photo sources were still writing unrotated files: the built-in camera's own capture_finish_photo, and the still extracted from a printer-recorded timelapse - which is the *preferred* source for a built-in camera print, so a user with a rotation set got a correctly oriented photo or not depending on which source happened to win. Neither ever holds the frame as bytes; ffmpeg writes the file and they return a filename. apply_camera_rotation_to_file handles that case and is best-effort - a failed rotate leaves the unrotated file rather than losing a delivered photo. The archived video itself is the printer's own file and is not re-encoded, so it still plays at the camera's native orientation; the CHANGELOG says so rather than leaving it to be discovered. apply_camera_rotation logs at debug, not info. It was on a path that runs once per layer, where a tall print would have put hundreds of lines in the log for something the surrounding capture already reports at debug. The moved rotation logic had no test of its own - every existing test patches it out and asserts the call, so a flipped sign or a dropped expand=True would have shipped green. test_camera_rotation.py drives the real round trip: a corner marker pins which way it turns, the dimensions pin that the frame is not cropped, and an undecodable frame comes back by identity because a capture path must not lose a frame to a failed rotate. Tests for the fix itself sit on both sides of the cache. The producer half is driven directly; the consumer half is a closure nested inside on_print_complete with nothing able to reach it, so it is pinned by an AST guard - checked against the source because the alternative is no check at all. Reverting main.py to the pre-fix shape fails three of the five, the guard among them. The three new tests used Path("/tmp/test") for a patched base_dir, which Bandit flagged (B108); they take tmp_path now.
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""Tests for the shared camera-rotation helpers (#2708).
|
|
|
|
Every other test of a rotating path patches ``apply_camera_rotation`` out and
|
|
asserts the call, which proves the wiring but not the rotation. These drive
|
|
the real PIL round trip, so a flipped sign or a dropped ``expand=True`` fails
|
|
here rather than shipping.
|
|
"""
|
|
|
|
import io
|
|
import logging
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from backend.app.services.camera import apply_camera_rotation, apply_camera_rotation_to_file
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _jpeg(width: int, height: int, corner: tuple[int, int, int] = (255, 0, 0)) -> bytes:
|
|
"""A JPEG with one distinctly coloured pixel block in the top-left corner,
|
|
so which way it turned is observable and not just the dimensions."""
|
|
img = Image.new("RGB", (width, height), (0, 0, 255))
|
|
for x in range(min(8, width)):
|
|
for y in range(min(8, height)):
|
|
img.putpixel((x, y), corner)
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="JPEG", quality=95)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _open(data: bytes) -> Image.Image:
|
|
return Image.open(io.BytesIO(data))
|
|
|
|
|
|
def _brightest_corner(img: Image.Image) -> str:
|
|
"""Which corner holds the red block, sampled a few pixels in to stay clear
|
|
of JPEG ringing at the edges."""
|
|
w, h = img.size
|
|
probes = {
|
|
"top-left": (3, 3),
|
|
"top-right": (w - 4, 3),
|
|
"bottom-left": (3, h - 4),
|
|
"bottom-right": (w - 4, h - 4),
|
|
}
|
|
return max(probes, key=lambda name: img.getpixel(probes[name])[0] - img.getpixel(probes[name])[2])
|
|
|
|
|
|
class TestApplyCameraRotation:
|
|
def test_zero_rotation_returns_the_input_object(self):
|
|
"""Not merely equal — identity. apply_camera_rotation_to_file uses this
|
|
to decide there is nothing to write back."""
|
|
src = _jpeg(64, 32)
|
|
assert apply_camera_rotation(src, 0, logger) is src
|
|
|
|
def test_90_degrees_turns_clockwise(self):
|
|
"""camera_rotation is documented as degrees *clockwise*, and PIL's
|
|
rotate() is counter-clockwise — the helper negates to compensate. A
|
|
lost negation would send the corner to bottom-right instead."""
|
|
src = _jpeg(64, 32)
|
|
assert _brightest_corner(_open(src)) == "top-left"
|
|
|
|
out = _open(apply_camera_rotation(src, 90, logger))
|
|
assert out.size == (32, 64) # expand=True, so the frame is not cropped
|
|
assert _brightest_corner(out) == "top-right"
|
|
|
|
def test_270_degrees_turns_the_other_way(self):
|
|
out = _open(apply_camera_rotation(_jpeg(64, 32), 270, logger))
|
|
assert out.size == (32, 64)
|
|
assert _brightest_corner(out) == "bottom-left"
|
|
|
|
def test_180_degrees_keeps_the_dimensions_and_flips_the_corner(self):
|
|
out = _open(apply_camera_rotation(_jpeg(64, 32), 180, logger))
|
|
assert out.size == (64, 32)
|
|
assert _brightest_corner(out) == "bottom-right"
|
|
|
|
def test_applying_180_twice_is_the_bug_that_was_fixed(self):
|
|
"""The regression this guards: two rotations cancel out and the photo
|
|
is upside-down again. Kept as a test so the invariant that
|
|
_stage22_finish_frames holds exactly one rotation has a stated reason.
|
|
"""
|
|
src = _jpeg(64, 32)
|
|
once = apply_camera_rotation(src, 180, logger)
|
|
twice = apply_camera_rotation(once, 180, logger)
|
|
assert _brightest_corner(_open(once)) == "bottom-right"
|
|
assert _brightest_corner(_open(twice)) == "top-left" # back to the original
|
|
|
|
def test_undecodable_bytes_return_unchanged(self):
|
|
"""A capture path must not lose a frame because the rotate failed —
|
|
an unrotated photo beats no photo."""
|
|
junk = b"not a jpeg at all"
|
|
assert apply_camera_rotation(junk, 90, logger) is junk
|
|
|
|
def test_a_failed_rotate_is_logged_as_a_warning(self, caplog):
|
|
with caplog.at_level(logging.WARNING, logger=__name__):
|
|
apply_camera_rotation(b"not a jpeg at all", 90, logger)
|
|
assert any("Failed to apply camera rotation" in r.message for r in caplog.records)
|
|
|
|
def test_a_successful_rotate_does_not_log_at_info(self, caplog):
|
|
"""Layer-timelapse calls this once per layer; at INFO a tall print
|
|
would bury the log."""
|
|
with caplog.at_level(logging.INFO, logger=__name__):
|
|
apply_camera_rotation(_jpeg(64, 32), 90, logger)
|
|
assert caplog.records == []
|
|
|
|
|
|
class TestApplyCameraRotationToFile:
|
|
"""The two finish-photo sources that let ffmpeg write the file and never
|
|
hold the bytes: capture_finish_photo and the timelapse last-frame extract."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rotates_in_place(self, tmp_path):
|
|
path = tmp_path / "finish.jpg"
|
|
path.write_bytes(_jpeg(64, 32))
|
|
|
|
await apply_camera_rotation_to_file(path, 90, logger)
|
|
|
|
out = _open(path.read_bytes())
|
|
assert out.size == (32, 64)
|
|
assert _brightest_corner(out) == "top-right"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_rotation_leaves_the_file_untouched(self, tmp_path):
|
|
path = tmp_path / "finish.jpg"
|
|
original = _jpeg(64, 32)
|
|
path.write_bytes(original)
|
|
|
|
await apply_camera_rotation_to_file(path, 0, logger)
|
|
|
|
assert path.read_bytes() == original
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_file_that_cannot_be_rotated_is_left_intact(self, tmp_path):
|
|
"""Not truncated, not deleted — the caller's unrotated photo survives."""
|
|
path = tmp_path / "finish.jpg"
|
|
path.write_bytes(b"not a jpeg at all")
|
|
|
|
await apply_camera_rotation_to_file(path, 90, logger)
|
|
|
|
assert path.read_bytes() == b"not a jpeg at all"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_missing_file_does_not_raise(self, tmp_path):
|
|
"""Best-effort: this runs after the capture reported success, and must
|
|
not turn a delivered photo into a failed one."""
|
|
await apply_camera_rotation_to_file(tmp_path / "gone.jpg", 90, logger)
|