Spaces:
Paused
Paused
File size: 7,027 Bytes
f66643d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """PDF page rendering utilities.
This module provides functions to render PDF pages to PIL Images
for Surya processing.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import fitz # PyMuPDF
from PIL import Image
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
def _extract_dominant_image(
page: fitz.Page,
coverage_threshold: float = 0.8,
) -> Image.Image | None:
"""Extract the dominant full-page embedded image, if one exists.
For scanned PDFs the page content is typically a single high-resolution
raster image embedded at 300+ DPI. Re-rendering through fitz at 150 DPI
halves the effective resolution and causes Surya to miss fine layout
structure. Extracting the raw embedded image preserves the original DPI.
Args:
page: fitz Page object
coverage_threshold: Minimum fraction of page area the image must cover
Returns:
PIL Image if a dominant embedded image is found, else None
"""
page_rect = page.rect
page_area = page_rect.width * page_rect.height
if page_area == 0:
return None
image_list = page.get_images(full=True)
best_xref = None
best_coverage = 0.0
for img_info in image_list:
xref = img_info[0]
try:
rects = list(page.get_image_rects(xref))
if not rects:
continue
covered = sum(r.width * r.height for r in rects) / page_area
if covered > best_coverage:
best_coverage = covered
best_xref = xref
except Exception:
continue
if best_xref is None or best_coverage < coverage_threshold:
return None
try:
pix = fitz.Pixmap(page.parent, best_xref)
# Normalize to RGB. pix.n counts all components including alpha.
# Grayscale (n=1), Gray+A (n=2), CMYK (n=4 no alpha), CMYK+A (n=5)
# all need conversion — only pure RGB (n=3, alpha=0) is already correct.
n_colors = pix.n - pix.alpha
if n_colors != 3:
pix = fitz.Pixmap(fitz.csRGB, pix)
mode = "RGBA" if pix.alpha else "RGB"
img = Image.frombytes(mode, (pix.width, pix.height), pix.samples)
if pix.alpha:
img = img.convert("RGB")
logger.debug(
f"Using embedded image (xref={best_xref}, "
f"{pix.width}×{pix.height}px, n_colors={n_colors}, coverage={best_coverage:.0%})"
)
return img
except Exception as e:
logger.debug(f"Could not extract embedded image xref={best_xref}: {e}")
return None
def render_page_to_image(
page: fitz.Page,
dpi: int = 150,
) -> Image.Image:
"""Render a PDF page for OCR — prefers native embedded image.
Prefers extracting the dominant full-page embedded image at its native
resolution. Higher resolution means clearer characters for OCR.
Falls back to fitz rendering when no dominant embedded image is found.
Args:
page: fitz Page object to render
dpi: Resolution used for fitz fallback rendering (default 150)
Returns:
PIL Image in RGB mode
"""
img = _extract_dominant_image(page)
if img is not None:
return img
return _fitz_render(page, dpi)
def render_page_for_layout(
page: fitz.Page,
dpi: int = 96,
) -> Image.Image:
"""Render a PDF page for layout detection — always uses fitz at fixed DPI.
Surya's layout model (and its sibling detection/reading-order models) is
calibrated to receive images rendered at IMAGE_DPI = 96. That is the DPI
that Surya itself uses internally (see surya/input/processing.py:
get_page_images). Passing images at a much higher DPI (e.g. 150-300)
causes the layout model to see the same document content at a larger pixel
scale than it was trained on, which results in collapsed or missed layout
bounding boxes.
This function always renders through fitz so that the image scale is
predictable regardless of whether embedded raster images exist.
Args:
page: fitz Page object to render
dpi: Target DPI for layout rendering (default 96, matching Surya IMAGE_DPI)
Returns:
PIL Image in RGB mode
"""
return _fitz_render(page, dpi)
def _fitz_render(page: fitz.Page, dpi: int) -> Image.Image:
"""Render a PDF page to a PIL Image using fitz at the specified DPI.
Converts ``dpi`` to a scale factor relative to fitz's default 72 DPI and
renders the page without an alpha channel (RGB mode).
Args:
page: fitz Page object to render.
dpi: Output resolution in dots per inch. Higher values produce larger
images with finer detail but require more memory.
Returns:
PIL Image in ``RGB`` mode at the requested DPI.
"""
zoom = dpi / 72.0
matrix = fitz.Matrix(zoom, zoom)
pixmap = page.get_pixmap(matrix=matrix, alpha=False)
return Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
def render_pages_batch(
doc: fitz.Document,
page_indices: list[int],
dpi: int = 150,
) -> list[Image.Image]:
"""Render multiple PDF pages to PIL Images.
Args:
doc: fitz Document object
page_indices: List of 0-based page indices to render
dpi: Resolution in dots per inch
Returns:
List of PIL Images in same order as page_indices
"""
images = []
for page_idx in page_indices:
page = doc[page_idx]
img = render_page_to_image(page, dpi=dpi)
images.append(img)
return images
def get_page_dimensions(page: fitz.Page) -> tuple[float, float]:
"""Get page dimensions in PDF points.
Args:
page: fitz Page object
Returns:
(width, height) in PDF points
"""
rect = page.rect
return rect.width, rect.height
def crop_image_to_bbox(
image: Image.Image,
bbox: list[float],
pdf_width: float,
pdf_height: float,
) -> Image.Image:
"""Crop a rendered image to a bounding box.
Args:
image: PIL Image rendered from PDF page
bbox: [x0, y0, x1, y1] in PDF points
pdf_width: Original page width in PDF points
pdf_height: Original page height in PDF points
Returns:
Cropped PIL Image
"""
img_width, img_height = image.size
# Calculate scale factors
scale_x = img_width / pdf_width
scale_y = img_height / pdf_height
# Convert bbox to image pixels
x0 = int(bbox[0] * scale_x)
y0 = int(bbox[1] * scale_y)
x1 = int(bbox[2] * scale_x)
y1 = int(bbox[3] * scale_y)
# Clamp to image bounds
x0 = max(0, min(x0, img_width))
y0 = max(0, min(y0, img_height))
x1 = max(0, min(x1, img_width))
y1 = max(0, min(y1, img_height))
# Ensure valid crop region
if x1 <= x0 or y1 <= y0:
# Return a small placeholder image
return Image.new("RGB", (1, 1), color=(255, 255, 255))
return image.crop((x0, y0, x1, y1))
|