|
|
""" |
|
|
Image Document Loading |
|
|
|
|
|
Handles single images and multi-page TIFF documents. |
|
|
""" |
|
|
|
|
|
import logging |
|
|
from pathlib import Path |
|
|
from typing import Iterator, List, Optional, Tuple, Union |
|
|
|
|
|
import numpy as np |
|
|
from PIL import Image |
|
|
|
|
|
from .base import ( |
|
|
DocumentFormat, |
|
|
DocumentInfo, |
|
|
DocumentLoader, |
|
|
PageInfo, |
|
|
PageRenderer, |
|
|
RenderOptions, |
|
|
) |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
class ImageLoader(DocumentLoader): |
|
|
""" |
|
|
Image document loader. |
|
|
|
|
|
Handles common image formats (JPEG, PNG, etc.) and multi-page TIFF. |
|
|
""" |
|
|
|
|
|
SUPPORTED_EXTENSIONS = { |
|
|
".jpg", ".jpeg", ".png", ".bmp", ".gif", |
|
|
".tif", ".tiff", ".webp" |
|
|
} |
|
|
|
|
|
def __init__(self): |
|
|
self._images: List[Image.Image] = [] |
|
|
self._info: Optional[DocumentInfo] = None |
|
|
self._path: Optional[Path] = None |
|
|
|
|
|
def load(self, path: Union[str, Path]) -> DocumentInfo: |
|
|
"""Load image(s) and extract metadata.""" |
|
|
self._path = Path(path) |
|
|
if not self._path.exists(): |
|
|
raise FileNotFoundError(f"Image file not found: {self._path}") |
|
|
|
|
|
suffix = self._path.suffix.lower() |
|
|
if suffix not in self.SUPPORTED_EXTENSIONS: |
|
|
raise ValueError(f"Unsupported image format: {suffix}") |
|
|
|
|
|
|
|
|
self.close() |
|
|
|
|
|
|
|
|
img = Image.open(self._path) |
|
|
|
|
|
|
|
|
if suffix in {".tif", ".tiff"}: |
|
|
self._load_multipage_tiff(img) |
|
|
else: |
|
|
|
|
|
self._images = [img.convert("RGB")] |
|
|
|
|
|
|
|
|
pages = [] |
|
|
for i, page_img in enumerate(self._images): |
|
|
dpi = page_img.info.get("dpi", (72, 72)) |
|
|
if isinstance(dpi, tuple): |
|
|
dpi = int(dpi[0]) |
|
|
else: |
|
|
dpi = int(dpi) |
|
|
|
|
|
page_info = PageInfo( |
|
|
page_number=i + 1, |
|
|
width_pixels=page_img.width, |
|
|
height_pixels=page_img.height, |
|
|
dpi=dpi, |
|
|
has_images=True |
|
|
) |
|
|
pages.append(page_info) |
|
|
|
|
|
|
|
|
if suffix in {".tif", ".tiff"} and len(self._images) > 1: |
|
|
doc_format = DocumentFormat.TIFF_MULTIPAGE |
|
|
else: |
|
|
doc_format = DocumentFormat.IMAGE |
|
|
|
|
|
self._info = DocumentInfo( |
|
|
path=self._path, |
|
|
format=doc_format, |
|
|
num_pages=len(self._images), |
|
|
pages=pages, |
|
|
file_size_bytes=self._path.stat().st_size, |
|
|
is_scanned=True, |
|
|
has_text_layer=False |
|
|
) |
|
|
|
|
|
return self._info |
|
|
|
|
|
def _load_multipage_tiff(self, img: Image.Image) -> None: |
|
|
"""Load all pages from a multi-page TIFF.""" |
|
|
self._images = [] |
|
|
|
|
|
try: |
|
|
page_num = 0 |
|
|
while True: |
|
|
img.seek(page_num) |
|
|
|
|
|
self._images.append(img.copy().convert("RGB")) |
|
|
page_num += 1 |
|
|
except EOFError: |
|
|
|
|
|
pass |
|
|
|
|
|
if not self._images: |
|
|
raise ValueError("No pages found in TIFF file") |
|
|
|
|
|
def close(self) -> None: |
|
|
"""Close all loaded images.""" |
|
|
for img in self._images: |
|
|
try: |
|
|
img.close() |
|
|
except Exception: |
|
|
pass |
|
|
self._images = [] |
|
|
|
|
|
def is_loaded(self) -> bool: |
|
|
"""Check if images are loaded.""" |
|
|
return len(self._images) > 0 |
|
|
|
|
|
@property |
|
|
def info(self) -> Optional[DocumentInfo]: |
|
|
"""Get document info.""" |
|
|
return self._info |
|
|
|
|
|
def get_image(self, page_number: int) -> Image.Image: |
|
|
"""Get PIL Image for a specific page (1-indexed).""" |
|
|
if not self._images: |
|
|
raise RuntimeError("No images loaded") |
|
|
if page_number < 1 or page_number > len(self._images): |
|
|
raise ValueError(f"Invalid page number: {page_number}") |
|
|
return self._images[page_number - 1] |
|
|
|
|
|
|
|
|
class ImageRenderer(PageRenderer): |
|
|
""" |
|
|
Image page renderer. |
|
|
|
|
|
Renders images with optional resizing and format conversion. |
|
|
""" |
|
|
|
|
|
def __init__(self, loader: ImageLoader): |
|
|
self._loader = loader |
|
|
|
|
|
def render_page( |
|
|
self, |
|
|
page_number: int, |
|
|
options: Optional[RenderOptions] = None |
|
|
) -> np.ndarray: |
|
|
"""Render an image page.""" |
|
|
if not self._loader.is_loaded(): |
|
|
raise RuntimeError("No document loaded") |
|
|
|
|
|
options = options or RenderOptions() |
|
|
img = self._loader.get_image(page_number) |
|
|
|
|
|
|
|
|
original_dpi = img.info.get("dpi", (72, 72)) |
|
|
if isinstance(original_dpi, tuple): |
|
|
original_dpi = original_dpi[0] |
|
|
|
|
|
|
|
|
if options.dpi != original_dpi and original_dpi > 0: |
|
|
scale = options.dpi / original_dpi |
|
|
new_size = (int(img.width * scale), int(img.height * scale)) |
|
|
|
|
|
resample = Image.LANCZOS if options.antialias else Image.NEAREST |
|
|
img = img.resize(new_size, resample=resample) |
|
|
|
|
|
|
|
|
if options.color_mode == "L": |
|
|
img = img.convert("L") |
|
|
elif options.color_mode == "RGBA": |
|
|
img = img.convert("RGBA") |
|
|
else: |
|
|
img = img.convert("RGB") |
|
|
|
|
|
return np.array(img) |
|
|
|
|
|
def render_pages( |
|
|
self, |
|
|
page_numbers: Optional[List[int]] = None, |
|
|
options: Optional[RenderOptions] = None |
|
|
) -> Iterator[Tuple[int, np.ndarray]]: |
|
|
"""Render multiple pages.""" |
|
|
if not self._loader.is_loaded(): |
|
|
raise RuntimeError("No document loaded") |
|
|
|
|
|
info = self._loader.info |
|
|
if page_numbers is None: |
|
|
page_numbers = list(range(1, info.num_pages + 1)) |
|
|
|
|
|
for page_num in page_numbers: |
|
|
yield page_num, self.render_page(page_num, options) |
|
|
|
|
|
|
|
|
def load_image(path: Union[str, Path]) -> Tuple[ImageLoader, ImageRenderer]: |
|
|
""" |
|
|
Convenience function to load an image document. |
|
|
|
|
|
Returns: |
|
|
Tuple of (loader, renderer) |
|
|
""" |
|
|
loader = ImageLoader() |
|
|
loader.load(path) |
|
|
renderer = ImageRenderer(loader) |
|
|
return loader, renderer |
|
|
|