diff --git a/src/images.py b/src/images.py new file mode 100644 index 0000000..15fd57e --- /dev/null +++ b/src/images.py @@ -0,0 +1,61 @@ +import hashlib +import logging +import os +from io import BytesIO + +import requests +from PIL import Image as PILImage + +import config + +logger = logging.getLogger(__name__) + + +def _url_hash(url: str) -> str: + return hashlib.sha256(url.encode()).hexdigest()[:16] + + +def _resize_to_fit(img: PILImage.Image) -> PILImage.Image: + w, h = img.size + if w >= h: + max_w, max_h = config.IMAGE_MAX_LANDSCAPE + else: + max_w, max_h = config.IMAGE_MAX_PORTRAIT + + scale = min(max_w / w, max_h / h) + + new_w = int(w * scale) + new_h = int(h * scale) + + if new_w == w and new_h == h: + return img + + return img.resize((new_w, new_h), PILImage.Resampling.LANCZOS) + + +def process_image(url: str, output_dir: str) -> tuple[str, int, int]: + """Download an image, resize it, save as baseline JPEG. + + Returns (local_path, width, height). Deduplicates by URL hash. + """ + os.makedirs(output_dir, exist_ok=True) + filename = f"{_url_hash(url)}.jpg" + local_path = os.path.join(output_dir, filename) + + if os.path.exists(local_path): + img = PILImage.open(local_path) + return local_path, img.width, img.height + + response = requests.get(url, timeout=30) + response.raise_for_status() + + img = PILImage.open(BytesIO(response.content)) + + if img.mode in ("RGBA", "P", "LA"): + img = img.convert("RGB") + + img = _resize_to_fit(img) + + img.save(local_path, format="JPEG", progressive=False, quality=85) + + return local_path, img.width, img.height diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..8a21afe --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,80 @@ +import os +import pytest +from io import BytesIO +from unittest.mock import patch, MagicMock +from PIL import Image as PILImage +from src.images import process_image, _resize_to_fit + + +def _make_test_image(width, height, fmt="JPEG"): + img = PILImage.new("RGB", (width, height), color="red") + buf = BytesIO() + img.save(buf, format=fmt) + buf.seek(0) + return buf.read() + + +def test_resize_landscape_downscale(): + img = PILImage.new("RGB", (1600, 900)) + result = _resize_to_fit(img) + assert result.width <= 800 + assert result.height <= 480 + assert result.width / result.height == pytest.approx(1600 / 900, rel=0.02) + + +def test_resize_portrait_downscale(): + img = PILImage.new("RGB", (600, 1200)) + result = _resize_to_fit(img) + assert result.width <= 480 + assert result.height <= 800 + assert result.width / result.height == pytest.approx(600 / 1200, rel=0.02) + + +def test_resize_small_image_upscales(): + img = PILImage.new("RGB", (200, 100)) + result = _resize_to_fit(img) + assert result.width > 200 + assert result.width <= 800 + assert result.height <= 480 + + +def test_resize_already_fits(): + img = PILImage.new("RGB", (800, 480)) + result = _resize_to_fit(img) + assert result.size == (800, 480) + + +def test_process_image_downloads_and_saves(tmp_path): + image_bytes = _make_test_image(1024, 768) + + mock_response = MagicMock() + mock_response.content = image_bytes + mock_response.raise_for_status = MagicMock() + + with patch("src.images.requests.get", return_value=mock_response): + path, w, h = process_image( + "https://example.com/photo.jpg", str(tmp_path) + ) + + assert os.path.exists(path) + assert path.endswith(".jpg") + assert w <= 800 + assert h <= 480 + + saved = PILImage.open(path) + assert saved.format == "JPEG" + + +def test_process_image_dedup(tmp_path): + image_bytes = _make_test_image(500, 300) + + mock_response = MagicMock() + mock_response.content = image_bytes + mock_response.raise_for_status = MagicMock() + + with patch("src.images.requests.get", return_value=mock_response) as mock_get: + path1, _, _ = process_image("https://example.com/same.jpg", str(tmp_path)) + path2, _, _ = process_image("https://example.com/same.jpg", str(tmp_path)) + + assert path1 == path2 + assert mock_get.call_count == 1