Compare commits
27
Commits
b679df6fd3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6688efc546 | ||
|
|
4b6bb065b1 | ||
|
|
d84c15e0d0 | ||
|
|
f0705a010c | ||
|
|
089d47a1ec | ||
|
|
75c479860f | ||
|
|
ffb2409c24 | ||
|
|
8c761c6fb5 | ||
|
|
5e64d260c5 | ||
|
|
500f9b6c3d | ||
|
|
87ba439287 | ||
|
|
b903b1c7aa | ||
|
|
1c10c68053 | ||
|
|
54b1447f21 | ||
|
|
3907204209 | ||
|
|
2d7778cea8 | ||
|
|
ebff173443 | ||
|
|
2e619ed64d | ||
|
|
2eb9d1ecf8 | ||
|
|
9ef038ca98 | ||
|
|
5a055923fe | ||
|
|
b94639ccd1 | ||
|
|
abb70e8b23 | ||
|
|
44526e7d3b | ||
|
|
69dbeeb062 | ||
|
|
8858ab2f88 | ||
|
|
d3be44e5d3 |
@@ -0,0 +1,3 @@
|
|||||||
|
# Passwort fuer den Zugang zur Seite. Der Server startet nicht ohne diesen Wert.
|
||||||
|
# In den VCH-Einstellungen setzen, nicht hier eintragen.
|
||||||
|
APP_PASSWORD=beispiel-passwort
|
||||||
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
runtime: systemd
|
||||||
|
|
||||||
|
# Vite-Projekt: erst bauen, das Ergebnis landet in dist/
|
||||||
|
build: "npm ci && npm run build"
|
||||||
|
|
||||||
|
# Passwortgeschuetzte Auslieferung von dist/ auf dem von VCH zugewiesenen Port.
|
||||||
|
# Das Passwort kommt aus der Umgebungsvariable APP_PASSWORD; fehlt sie, startet
|
||||||
|
# der Server absichtlich nicht. Kein Server-Code darueber hinaus noetig:
|
||||||
|
# Erkennung, Sortierung und Sitzung laufen vollstaendig im Browser.
|
||||||
|
start: "python3 server.py"
|
||||||
|
|
||||||
|
# Muss ohne Passwort erreichbar sein, sonst haelt die Ueberwachung die
|
||||||
|
# geschuetzte Anwendung faelschlich fuer ausgefallen.
|
||||||
|
health: "/healthz"
|
||||||
@@ -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 1–3 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,17 +294,35 @@ 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
|
||||||
|
|
||||||
|
Zum lokalen Nachschauen ohne Passwort:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
npm run preview
|
npm run preview
|
||||||
@@ -152,11 +331,44 @@ npm run preview
|
|||||||
`preview` hört auf `PORT` aus der Umgebung (Vorgabe `4173`, falls `PORT`
|
`preview` hört auf `PORT` aus der Umgebung (Vorgabe `4173`, falls `PORT`
|
||||||
nicht gesetzt ist).
|
nicht gesetzt ist).
|
||||||
|
|
||||||
|
Im Betrieb läuft stattdessen `server.py` — siehe nächster Abschnitt.
|
||||||
|
|
||||||
|
## Passwortschutz
|
||||||
|
|
||||||
|
Die veröffentlichte Seite ist passwortgeschützt. Geprüft wird **im Server**,
|
||||||
|
nicht im Browser: Ohne gültiges Passwort gibt `server.py` nichts heraus —
|
||||||
|
weder die Seite noch das gebündelte Skript. Ein Passwortschutz allein im
|
||||||
|
Browser wäre wirkungslos, weil das Passwort dann im ausgelieferten Skript
|
||||||
|
stünde und dort mitgelesen werden könnte.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_PASSWORD=dein-passwort python3 server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- Das Passwort kommt ausschließlich aus der Umgebungsvariable `APP_PASSWORD`
|
||||||
|
(siehe `.env.example`). Es steht **nicht** im Quelltext.
|
||||||
|
- **Fehlt die Variable, startet der Server nicht** und meldet das im Klartext.
|
||||||
|
Das ist Absicht: Ein vergessener Eintrag soll auffallen, statt stillschweigend
|
||||||
|
eine ungeschützte Seite ins Netz zu stellen.
|
||||||
|
- Abgefragt wird per HTTP-Basic-Auth. Der Browser fragt **einmal pro Sitzung**,
|
||||||
|
nicht bei jedem Seitenaufruf — beim Sortieren wäre das sonst unbrauchbar.
|
||||||
|
- Der **Benutzername ist beliebig**; geprüft wird nur das Passwort. Beim
|
||||||
|
Vergleich wird eine zeitkonstante Prüfung verwendet.
|
||||||
|
- `/healthz` antwortet **ohne** Passwort mit `ok`. Das braucht die Überwachung
|
||||||
|
der Hosting-Umgebung, die den geschützten Dienst sonst für ausgefallen hielte.
|
||||||
|
Der Pfad gibt nichts über die Anwendung preis.
|
||||||
|
|
||||||
|
Das Passwort wandert bei einer Basic-Auth-Anmeldung mit jeder Anfrage über die
|
||||||
|
Leitung. Das ist unbedenklich, solange die Seite über HTTPS ausgeliefert wird —
|
||||||
|
worauf du bei der Veröffentlichung achten solltest.
|
||||||
|
|
||||||
## Technik
|
## Technik
|
||||||
|
|
||||||
- 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
|
||||||
|
|
||||||
@@ -169,23 +381,33 @@ nicht gesetzt ist).
|
|||||||
| `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 |
|
||||||
|
| `.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.
|
||||||
|
|
||||||
@@ -210,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
|
||||||
@@ -247,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
|
||||||
|
|
||||||
@@ -290,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
|
||||||
@@ -334,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.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Statischer Server mit Passwortschutz fuer die RAM-Sortierhilfe.
|
||||||
|
|
||||||
|
Liefert das gebaute Ergebnis aus dist/ aus, gibt aber ohne gueltiges Passwort
|
||||||
|
nichts heraus - weder HTML noch Skripte. Nutzt ausschliesslich die
|
||||||
|
Python-Standardbibliothek, damit keine zusaetzliche Abhaengigkeit entsteht.
|
||||||
|
|
||||||
|
Das Passwort kommt aus der Umgebungsvariable APP_PASSWORD. Fehlt sie, startet
|
||||||
|
der Server absichtlich nicht: Ein vergessener Eintrag soll auffallen, statt
|
||||||
|
stillschweigend eine ungeschuetzte Seite ins Netz zu stellen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Absolut aufgeloest, damit es nicht darauf ankommt, aus welchem Verzeichnis
|
||||||
|
# die Hosting-Umgebung den Server startet.
|
||||||
|
SERVE_DIRECTORY = str(Path(__file__).resolve().parent / 'dist')
|
||||||
|
HEALTH_PATH = '/healthz'
|
||||||
|
REALM = 'RAM-Sortierhilfe'
|
||||||
|
|
||||||
|
|
||||||
|
def read_password():
|
||||||
|
"""Liest das Passwort aus der Umgebung. Fehlt es, wird der Start abgebrochen."""
|
||||||
|
password = os.environ.get('APP_PASSWORD', '')
|
||||||
|
if not password:
|
||||||
|
sys.stderr.write(
|
||||||
|
'FEHLER: Die Umgebungsvariable APP_PASSWORD ist nicht gesetzt.\n'
|
||||||
|
'Der Server startet nicht, damit die Seite nicht ungeschuetzt\n'
|
||||||
|
'erreichbar wird. Setze das Passwort in den VCH-Einstellungen.\n'
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def build_handler(password):
|
||||||
|
"""Erzeugt einen Handler, der das Passwort per HTTP-Basic-Auth prueft."""
|
||||||
|
expected = base64.b64encode(f':{password}'.encode('utf-8'))
|
||||||
|
|
||||||
|
class ProtectedHandler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=SERVE_DIRECTORY, **kwargs)
|
||||||
|
|
||||||
|
def is_authorised(self):
|
||||||
|
"""Prueft nur das Passwort; der Benutzername darf beliebig sein."""
|
||||||
|
header = self.headers.get('Authorization', '')
|
||||||
|
if not header.startswith('Basic '):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(header[6:].strip(), validate=True)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
_, separator, supplied = decoded.partition(b':')
|
||||||
|
if not separator:
|
||||||
|
return False
|
||||||
|
# Zeitkonstanter Vergleich, damit die Antwortdauer nichts verraet.
|
||||||
|
return hmac.compare_digest(
|
||||||
|
base64.b64encode(b':' + supplied), expected
|
||||||
|
)
|
||||||
|
|
||||||
|
def demand_password(self):
|
||||||
|
self.send_response(401)
|
||||||
|
self.send_header('WWW-Authenticate', f'Basic realm="{REALM}", charset="UTF-8"')
|
||||||
|
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
self.send_header('Content-Length', '0')
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def answer_health(self):
|
||||||
|
"""Lebenszeichen fuer die Ueberwachung - ohne Passwort, ohne Inhalt."""
|
||||||
|
body = b'ok'
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
if self.command != 'HEAD':
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def guard(self):
|
||||||
|
"""True, wenn die Anfrage weiterverarbeitet werden darf."""
|
||||||
|
if self.path.split('?', 1)[0] == HEALTH_PATH:
|
||||||
|
self.answer_health()
|
||||||
|
return False
|
||||||
|
if not self.is_authorised():
|
||||||
|
self.demand_password()
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.guard():
|
||||||
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
if self.guard():
|
||||||
|
super().do_HEAD()
|
||||||
|
|
||||||
|
return ProtectedHandler
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
password = read_password()
|
||||||
|
port = int(os.environ.get('PORT', '8000'))
|
||||||
|
server = ThreadingHTTPServer(('0.0.0.0', port), build_handler(password))
|
||||||
|
sys.stderr.write(f'RAM-Sortierhilfe laeuft auf Port {port}, Zugang nur mit Passwort.\n')
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+11
-3
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+440
-19
@@ -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,14 +595,25 @@ 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.
|
||||||
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`;
|
cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`;
|
||||||
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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 () => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Vendored
+10
File diff suppressed because one or more lines are too long
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user