Skip to content
Merged
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
62 changes: 62 additions & 0 deletions src/photo_tools/image/optimisation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from io import BytesIO

from PIL import Image


def resize_to_max_width(img: Image.Image, max_width: int) -> Image.Image:
if img.width <= max_width:
return img

new_height = int(img.height * (max_width / img.width))
return img.resize((max_width, new_height), Image.Resampling.LANCZOS)


# Binary search to find the highest JPEG quality that produces a file
# within the maximum allowed size. Assumes file size increases with quality.
def optimise_jpeg(
img: Image.Image,
max_file_size_bytes: int,
min_quality: int = 70,
max_quality: int = 100,
) -> tuple[bytes, int]:
"""
Binary search to find the highest JPEG quality that produces a file
within the maximum allowed size.
"""

low = min_quality
high = max_quality

best_bytes: bytes | None = None
best_quality = min_quality

while low <= high:
quality = (low + high) // 2
jpeg_bytes = encode_jpeg(img, quality)

if len(jpeg_bytes) <= max_file_size_bytes:
best_bytes = jpeg_bytes
best_quality = quality
low = quality + 1
else:
high = quality - 1

if best_bytes is None:
jpeg_bytes = encode_jpeg(img, min_quality)
return jpeg_bytes, min_quality

return best_bytes, best_quality


# Encode a PIL Image into JPEG bytes in memory using the given quality.
# This is used to evaluate the resulting file size without writing to disk.
# `quality` controls compression (higher = better quality, larger size).
def encode_jpeg(img: Image.Image, quality: int) -> bytes:
buffer = BytesIO()
img.save(
buffer,
format="JPEG",
quality=quality,
optimize=True,
)
return buffer.getvalue()
47 changes: 2 additions & 45 deletions src/photo_tools/optimise.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import logging
from io import BytesIO
from pathlib import Path

from PIL import Image

from photo_tools.core.validation import validate_input_dir
from photo_tools.image.optimisation import optimise_jpeg, resize_to_max_width

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -39,7 +39,7 @@ def optimise(input_dir: str, dry_run: bool = False) -> None:
with Image.open(file_path) as original_img:
img = original_img.convert("RGB")
resized_img = resize_to_max_width(img, MAX_WIDTH)
jpeg_bytes, quality = find_best_jpeg_bytes(resized_img)
jpeg_bytes, quality = optimise_jpeg(resized_img, MAX_FILE_SIZE_BYTES)

size_kb = len(jpeg_bytes) // 1024

Expand All @@ -56,46 +56,3 @@ def optimise(input_dir: str, dry_run: bool = False) -> None:
f"Optimised {file_path.name} → {output_path.name} "
f"(quality={quality}, size={size_kb} KB)"
)


def resize_to_max_width(img: Image.Image, max_width: int) -> Image.Image:
if img.width <= max_width:
return img

new_height = int(img.height * (max_width / img.width))
return img.resize((max_width, new_height), Image.Resampling.LANCZOS)


def find_best_jpeg_bytes(img: Image.Image) -> tuple[bytes, int]:
low = MIN_QUALITY
high = MAX_QUALITY

best_bytes: bytes | None = None
best_quality = MIN_QUALITY

while low <= high:
quality = (low + high) // 2
jpeg_bytes = render_jpeg_bytes(img, quality)

if len(jpeg_bytes) <= MAX_FILE_SIZE_BYTES:
best_bytes = jpeg_bytes
best_quality = quality
low = quality + 1
else:
high = quality - 1

if best_bytes is None:
return render_jpeg_bytes(img, MIN_QUALITY), MIN_QUALITY

return best_bytes, best_quality


def render_jpeg_bytes(img: Image.Image, quality: int) -> bytes:
buffer = BytesIO()
img.save(
buffer,
format="JPEG",
quality=quality,
optimize=True,
)
return buffer.getvalue()
19 changes: 19 additions & 0 deletions tests/image/test_optimisation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from PIL import Image

from photo_tools.image.optimisation import resize_to_max_width


def test_does_not_resize_when_width_is_within_limit():
img = Image.new("RGB", (1200, 800))

result = resize_to_max_width(img, max_width=2500)

assert result.size == img.size


def test_resizes_image_with_simple_ratio():
img = Image.new("RGB", (4000, 2000)) # 2:1 ratio

result = resize_to_max_width(img, max_width=2000)

assert result.size == (2000, 1000)
72 changes: 72 additions & 0 deletions tests/test_optimise.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from PIL import Image

from photo_tools.optimise import MAX_WIDTH, optimise


def test_dry_run_does_not_create_output(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

img_path = input_dir / "photo.jpg"
Image.new("RGB", (1000, 1000)).save(img_path)

optimise(str(input_dir), dry_run=True)

assert img_path.exists()
assert not (input_dir / "lq_photo.jpg").exists()


def test_creates_optimised_file(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

img_path = input_dir / "photo.jpg"
Image.new("RGB", (1000, 1000)).save(img_path)

optimise(str(input_dir), dry_run=False)

output = input_dir / "lq_photo.jpg"

assert img_path.exists()
assert output.exists()


def test_skips_unsupported_files(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

txt_file = input_dir / "notes.txt"
txt_file.write_text("hello")

optimise(str(input_dir), dry_run=False)

assert txt_file.exists()
assert not (input_dir / "lq_notes.txt").exists()


def test_skips_already_optimised_files(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

img_path = input_dir / "lq_photo.jpg"
Image.new("RGB", (1000, 1000)).save(img_path)

optimise(str(input_dir), dry_run=False)

assert img_path.exists()
assert not (input_dir / "lq_lq_photo.jpg").exists()


def test_output_image_width_is_capped(tmp_path):
input_dir = tmp_path / "input"
input_dir.mkdir()

img_path = input_dir / "photo.jpg"
Image.new("RGB", (4000, 2000)).save(img_path)

optimise(str(input_dir), dry_run=False)

output = input_dir / "lq_photo.jpg"

with Image.open(output) as img:
assert img.width <= MAX_WIDTH
Loading