# -*- coding: utf-8 -*- """Gegenprobe zur Kreuzerkennung: die Tintendichte im Kästchen-Inneren. ## Warum es dieses Skript gibt `parse_catalog.py` entscheidet allein über Vektorpfade: Ein Ankreuzkästchen gilt als angekreuzt, wenn zwei Diagonalsegmente vollständig darin liegen (`PageData.is_checked`). Diese Entscheidung trägt den halben Katalog – aus ihr folgt, welche Antwort richtig ist. Sie mit denselben Vektoren nachzuprüfen, belegt nichts: Ein Denkfehler in der Geometrie stünde in Prüfung und Umsetzung gleichermaßen. Diese Probe geht deshalb einen zweiten, unabhängigen Weg. Sie rendert dieselben Kästchen bei 8-facher Vergrößerung als Graustufenbild und zählt die dunklen Bildpunkte innerhalb des Rahmens. Beide Verfahren teilen genau eine Annahme – wo ein Kästchen liegt –, und keine einzige darüber, was ein Kreuz ist. ## Was die Messung am Original ergeben hat (Katalogstand 16.12.2024) Kästchen gesamt 1430 Vektor sagt LEER 801 Tintendichte 0,0000 bis 0,0000 Vektor sagt KREUZ 629 Tintendichte 0,1653 bis 0,1978 Widersprüche 0 Das Entscheidende ist nicht der Trennwert, sondern die **Lücke**: Zwischen der dunkelsten leeren und dem hellsten angekreuzten Kästchen liegen 0,165 – rund das Achtfache dessen, was ein Schwellwert an Spielraum bräuchte. Wo eine Erkennung eine solche Lücke hat, ist sie nicht knapp richtig, sondern eindeutig. Anlass der Messung waren die 28 Fragen, bei denen **alle** Optionen angekreuzt sind (Verdacht: Layout nicht erkannt, vorsichtshalber alles genommen); auch deren Kästchen liegen sämtlich in der oberen Gruppe. ## Ausführen python data-pipeline/diagnose_kreuze.py # ganzer Katalog python data-pipeline/diagnose_kreuze.py --seiten 6-20 # Ausschnitt python data-pipeline/diagnose_kreuze.py --selbsttest # die Probe prüfen Exit-Code 0, wenn beide Verfahren übereinstimmen und die Lücke bleibt; sonst 1. Voraussetzung ist allein PyMuPDF – dieselbe und einzige Abhängigkeit, die `parse_catalog.py` schon mitbringt. """ from __future__ import annotations import argparse import sys from dataclasses import dataclass from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] import fitz # noqa: E402 import parse_catalog as pc # noqa: E402 WURZEL = Path(__file__).resolve().parent.parent PDF_VORGABE = WURZEL / "Fragenkatalog_sachkunde_mitAntworten.pdf" # 8-fach: Ein Kästchen von ~19 pt Kantenlänge wird damit zu ~150 Bildpunkten # je Seite. Feiner brächte nichts – die Strichstärke des Kreuzes liegt bereits # bei mehreren Bildpunkten –, gröber ließe dünne Striche verschwinden. ZOOM = 8.0 # Abstand zum Rahmen in pt. Der Rahmen ist selbst schwarz; ohne diesen Rand # maße die Probe ihn mit und fände in jedem Kästchen Tinte. RAND = 1.6 # Bis 127 gilt ein Bildpunkt als dunkel. Die Vorlage kennt nur Schwarz auf # Weiß; der Wert liegt in der Mitte und ist damit unkritisch. DUNKELSCHWELLE = 128 # Grenzwerte der Bewertung, hergeleitet aus der oben festgehaltenen Messung: # leere Kästchen maßen 0,0000, angekreuzte mindestens 0,1653. Die Schranken # lassen also reichlich Luft und schlagen erst an, wenn die Trennung wirklich # zu wackeln beginnt. LEER_HOECHSTENS = 0.02 KREUZ_MINDESTENS = 0.10 MINDESTABSTAND = 0.05 @dataclass(frozen=True) class Kaestchen: """Ein vermessenes Ankreuzkästchen.""" seite: int y: float dichte: float vektor_kreuz: bool # ------------------------------------------------------------------ Messung def messe(doc: fitz.Document, von: int, bis: int) -> list[Kaestchen]: """Vermisst alle Kästchen der Seiten `von` bis `bis` (1-basiert). Die Umsetzungstabelle macht aus dem Graustufenbild eine Maske und lässt `bytes.count` zählen. Eine Python-Schleife über 23 Millionen Bildpunkte dauerte Minuten, das hier Sekunden – dieselbe Zahl kommt heraus. """ maske = bytes(255 if wert < DUNKELSCHWELLE else 0 for wert in range(256)) werte: list[Kaestchen] = [] for pno in range(von, bis + 1): seite = doc[pno - 1] daten = pc.PageData(seite, pno) for kasten in daten.checkboxes: innen = fitz.Rect( kasten.x0 + RAND, kasten.y0 + RAND, kasten.x1 - RAND, kasten.y1 - RAND ) bild = seite.get_pixmap( matrix=fitz.Matrix(ZOOM, ZOOM), clip=innen, colorspace=fitz.csGRAY ) proben = bild.samples dunkel = proben.translate(maske).count(255) werte.append( Kaestchen( seite=pno, y=kasten.y0, dichte=dunkel / max(1, len(proben)), vektor_kreuz=daten.is_checked(kasten), ) ) return werte # ----------------------------------------------------------------- Bewertung @dataclass(frozen=True) class Befund: """Das Ergebnis einer Messreihe, samt aller Zahlen für den Bericht.""" leer: int kreuz: int leer_min: float leer_max: float kreuz_min: float kreuz_max: float schwelle: float widersprueche: list[Kaestchen] beanstandungen: list[str] @property def abstand(self) -> float: return self.kreuz_min - self.leer_max def bewerte(werte: list[Kaestchen]) -> Befund | None: """Vergleicht beide Verfahren. `None`, wenn nichts zu vergleichen war.""" leer = [k for k in werte if not k.vektor_kreuz] kreuz = [k for k in werte if k.vektor_kreuz] if not leer or not kreuz: return None leer_max = max(k.dichte for k in leer) kreuz_min = min(k.dichte for k in kreuz) # Der Trennwert wird nicht vorgegeben, sondern aus der Messung genommen: # genau zwischen den beiden Gruppen. Überlappen sie, liegt er mitten im # Gedränge und die Widersprüche werden sichtbar – das ist gewollt. schwelle = (leer_max + kreuz_min) / 2 widersprueche = [k for k in werte if (k.dichte > schwelle) != k.vektor_kreuz] beanstandungen: list[str] = [] if widersprueche: beanstandungen.append( f"{len(widersprueche)} Kästchen: Vektorurteil und Tintendichte " f"widersprechen sich." ) if leer_max > LEER_HOECHSTENS: beanstandungen.append( f"Ein leeres Kästchen trägt Tinte: {leer_max:.4f} > {LEER_HOECHSTENS:.4f}." ) if kreuz_min < KREUZ_MINDESTENS: beanstandungen.append( f"Ein angekreuztes Kästchen ist fast leer: " f"{kreuz_min:.4f} < {KREUZ_MINDESTENS:.4f}." ) if kreuz_min - leer_max < MINDESTABSTAND: beanstandungen.append( f"Die Lücke zwischen beiden Gruppen ist auf " f"{kreuz_min - leer_max:.4f} geschrumpft (mindestens " f"{MINDESTABSTAND:.4f}) – aus der Lücke würde ein Schwellwert." ) return Befund( leer=len(leer), kreuz=len(kreuz), leer_min=min(k.dichte for k in leer), leer_max=leer_max, kreuz_min=kreuz_min, kreuz_max=max(k.dichte for k in kreuz), schwelle=schwelle, widersprueche=widersprueche, beanstandungen=beanstandungen, ) def bericht(werte: list[Kaestchen], befund: Befund | None) -> None: """Gibt die Messwerte aus – auch dann, wenn nichts zu beanstanden ist.""" print(f"Kästchen vermessen: {len(werte)}") if befund is None: print(" Eine der beiden Gruppen ist leer – hier gibt es nichts zu trennen.") return print( f" Vektor sagt LEER : {befund.leer:5d} Tintendichte " f"{befund.leer_min:.4f} bis {befund.leer_max:.4f}" ) print( f" Vektor sagt KREUZ : {befund.kreuz:5d} Tintendichte " f"{befund.kreuz_min:.4f} bis {befund.kreuz_max:.4f}" ) print(f" Lücke zwischen den Gruppen: {befund.abstand:.4f}") print(f" Daraus abgeleiteter Trennwert: {befund.schwelle:.4f}") print(f" Widersprüche Vektor gegen Tinte: {len(befund.widersprueche)}") for k in befund.widersprueche[:20]: urteil = "KREUZ" if k.vektor_kreuz else "LEER " print(f" S.{k.seite:3d} y={k.y:7.2f} Vektor {urteil} Dichte {k.dichte:.4f}") if len(befund.widersprueche) > 20: print(f" … und {len(befund.widersprueche) - 20} weitere") # --------------------------------------------------------------- Selbsttest def selbsttest(doc: fitz.Document) -> int: """Prüft die Probe. Eine Probe, die nichts beanstandet, ist von einer funktionierenden nicht zu unterscheiden – dieselbe Regel wie in `pruefe_erklaerungen.py`. Deshalb wird jedes Fehlerbild einmal absichtlich erzeugt: an echten Messwerten, nicht an erfundenen, damit die Fälle auch dann noch stimmen, wenn eine neue Katalogfassung andere Zahlen bringt. """ seiten_bis = min(pc.FIRST_CONTENT_PAGE + 5, doc.page_count) echte = messe(doc, pc.FIRST_CONTENT_PAGE + 1, seiten_bis) grundbefund = bewerte(echte) if grundbefund is None: print("Selbsttest nicht durchführbar: Die Probeseiten tragen nur eine Gruppe.") return 1 ein_leeres = next(i for i, k in enumerate(echte) if not k.vektor_kreuz) ein_kreuz = next(i for i, k in enumerate(echte) if k.vektor_kreuz) def mit(index: int, **aenderung: object) -> list[Kaestchen]: kopie = list(echte) alt = kopie[index] kopie[index] = Kaestchen( seite=aenderung.get("seite", alt.seite), # type: ignore[arg-type] y=aenderung.get("y", alt.y), # type: ignore[arg-type] dichte=aenderung.get("dichte", alt.dichte), # type: ignore[arg-type] vektor_kreuz=aenderung.get("vektor_kreuz", alt.vektor_kreuz), # type: ignore[arg-type] ) return kopie faelle: list[tuple[str, list[Kaestchen], str]] = [ ( "Vektor behauptet ein Kreuz, wo keine Tinte ist", mit(ein_leeres, vektor_kreuz=True), "widersprechen sich", ), ( "Vektor übersieht ein Kreuz, das im Bild steht", mit(ein_kreuz, vektor_kreuz=False), "widersprechen sich", ), ( "ein leeres Kästchen trägt plötzlich Tinte", mit(ein_leeres, dichte=0.09), "trägt Tinte", ), ( "ein angekreuztes Kästchen ist fast leer", mit(ein_kreuz, dichte=0.03), "ist fast leer", ), ] fehlgeschlagen = 0 for name, verfaelscht, erwartet in faelle: befund = bewerte(verfaelscht) gemeldet = befund.beanstandungen if befund else [] if any(erwartet in text for text in gemeldet): print(f" erkannt: {name}") else: print(f" DURCHGELASSEN: {name}") print(f" erwartet: …{erwartet}…") print(f" gemeldet: {'; '.join(gemeldet) or '(nichts)'}") fehlgeschlagen += 1 if grundbefund.beanstandungen: print(" FALSCHER ALARM an den unveränderten Messwerten:") for text in grundbefund.beanstandungen: print(f" {text}") fehlgeschlagen += 1 else: print(" erkannt: die echte Messung bleibt unbeanstandet") print() bericht(echte, grundbefund) print() if fehlgeschlagen: print(f"Selbsttest fehlgeschlagen: {fehlgeschlagen} Fälle.") return 1 print(f"Selbsttest bestanden: {len(faelle) + 1} Fälle.") return 0 # ---------------------------------------------------------------------- CLI def seitenbereich(text: str, doc: fitz.Document) -> tuple[int, int]: """`6-20` oder `6` – 1-basiert wie die Seitenzahlen im PDF-Betrachter.""" teile = text.split("-", 1) von = int(teile[0]) bis = int(teile[1]) if len(teile) > 1 else von if not 1 <= von <= bis <= doc.page_count: raise SystemExit( f"Seitenbereich {text} liegt nicht in 1-{doc.page_count}." ) return von, bis def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--pdf", default=str(PDF_VORGABE)) ap.add_argument( "--seiten", default=None, help="Seitenbereich, etwa 6-20. Ohne Angabe: alle Inhaltsseiten.", ) ap.add_argument( "--selbsttest", action="store_true", help="Erzeugt jedes Fehlerbild absichtlich und prüft, dass es auffällt.", ) args = ap.parse_args() pfad = Path(args.pdf) if not pfad.exists(): print(f"PDF nicht gefunden: {pfad}") return 1 doc = fitz.open(pfad) try: if args.selbsttest: return selbsttest(doc) if args.seiten: von, bis = seitenbereich(args.seiten, doc) else: von, bis = pc.FIRST_CONTENT_PAGE + 1, doc.page_count print(f"Vorlage: {pfad.name}") print(f"Seiten {von} bis {bis}, Vergrößerung {ZOOM:.0f}-fach") print() werte = messe(doc, von, bis) befund = bewerte(werte) bericht(werte, befund) print() if befund is None: print("Kein Urteil möglich – die Probe hatte nichts zu trennen.") return 1 if befund.beanstandungen: for text in befund.beanstandungen: print(f"BEANSTANDET: {text}") return 1 print( "Beide Verfahren stimmen überein. Die Kreuzerkennung steht vor " "einer Lücke, nicht vor einem Schwellwert." ) return 0 finally: doc.close() if __name__ == "__main__": raise SystemExit(main())