mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
0171a9dd18
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.
50 lines
1.7 KiB
Python
50 lines
1.7 KiB
Python
"""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""
|