Files
turnstone/tests/test_images.py
T
Patrick Buckley 0171a9dd18 fix(attachments): normalize EXIF orientation so thumbnails and models see upright images
Phone photos store landscape pixels plus an EXIF orientation tag. Browsers honour
the tag for <img>, but Pillow (our thumbnails) and many vision-model image
decoders do not — so the thumbnail rendered rotated AND the model literally
perceived the photo sideways (noticed earlier as model "hallucinations", before
thumbnails made the rotation visible).

Normalize on read, at both surfaces:
- new core/images.normalize_image_orientation: bakes the rotation into the pixels
  and re-encodes (preserving format); images with no / identity orientation pass
  through untouched (pristine original, no per-send cost).
- make_thumbnail applies exif_transpose — after the decompression-bomb pixel gate,
  which now also covers the transpose decode.
- attachment_to_content_part runs image bytes through the normalizer before
  base64, so the primary model and the perception model both get upright pixels.

Because normalization is on read (not at upload), it fixes already-stored uploads
too.
2026-06-16 03:41:51 -07:00

50 lines
1.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for EXIF-orientation normalisation (turnstone.core.images)."""
from __future__ import annotations
from io import BytesIO
import pytest
from turnstone.core.images import normalize_image_orientation
Image = pytest.importorskip("PIL.Image")
_ORIENTATION_TAG = 0x0112 # EXIF orientation (standard tag id)
def _oriented_jpeg(orientation: int, size: tuple[int, int] = (4, 2)) -> bytes:
img = Image.new("RGB", size, "red")
exif = img.getexif()
exif[_ORIENTATION_TAG] = orientation
buf = BytesIO()
img.save(buf, format="JPEG", exif=exif)
return buf.getvalue()
def test_applies_rotation_and_strips_tag() -> None:
# Orientation 6 = "rotate 90° for display": a 4×2 landscape becomes 2×4.
data = _oriented_jpeg(6, size=(4, 2))
out = normalize_image_orientation(data)
assert out != data, "a rotated image must be re-encoded upright"
img = Image.open(BytesIO(out))
assert img.size == (2, 4), "the 90° rotation must be baked into the pixels"
assert img.getexif().get(_ORIENTATION_TAG) in (None, 1), "the orientation tag must be cleared"
def test_passthrough_when_upright() -> None:
data = _oriented_jpeg(1, size=(4, 2))
assert normalize_image_orientation(data) == data, "identity orientation must not re-encode"
def test_passthrough_when_no_exif() -> None:
buf = BytesIO()
Image.new("RGB", (3, 3), "blue").save(buf, format="PNG")
data = buf.getvalue()
assert normalize_image_orientation(data) == data, "a tag-less image must pass through verbatim"
def test_never_raises_on_garbage() -> None:
assert normalize_image_orientation(b"not an image") == b"not an image"
assert normalize_image_orientation(b"") == b""