Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Release type: patch

Auto-rotate images based on EXIF Orientation tag before applying user transforms, and clear the orientation tag in the output to prevent double-rotation in EXIF-aware viewers.
5 changes: 4 additions & 1 deletion pelican/plugins/image_process/image_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from urllib.request import pathname2url, url2pathname

from bs4 import BeautifulSoup
from PIL import Image, ImageFilter, UnidentifiedImageError
from PIL import Image, ImageFilter, ImageOps, UnidentifiedImageError

from pelican import __version__ as pelican_version, signals

Expand Down Expand Up @@ -102,6 +102,7 @@ def _copy_tags(self, src, dst):
b"-TagsFromFile",
src.encode(self.encoding, ExifTool.errors),
dst.encode(self.encoding, ExifTool.errors),
b"-Orientation=1",
)
self._send_command(params)
params = (
Expand Down Expand Up @@ -752,6 +753,8 @@ def process_image(image, settings):
except (UnidentifiedImageError, FileNotFoundError):
return None

i = ImageOps.exif_transpose(i)

for step in image[2]:
if callable(step):
i = step(i)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 38 additions & 1 deletion pelican/plugins/image_process/test_image_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import warnings

from bs4 import BeautifulSoup
from PIL import Image, UnidentifiedImageError
from PIL import Image, ImageChops, ImageStat, UnidentifiedImageError
import pytest

from pelican.plugins.image_process import (
Expand Down Expand Up @@ -47,6 +47,11 @@
TEST_DATA.joinpath("noexif", f"pelican-bird.{ext}").resolve()
for ext in SUPPORTED_EXIF_IMAGE_FORMATS
]
EXIF_ORIENTATION_TEST_IMAGES = [
TEST_DATA.joinpath("exif", f"pelican-bird-rot90.{ext}").resolve()
for ext in SUPPORTED_EXIF_IMAGE_FORMATS
]
PIXEL_DIFF_MEAN_THRESHOLD = 2
TRANSFORM_RESULTS = TEST_DATA.joinpath("results").resolve()

# Register all supported transforms.
Expand Down Expand Up @@ -826,6 +831,38 @@ def test_copy_exif_tags_does_not_add_exif_dims_tags(tmp_path, image_path):
assert "ExifImageHeight" not in actual_tags


@pytest.mark.parametrize("image_path", EXIF_ORIENTATION_TEST_IMAGES)
@pytest.mark.parametrize("copy_tags", [True, False])
def test_exif_orientation(tmp_path, image_path, copy_tags):
original_path = TEST_DATA.joinpath("pelican-bird.png")
original = Image.open(original_path)

settings = get_settings(IMAGE_PROCESS_COPY_EXIF_TAGS=copy_tags)

transform_params = []
image_name = image_path.name
destination_path = tmp_path / image_name

process_image(
(str(image_path), str(destination_path), transform_params),
settings,
)

result = Image.open(destination_path)

# The output should be the same size as the original (not the rotated
# source), because ImageOps.exif_transpose() should correct the
# orientation before saving.
assert result.size == original.size

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the size assert is sufficient, the assert following this could be skipped, which would avoid the additional imports. Weaker test, but less imports. Just affects testing though.

# Mean per-channel pixel diff must stay under 2. JPEG artifacts
# produce values around 1.5, while a wrong orientation (no transpose
# applied) would produce values between 8 and ~30 per channel.
diff = ImageChops.difference(result.convert("RGB"), original.convert("RGB"))
stat = ImageStat.Stat(diff)
assert all(m < PIXEL_DIFF_MEAN_THRESHOLD for m in stat.mean)


def test_try_open_image():
for test_image in FILE_FORMAT_TEST_IMAGES:
assert try_open_image(test_image)
Expand Down