waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ data-pipeline diagnose_kreuze.py

13,5 KB Rohdatei
data-pipeline/diagnose_kreuze.py — 383 Zeilen
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())