Compare commits

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:56:26 +02:00
12 changed files with 760 additions and 192 deletions
+102 -45
View File
@@ -9,8 +9,9 @@ QR-Code, Text erkennen oder Zählen (siehe „Funktionsweise" unten). Jede
Funktion nutzt ausschließlich ihre eigene Quelle; anders als früher weicht die Funktion nutzt ausschließlich ihre eigene Quelle; anders als früher weicht die
App nicht mehr automatisch auf Texterkennung aus, wenn kein Code gefunden App nicht mehr automatisch auf Texterkennung aus, wenn kein Code gefunden
wird. Die vierte Funktion, „Zählen", gehört nicht zum eigentlichen Sortieren: wird. Die vierte Funktion, „Zählen", gehört nicht zum eigentlichen Sortieren:
Sie bucht nichts, sondern zeigt beim Auslegen von Kleinteilen (z. B. Sie bucht nichts, sondern ermittelt auf Knopfdruck beim Auslegen von
Festplattenschrauben) fortlaufend deren Anzahl an. 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.
@@ -65,12 +66,12 @@ keine Daten verlassen das Gerät.
ausschließlich in `src/scan-modes.js` — es gibt keine zweite Stelle, an der ausschließlich in `src/scan-modes.js` — es gibt keine zweite Stelle, an der
das steht: das steht:
| Funktion | Liest | Laufende Suche/Zählung | Texterkennung | | Funktion | Liest | Laufende Codesuche | Texterkennung |
|---|---|---|---| |---|---|---|---|
| **Strichcode** | Code128, Code39, Code93, ITF, EAN-13, EAN-8, UPC-A, UPC-E, Codabar | ja (Codesuche) | nein | | **Strichcode** | Code128, Code39, Code93, ITF, EAN-13, EAN-8, UPC-A, UPC-E, Codabar | ja | nein |
| **QR-Code** | QRCode, MicroQRCode, RMQRCode, DataMatrix, Aztec, PDF417 | ja (Codesuche) | nein | | **QR-Code** | QRCode, MicroQRCode, RMQRCode, DataMatrix, Aztec, PDF417 | ja | nein |
| **Text erkennen** | keine Codes | nein | ja, auf Knopfdruck | | **Text erkennen** | keine Codes | nein | ja, auf Knopfdruck |
| **Zählen** | keine Codes | ja (Zählung, keine Buchung) | nein | | **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 „QR-Code" heißt auf Wunsch des Auftraggebers „QR-Code", liest aber bewusst
nicht nur QR-Varianten, sondern auch DataMatrix, Aztec und PDF417. Der nicht nur QR-Varianten, sondern auch DataMatrix, Aztec und PDF417. Der
@@ -80,18 +81,24 @@ keine Daten verlassen das Gerät.
`src/scan-modes.js`), und der Name ist Absicht — es ist der Wunsch des `src/scan-modes.js`), und der Name ist Absicht — es ist der Wunsch des
Auftraggebers für seinen Sprachgebrauch. Auftraggebers für seinen Sprachgebrauch.
„Modul scannen" erscheint nur in „Text erkennen" — in „Strichcode" und Eine einzige Aufnahme-Schaltfläche bedient beide Knopfdruck-Funktionen —
QR-Code" sucht die App ohnehin laufend, ein Knopf ohne Texterkennung hätte Modul scannen" in „Text erkennen", „Zählen" bzw. (nach der ersten
dort nichts zu tun. Der Zielrahmen hebt sich in „Strichcode" und „QR-Code" Aufnahme seit Betreten der Funktion) „Nochmal zählen" in „Zählen" — statt
hervor, sobald ein passender Code im Bild ist; in „Text erkennen" bleibt er 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 schlicht, weil dort nichts laufend erkannt wird. Trotzdem ist der Rahmen
auch dort wirksam: „Modul scannen" liest genau den Bildausschnitt innerhalb auch dort wirksam: Sowohl „Modul scannen" als auch „Zählen" lesen genau den
des Rahmens, in voller Kameraauflösung, nicht mehr das gesamte, Bildausschnitt innerhalb des Rahmens, in voller Kameraauflösung, nicht das
heruntergerechnete Kamerabild (`grabFrameRegion` in `src/camera.js` gesamte, heruntergerechnete Kamerabild (`grabFrameRegion` in
derselbe Ausschnitt, den auch die laufende Barcode-Suche benutzt). Ein `src/camera.js` derselbe Ausschnitt, den auch die laufende Barcode-Suche
Wechsel der Funktion während des Sortierens (z. B. bei gemischter Ware) benutzt). Ein Wechsel der Funktion während des Sortierens (z. B. bei
gilt sofort: laufende Suche startet oder stoppt, der Knopf erscheint oder gemischter Ware) gilt sofort: laufende Suche startet oder stoppt, die
verschwindet. Schaltfläche erscheint, verschwindet oder ändert ihren Wortlaut.
Liegt beim Start eine gesicherte Sitzung vor, erscheint zuerst die Frage Liegt beim Start eine gesicherte Sitzung vor, erscheint zuerst die Frage
nach dem Fortsetzen (siehe Punkt 6 unten) und erst danach die Funktionswahl nach dem Fortsetzen (siehe Punkt 6 unten) und erst danach die Funktionswahl
@@ -172,15 +179,36 @@ keine Daten verlassen das Gerät.
`src/ui/result-overlay.js`). Der Rohtext jedes Eintrags lässt sich später `src/ui/result-overlay.js`). Der Rohtext jedes Eintrags lässt sich später
auch in der Sitzungsliste nachlesen. auch in der Sitzungsliste nachlesen.
4. **Zählen** (ausschließlich in Funktion „Zählen"). Reine Anzeige, ohne 4. **Zählen** (ausschließlich in Funktion „Zählen"). Reine Anzeige, ohne
jeden Bezug zur Sitzung: Solange die Kamera läuft, wird der Zielrahmen jeden Bezug zur Sitzung — und, anders als die drei übrigen Funktionen mit
mehrmals pro Sekunde ausgewertet und die Anzahl der darin gefundenen laufender Erkennung, auf **Aufnahme statt Dauerbetrieb**: Eine Zählung ist
Fundstücke groß im Bild eingeblendet (`countObjects` in eine Schätzung, kein eindeutiger Treffer wie ein Barcode; sie mehrmals pro
`src/count-objects.js`, aufgerufen über denselben Zeitgeber/dieselbe Sekunde neu anzuzeigen macht sie nicht genauer, nur unruhig (am
Überlappungssperre wie die Barcode-Dauersuche — siehe Nutzergerät sprang die Anzeige im Dauerbetrieb zwischen etwa 3 und 60, an
`continuousSearchLoop` in `src/main.js`). Es gibt in dieser Funktion einem Standfoto derselben Teile lieferte dasselbe Verfahren stabil 18).
keinen „Modul scannen"-Knopf, keine Treffer-Rückmeldung und keine Antippen der Schaltfläche „Zählen“/„Nochmal zählen“ nimmt deshalb binnen
Stapel-Zuweisung; die Zahl wird nirgends gespeichert, nicht einmal rund einer Sekunde fünf Bilder aus dem Zielrahmen des laufenden
flüchtig für die Dauer der Sitzung. 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`), Kein Bilderkennungsmodell: Der **Otsu-Schwellwert** (`threshold.js`),
den die Texterkennung weiterhin nutzt, taugt für „Zählen" nicht — er den die Texterkennung weiterhin nutzt, taugt für „Zählen" nicht — er
@@ -266,7 +294,7 @@ korrekt funktioniert.
npm test npm test
``` ```
165 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, jetzt in und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert, jetzt in
`threshold.js`) und -Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline `threshold.js`) und -Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline
@@ -282,7 +310,11 @@ nie in „Text erkennen"/„Zählen"). Dazu die Zähl-Berechnung selbst
(`count-objects.js` — leeres Bild, einzelne und mehrere getrennte Flächen, (`count-objects.js` — leeres Bild, einzelne und mehrere getrennte Flächen,
verworfenes Rauschen, Hochrechnung berührender Teile ohne ein nur leicht verworfenes Rauschen, Hochrechnung berührender Teile ohne ein nur leicht
größeres Einzelteil zu verdoppeln, helle wie dunkle Objekte, ein Bild ohne größeres Einzelteil zu verdoppeln, helle wie dunkle Objekte, ein Bild ohne
Bildpunkte). Kamera, Barcode-/OCR-Adapter selbst, die Auswahl-Oberfläche und 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 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 manuell geprüft (siehe „Am Gerät noch zu prüfen" unten) — `node --test` kennt
kein DOM. kein DOM.
@@ -350,15 +382,16 @@ worauf du bei der Veröffentlichung achten solltest.
| `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`) — kennt keine Scan-Funktionen; liefert zusätzlich (additiv) den ungefilterten OCR-Rohtext (`rawText`), leer, wenn keine Texterkennung lief | | `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/scan-modes.js` | Die vier Scan-Funktionen (Strichcode, QR-Code, Text erkennen, Zählen): Kennung, deutsche Beschriftung, gelesene Codearten, laufende Suche ja/nein, Texterkennung ja/nein, laufende Zählung ja/nein — einzige Quelle, sowohl für die Auswahl-Oberfläche als auch für `main.js` | | `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/scan-recognition.js` | Baut die an `recognize()` übergebenen Adapter anhand der gewählten Funktion: in „Strichcode"/„QR-Code" liefert die Texterkennung sofort leeren Text ohne Tesseract anzustoßen, in „Text erkennen" liefert die Barcode-Dekodierung sofort eine leere Liste ohne `zxing-wasm` anzustoßen, in „Zählen" liefern beide sofort leer (diese Funktion ruft `recognize()` im normalen Betrieb ohnehin nicht auf) | | `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/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/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/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/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/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/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/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** die laufende Zählung in „Zählen"), Bilddatei-Ersatzweg | | `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/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/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), Scan-Knopf (nur in „Text erkennen" sichtbar), große Zählanzeige (nur in „Zählen" sichtbar), Stapel-Leiste, „Zuletzt"-Zeile, Ersatzweg-Schaltfläche bei fehlender Kamera | | `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/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/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 |
@@ -371,9 +404,10 @@ worauf du bei der Veröffentlichung achten solltest.
| `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung | | `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung |
`spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline`, `storage`, `spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline`, `storage`,
`scan-modes`, `scan-recognition`, `threshold` und `count-objects` sind reine `scan-modes`, `scan-recognition`, `threshold`, `count-objects` und
Funktionen ohne Browser-Zugriff (kein `window`, `document` oder `count-capture` sind reine Funktionen ohne Browser-Zugriff (kein `window`,
`localStorage` direkt) und deshalb vollständig mit `node:test` prüfbar. `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.
@@ -586,18 +620,41 @@ werden:
dass die Vorbereitung tatsächlich nur einmal läuft)? In Funktion „Text dass die Vorbereitung tatsächlich nur einmal läuft)? In Funktion „Text
erkennen" darf dieses Laden dagegen gar nicht erst anlaufen, da dort nie erkennen" darf dieses Laden dagegen gar nicht erst anlaufen, da dort nie
`decodeBarcodes` aufgerufen wird. `decodeBarcodes` aufgerufen wird.
**Zählen (neu)** **Zählen (Aufnahme statt Dauerbetrieb)**
- „Zählen" wählen, Kamera auf ausgelegte Kleinteile richten (z. B. silberne - „Zählen" wählen, Kamera auf ausgelegte Kleinteile richten (z. B. silberne
Festplattenschrauben auf dunklem Untergrund): Eine große Zahl erscheint im Festplattenschrauben auf dunklem Untergrund): Es erscheint **keine**
Bild und aktualisiert sich laufend, ohne dass etwas angetippt werden muss. Zahl und **keine** fortlaufende Aktualisierung, sondern eine gut
Kein „Modul scannen"-Knopf, keine Treffer-Rückmeldung, kein roter Dialog, erreichbare Schaltfläche mit der Aufschrift „Zählen". Kein roter Dialog,
keine Stapel-Zuweisung — der Zielrahmen bleibt sichtbar und zeigt weiterhin, keine Treffer-Rückmeldung, keine Stapel-Zuweisung — der Zielrahmen bleibt
was gezählt wird. sichtbar und zeigt, was beim nächsten Antippen gezählt wird.
- Die angezeigte Zahl aus normalem Bedienabstand (Armlänge) lesbar prüfen. - „Zählen" antippen: Für rund eine Sekunde zeigt die Statuszeile „zähle …“,
- Ein paar Teile wegnehmen bzw. hinzufügen, während die Kamera läuft: Die danach erscheint eine große Zahl im Bild und bleibt stehen — sie
Zahl folgt ohne spürbare Verzögerung. 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 - Zugänge zur Sitzungsliste und zum Funktionswechsel bleiben auch in dieser
Funktion erreichbar (Anzeige der Funktion antippen, Stapel-Leiste antippen). 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 - Gegenprobe der Grenzen (siehe „Grenzen" oben): Teile eng aneinanderlegen
(Hochrechnung), Teile stapeln (nur oberste Schicht zählt), einseitige (Hochrechnung), Teile stapeln (nur oberste Schicht zählt), einseitige
Beleuchtung, geringer Kontrast zum Untergrund — beobachten, wo die Zahl Beleuchtung, geringer Kontrast zum Untergrund — beobachten, wo die Zahl
+39
View File
@@ -0,0 +1,39 @@
// Bildet aus den Einzelmessungen einer Zaehl-Aufnahme (Funktion "Zählen",
// siehe main.js) das anzuzeigende Endergebnis. main.js nimmt je Antippen der
// Schaltflaeche "Zählen"/"Nochmal zählen" binnen rund einer Sekunde mehrere
// Bilder aus dem Zielrahmen auf und zaehlt jedes einzeln (countObjects() in
// count-objects.js, unveraendert) - ein einzelnes verwackeltes oder mitten in
// eine Fokusregelung fallendes Bild soll das Ergebnis nicht verzerren. Frueher
// glaettete ein gleitender Median ueber die letzten neun Durchlaeufe einer
// Dauerzaehlung (siehe .superpowers/sdd/counting-capture-report.md); das ist
// entfallen, weil eine Aufnahme mit mehreren Bildern dieselbe Robustheit
// bereits in sich selbst leistet - zwei Glaettungen uebereinander waeren nur
// zusaetzliche Traegheit ohne zusaetzlichen Nutzen.
//
// Der Median der Einzelmessungen ist robust gegen einen einzelnen groben
// Ausreisser (anders als deren Mittelwert) - siehe median() in
// count-objects.js fuer dieselbe Ueberlegung bei den dortigen
// Flaechengroessen.
//
// Reines, browserfreies Modul wie count-objects.js - kennt weder Kamera noch
// Bilddaten, nur die Zahlenreihe der Einzelmessungen selbst.
import { median } from './count-objects.js';
/**
* Bildet den Median der Einzelmessungen einer Aufnahme (siehe Moduldoku
* oben). Bleibt auch dann sinnvoll, wenn eine Aufnahme aus weniger als den
* vorgesehenen fuenf Bildern besteht (z. B. weil das Kamerabild waehrend
* eines Versuchs kurz nicht bereit war, siehe grabFrameRegion() in
* camera.js) - nur eine vollstaendig leere Aufnahme (kein einziges
* verwertbares Bild) ist kein regulaerer Ausgang und wirft; main.js prueft
* diesen Fall vorher selbst und ruft diese Funktion dann gar nicht erst auf.
* @param {number[]} counts Einzelmessungen dieser Aufnahme - nicht-leer.
* @returns {number}
* @throws {Error} wenn counts leer ist.
*/
export function combineCaptureCounts(counts) {
if (counts.length === 0) {
throw new Error('combineCaptureCounts: keine Messung vorhanden');
}
return median(counts);
}
+143 -55
View File
@@ -69,10 +69,15 @@ const SCALE_FRACTION_OF_LARGEST = 0.2;
/** /**
* Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene * Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene
* Teile) oder sehr kleine (Rauschen) Ausreisser, anders als der Mittelwert. * 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 * @param {number[]} values nicht-leer
* @returns {number} * @returns {number}
*/ */
function median(values) { export function median(values) {
const sorted = [...values].sort((a, b) => a - b); const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2); const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 return sorted.length % 2 === 0
@@ -260,16 +265,21 @@ function floodFill(startIndex, isObjectPixel, visited, width, height, stack) {
/** /**
* Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus * Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus
* (helle Objekte auf dunklerem Grund oder umgekehrt). * (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 {(index: number) => boolean} isObjectPixel
* @param {number} width * @param {number} width
* @param {number} height * @param {number} height
* @param {number} totalPixels * @param {number} totalPixels
* @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher * @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher
* @param {Int32Array} stack wiederverwendeter Arbeitsspeicher * @param {Int32Array} stack wiederverwendeter Arbeitsspeicher
* @returns {{count: number, totalArea: number, significantCount: number} | null} * @returns {{count: number, totalArea: number, rawCount: number, filteredCount: number, significantCount: number, largestArea: number, typicalArea: number}}
* null, wenn keine Flaeche dieser Polaritaet uebrig bleibt (kein Rauschen
* ausgenommen, oder gar keine gefunden)
*/ */
function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) { function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) {
const rawAreas = []; const rawAreas = [];
@@ -277,32 +287,29 @@ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, sta
if (visited[p] || !isObjectPixel(p)) continue; if (visited[p] || !isObjectPixel(p)) continue;
rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack)); rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack));
} }
if (rawAreas.length === 0) {
return null;
}
// Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen. // 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 noiseThreshold = Math.max(8, 0.00002 * totalPixels);
const afterNoise = rawAreas.filter((area) => area >= noiseThreshold); const afterNoise = rawAreas.filter((area) => area >= noiseThreshold);
if (afterNoise.length === 0) {
return null;
}
// Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten - // Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten -
// bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. // bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. Bleibt
const largestArea = Math.max(...afterNoise); // 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 scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST;
const significantAreas = afterNoise.filter((area) => area >= scaleThreshold); const significantAreas = afterNoise.filter((area) => area >= scaleThreshold);
if (significantAreas.length === 0) {
return null;
}
// Schritt 9: typische Einzelgroesse. // Schritt 9: typische Einzelgroesse - nur bestimmbar, wenn ueberhaupt eine
const typicalArea = median(significantAreas); // 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 // Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte
// Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als // Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als
// zwei zaehlen). // zwei zaehlen). Bleibt keine Flaeche uebrig, durchlaeuft die Schleife kein
// einziges Mal - count und totalArea bleiben bei 0.
let count = 0; let count = 0;
let totalArea = 0; let totalArea = 0;
for (const area of significantAreas) { for (const area of significantAreas) {
@@ -310,21 +317,103 @@ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, sta
totalArea += area; totalArea += area;
} }
return { count, totalArea, significantCount: significantAreas.length }; 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 * Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
* Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion - * Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion -
* erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung. * 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 * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{count: number}} count: ermittelte, bereits gerundete Anzahl - * @returns {{
* 0, wenn keine Objektflaeche gefunden wurde (leerer Ausschnitt, reiner * count: number,
* Untergrund ohne Kontrast oder ein Ausschnitt ohne Bildpunkte). * 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) { export function countObjects(imageData) {
if (imageData.width * imageData.height === 0) { if (imageData.width * imageData.height === 0) {
return { count: 0 }; return {
count: 0,
width: 0,
height: 0,
regionsFound: 0,
regionsKept: 0,
largestArea: 0,
typicalArea: 0,
polarity: null,
};
} }
// Schritt 1: auf feste Breite normieren. // Schritt 1: auf feste Breite normieren.
@@ -361,35 +450,34 @@ export function countObjects(imageData) {
const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN; const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN;
const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack); const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack);
if (!bright && !dark) { // Eine Polaritaet muss gewinnen (siehe pickWinner() oben fuer den
return { count: 0 }; // Sonderfall "beide leer"). Am echten Foto (silberne, glaenzende
} // Schrauben, einseitig beleuchtet) traegt jede Schraube sowohl eine helle
if (!dark) { // Reflexflaeche als auch einen dunklen Schlagschatten - beide Polaritaeten
return { count: bright.count }; // finden also echte, nicht zufaellige Struktur, und ihre Gesamtflaechen
} // liegen dicht beieinander (am Pruefbild rund 2826 zu 2140 Bildpunkte).
if (!bright) { // Die Gesamtflaeche allein (kleinere gewinnt) ist in diesem Fall kein
return { count: dark.count }; // 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);
// Beide Polaritaeten haben etwas gefunden - eine muss gewinnen. Am echten return {
// Foto (silberne, glaenzende Schrauben, einseitig beleuchtet) traegt jede count: winner.count,
// Schraube sowohl eine helle Reflexflaeche als auch einen dunklen width,
// Schlagschatten - beide Polaritaeten finden also echte, nicht zufaellige height,
// Struktur, und ihre Gesamtflaechen liegen dicht beieinander (am regionsFound: winner.rawCount,
// Pruefbild rund 2826 zu 2140 Bildpunkte). Die Gesamtflaeche allein regionsKept: winner.significantCount,
// (kleinere gewinnt) ist in diesem Fall kein verlaessliches Kriterium: der largestArea: winner.largestArea,
// Schlagschatten jeder Schraube ist schmaler als ihre Reflexflaeche und typicalArea: winner.typicalArea,
// summiert sich deshalb zu einer kleineren Gesamtflaeche, obwohl die polarity: winner.polarity,
// 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.
if (bright.significantCount !== dark.significantCount) {
return { count: bright.significantCount > dark.significantCount ? bright.count : dark.count };
}
return { count: bright.totalArea <= dark.totalArea ? bright.count : dark.count };
} }
+202 -56
View File
@@ -4,6 +4,7 @@ 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 { 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 { getScanMode } from './scan-modes.js';
@@ -50,14 +51,33 @@ let cameraUnavailableMessage = null;
// ohnehin unerreichbar. // ohnehin unerreichbar.
let currentModeId = null; 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, {
// "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe // Eine gemeinsame Schaltflaeche fuer beide Knopfdruck-Funktionen (siehe
// applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort // scan-view.js/setCaptureLabel()): in Funktion "Text erkennen" loest sie
// einen Ausschnitt, den grabFrame() (Vollbild, herunterskaliert) bisher gar // processCapture() aus (Erkennung + Buchung), in Funktion "Zählen"
// nicht einloeste. grabFrameRegion() liest genau den Rahmenausschnitt, in // stattdessen runCountCapture() (Aufnahme mehrerer Bilder, keine Buchung).
// voller Aufloesung - derselbe Ausschnitt, den auch die Barcode-Dauersuche // 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). // benutzt (siehe runSearchAttempt() weiter unten).
onCapture: () => processCapture(() => grabFrameRegion(view.video)), 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,
@@ -83,27 +103,38 @@ const view = renderScanView(app, {
}); });
/** /**
* Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit von * Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit und
* "Modul scannen" und - bei einem Wechsel weg von laufender Codesuche - der * Beschriftung der Aufnahme-Schaltflaeche und - bei einem Wechsel weg von
* Zielrahmen wird sofort zurueckgesetzt, statt auf den naechsten Durchlauf * laufender Codesuche - der Zielrahmen wird sofort zurueckgesetzt, statt auf
* der Dauersuche zu warten. * den naechsten Durchlauf der Dauersuche zu warten.
* @param {string} modeId * @param {string} modeId
*/ */
function applyMode(modeId) { function applyMode(modeId) {
currentModeId = modeId; currentModeId = modeId;
const mode = getScanMode(modeId); const mode = getScanMode(modeId);
view.setMode(mode.label); view.setMode(mode.label);
// "Modul scannen" hat nur dort etwas zu tun, wo es weder laufende // Die Aufnahme-Schaltflaeche hat nur dort etwas zu tun, wo es keine
// Codesuche noch laufende Zaehlung gibt (Funktion 3) - siehe Kommentar in // laufende Codesuche gibt (Funktion 3 "Text erkennen" und Funktion 4
// scan-view.js/setCaptureVisible. // "Zählen") - siehe Kommentar in scan-view.js/setCaptureVisible.
view.setCaptureVisible(!mode.continuousSearch && !mode.continuousCount); view.setCaptureVisible(!mode.continuousSearch);
if (!mode.continuousSearch) { if (!mode.continuousSearch) {
view.setFrameDetected(false); 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" - // Die grosse Zaehlanzeige gehoert ausschliesslich zu Funktion "Zählen" -
// beim Verlassen sofort ausblenden, statt auf den naechsten (dann gar // beim Verlassen sofort ausblenden (loescht dabei auch ein stehendes
// nicht mehr laufenden) Durchlauf der Dauerzaehlung zu warten. // Ergebnis samt Diagnose, siehe scan-view.js/setCountVisible), statt auf
view.setCountVisible(mode.continuousCount); // die naechste Aufnahme zu warten.
view.setCountVisible(mode.id === 'count');
} }
/** /**
@@ -128,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,
@@ -226,19 +276,8 @@ 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,
);
}
}
} }
} }
@@ -357,52 +396,159 @@ async function runSearchAttempt() {
await processCapture(() => Promise.resolve(region)); await processCapture(() => Promise.resolve(region));
} }
/** /** Kurzbezeichnung der Polaritaet (siehe count-objects.js) fuer die Diagnosezeile. */
* Ein einzelner Durchlauf der Dauerzaehlung (Funktion "Zählen"): Ausschnitt function describePolarity(polarity) {
* in nativer Aufloesung holen, Fundstuecke zaehlen (siehe count-objects.js) if (polarity === 'hell') return 'hell auf dunkel';
* und die grosse Anzeige aktualisieren. Anders als runSearchAttempt() wird if (polarity === 'dunkel') return 'dunkel auf hell';
* hier nie gebucht und nichts gesperrt - reine Anzeige, busy bleibt return '';
* unberuehrt, weil kein Ergebnis in die Sitzung geht und daher auch kein }
* "Modul scannen", keine Treffer-Rueckmeldung und keine Stapel-Zuweisung
* dazugehoeren (siehe scan-modes.js).
*/
async function runCountAttempt() {
// Nur in Funktion "Zählen" aktiv - siehe scan-modes.js.
if (!getScanMode(currentModeId).continuousCount) return;
// Dieselbe Pausenbedingung wie bei der Barcode-Dauersuche (laufende /**
// Erkennung oder offenes Vollbild-Overlay) - siehe searchPaused() oben. * Baut den Wortlaut der Diagnosezeile unter der grossen Zahl (siehe
if (searchPaused()) return; * 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; let region;
try { try {
region = grabFrameRegion(view.video); region = grabFrameRegion(view.video);
} catch { } catch {
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf // Kamerabild in diesem Augenblick nicht bereit - dieses Einzelbild
// versucht es erneut. // 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; return;
} }
const { count } = countObjects(region); const finalCount = combineCaptureCounts(counts);
view.setCount(count); 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 * Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
* der vorherige vollstaendig fertig ist - nie ueberlappend. Fuehrt pro * der vorherige vollstaendig fertig ist - nie ueberlappend. Fuehrt pro
* Durchlauf sowohl den Barcode- als auch den Zaehl-Versuch aus; jeder der * Durchlauf die Barcode-Dauersuche aus; sie bricht selbst sofort ab, wenn
* beiden bricht selbst sofort ab, wenn die gewaehlte Funktion nicht zu ihm * die gewaehlte Funktion keine laufende Suche vorsieht (siehe
* passt (siehe runSearchAttempt()/runCountAttempt()) - so bleibt es bei * runSearchAttempt()). Funktion "Zählen" laeuft seit dem Umbau auf
* genau einem Zeitgeber und derselben Ueberlappungssperre fuer alle * Aufnahmen statt Dauerbetrieb (siehe runCountCapture()) und braucht hier
* fortlaufenden Funktionen, statt einen zweiten, parallelen Mechanismus * deshalb keinen eigenen Aufruf mehr.
* fuer "Zählen" danebenzustellen.
*/ */
async function continuousSearchLoop() { async function continuousSearchLoop() {
for (;;) { for (;;) {
const started = performance.now(); const started = performance.now();
try { try {
await runSearchAttempt(); await runSearchAttempt();
await runCountAttempt();
} catch { } catch {
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht // Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
// dauerhaft abbrechen. // dauerhaft abbrechen.
+13 -10
View File
@@ -14,9 +14,13 @@
// //
// Funktion 4 ("Zaehlen") ist reine Anzeige: Sie liest weder Codes noch Text // Funktion 4 ("Zaehlen") ist reine Anzeige: Sie liest weder Codes noch Text
// und bucht nichts in die Sitzung - deshalb `continuousSearch: false` und // und bucht nichts in die Sitzung - deshalb `continuousSearch: false` und
// `useOcr: false`, obwohl sie (wie die Barcode-Dauersuche) fortwaehrend // `useOcr: false`. Anders als frueher laeuft sie nicht mehr fortwaehrend,
// laeuft, solange die Kamera aktiv ist. Dafuer eigenes Merkmal // solange die Kamera aktiv ist, sondern - wie Funktion 3 ("Text erkennen") -
// `continuousCount`, siehe main.js. // 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 * @typedef {object} ScanMode
@@ -26,12 +30,11 @@
* Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine * Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine
* Barcode-Dekodierung nutzt. * Barcode-Dekodierung nutzt.
* @property {boolean} continuousSearch Ob waehrend die Kamera laeuft * @property {boolean} continuousSearch Ob waehrend die Kamera laeuft
* fortwaehrend nach Codes gesucht wird (statt nur auf Knopfdruck). * 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. * @property {boolean} useOcr Ob die Texterkennung (Tesseract) genutzt wird.
* @property {boolean} continuousCount Ob waehrend die Kamera laeuft
* fortwaehrend die Fundstuecke im Zielrahmen gezaehlt werden (siehe
* count-objects.js) - reine Anzeige, ohne Buchung. Nur bei Funktion
* "Zaehlen" true.
*/ */
/** @type {ScanMode[]} */ /** @type {ScanMode[]} */
@@ -70,11 +73,11 @@ export const SCAN_MODES = [
id: 'count', id: 'count',
label: 'Zählen', label: 'Zählen',
// Keine Codes, keine Texterkennung - reine Bildauswertung (siehe // Keine Codes, keine Texterkennung - reine Bildauswertung (siehe
// count-objects.js), die nichts bucht. // count-objects.js), die nichts bucht. Auf Knopfdruck wie Funktion 3,
// siehe Moduldoku oben.
barcodeFormats: [], barcodeFormats: [],
continuousSearch: false, continuousSearch: false,
useOcr: false, useOcr: false,
continuousCount: true,
}, },
]; ];
+10 -4
View File
@@ -12,10 +12,16 @@
// aufgerufen wird. // aufgerufen wird.
// Funktion 4 (Zaehlen): weder Barcode-Dekodierung noch Texterkennung - // Funktion 4 (Zaehlen): weder Barcode-Dekodierung noch Texterkennung -
// recognize() (pipeline.js) wird im normalen Ablauf fuer diese Funktion gar // recognize() (pipeline.js) wird im normalen Ablauf fuer diese Funktion gar
// nicht aufgerufen (siehe main.js: kein Knopf, keine Dauersuche im Sinne // nicht aufgerufen (siehe main.js: die Schaltflaeche "Zählen"/"Nochmal
// dieses Moduls); dieselbe leere Ruecklieferung wie in Funktion 3 dient hier // zählen" ruft stattdessen runCountCapture(), das direkt countObjects()
// nur als Absicherung fuer den seltenen Fall, dass der Ersatzweg ohne Kamera // 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. // (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'; import { getScanMode } from './scan-modes.js';
@@ -30,7 +36,7 @@ export function buildRecognitionAdapters(modeId, adapters) {
const mode = getScanMode(modeId); const mode = getScanMode(modeId);
return { return {
decodeBarcodes: (mode.useOcr || mode.continuousCount) decodeBarcodes: mode.barcodeFormats.length === 0
? async () => [] ? async () => []
: (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats), : (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats),
runOcr: mode.useOcr runOcr: mode.useOcr
+42 -4
View File
@@ -54,20 +54,58 @@ body {
/* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt /* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt
ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert. ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert.
pointer-events: none, damit sie den Rahmen/die Kamera nicht fuer Der Container selbst blockiert keine Beruehrungen (pointer-events: none);
Beruehrungen blockiert - diese Funktion hat ohnehin keinen Knopf. Riesige die grosse Zahl darunter (.count-number) ist die einzige antippbare
Schriftgroesse mit Schlagschatten statt Kontrastfarbe, damit die Zahl auf Flaeche darin - sie deckt den ganzen Container ab, ist also weit ueber
jedem Untergrund (hell wie dunkel) aus Armlaenge lesbar bleibt. */ den geforderten 56 Bildpunkten Mindestgroesse. */
.count-display { .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; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%;
border: none;
padding: 0;
background: none;
font-size: min(35vw, 220px); font-size: min(35vw, 220px);
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9); 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; pointer-events: none;
} }
+5 -2
View File
@@ -5,8 +5,11 @@ import { SCAN_MODES } from '../scan-modes.js';
* scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion. * scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion.
*/ */
function describeMode(mode) { function describeMode(mode) {
if (mode.useOcr) return 'Auf Knopfdruck'; // "Text erkennen" und "Zählen" laufen beide auf Knopfdruck (siehe
if (mode.continuousCount) return 'Zählt fortlaufend, solange die Kamera läuft'; // 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'; return 'Läuft von selbst, sobald ein Code im Bild ist';
} }
+76 -11
View File
@@ -10,10 +10,14 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
<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 aria-live="polite"></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" hidden>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">
@@ -32,6 +36,14 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
const captureEl = root.querySelector('#capture'); const captureEl = root.querySelector('#capture');
const modeButtonEl = root.querySelector('#mode-button'); const modeButtonEl = root.querySelector('#mode-button');
const countDisplayEl = root.querySelector('#count-display'); const countDisplayEl = root.querySelector('#count-display');
const countNumberEl = root.querySelector('#count-number');
const countDiagnosticsEl = root.querySelector('#count-diagnostics');
// 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 // FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt // fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
@@ -52,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'),
@@ -93,15 +115,30 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
}, },
/** /**
* "Modul scannen" hat nur in Funktionen mit Texterkennung auf Knopfdruck * Diese eine Schaltflaeche bedient beide Knopfdruck-Funktionen ("Modul
* etwas zu tun (Funktion 3) - in Funktionen mit laufender Codesuche sucht * scannen" in Funktion 3 "Text erkennen", "Zählen"/"Nochmal zählen" in
* die App ohnehin durchgehend, ein Knopf dafuer waere wirkungslos. * 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 * @param {boolean} visible
*/ */
setCaptureVisible(visible) { setCaptureVisible(visible) {
captureEl.hidden = !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;
@@ -136,22 +173,50 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
* Blendet die grosse Zaehlanzeige ein oder aus (nur in Funktion * Blendet die grosse Zaehlanzeige ein oder aus (nur in Funktion
* "Zählen" sichtbar, siehe applyMode() in main.js). Beim Ausblenden wird * "Zählen" sichtbar, siehe applyMode() in main.js). Beim Ausblenden wird
* die zuletzt angezeigte Zahl geloescht, damit beim naechsten Wechsel in * die zuletzt angezeigte Zahl geloescht, damit beim naechsten Wechsel in
* diese Funktion nicht kurz eine veraltete Zahl aufblitzt. * 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 * @param {boolean} visible
*/ */
setCountVisible(visible) { setCountVisible(visible) {
countDisplayEl.hidden = !visible; countDisplayEl.hidden = !visible;
if (!visible) countDisplayEl.textContent = ''; if (!visible) {
countNumberEl.textContent = '';
countDiagnosticsEl.textContent = '';
countDiagnosticsVisible = false;
countDiagnosticsEl.hidden = true;
}
}, },
/** /**
* Zeigt die zuletzt ermittelte Anzahl gefundener Fundstuecke (siehe * Zeigt das Ergebnis der zuletzt angetippten Zaehl-Aufnahme (Median der
* count-objects.js) - gross und aus Armlaenge lesbar, aktualisiert sich * fuenf Einzelmessungen dieser Aufnahme, siehe count-capture.js und
* mit jedem Durchlauf der Dauerzaehlung. * 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 * @param {number} count
*/ */
setCount(count) { setCount(count) {
countDisplayEl.textContent = String(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;
}, },
}; };
} }
+44
View File
@@ -0,0 +1,44 @@
// Reine Rechenlogik der Zaehl-Aufnahme (Funktion "Zählen", siehe main.js):
// aus den (bis zu fuenf) Einzelmessungen einer Aufnahme wird deren Median
// gebildet. Siehe count-capture.js fuer die Begruendung (robust gegen einen
// einzelnen Ausreisser, ohne wie eine fortlaufende Glaettung traege zu sein).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { combineCaptureCounts } from '../src/count-capture.js';
test('fuenf gleiche Messungen liefern diese Zahl', () => {
assert.equal(combineCaptureCounts([18, 18, 18, 18, 18]), 18);
});
test('fuenf verschiedene Messungen liefern deren Median (ungerade Anzahl)', () => {
assert.equal(combineCaptureCounts([17, 19, 18, 20, 18]), 18);
});
test('ein einzelner grober Ausreisser unter fuenf Messungen schlaegt nicht durch', () => {
// Vier stabile Messungen um 18 herum, eine grob daneben (z. B.
// Bewegungsunschaerfe oder Autofokus mitten in der Aufnahme) - der Median
// bleibt bei 18, ein Mittelwert waere durch den Ausreisser spuerbar verzerrt.
assert.equal(combineCaptureCounts([18, 18, 3, 18, 60]), 18);
});
test('weniger als fuenf Messungen (eine Kameraaufnahme scheiterte) liefern trotzdem ein Ergebnis', () => {
// Gerade Anzahl (vier statt fuenf) - Median ist der Mittelwert der beiden
// mittleren Werte.
const result = combineCaptureCounts([17, 18, 18, 19]);
assert.equal(result, 18);
});
test('nur eine einzige verwertbare Messung ist ihr eigener Median', () => {
assert.equal(combineCaptureCounts([21]), 21);
});
test('eine leere Messreihe wirft, statt stillschweigend ein sinnloses Ergebnis (NaN) zu liefern', () => {
assert.throws(() => combineCaptureCounts([]));
});
test('combineCaptureCounts veraendert die uebergebene Liste nicht (reine Funktion)', () => {
const counts = [5, 1, 3];
const untouched = [...counts];
combineCaptureCounts(counts);
assert.deepEqual(counts, untouched);
});
+80
View File
@@ -158,3 +158,83 @@ test('zwei nahe, aber nur diagonal benachbarte Flaechen zaehlen getrennt (4er-Na
const result = countObjects(image(rows)); const result = countObjects(image(rows));
assert.equal(result.count, 2, 'nur diagonal benachbarte Flaechen bleiben getrennt'); 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');
});
+1 -2
View File
@@ -43,13 +43,12 @@ test('Funktion 3 (Text erkennen): keine Codearten, keine laufende Suche, Texterk
assert.equal(mode.useOcr, true); assert.equal(mode.useOcr, true);
}); });
test('Funktion 4 (Zählen): keine Codearten, keine laufende Codesuche, keine Texterkennung, dafuer laufende Zaehlung', () => { test('Funktion 4 (Zählen): keine Codearten, keine laufende Codesuche, keine Texterkennung - auf Knopfdruck wie Funktion 3', () => {
const mode = getScanMode('count'); const mode = getScanMode('count');
assert.equal(mode.label, 'Zählen'); assert.equal(mode.label, 'Zählen');
assert.deepEqual(mode.barcodeFormats, []); assert.deepEqual(mode.barcodeFormats, []);
assert.equal(mode.continuousSearch, false); assert.equal(mode.continuousSearch, false);
assert.equal(mode.useOcr, false); assert.equal(mode.useOcr, false);
assert.equal(mode.continuousCount, true);
}); });
test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => { test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => {