- prepare_corpus.py: split double-page scans (split_spreads.py), re-OCR with OCRmyPDF/Tesseract only where the existing text layer is worse, Docling layout extraction with heading hierarchy, QA report per PDF - docling_loader.py: cleanup (margin noise, split paragraphs, footnote marks, OCR comma/period repair), page mapping back to the original PDF, chunk headers with author, title and section - local_rag.py: bge-m3 embeddings, configurable chunking and loader, index settings check (index_info.json), hybrid/union BM25 search with Reciprocal Rank Fusion and bge-reranker-v2-m3 cross-encoder; query instruction text removed after it lowered recall - eval_retrieval.py: CLI options, same retrieval path as the chat, tolerant file name matching; goldsets and evaluation reports for the Kafka corpus - grobid_test.py, grobid_qa.py, extract_docling.py, compare_extractors.py: extractor tests and quality checks - .gitignore: corpus PDFs, vector stores and extractor output stay local - server.py: uses ./vectorstore_docling_hdr Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.4 KiB
Python
103 lines
4.4 KiB
Python
"""
|
|
Trennt Doppelseiten-Scans in Einzelseiten, bevor OCR und Docling sie lesen.
|
|
|
|
Bei den meisten Titeln im Kafka-Korpus liegen zwei Buchseiten auf einer PDF-Seite
|
|
(Querformat). Tesseract liest dann stellenweise quer über den Falz, Doclings
|
|
Layout-Modell trennt Absätze an der Seitenmitte. Hier wird jede Querformat-Seite
|
|
in zwei Seiten zugeschnitten: Der Falz wird im Bild gesucht, als die senkrechte
|
|
Spalte in der Seitenmitte mit den wenigsten Hell-Dunkel-Wechseln (Text erzeugt
|
|
viele, Innenrand und Falzschatten kaum welche). Bildinhalt und Textschicht bleiben
|
|
unverändert, nur der sichtbare Seitenbereich (MediaBox/CropBox) wird gesetzt —
|
|
Docling und OCRmyPDF halten sich daran.
|
|
|
|
Neben jedes getrennte PDF wird <pdf>.pages.json geschrieben: zu jeder neuen Seite
|
|
die Seite im Original-PDF (ab 0) und die Hälfte ("L", "R" oder "" für ungeteilt).
|
|
docling_loader.py rechnet Seitenangaben damit auf das Original zurück.
|
|
|
|
Benutzt von prepare_corpus.py; läuft in der Docling-Umgebung (pypdfium2, numpy).
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pypdfium2 as pdfium
|
|
from pypdf import PdfReader, PdfWriter
|
|
from pypdf.generic import RectangleObject
|
|
|
|
LANDSCAPE_RATIO = 1.15 # Breite/Höhe, ab der eine Seite als Doppelseite gilt
|
|
SEARCH_BAND = (0.35, 0.65) # Falz wird in diesem Bereich der Seitenbreite gesucht
|
|
RENDER_SCALE = 0.5 # grobes Vorschaubild genügt für die Falzsuche
|
|
|
|
|
|
def find_gutter(page: pdfium.PdfPage) -> float:
|
|
"""Lage des Falzes als Anteil der Seitenbreite (0..1)."""
|
|
gray = np.asarray(page.render(scale=RENDER_SCALE).to_pil().convert("L"), dtype=np.int16)
|
|
h, w = gray.shape
|
|
band = gray[int(h * 0.08):int(h * 0.92)] # Ober- und Unterkante meiden
|
|
transitions = (np.abs(np.diff(band, axis=0)) > 40).sum(axis=0).astype(float)
|
|
k = max(3, w // 100)
|
|
smooth = np.convolve(transitions, np.ones(k) / k, mode="same")
|
|
lo, hi = int(w * SEARCH_BAND[0]), int(w * SEARCH_BAND[1])
|
|
window = smooth[lo:hi]
|
|
# Mitte des längsten Abschnitts nahe dem Minimum: der leere Streifen zwischen den Seiten
|
|
quiet = window <= window.min() + 0.15 * (np.median(window) - window.min())
|
|
best, start, run = (0, 0), None, []
|
|
for i, q in enumerate(list(quiet) + [False]):
|
|
if q and start is None:
|
|
start = i
|
|
elif not q and start is not None:
|
|
best = max(best, (i - start, start))
|
|
start = None
|
|
length, first = best
|
|
return (lo + first + length / 2) / w
|
|
|
|
|
|
def split_pdf(src: Path, dst: Path) -> list[dict]:
|
|
"""Schreibt das getrennte PDF nach dst und liefert die Seitenzuordnung."""
|
|
reader = PdfReader(src)
|
|
preview = pdfium.PdfDocument(str(src))
|
|
writer = PdfWriter()
|
|
mapping = []
|
|
for i, page in enumerate(reader.pages):
|
|
box = page.mediabox
|
|
left, bottom, right, top = (float(v) for v in (box.left, box.bottom, box.right, box.top))
|
|
rotated = int(page.get("/Rotate", 0) or 0) % 180 != 0
|
|
if rotated or (right - left) <= LANDSCAPE_RATIO * (top - bottom):
|
|
writer.add_page(page)
|
|
mapping.append({"original_page": i, "half": ""})
|
|
continue
|
|
x = left + find_gutter(preview[i]) * (right - left)
|
|
for half, (x0, x1) in (("L", (left, x)), ("R", (x, right))):
|
|
new = writer.add_page(page)
|
|
rect = RectangleObject([x0, bottom, x1, top])
|
|
new.mediabox = rect
|
|
new.cropbox = rect
|
|
mapping.append({"original_page": i, "half": half, "gutter_x": round(x, 1)})
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(dst, "wb") as f:
|
|
writer.write(f)
|
|
pages_json(dst).write_text(json.dumps(mapping, indent=1), encoding="utf-8")
|
|
return mapping
|
|
|
|
|
|
def pages_json(pdf: Path) -> Path:
|
|
return pdf.with_name(pdf.stem + ".pages.json")
|
|
|
|
|
|
def is_spread_pdf(pdf: Path, min_share: float = 0.5) -> bool:
|
|
"""Liegt bei mindestens min_share der Seiten Querformat vor?"""
|
|
pages = PdfReader(pdf).pages
|
|
land = sum(float(p.mediabox.width) > LANDSCAPE_RATIO * float(p.mediabox.height) for p in pages)
|
|
return land >= min_share * len(pages)
|
|
|
|
|
|
def bounded_page_texts(pdf: Path) -> list[str]:
|
|
"""Text jeder Seite, auf den sichtbaren Bereich begrenzt (pypdf ignoriert den Zuschnitt)."""
|
|
doc = pdfium.PdfDocument(str(pdf))
|
|
texts = []
|
|
for page in doc:
|
|
left, bottom, right, top = page.get_cropbox()
|
|
texts.append(page.get_textpage().get_text_bounded(left, bottom, right, top))
|
|
return texts
|