Add package skeleton, source classification and raster loading
The pdf module is the whole of M1 except the debug overlay, which needs detection results to draw. Source type is detected per PDF by looking for a page-covering image, and is always reported for confirmation rather than applied silently (ADR 0004). Rasters are extracted via Pixmap(doc, xref) rather than by decoding extract_image() bytes, because MuPDF handles JBIG2 and CCITT scans that no image library will. Scanned pages are extracted at the embedded image's native resolution; only vector pages are rendered, at 600 DPI. Verified against the corpus: Elaman nalka (vector) renders 4959x7017, Ketun joululaulu (scan) loads 1653x2332, Engel (scan) 2552x3504 — and Engel's page 2 is 2480 wide where page 1 is 2552, so scan width varies within one PDF. tests/test_pdf.py builds its own PDFs so the check runs without corpus files, which are copyrighted and gitignored. Closes #1, #2, #3
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Command line entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from . import __version__
|
||||
from .pdf import SourceType, open_source, page_raster
|
||||
|
||||
|
||||
def _info(args: argparse.Namespace) -> int:
|
||||
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
|
||||
note = f" (detected {source.detected.value}, overridden)" if source.overridden else ""
|
||||
print(f"{source.path.name}: {source.type.value}{note}, {len(source)} pages")
|
||||
for i in range(len(source)):
|
||||
h, w = page_raster(source, i).shape
|
||||
print(f" p{i + 1:<3} {w}x{h}")
|
||||
source.close()
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="noteman-slicer",
|
||||
description="Cut score PDFs into noteman's slice images and markers.",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=__version__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
info = sub.add_parser("info", help="classify a PDF and report its page rasters")
|
||||
info.add_argument("pdf")
|
||||
info.add_argument(
|
||||
"--type",
|
||||
choices=[t.value for t in SourceType],
|
||||
help="override source-type detection",
|
||||
)
|
||||
info.set_defaults(func=_info)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""PDF input: classify a score source and hand back page rasters.
|
||||
|
||||
Two source types, never mixed within one PDF (docs/spec.md):
|
||||
|
||||
raster — a scan; every page carries one full-page image, and *that image
|
||||
is the scan*. It is extracted at its native resolution rather
|
||||
than re-rendered: real scans in this corpus run ~200 DPI, and
|
||||
re-rendering at 600 would triple the pixel count for no detail.
|
||||
vector — an engraving; nothing to extract, so the page is rendered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
|
||||
VECTOR_RENDER_DPI = 600
|
||||
|
||||
# An image covering at least this fraction of the page is the page's scan
|
||||
# rather than an illustration sitting on an engraving.
|
||||
_FULL_PAGE_AREA = 0.5
|
||||
|
||||
|
||||
class SourceType(Enum):
|
||||
RASTER = "raster"
|
||||
VECTOR = "vector"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Source:
|
||||
path: Path
|
||||
doc: pymupdf.Document
|
||||
type: SourceType
|
||||
detected: SourceType
|
||||
render_dpi: int = VECTOR_RENDER_DPI
|
||||
|
||||
@property
|
||||
def overridden(self) -> bool:
|
||||
"""True when the user's choice disagrees with detection."""
|
||||
return self.type is not self.detected
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.doc)
|
||||
|
||||
def close(self) -> None:
|
||||
self.doc.close()
|
||||
|
||||
|
||||
def _full_page_image(page: pymupdf.Page) -> int | None:
|
||||
"""xref of the image covering this page, or None."""
|
||||
page_area = abs(page.rect.get_area())
|
||||
if page_area <= 0:
|
||||
return None
|
||||
# full=True is required, or get_image_bbox rejects the item.
|
||||
for item in page.get_images(full=True):
|
||||
try:
|
||||
bbox = pymupdf.Rect(page.get_image_bbox(item))
|
||||
except ValueError:
|
||||
continue
|
||||
if abs(bbox.get_area()) >= page_area * _FULL_PAGE_AREA:
|
||||
return item[0]
|
||||
return None
|
||||
|
||||
|
||||
def classify(doc: pymupdf.Document) -> SourceType:
|
||||
"""Detection only — the caller confirms with the user (ADR 0004)."""
|
||||
scanned = sum(_full_page_image(page) is not None for page in doc)
|
||||
return SourceType.RASTER if scanned * 2 > len(doc) else SourceType.VECTOR
|
||||
|
||||
|
||||
def open_source(path: str | Path, source_type: SourceType | None = None) -> Source:
|
||||
"""Open a PDF. `source_type` overrides detection; it never silently wins."""
|
||||
path = Path(path)
|
||||
doc = pymupdf.open(path)
|
||||
detected = classify(doc)
|
||||
return Source(path=path, doc=doc, type=source_type or detected, detected=detected)
|
||||
|
||||
|
||||
def page_raster(source: Source, index: int) -> np.ndarray:
|
||||
"""One page as a grayscale array, at the resolution the pipeline should work at."""
|
||||
page = source.doc[index]
|
||||
|
||||
if source.type is SourceType.RASTER:
|
||||
xref = _full_page_image(page)
|
||||
if xref is not None:
|
||||
# Pixmap(doc, xref) rather than decoding extract_image() bytes:
|
||||
# MuPDF handles JBIG2 and CCITT, which no image library will.
|
||||
pix = pymupdf.Pixmap(source.doc, xref)
|
||||
return _to_gray(pix)
|
||||
# A scanned PDF whose page has no embedded image (a blank, or a
|
||||
# cover typeset in vector). Rendering is the only option left.
|
||||
|
||||
return _to_gray(page.get_pixmap(dpi=source.render_dpi, colorspace=pymupdf.csGRAY))
|
||||
|
||||
|
||||
def _to_gray(pix: pymupdf.Pixmap) -> np.ndarray:
|
||||
if pix.alpha or pix.colorspace is None or pix.colorspace.n != 1:
|
||||
pix = pymupdf.Pixmap(pymupdf.csGRAY, pix)
|
||||
return np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width)
|
||||
Reference in New Issue
Block a user