fix: Ueberschriften-Erkennung, Bild-Referenzierung und Symbol-Qualitaet

Behebt mehrere Extraktionsprobleme in lib/pdf_extract.py, die zu doppelter
Nummerierung, sinnentleertem Text und falsch dimensionierten Symbolen fuehrten:

- Ueberschriften nur noch bei grosser/fetter Quellschrift (>= 12.5pt oder bold)
  statt bei jeder Zeile mit Nummern-Praefix. Vorher wurden hunderte normale
  Textzeilen (Querverweise, Aufzaehlungen) faelschlich zu Ueberschriften.
- Bildunterschriften ("Abbildung N: ...") werden nie mehr als Ueberschrift
  gerendert (Caption-Erkennung vor Heading-Check, Merge-Regel verschaerft),
  sondern als kursiver Absatz bzw. figure-Caption.
- Symbole (Warn-/Gebots-/Verbotspiktogramme) werden per Piktogramm-Heuristik
  (Signalfarben + wenige Flaechenfarben) erkannt statt nur ueber Haeufigkeit -
  findet auch Symbole, die im Dokument nur 1x vorkommen (z.B. Warndreiecke).
  Auf quadratische Canvas normalisiert (normalize_symbol) und einheitlich
  klein referenziert (:height: 10mm, :align: left), damit alle Symbole
  unabhaengig vom Seitenverhaeltnis der Quelle gleich gross erscheinen.
- Junk-Erkennung erweitert: gerenderte Kapitelnummern-Bilder, Barcodes und
  Foto-Ausschnitte werden nicht mehr als Symbol/Inhaltsbild extrahiert.
- Manuelle SYMBOL_BLOCKLIST (aHash-basiert) fuer sichtgepruefte Fehlklassi-
  fikationen (Foto-/3D-/Diagramm-Fragmente), damit sie bei erneuter
  Konvertierung nicht wieder auftauchen.
- Verzeichnislisten (Lieferantendoku etc.) werden im LaTeX-Build quer
  (landscape via pdflscape) gesetzt, da die langen Quellpfade in Hochformat
  nicht passten.

assets/shared/symbols/: auf normalisierte .png umgestellt, 7 Fehlklassi-
fikationen (Fotos/3D-Renderings/Diagramme statt Piktogramme) entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-09-23 15:23:29 +02:00
co-authored by Claude Sonnet 5
parent 3ebfe06139
commit 982879d9f0
22 changed files with 217 additions and 21 deletions
+201 -20
View File
@@ -30,10 +30,20 @@ from pathlib import Path
log = logging.getLogger(__name__)
# Schwellwerte fuer die Symbol-/Icon-Erkennung
SYMBOL_MAX_EDGE = 260 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat
SYMBOL_MAX_EDGE = 340 # Bilder <= dieser Kantenlaenge gelten als Symbol-Kandidat
# (Sicherheitspiktogramme sind oft ~300 px)
SYMBOL_MIN_COUNT = 2 # ... wenn dasselbe Bild mind. so oft vorkommt
AHASH_THRESHOLD = 6 # max. Hamming-Distanz, ab der zwei aHashes als gleich gelten
# Manuelle Blockliste: untere 32 Bit des aHash von Bildern, die faelschlich als
# Symbol erkannt wurden (Fotos/3D-Renderings/Diagramm-Fragmente, KEINE echten
# Sicherheitspiktogramme). Diese werden nie als Symbol behandelt und nicht in
# assets/shared/symbols abgelegt/referenziert. (Kuratiert nach Sichtpruefung.)
SYMBOL_BLOCKLIST = {
0x1d0580bf, 0x71c70f3f, 0x808081ff, 0x818183ff,
0x81f1f3f3, 0xc3c1f7ff, 0xdbc3e7e7,
}
def average_hash(image_bytes: bytes, size: int = 8) -> int:
"""Perceptual Average-Hash (aHash) eines Bildes als Integer.
@@ -64,20 +74,133 @@ def hamming(a: int, b: int) -> int:
return bin(a ^ b).count("1")
def normalize_symbol(image_bytes: bytes, canvas: int = 200) -> bytes:
"""Zentriert ein Symbol auf einer quadratischen weissen Canvas.
So erscheinen alle Warn-/Gebots-/Verbotssymbole - unabhaengig vom Seiten-
verhaeltnis und der Aufloesung der Quelle - bei gleicher RST-Hoehe gleich
gross. Gibt PNG-Bytes zurueck.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
w, h = im.size
scale = (canvas - 16) / max(w, h) # 8 px Rand ringsum
new = im.resize((max(1, int(w * scale)), max(1, int(h * scale))))
bg = Image.new("RGB", (canvas, canvas), (255, 255, 255))
bg.paste(new, ((canvas - new.width) // 2, (canvas - new.height) // 2))
buf = io.BytesIO()
bg.save(buf, format="PNG")
return buf.getvalue()
except Exception:
return image_bytes
# --- Bild-Klassifizierung -------------------------------------------------
SKETCH_MIN_BYTES = 10_000 # SW-Grafik groesser als ~10 KB gilt als Sketch
def white_ratio(image_bytes: bytes) -> float:
"""Anteil (nahezu) weisser Pixel (Helligkeit > 230) im Bild [0..1]."""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("L")
data = list(im.getdata())
if not data:
return 0.0
step = max(1, len(data) // 400)
sample = data[::step]
return sum(1 for p in sample if p > 230) / len(sample)
except Exception:
return 0.0
def _dominant_color_count(im, thresh: float = 0.02) -> int:
"""Anzahl 'dominanter' Farben (>= thresh Anteil) in grober 16er-Quantisierung.
Piktogramme haben wenige Flaechenfarben (<= ~7), Fotos/Barcodes viele (>= 8).
"""
from collections import Counter
small = im.resize((48, 48))
c: Counter = Counter((r // 48, g // 48, b // 48) for r, g, b in small.getdata())
total = sum(c.values()) or 1
return sum(1 for v in c.values() if v / total >= thresh)
def _few_colors(image_bytes: bytes, max_colors: int = 7) -> bool:
"""True, wenn das Bild wenige dominante Flaechenfarben hat (Piktogramm).
Fotos und Barcodes haben viele Farbabstufungen und werden ausgeschlossen.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
return _dominant_color_count(im) <= max_colors
except Exception:
return False
def looks_like_pictogram(image_bytes: bytes) -> bool:
"""True, wenn ein Bild wie ein Warn-/Gebots-/Verbotssymbol aussieht.
Piktogramme (Warndreieck gelb, Gebot blau, Verbot rot/weiss) bestehen aus
WENIGEN kraeftigen Flaechenfarben + viel Weiss/Schwarz. Kennzeichen:
- hoher Anteil an Signalfarben (gelb/orange, kraeftiges blau, rot) UND
- wenige dominante Farben (<= 7) -> schliesst Fotos & Barcodes aus, die zwar
blaue/farbige Anteile haben, aber viele Farbabstufungen.
"""
from PIL import Image
try:
im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
data = list(im.getdata())
if not data:
return False
# Foto/Barcode -> kein Piktogramm. Grenze 9 (nicht 7): JPEG-komprimierte
# Verbots-/Gebotssymbole haben durch Randartefakte etwas mehr Farben.
if _dominant_color_count(im) > 9:
return False
step = max(1, len(data) // 500)
sample = data[::step]
n = len(sample)
signal = 0
for r, g, b in sample:
# gelb/orange: hohes R+G, niedriges B
if r > 180 and g > 150 and b < 110:
signal += 1
# kraeftiges blau (Gebotssymbol): b deutlich groesser als r
elif b > 110 and b - r > 50 and r < 130:
signal += 1
# rot (Verbotssymbol): r deutlich groesser als g und b
elif r > 150 and r - g > 70 and r - b > 70:
signal += 1
return signal / n > 0.12
except Exception:
return False
def is_junk_fragment(image_bytes: bytes, w: int, h: int) -> bool:
"""True fuer wertlose Text-/Nummern-Fragmente (kein echtes Symbol/Bild).
Typisch: flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel, die in
Wahrheit gerenderte Kapitelnummern/Beschriftungen sind (z.B. "5.2.6",
zwei Quadrate + Zahlen). Diese werden verworfen.
Erkennt:
a) flache (Hoehe <= 55 px), breite, schwarz-weisse Schnipsel (gerenderte
Beschriftungen/Kapitelnummern wie "5.2.6").
b) kleine, schwarz-weisse Bilder mit sehr hohem Weissanteil und geringer
Datenmenge - das sind gerenderte Kapitelnummern ("3.2.3", "3.2.4"), die
als eigenstaendige Grafik im PDF liegen (viel Weissraum, duenne Ziffern).
"""
# a) flache breite SW-Schnipsel
if h <= 55 and w / max(h, 1) >= 1.6 and is_grayscale(image_bytes):
return True
# b) kleine SW-Nummerngrafik: kleine Flaeche, SW, sehr wenig "Tinte"
if (max(w, h) <= 200 and len(image_bytes) < 4000
and is_grayscale(image_bytes) and white_ratio(image_bytes) > 0.80):
return True
return False
@@ -395,8 +518,23 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
logo_registry[c["ahash"]] = idx
continue
is_symbol = (max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
and c["count"] >= SYMBOL_MIN_COUNT)
# Symbol-Erkennung (Warn-/Gebots-/Verbotszeichen, Bedien-Piktogramme):
# klein (<= 260px) UND quadratisch-aehnlich (ratio 0.55..1.8) UND
# entweder Piktogramm-Form (Signalfarben) ODER (mehrfach + heller Grund).
# Piktogramme werden so auch erkannt, wenn sie im Dokument nur 1x vorkommen
# (Warndreiecke stehen oft einzeln), Foto-Ausschnitte dagegen nicht.
# Manuell geblockte Fehlklassifikationen nie als Symbol behandeln.
# Vergleich ueber die unteren 32 Bit des aHash (= Wert im Dateinamen);
# bis zu 2 abweichende Bits werden toleriert (JPEG-/Skalierungsvarianz).
low = c["ahash"] & 0xffffffff
blocked = any(bin(low ^ b).count("1") <= 2 for b in SYMBOL_BLOCKLIST)
small = max(c["w"], c["h"]) <= SYMBOL_MAX_EDGE
squarish = 0.55 <= ratio <= 1.8
few_colors = _few_colors(c["data"], max_colors=9) # Piktogramm: wenige Farben
is_symbol = (not blocked) and small and squarish and few_colors and (
looks_like_pictogram(c["data"])
or (c["count"] >= SYMBOL_MIN_COUNT and white_ratio(c["data"]) > 0.35)
)
cluster_is_symbol[ci] = is_symbol
if is_symbol:
match = None
@@ -406,8 +544,9 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
break
if match is None:
symbols_dir.mkdir(parents=True, exist_ok=True)
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.{c['ext']}"
(symbols_dir / match).write_bytes(c["data"])
# auf quadratische Canvas normalisieren -> immer als PNG ablegen
match = f"symbol_{c['ahash'] & 0xffffffff:08x}.png"
(symbols_dir / match).write_bytes(normalize_symbol(c["data"]))
symbol_registry[c["ahash"]] = match
cluster_sympath[ci] = f"{sym_rel_prefix}/{match}"
@@ -423,10 +562,13 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
e = elements[i]
if e["kind"] == "text":
nxt = elements[i + 1] if i + 1 < len(elements) else None
# a) Gliederungsnummer + Titel
# a) Gliederungsnummer + Titel. NUR mehrteilige Nummern (z.B. "2.3.1"),
# nicht einzelne lose Ziffern (Bildlegenden-Nummern wie "2", "24"),
# und NICHT, wenn die Folgezeile eine Bildunterschrift ist.
m = RE_NUM_ONLY.match(e["text"])
if (m and nxt and nxt["kind"] == "text"
and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])):
if (m and "." in m.group(1) and nxt and nxt["kind"] == "text"
and re.match(r"[A-Za-zÄÖÜäöüß]", nxt["text"])
and not RE_CAPTION.match(nxt["text"])):
merged.append({**e, "text": f"{m.group(1)} {nxt['text']}",
"size": max(e["size"], nxt["size"])})
i += 2
@@ -511,9 +653,29 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
# zusammenfuehren (PDF-Zeilenumbrueche sind kein Absatzende).
para: list[str] = []
def _join_para(lines: list[str]) -> str:
"""Fuegt Absatzzeilen zusammen und entfernt PDF-Silbentrennung.
Endet eine Zeile auf "wort-" (Trennstrich nach Kleinbuchstabe), wird sie
ohne Leerzeichen und ohne Bindestrich mit dem naechsten Wort verbunden
("Anlagenkompo-" + "nente" -> "Anlagenkomponente"). Ein Bindestrich nach
Grossbuchstabe/Ziffer (Kompositum wie "ILS-", "24-") bleibt erhalten.
"""
result = ""
for i, ln in enumerate(lines):
ln = ln.strip()
if not result:
result = ln
continue
if re.search(r"[a-zäöüß]-$", result):
result = result[:-1] + ln # Silbentrennung aufloesen
else:
result += " " + ln
return result.strip()
def flush_para() -> None:
if para:
out.append(" ".join(para).strip())
out.append(_join_para(para))
out.append("")
para.clear()
@@ -528,12 +690,15 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
if not path:
continue
if e.get("is_symbol"):
# Symbol/Warnzeichen: kleine, zentrierte Grafik ohne Caption-Box,
# verweist auf die eine zentrale Quelle in assets/shared/symbols.
# Symbol/Warnzeichen: einheitlich kleine Grafik, links ausgerichtet
# (Piktogramm-Charakter, daneben steht der Warntext). Feste Hoehe UND
# Breite begrenzen, damit alle Symbole - unabhaengig vom Seiten-
# verhaeltnis der Quelle - gleich gross erscheinen.
out.append("")
out.append(f".. image:: {path}")
out.append(" :align: center")
out.append(" :height: 12mm")
out.append(" :align: left")
out.append(" :height: 10mm")
out.append(" :class: warnsymbol")
out.append("")
blocks += 1
continue
@@ -558,14 +723,30 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
continue
text = e["text"]
# Bildunterschriften ("Abbildung N: ...") sind NIE Ueberschriften. Wenn sie
# nicht direkt als figure-Caption konsumiert wurden (z.B. weil Text
# dazwischen stand), als eigenen (kursiven) Absatz ausgeben.
if RE_CAPTION.match(text):
flush_para()
out.append(f"*{text}*")
out.append("")
blocks += 1
continue
hm = RE_HEADING.match(text)
# Ueberschrift NUR mit fuehrender PDF-Gliederungsnummer akzeptieren und
# offensichtliche Fragmente/Bildlegenden ausschliessen (sonst werden
# Zellen wie "POS", "li.", "re.", "bar, max. 10 bar" zu Ueberschriften).
# Ueberschrift NUR, wenn ALLE Kriterien erfuellt sind:
# 1. fuehrende Gliederungsnummer ("N", "N.N", ...),
# 2. Quell-Schrift ist gross (>= 12.5pt) ODER fett -> unterscheidet echte
# Ueberschriften von normalen Textzeilen, die zufaellig mit einer Nummer
# beginnen (Kapitelverweise "gemaess 2.8.2 ...", nummerierte Aufzaehlungen).
# Das war die Hauptursache falscher Ueberschriften.
# 3. kein offensichtliches Fragment/Bildlegende ("POS", "li.", "5 mm", ...).
is_heading = False
if hm and len(hm.group(2)) < 120:
num, rest = hm.group(1), hm.group(2)
rest_stripped = rest.strip()
looks_like_title = e.get("size", 0) >= 12.5 or e.get("bold", False)
is_frag = (
len(rest_stripped) < 4 # zu kurz ("POS", "li.")
or rest_stripped.endswith((",",)) # abgeschnittene Zelle
@@ -574,7 +755,7 @@ def extract_manual(pdf_path: Path, rst_path: Path, img_dir: Path,
or re.match(r"^(li|re|oben|unten|vorne|hinten|links|rechts)\.?$",
rest_stripped, re.I)
)
if not is_frag:
if looks_like_title and not is_frag:
is_heading = True
if is_heading: