MicroQA/microqa/engine.py

272 lines
9.1 KiB
Python
Raw Normal View History

import re
from sys import stdout
from typing import Optional
2025-10-04 15:09:16 -07:00
import numpy as np
from PIL import Image
2025-10-04 15:09:16 -07:00
2025-12-20 02:16:41 +00:00
from .items import ArchiveDoc, ArchiveLeaf
from .ocr import OcrEngine, OcrResult, TextBlock
2025-10-04 15:09:16 -07:00
2025-12-20 02:16:41 +00:00
def analyze_doc(
doc: ArchiveDoc,
ocr_engine: OcrEngine,
use_cache: bool = False,
verbose: bool = False,
):
"""
Analyzes all pages in an ArchiveDoc for useful metrics such as sharpness,
orientation, presence of text overflows, and so on.
"""
2025-10-04 15:09:16 -07:00
if verbose:
print(f"Loading {doc.name}...")
stdout.flush()
2025-10-04 15:09:16 -07:00
2025-12-20 02:16:41 +00:00
all_leaves = doc.fetch_leaves(use_cache=use_cache)
2025-10-04 15:09:16 -07:00
if verbose:
2025-12-20 02:16:41 +00:00
print(f"Processing {len(all_leaves)} pages...", file=stdout)
stdout.flush()
2025-10-04 15:09:16 -07:00
2025-12-20 02:16:41 +00:00
analyzed_pages = []
for leaf in all_leaves:
im_normalized, is_blank = normalize_contrast_for_text(leaf.image)
if is_blank:
analyzed_pages.append(
{
"is_blank": True,
"page_angle": 0,
"size_analyzed": leaf.image.size,
"sharpness": None,
"text_margin_px": None,
}
2025-12-20 02:16:41 +00:00
)
2025-10-04 15:09:16 -07:00
else:
sharpness = analyze_sharpness(
# Exclude edges, which typically include the page border.
im_normalized.crop(
(
im_normalized.size[0] * 0.15,
im_normalized.size[1] * 0.15,
im_normalized.size[0] * 0.85,
im_normalized.size[1] * 0.85,
2025-12-20 02:16:41 +00:00
)
)
2025-10-04 15:09:16 -07:00
)
ocr_result, leaf = compute_ocr(leaf, im_normalized, ocr_engine)
page_angle = 0 if ocr_result is None else ocr_result.page_angle
text_margin_px = compute_text_margin_px(
im_normalized.size, leaf.text_blocks
2025-10-04 15:09:16 -07:00
)
# If OCR turns up issues based on the PDF's original text boxes,
# re-run it ourselves to help weed out false positives.
CLIPPING_THRESHOLD_PX = 30
ROT_THRESHOLD_DEG = 30
if ocr_result is None and (
ROT_THRESHOLD_DEG < page_angle < 360 - ROT_THRESHOLD_DEG
or text_margin_px < CLIPPING_THRESHOLD_PX
):
ocr_result, leaf = compute_ocr(
leaf, im_normalized, ocr_engine, force_recompute=True
)
assert ocr_result is not None, (
"compute_ocr(..., force_recompute=True) should always return an OcrResult"
)
page_angle = ocr_result.page_angle
text_margin_px = compute_text_margin_px(
im_normalized.size, leaf.text_blocks
)
assert page_angle is not None, (
"OCR engine should be running with page orientation detection"
)
analyzed_pages.append(
{
"is_blank": False,
"page_angle": page_angle,
"size_analyzed": leaf.image.size,
"sharpness": sharpness,
"text_margin_px": text_margin_px,
}
)
2025-12-20 02:16:41 +00:00
return {"pages": analyzed_pages}
2025-10-04 15:09:16 -07:00
def normalize_contrast_for_text(im: Image.Image) -> tuple[Image.Image, bool]:
2025-10-04 15:09:16 -07:00
"""
Most of the pages being analyzed, and virtually all of the pages we care
about for the purposes of QA, primarily contain text on a contrasting
background. We can therefore typically assume that it is reasonable to boost
contrast so that the lightest pixels are nearly white and the darkest pixels
are nearly black. This can help make analysis more consistent across leaves
with varying contrast ratios due to varied scanner settings, contrast ratios
of the original documnets, or weathering/fading of the physical fiche.
Processed leaves usually contain some amount of margin around the edges
where the backlight of the scanning rig is visible through the unexposed
region of the negative, so contrast detection is heavily center-weighted.
Params:
im Scan image as a 2-dimensional numpy array. (Use `np.asarray()` to
convert PIL `Image` objects to an array format.)
Returns:
(normalized_image, is_blank)
2025-10-04 15:09:16 -07:00
"""
pixel_values = np.asarray(
# Exclude edges, which typically include the page border.
im.crop(
(
im.size[0] * 0.15,
im.size[1] * 0.15,
im.size[0] * 0.85,
im.size[1] * 0.85,
)
2025-10-04 15:09:16 -07:00
)
)
# To avoid extreme outliers, use quantiles instead of absolute extrema.
extrema = (np.quantile(pixel_values, 0.002), np.quantile(pixel_values, 0.998))
if extrema[1] - extrema[0] < 64:
# Assume there is essentially no content here and return the original.
return im, True
# Apply a rudimentary tone curve to the image, with the goal that the
# extrema we just calculated will evaluate to values "pretty close to" 0%
# and 100% of the available range.
return im.point(
lambda x: np.interp(x, (0, extrema[0], extrema[1], 255), (0, 8, 247, 255))
), False
def analyze_sharpness(im: Image.Image) -> float:
"""
Attempts to quantify the sharpness an image, on a scale of 0 to 1.
"""
# Inferring sharpness by measuring the peak intensity of an edge detection/
# high pass filter over the image tends to produce different baseline
# results across documents. We've had much more luck with a direct gradient
# computation based on https://stackoverflow.com/a/26014796.
grad_y, grad_x = np.gradient(np.asarray(im))
return float(np.clip(np.quantile(np.sqrt(grad_x**2 + grad_y**2), 0.99) / 255, 0, 1))
def compute_ocr(
leaf: ArchiveLeaf,
im_normalized: Image.Image,
ocr_engine: OcrEngine,
force_recompute: bool = False,
) -> (Optional[OcrResult], ArchiveLeaf):
"""
OCR is computationally expensive, so we try to take advantage of
the Tesseract data already parsed by the Internet Archive and
embedded in the PDF, when possible. If there is not sufficient
text in the PDF to be confident that the Archive's OCR
postprocessing captured it all, then OCR is recomputed locally.
In some instances, the Archive's OCR detects rotated text but
parses it as gibberish. To partially mitigate this, we ignore all
precomputed text blocks with a "portrait" aspect ratio. This will
not necessarily help with text that is rotated 180 degrees, and will
not work well with non-latin scripts that are intended to be oriented
vertically.
Params:
leaf Information for the document page.
im_normalized Contrast-normalized image.
ocr_engine Engine to use as needed for OCR.
force_recompute If `True`, OCR is re-run even if there is already
text data associated with the leaf.
Returns:
Tuple of `OcrResult` and `ArchiveLeaf` if OCR was recomputed; otherwise
tuple of `None` and `ArchiveLeaf` if existing text data was reused.
"""
if not force_recompute:
PDF_OCR_THRESHOLD_WORDS = 30
pdf_word_count = sum(
(
len(block.text.split())
for block in leaf.text_blocks
if block.x1 - block.x0 > block.y1 - block.y0
)
)
if pdf_word_count >= PDF_OCR_THRESHOLD_WORDS:
return None, leaf
ocr_result = ocr_engine.process(im_normalized)
return ocr_result, ArchiveLeaf(
image=leaf.image,
page_number=leaf.page_number,
text_blocks=[
TextBlock(
x0=int(block.x0),
y0=int(block.y0),
x1=int(block.x1),
y1=int(block.y1),
text=block.text,
)
for block in ocr_result.blocks
],
)
def compute_text_margin_px(
im_size: tuple[int, int], text_blocks: list[TextBlock]
) -> Optional[int]:
"""
Infer the margins between OCR'ed text blocks and the edge of their page's
bounds. This helps to detect if adjacent content may be cropped out.
Params:
im_size Dimensions of the page image, in pixels.
text_blocks List of text blocks detected by OCR.
Returns:
Integer pixel count if text is present; otherwise `None`.
"""
block_margins = [
np.array(
[
block.x0,
block.y0,
im_size[0] - block.x1,
im_size[1] - block.y1,
]
)
for block in text_blocks
# Exclude text without clear alphanumeric substance.
if len(re.sub(r"[^a-zA-Z0-9]", "", block.text)) > 1
]
word_margins_all_directions = (
np.sort(np.concat(block_margins).astype(np.int_))
if len(block_margins) > 0
else np.array([])
)
# Skip the n closest words to the edge, to help ignore stray OCR artifacts.
SKIP_WORDS = 2
return (
int(word_margins_all_directions[SKIP_WORDS])
if word_margins_all_directions.shape[0] > SKIP_WORDS
else None
)