Compare commits

..
24 Commits
Author SHA1 Message Date
TanerUsluandClaude Opus 5 6688efc546 README: Zählen-Abschnitte auf Aufnahme statt Dauerbetrieb aktualisiert
Beschreibt die neue Aufnahme-Schaltfläche ("Zählen"/"Nochmal zählen"), die
fünf Bilder je Aufnahme samt Median, die um die Einzelmessungen erweiterte
Diagnosezeile und das Zurücksetzen beim Verlassen der Funktion. Aufbau-
Tabelle, Testanzahl und der Prüfabschnitt "Am Gerät noch zu prüfen" folgen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:37:10 +02:00
TanerUsluandClaude Opus 5 4b6bb065b1 Zählen: Aufnahme statt Dauerbetrieb
Die Zaehlanzeige sprang beim Nutzer im Dauerbetrieb wild zwischen etwa 3
und 60, obwohl dasselbe Verfahren an einem Standfoto stabil 18 lieferte -
eine Schaetzung wird durch staendiges Neuanzeigen nicht praeziser, nur
unruhig. "Zählen" laeuft jetzt wie "Text erkennen" auf Knopfdruck: eine
Aufnahme nimmt binnen rund einer Sekunde fuenf Bilder aus dem Zielrahmen
auf, zaehlt jedes einzeln (count-objects.js unveraendert) und zeigt deren
Median (neues Modul count-capture.js). Das Ergebnis bleibt stehen, bis
erneut angetippt wird; die Diagnosezeile zeigt zusaetzlich die fuenf
Einzelmessungen. Der gleitende Median ueber neun Durchlaeufe der
Dauerzaehlung (count-history.js) entfaellt, da die Aufnahme dieselbe
Robustheit bereits selbst leistet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:36:43 +02:00
TanerUsluandClaude Opus 5 d84c15e0d0 Diagnoseanzeige fuer die Funktion "Zählen" ergaenzt
Der Nutzer meldet eine stark schwankende Zaehlanzeige im Livebetrieb
(3-60), obwohl dasselbe Verfahren an einem Standfoto stabil 18 liefert -
er kann aber keine Bilder hochladen und muss Messwerte daher vom
Bildschirm ablesen und durchgeben koennen.

- count-objects.js liefert additiv Zwischenwerte (Bildgroesse nach
  Normierung, Flaechen vor/nach Filterung, groesste Flaeche, typische
  Groesse, gewinnende Polaritaet) - auch bei count 0 sinnvoll belegt.
  Die eigentliche Zaehllogik bleibt unveraendert.
- count-history.js (neu): gleitender Median der letzten 9 Messungen,
  unterdrueckt einzelne Ausreisser ohne die Anzeige traege zu machen.
- main.js zeigt den Median als grosse Zahl, misst den Zeitbedarf des
  Zaehldurchlaufs und baut daraus die Diagnosezeile; der Verlauf wird
  bei jedem Funktionswechsel zurueckgesetzt.
- scan-view.js/styles.css: die grosse Zahl ist antippbar und blendet
  eine anfangs verborgene Diagnosezeile ein/aus, ohne selbst zu
  verrutschen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:56:26 +02:00
TanerUsluandClaude Opus 5 f0705a010c Doku: Zaehlfunktion und ihre Grenzen auf das neue Verfahren aktualisiert
Beschreibt oertlichen Kontrast statt Otsu (Normierung, Glaettung,
oertlicher Hintergrund, Massstab relativ zur groessten Flaeche) und die
neuen Grenzen: Untergrund muss sich abheben, gleichmaessige Ausleuchtung
hilft, auseinandergelegt zaehlt besser als aufgehaeuft, stark
unterschiedlich grosse Teile verfaelschen die Hochrechnung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:21:35 +02:00
TanerUsluandClaude Opus 5 089d47a1ec Synthetische Zaehl-Tests an das oertlich-kontrastbasierte Verfahren angepasst
Das neue Verfahren braucht ein Umgebungsfenster (Radius max(Breite,Hoehe)/12),
das an jeder Fundstueck-Stelle noch echten Untergrund sieht - die alten
40x40-/80x80-Testbilder mit 10x10-Quadraten waren dafuer zu klein. Bildgroesse
und Quadratmasse angepasst (300x300/600x600), Kernaussagen unveraendert:
leeres Bild -> null, getrennte gleich grosse Flaechen -> ihre Anzahl, Rauschen
zaehlt nicht mit, Hochrechnung beruehrender Teile, kein Doppelzaehlen leicht
groesserer Einzelteile, beide Polaritaeten, 4er- statt 8er-Nachbarschaft
(Luecke statt gemeinsamem Eckpixel, da die Glaettung Raender aufweitet).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:21:24 +02:00
TanerUsluandClaude Opus 5 75c479860f Zaehlung auf oertlichen Kontrast umgestellt (Otsu versagte am echten Foto)
Otsu (bisheriges Verfahren) meldete am echten Foto des Auftraggebers
(~20 Schrauben auf Leder, unter 1% Flaechenanteil) 76712 statt rund
zwanzig - Otsu taugt nur bei etwa gleich grossen Objekt-/
Untergrundflaechen. countObjects() vergleicht jetzt jeden Bildpunkt mit
einem oertlichen Hintergrund (Kastenfilter ueber Summenbild, Radius
max(Breite,Hoehe)/12), was Ausleuchtungsunterschiede abfaengt und am
echten Foto 18 statt 76712 liefert (Test in
test/count-objects-photo.test.js, ~30-49ms Laufzeit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:21:13 +02:00
TanerUsluandClaude Opus 5 ffb2409c24 Doku: vierte Funktion "Zählen" im README ergänzt
Funktionstabelle, Ablaufbeschreibung, Aufbau-Tabelle (threshold.js,
count-objects.js), Testzahl (165), neuer Grenzen-Abschnitt (beruehrende/
gestapelte Teile, ungleichmaessige Ausleuchtung, geringer Kontrast, stark
unterschiedliche Teilegroessen) sowie ein Geraete-Pruefpunkt fuer "Zählen".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:48:04 +02:00
TanerUsluandClaude Opus 5 8c761c6fb5 Vierte Scan-Funktion "Zählen": laufende Anzeige, keine Buchung
Neue Funktion in scan-modes.js (Merkmal continuousCount), verdrahtet in
main.js ueber denselben Zeitgeber/dieselbe Ueberlappungssperre wie die
Barcode-Dauersuche (continuousSearchLoop ruft jetzt runCountAttempt()
zusaetzlich zu runSearchAttempt(); jeder bricht selbst ab, wenn die
Funktion nicht passt) - kein zweiter, paralleler Mechanismus. Kein "Modul
scannen"-Knopf, keine Treffer-Rueckmeldung, kein roter Dialog, keine
Stapel-Zuweisung; die grosse Zaehlanzeige (scan-view.js/styles.css) ist
reine Anzeige und speichert nichts.

buildRecognitionAdapters() liefert fuer "Zählen" zusaetzlich leere Adapter
(Absicherung gegen den seltenen Datei-Ersatzweg ohne Kamera). Die drei
bestehenden Funktionen bleiben unveraendert; einzige notwendige Anpassung an
scan-modes.test.js war die Anzahl-Pruefung (3 -> 4 Funktionen).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:47:44 +02:00
TanerUsluandClaude Opus 5 5e64d260c5 Reine Zaehl-Berechnung fuer Fundstuecke in einem Bildausschnitt
Neues Modul src/count-objects.js: Otsu-Schwellwert (threshold.js) teilt den
Ausschnitt in zwei Klassen, die flaechenmaessig kleinere gilt als Objekt
(funktioniert fuer helle wie dunkle Teile auf beliebigem Untergrund).
Connected-Component-Labeling ueber eine eigene Arbeitsliste statt Rekursion
(vermeidet Stapelueberlauf bei ~1 Mio. Bildpunkten), Rauschflaechen werden
verworfen, beruehrende Teile anhand des Flaechen-Medians hochgerechnet.

Test-Driven: leeres Bild, einzelne/mehrere getrennte Flaechen, verworfenes
Rauschen, Hochrechnung ohne ein nur leicht groesseres Einzelteil zu
verdoppeln, helle wie dunkle Objekte, Bild ohne Bildpunkte, diagonal statt
flaechig beruehrende Teile bleiben getrennt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:47:33 +02:00
TanerUsluandClaude Opus 5 500f9b6c3d Otsu-Schwellwert in eigenes reines Modul ausgelagert (threshold.js)
Zieht otsuThreshold()/grayscaleHistogram() aus ocr.js in src/threshold.js,
ohne das Verhalten von preprocess() zu aendern (bestehende Tests in
ocr-preprocess.test.js bleiben unveraendert gruen). Vorbereitung fuer die
neue Zaehl-Funktion, die dieselbe Schwellwertbestimmung braucht, aber nichts
mit tesseract.js (ocr.js) zu tun haben soll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:47:22 +02:00
TanerUslu 87ba439287 Doku: voller OCR-Rohtext in "Text erkennen" (Rahmen, Whitelist, Anzeige)
README auf den aktuellen Stand gebracht: Zielrahmen ist jetzt auch in "Text
erkennen" wirksam (grabFrameRegion statt grabFrame), breitere Zeichen-
Whitelist samt Abwägung, additiver OCR-Rohtext (rawText) in Pipeline,
Speicherung und Anzeige, wartende statt automatisch ausblendende
Rückmeldung in "Text erkennen". Datei-Tabelle, Abschnitt
"Sitzungssicherung im Detail", "Grenzen" und die manuelle Geräte-Prüfliste
entsprechend ergänzt; Testanzahl 144 → 154.
2026-07-29 15:19:34 +02:00
TanerUslu b903b1c7aa OCR-Rohtext additiv zurückgeben, je Eintrag speichern und anzeigen
recognize() (pipeline.js) liefert zusätzlich den ungefilterten OCR-Rohtext
(rawText) zurück - leer, wenn keine Texterkennung lief. Rein additiv: die
Stapelzuordnung (session.js/spec.js, unangetastet) stützt sich weiterhin
ausschließlich auf die daraus abgeleiteten, verstandenen Spec-Felder.

main.js hängt den Rohtext nach dem Buchen als zusätzliche Eigenschaft an den
Eintrag (analog zu den bereits vorhandenen Barcode-Inhalten je Eintrag).
storage.js sichert/prüft/stellt entry.rawText nach demselben optionalen
Muster wie entry.codes wieder her - ein gesicherter Stand ohne dieses Feld
wird nicht verworfen.

In Funktion "Text erkennen" bleibt die Treffer-Rückmeldung jetzt stehen, bis
der Nutzer sie wegtippt, und zeigt den vollen erkannten Text (lesbar über
dasselbe Scroll-Muster wie die übrigen Vollbild-Overlays); in den beiden
Barcode-Funktionen bleibt es beim automatischen Ausblenden. Das
zurückgegebene Versprechen von showResult löst in jedem Fall - Zeitgeber,
Wegtippen, verdrängende neue Rückmeldung - genau einmal ein, damit die
Bedienung nie dauerhaft gesperrt bleibt. Die Sitzungsliste zeigt den Rohtext
zusätzlich je Eintrag zum Nachlesen.

Tests (pipeline.test.js, storage.test.js): rawText nur bei tatsächlich
gelaufener Texterkennung, unterschiedlicher Rohtext beeinflusst die
Stapelzuordnung nachweislich nicht, Rohtext übersteht Sichern/Laden inkl.
Abwärtskompatibilität zu einem Stand ohne dieses Feld.
2026-07-29 15:19:13 +02:00
TanerUslu 1c10c68053 Zeichen-Whitelist der Texterkennung um Kleinbuchstaben erweitert
tessedit_char_whitelist ließ bisher nur Großbuchstaben, Ziffern und wenige
Sonderzeichen zu. Der Nutzer will den vollständigen Etikettentext sehen, nicht
nur die von der App verstandenen Felder - die Whitelist deckt deshalb jetzt
Groß- und Kleinbuchstaben, Ziffern sowie die auf Etiketten üblichen Satz- und
Sonderzeichen ab. Das kann die Genauigkeit bei den Codeteilen (Kapazität,
Rank, Geschwindigkeit, Teilenummer) etwas verringern - eine bewusst in Kauf
genommene Abwägung, siehe Kommentar an Ort und Stelle.
2026-07-29 15:18:56 +02:00
TanerUslu 54b1447f21 Text erkennen liest den Zielrahmen in voller Auflösung, nicht das Vollbild
grabFrameRegion() statt grabFrame(): "Modul scannen" bekam bisher das ganze
Kamerabild, auf 1280 Pixel herunterskaliert - der Zielrahmen war dabei rein
kosmetisch, obwohl er dem Nutzer einen wirksamen Ausschnitt suggeriert. Jetzt
liest die Texterkennung genau den Rahmenausschnitt, in nativer Auflösung -
denselben, den auch die laufende Barcode-Suche längst benutzt. Die
Dateiauswahl (onPickFile) bleibt unverändert beim Vollbild, da sie keinen
Zielrahmen kennt.
2026-07-29 15:18:48 +02:00
TanerUsluandClaude Opus 5 3907204209 Change QR-Code function label from "2D-Code" back to "QR-Code"
The function keeps reading all 2D-code types (QR-Code, MicroQRCode, RMQRCode,
DataMatrix, Aztec, PDF417) as requested. The label is now "QR-Code" per the
client's preference for their terminology. Added clarifying comments explaining
the intentional mismatch between the name and the supported formats, since the
hardware labels use DataMatrix (not QR). Updated README and test accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:50:24 +02:00
TanerUsluandClaude Opus 5 2d7778cea8 Funktion 2 liest 2D-Codes statt nur QR (Auftraggeber-Entscheidung revidiert)
Der Auftraggeber hatte Funktion 2 ausdruecklich auf QR beschraenkt, obwohl
die 2D-Codes auf seinen RAM-Etiketten DataMatrix sind. Am Geraet zeigte sich,
dass die Funktion auf seiner Ware nichts fand - er hat die Entscheidung
revidiert. Funktion 2 liest jetzt zusaetzlich zu QRCode/MicroQRCode/RMQRCode
auch DataMatrix, Aztec und PDF417 und heisst in der Oberflaeche "2D-Code"
statt "QR-Code". Die Kennung bleibt aus historischen Gruenden `qrcode`.

Veralteten Kommentar in scan-modes.js berichtigt, Test auf die neue
Codeliste/Beschriftung umgestellt (statt geloescht) und einen Test ergaenzt,
der paarweise verschiedene Kennungen/Beschriftungen der drei Funktionen
erzwingt. README-Funktionstabelle und Geraete-Checkliste entsprechend
angepasst.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 14:32:41 +02:00
TanerUslu ebff173443 Doku: drei Scan-Funktionen statt automatischem Barcode/OCR-Ausweichen
Funktionsweise-Abschnitt, Technik- und Aufbau-Tabelle sowie die
Geraete-Checkliste auf die neue Funktionswahl gebracht. Haelt fest,
dass in "Text erkennen" mangels Barcode-Teilenummer allein die
technischen Angaben ueber die Stapelzuordnung entscheiden, und dass
"QR-Code" bewusst kein DataMatrix liest. Testanzahl auf 143 aktualisiert.
2026-07-29 13:37:18 +02:00
TanerUslu 2e619ed64d Funktionswahl vor der Kamera, Wechsel und Knopf-Sichtbarkeit je Funktion
Neuer Vollbild-Dialog (src/ui/mode-dialog.js) mit drei grossen Flaechen,
gebaut nach dem Muster der bestehenden Dialoge. Ablauf beim Start: erst
die Fortsetzen-Frage (falls eine gesicherte Sitzung vorliegt), dann die
Funktionswahl, erst danach startet die Kamera.

Die gewaehlte Funktion steht antippbar in der Scan-Ansicht und laesst
sich dort jederzeit wechseln - der Wechsel gilt sofort: laufende Suche
startet/stoppt, "Modul scannen" erscheint nur noch in Funktion "Text
erkennen", der Zielrahmen wird beim Wechsel weg von laufender Suche
zurueckgesetzt. pipeline.js bleibt unangetastet; main.js reicht je nach
Funktion nur unterschiedliche Adapter hinein (scan-recognition.js).
2026-07-29 13:37:12 +02:00
TanerUslu 2eb9d1ecf8 Barcode-Adapter nimmt Codearten entgegen, Adapter-Weiche je Funktion
decodeBarcodes(imageData, formats) bekommt die zu suchenden Codearten
uebergeben; ohne Angabe unveraendertes Verhalten (Code128 + DataMatrix).

buildRecognitionAdapters() baut daraus und aus dem echten OCR-Adapter
die an recognize() (pipeline.js, unveraendert) uebergebenen Adapter:
in Strichcode/QR-Code liefert die Texterkennung sofort leeren Text ohne
Tesseract anzustossen, in Text-erkennen liefert die Barcode-Dekodierung
sofort eine leere Liste ohne zxing-wasm anzustossen.
2026-07-29 13:37:05 +02:00
TanerUslu 9ef038ca98 Reines Funktionen-Modul fuer die drei Scan-Funktionen
Kennung, deutsche Beschriftung, Codearten, laufende Suche und
Texterkennung je Funktion an einer einzigen Stelle (src/scan-modes.js),
ohne Browser-Zugriff und damit unter Node pruefbar. Funktion "QR-Code"
enthaelt bewusst kein DataMatrix.
2026-07-29 13:36:58 +02:00
TanerUslu 5a055923fe Trenne Dialogtexte fuer echte Mehrdeutigkeit und nichts erkannt
Der rote Dialog zeigte bisher immer "Nicht eindeutig", auch wenn die
Erkennung schlicht nichts Verwertbares geliefert hat (kein Barcode, kein
brauchbarer Klartext). Das verwirrt: es klingt nach mehreren Kandidaten,
obwohl gar keine Erkennung stattfand.

askForStack() bekommt einen expliziten Parameter nothingRecognized, da
sich die beiden Lagen nicht zweifelsfrei aus spec/candidates ableiten
lassen (main.js reicht bei "nichts erkannt" als Rueckfall alle
vorhandenen Stapel als candidates durch). main.js setzt ihn anhand von
plan.kind === 'ambiguous' (echte Mehrdeutigkeit) vs. allem anderen
(nur wegen roter Konfidenz im Dialog).

Signatur, Rueckgabeform, Schaltflaechen, Klassennamen, die
Mehrfachausloese-Sperre und die inert-Handhabung bleiben unveraendert.
2026-07-29 13:05:37 +02:00
TanerUsluandClaude Opus 5 b94639ccd1 Laufende Barcode-Suche statt Einzelbild beim Knopfdruck
Solange Kamera laeuft und keine Erkennung/kein Dialog/keine
Sitzungsliste im Weg steht, sucht die App ~5x/s im Zielrahmen nach
Barcodes. Ein gefundener Code durchlaeuft denselben Weg wie der
Knopfdruck (processCapture). Sperrzeit von 2s plus Codevergleich
verhindert, dass ein laenger vor die Kamera gehaltenes Modul mehrfach
gebucht wird, ohne einen zuegigen Modulwechsel auszubremsen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:59:23 +02:00
TanerUsluandClaude Opus 5 abb70e8b23 Zielrahmen aus FRAME_INSET setzen, Erkennungs-Hervorhebung ergaenzen
scan-view.js setzt die Rahmenlage jetzt aus camera.js' FRAME_INSET
statt aus einem im Stilblatt duplizierten Wert. Neue Methode
setFrameDetected() faerbt den Rahmen gruen, sobald die Dauersuche
einen Barcode im Ausschnitt findet - schlichte Farbaenderung, keine
Animation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:59:14 +02:00
TanerUsluandClaude Opus 5 44526e7d3b Ausschnitt in nativer Aufloesung fuer Barcode-Erkennung
FRAME_INSET und frameRect() legen die Rahmenmasse einmalig fest, statt
sie nur im Stilblatt zu duplizieren. grabFrameRegion() liefert den
Zielrahmen unskaliert aus dem Kamerabild - grabFrame() (fuer OCR)
bleibt unveraendert. Ein Code-128 auf kleinem Etikett braucht die
volle Aufloesung, sonst verschmieren die Striche unlesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:59:08 +02:00
27 changed files with 2877 additions and 193 deletions
+404 -69
View File
@@ -4,6 +4,15 @@ Browser-App, die per Handykamera gebrauchte Server-RAM-Module erkennt und
beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die
App nennt den Stapel. App nennt den Stapel.
Vor dem Scannen wählt der Nutzer eine von vier Funktionen — Strichcode,
QR-Code, Text erkennen oder Zählen (siehe „Funktionsweise" unten). Jede
Funktion nutzt ausschließlich ihre eigene Quelle; anders als früher weicht die
App nicht mehr automatisch auf Texterkennung aus, wenn kein Code gefunden
wird. Die vierte Funktion, „Zählen", gehört nicht zum eigentlichen Sortieren:
Sie bucht nichts, sondern ermittelt auf Knopfdruck beim Auslegen von
Kleinteilen (z. B. Festplattenschrauben) deren Anzahl und zeigt sie an, bis
der Nutzer erneut antippt.
Zwei Module gelten als zusammengehörig, wenn sie denselben Barcode-Inhalt Zwei Module gelten als zusammengehörig, wenn sie denselben Barcode-Inhalt
tragen — ein Barcode ist exakt gelesen, das ist der zuverlässigste Maßstab. tragen — ein Barcode ist exakt gelesen, das ist der zuverlässigste Maßstab.
Jeder Stapel führt dafür die Menge der Barcode-Zeichenketten, die bei seinen Jeder Stapel führt dafür die Menge der Barcode-Zeichenketten, die bei seinen
@@ -51,16 +60,75 @@ keine Daten verlassen das Gerät.
## Funktionsweise ## Funktionsweise
1. **Barcode zuerst.** Code-128 und DataMatrix werden aus dem Kamerabild 0. **Funktion wählen.** Vor dem ersten Scan (und jederzeit erneut über die
dekodiert (`zxing-wasm`). Das ist exakt, im Gegensatz zu Texterkennung. antippbare Anzeige in der Scan-Ansicht) wählt der Nutzer eine von vier
Werden mehrere Barcodes im Bild gefunden, die sich widersprechen (nicht Funktionen. Kennung, Beschriftung, gelesene Codearten und Verhalten stehen
dieselbe Teilenummer), gilt das als mehrdeutig — die App rät nicht, ausschließlich in `src/scan-modes.js` — es gibt keine zweite Stelle, an der
sondern fragt nach. Das gilt nur für Barcodes mit einem bekannten das steht:
Nummernschema; widersprechen sich mehrere Barcodes mit unbekanntem
Nummernschema, wird keine ihrer Teilenummern übernommen. | Funktion | Liest | Laufende Codesuche | Texterkennung |
2. **Teilenummer-Decoder.** Aus einer Hersteller-PN wie `M386A8K40BM1-CRC4Y` |---|---|---|---|
werden Kapazität, Bauform und Geschwindigkeit tabellengesteuert | **Strichcode** | Code128, Code39, Code93, ITF, EAN-13, EAN-8, UPC-A, UPC-E, Codabar | ja | nein |
abgeleitet. Gelingt das, entfällt OCR vollständig, das Ergebnis ist grün. | **QR-Code** | QRCode, MicroQRCode, RMQRCode, DataMatrix, Aztec, PDF417 | ja | nein |
| **Text erkennen** | keine Codes | nein | ja, auf Knopfdruck |
| **Zählen** | keine Codes | nein | nein, dafür Zählung auf Knopfdruck (keine Buchung) |
„QR-Code" heißt auf Wunsch des Auftraggebers „QR-Code", liest aber bewusst
nicht nur QR-Varianten, sondern auch DataMatrix, Aztec und PDF417. Der
Grund: Die 2D-Codes auf den RAM-Etiketten sind DataMatrix, nicht QR — eine
auf QR beschränkte Funktion fände auf dieser Ware nichts. Die interne
Kennung heißt weiterhin `qrcode` (aus historischen Gründen, siehe
`src/scan-modes.js`), und der Name ist Absicht — es ist der Wunsch des
Auftraggebers für seinen Sprachgebrauch.
Eine einzige Aufnahme-Schaltfläche bedient beide Knopfdruck-Funktionen —
„Modul scannen" in „Text erkennen", „Zählen" bzw. (nach der ersten
Aufnahme seit Betreten der Funktion) „Nochmal zählen" in „Zählen" — statt
einer zweiten Schaltfläche daneben; nur Wortlaut und Rückruf unterscheiden
sich
(`setCaptureLabel` in `src/ui/scan-view.js`, verdrahtet in
`src/main.js`/`applyMode()`). In „Strichcode" und „QR-Code" bleibt sie
verborgen — dort sucht die App ohnehin laufend, ein Knopf hätte nichts zu
tun. Der Zielrahmen hebt sich in „Strichcode" und „QR-Code" hervor, sobald
ein passender Code im Bild ist; in „Text erkennen" und „Zählen" bleibt er
schlicht, weil dort nichts laufend erkannt wird. Trotzdem ist der Rahmen
auch dort wirksam: Sowohl „Modul scannen" als auch „Zählen" lesen genau den
Bildausschnitt innerhalb des Rahmens, in voller Kameraauflösung, nicht das
gesamte, heruntergerechnete Kamerabild (`grabFrameRegion` in
`src/camera.js` — derselbe Ausschnitt, den auch die laufende Barcode-Suche
benutzt). Ein Wechsel der Funktion während des Sortierens (z. B. bei
gemischter Ware) gilt sofort: laufende Suche startet oder stoppt, die
Schaltfläche erscheint, verschwindet oder ändert ihren Wortlaut.
Liegt beim Start eine gesicherte Sitzung vor, erscheint zuerst die Frage
nach dem Fortsetzen (siehe Punkt 6 unten) und erst danach die Funktionswahl
— die Kamera startet in jedem Fall erst, nachdem beides geklärt ist.
Technisch bekommen „Strichcode" und „QR-Code" eine Texterkennung
hineingereicht, die sofort leeren Text liefert, ohne Tesseract anzustoßen;
„Text erkennen" bekommt eine Barcode-Dekodierung hineingereicht, die sofort
eine leere Liste liefert; „Zählen" bekommt beides leer hineingereicht
(`buildRecognitionAdapters` in `src/scan-recognition.js`). Die
Erkennungs-Pipeline selbst (`src/pipeline.js`, Punkte 13 und 5 unten)
bleibt dabei unverändert und kennt keine Funktionen — die drei
code-/textbasierten Funktionen unterscheiden sich ausschließlich darin,
welche Adapter ihr übergeben werden. „Zählen" ruft diese Pipeline im
normalen Betrieb gar nicht erst auf (siehe Punkt 4 unten) — es gibt dort ja
nichts zu buchen, das leere Adapterpaar dient nur als Absicherung für den
seltenen Ersatzweg ohne Kamera (Punkt 7).
1. **Barcode-Dekodierung** (nur in „Strichcode" und „QR-Code"). Die zur
Funktion gehörenden Codearten werden laufend aus dem Kamerabild dekodiert
(`zxing-wasm`, `decodeBarcodes` in `src/barcode.js` bekommt die Codearten
der gewählten Funktion übergeben). Das ist exakt, im Gegensatz zu
Texterkennung. Werden mehrere Barcodes im Bild gefunden, die sich
widersprechen (nicht dieselbe Teilenummer), gilt das als mehrdeutig — die
App rät nicht, sondern fragt nach. Das gilt nur für Barcodes mit einem
bekannten Nummernschema; widersprechen sich mehrere Barcodes mit
unbekanntem Nummernschema, wird keine ihrer Teilenummern übernommen.
2. **Teilenummer-Decoder** (nur relevant in „Strichcode"/„QR-Code" — „Text
erkennen" liest nie einen Barcode, siehe Punkt 0). Aus einer Hersteller-PN
wie `M386A8K40BM1-CRC4Y` werden Kapazität, Bauform und Geschwindigkeit
tabellengesteuert abgeleitet. Gelingt das, entfällt OCR vollständig, das Ergebnis ist grün.
Kennt der Decoder das Nummernschema nicht (siehe „Grenzen“ unten), ist das Kennt der Decoder das Nummernschema nicht (siehe „Grenzen“ unten), ist das
allein noch kein Grund für Rot: Zum Sortieren muss keine Kapazität bekannt allein noch kein Grund für Rot: Zum Sortieren muss keine Kapazität bekannt
sein, eine exakt gelesene Teilenummer genügt, um ein Modul wiederzuerkennen sein, eine exakt gelesene Teilenummer genügt, um ein Modul wiederzuerkennen
@@ -72,29 +140,122 @@ keine Daten verlassen das Gerät.
könnte, das über die Zuordnung entscheidet. Bei einem tatsächlich neuen könnte, das über die Zuordnung entscheidet. Bei einem tatsächlich neuen
Modul läuft sie dagegen wie gehabt, denn dort liefert sie die lesbare Modul läuft sie dagegen wie gehabt, denn dort liefert sie die lesbare
Beschriftung des neuen Stapels. Beschriftung des neuen Stapels.
3. **OCR als Rückfallebene.** Nur wenn kein Barcode eindeutig lesbar war oder 3. **Texterkennung** (ausschließlich in „Text erkennen", ausgelöst durch
sein Nummernschema unbekannt ist (z. B. überklebtes/beschädigtes Etikett „Modul scannen"). Anders als früher ist das kein automatischer Rückfall
oder ein noch nicht in `src/pn-tables.js` hinterlegter Hersteller). Das vom Barcode-Weg mehr, sondern die einzige Quelle dieser Funktion — sie
Kamerabild wird dafür in Graustufen gewandelt und mit einem liest ohnehin nie einen Barcode (siehe Punkt 0). Gelesen wird der
**Otsu-Schwellwert** in Schwarz/Weiß aufbereitet: Der Schwellwert wird aus Bildausschnitt innerhalb des Zielrahmens, in voller Kameraauflösung
der Helligkeitsverteilung des gesamten Bildes bestimmt, nicht aus einer (`grabFrameRegion`, siehe Punkt 0) — nicht mehr das ganze, auf 1280 Pixel
festen Kontrastspreizung zwischen hellstem und dunkelstem Pixel. Das macht heruntergerechnete Kamerabild. Das Bild wird dafür
die Aufbereitung robust gegen einzelne Lichtreflexe auf glänzenden in Graustufen gewandelt und mit einem **Otsu-Schwellwert** in Schwarz/Weiß
Metalletiketten, die eine reine Min/Max-Spreizung leicht kippen würden. aufbereitet: Der Schwellwert wird aus der Helligkeitsverteilung des
Das OCR-Ergebnis wird anschließend gegen die bekannten Spec-Werte gesamten Bildes bestimmt, nicht aus einer festen Kontrastspreizung
abgeglichen, was die typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst. zwischen hellstem und dunkelstem Pixel. Das macht die Aufbereitung robust
4. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe gegen einzelne Lichtreflexe auf glänzenden Metalletiketten, die eine reine
Min/Max-Spreizung leicht kippen würden. Die Zeichen-Whitelist von
Tesseract (`tessedit_char_whitelist` in `src/ocr.js`) lässt Groß- **und**
Kleinbuchstaben, Ziffern sowie die auf Etiketten üblichen Satz- und
Sonderzeichen zu — nicht mehr nur Großbuchstaben. Das OCR-Ergebnis wird
anschließend gegen die bekannten Spec-Werte abgeglichen, was die
typischen Verwechslungen (0/O, 1/I, 8/B, …) auflöst; das kann durch die
breitere Whitelist bei den Codeteilen (Kapazität, Rank, Geschwindigkeit,
Teilenummer) etwas an Genauigkeit kosten, weil Tesseract jetzt z. B.
zwischen „O“ und „o“ unterscheiden muss — eine bewusst in Kauf genommene
Abwägung, damit der vollständige Etikettentext lesbar wird. Da in dieser
Funktion nie eine per Barcode gelesene Teilenummer vorliegt, entscheiden
hier ausschließlich die aus dem erkannten Text abgeleiteten technischen
Angaben (Kapazität, Bauform, Rank, Geschwindigkeit) über die
Stapelzuordnung (`specsCompatible` in `src/spec.js`) — anders als in
„Strichcode"/„QR-Code", wo meist die exakte Teilenummer trägt.
Der vollständige, ungefilterte Erkennungstext (nicht nur die daraus
verstandenen Felder) wird zusätzlich mitgeführt (`recognize` in
`src/pipeline.js` liefert ihn als `rawText` zurück) und je Eintrag
gespeichert — rein additiv, ohne jeden Einfluss auf die Stapelzuordnung
(siehe „Grenzen" unten). In Funktion „Text erkennen" bleibt die
Treffer-Rückmeldung deshalb stehen, bis der Nutzer sie wegtippt, und zeigt
den vollen erkannten Text an, statt sich nach gut einer Sekunde von selbst
auszublenden wie in den beiden Barcode-Funktionen (`showResult` in
`src/ui/result-overlay.js`). Der Rohtext jedes Eintrags lässt sich später
auch in der Sitzungsliste nachlesen.
4. **Zählen** (ausschließlich in Funktion „Zählen"). Reine Anzeige, ohne
jeden Bezug zur Sitzung — und, anders als die drei übrigen Funktionen mit
laufender Erkennung, auf **Aufnahme statt Dauerbetrieb**: Eine Zählung ist
eine Schätzung, kein eindeutiger Treffer wie ein Barcode; sie mehrmals pro
Sekunde neu anzuzeigen macht sie nicht genauer, nur unruhig (am
Nutzergerät sprang die Anzeige im Dauerbetrieb zwischen etwa 3 und 60, an
einem Standfoto derselben Teile lieferte dasselbe Verfahren stabil 18).
Antippen der Schaltfläche „Zählen“/„Nochmal zählen“ nimmt deshalb binnen
rund einer Sekunde fünf Bilder aus dem Zielrahmen des laufenden
Videobilds auf (`grabFrameRegion` in `src/camera.js` — bewusst nicht die
native Fotoaufnahme des Geräts, deren Bildausschnitt von der Vorschau
abweichen kann), zählt jedes einzeln (`countObjects` in
`src/count-objects.js`, unverändert) und zeigt als Ergebnis deren Median
(`combineCaptureCounts` in `src/count-capture.js`) — robust gegen ein
einzelnes verwackeltes oder mitten in eine Fokusregelung fallendes Bild,
ohne die frühere fortlaufende Glättung über die letzten neun Messungen
(`count-history.js`, entfallen: eine Aufnahme mit fünf Bildern leistet
dieselbe Robustheit bereits selbst). Während der rund einen Sekunde zeigt
die Statuszeile „zähle …“, damit der Nutzer die Kamera ruhig hält und den
Knopfdruck nicht für wirkungslos hält (`runCountCapture` in
`src/main.js`). Das Ergebnis bleibt danach stehen, bis der Nutzer erneut
antippt — verlässt er die Funktion oder wechselt zu einer anderen, werden
Ergebnis und Diagnosezeile zurückgesetzt. Es gibt in dieser Funktion keine
Treffer-Rückmeldung und keine Stapel-Zuweisung; die Zahl wird nirgends
gespeichert, nicht einmal flüchtig für die Dauer der Sitzung. Die
Diagnosezeile unter der großen Zahl (Antippen der Zahl blendet sie ein
oder aus) zeigt zusätzlich zu den übrigen Zwischenwerten (die sich auf die
*letzte* der fünf Einzelmessungen beziehen) die fünf Einzelmessungen
dieser Aufnahme selbst — daran erkennen Nutzer und Entwickler sofort, ob
das Ergebnis belastbar ist: Fünf Messungen von 17 bis 19 bedeuten etwas
anderes als fünf Messungen von 4 bis 50.
Kein Bilderkennungsmodell: Der **Otsu-Schwellwert** (`threshold.js`),
den die Texterkennung weiterhin nutzt, taugt für „Zählen" nicht — er
setzt voraus, dass Objekt- und Untergrundfläche etwa gleich groß sind.
Am echten Foto des Auftraggebers (rund zwanzig silberne
Festplattenschrauben auf dunklem Leder) belegen die Schrauben unter
einem Prozent der Fläche; Otsu rutschte deshalb auf den höchsten
Helligkeitswert und erklärte nur die hellsten Punkte der Ledernarbung
zum Objekt (gemessenes Fehlverhalten: 76712 statt rund zwanzig). Über
die Helligkeit allein sind Schraube und Untergrund an diesem Foto gar
nicht trennbar.
`countObjects` (`src/count-objects.js`) verwendet stattdessen
**örtlichen Kontrast**: Der Ausschnitt wird auf 800 Bildpunkte Breite
normiert (schmalere Ausschnitte bleiben unverändert — macht alle
folgenden Maße unabhängig von der Kameraauflösung), in Graustufen
gewandelt und mit einem Kastenfilter geglättet (unterdrückt feine
Untergrundnarbung, über ein Summenbild für radius-unabhängige
Laufzeit). Ein zweiter, deutlich größerer Kastenmittelwert liefert den
**örtlichen Hintergrund** an jeder Stelle — das fängt ungleichmäßige
Ausleuchtung ab. Ein Bildpunkt gilt als Objekt, wenn er um mehr als
einen festen Betrag heller **oder** dunkler ist als sein örtlicher
Hintergrund (beide Richtungen werden berechnet; es gewinnt die mit den
meisten gefundenen Einzelflächen — Details siehe unten). Anschließend
werden zusammenhängende Objektflächen ermittelt
(Connected-Component-Labeling mit einer eigenen Arbeitsliste statt
Rekursion, damit ein rund eine Viertelmillion Bildpunkte großer
Ausschnitt nicht in den Stapelüberlauf läuft), winzige Flächen als
Rauschen verworfen und nur Flächen ab einem festen **Anteil der
größten gefundenen Fläche** behalten — bewusst relativ, nicht absolut,
damit ein näher oder weiter weg gehaltenes Kamerabild die Zählung nicht
verstellt. Auffällig große Flächen (mehrere berührende Teile) werden
auf ein Vielfaches der typischen (Median-)Einzelteilgröße
hochgerechnet. Details, alle verwendeten Schwellen und die Messung am
echten Foto: `.superpowers/sdd/counting-rework-report.md`.
5. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt, widersprüchliche durch; nur bei roter Konfidenz (nichts Eindeutiges erkannt, widersprüchliche
Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität) Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität)
oder mehrdeutiger Stapelzuordnung fragt die App nach. oder mehrdeutiger Stapelzuordnung fragt die App nach.
5. **Sitzungsfortsetzung beim Start.** Findet die App beim Laden eine in 6. **Sitzungsfortsetzung beim Start.** Findet die App beim Laden eine in
`localStorage` gesicherte, nicht leere Sitzung vor, wird sie nicht `localStorage` gesicherte, nicht leere Sitzung vor, wird sie nicht
stillschweigend übernommen, sondern zur Fortsetzung oder zum Verwerfen stillschweigend übernommen, sondern zur Fortsetzung oder zum Verwerfen
angeboten (`askResumeSession` in `src/ui/resume-dialog.js`, verdrahtet über angeboten (`askResumeSession` in `src/ui/resume-dialog.js`, verdrahtet über
`initResumeOffer()` in `src/main.js`). Erst „fortsetzen“ stellt Stapel, `initResumeOffer()` in `src/main.js`). Erst „fortsetzen“ stellt Stapel,
Zähler und die „Zuletzt“-Zeile wieder her; „verwerfen“ löscht den Zähler und die „Zuletzt“-Zeile wieder her; „verwerfen“ löscht den
gesicherten Stand und die App startet leer. gesicherten Stand und die App startet leer.
6. **Ersatzweg ohne Kamera.** Lässt sich die Kamera nicht starten (z. B. 7. **Ersatzweg ohne Kamera.** Lässt sich die Kamera nicht starten (z. B.
verweigerte Berechtigung), bleibt die App bedienbar: eine dauerhaft verweigerte Berechtigung), bleibt die App bedienbar: eine dauerhaft
sichtbare Schaltfläche „Bild auswählen“ öffnet stattdessen die native sichtbare Schaltfläche „Bild auswählen“ öffnet stattdessen die native
Dateiauswahl (`setFilePickerVisible` in `src/ui/scan-view.js`). Die App Dateiauswahl (`setFilePickerVisible` in `src/ui/scan-view.js`). Die App
@@ -133,14 +294,30 @@ korrekt funktioniert.
npm test npm test
``` ```
124 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich 179 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert) und und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert, jetzt in
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen `threshold.js`) und -Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline
und Mehrdeutigkeitsbehandlung, sowie die Sitzungssicherung samt Prüfung eines samt Zeitgrenzen, Mehrdeutigkeitsbehandlung und dem additiven OCR-Rohtext
wiederhergestellten Zustands. Kamera, Barcode-/OCR-Adapter selbst und (`rawText` wird nur zurückgegeben, wenn die Texterkennung tatsächlich lief,
Oberfläche laufen nur im echten Browser und werden dort manuell geprüft und beeinflusst nachweislich nie die Stapelzuordnung), die Sitzungssicherung
(siehe „Am Gerät noch zu prüfen" unten) — `node --test` kennt kein DOM. samt Prüfung eines wiederhergestellten Zustands (einschließlich
`entry.rawText`), die vier Scan-Funktionen (`scan-modes.js` — Codearten je
Funktion, DataMatrix/Aztec/PDF417 in „QR-Code") sowie die je Funktion
tatsächlich aufgerufenen Adapter (`scan-recognition.js` — Texterkennung läuft
nachweislich nie in „Strichcode"/„QR-Code", Barcode-Dekodierung nachweislich
nie in „Text erkennen"/„Zählen"). Dazu die Zähl-Berechnung selbst
(`count-objects.js` — leeres Bild, einzelne und mehrere getrennte Flächen,
verworfenes Rauschen, Hochrechnung berührender Teile ohne ein nur leicht
größeres Einzelteil zu verdoppeln, helle wie dunkle Objekte, ein Bild ohne
Bildpunkte). Dazu die reine Zusammenfassung der fünf Einzelmessungen einer
Zaehl-Aufnahme zu deren Median (`count-capture.js` — auch für den Fall
weniger als fünf verwertbarer Bilder, sowie eine leere Messreihe, die
regulär wirft statt stillschweigend `NaN` zu liefern). Kamera,
Barcode-/OCR-Adapter selbst, die Auswahl-Oberfläche und
die Verdrahtung in `main.js` laufen nur im echten Browser und werden dort
manuell geprüft (siehe „Am Gerät noch zu prüfen" unten) — `node --test` kennt
kein DOM.
## Produktion ## Produktion
@@ -189,7 +366,9 @@ worauf du bei der Veröffentlichung achten solltest.
- Vanilla JavaScript (ES Modules), kein Framework - Vanilla JavaScript (ES Modules), kein Framework
- [Vite](https://vite.dev/) als Entwicklungsserver und Bündler - [Vite](https://vite.dev/) als Entwicklungsserver und Bündler
- [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm) für Code-128 und DataMatrix - [`zxing-wasm`](https://github.com/Sec-ant/zxing-wasm) zur Barcode-Dekodierung;
welche Codearten gesucht werden, gibt die gewählte Funktion vor
(`src/scan-modes.js`)
- [`tesseract.js`](https://tesseract.projectnaptha.com/) für Texterkennung - [`tesseract.js`](https://tesseract.projectnaptha.com/) für Texterkennung
- `node:test` für Tests, ohne zusätzliche Test-Bibliothek - `node:test` für Tests, ohne zusätzliche Test-Bibliothek
@@ -202,25 +381,33 @@ worauf du bei der Veröffentlichung achten solltest.
| `src/pn-decoder.js` | Teilenummer → Spec-Felder | | `src/pn-decoder.js` | Teilenummer → Spec-Felder |
| `src/ocr-extract.js` | OCR-Rohtext → Spec-Felder | | `src/ocr-extract.js` | OCR-Rohtext → Spec-Felder |
| `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen — inklusive der aus den Einträgen abgeleiteten, je Stapel bekannten Barcode-Inhalte (`stack.codes`), die vor dem Vergleich der technischen Angaben über die Zuordnung entscheiden, sofern diese Angaben nicht widersprechen | | `src/session.js` | Stapel halten, vorschlagen, buchen, umsortieren, zurücknehmen — inklusive der aus den Einträgen abgeleiteten, je Stapel bekannten Barcode-Inhalte (`stack.codes`), die vor dem Vergleich der technischen Angaben über die Zuordnung entscheiden, sofern diese Angaben nicht widersprechen |
| `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) | | `src/pipeline.js` | Barcode → Teilenummer → OCR → Ampelfarbe, inkl. Zeitgrenzen, Mehrdeutigkeitsbehandlung und Kurzschluss über einen bereits bekannten Barcode-Inhalt (`deps.isKnownCode`) — kennt keine Scan-Funktionen; liefert zusätzlich (additiv) den ungefilterten OCR-Rohtext (`rawText`), leer, wenn keine Texterkennung lief |
| `src/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten) | | `src/scan-modes.js` | Die vier Scan-Funktionen (Strichcode, QR-Code, Text erkennen, Zählen): Kennung, deutsche Beschriftung, gelesene Codearten, laufende Codesuche ja/nein, Texterkennung ja/nein — einzige Quelle, sowohl für die Auswahl-Oberfläche als auch für `main.js`. „Zählen" läuft wie „Text erkennen" auf Knopfdruck (`continuousSearch: false`) und braucht dafür kein eigenes Merkmal mehr |
| `src/camera.js` | Kamerastart, Einzelbildaufnahme, Bilddatei-Ersatzweg | | `src/scan-recognition.js` | Baut die an `recognize()` übergebenen Adapter anhand der gewählten Funktion: liest eine Funktion keine Codearten (leeres `barcodeFormats`, bei „Text erkennen" und „Zählen"), liefert die Barcode-Dekodierung sofort eine leere Liste ohne `zxing-wasm` anzustoßen; nur bei `useOcr` läuft die echte Texterkennung, sonst liefert sie sofort leeren Text ohne Tesseract anzustoßen (in „Zählen" ruft `recognize()` im normalen Betrieb ohnehin nicht auf — siehe `count-capture.js`) |
| `src/barcode.js` | Adapter zu `zxing-wasm` | | `src/count-capture.js` | Reine Berechnung für die Aufnahme in Funktion „Zählen": bildet aus den (bis zu fünf) Einzelmessungen einer Aufnahme (`countObjects()` je Bild) deren Median (`combineCaptureCounts`) — robust gegen ein einzelnes verwackeltes oder unscharfes Bild, bleibt auch bei weniger als fünf verwertbaren Bildern sinnvoll |
| `src/ocr.js` | Bildaufbereitung (Otsu) und Adapter zu `tesseract.js` | | `src/threshold.js` | Otsu-Schwellwertbestimmung (Graustufen, Histogramm, Schwellwert) — reines Modul ohne Browser-Zugriff, aus `ocr.js` herausgezogen; gemeinsame Grundlage für `preprocess()` (ocr.js) und `countObjects()` (count-objects.js) |
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, Scan-Knopf, Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera | | `src/count-objects.js` | Reine Berechnung für Funktion „Zählen": zählt zusammenhängende Objektflächen in einem Bildausschnitt über örtlichen Kontrast (Normierung auf 800 Bildpunkte Breite, Glättung und örtlicher Hintergrund je über Kastenfilter/Summenbild, Maske aus geglättetem Bild vs. örtlichem Hintergrund ± Marge, beide Polaritäten, Connected-Component-Labeling mit eigener Arbeitsliste, Rauschfilter, Massstab relativ zur größten Fläche, Hochrechnung berührender Teile über den Flächen-Median) — nutzt `threshold.js`/Otsu bewusst **nicht** mehr (siehe „Funktionsweise" oben) |
| `src/ui/result-overlay.js` | Kurze Treffer-Rückmeldung (grün/gelb), blendet sich nach kurzer Zeit selbst wieder aus | | `src/storage.js` | Absturzschutz der laufenden Sitzung: sichert und lädt aus `localStorage`, verwirft beim Laden jeden in sich unstimmigen Zustand vollständig (siehe unten); `entry.rawText` ist wie `entry.codes` optional und wird bei Fehlen als leerer Rohtext wiederhergestellt |
| `src/camera.js` | Kamerastart, Einzelbildaufnahme (herunterskaliert, für die Dateiauswahl), Rahmenausschnitt in voller Auflösung (`grabFrameRegion`, für die laufende Barcode-Suche, „Modul scannen" in „Text erkennen" **und** jedes der fünf Bilder einer Aufnahme in „Zählen"), Bilddatei-Ersatzweg |
| `src/barcode.js` | Adapter zu `zxing-wasm`; `decodeBarcodes(imageData, formats)` bekommt die Codearten übergeben, ohne Angabe wie bisher (Code128 + DataMatrix) |
| `src/ocr.js` | Bildaufbereitung (Otsu, über `threshold.js`) und Adapter zu `tesseract.js`; Zeichen-Whitelist deckt Groß- und Kleinbuchstaben, Ziffern sowie übliche Etiketten-Sonderzeichen ab |
| `src/ui/scan-view.js` | Scan-Ansicht: Kamera-Vorschau, antippbare Anzeige der gewählten Funktion (Wechsel), eine gemeinsame Aufnahme-Schaltfläche für beide Knopfdruck-Funktionen (sichtbar in „Text erkennen" und „Zählen", Wortlaut je nach Funktion und Zustand über `setCaptureLabel`), große Zählanzeige samt Diagnosezeile (nur in „Zählen" sichtbar), Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera |
| `src/ui/mode-dialog.js` | Vollbild-Auswahl der Scan-Funktion vor dem Start und beim Wechsel: vier große Flächen, eine je Funktion aus `src/scan-modes.js` |
| `src/ui/result-overlay.js` | Treffer-Rückmeldung (grün/gelb): blendet sich in „Strichcode"/„QR-Code" nach kurzer Zeit selbst aus; in „Text erkennen" bleibt sie stehen, zeigt den vollen OCR-Rohtext und wartet auf eine Eingabe, bevor sie schließt |
| `src/ui/ambiguous-dialog.js` | Rot-Dialog bei roter Konfidenz oder mehrdeutiger Stapelzuordnung | | `src/ui/ambiguous-dialog.js` | Rot-Dialog bei roter Konfidenz oder mehrdeutiger Stapelzuordnung |
| `src/ui/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen | | `src/ui/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen — läuft vor der Funktionswahl |
| `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden | | `src/ui/session-list.js` | Sitzungsliste: Stapel-Übersicht, Umsortieren, Entfernen, Sitzung beenden, Anzeige des vollständigen OCR-Rohtexts je Eintrag (sofern vorhanden) |
| `src/ui/describe-spec.js` | Gemeinsame Kurzbeschreibung eines Specs (Kapazität, Rank, Geschwindigkeit, Bauform), wahlweise mit Platzhaltern für fehlende Felder; von Treffer-Rückmeldung, „Zuletzt"-Zeile und Sitzungsliste gemeinsam genutzt | | `src/ui/describe-spec.js` | Gemeinsame Kurzbeschreibung eines Specs (Kapazität, Rank, Geschwindigkeit, Bauform), wahlweise mit Platzhaltern für fehlende Felder; von Treffer-Rückmeldung, „Zuletzt"-Zeile und Sitzungsliste gemeinsam genutzt |
| `src/main.js` | Verdrahtung aller Module zur lauffähigen App | | `src/main.js` | Verdrahtung aller Module zur lauffähigen App |
| `src/styles.css` | Farbvariablen (Grün/Gelb/Rot der Ampel-Rückmeldung), Layout des Kamera-Vollbilds samt Zielrahmen, Stapel-Leiste und Aktionsknöpfe sowie die Overlays für Treffer-Rückmeldung, Rot-Dialog und Sitzungsliste | | `src/styles.css` | Farbvariablen (Grün/Gelb/Rot der Ampel-Rückmeldung), Layout des Kamera-Vollbilds samt Zielrahmen, Stapel-Leiste und Aktionsknöpfe sowie die Overlays für Treffer-Rückmeldung, Rot-Dialog und Sitzungsliste |
| `server.py` | Auslieferung im Betrieb: statischer Server für `dist/` mit Passwortabfrage vor jeder Datei, plus passwortfreier Gesundheitspfad `/healthz`. Nur Python-Standardbibliothek, keine zusätzliche Abhängigkeit | | `server.py` | Auslieferung im Betrieb: statischer Server für `dist/` mit Passwortabfrage vor jeder Datei, plus passwortfreier Gesundheitspfad `/healthz`. Nur Python-Standardbibliothek, keine zusätzliche Abhängigkeit |
| `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung | | `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung |
`spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline` und `storage` sind `spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline`, `storage`,
reine Funktionen ohne Browser-Zugriff (kein `window`, `document` oder `scan-modes`, `scan-recognition`, `threshold`, `count-objects` und
`localStorage` direkt) und deshalb vollständig mit `node:test` prüfbar. `count-capture` sind reine Funktionen ohne Browser-Zugriff (kein `window`,
`document` oder `localStorage` direkt) und deshalb vollständig mit
`node:test` prüfbar.
`camera.js`, `barcode.js`, `ocr.js` und `src/ui/` brauchen einen echten `camera.js`, `barcode.js`, `ocr.js` und `src/ui/` brauchen einen echten
Browser und werden nur manuell geprüft. Browser und werden nur manuell geprüft.
@@ -245,6 +432,14 @@ Zeichenketten); ein gesicherter Stand aus einer Fassung vor dieser Erweiterung
ohne diese Felder gilt dagegen nicht als unstimmig, sondern wird beim Laden ohne diese Felder gilt dagegen nicht als unstimmig, sondern wird beim Laden
als Stapel bzw. Eintrag ohne bekannte Codes wiederhergestellt. als Stapel bzw. Eintrag ohne bekannte Codes wiederhergestellt.
Nach demselben Muster ist `entry.rawText` (der volle OCR-Rohtext, siehe
„Funktionsweise" oben) optional: Ist er vorhanden, muss er eine Zeichenkette
sein; ein gesicherter Stand aus einer Fassung vor dieser Erweiterung ohne
dieses Feld gilt nicht als unstimmig und wird beim Laden als Eintrag ohne
Rohtext (leere Zeichenkette) wiederhergestellt. Anders als `stack.codes` wird
`entry.rawText` beim Laden nicht neu abgeleitet — er hängt an genau diesem
Eintrag und fließt in keine Stapel-Berechnung ein.
## Grenzen ## Grenzen
- Keine Bestandsführung über Sitzungen hinweg, kein Export. Die Sicherung in - Keine Bestandsführung über Sitzungen hinweg, kein Export. Die Sicherung in
@@ -282,6 +477,50 @@ als Stapel bzw. Eintrag ohne bekannte Codes wiederhergestellt.
Validierung braucht deshalb echte Module, keine Code-Prüfung. Validierung braucht deshalb echte Module, keine Code-Prüfung.
- Farb- und Schriftgestaltung ist bewusst schlicht gehalten und kann nach dem - Farb- und Schriftgestaltung ist bewusst schlicht gehalten und kann nach dem
ersten Einsatz am Tisch nachgezogen werden. ersten Einsatz am Tisch nachgezogen werden.
- Der volle OCR-Rohtext (`rawText`) dient ausschließlich der Anzeige für den
Nutzer (Treffer-Rückmeldung, Sitzungsliste). Die Stapelzuordnung stützt
sich bewusst weiterhin ausschließlich auf die daraus abgeleiteten,
verstandenen Spec-Felder — roher Erkennungstext schwankt zwischen
Aufnahmen (Zeilenumbrüche, zusätzlich erkannte Wörter, Groß-/
Kleinschreibung, …) und würde als Gruppierungsmerkmal für praktisch jedes
Modul einen eigenen Stapel erzeugen.
- **Funktion „Zählen" ist eine Schätzung, keine exakte Zählung** — sie wird
ungenau, wenn eine der folgenden Bedingungen zutrifft:
- **Der Untergrund muss sich abheben.** Das Verfahren erkennt Fundstücke
über den örtlichen Kontrast zu ihrer unmittelbaren Umgebung (siehe
„Funktionsweise" oben) — es braucht also eine Stelle, an der sich das
Fundstück *lokal* von dem unterscheidet, was um es herum liegt. Ein
Fundstück, das nahezu dieselbe Helligkeit wie der Tisch/das Leder
darunter hat, wird nicht zuverlässig gefunden.
- **Gleichmäßige Ausleuchtung hilft.** Der örtliche Hintergrund wird aus
einem Fenster um jeden Bildpunkt gebildet und fängt einseitigen
Lichteinfall dadurch besser ab als ein einzelner, fester Schwellwert
für das ganze Bild. Sehr harte Schlagschatten oder Reflexe können
trotzdem eigene, dem Fundstück ähnliche Kontrastflächen erzeugen (siehe
„mehrere gefundene Flächen" unten) und die Zählung verfälschen.
- **Auseinandergelegt zählt besser als aufgehäuft/berührend.** Mehrere
Teile, die sich berühren, bilden eine einzige zusammenhängende Fläche.
Die App rechnet diese über die typische Einzelteilgröße (Median der
gefundenen Flächen) auf ein Vielfaches hoch, aber je unregelmäßiger sich
die Teile überlappen (z. B. mehrere Schrauben, die sich gegenseitig
teilweise verdecken statt sich nur zu berühren), desto ungenauer wird
die hochgerechnete Fläche — und je dichter die Teile gedrängt liegen,
desto eher sieht das Umgebungsfenster (siehe oben) an ihrer eigenen
Stelle vor allem Nachbarteile statt echten Untergrund. Liegen Teile
übereinander gestapelt statt nebeneinander ausgelegt, sieht die Kamera
von oben nur die Fläche des obersten Teils — verdeckte Teile werden
nicht mitgezählt.
- **Stark unterschiedlich große Teile verfälschen die Hochrechnung.** Die
Hochrechnung setzt voraus, dass die meisten Fundstücke ungefähr gleich
groß sind (der Median dient als Maßstab für „ein Teil"). Liegen im
selben Rahmen sowohl deutlich größere als auch deutlich kleinere Teile
derselben Sorte (oder gar unterschiedliche Teile gemischt), kann die
Rauschschwelle ein tatsächliches kleines Teil verwerfen oder die
Hochrechnung ein einzelnes großes Teil als mehrere zählen.
- Details, die verwendeten Schwellenwerte, ihre Begründung und die
Messung am echten Foto (~20 Festplattenschrauben auf Leder, für das
das alte Otsu-basierte Verfahren 76712 statt rund zwanzig meldete)
stehen in `.superpowers/sdd/counting-rework-report.md`.
## Herstellertabellen erweitern ## Herstellertabellen erweitern
@@ -325,40 +564,130 @@ werden:
- Datei-Auswahl als Ersatzweg (ohne Kamera) öffnet den nativen Dialog und - Datei-Auswahl als Ersatzweg (ohne Kamera) öffnet den nativen Dialog und
hinterlässt keine leere Fläche im Layout. hinterlässt keine leere Fläche im Layout.
**Funktionswahl**
- Erster Start ohne gesicherte Sitzung: Auswahl mit vier großen Flächen
(„Strichcode", „QR-Code", „Text erkennen", „Zählen") erscheint, *bevor* die
Kamera startet — kein Kamerabild sichtbar, solange keine Funktion gewählt
ist.
- Erster Start *mit* gesicherter Sitzung: erst die Frage „Gesicherte Sitzung
gefunden" (fortsetzen/verwerfen), erst danach die Funktionswahl.
- „Strichcode" wählen: laufende Suche erkennt Code-128/Code-39/EAN/UPC/…,
kein „Modul scannen"-Knopf sichtbar, Zielrahmen hebt sich bei erkanntem
Code hervor.
- „QR-Code" wählen und ein Etikett mit dem eigenen DataMatrix-Code vor die
Kamera halten: Rahmen hebt sich hervor, Treffer wird gebucht (DataMatrix
liest diese Funktion jetzt mit). Einen echten QR-Code vor dieselbe Funktion
halten: ebenfalls Treffer.
- „Text erkennen" wählen: kein automatisches Hervorheben des Rahmens (er
bleibt durchgehend schlicht), „Modul scannen"-Knopf sichtbar und löst die
Texterkennung aus. Der Rahmen ist dort trotzdem wirksam: nur der
Bildausschnitt innerhalb des Rahmens wird gelesen (nicht das ganze
Kamerabild) — mit Text außerhalb des Rahmens auf demselben Etikett prüfen,
dass er **nicht** erkannt wird, während Text innerhalb des Rahmens
vollständig erscheint.
- Anzeige der gewählten Funktion in der Scan-Ansicht antippen: Auswahl
erscheint erneut, nach der Wahl gilt der Wechsel sofort — laufende Suche
startet/stoppt, Knopf erscheint/verschwindet, **ohne** die Seite neu zu
laden.
- Anzeige der gewählten Funktion aus rund einem Meter Entfernung lesbar.
**Barcode- und OCR-Erkennung** **Barcode- und OCR-Erkennung**
- Samsung-Referenzmodul scannen: Code-128 *und* DataMatrix werden erkannt, - Samsung-Referenzmodul in Funktion „Strichcode" scannen:
`M386A8K40BM1-CRC4Y` wird korrekt als Teilenummer übernommen, grüne `M386A8K40BM1-CRC4Y` wird korrekt als Teilenummer übernommen, grüne
Rückmeldung mit `STAPEL A`, `64GB 4DRx4 PC4-2400 LRDIMM`. Rückmeldung mit `STAPEL A`, `64GB 4DRx4 PC4-2400 LRDIMM`.
- Etikett ohne lesbaren Barcode fotografieren: OCR-Weg greift, die - In Funktion „Text erkennen" ein Etikett fotografieren und „Modul scannen"
Otsu-Aufbereitung liefert auf einem echten, glänzenden Etikettenfoto antippen: die Otsu-Aufbereitung liefert auf einem echten, glänzenden
tatsächlich brauchbaren Text (bisher nur an synthetischen Testbildern Etikettenfoto tatsächlich brauchbaren Text (bisher nur an synthetischen
geprüft, nie an einem echten Foto). Testbildern geprüft, nie an einem echten Foto). Prüfen, dass die
Rückmeldung dabei den **vollständigen** erkannten Text zeigt (Groß- und
Kleinbuchstaben, nicht nur die von der App verstandenen Felder wie
`32GB 2Rx4 PC4-2666`), stehen bleibt, bis sie weggetippt wird, und bei
langem Text lesbar bleibt (per Scrollen erreichbar, wächst nicht aus dem
Bildschirm heraus). Danach in der Sitzungsliste nachsehen, dass derselbe
Rohtext beim Eintrag zum Nachlesen erscheint.
- Da der Bildausschnitt für die Texterkennung jetzt deutlich größer ist als
das bisher übergebene, auf 1280 Pixel herunterskalierte Vollbild
(`grabFrameRegion` statt `grabFrame`, siehe „Funktionsweise"), dauert ein
einzelner Erkennungsversuch länger als vorher — beobachten, wie lange ein
normaler Scan auf einem durchschnittlichen Handy tatsächlich braucht, und
ob er dabei spürbar unter der 20-Sekunden-Zeitgrenze der Pipeline bleibt
(siehe dazu auch den folgenden Punkt zur Ladezeit des Tesseract-Arbeiters,
die zusätzlich in dieselbe Zeitgrenze fällt).
- Ladezeit des WASM-Barcode-Moduls (`zxing-wasm`) beim allerersten Scan einer - Ladezeit des WASM-Barcode-Moduls (`zxing-wasm`) beim allerersten Scan einer
Sitzung beobachten — bleibt sie deutlich unter der 10-Sekunden-Zeitgrenze Sitzung in Funktion „Strichcode" oder „QR-Code" beobachten — bleibt sie
der Pipeline, und braucht ein zweiter, schnell nachfolgender Scan nicht deutlich unter der 10-Sekunden-Zeitgrenze der Pipeline, und braucht ein
erneut die volle Ladezeit (Beleg, dass die Vorbereitung tatsächlich nur zweiter, schnell nachfolgender Scan nicht erneut die volle Ladezeit (Beleg,
einmal läuft)? dass die Vorbereitung tatsächlich nur einmal läuft)? In Funktion „Text
erkennen" darf dieses Laden dagegen gar nicht erst anlaufen, da dort nie
`decodeBarcodes` aufgerufen wird.
**Zählen (Aufnahme statt Dauerbetrieb)**
- „Zählen" wählen, Kamera auf ausgelegte Kleinteile richten (z. B. silberne
Festplattenschrauben auf dunklem Untergrund): Es erscheint **keine**
Zahl und **keine** fortlaufende Aktualisierung, sondern eine gut
erreichbare Schaltfläche mit der Aufschrift „Zählen". Kein roter Dialog,
keine Treffer-Rückmeldung, keine Stapel-Zuweisung — der Zielrahmen bleibt
sichtbar und zeigt, was beim nächsten Antippen gezählt wird.
- „Zählen" antippen: Für rund eine Sekunde zeigt die Statuszeile „zähle …“,
danach erscheint eine große Zahl im Bild und bleibt stehen — sie
aktualisiert sich **nicht** von selbst weiter. Die Schaltfläche trägt jetzt
die Aufschrift „Nochmal zählen".
- Während der rund einen Sekunde Kamera und Teile ruhig halten (leichte
Bewegung zwischendurch simulieren) und danach prüfen, dass das Ergebnis
plausibel bleibt — die App nimmt in dieser Zeit fünf Bilder auf und zeigt
deren Median.
- Die Zahl antippen: Diagnosezeile blendet sich ein und zeigt (in dieser
Reihenfolge) `Roh: …`, `Messungen: …` (die fünf Einzelmessungen dieser
Aufnahme, z. B. `17, 18, 19, 18, 20`), `Bild: …`, `Flächen: …`,
`Größte Fläche: …`, `Typische Größe: …`, `Art: …`, `Dauer: …`. Nochmaliges
Antippen blendet sie wieder aus. Prüfen, dass die fünf Messungen tatsächlich
eng beieinanderliegen, wenn die Teile ruhig lagen, und spürbar streuen,
wenn während der Aufnahme bewegt wurde.
- „Nochmal zählen" antippen: neue Aufnahme, neues Ergebnis ersetzt das alte
vollständig (Zahl **und** Diagnosezeile).
- Zur Funktionswahl wechseln und zurück zu „Zählen": Zahl und Diagnosezeile
sind zurückgesetzt (nichts angezeigt), Schaltfläche zeigt wieder „Zählen"
statt „Nochmal zählen". Dasselbe beim Wechsel zu einer anderen Funktion und
zurück.
- Zugänge zur Sitzungsliste und zum Funktionswechsel bleiben auch in dieser
Funktion erreichbar (Anzeige der Funktion antippen, Stapel-Leiste antippen)
— außer während die rund einssekündige Aufnahme selbst läuft, genau wie bei
einem laufenden Scan in „Text erkennen".
- Aufnahme antippen, bevor das Kamerabild bereit ist (z. B. gleich nach dem
Start): verständliche Meldung „Zählen nicht möglich — Kamerabild noch
nicht bereit" statt Absturz oder stiller Nichtreaktion.
- Gegenprobe der Grenzen (siehe „Grenzen" oben): Teile eng aneinanderlegen
(Hochrechnung), Teile stapeln (nur oberste Schicht zählt), einseitige
Beleuchtung, geringer Kontrast zum Untergrund — beobachten, wo die Zahl
spürbar daneben liegt, bisher nur an synthetischen Testbildern geprüft, nie
an einem echten Foto.
- Verbindung während des allerersten Scans einer Sitzung unterbrechen (WASM - Verbindung während des allerersten Scans einer Sitzung unterbrechen (WASM
lädt per CDN), danach mit wiederhergestellter Verbindung erneut scannen: lädt per CDN), danach mit wiederhergestellter Verbindung erneut scannen:
Das muss einen echten neuen Ladeversuch auslösen statt dauerhaft mit Das muss einen echten neuen Ladeversuch auslösen statt dauerhaft mit
demselben Fehler zu scheitern. demselben Fehler zu scheitern.
- Ladezeit des Tesseract-Arbeiters beim allerersten Scan einer Sitzung - Ladezeit des Tesseract-Arbeiters beim ersten Antippen von „Modul scannen"
beobachten — bleibt sie auf einem normalen Handy deutlich unter der in Funktion „Text erkennen" beobachten — bleibt sie auf einem normalen
20-Sekunden-Zeitgrenze der Pipeline? Handy deutlich unter der 20-Sekunden-Zeitgrenze der Pipeline? In den
Funktionen „Strichcode"/„QR-Code" darf dieses Laden nie anlaufen, auch
nicht bei einem gänzlich unbekannten Etikett, da `runOcr` dort nie
aufgerufen wird.
- Verfügbarkeitsanzeige der Texterkennung: bleibt nach einem erfolgreichen - Verfügbarkeitsanzeige der Texterkennung: bleibt nach einem erfolgreichen
Ladevorgang dauerhaft "verfügbar", springt nach einem erzwungenen Ladevorgang dauerhaft "verfügbar", springt nach einem erzwungenen
Fehlschlag (z. B. Flugmodus beim ersten Laden) beim nächsten Erfolg wieder Fehlschlag (z. B. Flugmodus beim ersten Laden) beim nächsten Erfolg wieder
darauf zurück? darauf zurück?
- Stichprobe, ob die feste Zeichen-Whitelist der Texterkennung auf echten - Stichprobe, ob die Zeichen-Whitelist der Texterkennung (Groß-/
Etiketten keine tatsächlich benötigten Zeichen ausschließt. Kleinbuchstaben, Ziffern, übliche Satz-/Sonderzeichen) auf echten Etiketten
- Bild ohne lesbaren Barcode und ohne verwertbaren OCR-Text: Rot-Dialog keine tatsächlich benötigten Zeichen ausschließt, und ob die Erkennung der
erscheint statt Fehler oder Absturz. Codeteile (Kapazität, Rank, Geschwindigkeit, Teilenummer) durch die jetzt
- Modul mit unbekanntem Nummernschema (z. B. ein Hynix-Modul) scannen: grüne mögliche Groß-/Kleinschreibung noch verlässlich genug bleibt.
Rückmeldung statt Rot, auch wenn keine Kapazität abgeleitet werden kann. - Funktion „Strichcode"/„QR-Code": kein passender Code im Bild führt zu
Dasselbe Modul ein zweites Mal scannen: landet auf demselben Stapel — und Rot-Dialog (statt Fehler oder Absturz) — hier ohne jeden OCR-Versuch.
die Rückmeldung erscheint spürbar schneller als beim ersten Scan, weil die Funktion „Text erkennen": kein verwertbarer OCR-Text führt ebenfalls zu
Texterkennung diesmal übersprungen wird (nur am Gerät beobachtbar, da Rot, hier ohne jeden Barcode-Versuch.
`node --test` keine echte Ladezeit misst). - In Funktion „Strichcode" ein Modul mit unbekanntem Nummernschema (z. B. ein
Hynix-Modul) scannen: grüne Rückmeldung statt Rot, auch wenn keine
Kapazität abgeleitet werden kann (die Texterkennung ist in dieser Funktion
ohnehin stumm geschaltet und liefert nie eine Kapazität nach). Dasselbe
Modul ein zweites Mal scannen: landet auf demselben Stapel.
- Etikett mit zwei Barcodes (Teile- *und* Seriennummer) mehrfach scannen: - Etikett mit zwei Barcodes (Teile- *und* Seriennummer) mehrfach scannen:
beide Scans landen auf demselben Stapel, obwohl die Seriennummer bei realen beide Scans landen auf demselben Stapel, obwohl die Seriennummer bei realen
Modulen nie identisch ist — das belegt, dass die Teilenummer und nicht die Modulen nie identisch ist — das belegt, dass die Teilenummer und nicht die
@@ -369,9 +698,15 @@ werden:
es passend. es passend.
- Stapel-Leiste (`A: 12`, `B: 4`) aus rund einem Meter Entfernung mit einer - Stapel-Leiste (`A: 12`, `B: 4`) aus rund einem Meter Entfernung mit einer
Hand lesbar. Hand lesbar.
- Grüne/gelbe Rückmeldung erscheint nach dem Scan und verschwindet nach - In „Strichcode"/„QR-Code": Grüne/gelbe Rückmeldung erscheint nach dem Scan
rund 1200 ms von selbst; bei zwei Scans deutlich unter 1200 ms Abstand und verschwindet nach rund 1200 ms von selbst; bei zwei Scans deutlich
bleibt jeweils nur eine Rückmeldung sichtbar und die App bleibt bedienbar. unter 1200 ms Abstand bleibt jeweils nur eine Rückmeldung sichtbar und die
App bleibt bedienbar.
- In „Text erkennen": Rückmeldung bleibt stehen, bis sie über die
„weiter"-Schaltfläche (mindestens 56px hoch) weggetippt wird — kein
automatisches Ausblenden. Ein weiterer Scan, bevor die vorherige
Rückmeldung weggetippt wurde, ersetzt sie sofort durch die neue, ohne die
App dauerhaft zu sperren.
- Alle Bedienflächen (Scan-, Rückgängig-, Stapel-Knöpfe) mit dem Daumen - Alle Bedienflächen (Scan-, Rückgängig-, Stapel-Knöpfe) mit dem Daumen
erreichbar, während die andere Hand das Modul hält. erreichbar, während die andere Hand das Modul hält.
- Rückgängig-Knopf: Screenreader liest "Letzten Scan zurücknehmen" vor. - Rückgängig-Knopf: Screenreader liest "Letzten Scan zurücknehmen" vor.
+11 -3
View File
@@ -25,8 +25,14 @@ async function ensureReady() {
await readyPromise; await readyPromise;
} }
// Bisheriges Verhalten (Code-128 und DataMatrix), falls der Aufrufer keine
// eigene Liste von Codearten angibt - main.js reicht seit den Scan-Funktionen
// (siehe scan-modes.js) stets eine eigene Liste durch, aber ohne Angabe
// bleibt der Adapter unveraendert nutzbar (z. B. fuer kuenftige Aufrufer).
const DEFAULT_FORMATS = ['Code128', 'DataMatrix'];
/** /**
* Dekodiert Code-128 und DataMatrix aus einem Einzelbild. * Dekodiert Barcodes aus einem Einzelbild.
* Barcode-Dekodierung ist exakt - deshalb ist sie die bevorzugte Quelle * Barcode-Dekodierung ist exakt - deshalb ist sie die bevorzugte Quelle
* gegenueber der fehleranfaelligen Texterkennung. * gegenueber der fehleranfaelligen Texterkennung.
* *
@@ -35,12 +41,14 @@ async function ensureReady() {
* Ausgang und kein Fehler. * Ausgang und kein Fehler.
* *
* @param {ImageData} imageData Einzelbild aus der Kamera. * @param {ImageData} imageData Einzelbild aus der Kamera.
* @param {string[]} [formats] Zu suchende Codearten (zxing-wasm-Formatnamen,
* siehe scan-modes.js). Ohne Angabe wie bisher: Code128 und DataMatrix.
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
*/ */
export async function decodeBarcodes(imageData) { export async function decodeBarcodes(imageData, formats = DEFAULT_FORMATS) {
await ensureReady(); await ensureReady();
const results = await readBarcodes(imageData, { const results = await readBarcodes(imageData, {
formats: ['Code128', 'DataMatrix'], formats,
tryHarder: true, tryHarder: true,
maxNumberOfSymbols: 4, maxNumberOfSymbols: 4,
}); });
+57
View File
@@ -32,6 +32,39 @@ export async function startCamera(videoElement) {
}; };
} }
/**
* Rahmenmasse als Anteil der Bildkanten - einzige Quelle fuer die Lage des
* Zielrahmens. scan-view.js uebernimmt daraus den sichtbaren Rahmen,
* grabFrameRegion() weiter unten den nativen Ausschnitt - beide koennen so
* nicht mehr auseinanderlaufen (frueher stand das, unabhaengig davon, nur
* im Stilblatt).
*/
export const FRAME_INSET = { top: 0.2, right: 0.08, bottom: 0.2, left: 0.08 };
/**
* Rechnet die Rahmenmasse (Anteile der Kantenlaenge) in Bildpunkte um.
* Reine Funktion ohne Bezug zu einem konkreten Bild oder einer Browser-API -
* damit auch ohne DOM testbar.
*
* Breite und Hoehe werden als Differenz aus der Gesamtkante und den beiden
* gerundeten Raendern gebildet, nicht unabhaengig aus dem Anteil selbst
* gerundet - so bleibt der Ausschnitt bei krummen Bildgroessen garantiert
* innerhalb des Bildes, auch wenn die Anteile keine ganzzahligen Ergebnisse
* liefern.
*/
export function frameRect(width, height, inset = FRAME_INSET) {
const left = Math.round(width * inset.left);
const right = Math.round(width * inset.right);
const top = Math.round(height * inset.top);
const bottom = Math.round(height * inset.bottom);
return {
x: left,
y: top,
width: Math.max(0, width - left - right),
height: Math.max(0, height - top - bottom),
};
}
function drawScaled(source, sourceWidth, sourceHeight, maxEdge) { function drawScaled(source, sourceWidth, sourceHeight, maxEdge) {
const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight)); const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
const width = Math.round(sourceWidth * scale); const width = Math.round(sourceWidth * scale);
@@ -64,6 +97,30 @@ export function grabFrame(videoElement, maxEdge = 1280) {
); );
} }
/**
* Schneidet den Zielrahmen (siehe FRAME_INSET) aus dem laufenden Kamerabild -
* in unveraenderter Aufloesung, ohne jede Verkleinerung. Anders als
* grabFrame(): fuer die Texterkennung genuegt ein herunterskaliertes
* Vollbild, aber ein Code-128 auf einem kleinen Etikett braucht seine
* feinen Striche in voller Schaerfe - beim Skalieren auf die fuer OCR
* ausreichende Kantenlaenge verschmieren sie unlesbar.
*/
export function grabFrameRegion(videoElement, inset = FRAME_INSET) {
assertHasSize(videoElement.videoWidth, videoElement.videoHeight);
const rect = frameRect(videoElement.videoWidth, videoElement.videoHeight, inset);
const canvas = document.createElement('canvas');
canvas.width = rect.width;
canvas.height = rect.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(
videoElement,
rect.x, rect.y, rect.width, rect.height,
0, 0, rect.width, rect.height,
);
return context.getImageData(0, 0, rect.width, rect.height);
}
/** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */ /** Ersatzweg ohne Kamera: Bilddatei auswaehlen (z. B. am Rechner). */
export async function imageDataFromFile(file, maxEdge = 1280) { export async function imageDataFromFile(file, maxEdge = 1280) {
const bitmap = await createImageBitmap(file); const bitmap = await createImageBitmap(file);
+39
View File
@@ -0,0 +1,39 @@
// Bildet aus den Einzelmessungen einer Zaehl-Aufnahme (Funktion "Zählen",
// siehe main.js) das anzuzeigende Endergebnis. main.js nimmt je Antippen der
// Schaltflaeche "Zählen"/"Nochmal zählen" binnen rund einer Sekunde mehrere
// Bilder aus dem Zielrahmen auf und zaehlt jedes einzeln (countObjects() in
// count-objects.js, unveraendert) - ein einzelnes verwackeltes oder mitten in
// eine Fokusregelung fallendes Bild soll das Ergebnis nicht verzerren. Frueher
// glaettete ein gleitender Median ueber die letzten neun Durchlaeufe einer
// Dauerzaehlung (siehe .superpowers/sdd/counting-capture-report.md); das ist
// entfallen, weil eine Aufnahme mit mehreren Bildern dieselbe Robustheit
// bereits in sich selbst leistet - zwei Glaettungen uebereinander waeren nur
// zusaetzliche Traegheit ohne zusaetzlichen Nutzen.
//
// Der Median der Einzelmessungen ist robust gegen einen einzelnen groben
// Ausreisser (anders als deren Mittelwert) - siehe median() in
// count-objects.js fuer dieselbe Ueberlegung bei den dortigen
// Flaechengroessen.
//
// Reines, browserfreies Modul wie count-objects.js - kennt weder Kamera noch
// Bilddaten, nur die Zahlenreihe der Einzelmessungen selbst.
import { median } from './count-objects.js';
/**
* Bildet den Median der Einzelmessungen einer Aufnahme (siehe Moduldoku
* oben). Bleibt auch dann sinnvoll, wenn eine Aufnahme aus weniger als den
* vorgesehenen fuenf Bildern besteht (z. B. weil das Kamerabild waehrend
* eines Versuchs kurz nicht bereit war, siehe grabFrameRegion() in
* camera.js) - nur eine vollstaendig leere Aufnahme (kein einziges
* verwertbares Bild) ist kein regulaerer Ausgang und wirft; main.js prueft
* diesen Fall vorher selbst und ruft diese Funktion dann gar nicht erst auf.
* @param {number[]} counts Einzelmessungen dieser Aufnahme - nicht-leer.
* @returns {number}
* @throws {Error} wenn counts leer ist.
*/
export function combineCaptureCounts(counts) {
if (counts.length === 0) {
throw new Error('combineCaptureCounts: keine Messung vorhanden');
}
return median(counts);
}
+483
View File
@@ -0,0 +1,483 @@
// Zaehlt zusammenhaengende Objektflaechen in einem Kamera-Ausschnitt - die
// vierte Scan-Funktion "Zaehlen" (siehe scan-modes.js). Reine Berechnung auf
// Bilddaten, ohne jeden Browser-Zugriff (kein window/document/canvas) und
// ohne neue Abhaengigkeit.
//
// Otsu (threshold.js) taugt nur, wenn Objekt- und Untergrundflaeche etwa
// gleich gross sind. Am echten Foto des Auftraggebers (rund zwanzig
// Festplattenschrauben auf dunklem Leder) belegen die Schrauben unter einem
// Prozent der Flaeche - Otsu rutscht dann auf den hoechsten Helligkeitswert
// und erklaert nur die hellsten Punkte der Ledernarbung zum Objekt
// (gemessen: 76712 statt rund zwanzig, siehe
// test/count-objects-photo.test.js). Otsu betrachtet zudem nur die absolute
// Helligkeit ueber das ganze Bild hinweg; am echten Foto sind Schraube und
// Untergrund darueber gar nicht trennbar (Median 114, 90. Perzentil 221,
// 99. Perzentil 254 - deutliche Ueberlappung).
//
// Dieses Modul verwendet stattdessen oertlichen Kontrast (siehe
// .superpowers/sdd/counting-rework-report.md fuer die Herleitung und die
// Messungen am echten Foto):
// 1. Auf 800 Bildpunkte Breite normieren (Seitenverhaeltnis erhalten) -
// macht alle folgenden Groessen unabhaengig von der Kameraaufloesung und
// die Rechnung schneller.
// 2. Graustufen (uebliche Gewichte 0,299 / 0,587 / 0,114).
// 3. Glaetten mit einem Kastenfilter (unterdrueckt feine Untergrundnarbung,
// ohne Teile zu verschmieren) ueber ein Summenbild, damit die
// Mittelwertbildung unabhaengig vom Radius in gleicher Zeit laeuft.
// 4. Oertlichen Hintergrund ueber einen zweiten, deutlich groesseren
// Kastenmittelwert bestimmen - faengt ungleichmaessige Ausleuchtung ab
// und bleibt auch dann noch "Untergrund", wenn ein einzelnes Teil den
// Mittelwert an seiner eigenen Stelle nicht dominieren kann (das Fenster
// ist viel groesser als ein einzelnes Teil).
// 5. Maske: Bildpunkt geglaettet vs. oertlicher Hintergrund +/- Marge.
// 6. Connected-Component-Labeling mit 4er-Nachbarschaft ueber eine eigene
// Arbeitsliste statt Rekursion (rund eine Viertelmillion Bildpunkte
// wuerden einen rekursiven Aufruf je Bildpunkt in den Stapelueberlauf
// laufen lassen).
// 7. Grobes Rauschen verwerfen (Staub, Kratzer, Lichtreflexe).
// 8. Massstab aus der groessten verbliebenen Flaeche ableiten: nur Flaechen
// ab einem festen Anteil der groessten Flaeche behalten - bewusst
// relativ, nicht absolut, damit ein naeher oder weiter weg gehaltenes
// Kamerabild die Zaehlung nicht verstellt.
// 9. Typische Einzelgroesse: Median der verbliebenen Flaechen.
// 10. Zaehlen: jede Flaeche zaehlt max(1, gerundet(flaeche / typischeGroesse)).
//
// Beide Polaritaeten (helle Objekte auf dunklerem Grund, dunkle auf
// hellerem) werden berechnet; es gewinnt die mit den meisten behaltenen
// Flaechen (siehe Begruendung und Messung am echten Foto in
// countObjects() unten und im Bericht) - nur bei Gleichstand entscheidet
// ersatzweise die kleinere Gesamtflaeche (Objekte belegen in aller Regel
// weniger Flaeche als der Untergrund).
// Zielbreite fuer die Normierung (Schritt 1). Schmalere Bilder bleiben
// unveraendert.
const TARGET_WIDTH = 800;
// Kontrastmarge fuer die Maskenbildung (Schritt 5): ein Bildpunkt muss um
// mehr als diesen Betrag heller/dunkler sein als sein oertlicher Hintergrund,
// um als Objekt zu gelten - unterdrueckt Sensorrauschen und feine
// Resthelligkeitsschwankungen, die die Glaettung (Schritt 3) nicht restlos
// entfernt.
const LOCAL_CONTRAST_MARGIN = 25;
// Anteil der groessten Flaeche, ab dem eine kleinere Flaeche noch als
// eigenes Fundstueck zaehlt (Schritt 8). Bewusst relativ zur groessten
// gefundenen Flaeche - siehe Moduldoku oben. Am echten Foto ist das Ergebnis
// zwischen 15 und 25 Prozent unveraendert.
const SCALE_FRACTION_OF_LARGEST = 0.2;
/**
* Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene
* Teile) oder sehr kleine (Rauschen) Ausreisser, anders als der Mittelwert.
* Exportiert, weil dieselbe Robustheit die Diagnoseanzeige braucht, um aus
* den letzten Zaehlmessungen eine ruhigere Zahl zu bilden (siehe
* count-history.js) - ein einzelner Ausreisser (Bewegungsunschaerfe,
* nachregelnder Autofokus) soll dort ebenso wenig durchschlagen wie hier bei
* den Flaechengroessen.
* @param {number[]} values nicht-leer
* @returns {number}
*/
export function median(values) {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle];
}
/**
* Normiert einen Bildausschnitt auf `targetWidth` Bildpunkte Breite
* (Seitenverhaeltnis erhalten, naechster Nachbar). Ist der Ausschnitt bereits
* schmaler oder gleich breit, wird er unveraendert zurueckgegeben - macht
* alle nachfolgenden Groessen (Glaettungsradius, Hintergrundradius,
* Rauschschwelle) unabhaengig von der Kameraaufloesung.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @param {number} targetWidth
* @returns {{width: number, height: number, data: Uint8ClampedArray}}
*/
function resizeToWidth(imageData, targetWidth) {
const { width, height, data } = imageData;
if (width <= targetWidth) {
return imageData;
}
const scale = targetWidth / width;
const newHeight = Math.max(1, Math.round(height * scale));
const newData = new Uint8ClampedArray(targetWidth * newHeight * 4);
for (let y = 0; y < newHeight; y += 1) {
const srcY = Math.min(height - 1, Math.floor((y + 0.5) / scale));
for (let x = 0; x < targetWidth; x += 1) {
const srcX = Math.min(width - 1, Math.floor((x + 0.5) / scale));
const srcIndex = (srcY * width + srcX) * 4;
const dstIndex = (y * targetWidth + x) * 4;
newData[dstIndex] = data[srcIndex];
newData[dstIndex + 1] = data[srcIndex + 1];
newData[dstIndex + 2] = data[srcIndex + 2];
newData[dstIndex + 3] = data[srcIndex + 3];
}
}
return { width: targetWidth, height: newHeight, data: newData };
}
/**
* Graustufen (uebliche Gewichte) aus RGBA-Bilddaten.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {Float64Array} ein Eintrag je Bildpunkt
*/
function toGrayscale({ width, height, data }) {
const gray = new Float64Array(width * height);
for (let p = 0, i = 0; p < width * height; p += 1, i += 4) {
gray[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
}
return gray;
}
/**
* Summenbild (Integral Image): jede Zelle enthaelt die Summe aller
* Bildpunkte oben links davon. Erlaubt es, die Summe ueber ein beliebiges
* Rechteck in konstanter Zeit zu bestimmen - Grundlage fuer den
* radius-unabhaengigen Kastenmittelwert unten.
* @param {Float64Array} values width*height Eintraege
* @param {number} width
* @param {number} height
* @returns {Float64Array} (width+1)*(height+1) Eintraege
*/
function buildIntegralImage(values, width, height) {
const stride = width + 1;
const integral = new Float64Array(stride * (height + 1));
for (let y = 0; y < height; y += 1) {
let rowSum = 0;
const rowAbove = y * stride;
const rowHere = (y + 1) * stride;
for (let x = 0; x < width; x += 1) {
rowSum += values[y * width + x];
integral[rowHere + x + 1] = integral[rowAbove + x + 1] + rowSum;
}
}
return integral;
}
/**
* Kastenmittelwert ueber `values` mit gegebenem Radius, berechnet aus einem
* zuvor gebauten Summenbild - unabhaengig vom Radius in derselben Zeit (nur
* vier Zugriffe auf das Summenbild je Bildpunkt). Am Bildrand wird das
* Fenster auf das Bild geklemmt und durch die tatsaechliche (kleinere)
* Fensterflaeche geteilt, statt den Rand mit Nullen aufzufuellen.
* @param {Float64Array} values width*height Eintraege
* @param {number} width
* @param {number} height
* @param {number} radius
* @returns {Float64Array} width*height Eintraege
*/
function boxBlur(values, width, height, radius) {
const integral = buildIntegralImage(values, width, height);
const stride = width + 1;
const out = new Float64Array(width * height);
for (let y = 0; y < height; y += 1) {
const y0 = Math.max(0, y - radius);
const y1 = Math.min(height, y + radius + 1);
for (let x = 0; x < width; x += 1) {
const x0 = Math.max(0, x - radius);
const x1 = Math.min(width, x + radius + 1);
const sum =
integral[y1 * stride + x1] -
integral[y0 * stride + x1] -
integral[y1 * stride + x0] +
integral[y0 * stride + x0];
const count = (x1 - x0) * (y1 - y0);
out[y * width + x] = sum / count;
}
}
return out;
}
/**
* Flutet eine zusammenhaengende Objektflaeche ausgehend von startIndex ueber
* eine eigene Arbeitsliste (kein Rekursionsaufruf je Bildpunkt) und markiert
* dabei jeden erreichten Bildpunkt in `visited`. Der Stapelspeicher `stack`
* wird vom Aufrufer einmal pro Bild angelegt und fuer alle Flaechen
* (und beide Polaritaeten) wiederverwendet.
* @param {number} startIndex
* @param {(index: number) => boolean} isObjectPixel
* @param {Uint8Array} visited
* @param {number} width
* @param {number} height
* @param {Int32Array} stack wiederverwendeter Arbeitsspeicher, mind. width*height gross
* @returns {number} Anzahl der zur Flaeche gehoerenden Bildpunkte
*/
function floodFill(startIndex, isObjectPixel, visited, width, height, stack) {
let top = 0;
stack[top] = startIndex;
top += 1;
visited[startIndex] = 1;
let area = 0;
while (top > 0) {
top -= 1;
const index = stack[top];
area += 1;
const x = index % width;
const y = (index - x) / width;
if (x > 0) {
const n = index - 1;
if (!visited[n] && isObjectPixel(n)) {
visited[n] = 1;
stack[top] = n;
top += 1;
}
}
if (x < width - 1) {
const n = index + 1;
if (!visited[n] && isObjectPixel(n)) {
visited[n] = 1;
stack[top] = n;
top += 1;
}
}
if (y > 0) {
const n = index - width;
if (!visited[n] && isObjectPixel(n)) {
visited[n] = 1;
stack[top] = n;
top += 1;
}
}
if (y < height - 1) {
const n = index + width;
if (!visited[n] && isObjectPixel(n)) {
visited[n] = 1;
stack[top] = n;
top += 1;
}
}
}
return area;
}
/**
* Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus
* (helle Objekte auf dunklerem Grund oder umgekehrt). Liefert - anders als
* die fruehere Fassung - nie `null`, sondern immer ein vollstaendig belegtes
* Ergebnis, auch wenn keine (oder keine signifikante) Flaeche uebrig bleibt:
* die Diagnoseanzeige muss gerade dann zeigen koennen, *woran* es gescheitert
* ist (gar nichts gefunden? nur Rauschen? nur zu kleine Flaechen?). An der
* eigentlichen Zaehlung (Schritte 6-10 selbst) aendert das nichts - `count`
* ist bei einer leeren `significantAreas`-Liste weiterhin 0, exakt wie beim
* frueheren `null`.
* @param {(index: number) => boolean} isObjectPixel
* @param {number} width
* @param {number} height
* @param {number} totalPixels
* @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher
* @param {Int32Array} stack wiederverwendeter Arbeitsspeicher
* @returns {{count: number, totalArea: number, rawCount: number, filteredCount: number, significantCount: number, largestArea: number, typicalArea: number}}
*/
function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) {
const rawAreas = [];
for (let p = 0; p < totalPixels; p += 1) {
if (visited[p] || !isObjectPixel(p)) continue;
rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack));
}
// Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen. Auf
// einer leeren rawAreas-Liste bleibt auch afterNoise leer - kein
// Sonderfall noetig.
const noiseThreshold = Math.max(8, 0.00002 * totalPixels);
const afterNoise = rawAreas.filter((area) => area >= noiseThreshold);
// Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten -
// bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. Bleibt
// nach der Rauschfilterung nichts uebrig, gibt es auch keinen Massstab
// (largestArea 0) und folglich auch keine signifikanten Flaechen.
const largestArea = afterNoise.length > 0 ? Math.max(...afterNoise) : 0;
const scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST;
const significantAreas = afterNoise.filter((area) => area >= scaleThreshold);
// Schritt 9: typische Einzelgroesse - nur bestimmbar, wenn ueberhaupt eine
// Flaeche beide Filter uebersteht (median() verlangt eine nicht-leere Liste).
const typicalArea = significantAreas.length > 0 ? median(significantAreas) : 0;
// Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte
// Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als
// zwei zaehlen). Bleibt keine Flaeche uebrig, durchlaeuft die Schleife kein
// einziges Mal - count und totalArea bleiben bei 0.
let count = 0;
let totalArea = 0;
for (const area of significantAreas) {
count += Math.max(1, Math.round(area / typicalArea));
totalArea += area;
}
return {
count,
totalArea,
rawCount: rawAreas.length,
filteredCount: afterNoise.length,
significantCount: significantAreas.length,
largestArea,
typicalArea,
};
}
/**
* Waehlt die gewinnende Polaritaet (siehe ausfuehrliche Begruendung im
* Kommentar in countObjects() unten). Massgeblich ist, welche Polaritaet
* mehr signifikante (Rausch- und Groessenfilter ueberstehende) Flaechen
* behalten hat; bei Gleichstand entscheidet die kleinere Gesamtflaeche.
* Haben beide Polaritaeten keine einzige signifikante Flaeche behalten (die
* Zaehlung ergibt in jedem Fall 0), entscheidet ersatzweise, welche
* ueberhaupt mehr Struktur gefunden hat (erst nach, dann vor der
* Rauschfilterung) - das aendert nichts mehr am Ergebnis (0), belegt aber
* die Diagnosewerte weiterhin sinnvoll statt mit einer willkuerlichen Wahl.
* @param {ReturnType<typeof analyzePolarity>} bright
* @param {ReturnType<typeof analyzePolarity>} dark
* @returns {ReturnType<typeof analyzePolarity> & {polarity: 'hell' | 'dunkel'}}
*/
function pickWinner(bright, dark) {
if (bright.significantCount === 0 && dark.significantCount === 0) {
if (dark.filteredCount > bright.filteredCount) return { ...dark, polarity: 'dunkel' };
if (dark.filteredCount === bright.filteredCount && dark.rawCount > bright.rawCount) {
return { ...dark, polarity: 'dunkel' };
}
return { ...bright, polarity: 'hell' };
}
if (dark.significantCount === 0) return { ...bright, polarity: 'hell' };
if (bright.significantCount === 0) return { ...dark, polarity: 'dunkel' };
if (bright.significantCount !== dark.significantCount) {
return bright.significantCount > dark.significantCount
? { ...bright, polarity: 'hell' }
: { ...dark, polarity: 'dunkel' };
}
return bright.totalArea <= dark.totalArea
? { ...bright, polarity: 'hell' }
: { ...dark, polarity: 'dunkel' };
}
/**
* Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
* Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion -
* erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung.
*
* Die Rueckgabe traegt neben der Anzahl selbst (unveraendert in Bedeutung)
* additiv die Zwischenwerte, die eine Diagnoseanzeige braucht, um am
* Nutzergeraet zu erkennen, *woran* eine abweichende Zaehlung liegt (siehe
* ui/scan-view.js und main.js) - allesamt auch dann sinnvoll belegt, wenn
* count 0 ergibt.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{
* count: number,
* width: number,
* height: number,
* regionsFound: number,
* regionsKept: number,
* largestArea: number,
* typicalArea: number,
* polarity: 'hell' | 'dunkel' | null,
* }}
* count: ermittelte, bereits gerundete Anzahl - 0, wenn keine
* Objektflaeche gefunden wurde (leerer Ausschnitt, reiner Untergrund
* ohne Kontrast oder ein Ausschnitt ohne Bildpunkte).
* width, height: Groesse des tatsaechlich ausgewerteten Bildes, also nach
* der Normierung auf feste Breite (Schritt 1) - 0/0, wenn der
* Ausschnitt selbst schon keine Bildpunkte hatte (dann fand keine
* Normierung statt).
* regionsFound: Anzahl zusammenhaengender Flaechen der gewinnenden
* Polaritaet vor jeder Filterung (Schritt 6, vor Schritt 7).
* regionsKept: Anzahl der Flaechen, die nach vollstaendiger Filterung
* (Rauschen Schritt 7, Groesse Schritt 8) uebrig bleiben.
* largestArea: Groesse der groessten verbliebenen Flaeche in Bildpunkten.
* typicalArea: als typisch bestimmte Einzelgroesse (Median, Schritt 9) in
* Bildpunkten - 0, wenn keine Flaeche uebrig blieb.
* polarity: welche Polaritaet gewonnen hat ('hell': helle Objekte auf
* dunklerem Grund, 'dunkel': umgekehrt) - null nur, wenn der Ausschnitt
* selbst schon keine Bildpunkte hatte und daher gar keine Polaritaet
* berechnet wurde.
*/
export function countObjects(imageData) {
if (imageData.width * imageData.height === 0) {
return {
count: 0,
width: 0,
height: 0,
regionsFound: 0,
regionsKept: 0,
largestArea: 0,
typicalArea: 0,
polarity: null,
};
}
// Schritt 1: auf feste Breite normieren.
const resized = resizeToWidth(imageData, TARGET_WIDTH);
const { width, height } = resized;
const totalPixels = width * height;
// Schritt 2: Graustufen.
const gray = toGrayscale(resized);
// Schritt 3: glaetten (unterdrueckt feine Untergrundnarbung).
const smoothRadius = Math.max(2, Math.round(width * 0.0025));
const smoothed = boxBlur(gray, width, height, smoothRadius);
// Schritt 4: oertlichen Hintergrund bestimmen (deutlich groesseres
// Fenster als der Glaettungsradius - faengt ungleichmaessige Ausleuchtung
// ab, ohne von einem einzelnen Fundstueck selbst dominiert zu werden). Ein
// Mindestradius von 1 verhindert ein entartetes Ein-Bildpunkt-Fenster bei
// sehr kleinen Ausschnitten, in dem der "Hintergrund" schlicht der
// geglaettete Wert selbst waere.
const backgroundRadius = Math.max(1, Math.round(Math.max(width, height) / 12));
const localBackground = boxBlur(smoothed, width, height, backgroundRadius);
const visited = new Uint8Array(totalPixels);
const stack = new Int32Array(totalPixels);
// Beide Polaritaeten: helle Objekte auf dunklerem Grund ...
const isBrightObject = (p) => smoothed[p] > localBackground[p] + LOCAL_CONTRAST_MARGIN;
const bright = analyzePolarity(isBrightObject, width, height, totalPixels, visited, stack);
// ... und dunkle Objekte auf hellerem Grund. Arbeitsspeicher wird
// wiederverwendet, muss dafuer zurueckgesetzt werden.
visited.fill(0);
const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN;
const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack);
// Eine Polaritaet muss gewinnen (siehe pickWinner() oben fuer den
// Sonderfall "beide leer"). Am echten Foto (silberne, glaenzende
// Schrauben, einseitig beleuchtet) traegt jede Schraube sowohl eine helle
// Reflexflaeche als auch einen dunklen Schlagschatten - beide Polaritaeten
// finden also echte, nicht zufaellige Struktur, und ihre Gesamtflaechen
// liegen dicht beieinander (am Pruefbild rund 2826 zu 2140 Bildpunkte).
// Die Gesamtflaeche allein (kleinere gewinnt) ist in diesem Fall kein
// verlaessliches Kriterium: der Schlagschatten jeder Schraube ist schmaler
// als ihre Reflexflaeche und summiert sich deshalb zu einer kleineren
// Gesamtflaeche, obwohl die Reflexflaechen die tatsaechlichen Fundstuecke
// vollstaendiger und stabiler nachzeichnen (siehe
// .superpowers/sdd/counting-rework-report.md fuer die Messung).
// Ausschlaggebend ist deshalb, welche Polaritaet mehr der (bekanntermassen
// um die zwanzig) tatsaechlichen Fundstuecke als eigene, den Rausch- und
// Groessenfilter ueberstehende Flaeche auflaesst - die Polaritaet mit den
// meisten behaltenen Flaechen gewinnt. Nur bei gleich vielen behaltenen
// Flaechen entscheidet die kleinere Gesamtflaeche (die urspruengliche
// Regel) als Ausweichkriterium.
const winner = pickWinner(bright, dark);
return {
count: winner.count,
width,
height,
regionsFound: winner.rawCount,
regionsKept: winner.significantCount,
largestArea: winner.largestArea,
typicalArea: winner.typicalArea,
polarity: winner.polarity,
};
}
+439 -18
View File
@@ -1,10 +1,14 @@
// Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung // Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung
// und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig. // und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig.
import { startCamera, grabFrame, imageDataFromFile } from './camera.js'; import { startCamera, grabFrameRegion, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js'; import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js'; import { runOcr, isOcrAvailable } from './ocr.js';
import { countObjects } from './count-objects.js';
import { combineCaptureCounts } from './count-capture.js';
import { recognize } from './pipeline.js'; import { recognize } from './pipeline.js';
import { normalizeToken } from './spec.js'; import { normalizeToken } from './spec.js';
import { getScanMode } from './scan-modes.js';
import { buildRecognitionAdapters } from './scan-recognition.js';
import { import {
createSession, proposeAssignment, commitAssignment, createSession, proposeAssignment, commitAssignment,
undoLast, moveEntry, removeEntry, nextStackId, undoLast, moveEntry, removeEntry, nextStackId,
@@ -14,6 +18,7 @@ import { renderScanView } from './ui/scan-view.js';
import { showResult } from './ui/result-overlay.js'; import { showResult } from './ui/result-overlay.js';
import { askForStack } from './ui/ambiguous-dialog.js'; import { askForStack } from './ui/ambiguous-dialog.js';
import { askResumeSession } from './ui/resume-dialog.js'; import { askResumeSession } from './ui/resume-dialog.js';
import { askForScanMode } from './ui/mode-dialog.js';
import { renderSessionList } from './ui/session-list.js'; import { renderSessionList } from './ui/session-list.js';
import { describeSpec } from './ui/describe-spec.js'; import { describeSpec } from './ui/describe-spec.js';
@@ -38,8 +43,41 @@ let busy = false;
// Scan-Durchlaufs ueberschrieben werden. // Scan-Durchlaufs ueberschrieben werden.
let cameraUnavailableMessage = null; let cameraUnavailableMessage = null;
// Vor dem ersten Start der Kamera waehlt der Nutzer eine der drei Scan-
// Funktionen (siehe scan-modes.js); erst danach steht diese Kennung fest.
// Waehrend applyMode() noch nicht gelaufen ist, darf weder die Dauersuche
// noch ein Scan-Durchlauf ausgeloest werden - beide sind bis dahin durch das
// Auswahl-Overlay (searchPaused()) bzw. durch busy/den fehlenden Kamerastart
// ohnehin unerreichbar.
let currentModeId = null;
// Ob die aktuelle Sitzung in Funktion "Zählen" bereits eine Aufnahme gemacht
// hat - traegt die Beschriftung der Aufnahme-Schaltflaeche ("Zählen" vor der
// ersten Aufnahme seit Betreten der Funktion, "Nochmal zählen" danach, siehe
// updateCountCaptureLabel() unten). Wird bei jedem Aufruf von applyMode()
// zurueckgesetzt: sowohl ein Wechsel weg von "Zählen" als auch ein (erneuter)
// Eintritt in die Funktion beginnt wieder bei "Zählen" - siehe auch
// view.setCountVisible() (loescht das stehende Ergebnis samt Diagnose beim
// Verlassen).
let hasCountResult = false;
const view = renderScanView(app, { const view = renderScanView(app, {
onCapture: () => processCapture(() => grabFrame(view.video)), // Eine gemeinsame Schaltflaeche fuer beide Knopfdruck-Funktionen (siehe
// scan-view.js/setCaptureLabel()): in Funktion "Text erkennen" loest sie
// processCapture() aus (Erkennung + Buchung), in Funktion "Zählen"
// stattdessen runCountCapture() (Aufnahme mehrerer Bilder, keine Buchung).
// Der Zielrahmen suggeriert dem Nutzer in beiden Faellen einen Ausschnitt,
// den grabFrame() (Vollbild, herunterskaliert) nicht einloeste -
// grabFrameRegion() liest genau den Rahmenausschnitt, in voller
// Aufloesung - derselbe Ausschnitt, den auch die Barcode-Dauersuche
// benutzt (siehe runSearchAttempt() weiter unten).
onCapture: () => {
if (currentModeId === 'count') {
runCountCapture();
} else {
processCapture(() => grabFrameRegion(view.video));
}
},
onUndo: () => { onUndo: () => {
// Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen // Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen
// werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen, // werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen,
@@ -56,8 +94,60 @@ const view = renderScanView(app, {
openSessionList(); openSessionList();
}, },
onPickFile: (file) => processCapture(() => imageDataFromFile(file)), onPickFile: (file) => processCapture(() => imageDataFromFile(file)),
onChangeMode: () => {
// Waehrend eine Erkennung laeuft, bleibt auch der Funktionswechsel
// unerreichbar - aus demselben Grund wie bei Rueckgaengig/Sitzungsliste.
if (busy) return;
openModeDialog();
},
}); });
/**
* Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit und
* Beschriftung der Aufnahme-Schaltflaeche und - bei einem Wechsel weg von
* laufender Codesuche - der Zielrahmen wird sofort zurueckgesetzt, statt auf
* den naechsten Durchlauf der Dauersuche zu warten.
* @param {string} modeId
*/
function applyMode(modeId) {
currentModeId = modeId;
const mode = getScanMode(modeId);
view.setMode(mode.label);
// Die Aufnahme-Schaltflaeche hat nur dort etwas zu tun, wo es keine
// laufende Codesuche gibt (Funktion 3 "Text erkennen" und Funktion 4
// "Zählen") - siehe Kommentar in scan-view.js/setCaptureVisible.
view.setCaptureVisible(!mode.continuousSearch);
if (!mode.continuousSearch) {
view.setFrameDetected(false);
}
// "Zählen" beginnt bei jedem (erneuten) Betreten wieder bei der
// Beschriftung "Zählen" statt "Nochmal zählen" - ein Wechsel weg von der
// Funktion soll das ebenso wenig ueberleben wie ein erneuter Eintritt mit
// altem Zustand (siehe Deklaration von hasCountResult oben).
hasCountResult = false;
if (mode.id === 'count') {
updateCountCaptureLabel();
} else {
view.setCaptureLabel(mode.useOcr ? 'Modul scannen' : '');
}
// Die grosse Zaehlanzeige gehoert ausschliesslich zu Funktion "Zählen" -
// beim Verlassen sofort ausblenden (loescht dabei auch ein stehendes
// Ergebnis samt Diagnose, siehe scan-view.js/setCountVisible), statt auf
// die naechste Aufnahme zu warten.
view.setCountVisible(mode.id === 'count');
}
/**
* Oeffnet die Funktionswahl (auch fuer den Wechsel aus der laufenden
* Scan-Ansicht heraus, nicht nur beim Start). Solange das Overlay offen ist,
* pausiert die Dauersuche von selbst (siehe searchPaused()).
*/
async function openModeDialog() {
closeOverlays();
const modeId = await askForScanMode(app, { current: currentModeId });
applyMode(modeId);
}
/** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */ /** Spiegelt den Sitzungsstand in die Oberflaeche und sichert ihn gegen Neuladen ab. */
function syncView() { function syncView() {
view.setStacks(session.stacks); view.setStacks(session.stacks);
@@ -69,6 +159,25 @@ function describeEntry(spec, stackId) {
return `${describeSpec(spec)} → Stapel ${stackId}`; return `${describeSpec(spec)} → Stapel ${stackId}`;
} }
/**
* Stellt die Statuszeile nach einem erfolgreich abgeschlossenen Durchlauf
* (Erkennung wie Zaehl-Aufnahme) wieder her - von processCapture() und
* runCountCapture() gemeinsam genutzt, statt denselben Vorrang zweimal
* hinzuschreiben. Der Hinweis auf eine nicht verfuegbare Kamera hat Vorrang
* vor der OCR-Verfuegbarkeitsanzeige und bleibt dauerhaft stehen.
*/
function restoreIdleStatus() {
if (cameraUnavailableMessage) {
view.setStatus(cameraUnavailableMessage, true);
} else {
const available = isOcrAvailable();
view.setStatus(
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
!available,
);
}
}
/** /**
* Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung * Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung
* nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch, * nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch,
@@ -98,9 +207,14 @@ async function processCapture(getFrame) {
(stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized), (stack) => Array.isArray(stack.codes) && stack.codes.includes(normalized),
); );
}; };
const { spec, source, confidence, codes } = await recognize( // Welcher Adapter tatsaechlich etwas tut, haengt von der gewaehlten
// Scan-Funktion ab (siehe scan-modes.js/scan-recognition.js) - recognize()
// selbst (pipeline.js) bleibt unveraendert und kennt keine Funktionen.
const { decodeBarcodes: decode, runOcr: recognizeText } =
buildRecognitionAdapters(currentModeId, { decodeBarcodes, runOcr });
const { spec, source, confidence, codes, rawText } = await recognize(
frame, frame,
{ decodeBarcodes, runOcr, isKnownCode }, { decodeBarcodes: decode, runOcr: recognizeText, isKnownCode },
); );
const plan = proposeAssignment(targetSession, spec, codes); const plan = proposeAssignment(targetSession, spec, codes);
@@ -112,6 +226,10 @@ async function processCapture(getFrame) {
candidates: plan.kind === 'ambiguous' candidates: plan.kind === 'ambiguous'
? plan.candidates ? plan.candidates
: targetSession.stacks.map((stack) => stack.id), : targetSession.stacks.map((stack) => stack.id),
// Nur eine echte Stapel-Mehrdeutigkeit (plan.kind === 'ambiguous')
// bedeutet "mehrere passen" - alles andere landet nur wegen roter
// Konfidenz hier, also weil nichts Verwertbares erkannt wurde.
nothingRecognized: plan.kind !== 'ambiguous',
}); });
// "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt // "nochmal scannen": abbrechen, ohne zu buchen. Der fruehe return liegt
@@ -132,13 +250,25 @@ async function processCapture(getFrame) {
if (targetSession !== session) return; if (targetSession !== session) return;
const entry = commitAssignment(targetSession, spec, source, stackId, codes); const entry = commitAssignment(targetSession, spec, source, stackId, codes);
// entry.rawText ist rein additiv (siehe recognize() in pipeline.js) -
// session.js kennt das Feld nicht und bleibt deshalb unangetastet; es wird
// hier von aussen an den bereits gebuchten Eintrag angehaengt, genau wie
// die Stapelzuordnung selbst nie vom Rohtext abhaengt.
entry.rawText = rawText;
view.setLast(describeEntry(spec, entry.stackId)); view.setLast(describeEntry(spec, entry.stackId));
syncView(); syncView();
// Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr // Nach einer manuellen Entscheidung im Rot-Dialog gibt es nichts mehr
// rueckzumelden - der Dialog selbst war die Rueckmeldung. // rueckzumelden - der Dialog selbst war die Rueckmeldung.
if (confidence !== 'red') { if (confidence !== 'red') {
await showResult(app, { stackId: entry.stackId, spec, confidence }); // Nur in Funktion 3 ("Text erkennen") ist der erkannte Text lang genug,
// dass er gelesen werden will - dort bleibt die Rueckmeldung deshalb
// stehen, bis der Nutzer sie wegtippt, statt sich nach kurzer Zeit von
// selbst auszublenden (siehe showResult()). In den beiden Barcode-
// Funktionen zaehlt Tempo, dort bleibt es beim automatischen Ausblenden.
await showResult(app, {
stackId: entry.stackId, spec, confidence, rawText, autoHide: currentModeId !== 'ocr',
});
} }
} catch (error) { } catch (error) {
failed = true; failed = true;
@@ -146,19 +276,285 @@ async function processCapture(getFrame) {
} finally { } finally {
busy = false; busy = false;
// Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer // Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer
// noch lesen muss, nicht ueberschreiben. Der Hinweis auf eine nicht // noch lesen muss, nicht ueberschreiben.
// verfuegbare Kamera hat Vorrang vor beidem und bleibt dauerhaft stehen. if (!failed) restoreIdleStatus();
if (!failed) {
if (cameraUnavailableMessage) {
view.setStatus(cameraUnavailableMessage, true);
} else {
const available = isOcrAvailable();
view.setStatus(
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
!available,
);
} }
} }
// --- Dauersuche nach Barcodes -----------------------------------------
//
// Solange die Kamera laeuft, sucht die App fortwaehrend im Zielrahmen nach
// Barcodes, statt auf den Knopfdruck zu warten - ein Modul in Freihand vor
// die Kamera zu halten und zu treffen, gelingt selten beim ersten Bild.
// Etwa fuenf Versuche pro Sekunde: haeufig genug, dass ein kurzes Hinhalten
// genuegt, aber selten genug, dass ein Handy dabei nicht dauerhaft ausgelastet
// ist. Die tatsaechliche Dauer eines Versuchs wird unten gemessen und von
// dieser Zielspanne abgezogen - dauert ein Versuch laenger (z. B. weil er
// eine Buchung samt kurzer Rueckmeldung ausgeloest hat), startet der naechste
// sofort, ohne zusaetzlich zu warten.
const SEARCH_INTERVAL_MS = 200;
// Sperrzeit gegen Doppelerfassung: haelt man ein Modul mehrere Sekunden vor
// die Kamera, darf es nicht mehrfach gebucht werden. Zwei Sekunden reichen,
// um eine kurze Verdeckung oder ein Wackeln zu ueberbruecken, waehrend
// derselbe Barcode noch im Bild ist - ein zuegiger Wechsel zu einem anderen
// Modul wird davon nicht ausgebremst, denn ein *anderer* Code hebt die Sperre
// sofort auf (siehe runSearchAttempt). Bewusst kurz gehalten, damit ein
// tatsaechlich zweites Exemplar desselben Teils (z. B. beim Sortieren vieler
// gleicher Module) nach dem Wegnehmen ohne spuerbare Wartezeit erneut gezaehlt
// werden kann, sobald kein Code mehr im Bild ist.
const REBOOK_COOLDOWN_MS = 2000;
// Zuletzt automatisch (per Dauersuche) gebuchte Codemenge, waehrend die Suche
// wegen der Sperrzeit ruht - null, solange keine Sperre aktiv ist.
let restingCodes = null;
// Zeitpunkt (performance.now()), ab dem die Sperrzeit abgelaufen ist.
let restUntil = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Dieselben Bedingungen, unter denen heute schon die Bedienelemente gesperrt
* sind: eine Erkennung laeuft (busy) oder ein Dialog/die Sitzungsliste liegt
* als Vollbild-Overlay ueber der Ansicht.
*/
function searchPaused() {
return busy || app.querySelector('.overlay') !== null;
}
/**
* Ein einzelner Durchlauf der Dauersuche: Ausschnitt in nativer Aufloesung
* holen, auf Barcodes pruefen, Rahmen entsprechend hervorheben. Wird ein noch
* nicht gesperrter Code gefunden, laeuft ab hier derselbe Weg wie beim
* Antippen von "Modul scannen" (siehe processCapture) - der Nutzer muss dafuer
* nichts tun.
*/
async function runSearchAttempt() {
// Funktion 3 (Text erkennen) hat keine laufende Suche - siehe scan-modes.js.
// Ein Wechsel dorthin setzt den Rahmen bereits in applyMode() zurueck;
// hier genuegt es, schlicht nichts zu tun.
if (!getScanMode(currentModeId).continuousSearch) return;
if (searchPaused()) {
view.setFrameDetected(false);
return;
}
let region;
try {
region = grabFrameRegion(view.video);
} catch {
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf
// versucht es erneut, kein Grund die Dauersuche abzubrechen.
view.setFrameDetected(false);
return;
}
let codes;
try {
codes = await decodeBarcodes(region, getScanMode(currentModeId).barcodeFormats);
} catch {
view.setFrameDetected(false);
return;
}
view.setFrameDetected(codes.length > 0);
if (codes.length === 0) {
// Kein Code mehr im Bild: Die Sperrzeit darf jetzt ablaufen (siehe
// REBOOK_COOLDOWN_MS oben), aber nur wenn sie tatsaechlich verstrichen ist.
if (restingCodes !== null && performance.now() >= restUntil) {
restingCodes = null;
}
return;
}
const normalizedCodes = codes.map((code) => normalizeToken(code));
if (restingCodes !== null) {
const stillSameModule = normalizedCodes.some((code) => restingCodes.has(code));
if (stillSameModule) {
// Dasselbe Modul haengt noch im Bild - nicht erneut buchen, egal ob die
// Sperrzeit schon verstrichen ist.
return;
}
// Ein anderer Code ist erschienen: Modulwechsel, die Sperre faellt sofort.
restingCodes = null;
}
// Waehrend decodeBarcodes lief, koennte sich der Zustand veraendert haben
// (z. B. Knopfdruck des Nutzers) - unmittelbar vor dem Ausloesen erneut pruefen.
if (searchPaused()) return;
restingCodes = new Set(normalizedCodes);
restUntil = performance.now() + REBOOK_COOLDOWN_MS;
// Derselbe Ausschnitt, der den Code gerade geliefert hat, geht direkt in
// denselben Ablauf wie beim Knopfdruck - kein zweiter, zeitversetzter Griff
// zur Kamera noetig.
await processCapture(() => Promise.resolve(region));
}
/** Kurzbezeichnung der Polaritaet (siehe count-objects.js) fuer die Diagnosezeile. */
function describePolarity(polarity) {
if (polarity === 'hell') return 'hell auf dunkel';
if (polarity === 'dunkel') return 'dunkel auf hell';
return '';
}
/**
* Baut den Wortlaut der Diagnosezeile unter der grossen Zahl (siehe
* ui/scan-view.js/setCountDiagnostics()). Kurze, unfachliche Bezeichnungen
* in eigenen Zeilen statt einer einzigen langen Zeile - der Nutzer soll sie
* bei Bedarf jemandem am Telefon vorlesen koennen, ohne sich zu verhaspeln.
* Enthaelt zusaetzlich zu den schon vorhandenen Werten die fuenf
* Einzelmessungen dieser Aufnahme (`counts`) - daran erkennen Nutzer und
* Entwickler sofort, ob das Ergebnis belastbar ist (fuenf Messungen von 17
* bis 19 bedeuten etwas anderes als fuenf Messungen von 4 bis 50). Die
* uebrigen Werte (`last`) beziehen sich weiterhin auf die *letzte* der
* fuenf Einzelmessungen - der gleitende Median ueber mehrere Durchlaeufe
* (frueher: count-history.js) entfaellt, die Aufnahme mit ihren fuenf
* Bildern leistet die noetige Beruhigung bereits selbst (siehe
* count-capture.js).
* @param {number[]} counts die (bis zu fuenf) Einzelmessungen dieser Aufnahme
* @param {ReturnType<typeof countObjects>} last Ergebnis der letzten der
* fuenf Einzelmessungen
* @param {number} durationMs Zeitbedarf der letzten Einzelmessung
*/
function describeCountDiagnostics(counts, last, durationMs) {
return [
`Roh: ${last.count}`,
`Messungen: ${counts.join(', ')}`,
`Bild: ${last.width}×${last.height}`,
`Flächen: ${last.regionsFound}${last.regionsKept}`,
`Größte Fläche: ${last.largestArea} Bildpunkte`,
`Typische Größe: ${last.typicalArea} Bildpunkte`,
`Art: ${describePolarity(last.polarity)}`,
`Dauer: ${durationMs.toFixed(0)} ms`,
].join('\n');
}
// Anzahl der Bilder je Zaehl-Aufnahme (siehe runCountCapture() unten): ein
// einzelnes Videobild kann verwackelt sein oder mitten in eine
// Fokusregelung fallen - fuenf einzeln gezaehlte Bilder, zusammengefasst
// ueber ihren Median (count-capture.js), sind robuster als eines allein.
const CAPTURE_SAMPLE_SIZE = 5;
// Zeitspanne, ueber die die CAPTURE_SAMPLE_SIZE Bilder einer Aufnahme verteilt
// aufgenommen werden - kurz genug, dass der Nutzer die Kamera dafuer ohne
// Muehe ruhig haelt, lang genug, dass aufeinanderfolgende Bilder sich
// tatsaechlich unterscheiden (statt praktisch identische Wiederholungen
// desselben Videobilds zu zaehlen).
const CAPTURE_DURATION_MS = 1000;
/**
* Setzt die Beschriftung der Aufnahme-Schaltflaeche in Funktion "Zählen"
* anhand von hasCountResult (siehe Deklaration oben): "Zählen" vor der
* ersten Aufnahme seit Betreten der Funktion, "Nochmal zählen" danach.
*/
function updateCountCaptureLabel() {
view.setCaptureLabel(hasCountResult ? 'Nochmal zählen' : 'Zählen');
}
/**
* Eine Aufnahme der Funktion "Zählen" (ausgeloest durch die Schaltflaeche
* "Zählen"/"Nochmal zählen"): nimmt binnen rund CAPTURE_DURATION_MS
* insgesamt CAPTURE_SAMPLE_SIZE Bilder aus dem Zielrahmen des laufenden
* Videobilds auf (`grabFrameRegion`, derselbe Ausschnitt wie ueberall sonst
* - bewusst *nicht* die native Fotoaufnahme des Geraets, deren
* Bildausschnitt von der Vorschau abweichen kann), zaehlt jedes einzeln
* (`countObjects`, unveraendert) und zeigt als Ergebnis deren Median
* (`combineCaptureCounts`, count-capture.js) - robust gegen ein einzelnes
* verwackeltes oder unscharfes Bild. Waehrend der Aufnahme zeigt die
* Statuszeile "zähle …" (dasselbe Muster wie "erkenne …" in
* processCapture()), damit der Nutzer das Handy ruhig haelt und den
* Knopfdruck nicht fuer wirkungslos haelt.
*
* Anders als frueher (Dauerzaehlung) bleibt das Ergebnis danach stehen, bis
* der Nutzer erneut antippt - eine Zaehlung ist eine Schaetzung, die durch
* staendiges Neuanzeigen nicht praeziser wird, nur unruhig. Bucht nichts in
* die Sitzung, sperrt aber wie processCapture() ueber `busy` waehrend sie
* laeuft (Undo, Sitzungsliste, Funktionswechsel bleiben so lange
* unerreichbar - dieselbe kurze, unproblematische Sperre wie bei jedem
* anderen Scan-Durchlauf).
*/
async function runCountCapture() {
if (busy) return;
busy = true;
view.setStatus('zähle …');
let failed = false;
try {
const counts = [];
let last = null;
for (let i = 0; i < CAPTURE_SAMPLE_SIZE; i += 1) {
if (i > 0) {
await sleep(CAPTURE_DURATION_MS / (CAPTURE_SAMPLE_SIZE - 1));
}
let region;
try {
region = grabFrameRegion(view.video);
} catch {
// Kamerabild in diesem Augenblick nicht bereit - dieses Einzelbild
// faellt aus, die Aufnahme laeuft mit den uebrigen weiter (siehe
// combineCaptureCounts() fuer den Fall weniger als fuenf Bilder).
continue;
}
const start = performance.now();
const result = countObjects(region);
const durationMs = performance.now() - start;
counts.push(result.count);
last = { result, durationMs };
}
if (last === null) {
// Kein einziges Bild dieser Aufnahme war verwertbar - kein regulaeres
// Ergebnis, siehe combineCaptureCounts() (count-capture.js). Bleibt
// wie ein Fehlschlag stehen, statt sofort von restoreIdleStatus()
// ueberschrieben zu werden.
failed = true;
view.setStatus('Zählen nicht möglich — Kamerabild noch nicht bereit', true);
return;
}
const finalCount = combineCaptureCounts(counts);
view.setCount(Math.round(finalCount));
view.setCountDiagnostics(describeCountDiagnostics(counts, last.result, last.durationMs));
hasCountResult = true;
updateCountCaptureLabel();
} catch (error) {
failed = true;
view.setStatus(`Fehler: ${error.message}`, true);
} finally {
busy = false;
if (!failed) restoreIdleStatus();
}
}
/**
* Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
* der vorherige vollstaendig fertig ist - nie ueberlappend. Fuehrt pro
* Durchlauf die Barcode-Dauersuche aus; sie bricht selbst sofort ab, wenn
* die gewaehlte Funktion keine laufende Suche vorsieht (siehe
* runSearchAttempt()). Funktion "Zählen" laeuft seit dem Umbau auf
* Aufnahmen statt Dauerbetrieb (siehe runCountCapture()) und braucht hier
* deshalb keinen eigenen Aufruf mehr.
*/
async function continuousSearchLoop() {
for (;;) {
const started = performance.now();
try {
await runSearchAttempt();
} catch {
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
// dauerhaft abbrechen.
}
const elapsed = performance.now() - started;
await sleep(Math.max(0, SEARCH_INTERVAL_MS - elapsed));
} }
} }
@@ -199,7 +595,17 @@ function openSessionList() {
}); });
} }
startCamera(view.video).catch((error) => { /** Startet die Kamera und, sobald sie brauchbare Bilder liefert, die Dauersuche. */
function startCameraAndSearch() {
startCamera(view.video)
.then(() => {
// Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche
// laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss.
// runSearchAttempt() selbst haelt sich zurueck, wenn die gewaehlte
// Funktion keine laufende Suche vorsieht (Funktion 3).
continuousSearchLoop();
})
.catch((error) => {
// Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy-
// Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg // Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg
// ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. // ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar.
@@ -207,6 +613,7 @@ startCamera(view.video).catch((error) => {
view.setStatus(cameraUnavailableMessage, true); view.setStatus(cameraUnavailableMessage, true);
view.setFilePickerVisible(true); view.setFilePickerVisible(true);
}); });
}
/** /**
* Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an, * Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,
@@ -229,4 +636,18 @@ async function initResumeOffer() {
syncView(); syncView();
} }
initResumeOffer(); /**
* Ablauf beim Start: erst - falls vorhanden - die Frage nach dem Fortsetzen
* einer gesicherten Sitzung, danach die Wahl der Scan-Funktion, und erst
* danach die Kamera. So sieht der Nutzer die Funktionswahl nie, bevor die
* Fortsetzen-Frage (falls noetig) beantwortet ist, und die Kamera startet
* nie, bevor feststeht, welche Codearten sie ueberhaupt suchen soll.
*/
async function boot() {
await initResumeOffer();
const modeId = await askForScanMode(app);
applyMode(modeId);
startCameraAndSearch();
}
boot();
+19 -66
View File
@@ -1,4 +1,5 @@
import { createWorker } from 'tesseract.js'; import { createWorker } from 'tesseract.js';
import { computeOtsuThreshold } from './threshold.js';
// Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen- // Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen-
// gespeichert, damit sich auch mehrere schnell hintereinander gestartete // gespeichert, damit sich auch mehrere schnell hintereinander gestartete
@@ -16,65 +17,15 @@ let workerPromise = null;
// Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung. // Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung.
let available = true; let available = true;
/**
* Bestimmt den Otsu-Schwellwert aus einem Histogramm der 256 Helligkeits-
* stufen. Otsu sucht den Schwellwert, der die Pixel in zwei Gruppen teilt
* (dunkler / heller) und dabei die Streuung innerhalb jeder Gruppe minimiert
* bzw. die Streuung zwischen den Gruppen maximiert - beides ist aequivalent
* und wird hier ueber die Zwischen-Klassen-Varianz berechnet.
* Da der Schwellwert aus der gesamten Verteilung stammt, verschiebt ein
* einzelnes Ausreisserpixel (z. B. ein Lichtreflex) das Histogramm kaum und
* damit auch den Schwellwert kaum - anders als eine Min/Max-Spreizung, die
* von genau diesem einen Pixel dominiert wird.
* @param {Uint32Array | number[]} histogram 256 Eintraege, einer je Helligkeitsstufe
* @param {number} totalPixels Anzahl der Pixel, aus denen das Histogramm stammt
* @returns {number} Schwellwert (0-255): Helligkeit darunter wird schwarz, ab hier weiss
*/
function otsuThreshold(histogram, totalPixels) {
if (totalPixels === 0) return 128;
let sumAll = 0;
for (let level = 0; level < 256; level += 1) {
sumAll += level * histogram[level];
}
let sumBackground = 0;
let weightBackground = 0;
let bestVariance = -1;
let bestThreshold = 128;
for (let level = 0; level < 256; level += 1) {
weightBackground += histogram[level];
if (weightBackground === 0) continue;
const weightForeground = totalPixels - weightBackground;
if (weightForeground === 0) break;
sumBackground += level * histogram[level];
const meanBackground = sumBackground / weightBackground;
const meanForeground = (sumAll - sumBackground) / weightForeground;
const meanDiff = meanBackground - meanForeground;
const betweenClassVariance = weightBackground * weightForeground * meanDiff * meanDiff;
if (betweenClassVariance > bestVariance) {
bestVariance = betweenClassVariance;
bestThreshold = level + 1;
}
}
return bestThreshold;
}
/** /**
* Graustufen, Otsu-Schwellwert. * Graustufen, Otsu-Schwellwert.
* Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder mit * Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder mit
* Lichtreflexen als Normalfall; ohne diese Aufbereitung liest Tesseract dort * Lichtreflexen als Normalfall; ohne diese Aufbereitung liest Tesseract dort
* kaum etwas Brauchbares. Der Schwellwert wird aus der Helligkeitsverteilung * kaum etwas Brauchbares. Der Schwellwert wird aus der Helligkeitsverteilung
* des Bildes selbst bestimmt (Otsu), statt aus einer Kontrastspreizung * des Bildes selbst bestimmt (Otsu, siehe threshold.js), statt aus einer
* zwischen dunkelstem und hellstem Pixel - ein einzelner Reflex kann so * Kontrastspreizung zwischen dunkelstem und hellstem Pixel - ein einzelner
* nicht mehr Schrift und Untergrund gemeinsam auf dieselbe Seite eines * Reflex kann so nicht mehr Schrift und Untergrund gemeinsam auf dieselbe
* festen Schwellwerts ziehen. * Seite eines festen Schwellwerts ziehen.
* Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit * Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit
* ohne Browser testbar. * ohne Browser testbar.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
@@ -82,16 +33,7 @@ function otsuThreshold(histogram, totalPixels) {
*/ */
export function preprocess(imageData) { export function preprocess(imageData) {
const { width, height, data } = imageData; const { width, height, data } = imageData;
const gray = new Uint8ClampedArray(width * height); const { gray, threshold } = computeOtsuThreshold(imageData);
const histogram = new Uint32Array(256);
for (let i = 0, p = 0; i < data.length; i += 4, p += 1) {
const value = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
gray[p] = value;
histogram[value] += 1;
}
const threshold = otsuThreshold(histogram, gray.length);
const out = new Uint8ClampedArray(data.length); const out = new Uint8ClampedArray(data.length);
for (let p = 0; p < gray.length; p += 1) { for (let p = 0; p < gray.length; p += 1) {
const value = gray[p] >= threshold ? 255 : 0; const value = gray[p] >= threshold ? 255 : 0;
@@ -117,8 +59,19 @@ function getWorker() {
workerPromise = createWorker('eng') workerPromise = createWorker('eng')
.then(async (worker) => { .then(async (worker) => {
await worker.setParameters({ await worker.setParameters({
// Etiketten enthalten nur Grossbuchstaben, Ziffern und wenige Sonderzeichen. // Der Nutzer will den vollstaendigen Etikettentext sehen, nicht nur
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/. x', // die von der App verstandenen Felder (die ohnehin nur Grossbuch-
// staben, Ziffern und wenige Zeichen brauchen) - deshalb Gross- UND
// Kleinbuchstaben, Ziffern sowie die auf Etiketten ueblichen Satz-
// und Sonderzeichen. Das kann die Genauigkeit bei den Codeteilen
// (Kapazitaet, Rank, Geschwindigkeit, Teilenummer) etwas verringern,
// weil Tesseract nun z.B. zwischen "O" und "o" unterscheiden muss -
// hingenommen, weil extractFields() ohnehin verwechslungstolerant
// gegen die bekannten Werte prueft (siehe spec.js/CONFUSIONS) und
// der Nutzer diesen Tausch ausdruecklich will.
tessedit_char_whitelist:
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+ ' -/.,:;()+*_#%&',
}); });
return worker; return worker;
}) })
+14 -8
View File
@@ -60,10 +60,16 @@ function allCompatible(specs) {
* Stapel bekannt ist. Ist einer der gelesenen Codes bekannt, steht die * Stapel bekannt ist. Ist einer der gelesenen Codes bekannt, steht die
* Zuordnung fest und die Texterkennung wird uebersprungen. Fehlt die * Zuordnung fest und die Texterkennung wird uebersprungen. Fehlt die
* Funktion, verhaelt sich die Pipeline wie ohne diese Optimierung. * Funktion, verhaelt sich die Pipeline wie ohne diese Optimierung.
* @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[]}>} * @returns {Promise<{spec: object, source: 'barcode'|'ocr'|'none', confidence: 'green'|'yellow'|'red', codes: string[], rawText: string}>}
* codes sind die im Bild gelesenen rohen Barcode-Zeichenketten (koennen * codes sind die im Bild gelesenen rohen Barcode-Zeichenketten (koennen
* neben der Teilenummer z.B. auch eine Seriennummer enthalten) - zur * neben der Teilenummer z.B. auch eine Seriennummer enthalten) - zur
* Weitergabe an proposeAssignment/commitAssignment in session.js. * Weitergabe an proposeAssignment/commitAssignment in session.js.
* rawText ist der ungefilterte Texterkennungs-Rohtext, rein additiv: er
* fliesst nirgends in die Stapelzuordnung ein (die stuetzt sich weiterhin
* ausschliesslich auf die aus ihm abgeleiteten, verstandenen Spec-Felder,
* siehe extractFields/ocr-extract.js) und dient allein der Anzeige fuer den
* Nutzer. Leer, wenn die Texterkennung in diesem Durchlauf nicht lief (z.B.
* weil ein gruener Barcode-Treffer sie ueberfluessig gemacht hat).
*/ */
export async function recognize(frame, deps) { export async function recognize(frame, deps) {
const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS; const barcodeTimeoutMs = deps.barcodeTimeoutMs ?? DEFAULT_BARCODE_TIMEOUT_MS;
@@ -99,7 +105,7 @@ export async function recognize(frame, deps) {
} }
if (usable.length === 1) { if (usable.length === 1) {
return { spec: usable[0], source: 'barcode', confidence: 'green', codes }; return { spec: usable[0], source: 'barcode', confidence: 'green', codes, rawText: '' };
} }
if (usable.length > 1) { if (usable.length > 1) {
if (allCompatible(usable)) { if (allCompatible(usable)) {
@@ -109,10 +115,10 @@ export async function recognize(frame, deps) {
// da alle uebrigen Felder aus derselben Teilenummer abgeleitet werden, // da alle uebrigen Felder aus derselben Teilenummer abgeleitet werden,
// sind vertraegliche Treffer ohnehin gleich. Es gibt nichts aufzufuellen, // sind vertraegliche Treffer ohnehin gleich. Es gibt nichts aufzufuellen,
// der erste Treffer genuegt. // der erste Treffer genuegt.
return { spec: usable[0], source: 'barcode', confidence: 'green', codes }; return { spec: usable[0], source: 'barcode', confidence: 'green', codes, rawText: '' };
} }
// Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet. // Mehrdeutigkeit zwischen verwertbaren Barcodes: nicht raten, Nutzer entscheidet.
return { spec: emptySpec(), source: 'none', confidence: 'red', codes }; return { spec: emptySpec(), source: 'none', confidence: 'red', codes, rawText: '' };
} }
// Ist einer der gelesenen Codes bereits einem Stapel bekannt, steht die // Ist einer der gelesenen Codes bereits einem Stapel bekannt, steht die
@@ -121,7 +127,7 @@ export async function recognize(frame, deps) {
// Bei einem tatsaechlich neuen Modul (kein Code bekannt) laeuft sie wie // Bei einem tatsaechlich neuen Modul (kein Code bekannt) laeuft sie wie
// bisher, denn dort liefert sie die lesbare Beschriftung des neuen Stapels. // bisher, denn dort liefert sie die lesbare Beschriftung des neuen Stapels.
if (typeof deps.isKnownCode === 'function' && codes.some((code) => deps.isKnownCode(code))) { if (typeof deps.isKnownCode === 'function' && codes.some((code) => deps.isKnownCode(code))) {
return { spec: best, source: 'barcode', confidence: 'green', codes }; return { spec: best, source: 'barcode', confidence: 'green', codes, rawText: '' };
} }
const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs); const ocrResult = await safely(() => deps.runOcr(frame), '', ocrTimeoutMs);
@@ -136,7 +142,7 @@ export async function recognize(frame, deps) {
} }
if (isUsable(merged)) { if (isUsable(merged)) {
return { spec: merged, source: 'ocr', confidence: 'yellow', codes }; return { spec: merged, source: 'ocr', confidence: 'yellow', codes, rawText: text };
} }
// Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene // Zum Sortieren muss keine Kapazitaet bekannt sein - eine exakt gelesene
// Teilenummer genuegt, um ein Modul wiederzuerkennen. Sie ueberlebt bis // Teilenummer genuegt, um ein Modul wiederzuerkennen. Sie ueberlebt bis
@@ -144,7 +150,7 @@ export async function recognize(frame, deps) {
// mehrere sich einig waren (siehe 'best' oben) - bei widersprechenden // mehrere sich einig waren (siehe 'best' oben) - bei widersprechenden
// Codes bleibt merged.partNumber null und die Vorsicht damit erhalten. // Codes bleibt merged.partNumber null und die Vorsicht damit erhalten.
if (merged.partNumber !== null) { if (merged.partNumber !== null) {
return { spec: merged, source: 'barcode', confidence: 'green', codes }; return { spec: merged, source: 'barcode', confidence: 'green', codes, rawText: text };
} }
return { spec: merged, source: 'none', confidence: 'red', codes }; return { spec: merged, source: 'none', confidence: 'red', codes, rawText: text };
} }
+98
View File
@@ -0,0 +1,98 @@
// Die vier Scan-Funktionen, die der Nutzer vor dem Scannen waehlt. Reines
// Modul ohne Browser-Zugriff (kein window/document/navigator) - einzige
// Quelle fuer Kennung, Beschriftung, Codearten und Verhalten je Funktion.
// Sowohl die Auswahl-Oberflaeche als auch main.js lesen ausschliesslich von
// hier - es darf keine zweite Stelle geben, an der Codearten oder
// Beschriftungen stehen.
//
// Funktion 2 ("2D-Code") deckt die praktisch vorkommenden zweidimensionalen
// Codearten ab: die QR-Varianten (QRCode, MicroQRCode, RMQRCode) sowie
// DataMatrix, Aztec und PDF417. Grund: Die 2D-Codes auf den eigenen
// RAM-Etiketten sind DataMatrix, nicht QR - eine auf QR beschraenkte Funktion
// faende auf dieser Ware nichts. Die Kennung heisst weiterhin `qrcode`, siehe
// Kommentar dort.
//
// Funktion 4 ("Zaehlen") ist reine Anzeige: Sie liest weder Codes noch Text
// und bucht nichts in die Sitzung - deshalb `continuousSearch: false` und
// `useOcr: false`. Anders als frueher laeuft sie nicht mehr fortwaehrend,
// solange die Kamera aktiv ist, sondern - wie Funktion 3 ("Text erkennen") -
// auf Knopfdruck: eine Zaehlung ist eine Schaetzung, die durch staendiges
// Neuanzeigen nicht praeziser wird, nur unruhig. Ein eigenes Merkmal dafuer
// braucht es nicht mehr - `continuousSearch: false` allein genuegt bereits,
// um main.js (siehe applyMode()) und scan-recognition.js dieselbe
// Knopfdruck-Behandlung wie bei Funktion 3 zukommen zu lassen.
/**
* @typedef {object} ScanMode
* @property {string} id Kennung, intern verwendet (main.js, Speicherung der Wahl).
* @property {string} label Deutsche Beschriftung fuer die Oberflaeche.
* @property {string[]} barcodeFormats An decodeBarcodes() zu uebergebende
* Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine
* Barcode-Dekodierung nutzt.
* @property {boolean} continuousSearch Ob waehrend die Kamera laeuft
* fortwaehrend nach Codes gesucht wird (statt nur auf Knopfdruck). Bei
* Funktion "Zaehlen" ebenso `false` wie bei "Text erkennen" - siehe
* count-objects.js fuer das Zaehlverfahren selbst und main.js
* (runCountCapture()) fuer den Knopfdruck-Ablauf.
* @property {boolean} useOcr Ob die Texterkennung (Tesseract) genutzt wird.
*/
/** @type {ScanMode[]} */
export const SCAN_MODES = [
{
id: 'barcode',
label: 'Strichcode',
// Eindimensionale Codes, wie sie auf Teilenummern-Etiketten vorkommen.
barcodeFormats: ['Code128', 'Code39', 'Code93', 'ITF', 'EAN13', 'EAN8', 'UPCA', 'UPCE', 'Codabar'],
continuousSearch: true,
useOcr: false,
},
{
// Kennung bleibt aus historischen Gruenden `qrcode`, obwohl die Funktion
// inzwischen alle 2D-Codearten liest (nicht mehr nur QR) - Umbenennung
// nicht vorgenommen, da `qrcode` an mehreren Stellen durchgereicht wird.
// Label heisst auf Wunsch des Auftraggebers "QR-Code", obwohl die Funktion
// bewusst auch DataMatrix, Aztec und PDF417 liest - die RAM-Etiketten
// tragen DataMatrix, nicht QR.
id: 'qrcode',
label: 'QR-Code',
// QR-Varianten plus DataMatrix, Aztec, PDF417 - siehe Kommentar am Kopf.
barcodeFormats: ['QRCode', 'MicroQRCode', 'RMQRCode', 'DataMatrix', 'Aztec', 'PDF417'],
continuousSearch: true,
useOcr: false,
},
{
id: 'ocr',
label: 'Text erkennen',
// Keine Codes - allein die Texterkennung, auf Knopfdruck.
barcodeFormats: [],
continuousSearch: false,
useOcr: true,
},
{
id: 'count',
label: 'Zählen',
// Keine Codes, keine Texterkennung - reine Bildauswertung (siehe
// count-objects.js), die nichts bucht. Auf Knopfdruck wie Funktion 3,
// siehe Moduldoku oben.
barcodeFormats: [],
continuousSearch: false,
useOcr: false,
},
];
/**
* Liefert die Funktion zu einer Kennung.
* @param {string} id
* @returns {ScanMode}
* @throws {Error} wenn die Kennung keiner der drei Funktionen entspricht -
* ein Programmfehler (main.js darf nie eine fremde Kennung durchreichen),
* kein regulaerer Ausgang.
*/
export function getScanMode(id) {
const mode = SCAN_MODES.find((candidate) => candidate.id === id);
if (!mode) {
throw new Error(`Unbekannte Scan-Funktion: ${id}`);
}
return mode;
}
+46
View File
@@ -0,0 +1,46 @@
// Baut die Adapter fuer recognize() (pipeline.js) anhand der gewaehlten
// Scan-Funktion (siehe scan-modes.js). Reines Modul ohne Browser-Zugriff -
// direkt unter Node pruefbar. pipeline.js selbst bleibt unveraendert: die
// Funktionen unterscheiden sich ausschliesslich darin, welche Adapter
// hineingereicht werden.
//
// Funktionen 1/2 (laufende Codesuche): echte Barcode-Dekodierung mit den zur
// Funktion gehoerenden Codearten; die Texterkennung liefert sofort leeren
// Text, ohne dass runOcr ueberhaupt aufgerufen wird.
// Funktion 3 (Text erkennen): echte Texterkennung; die Barcode-Dekodierung
// liefert sofort eine leere Liste, ohne dass decodeBarcodes ueberhaupt
// aufgerufen wird.
// Funktion 4 (Zaehlen): weder Barcode-Dekodierung noch Texterkennung -
// recognize() (pipeline.js) wird im normalen Ablauf fuer diese Funktion gar
// nicht aufgerufen (siehe main.js: die Schaltflaeche "Zählen"/"Nochmal
// zählen" ruft stattdessen runCountCapture(), das direkt countObjects()
// verwendet); dieselbe leere Ruecklieferung wie in Funktion 3 dient hier nur
// als Absicherung fuer den seltenen Fall, dass der Ersatzweg ohne Kamera
// (Dateiauswahl) waehrend gewaehlter Funktion "Zaehlen" ausgeloest wird.
//
// Sowohl Funktion 3 als auch Funktion 4 haben `barcodeFormats: []` (siehe
// scan-modes.js) - eine leere Codeartenliste ist deshalb das gemeinsame,
// einzige Merkmal, an dem sich "keine Barcode-Dekodierung noetig" ablesen
// laesst, statt je Funktion einzeln nachzufragen.
import { getScanMode } from './scan-modes.js';
/**
* @param {string} modeId Kennung aus scan-modes.js.
* @param {{decodeBarcodes: Function, runOcr: Function}} adapters echte,
* browser-gebundene Adapter (aus barcode.js / ocr.js).
* @returns {{decodeBarcodes: Function, runOcr: Function}} an recognize()
* durchzureichende Adapter, passend zur gewaehlten Funktion.
*/
export function buildRecognitionAdapters(modeId, adapters) {
const mode = getScanMode(modeId);
return {
decodeBarcodes: mode.barcodeFormats.length === 0
? async () => []
: (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats),
runOcr: mode.useOcr
? adapters.runOcr
: async () => '',
};
}
+12
View File
@@ -52,6 +52,9 @@ function isUsableStack(stack) {
// dieser Erweiterung kennt das Feld nicht und gilt deshalb nicht als // dieser Erweiterung kennt das Feld nicht und gilt deshalb nicht als
// unstimmig (siehe isUsableStack). Ist es vorhanden, muss es eine Liste von // unstimmig (siehe isUsableStack). Ist es vorhanden, muss es eine Liste von
// Zeichenketten sein. // Zeichenketten sein.
// entry.rawText (der OCR-Rohtext, siehe pipeline.js/recognize) ist nach
// demselben Muster optional: ein Stand aus einer Fassung ohne dieses Feld ist
// nicht unstimmig; ist es vorhanden, muss es eine Zeichenkette sein.
function isUsableEntry(entry) { function isUsableEntry(entry) {
return ( return (
isPlainObject(entry) isPlainObject(entry)
@@ -59,6 +62,7 @@ function isUsableEntry(entry) {
&& typeof entry.stackId === 'string' && typeof entry.stackId === 'string'
&& isPlainObject(entry.spec) && isPlainObject(entry.spec)
&& (entry.codes === undefined || isStringArray(entry.codes)) && (entry.codes === undefined || isStringArray(entry.codes))
&& (entry.rawText === undefined || typeof entry.rawText === 'string')
); );
} }
@@ -107,6 +111,14 @@ export function loadSession(store) {
if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null; if (entryIds.some((entryId) => entryId >= parsed.nextEntryId)) return null;
// rawText fehlt bei einem Eintrag aus einer Fassung vor dieser Erweiterung
// (siehe isUsableEntry oben) - beim Wiederherstellen wird das einheitlich
// als leerer Rohtext behandelt, damit main.js/session-list.js sich nicht
// um "undefined vs. leer" kuemmern muessen.
for (const entry of parsed.entries) {
if (entry.rawText === undefined) entry.rawText = '';
}
for (const stack of parsed.stacks) { for (const stack of parsed.stacks) {
const stackEntries = parsed.entries.filter((entry) => entry.stackId === stack.id); const stackEntries = parsed.entries.filter((entry) => entry.stackId === stack.id);
stack.count = stackEntries.length; stack.count = stackEntries.length;
+110 -1
View File
@@ -21,17 +21,94 @@ body {
#app { display: flex; flex-direction: column; height: 100dvh; } #app { display: flex; flex-direction: column; height: 100dvh; }
.mode-button {
min-height: 56px;
width: 100%;
border: none;
border-bottom: 1px solid var(--line);
background: #1c2027;
color: var(--fg);
font-size: 15px;
font-weight: 600;
text-align: left;
padding: 0 14px;
}
.camera { position: relative; flex: 1; overflow: hidden; background: #000; } .camera { position: relative; flex: 1; overflow: hidden; background: #000; }
.camera video { width: 100%; height: 100%; object-fit: cover; } .camera video { width: 100%; height: 100%; object-fit: cover; }
.frame { .frame {
/* Lage (inset) wird von scan-view.js aus FRAME_INSET (camera.js) gesetzt -
einzige Quelle, die sich auch der native Ausschnitt teilt. */
position: absolute; position: absolute;
inset: 20% 8%;
border: 3px solid rgba(255, 255, 255, 0.7); border: 3px solid rgba(255, 255, 255, 0.7);
border-radius: 8px; border-radius: 8px;
pointer-events: none; pointer-events: none;
} }
/* Dauersuche hat einen Barcode im Ausschnitt gefunden - schlichte
Farbaenderung, keine Animation. */
.frame.detected {
border-color: var(--green);
}
/* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt
ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert.
Der Container selbst blockiert keine Beruehrungen (pointer-events: none);
die grosse Zahl darunter (.count-number) ist die einzige antippbare
Flaeche darin - sie deckt den ganzen Container ab, ist also weit ueber
den geforderten 56 Bildpunkten Mindestgroesse. */
.count-display {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Die Zahl selbst: riesige Schriftgroesse mit Schlagschatten statt
Kontrastfarbe, damit sie auf jedem Untergrund (hell wie dunkel) aus
Armlaenge lesbar bleibt. Als eigene, absolut positionierte Flaeche (statt
als Flex-Kind neben der Diagnosezeile) bleibt sie beim Ein-/Ausblenden der
Diagnosezeile darunter exakt an derselben Stelle stehen - sie "verrutscht"
beim Antippen nicht, auch wenn der Nutzer dabei nur eine Hand frei hat. */
.count-number {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
border: none;
padding: 0;
background: none;
font-size: min(35vw, 220px);
font-weight: 800;
color: #fff;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9);
pointer-events: auto;
}
/* Diagnosezeile (siehe main.js fuer den Wortlaut) - anfangs verborgen,
erscheint per Antippen der grossen Zahl. Am unteren Rand des
Kamerabilds verankert statt im Fluss neben der Zahl, damit sie beim
Ein-/Ausblenden nicht die Zahl selbst verschiebt (siehe .count-number
oben). Klein und unaufdringlich, aber mit demselben Schlagschatten-Trick
auf jedem Untergrund lesbar; white-space: pre-line setzt die einzelnen,
mit "\n" getrennten Werte (main.js) als eigene, kurze Zeilen. */
.count-diagnostics {
position: absolute;
left: 0;
right: 0;
bottom: 6%;
padding: 0 16px;
text-align: center;
font-size: 14px;
line-height: 1.6;
color: #fff;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9);
white-space: pre-line;
pointer-events: none;
}
.stacks { .stacks {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -105,8 +182,40 @@ body {
.overlay .detail { font-size: 20px; line-height: 1.5; } .overlay .detail { font-size: 20px; line-height: 1.5; }
.overlay .pn { font-size: 15px; opacity: 0.85; word-break: break-all; } .overlay .pn { font-size: 15px; opacity: 0.85; word-break: break-all; }
/* Voller OCR-Rohtext in der wartenden Rueckmeldung (Funktion "Text
erkennen", siehe result-overlay.js) - linksbuendig und mit Zeilenumbruch,
damit ein langer Etikettentext lesbar bleibt statt einer einzelnen,
abgeschnittenen Zeile. Waechst nicht aus dem Bildschirm heraus, weil
`.overlay` selbst scrollbar ist (overflow-y: auto, siehe oben). */
.overlay .raw-text {
font-size: 16px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
overflow-wrap: break-word;
max-width: 420px;
width: 100%;
}
.choices { display: grid; gap: 12px; width: 100%; max-width: 420px; } .choices { display: grid; gap: 12px; width: 100%; max-width: 420px; }
.choices button { min-height: 64px; font-size: 20px; } .choices button { min-height: 64px; font-size: 20px; }
/* Auswahl der Scan-Funktion vor dem Scannen (mode-dialog.js): drei grosse,
gleich gestaltete Flaechen statt schmaler Schaltflaechen. */
.mode-tile {
min-height: 96px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px;
}
.mode-tile-label { font-size: 24px; font-weight: 700; }
.mode-tile-hint { font-size: 14px; opacity: 0.8; }
.mode-tile-current { outline: 3px solid var(--fg); outline-offset: -3px; }
.status { padding: 8px 12px; font-size: 14px; color: var(--muted); } .status { padding: 8px 12px; font-size: 14px; color: var(--muted); }
.status.warn { color: #f0b429; } .status.warn { color: #f0b429; }
+91
View File
@@ -0,0 +1,91 @@
// Otsu-Schwellwertbestimmung: reines Modul ohne jeden Browser-Zugriff (kein
// window/document/canvas). Vorher steckte das ausschließlich in `preprocess`
// in ocr.js - das aber tesseract.js einbindet und damit nichts mit reiner
// Bildauswertung zu tun hat. Sowohl `preprocess` (ocr.js, für die
// Texterkennung) als auch `countObjects` (count-objects.js, für die
// Zähl-Funktion) brauchen denselben ersten Schritt - Graustufen bilden und
// die Helligkeitsverteilung in zwei Klassen teilen - und lesen ihn deshalb
// beide von hier.
/**
* Bestimmt den Otsu-Schwellwert aus einem Histogramm der 256 Helligkeits-
* stufen. Otsu sucht den Schwellwert, der die Pixel in zwei Gruppen teilt
* (dunkler / heller) und dabei die Streuung innerhalb jeder Gruppe minimiert
* bzw. die Streuung zwischen den Gruppen maximiert - beides ist aequivalent
* und wird hier ueber die Zwischen-Klassen-Varianz berechnet.
* Da der Schwellwert aus der gesamten Verteilung stammt, verschiebt ein
* einzelnes Ausreisserpixel (z. B. ein Lichtreflex) das Histogramm kaum und
* damit auch den Schwellwert kaum - anders als eine Min/Max-Spreizung, die
* von genau diesem einen Pixel dominiert wird.
* @param {Uint32Array | number[]} histogram 256 Eintraege, einer je Helligkeitsstufe
* @param {number} totalPixels Anzahl der Pixel, aus denen das Histogramm stammt
* @returns {number} Schwellwert (0-255): Helligkeit darunter wird schwarz, ab hier weiss
*/
export function otsuThreshold(histogram, totalPixels) {
if (totalPixels === 0) return 128;
let sumAll = 0;
for (let level = 0; level < 256; level += 1) {
sumAll += level * histogram[level];
}
let sumBackground = 0;
let weightBackground = 0;
let bestVariance = -1;
let bestThreshold = 128;
for (let level = 0; level < 256; level += 1) {
weightBackground += histogram[level];
if (weightBackground === 0) continue;
const weightForeground = totalPixels - weightBackground;
if (weightForeground === 0) break;
sumBackground += level * histogram[level];
const meanBackground = sumBackground / weightBackground;
const meanForeground = (sumAll - sumBackground) / weightForeground;
const meanDiff = meanBackground - meanForeground;
const betweenClassVariance = weightBackground * weightForeground * meanDiff * meanDiff;
if (betweenClassVariance > bestVariance) {
bestVariance = betweenClassVariance;
bestThreshold = level + 1;
}
}
return bestThreshold;
}
/**
* Graustufen (gewichtetes RGB, wie menschliche Helligkeitswahrnehmung) und
* das zugehörige 256-stufige Helligkeitshistogramm - der gemeinsame erste
* Schritt für `preprocess` (ocr.js) und `countObjects` (count-objects.js).
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{gray: Uint8ClampedArray, histogram: Uint32Array}}
*/
export function grayscaleHistogram(imageData) {
const { data } = imageData;
const gray = new Uint8ClampedArray(data.length / 4);
const histogram = new Uint32Array(256);
for (let i = 0, p = 0; i < data.length; i += 4, p += 1) {
const value = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
gray[p] = value;
histogram[value] += 1;
}
return { gray, histogram };
}
/**
* Graustufen und Otsu-Schwellwert in einem Schritt - der gemeinsame
* Ausgangspunkt für `preprocess` und `countObjects`.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{gray: Uint8ClampedArray, threshold: number}}
*/
export function computeOtsuThreshold(imageData) {
const { gray, histogram } = grayscaleHistogram(imageData);
const threshold = otsuThreshold(histogram, gray.length);
return { gray, threshold };
}
+24 -3
View File
@@ -19,21 +19,42 @@ function describeReading(spec) {
* Reine Darstellung: kennt weder Stapel-Logik noch Erkennung, * Reine Darstellung: kennt weder Stapel-Logik noch Erkennung,
* bekommt die zur Wahl stehenden Stapel als Liste herein. * bekommt die zur Wahl stehenden Stapel als Liste herein.
* *
* Der Dialog deckt zwei grundverschiedene Lagen ab, die main.js nicht
* auseinanderhaelt, wenn man nur spec/candidates ansieht: mehrere Stapel
* passen (echte Mehrdeutigkeit) oder die Erkennung hat schlicht nichts
* Verwertbares geliefert. Da beide Lagen mit leerem spec und/oder nicht-
* leeren candidates auftreten koennen (main.js reicht bei "nichts erkannt"
* als Rueckfall alle vorhandenen Stapel als candidates durch, damit der
* Dialog trotzdem Schaltflaechen hat), laesst sich die Unterscheidung nicht
* zweifelsfrei aus spec/candidates ableiten - deshalb der ausdrueckliche
* Parameter nothingRecognized.
*
* @param {HTMLElement} root * @param {HTMLElement} root
* @param {{spec: object, candidates: string[]}} args * @param {{spec: object, candidates: string[], nothingRecognized?: boolean}} args
* nothingRecognized: true, wenn weder Barcode noch Texterkennung etwas
* Verwertbares geliefert haben (main.js: confidence 'red' ausserhalb
* einer echten Stapel-Mehrdeutigkeit) - dann ist der Dialog eine
* Rueckfallebene und kein Angebot mehrerer passender Stapel.
* @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>} * @returns {Promise<{action: 'stack', stackId: string}|{action: 'new'}|{action: 'retry'}>}
*/ */
export function askForStack(root, { spec, candidates }) { export function askForStack(root, { spec, candidates, nothingRecognized = false }) {
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'overlay red'; overlay.className = 'overlay red';
overlay.setAttribute('role', 'dialog'); overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'ambiguous-dialog-heading'); overlay.setAttribute('aria-labelledby', 'ambiguous-dialog-heading');
const heading = nothingRecognized ? 'Nichts erkannt' : 'Nicht eindeutig';
const hint = nothingRecognized
? 'Etikett näher heranhalten, auf gleichmäßiges Licht achten und Spiegelungen vermeiden. Sonst hier den Stapel selbst wählen.'
: 'Mehrere Stapel passen zu diesem Modul bitte auswählen.';
overlay.innerHTML = ` overlay.innerHTML = `
<div class="stack-id" id="ambiguous-dialog-heading" style="font-size:34px">Nicht eindeutig</div> <div class="stack-id" id="ambiguous-dialog-heading" style="font-size:34px">${heading}</div>
<div class="detail" id="hint"></div>
<div class="detail">Gelesen: <span id="read"></span></div> <div class="detail">Gelesen: <span id="read"></span></div>
<div class="choices" id="choices"></div> <div class="choices" id="choices"></div>
`; `;
// Der Hinweistext ist fest vorgegeben (kein Fremdinhalt), textContent genuegt trotzdem als sichere Wahl.
overlay.querySelector('#hint').textContent = hint;
// Der gelesene Wert stammt aus dem Etikett (Fremdeingabe) - als Text setzen, nicht als Auszeichnung. // Der gelesene Wert stammt aus dem Etikett (Fremdeingabe) - als Text setzen, nicht als Auszeichnung.
overlay.querySelector('#read').textContent = describeReading(spec); overlay.querySelector('#read').textContent = describeReading(spec);
+99
View File
@@ -0,0 +1,99 @@
import { SCAN_MODES } from '../scan-modes.js';
/**
* Kurzer Hinweis je Funktion, direkt aus ihren Merkmalen abgeleitet (siehe
* scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion.
*/
function describeMode(mode) {
// "Text erkennen" und "Zählen" laufen beide auf Knopfdruck (siehe
// scan-modes.js) - `continuousSearch: false` ist bei beiden das
// gemeinsame Merkmal dafuer, eine Fallunterscheidung je Funktion braucht
// es hier nicht.
if (!mode.continuousSearch) return 'Auf Knopfdruck';
return 'Läuft von selbst, sobald ein Code im Bild ist';
}
/**
* Vollbild-Auswahl der Scan-Funktion: drei grosse Flaechen, eine je Funktion
* aus scan-modes.js (einzige Quelle fuer Kennung und Beschriftung). Gebaut
* nach demselben Muster wie die anderen Vollbild-Dialoge (ambiguous-dialog.js,
* resume-dialog.js): Geschwister waehrend der Anzeige per inert unerreichbar,
* Fokus kehrt danach zurueck. Reine Darstellung - kennt weder Kamera noch
* Erkennung.
*
* Wird sowohl vor dem ersten Start der Kamera verwendet als auch fuer den
* Wechsel aus der laufenden Scan-Ansicht heraus (siehe main.js).
*
* @param {HTMLElement} root
* @param {{current?: string}} [args] Kennung der aktuell gewaehlten Funktion,
* falls es sich um einen Wechsel handelt - hebt die passende Flaeche hervor.
* @returns {Promise<string>} Kennung der gewaehlten Funktion (siehe scan-modes.js).
*/
export function askForScanMode(root, { current } = {}) {
const overlay = document.createElement('div');
overlay.className = 'overlay';
overlay.style.background = 'var(--bg)';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'mode-dialog-heading');
overlay.innerHTML = `
<div class="stack-id" id="mode-dialog-heading" style="font-size:28px">Funktion wählen</div>
<div class="choices mode-choices" id="choices"></div>
`;
const choices = overlay.querySelector('#choices');
// Siehe ambiguous-dialog.js / resume-dialog.js: waehrend der Dialog offen
// ist, werden die Geschwisterelemente per inert unerreichbar gemacht und
// danach wieder freigegeben.
const previouslyFocused = document.activeElement;
const madeInert = [];
for (const sibling of root.children) {
if (!sibling.hasAttribute('inert')) {
sibling.setAttribute('inert', '');
madeInert.push(sibling);
}
}
root.appendChild(overlay);
return new Promise((resolve) => {
let settled = false;
const finish = (modeId) => {
if (settled) return;
settled = true;
overlay.remove();
for (const sibling of madeInert) {
sibling.removeAttribute('inert');
}
if (
previouslyFocused &&
typeof previouslyFocused.focus === 'function' &&
document.contains(previouslyFocused)
) {
previouslyFocused.focus();
} else {
root.focus?.();
}
resolve(modeId);
};
let firstButton = null;
for (const mode of SCAN_MODES) {
const button = document.createElement('button');
button.className = 'action mode-tile';
if (mode.id === current) button.classList.add('mode-tile-current');
button.setAttribute('aria-pressed', String(mode.id === current));
button.innerHTML = `
<span class="mode-tile-label">${mode.label}</span>
<span class="mode-tile-hint"></span>
`;
button.querySelector('.mode-tile-hint').textContent = describeMode(mode);
button.addEventListener('click', () => finish(mode.id));
choices.appendChild(button);
firstButton = firstButton ?? button;
}
firstButton?.focus();
});
}
+82 -14
View File
@@ -1,38 +1,106 @@
import { describeSpec } from './describe-spec.js'; import { describeSpec } from './describe-spec.js';
/** Haelt pro Root die aktuell sichtbare Rueckmeldung samt Zeitgeber fest. */ /** Haelt pro Root die aktuell sichtbare Rueckmeldung samt Abschlussfunktion fest. */
const currentFeedback = new WeakMap(); const currentFeedback = new WeakMap();
/** /**
* Blendet die Treffer-Rueckmeldung ein und nach kurzer Zeit wieder aus. * Blendet die Treffer-Rueckmeldung ein. Gruen bei Barcode, gelb bei OCR.
* Gruen bei Barcode, gelb bei OCR. Keine Eingabe noetig. *
* In den Barcode-Funktionen (autoHide: true, Voreinstellung) blendet sie sich
* nach kurzer Zeit von selbst wieder aus - dort zaehlt Tempo, keine Eingabe
* noetig. In Funktion 3 ("Text erkennen", autoHide: false) ist der erkannte
* Text potenziell ein ganzer Etikettentext und damit zu lang fuer eine knappe
* automatische Anzeige: die Rueckmeldung bleibt dort stehen, bis der Nutzer
* sie ueber die Schaltflaeche wegtippt, und zeigt zusaetzlich den vollen
* Rohtext (rawText). Lang werdender Text bleibt lesbar ueber dasselbe Muster,
* mit dem auch die anderen Vollbild-Overlays lange Inhalte handhaben: die
* `.overlay`-Klasse ist selbst scrollbar (overflow-y: auto), statt aus dem
* Bildschirm herauszuwachsen (siehe styles.css).
*
* Das zurueckgegebene Versprechen wird in jedem Fall genau einmal eingeloest -
* beim automatischen Ausblenden, beim Wegtippen und auch dann, wenn eine neue
* Rueckmeldung (z.B. durch einen weiteren Scan) diese hier verdraengt, bevor
* sie selbst eingeloest wurde. Die App sperrt waehrend der laufenden
* Erkennung die Bedienung (siehe processCapture() in main.js) - ein nie
* eingeloestes Versprechen wuerde sie dauerhaft lahmlegen.
*
* @param {HTMLElement} root
* @param {{stackId: string, spec: object, confidence: string, rawText?: string, autoHide?: boolean}} args
* @param {number} durationMs Anzeigedauer bei autoHide: true.
* @returns {Promise<void>}
*/ */
export function showResult(root, { stackId, spec, confidence }, durationMs = 1200) { export function showResult(
root,
{ stackId, spec, confidence, rawText = '', autoHide = true },
durationMs = 1200,
) {
// Eine noch offene vorherige Rueckmeldung wird sofort abgeschlossen (nicht
// nur entfernt) - ihr Versprechen darf nicht offen bleiben, nur weil eine
// neue Rueckmeldung sie verdraengt.
const previous = currentFeedback.get(root); const previous = currentFeedback.get(root);
if (previous) { if (previous) previous.finish();
clearTimeout(previous.timer);
previous.overlay.remove();
previous.resolve();
currentFeedback.delete(root);
}
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = `overlay ${confidence}`; overlay.className = `overlay ${confidence}`;
if (!autoHide) {
// Nur die wartende Variante ist eine echte Eingabeaufforderung - siehe
// ambiguous-dialog.js/mode-dialog.js fuer dasselbe Muster (role="dialog",
// aria-modal, Ueberschrift referenziert per aria-labelledby).
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-labelledby', 'result-overlay-heading');
}
overlay.innerHTML = ` overlay.innerHTML = `
<div class="stack-id">STAPEL ${stackId}</div> <div class="stack-id" id="result-overlay-heading">STAPEL ${stackId}</div>
<div class="detail"></div> <div class="detail"></div>
<div class="pn"></div> <div class="pn"></div>
<div class="raw-text" id="raw-text" hidden></div>
`; `;
overlay.querySelector('.detail').textContent = describeSpec(spec); overlay.querySelector('.detail').textContent = describeSpec(spec);
overlay.querySelector('.pn').textContent = spec.partNumber ?? ''; overlay.querySelector('.pn').textContent = spec.partNumber ?? '';
if (!autoHide) {
// Der vollstaendige erkannte Text - Fremdinhalt vom Etikett, deshalb als
// Text gesetzt, nicht als Auszeichnung (siehe ambiguous-dialog.js/#read).
const rawTextEl = overlay.querySelector('#raw-text');
rawTextEl.hidden = false;
rawTextEl.textContent = rawText;
}
root.appendChild(overlay); root.appendChild(overlay);
return new Promise((resolve) => { return new Promise((resolve) => {
const timer = setTimeout(() => { let settled = false;
let timer = null;
// Schuetzt vor Mehrfacheinloesung: gleichzeitig ablaufender Zeitgeber und
// Tippen auf "weiter", oder ein Abschluss durch eine verdraengende neue
// Rueckmeldung, nachdem der Zeitgeber schon gelaufen ist.
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
overlay.remove(); overlay.remove();
currentFeedback.delete(root); currentFeedback.delete(root);
resolve(); resolve();
}, durationMs); };
currentFeedback.set(root, { overlay, timer, resolve });
if (autoHide) {
timer = setTimeout(finish, durationMs);
} else {
// Bedienflaeche mindestens 56px hoch (siehe Vorgaben) - der einzige Weg,
// diese Rueckmeldung zu schliessen, solange sie nicht autoHide ist.
const dismissButton = document.createElement('button');
dismissButton.className = 'action';
dismissButton.style.minHeight = '56px';
dismissButton.textContent = 'weiter';
dismissButton.addEventListener('click', finish);
overlay.appendChild(dismissButton);
dismissButton.focus();
}
currentFeedback.set(root, { finish });
}); });
} }
+139 -3
View File
@@ -1,15 +1,23 @@
import { FRAME_INSET } from '../camera.js';
/** /**
* Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck. * Baut die Hauptansicht auf und liefert Aktualisierungsfunktionen zurueck.
* Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe. * Kennt weder Kamera noch Erkennung - alles kommt ueber die Rueckrufe.
*/ */
export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }) { export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile, onChangeMode }) {
root.innerHTML = ` root.innerHTML = `
<button class="mode-button" id="mode-button" type="button"></button>
<div class="camera"> <div class="camera">
<video id="preview" playsinline muted></video> <video id="preview" playsinline muted></video>
<div class="frame"></div> <div class="frame"></div>
<div class="count-display" id="count-display" hidden>
<button class="count-number" id="count-number" type="button" aria-live="polite"
aria-label="Messwerte zur Zaehlung ein- oder ausblenden"></button>
<div class="count-diagnostics" id="count-diagnostics" hidden aria-live="polite"></div>
</div>
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
<button class="action capture" id="capture">Modul scannen</button> <button class="action capture" id="capture" hidden></button>
<button class="action secondary" id="pick-file-button" hidden>Bild auswählen</button> <button class="action secondary" id="pick-file-button" hidden>Bild auswählen</button>
<div class="stacks" id="stacks"></div> <div class="stacks" id="stacks"></div>
<div class="last"> <div class="last">
@@ -24,9 +32,30 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
const statusEl = root.querySelector('#status'); const statusEl = root.querySelector('#status');
const fileEl = root.querySelector('#file'); const fileEl = root.querySelector('#file');
const pickFileButtonEl = root.querySelector('#pick-file-button'); const pickFileButtonEl = root.querySelector('#pick-file-button');
const frameEl = root.querySelector('.frame');
const captureEl = root.querySelector('#capture');
const modeButtonEl = root.querySelector('#mode-button');
const countDisplayEl = root.querySelector('#count-display');
const countNumberEl = root.querySelector('#count-number');
const countDiagnosticsEl = root.querySelector('#count-diagnostics');
root.querySelector('#capture').addEventListener('click', onCapture); // Ob die Diagnosezeile unter der grossen Zahl gerade eingeblendet ist -
// rein lokaler Anzeigezustand dieser Ansicht, siehe setCountVisible()
// (Zuruecksetzen beim Verlassen der Funktion "Zählen") und den Klick-
// Handler auf die grosse Zahl unten (Umschalten).
let countDiagnosticsVisible = false;
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
// (grabFrameRegion). Damit koennen beide nicht mehr auseinanderlaufen;
// das Stilblatt selbst legt keinen Abstand mehr fest.
frameEl.style.inset =
`${FRAME_INSET.top * 100}% ${FRAME_INSET.right * 100}% ` +
`${FRAME_INSET.bottom * 100}% ${FRAME_INSET.left * 100}%`;
captureEl.addEventListener('click', onCapture);
root.querySelector('#undo').addEventListener('click', onUndo); root.querySelector('#undo').addEventListener('click', onUndo);
modeButtonEl.addEventListener('click', onChangeMode);
// Klick auf die sichtbare Schaltflaeche ist selbst die Nutzergeste, die // Klick auf die sichtbare Schaltflaeche ist selbst die Nutzergeste, die
// die versteckte Dateiauswahl oeffnen darf - anders als ein Aufruf aus dem // die versteckte Dateiauswahl oeffnen darf - anders als ein Aufruf aus dem
// Code heraus, den Handy-Browser blockieren. // Code heraus, den Handy-Browser blockieren.
@@ -35,6 +64,16 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
if (fileEl.files[0]) onPickFile(fileEl.files[0]); if (fileEl.files[0]) onPickFile(fileEl.files[0]);
fileEl.value = ''; fileEl.value = '';
}); });
// Antippen der grossen Zahl blendet die Diagnosezeile ein, nochmaliges
// Antippen wieder aus (siehe Moduldoku zu setCount()/setCountDiagnostics()
// unten) - reiner Anzeigezustand dieser Ansicht, main.js muss davon
// nichts wissen. Die grosse Zahl selbst bleibt dabei an derselben Stelle
// stehen: die Diagnosezeile ist eigenstaendig positioniert (siehe
// styles.css), ihr Ein-/Ausblenden nimmt der Zahl nicht den Platz weg.
countNumberEl.addEventListener('click', () => {
countDiagnosticsVisible = !countDiagnosticsVisible;
countDiagnosticsEl.hidden = !countDiagnosticsVisible;
});
return { return {
video: root.querySelector('#preview'), video: root.querySelector('#preview'),
@@ -63,6 +102,43 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
lastEl.textContent = text; lastEl.textContent = text;
}, },
/**
* Zeigt die gewaehlte Scan-Funktion in der Ansicht an - antippbar, um zu
* wechseln (siehe onChangeMode). Beschriftung kommt vom Aufrufer, der sie
* seinerseits aus scan-modes.js liest; diese Ansicht kennt die Funktionen
* selbst nicht.
* @param {string} label
*/
setMode(label) {
modeButtonEl.textContent = `${label} ändern ⌄`;
modeButtonEl.setAttribute('aria-label', `Scan-Funktion wechseln (aktuell: ${label})`);
},
/**
* Diese eine Schaltflaeche bedient beide Knopfdruck-Funktionen ("Modul
* scannen" in Funktion 3 "Text erkennen", "Zählen"/"Nochmal zählen" in
* Funktion 4 "Zählen") - nur ihr Wortlaut und ihr Rueckruf unterscheiden
* sich, siehe setCaptureLabel() unten und main.js/applyMode(). In
* Funktionen mit laufender Codesuche sucht die App ohnehin durchgehend,
* ein Knopf dafuer waere wirkungslos.
* @param {boolean} visible
*/
setCaptureVisible(visible) {
captureEl.hidden = !visible;
},
/**
* Setzt den Wortlaut der Aufnahme-Schaltflaeche - siehe setCaptureVisible()
* oben fuer den Grund, warum es dafuer nur eine gemeinsame Flaeche statt
* einer zweiten daneben gibt. main.js setzt hier "Modul scannen" (Funktion
* "Text erkennen"), "Zählen" (Funktion "Zählen", vor der ersten Aufnahme
* seit Betreten der Funktion) bzw. "Nochmal zählen" (danach).
* @param {string} text
*/
setCaptureLabel(text) {
captureEl.textContent = text;
},
/** @param {string} text @param {boolean} warn */ /** @param {string} text @param {boolean} warn */
setStatus(text, warn = false) { setStatus(text, warn = false) {
statusEl.textContent = text; statusEl.textContent = text;
@@ -82,5 +158,65 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
setFilePickerVisible(visible) { setFilePickerVisible(visible) {
pickFileButtonEl.hidden = !visible; pickFileButtonEl.hidden = !visible;
}, },
/**
* Hebt den Zielrahmen hervor, solange die Dauersuche einen Barcode im
* Ausschnitt erkennt - schlichte Farbaenderung, keine Animation. Der
* Nutzer sieht damit beim Hinhalten sofort, ob es klappt.
* @param {boolean} detected
*/
setFrameDetected(detected) {
frameEl.classList.toggle('detected', detected);
},
/**
* Blendet die grosse Zaehlanzeige ein oder aus (nur in Funktion
* "Zählen" sichtbar, siehe applyMode() in main.js). Beim Ausblenden wird
* die zuletzt angezeigte Zahl geloescht, damit beim naechsten Wechsel in
* diese Funktion nicht kurz eine veraltete Zahl aufblitzt - ebenso die
* Diagnosezeile: sie faellt dabei wieder in ihren Ausgangszustand
* (verborgen, leer) zurueck, damit sie nach einem Funktionswechsel nicht
* ungefragt (weil sie beim letzten Mal offen war) oder mit veralteten
* Werten wieder auftaucht. Das gilt sowohl beim Verlassen der Funktion
* "Zählen" als auch beim erneuten Betreten - ein stehendes Ergebnis
* einer frueheren Aufnahme (andere Teile, andere Stelle) darf nicht
* hinueberwirken (siehe main.js/applyMode()).
* @param {boolean} visible
*/
setCountVisible(visible) {
countDisplayEl.hidden = !visible;
if (!visible) {
countNumberEl.textContent = '';
countDiagnosticsEl.textContent = '';
countDiagnosticsVisible = false;
countDiagnosticsEl.hidden = true;
}
},
/**
* Zeigt das Ergebnis der zuletzt angetippten Zaehl-Aufnahme (Median der
* fuenf Einzelmessungen dieser Aufnahme, siehe count-capture.js und
* runCountCapture() in main.js) - gross und aus Armlaenge lesbar. Bleibt
* unveraendert stehen, bis der Nutzer erneut auf die Schaltflaeche
* "Nochmal zählen" tippt (keine fortlaufende Aktualisierung mehr). Ein
* Antippen der Zahl selbst blendet die Diagnosezeile darunter ein oder
* aus (siehe Klick-Handler oben).
* @param {number} count
*/
setCount(count) {
countNumberEl.textContent = String(count);
},
/**
* Setzt den Text der kleinen Diagnosezeile unter der grossen Zahl (siehe
* main.js fuer den genauen Wortlaut, u. a. die fuenf Einzelmessungen der
* zuletzt angetippten Aufnahme) - unabhaengig davon, ob sie gerade
* eingeblendet ist. Reine Textaktualisierung, die Sichtbarkeit steuert
* ausschliesslich der Klick auf die grosse Zahl (siehe oben).
* @param {string} text
*/
setCountDiagnostics(text) {
countDiagnosticsEl.textContent = text;
},
}; };
} }
+16
View File
@@ -90,6 +90,22 @@ export function renderSessionList(root, session, { onMove, onRemove, onEndSessio
row.appendChild(removeButton); row.appendChild(removeButton);
body.appendChild(row); body.appendChild(row);
// entry.rawText ist additiv (siehe pipeline.js/recognize) und nur bei
// per Texterkennung erfassten Eintraegen nicht leer - zum Nachlesen des
// vollstaendigen Etikettentexts, unabhaengig von den daraus verstandenen
// Feldern oben in derselben Zeile.
if (entry.rawText) {
const rawTextRow = document.createElement('div');
rawTextRow.style.fontSize = '12px';
rawTextRow.style.opacity = '0.7';
rawTextRow.style.textAlign = 'left';
rawTextRow.style.whiteSpace = 'pre-wrap';
rawTextRow.style.overflowWrap = 'break-word';
rawTextRow.style.marginBottom = '6px';
rawTextRow.textContent = `Erkannter Text: ${entry.rawText}`;
body.appendChild(rawTextRow);
}
} }
} }
+60
View File
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { frameRect, FRAME_INSET } from '../src/camera.js';
// frameRect() ist eine reine Funktion (kein window/document noetig) - sie
// laesst sich direkt unter Node pruefen, obwohl camera.js an anderer Stelle
// Browser-APIs verwendet (die dort nur beim Aufruf, nicht beim Import
// ausgewertet werden).
test('glatte Standardaufloesung: Rahmenmasse exakt aus den Anteilen', () => {
const rect = frameRect(1920, 1080, FRAME_INSET);
assert.deepEqual(rect, { x: 154, y: 216, width: 1612, height: 648 });
});
test('krumme Bildgroesse: Ausschnitt bleibt vollstaendig innerhalb des Bildes', () => {
const rect = frameRect(1921, 1079, FRAME_INSET);
assert.deepEqual(rect, { x: 154, y: 216, width: 1613, height: 647 });
assert.ok(rect.x >= 0 && rect.y >= 0);
assert.ok(rect.x + rect.width <= 1921);
assert.ok(rect.y + rect.height <= 1079);
});
test('nicht-ganzzahliges Zwischenergebnis wird gerundet, nicht abgeschnitten', () => {
// 999 * 0.08 = 79.92 -> rundet auf 80, nicht auf 79.
const rect = frameRect(999, 333, FRAME_INSET);
assert.equal(rect.x, 80);
assert.equal(rect.width, 999 - 80 - 80);
// 333 * 0.2 = 66.6 -> rundet auf 67.
assert.equal(rect.y, 67);
assert.equal(rect.height, 333 - 67 - 67);
});
test('Breite/Hoehe werden als Differenz gebildet, nicht unabhaengig gerundet', () => {
// Wuerde man Breite unabhaengig aus dem Anteil runden, koennte sie um ein
// Pixel von "Gesamtbreite minus beide Raender" abweichen. Das darf nicht
// passieren - hier ueber viele krumme Groessen hinweg geprueft.
for (let width = 1; width <= 200; width++) {
for (const inset of [FRAME_INSET, { top: 0.2, right: 0.08, bottom: 0.2, left: 0.08 }]) {
const rect = frameRect(width, width, inset);
const left = Math.round(width * inset.left);
const right = Math.round(width * inset.right);
assert.equal(rect.width, width - left - right);
}
}
});
test('eigene Rahmenmasse werden statt der Voreinstellung verwendet', () => {
const rect = frameRect(1000, 1000, { top: 0.1, right: 0.1, bottom: 0.1, left: 0.1 });
assert.deepEqual(rect, { x: 100, y: 100, width: 800, height: 800 });
});
test('ueberlappende Raender ergeben nie eine negative Breite/Hoehe', () => {
const rect = frameRect(100, 100, { top: 0.6, bottom: 0.6, left: 0.6, right: 0.6 });
assert.equal(rect.width, 0);
assert.equal(rect.height, 0);
});
test('Standard-Rahmenmasse ohne Argument identisch zu FRAME_INSET', () => {
assert.deepEqual(frameRect(1280, 720), frameRect(1280, 720, FRAME_INSET));
});
+44
View File
@@ -0,0 +1,44 @@
// Reine Rechenlogik der Zaehl-Aufnahme (Funktion "Zählen", siehe main.js):
// aus den (bis zu fuenf) Einzelmessungen einer Aufnahme wird deren Median
// gebildet. Siehe count-capture.js fuer die Begruendung (robust gegen einen
// einzelnen Ausreisser, ohne wie eine fortlaufende Glaettung traege zu sein).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { combineCaptureCounts } from '../src/count-capture.js';
test('fuenf gleiche Messungen liefern diese Zahl', () => {
assert.equal(combineCaptureCounts([18, 18, 18, 18, 18]), 18);
});
test('fuenf verschiedene Messungen liefern deren Median (ungerade Anzahl)', () => {
assert.equal(combineCaptureCounts([17, 19, 18, 20, 18]), 18);
});
test('ein einzelner grober Ausreisser unter fuenf Messungen schlaegt nicht durch', () => {
// Vier stabile Messungen um 18 herum, eine grob daneben (z. B.
// Bewegungsunschaerfe oder Autofokus mitten in der Aufnahme) - der Median
// bleibt bei 18, ein Mittelwert waere durch den Ausreisser spuerbar verzerrt.
assert.equal(combineCaptureCounts([18, 18, 3, 18, 60]), 18);
});
test('weniger als fuenf Messungen (eine Kameraaufnahme scheiterte) liefern trotzdem ein Ergebnis', () => {
// Gerade Anzahl (vier statt fuenf) - Median ist der Mittelwert der beiden
// mittleren Werte.
const result = combineCaptureCounts([17, 18, 18, 19]);
assert.equal(result, 18);
});
test('nur eine einzige verwertbare Messung ist ihr eigener Median', () => {
assert.equal(combineCaptureCounts([21]), 21);
});
test('eine leere Messreihe wirft, statt stillschweigend ein sinnloses Ergebnis (NaN) zu liefern', () => {
assert.throws(() => combineCaptureCounts([]));
});
test('combineCaptureCounts veraendert die uebergebene Liste nicht (reine Funktion)', () => {
const counts = [5, 1, 3];
const untouched = [...counts];
combineCaptureCounts(counts);
assert.deepEqual(counts, untouched);
});
+57
View File
@@ -0,0 +1,57 @@
// Prueffall am echten Foto des Auftraggebers (siehe
// .superpowers/sdd/counting-rework-report.md): Otsu allein scheiterte hier,
// weil die Schrauben unter einem Prozent der Bildflaeche belegen - Otsu
// rutscht in so einem Fall auf den hoechsten Helligkeitswert und erklaert nur
// die hellsten Punkte der Ledernarbung zum Objekt. Die fruehere Fassung
// meldete an genau diesem Bild 76712 statt der rund zwanzig tatsaechlich
// ausgelegten Schrauben. Dieser Test schuetzt vor einem Rueckfall in dieses
// Verhalten.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countObjects } from '../src/count-objects.js';
import {
SCREWS_WIDTH,
SCREWS_HEIGHT,
SCREWS_GRAY_BASE64,
} from './fixtures/screws-photo.js';
/**
* Baut aus dem Base64-kodierten Graustufenraster ein ImageData-artiges
* Objekt: Graustufenwert auf R, G und B gelegt, Alpha 255 - wie eine echte
* Kameraaufnahme ohne Farbinformation.
*/
function grayscaleImage(width, height, base64) {
const gray = Buffer.from(base64, 'base64');
const data = new Uint8ClampedArray(width * height * 4);
for (let p = 0, i = 0; p < width * height; p += 1, i += 4) {
const value = gray[p];
data[i] = value;
data[i + 1] = value;
data[i + 2] = value;
data[i + 3] = 255;
}
return { width, height, data };
}
test('echtes Foto (~20 Festplattenschrauben auf Leder) ergibt eine Zahl zwischen 15 und 24', () => {
const imageData = grayscaleImage(SCREWS_WIDTH, SCREWS_HEIGHT, SCREWS_GRAY_BASE64);
const start = performance.now();
const result = countObjects(imageData);
const durationMs = performance.now() - start;
// Nur zur Beobachtung im Testlauf, keine Zusicherung ueber die Konsole -
// die Laufzeit selbst wird unten geprueft.
console.log(`countObjects(screws-photo) dauerte ${durationMs.toFixed(2)} ms`);
assert.ok(
result.count >= 15 && result.count <= 24,
`erwartet 15..24, erhalten ${result.count} (fruehere Fassung meldete 76712)`,
);
// Soll mehrmals je Sekunde laufen koennen (laufende Anzeige waehrend die
// Kamera laeuft) - grosszuegige Grenze, die auf langsamerer Hardware nicht
// faelschlich anschlaegt, aber ein grundlegend zu langsames Verfahren
// (z. B. eine versehentlich quadratische Rechnung) auffangen wuerde.
assert.ok(durationMs < 500, `erwartet unter 500 ms, gemessen ${durationMs.toFixed(2)} ms`);
});
+240
View File
@@ -0,0 +1,240 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countObjects } from '../src/count-objects.js';
/**
* Baut ein ImageData-artiges Objekt aus einem Raster von Graustufenwerten
* (0-255 je Zelle) - derselbe minimale Ersatz wie in ocr-preprocess.test.js,
* hier zweidimensional, weil countObjects() auf zusammenhaengende Flaechen
* angewiesen ist.
* @param {number[][]} rows gleich lange Zeilen von Graustufenwerten
*/
function image(rows) {
const height = rows.length;
const width = height > 0 ? rows[0].length : 0;
const data = new Uint8ClampedArray(width * height * 4);
let i = 0;
for (const row of rows) {
for (const value of row) {
data[i] = value;
data[i + 1] = value;
data[i + 2] = value;
data[i + 3] = 255;
i += 4;
}
}
return { width, height, data };
}
/** Erzeugt ein width x height grosses Raster, komplett mit `background` gefuellt. */
function grid(width, height, background) {
return Array.from({ length: height }, () => new Array(width).fill(background));
}
/** Malt ein width x height grosses Quadrat/Rechteck an Position (x, y) mit `value`. */
function paint(rows, x, y, width, height, value) {
for (let row = y; row < y + height; row += 1) {
for (let col = x; col < x + width; col += 1) {
rows[row][col] = value;
}
}
}
// Das neue Verfahren (siehe count-objects.js) braucht oertlichen Kontrast:
// jeder Bildpunkt wird gegen den Mittelwert seiner Umgebung verglichen
// (Fensterradius max(Breite, Hoehe) / 12). Damit dieses Fenster an jeder
// Objektstelle noch echten Untergrund sieht (statt nur das Objekt selbst
// nochmal zu mitteln), muss der Untergrund eine deutlich groessere Flaeche
// einnehmen als jedes einzelne Fundstueck - anders als beim alten,
// Otsu-basierten Verfahren, wo ein 40x40- oder 80x80-Bild mit 10x10-
// Quadraten genuegte. Die Testbilder hier sind deshalb groesser (300x300
// bzw. 600x600) im Verhaeltnis zu den gemalten Flaechen.
test('leeres Bild (kein Objekt, gleichmaessiger Untergrund) ergibt null', () => {
const rows = grid(300, 300, 60);
const result = countObjects(image(rows));
assert.equal(result.count, 0);
});
test('eine einzelne Flaeche ergibt eins', () => {
const rows = grid(300, 300, 30);
paint(rows, 140, 140, 20, 20, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 1);
});
test('mehrere getrennte, gleich grosse Flaechen ergeben ihre Anzahl', () => {
const rows = grid(300, 300, 30);
paint(rows, 40, 40, 20, 20, 220);
paint(rows, 200, 40, 20, 20, 220);
paint(rows, 40, 200, 20, 20, 220);
paint(rows, 200, 200, 20, 20, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 4);
});
test('winzige Stoerflaechen (Staub, Reflexe) werden nicht mitgezaehlt', () => {
const rows = grid(300, 300, 30);
paint(rows, 40, 40, 20, 20, 220);
paint(rows, 200, 40, 20, 20, 220);
paint(rows, 40, 200, 20, 20, 220);
paint(rows, 200, 200, 20, 20, 220);
// Einzelpixel- bis Doppelpixel-Stoerflaechen, weit entfernt von den
// echten Flaechen und voneinander - weit unter der Rauschschwelle
// (max(8, 0.00002 * Bildpunktzahl), hier 8 Bildpunkte).
rows[150][150] = 220;
rows[160][160] = 220;
rows[160][161] = 220;
const result = countObjects(image(rows));
assert.equal(result.count, 4, 'Stoerflaechen duerfen die Anzahl nicht erhoehen');
});
test('eine Flaeche von etwa vierfacher Einzelgroesse wird als vier gezaehlt', () => {
// Groessere Quadrate und ein groesseres Bild als beim Rauschtest: die
// Glaettung (Schritt 3) dehnt jede Flaeche an ihrem Rand etwas auf - bei
// sehr kleinen Quadraten faellt diese Randzugabe relativ zur Flaeche viel
// staerker ins Gewicht als bei groesseren und wuerde das exakte
// Vierfache verzerren.
const rows = grid(600, 600, 30);
paint(rows, 60, 60, 40, 40, 220); // typische Groesse
paint(rows, 400, 60, 40, 40, 220);
paint(rows, 60, 400, 40, 40, 220);
// Verschmolzene Flaeche mehrerer beruehrender Teile: 80x80 = 4x 40x40.
paint(rows, 400, 400, 80, 80, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 7, '3 Einzelflaechen (je 1) + hochgerechnete Flaeche (4)');
});
test('ein einzelnes, nur leicht groesseres Teil wird nicht als zwei gezaehlt', () => {
const rows = grid(600, 600, 30);
paint(rows, 60, 60, 40, 40, 220);
paint(rows, 400, 60, 40, 40, 220);
paint(rows, 60, 400, 40, 40, 220);
// Rund 30% groesser als die anderen (44x48=2112 statt 1600) - haeufiger
// Fall, wenn ein Teil im Bild etwas naeher an der Kamera liegt.
paint(rows, 400, 400, 44, 48, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 4, 'leicht groessere Einzelflaeche zaehlt weiterhin als eins');
});
test('dunkle Objekte auf hellem Grund werden genauso gezaehlt wie helle auf dunklem', () => {
const darkOnLight = grid(300, 300, 220);
paint(darkOnLight, 40, 40, 20, 20, 30);
paint(darkOnLight, 200, 40, 20, 20, 30);
paint(darkOnLight, 40, 200, 20, 20, 30);
paint(darkOnLight, 200, 200, 20, 20, 30);
const lightOnDark = grid(300, 300, 30);
paint(lightOnDark, 40, 40, 20, 20, 220);
paint(lightOnDark, 200, 40, 20, 20, 220);
paint(lightOnDark, 40, 200, 20, 20, 220);
paint(lightOnDark, 200, 200, 20, 20, 220);
const darkResult = countObjects(image(darkOnLight));
const lightResult = countObjects(image(lightOnDark));
assert.equal(darkResult.count, 4);
assert.equal(lightResult.count, 4);
});
test('ein Bild ohne Bildpunkte stuerzt nicht ab', () => {
assert.doesNotThrow(() => {
const result = countObjects({ width: 0, height: 0, data: new Uint8ClampedArray(0) });
assert.equal(result.count, 0);
});
});
test('zwei nahe, aber nur diagonal benachbarte Flaechen zaehlen getrennt (4er-Nachbarschaft)', () => {
const rows = grid(300, 300, 30);
// Zwei 15x15-Quadrate an ihrer naechsten Ecke durch eine 3 Bildpunkte
// breite Luecke getrennt - nah genug, um wie ein einziges, diagonal
// ausgerichtetes Fundstueckpaar auszusehen, aber tatsaechlich getrennt.
// Die Luecke ist bewusst breiter als beim alten, Otsu-basierten Test
// (dort genuegte ein einzelnes gemeinsames Eckpixel): Die Glaettung
// (Schritt 3) dehnt jede Flaeche an ihrem Rand um ein bis zwei Bildpunkte
// auf, ein einzelnes gemeinsames Eckpixel wuerde die beiden Flaechen nach
// der Glaettung faelschlich zu einer verschmelzen lassen.
paint(rows, 130, 130, 15, 15, 220);
paint(rows, 148, 148, 15, 15, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 2, 'nur diagonal benachbarte Flaechen bleiben getrennt');
});
// --- Diagnosewerte (siehe Modul-Doku von countObjects()) ----------------
//
// Die Diagnoseanzeige (ui/scan-view.js, verdrahtet in main.js) liest diese
// Zwischenwerte vom Bildschirm ab, wenn beim Nutzer die Zaehlung schwankt -
// sie muessen deshalb auch dann sinnvoll belegt sein, wenn nichts gefunden
// wurde (count === 0), sonst zeigt die Anzeige in genau dem Fall nichts, in
// dem sie am dringendsten gebraucht wird.
test('ein Bild ohne Bildpunkte belegt alle Diagnosewerte mit 0/null', () => {
const result = countObjects({ width: 0, height: 0, data: new Uint8ClampedArray(0) });
assert.equal(result.count, 0);
assert.equal(result.width, 0);
assert.equal(result.height, 0);
assert.equal(result.regionsFound, 0);
assert.equal(result.regionsKept, 0);
assert.equal(result.largestArea, 0);
assert.equal(result.typicalArea, 0);
assert.equal(result.polarity, null);
});
test('leeres Bild (kein Objekt) belegt die Diagnosewerte trotzdem sinnvoll', () => {
const rows = grid(300, 300, 60);
const result = countObjects(image(rows));
assert.equal(result.count, 0);
// Kleiner als TARGET_WIDTH (800) - bleibt bei der Normierung unveraendert.
assert.equal(result.width, 300);
assert.equal(result.height, 300);
assert.equal(result.regionsFound, 0);
assert.equal(result.regionsKept, 0);
assert.equal(result.largestArea, 0);
assert.equal(result.typicalArea, 0);
// Keine Flaeche gefunden - trotzdem muss eine der beiden Polaritaeten als
// Diagnosewert benannt sein, nicht null (das bleibt dem Fall "gar kein
// Bildpunkt" vorbehalten, siehe Test oben).
assert.ok(result.polarity === 'hell' || result.polarity === 'dunkel');
});
test('eine einzelne Flaeche liefert plausible Diagnosewerte und die richtige Polaritaet', () => {
const rows = grid(300, 300, 30);
paint(rows, 140, 140, 20, 20, 220); // heller als der Untergrund
const result = countObjects(image(rows));
assert.equal(result.count, 1);
assert.equal(result.width, 300);
assert.equal(result.height, 300);
assert.equal(result.polarity, 'hell', 'helle Flaeche auf dunklerem Grund');
assert.equal(result.regionsFound, 1);
assert.equal(result.regionsKept, 1);
assert.ok(result.largestArea > 0);
// Bei genau einer signifikanten Flaeche ist der Median (die typische
// Groesse) exakt diese eine Flaeche.
assert.equal(result.typicalArea, result.largestArea);
});
test('dunkle Flaeche auf hellem Grund meldet die Polaritaet "dunkel"', () => {
const rows = grid(300, 300, 220);
paint(rows, 140, 140, 20, 20, 30);
const result = countObjects(image(rows));
assert.equal(result.count, 1);
assert.equal(result.polarity, 'dunkel');
});
test('regionsKept zaehlt Flaechen, nicht die hochgerechneten Fundstuecke', () => {
// Wie der Test "eine Flaeche von etwa vierfacher Einzelgroesse wird als
// vier gezaehlt" oben: drei Einzelflaechen + eine verschmolzene Flaeche
// vierfacher Groesse. count rechnet die verschmolzene Flaeche auf vier
// Fundstuecke hoch (macht 7 insgesamt) - regionsKept zaehlt aber die
// tatsaechlich gefundenen, zusammenhaengenden Flaechen (4), nicht die
// hochgerechneten Fundstuecke.
const rows = grid(600, 600, 30);
paint(rows, 60, 60, 40, 40, 220);
paint(rows, 400, 60, 40, 40, 220);
paint(rows, 60, 400, 40, 40, 220);
paint(rows, 400, 400, 80, 80, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 7);
assert.equal(result.regionsFound, 4);
assert.equal(result.regionsKept, 4);
assert.ok(result.largestArea > result.typicalArea, 'die verschmolzene Flaeche ist die groesste');
});
+10
View File
File diff suppressed because one or more lines are too long
+91
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { recognize } from '../src/pipeline.js'; import { recognize } from '../src/pipeline.js';
import { emptySpec } from '../src/spec.js'; import { emptySpec } from '../src/spec.js';
import { createSession, proposeAssignment, commitAssignment } from '../src/session.js';
const OCR_TEXT = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908'; const OCR_TEXT = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908';
@@ -281,3 +282,93 @@ test('ohne isKnownCode-Abhaengigkeit bleibt die Pipeline ohne sie lauffaehig (Te
assert.equal(ocrCalled, true); assert.equal(ocrCalled, true);
assert.equal(result.confidence, 'green'); assert.equal(result.confidence, 'green');
}); });
// --- Erweiterung: Rohtext als additives Feld (Funktion "Text erkennen") ---
//
// Der Rohtext ist reine Anzeige-Information fuer den Nutzer - er verlaesst
// die Erkennung, wird aber nirgends zur Stapelzuordnung herangezogen (siehe
// den letzten Test unten, der recognize() mit session.js zusammenspielen
// laesst).
test('recognize liefert den Rohtext, wenn die Texterkennung gelaufen ist (gelbes Ergebnis)', async () => {
const result = await recognize({}, deps({ codes: [], text: OCR_TEXT }));
assert.equal(result.source, 'ocr');
assert.equal(result.rawText, OCR_TEXT);
});
test('recognize liefert den Rohtext auch, wenn die Texterkennung lief, aber nichts Verwertbares fand (rot)', async () => {
const result = await recognize({}, deps({ codes: [], text: 'Made in Philippines' }));
assert.equal(result.confidence, 'red');
assert.equal(result.rawText, 'Made in Philippines');
});
test('recognize liefert den Rohtext, wenn OCR keine Kapazitaet findet, aber eine unverwertbare Barcode-Teilenummer bestaetigt (gruen)', async () => {
// '7325773' hat kein bekanntes Nummernschema (decodePartNumber liefert
// keine Kapazitaet) - die Texterkennung laeuft trotzdem und liefert Text
// ohne jede Kapazitaetsangabe. Die Teilenummer aus dem Barcode traegt
// (siehe recognize()), das Ergebnis bleibt gruen - der gelaufene Rohtext
// wird trotzdem zurueckgegeben.
const result = await recognize({}, {
decodeBarcodes: async () => ['7325773'],
runOcr: async () => 'Made in Philippines',
});
assert.equal(result.source, 'barcode');
assert.equal(result.confidence, 'green');
assert.equal(result.spec.partNumber, '7325773');
assert.equal(result.rawText, 'Made in Philippines');
});
test('recognize liefert eine leere Zeichenkette als Rohtext, wenn die Texterkennung nicht lief (gruener Barcode-Treffer)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
});
assert.equal(result.source, 'barcode');
assert.equal(result.rawText, '');
});
test('recognize liefert eine leere Zeichenkette als Rohtext, wenn ein bekannter Code die Texterkennung ueberfluessig macht', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['HMA84GL7AFR4N-UH'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
isKnownCode: (code) => code === 'HMA84GL7AFR4N-UH',
});
assert.equal(result.rawText, '');
});
test('recognize liefert eine leere Zeichenkette als Rohtext bei mehrdeutigen verwertbaren Barcodes (rot, ohne OCR)', async () => {
const result = await recognize({}, {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y', 'M386A8K40BM1-CWE4Y'],
runOcr: async () => { throw new Error('darf nicht aufgerufen werden'); },
});
assert.equal(result.confidence, 'red');
assert.equal(result.rawText, '');
});
test('der Rohtext beeinflusst die Stapelzuordnung nicht: zwei Aufnahmen mit unterschiedlichem Rohtext, aber gleichen verstandenen Feldern, landen auf demselben Stapel', async () => {
const firstText = '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908';
// Anderer Rohtext (andere Reihenfolge, zusaetzliches "Made in Korea",
// andere Gross-/Kleinschreibung) - dieselben verstandenen Felder.
const secondText = 'made in korea m386a8k40bm1-crc4y 64gb 4drx4 PC4-2400 1908';
const first = await recognize({}, deps({ codes: [], text: firstText }));
const second = await recognize({}, deps({ codes: [], text: secondText }));
assert.notEqual(first.rawText, second.rawText, 'Vorbedingung: die Rohtexte unterscheiden sich tatsaechlich');
assert.deepEqual(first.spec, second.spec, 'Vorbedingung: dieselben verstandenen Felder');
const session = createSession();
const plan1 = proposeAssignment(session, first.spec, first.codes);
const entry1 = commitAssignment(session, first.spec, first.source, plan1.stackId, first.codes);
// Der Rohtext haengt (wie main.js es tut) rein additiv am Eintrag, ohne
// dass session.js davon weiss - siehe main.js/processCapture().
entry1.rawText = first.rawText;
const plan2 = proposeAssignment(session, second.spec, second.codes);
assert.equal(plan2.kind, 'match', 'das zweite Modul muss denselben Stapel treffen');
const entry2 = commitAssignment(session, second.spec, second.source, plan2.stackId, second.codes);
entry2.rawText = second.rawText;
assert.equal(entry1.stackId, entry2.stackId, 'unterschiedlicher Rohtext darf keinen eigenen Stapel erzeugen');
assert.notEqual(entry1.rawText, entry2.rawText, 'die Eintraege fuehren trotzdem je ihren eigenen Rohtext');
});
+56
View File
@@ -0,0 +1,56 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SCAN_MODES, getScanMode } from '../src/scan-modes.js';
test('genau vier Funktionen in fester Reihenfolge', () => {
assert.equal(SCAN_MODES.length, 4);
assert.deepEqual(SCAN_MODES.map((mode) => mode.id), ['barcode', 'qrcode', 'ocr', 'count']);
});
test('Funktion 1 (Strichcode): eindimensionale Codearten, laufende Suche, keine Texterkennung', () => {
const mode = getScanMode('barcode');
assert.equal(mode.label, 'Strichcode');
assert.deepEqual(mode.barcodeFormats, [
'Code128', 'Code39', 'Code93', 'ITF', 'EAN13', 'EAN8', 'UPCA', 'UPCE', 'Codabar',
]);
assert.equal(mode.continuousSearch, true);
assert.equal(mode.useOcr, false);
});
test('Funktion 2 (QR-Code): QR-Varianten und DataMatrix, Aztec, PDF417', () => {
const mode = getScanMode('qrcode');
assert.equal(mode.label, 'QR-Code');
assert.deepEqual(mode.barcodeFormats, [
'QRCode', 'MicroQRCode', 'RMQRCode', 'DataMatrix', 'Aztec', 'PDF417',
]);
assert.ok(mode.barcodeFormats.includes('DataMatrix'), 'Funktion 2 muss DataMatrix lesen (Etiketten der Ware tragen DataMatrix)');
assert.equal(mode.continuousSearch, true);
assert.equal(mode.useOcr, false);
});
test('die vier Funktionen haben paarweise verschiedene Kennungen und Beschriftungen', () => {
const ids = SCAN_MODES.map((mode) => mode.id);
const labels = SCAN_MODES.map((mode) => mode.label);
assert.equal(new Set(ids).size, ids.length, 'Kennungen muessen paarweise verschieden sein');
assert.equal(new Set(labels).size, labels.length, 'Beschriftungen muessen paarweise verschieden sein');
});
test('Funktion 3 (Text erkennen): keine Codearten, keine laufende Suche, Texterkennung', () => {
const mode = getScanMode('ocr');
assert.equal(mode.label, 'Text erkennen');
assert.deepEqual(mode.barcodeFormats, []);
assert.equal(mode.continuousSearch, false);
assert.equal(mode.useOcr, true);
});
test('Funktion 4 (Zählen): keine Codearten, keine laufende Codesuche, keine Texterkennung - auf Knopfdruck wie Funktion 3', () => {
const mode = getScanMode('count');
assert.equal(mode.label, 'Zählen');
assert.deepEqual(mode.barcodeFormats, []);
assert.equal(mode.continuousSearch, false);
assert.equal(mode.useOcr, false);
});
test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => {
assert.throws(() => getScanMode('unbekannt'));
});
+92
View File
@@ -0,0 +1,92 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildRecognitionAdapters } from '../src/scan-recognition.js';
import { recognize } from '../src/pipeline.js';
test('Funktion 1: Texterkennung wird nicht aufgerufen, auch wenn kein Code gefunden wird', async () => {
let ocrCalled = false;
const adapters = buildRecognitionAdapters('barcode', {
decodeBarcodes: async () => [],
runOcr: async () => { ocrCalled = true; return 'irgendein Text'; },
});
const result = await recognize({}, adapters);
assert.equal(ocrCalled, false, 'runOcr darf in Funktion 1 nie aufgerufen werden');
assert.equal(result.confidence, 'red');
});
test('Funktion 2: Texterkennung wird nicht aufgerufen, auch wenn kein Code gefunden wird', async () => {
let ocrCalled = false;
const adapters = buildRecognitionAdapters('qrcode', {
decodeBarcodes: async () => [],
runOcr: async () => { ocrCalled = true; return 'irgendein Text'; },
});
const result = await recognize({}, adapters);
assert.equal(ocrCalled, false, 'runOcr darf in Funktion 2 nie aufgerufen werden');
assert.equal(result.confidence, 'red');
});
test('Funktion 3: Barcode-Dekodierung wird nicht aufgerufen', async () => {
let barcodeCalled = false;
const adapters = buildRecognitionAdapters('ocr', {
decodeBarcodes: async () => { barcodeCalled = true; return []; },
runOcr: async () => '64GB 4DRx4 PC4-2400T-LD1-11-MC0 M386A8K40BM1-CRC4Y 1908',
});
const result = await recognize({}, adapters);
assert.equal(barcodeCalled, false, 'decodeBarcodes darf in Funktion 3 nie aufgerufen werden');
assert.equal(result.source, 'ocr');
assert.equal(result.confidence, 'yellow');
});
test('Funktion 1: ein gelesener Strichcode fuehrt zu Gruen', async () => {
const adapters = buildRecognitionAdapters('barcode', {
decodeBarcodes: async () => ['M386A8K40BM1-CRC4Y'],
runOcr: async () => '',
});
const result = await recognize({}, adapters);
assert.equal(result.source, 'barcode');
assert.equal(result.confidence, 'green');
});
test('Funktion 3: kein brauchbarer Text fuehrt zu Rot, nicht zu Gruen', async () => {
const adapters = buildRecognitionAdapters('ocr', {
decodeBarcodes: async () => { throw new Error('darf nicht aufgerufen werden'); },
runOcr: async () => 'Made in Philippines',
});
const result = await recognize({}, adapters);
assert.equal(result.source, 'none');
assert.equal(result.confidence, 'red');
});
test('decodeBarcodes bekommt die Codearten der jeweiligen Funktion uebergeben', async () => {
let receivedFormats = null;
const adapters = buildRecognitionAdapters('qrcode', {
decodeBarcodes: async (_imageData, formats) => { receivedFormats = formats; return []; },
runOcr: async () => '',
});
await adapters.decodeBarcodes({});
assert.deepEqual(receivedFormats, ['QRCode', 'MicroQRCode', 'RMQRCode', 'DataMatrix', 'Aztec', 'PDF417']);
});
test('Funktion 4 (Zaehlen): weder Barcode-Dekodierung noch Texterkennung werden aufgerufen', async () => {
let barcodeCalled = false;
let ocrCalled = false;
const adapters = buildRecognitionAdapters('count', {
decodeBarcodes: async () => { barcodeCalled = true; return []; },
runOcr: async () => { ocrCalled = true; return 'irgendein Text'; },
});
const result = await recognize({}, adapters);
assert.equal(barcodeCalled, false, 'decodeBarcodes darf in Funktion 4 nie aufgerufen werden');
assert.equal(ocrCalled, false, 'runOcr darf in Funktion 4 nie aufgerufen werden');
assert.equal(result.confidence, 'red');
});
test('Funktion 3 uebergibt keine Codearten, weil decodeBarcodes gar nicht aufgerufen wird', async () => {
let called = false;
const adapters = buildRecognitionAdapters('ocr', {
decodeBarcodes: async () => { called = true; return []; },
runOcr: async () => '',
});
const codes = await adapters.decodeBarcodes({});
assert.deepEqual(codes, []);
assert.equal(called, false);
});
+36
View File
@@ -308,3 +308,39 @@ test('Eintrag mit gebrochener entryId liefert null', () => {
})); }));
assert.equal(loadSession(store), null); assert.equal(loadSession(store), null);
}); });
// --- Erweiterung: der OCR-Rohtext eines Eintrags (entry.rawText) ---
test('Rohtext eines Eintrags ueberlebt Sichern und Laden', () => {
const store = fakeStore();
const session = createSession();
const entry = commitAssignment(session, { ...emptySpec(), capacityGb: 32 }, 'ocr', 'A');
entry.rawText = '32GB 2Rx4 PC4-2666 Vollständiges Etikett Made in Korea';
saveSession(session, store);
const reloaded = loadSession(store);
assert.notEqual(reloaded, null);
assert.equal(reloaded.entries[0].rawText, '32GB 2Rx4 PC4-2666 Vollständiges Etikett Made in Korea');
});
test('ein Stand ohne rawText-Feld (aeltere Fassung) wird nicht verworfen, der Eintrag gilt als ohne Rohtext', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 1, spec: { ...emptySpec(), capacityGb: 64 } }],
entries: [{ entryId: 1, stackId: 'A', spec: { ...emptySpec(), capacityGb: 64 }, source: 'barcode' }],
nextEntryId: 2,
}));
const reloaded = loadSession(store);
assert.notEqual(reloaded, null, 'ein Stand ohne rawText-Feld darf nicht verworfen werden');
assert.equal(reloaded.entries[0].rawText, '');
});
test('Eintrag mit rawText als Nicht-Zeichenkette liefert null', () => {
const store = fakeStore();
store.setItem('ram-sortierhilfe:session', JSON.stringify({
stacks: [{ id: 'A', count: 1, spec: emptySpec() }],
entries: [{ entryId: 1, stackId: 'A', spec: emptySpec(), source: 'barcode', rawText: 123 }],
nextEntryId: 2,
}));
assert.equal(loadSession(store), null);
});