test_camera_rotation.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. """Tests for the shared camera-rotation helpers (#2708).
  2. Every other test of a rotating path patches ``apply_camera_rotation`` out and
  3. asserts the call, which proves the wiring but not the rotation. These drive
  4. the real PIL round trip, so a flipped sign or a dropped ``expand=True`` fails
  5. here rather than shipping.
  6. """
  7. import io
  8. import logging
  9. import pytest
  10. from PIL import Image
  11. from backend.app.services.camera import apply_camera_rotation, apply_camera_rotation_to_file
  12. logger = logging.getLogger(__name__)
  13. def _jpeg(width: int, height: int, corner: tuple[int, int, int] = (255, 0, 0)) -> bytes:
  14. """A JPEG with one distinctly coloured pixel block in the top-left corner,
  15. so which way it turned is observable and not just the dimensions."""
  16. img = Image.new("RGB", (width, height), (0, 0, 255))
  17. for x in range(min(8, width)):
  18. for y in range(min(8, height)):
  19. img.putpixel((x, y), corner)
  20. buf = io.BytesIO()
  21. img.save(buf, format="JPEG", quality=95)
  22. return buf.getvalue()
  23. def _open(data: bytes) -> Image.Image:
  24. return Image.open(io.BytesIO(data))
  25. def _brightest_corner(img: Image.Image) -> str:
  26. """Which corner holds the red block, sampled a few pixels in to stay clear
  27. of JPEG ringing at the edges."""
  28. w, h = img.size
  29. probes = {
  30. "top-left": (3, 3),
  31. "top-right": (w - 4, 3),
  32. "bottom-left": (3, h - 4),
  33. "bottom-right": (w - 4, h - 4),
  34. }
  35. return max(probes, key=lambda name: img.getpixel(probes[name])[0] - img.getpixel(probes[name])[2])
  36. class TestApplyCameraRotation:
  37. def test_zero_rotation_returns_the_input_object(self):
  38. """Not merely equal — identity. apply_camera_rotation_to_file uses this
  39. to decide there is nothing to write back."""
  40. src = _jpeg(64, 32)
  41. assert apply_camera_rotation(src, 0, logger) is src
  42. def test_90_degrees_turns_clockwise(self):
  43. """camera_rotation is documented as degrees *clockwise*, and PIL's
  44. rotate() is counter-clockwise — the helper negates to compensate. A
  45. lost negation would send the corner to bottom-right instead."""
  46. src = _jpeg(64, 32)
  47. assert _brightest_corner(_open(src)) == "top-left"
  48. out = _open(apply_camera_rotation(src, 90, logger))
  49. assert out.size == (32, 64) # expand=True, so the frame is not cropped
  50. assert _brightest_corner(out) == "top-right"
  51. def test_270_degrees_turns_the_other_way(self):
  52. out = _open(apply_camera_rotation(_jpeg(64, 32), 270, logger))
  53. assert out.size == (32, 64)
  54. assert _brightest_corner(out) == "bottom-left"
  55. def test_180_degrees_keeps_the_dimensions_and_flips_the_corner(self):
  56. out = _open(apply_camera_rotation(_jpeg(64, 32), 180, logger))
  57. assert out.size == (64, 32)
  58. assert _brightest_corner(out) == "bottom-right"
  59. def test_applying_180_twice_is_the_bug_that_was_fixed(self):
  60. """The regression this guards: two rotations cancel out and the photo
  61. is upside-down again. Kept as a test so the invariant that
  62. _stage22_finish_frames holds exactly one rotation has a stated reason.
  63. """
  64. src = _jpeg(64, 32)
  65. once = apply_camera_rotation(src, 180, logger)
  66. twice = apply_camera_rotation(once, 180, logger)
  67. assert _brightest_corner(_open(once)) == "bottom-right"
  68. assert _brightest_corner(_open(twice)) == "top-left" # back to the original
  69. def test_undecodable_bytes_return_unchanged(self):
  70. """A capture path must not lose a frame because the rotate failed —
  71. an unrotated photo beats no photo."""
  72. junk = b"not a jpeg at all"
  73. assert apply_camera_rotation(junk, 90, logger) is junk
  74. def test_a_failed_rotate_is_logged_as_a_warning(self, caplog):
  75. with caplog.at_level(logging.WARNING, logger=__name__):
  76. apply_camera_rotation(b"not a jpeg at all", 90, logger)
  77. assert any("Failed to apply camera rotation" in r.message for r in caplog.records)
  78. def test_a_successful_rotate_does_not_log_at_info(self, caplog):
  79. """Layer-timelapse calls this once per layer; at INFO a tall print
  80. would bury the log."""
  81. with caplog.at_level(logging.INFO, logger=__name__):
  82. apply_camera_rotation(_jpeg(64, 32), 90, logger)
  83. assert caplog.records == []
  84. class TestApplyCameraRotationToFile:
  85. """The two finish-photo sources that let ffmpeg write the file and never
  86. hold the bytes: capture_finish_photo and the timelapse last-frame extract."""
  87. @pytest.mark.asyncio
  88. async def test_rotates_in_place(self, tmp_path):
  89. path = tmp_path / "finish.jpg"
  90. path.write_bytes(_jpeg(64, 32))
  91. await apply_camera_rotation_to_file(path, 90, logger)
  92. out = _open(path.read_bytes())
  93. assert out.size == (32, 64)
  94. assert _brightest_corner(out) == "top-right"
  95. @pytest.mark.asyncio
  96. async def test_zero_rotation_leaves_the_file_untouched(self, tmp_path):
  97. path = tmp_path / "finish.jpg"
  98. original = _jpeg(64, 32)
  99. path.write_bytes(original)
  100. await apply_camera_rotation_to_file(path, 0, logger)
  101. assert path.read_bytes() == original
  102. @pytest.mark.asyncio
  103. async def test_a_file_that_cannot_be_rotated_is_left_intact(self, tmp_path):
  104. """Not truncated, not deleted — the caller's unrotated photo survives."""
  105. path = tmp_path / "finish.jpg"
  106. path.write_bytes(b"not a jpeg at all")
  107. await apply_camera_rotation_to_file(path, 90, logger)
  108. assert path.read_bytes() == b"not a jpeg at all"
  109. @pytest.mark.asyncio
  110. async def test_a_missing_file_does_not_raise(self, tmp_path):
  111. """Best-effort: this runs after the capture reported success, and must
  112. not turn a delivered photo into a failed one."""
  113. await apply_camera_rotation_to_file(tmp_path / "gone.jpg", 90, logger)