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:
co-authored by
Claude Sonnet 5
parent
3ebfe06139
commit
982879d9f0
@@ -245,9 +245,16 @@ def generate_index(chapter: str, items: list[dict], source_root: Path,
|
||||
lines.append(" Die referenzierten Dokumente liegen im Projekt-Rohmaterial und werden")
|
||||
lines.append(" hier nicht dupliziert.")
|
||||
lines.append("")
|
||||
# Die Verzeichnistabelle enthaelt lange Quellpfade und ist fuer Hochformat zu
|
||||
# breit -> im LaTeX/PDF-Build quer (landscape) setzen. Fuer HTML wird der
|
||||
# raw:latex-Block ignoriert (dort bricht die Tabelle ohnehin um).
|
||||
lines.append(".. raw:: latex")
|
||||
lines.append("")
|
||||
lines.append(" \\begin{landscape}")
|
||||
lines.append("")
|
||||
lines.append(".. list-table::")
|
||||
lines.append(" :header-rows: 1")
|
||||
lines.append(" :widths: 34 18 12 36")
|
||||
lines.append(" :widths: 30 16 8 46")
|
||||
lines.append("")
|
||||
lines.append(" * - Dokument")
|
||||
lines.append(" - Kategorie")
|
||||
@@ -260,6 +267,10 @@ def generate_index(chapter: str, items: list[dict], source_root: Path,
|
||||
lines.append(f" - {human_size(it['size_bytes'])}")
|
||||
lines.append(f" - ``{it['rel_path']}``")
|
||||
lines.append("")
|
||||
lines.append(".. raw:: latex")
|
||||
lines.append("")
|
||||
lines.append(" \\end{landscape}")
|
||||
lines.append("")
|
||||
|
||||
rst_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
+201
-20
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user