"""Image reader — uses ``Pillow`` (lazy import) to extract metadata.
Images are represented as a textual description of their EXIF/metadata,
plus an optional prompt to an LLM for visual description. Pixel data is
not stored in the Document; use :class:`~ractogateway.prompts.engine.RactoFile`
for multimodal vision calls.
Install with: pip install ractogateway[rag-image]
"""
from __future__ import annotations
import io
from pathlib import Path
from typing import Any
def _require_pillow() -> Any:
try:
from PIL import Image
except ImportError as exc:
raise ImportError(
"Reading image files requires the 'Pillow' package. "
"Install it with: pip install ractogateway[rag-image]"
) from exc
return Image
from ractogateway.rag._models.document import Document
from ractogateway.rag.readers.base import BaseReader
_EXIF_TAGS: dict[int, str] = {}
def _load_exif_tags() -> dict[int, str]:
global _EXIF_TAGS # noqa: PLW0603
if not _EXIF_TAGS:
try:
from PIL.ExifTags import TAGS
_EXIF_TAGS = dict(TAGS.items())
except Exception:
return _EXIF_TAGS
return _EXIF_TAGS
[docs]
class ImageReader(BaseReader):
"""Extract metadata from image files and represent them as text Documents.
The resulting ``Document.content`` is a human-readable summary of image
properties (size, mode, format, EXIF tags). Pass the image to a vision
LLM separately using :class:`~ractogateway.prompts.engine.RactoFile` for
actual visual understanding.
Accepts a file path (``str`` / ``Path``), raw ``bytes``, or any binary
file-like object with a ``.read()`` method.
Parameters
----------
include_exif:
Whether to extract and include EXIF metadata in the content.
"""
def __init__(self, include_exif: bool = True) -> None:
self._include_exif = include_exif
@property
def supported_extensions(self) -> frozenset[str]:
return frozenset(
{
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
}
)
def _read_path(self, path: Path) -> Document:
image_module = _require_pillow()
with image_module.open(str(path)) as img:
content, meta = self._extract_image_info(img, name=path.name)
return Document(
content=content,
source=str(path.resolve()),
metadata={
"extension": path.suffix.lower(),
"filename": path.name,
"size_bytes": path.stat().st_size,
**meta,
},
)
def _read_bytes(self, data: bytes, *, source_label: str = "<bytes>") -> Document:
image_module = _require_pillow()
with image_module.open(io.BytesIO(data)) as img:
content, meta = self._extract_image_info(img, name=source_label)
return Document(
content=content,
source=source_label,
metadata={"size_bytes": len(data), **meta},
)
def _extract_image_info(self, img: Any, *, name: str) -> tuple[str, dict[str, Any]]:
width, height = img.size
mode = img.mode
fmt = img.format or "UNKNOWN"
lines: list[str] = [
f"Image: {name}",
f"Format: {fmt}",
f"Size: {width}x{height} pixels",
f"Color mode: {mode}",
]
if self._include_exif:
exif_data = self._extract_exif(img)
if exif_data:
lines.append("EXIF metadata:")
for key, val in exif_data.items():
lines.append(f" {key}: {val}")
meta: dict[str, Any] = {
"width": width,
"height": height,
"format": fmt,
"mode": mode,
}
return "\n".join(lines), meta
def _extract_exif(self, img: Any) -> dict[str, str]:
tags = _load_exif_tags()
result: dict[str, str] = {}
try:
raw_exif = img._getexif()
if raw_exif:
for tag_id, value in raw_exif.items():
tag_name = tags.get(tag_id, str(tag_id))
if isinstance(value, bytes):
continue # skip binary blobs
result[tag_name] = str(value)[:200]
except Exception:
return result
return result