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
5 changes: 4 additions & 1 deletion index/src/pixelrag_index/pipelines.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ def _repl(m: re.Match) -> str:
# Render local images (PNG/JPG) — copy/resize into the tile directory structure
if image_docs:
from PIL import Image as PILImage
from PIL import ImageOps

_MAX_WIDTH = 4000 # cap large images to avoid VRAM pressure during embedding

Expand All @@ -263,7 +264,9 @@ def _repl(m: re.Match) -> str:
tile_dir = tiles_dir / f"{idx}.png.tiles"
tile_dir.mkdir(parents=True, exist_ok=True)
try:
img = PILImage.open(doc.path)
# Apply the display orientation before measuring or resizing pixels.
with PILImage.open(doc.path) as source_image:
img = ImageOps.exif_transpose(source_image)
# JPEG has no alpha: composite transparent images onto white
# before dropping the channel. A bare convert("RGB") maps
# fully-transparent pixels to their underlying RGB — black for
Expand Down
102 changes: 102 additions & 0 deletions tests/test_local_image_orientation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Exercise EXIF orientation through the real local-image build path."""

import json

import pytest
from PIL import Image
from pixelrag_index import pipelines


def _render(src, tmp_path, monkeypatch):
# Skip the chunk/embed/index subprocesses: no model or search backend needed.
monkeypatch.setattr(pipelines.subprocess, "run", lambda *args, **kwargs: None)
output = tmp_path / "index"
pipelines.build(
{"source": {"type": "local", "path": str(src)}, "output": str(output)}
)
tile_dir = output / "tiles" / "0.png.tiles"
manifest = json.loads((tile_dir / "tiles.json").read_text())
assert manifest["tiles"] == ["tile_0000.jpg"]
assert manifest["source"] == str(src)
with Image.open(tile_dir / "tile_0000.jpg") as tile:
assert manifest["page_height"] == tile.height
assert tile.getexif().get(274, 1) == 1
return tile.copy()


@pytest.mark.parametrize(
"orientation,size,corners",
[
(None, (80, 40), "RGBY"),
(1, (80, 40), "RGBY"),
(2, (80, 40), "GRYB"),
(3, (80, 40), "YBGR"),
(4, (80, 40), "BYRG"),
(5, (40, 80), "RBGY"),
(6, (40, 80), "BRYG"),
(7, (40, 80), "YGBR"),
(8, (40, 80), "GYRB"),
],
)
def test_local_image_exif_orientation(
tmp_path, monkeypatch, orientation, size, corners
):
colors = {"R": (255, 0, 0), "G": (0, 255, 0), "B": (0, 0, 255), "Y": (255, 255, 0)}
img = Image.new("RGB", (80, 40))
for color, box in zip(
"RGBY", [(0, 0, 40, 20), (40, 0, 80, 20), (0, 20, 40, 40), (40, 20, 80, 40)]
):
img.paste(colors[color], box)
exif = Image.Exif()
if orientation is not None:
exif[274] = orientation
src = tmp_path / "photo.jpg"
img.save(src, exif=exif, quality=100, subsampling=0)
original = src.read_bytes()

tile = _render(src, tmp_path, monkeypatch)

assert tile.size == size
w, h = size
for color, point in zip(
corners,
[
(w // 4, h // 4),
(3 * w // 4, h // 4),
(w // 4, 3 * h // 4),
(3 * w // 4, 3 * h // 4),
],
):
assert tile.getpixel(point) == pytest.approx(colors[color], abs=10)
assert src.read_bytes() == original


@pytest.mark.parametrize(
"stored_size,expected_size", [((80, 4200), (4000, 76)), ((4200, 80), (80, 4200))]
)
def test_width_cap_uses_oriented_dimensions(
tmp_path, monkeypatch, stored_size, expected_size
):
src = tmp_path / "large.jpg"
exif = Image.Exif()
exif[274] = 6
Image.new("RGB", stored_size, "red").save(src, exif=exif)

tile = _render(src, tmp_path, monkeypatch)

assert tile.size == expected_size


def test_oriented_transparent_image_keeps_white_background(tmp_path, monkeypatch):
src = tmp_path / "transparent.png"
img = Image.new("RGBA", (80, 40), (0, 0, 0, 0))
img.paste((255, 0, 0, 255), (0, 0, 40, 40))
exif = Image.Exif()
exif[274] = 6
img.save(src, exif=exif)

tile = _render(src, tmp_path, monkeypatch)

assert tile.size == (40, 80)
assert tile.getpixel((20, 20)) == pytest.approx((255, 0, 0), abs=10)
assert tile.getpixel((20, 60)) == pytest.approx((255, 255, 255), abs=10)