waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline diagnose_kreuze.py
| 1 | # -*- coding: utf-8 -*- |
| 2 | """Gegenprobe zur Kreuzerkennung: die Tintendichte im Kästchen-Inneren. |
| 3 | |
| 4 | ## Warum es dieses Skript gibt |
| 5 | |
| 6 | `parse_catalog.py` entscheidet allein über Vektorpfade: Ein Ankreuzkästchen |
| 7 | gilt als angekreuzt, wenn zwei Diagonalsegmente vollständig darin liegen |
| 8 | (`PageData.is_checked`). Diese Entscheidung trägt den halben Katalog – aus ihr |
| 9 | folgt, welche Antwort richtig ist. Sie mit denselben Vektoren nachzuprüfen, |
| 10 | belegt nichts: Ein Denkfehler in der Geometrie stünde in Prüfung und Umsetzung |
| 11 | gleichermaßen. |
| 12 | |
| 13 | Diese Probe geht deshalb einen zweiten, unabhängigen Weg. Sie rendert dieselben |
| 14 | Kästchen bei 8-facher Vergrößerung als Graustufenbild und zählt die dunklen |
| 15 | Bildpunkte innerhalb des Rahmens. Beide Verfahren teilen genau eine Annahme – |
| 16 | wo ein Kästchen liegt –, und keine einzige darüber, was ein Kreuz ist. |
| 17 | |
| 18 | ## Was die Messung am Original ergeben hat (Katalogstand 16.12.2024) |
| 19 | |
| 20 | Kästchen gesamt 1430 |
| 21 | Vektor sagt LEER 801 Tintendichte 0,0000 bis 0,0000 |
| 22 | Vektor sagt KREUZ 629 Tintendichte 0,1653 bis 0,1978 |
| 23 | Widersprüche 0 |
| 24 | |
| 25 | Das Entscheidende ist nicht der Trennwert, sondern die **Lücke**: Zwischen der |
| 26 | dunkelsten leeren und dem hellsten angekreuzten Kästchen liegen 0,165 – rund |
| 27 | das Achtfache dessen, was ein Schwellwert an Spielraum bräuchte. Wo eine |
| 28 | Erkennung eine solche Lücke hat, ist sie nicht knapp richtig, sondern |
| 29 | eindeutig. Anlass der Messung waren die 28 Fragen, bei denen **alle** Optionen |
| 30 | angekreuzt sind (Verdacht: Layout nicht erkannt, vorsichtshalber alles |
| 31 | genommen); auch deren Kästchen liegen sämtlich in der oberen Gruppe. |
| 32 | |
| 33 | ## Ausführen |
| 34 | |
| 35 | python data-pipeline/diagnose_kreuze.py # ganzer Katalog |
| 36 | python data-pipeline/diagnose_kreuze.py --seiten 6-20 # Ausschnitt |
| 37 | python data-pipeline/diagnose_kreuze.py --selbsttest # die Probe prüfen |
| 38 | |
| 39 | Exit-Code 0, wenn beide Verfahren übereinstimmen und die Lücke bleibt; sonst 1. |
| 40 | Voraussetzung ist allein PyMuPDF – dieselbe und einzige Abhängigkeit, die |
| 41 | `parse_catalog.py` schon mitbringt. |
| 42 | """ |
| 43 | from __future__ import annotations |
| 44 | |
| 45 | import argparse |
| 46 | import sys |
| 47 | from dataclasses import dataclass |
| 48 | from pathlib import Path |
| 49 | |
| 50 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 51 | sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] |
| 52 | |
| 53 | import fitz # noqa: E402 |
| 54 | import parse_catalog as pc # noqa: E402 |
| 55 | |
| 56 | WURZEL = Path(__file__).resolve().parent.parent |
| 57 | PDF_VORGABE = WURZEL / "Fragenkatalog_sachkunde_mitAntworten.pdf" |
| 58 | |
| 59 | # 8-fach: Ein Kästchen von ~19 pt Kantenlänge wird damit zu ~150 Bildpunkten |
| 60 | # je Seite. Feiner brächte nichts – die Strichstärke des Kreuzes liegt bereits |
| 61 | # bei mehreren Bildpunkten –, gröber ließe dünne Striche verschwinden. |
| 62 | ZOOM = 8.0 |
| 63 | |
| 64 | # Abstand zum Rahmen in pt. Der Rahmen ist selbst schwarz; ohne diesen Rand |
| 65 | # maße die Probe ihn mit und fände in jedem Kästchen Tinte. |
| 66 | RAND = 1.6 |
| 67 | |
| 68 | # Bis 127 gilt ein Bildpunkt als dunkel. Die Vorlage kennt nur Schwarz auf |
| 69 | # Weiß; der Wert liegt in der Mitte und ist damit unkritisch. |
| 70 | DUNKELSCHWELLE = 128 |
| 71 | |
| 72 | # Grenzwerte der Bewertung, hergeleitet aus der oben festgehaltenen Messung: |
| 73 | # leere Kästchen maßen 0,0000, angekreuzte mindestens 0,1653. Die Schranken |
| 74 | # lassen also reichlich Luft und schlagen erst an, wenn die Trennung wirklich |
| 75 | # zu wackeln beginnt. |
| 76 | LEER_HOECHSTENS = 0.02 |
| 77 | KREUZ_MINDESTENS = 0.10 |
| 78 | MINDESTABSTAND = 0.05 |
| 79 | |
| 80 | |
| 81 | @dataclass(frozen=True) |
| 82 | class Kaestchen: |
| 83 | """Ein vermessenes Ankreuzkästchen.""" |
| 84 | |
| 85 | seite: int |
| 86 | y: float |
| 87 | dichte: float |
| 88 | vektor_kreuz: bool |
| 89 | |
| 90 | |
| 91 | # ------------------------------------------------------------------ Messung |
| 92 | |
| 93 | |
| 94 | def messe(doc: fitz.Document, von: int, bis: int) -> list[Kaestchen]: |
| 95 | """Vermisst alle Kästchen der Seiten `von` bis `bis` (1-basiert). |
| 96 | |
| 97 | Die Umsetzungstabelle macht aus dem Graustufenbild eine Maske und lässt |
| 98 | `bytes.count` zählen. Eine Python-Schleife über 23 Millionen Bildpunkte |
| 99 | dauerte Minuten, das hier Sekunden – dieselbe Zahl kommt heraus. |
| 100 | """ |
| 101 | maske = bytes(255 if wert < DUNKELSCHWELLE else 0 for wert in range(256)) |
| 102 | werte: list[Kaestchen] = [] |
| 103 | |
| 104 | for pno in range(von, bis + 1): |
| 105 | seite = doc[pno - 1] |
| 106 | daten = pc.PageData(seite, pno) |
| 107 | for kasten in daten.checkboxes: |
| 108 | innen = fitz.Rect( |
| 109 | kasten.x0 + RAND, kasten.y0 + RAND, kasten.x1 - RAND, kasten.y1 - RAND |
| 110 | ) |
| 111 | bild = seite.get_pixmap( |
| 112 | matrix=fitz.Matrix(ZOOM, ZOOM), clip=innen, colorspace=fitz.csGRAY |
| 113 | ) |
| 114 | proben = bild.samples |
| 115 | dunkel = proben.translate(maske).count(255) |
| 116 | werte.append( |
| 117 | Kaestchen( |
| 118 | seite=pno, |
| 119 | y=kasten.y0, |
| 120 | dichte=dunkel / max(1, len(proben)), |
| 121 | vektor_kreuz=daten.is_checked(kasten), |
| 122 | ) |
| 123 | ) |
| 124 | |
| 125 | return werte |
| 126 | |
| 127 | |
| 128 | # ----------------------------------------------------------------- Bewertung |
| 129 | |
| 130 | |
| 131 | @dataclass(frozen=True) |
| 132 | class Befund: |
| 133 | """Das Ergebnis einer Messreihe, samt aller Zahlen für den Bericht.""" |
| 134 | |
| 135 | leer: int |
| 136 | kreuz: int |
| 137 | leer_min: float |
| 138 | leer_max: float |
| 139 | kreuz_min: float |
| 140 | kreuz_max: float |
| 141 | schwelle: float |
| 142 | widersprueche: list[Kaestchen] |
| 143 | beanstandungen: list[str] |
| 144 | |
| 145 | @property |
| 146 | def abstand(self) -> float: |
| 147 | return self.kreuz_min - self.leer_max |
| 148 | |
| 149 | |
| 150 | def bewerte(werte: list[Kaestchen]) -> Befund | None: |
| 151 | """Vergleicht beide Verfahren. `None`, wenn nichts zu vergleichen war.""" |
| 152 | leer = [k for k in werte if not k.vektor_kreuz] |
| 153 | kreuz = [k for k in werte if k.vektor_kreuz] |
| 154 | if not leer or not kreuz: |
| 155 | return None |
| 156 | |
| 157 | leer_max = max(k.dichte for k in leer) |
| 158 | kreuz_min = min(k.dichte for k in kreuz) |
| 159 | |
| 160 | # Der Trennwert wird nicht vorgegeben, sondern aus der Messung genommen: |
| 161 | # genau zwischen den beiden Gruppen. Überlappen sie, liegt er mitten im |
| 162 | # Gedränge und die Widersprüche werden sichtbar – das ist gewollt. |
| 163 | schwelle = (leer_max + kreuz_min) / 2 |
| 164 | widersprueche = [k for k in werte if (k.dichte > schwelle) != k.vektor_kreuz] |
| 165 | |
| 166 | beanstandungen: list[str] = [] |
| 167 | if widersprueche: |
| 168 | beanstandungen.append( |
| 169 | f"{len(widersprueche)} Kästchen: Vektorurteil und Tintendichte " |
| 170 | f"widersprechen sich." |
| 171 | ) |
| 172 | if leer_max > LEER_HOECHSTENS: |
| 173 | beanstandungen.append( |
| 174 | f"Ein leeres Kästchen trägt Tinte: {leer_max:.4f} > {LEER_HOECHSTENS:.4f}." |
| 175 | ) |
| 176 | if kreuz_min < KREUZ_MINDESTENS: |
| 177 | beanstandungen.append( |
| 178 | f"Ein angekreuztes Kästchen ist fast leer: " |
| 179 | f"{kreuz_min:.4f} < {KREUZ_MINDESTENS:.4f}." |
| 180 | ) |
| 181 | if kreuz_min - leer_max < MINDESTABSTAND: |
| 182 | beanstandungen.append( |
| 183 | f"Die Lücke zwischen beiden Gruppen ist auf " |
| 184 | f"{kreuz_min - leer_max:.4f} geschrumpft (mindestens " |
| 185 | f"{MINDESTABSTAND:.4f}) – aus der Lücke würde ein Schwellwert." |
| 186 | ) |
| 187 | |
| 188 | return Befund( |
| 189 | leer=len(leer), |
| 190 | kreuz=len(kreuz), |
| 191 | leer_min=min(k.dichte for k in leer), |
| 192 | leer_max=leer_max, |
| 193 | kreuz_min=kreuz_min, |
| 194 | kreuz_max=max(k.dichte for k in kreuz), |
| 195 | schwelle=schwelle, |
| 196 | widersprueche=widersprueche, |
| 197 | beanstandungen=beanstandungen, |
| 198 | ) |
| 199 | |
| 200 | |
| 201 | def bericht(werte: list[Kaestchen], befund: Befund | None) -> None: |
| 202 | """Gibt die Messwerte aus – auch dann, wenn nichts zu beanstanden ist.""" |
| 203 | print(f"Kästchen vermessen: {len(werte)}") |
| 204 | if befund is None: |
| 205 | print(" Eine der beiden Gruppen ist leer – hier gibt es nichts zu trennen.") |
| 206 | return |
| 207 | |
| 208 | print( |
| 209 | f" Vektor sagt LEER : {befund.leer:5d} Tintendichte " |
| 210 | f"{befund.leer_min:.4f} bis {befund.leer_max:.4f}" |
| 211 | ) |
| 212 | print( |
| 213 | f" Vektor sagt KREUZ : {befund.kreuz:5d} Tintendichte " |
| 214 | f"{befund.kreuz_min:.4f} bis {befund.kreuz_max:.4f}" |
| 215 | ) |
| 216 | print(f" Lücke zwischen den Gruppen: {befund.abstand:.4f}") |
| 217 | print(f" Daraus abgeleiteter Trennwert: {befund.schwelle:.4f}") |
| 218 | print(f" Widersprüche Vektor gegen Tinte: {len(befund.widersprueche)}") |
| 219 | for k in befund.widersprueche[:20]: |
| 220 | urteil = "KREUZ" if k.vektor_kreuz else "LEER " |
| 221 | print(f" S.{k.seite:3d} y={k.y:7.2f} Vektor {urteil} Dichte {k.dichte:.4f}") |
| 222 | if len(befund.widersprueche) > 20: |
| 223 | print(f" … und {len(befund.widersprueche) - 20} weitere") |
| 224 | |
| 225 | |
| 226 | # --------------------------------------------------------------- Selbsttest |
| 227 | |
| 228 | |
| 229 | def selbsttest(doc: fitz.Document) -> int: |
| 230 | """Prüft die Probe. |
| 231 | |
| 232 | Eine Probe, die nichts beanstandet, ist von einer funktionierenden nicht zu |
| 233 | unterscheiden – dieselbe Regel wie in `pruefe_erklaerungen.py`. Deshalb |
| 234 | wird jedes Fehlerbild einmal absichtlich erzeugt: an echten Messwerten, |
| 235 | nicht an erfundenen, damit die Fälle auch dann noch stimmen, wenn eine neue |
| 236 | Katalogfassung andere Zahlen bringt. |
| 237 | """ |
| 238 | seiten_bis = min(pc.FIRST_CONTENT_PAGE + 5, doc.page_count) |
| 239 | echte = messe(doc, pc.FIRST_CONTENT_PAGE + 1, seiten_bis) |
| 240 | grundbefund = bewerte(echte) |
| 241 | if grundbefund is None: |
| 242 | print("Selbsttest nicht durchführbar: Die Probeseiten tragen nur eine Gruppe.") |
| 243 | return 1 |
| 244 | |
| 245 | ein_leeres = next(i for i, k in enumerate(echte) if not k.vektor_kreuz) |
| 246 | ein_kreuz = next(i for i, k in enumerate(echte) if k.vektor_kreuz) |
| 247 | |
| 248 | def mit(index: int, **aenderung: object) -> list[Kaestchen]: |
| 249 | kopie = list(echte) |
| 250 | alt = kopie[index] |
| 251 | kopie[index] = Kaestchen( |
| 252 | seite=aenderung.get("seite", alt.seite), # type: ignore[arg-type] |
| 253 | y=aenderung.get("y", alt.y), # type: ignore[arg-type] |
| 254 | dichte=aenderung.get("dichte", alt.dichte), # type: ignore[arg-type] |
| 255 | vektor_kreuz=aenderung.get("vektor_kreuz", alt.vektor_kreuz), # type: ignore[arg-type] |
| 256 | ) |
| 257 | return kopie |
| 258 | |
| 259 | faelle: list[tuple[str, list[Kaestchen], str]] = [ |
| 260 | ( |
| 261 | "Vektor behauptet ein Kreuz, wo keine Tinte ist", |
| 262 | mit(ein_leeres, vektor_kreuz=True), |
| 263 | "widersprechen sich", |
| 264 | ), |
| 265 | ( |
| 266 | "Vektor übersieht ein Kreuz, das im Bild steht", |
| 267 | mit(ein_kreuz, vektor_kreuz=False), |
| 268 | "widersprechen sich", |
| 269 | ), |
| 270 | ( |
| 271 | "ein leeres Kästchen trägt plötzlich Tinte", |
| 272 | mit(ein_leeres, dichte=0.09), |
| 273 | "trägt Tinte", |
| 274 | ), |
| 275 | ( |
| 276 | "ein angekreuztes Kästchen ist fast leer", |
| 277 | mit(ein_kreuz, dichte=0.03), |
| 278 | "ist fast leer", |
| 279 | ), |
| 280 | ] |
| 281 | |
| 282 | fehlgeschlagen = 0 |
| 283 | for name, verfaelscht, erwartet in faelle: |
| 284 | befund = bewerte(verfaelscht) |
| 285 | gemeldet = befund.beanstandungen if befund else [] |
| 286 | if any(erwartet in text for text in gemeldet): |
| 287 | print(f" erkannt: {name}") |
| 288 | else: |
| 289 | print(f" DURCHGELASSEN: {name}") |
| 290 | print(f" erwartet: …{erwartet}…") |
| 291 | print(f" gemeldet: {'; '.join(gemeldet) or '(nichts)'}") |
| 292 | fehlgeschlagen += 1 |
| 293 | |
| 294 | if grundbefund.beanstandungen: |
| 295 | print(" FALSCHER ALARM an den unveränderten Messwerten:") |
| 296 | for text in grundbefund.beanstandungen: |
| 297 | print(f" {text}") |
| 298 | fehlgeschlagen += 1 |
| 299 | else: |
| 300 | print(" erkannt: die echte Messung bleibt unbeanstandet") |
| 301 | |
| 302 | print() |
| 303 | bericht(echte, grundbefund) |
| 304 | print() |
| 305 | if fehlgeschlagen: |
| 306 | print(f"Selbsttest fehlgeschlagen: {fehlgeschlagen} Fälle.") |
| 307 | return 1 |
| 308 | print(f"Selbsttest bestanden: {len(faelle) + 1} Fälle.") |
| 309 | return 0 |
| 310 | |
| 311 | |
| 312 | # ---------------------------------------------------------------------- CLI |
| 313 | |
| 314 | |
| 315 | def seitenbereich(text: str, doc: fitz.Document) -> tuple[int, int]: |
| 316 | """`6-20` oder `6` – 1-basiert wie die Seitenzahlen im PDF-Betrachter.""" |
| 317 | teile = text.split("-", 1) |
| 318 | von = int(teile[0]) |
| 319 | bis = int(teile[1]) if len(teile) > 1 else von |
| 320 | if not 1 <= von <= bis <= doc.page_count: |
| 321 | raise SystemExit( |
| 322 | f"Seitenbereich {text} liegt nicht in 1-{doc.page_count}." |
| 323 | ) |
| 324 | return von, bis |
| 325 | |
| 326 | |
| 327 | def main() -> int: |
| 328 | ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 329 | ap.add_argument("--pdf", default=str(PDF_VORGABE)) |
| 330 | ap.add_argument( |
| 331 | "--seiten", |
| 332 | default=None, |
| 333 | help="Seitenbereich, etwa 6-20. Ohne Angabe: alle Inhaltsseiten.", |
| 334 | ) |
| 335 | ap.add_argument( |
| 336 | "--selbsttest", |
| 337 | action="store_true", |
| 338 | help="Erzeugt jedes Fehlerbild absichtlich und prüft, dass es auffällt.", |
| 339 | ) |
| 340 | args = ap.parse_args() |
| 341 | |
| 342 | pfad = Path(args.pdf) |
| 343 | if not pfad.exists(): |
| 344 | print(f"PDF nicht gefunden: {pfad}") |
| 345 | return 1 |
| 346 | |
| 347 | doc = fitz.open(pfad) |
| 348 | try: |
| 349 | if args.selbsttest: |
| 350 | return selbsttest(doc) |
| 351 | |
| 352 | if args.seiten: |
| 353 | von, bis = seitenbereich(args.seiten, doc) |
| 354 | else: |
| 355 | von, bis = pc.FIRST_CONTENT_PAGE + 1, doc.page_count |
| 356 | |
| 357 | print(f"Vorlage: {pfad.name}") |
| 358 | print(f"Seiten {von} bis {bis}, Vergrößerung {ZOOM:.0f}-fach") |
| 359 | print() |
| 360 | werte = messe(doc, von, bis) |
| 361 | befund = bewerte(werte) |
| 362 | bericht(werte, befund) |
| 363 | print() |
| 364 | |
| 365 | if befund is None: |
| 366 | print("Kein Urteil möglich – die Probe hatte nichts zu trennen.") |
| 367 | return 1 |
| 368 | if befund.beanstandungen: |
| 369 | for text in befund.beanstandungen: |
| 370 | print(f"BEANSTANDET: {text}") |
| 371 | return 1 |
| 372 | |
| 373 | print( |
| 374 | "Beide Verfahren stimmen überein. Die Kreuzerkennung steht vor " |
| 375 | "einer Lücke, nicht vor einem Schwellwert." |
| 376 | ) |
| 377 | return 0 |
| 378 | finally: |
| 379 | doc.close() |
| 380 | |
| 381 | |
| 382 | if __name__ == "__main__": |
| 383 | raise SystemExit(main()) |