Compare commits

..
7 Commits
Author SHA1 Message Date
TanerUsluandClaude Opus 5 f0705a010c Doku: Zaehlfunktion und ihre Grenzen auf das neue Verfahren aktualisiert
Beschreibt oertlichen Kontrast statt Otsu (Normierung, Glaettung,
oertlicher Hintergrund, Massstab relativ zur groessten Flaeche) und die
neuen Grenzen: Untergrund muss sich abheben, gleichmaessige Ausleuchtung
hilft, auseinandergelegt zaehlt besser als aufgehaeuft, stark
unterschiedlich grosse Teile verfaelschen die Hochrechnung.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 15:47:22 +02:00
15 changed files with 1023 additions and 118 deletions
+159 -44
View File
@@ -4,10 +4,13 @@ Browser-App, die per Handykamera gebrauchte Server-RAM-Module erkennt und
beim physischen Sortieren am Tisch anleitet: Modul vor die Kamera halten, die
App nennt den Stapel.
Vor dem Scannen wählt der Nutzer eine von drei Funktionen — Strichcode,
QR-Code oder Text erkennen (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.
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 zeigt beim Auslegen von Kleinteilen (z. B.
Festplattenschrauben) fortlaufend deren Anzahl an.
Zwei Module gelten als zusammengehörig, wenn sie denselben Barcode-Inhalt
tragen — ein Barcode ist exakt gelesen, das ist der zuverlässigste Maßstab.
@@ -57,16 +60,17 @@ keine Daten verlassen das Gerät.
## Funktionsweise
0. **Funktion wählen.** Vor dem ersten Scan (und jederzeit erneut über die
antippbare Anzeige in der Scan-Ansicht) wählt der Nutzer eine von drei
antippbare Anzeige in der Scan-Ansicht) wählt der Nutzer eine von vier
Funktionen. Kennung, Beschriftung, gelesene Codearten und Verhalten stehen
ausschließlich in `src/scan-modes.js` — es gibt keine zweite Stelle, an der
das steht:
| Funktion | Liest | Laufende Suche | Texterkennung |
| Funktion | Liest | Laufende Suche/Zählung | Texterkennung |
|---|---|---|---|
| **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 | nein |
| **Strichcode** | Code128, Code39, Code93, ITF, EAN-13, EAN-8, UPC-A, UPC-E, Codabar | ja (Codesuche) | nein |
| **QR-Code** | QRCode, MicroQRCode, RMQRCode, DataMatrix, Aztec, PDF417 | ja (Codesuche) | nein |
| **Text erkennen** | keine Codes | nein | ja, auf Knopfdruck |
| **Zählen** | keine Codes | ja (Zählung, keine Buchung) | nein |
„QR-Code" heißt auf Wunsch des Auftraggebers „QR-Code", liest aber bewusst
nicht nur QR-Varianten, sondern auch DataMatrix, Aztec und PDF417. Der
@@ -90,17 +94,21 @@ keine Daten verlassen das Gerät.
verschwindet.
Liegt beim Start eine gesicherte Sitzung vor, erscheint zuerst die Frage
nach dem Fortsetzen (siehe Punkt 5 unten) und erst danach die Funktionswahl
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 (`buildRecognitionAdapters` in
`src/scan-recognition.js`). Die Erkennungs-Pipeline selbst (`src/pipeline.js`,
Punkte 14 unten) bleibt dabei unverändert und kennt keine Funktionen — die
drei Funktionen unterscheiden sich ausschließlich darin, welche Adapter ihr
übergeben werden.
eine leere Liste liefert; „Zählen" bekommt beides leer hineingereicht
(`buildRecognitionAdapters` in `src/scan-recognition.js`). Die
Erkennungs-Pipeline selbst (`src/pipeline.js`, Punkte 13 und 5 unten)
bleibt dabei unverändert und kennt keine Funktionen — die drei
code-/textbasierten Funktionen unterscheiden sich ausschließlich darin,
welche Adapter ihr übergeben werden. „Zählen" ruft diese Pipeline im
normalen Betrieb gar nicht erst auf (siehe Punkt 4 unten) — es gibt dort ja
nichts zu buchen, das leere Adapterpaar dient nur als Absicherung für den
seltenen Ersatzweg ohne Kamera (Punkt 7).
1. **Barcode-Dekodierung** (nur in „Strichcode" und „QR-Code"). Die zur
Funktion gehörenden Codearten werden laufend aus dem Kamerabild dekodiert
(`zxing-wasm`, `decodeBarcodes` in `src/barcode.js` bekommt die Codearten
@@ -163,18 +171,63 @@ keine Daten verlassen das Gerät.
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. **Stapel-Zuweisung.** Grün (Barcode) und Gelb (OCR) laufen ohne Eingabe
4. **Zählen** (ausschließlich in Funktion „Zählen"). Reine Anzeige, ohne
jeden Bezug zur Sitzung: Solange die Kamera läuft, wird der Zielrahmen
mehrmals pro Sekunde ausgewertet und die Anzahl der darin gefundenen
Fundstücke groß im Bild eingeblendet (`countObjects` in
`src/count-objects.js`, aufgerufen über denselben Zeitgeber/dieselbe
Überlappungssperre wie die Barcode-Dauersuche — siehe
`continuousSearchLoop` in `src/main.js`). Es gibt in dieser Funktion
keinen „Modul scannen"-Knopf, keine Treffer-Rückmeldung und keine
Stapel-Zuweisung; die Zahl wird nirgends gespeichert, nicht einmal
flüchtig für die Dauer der Sitzung.
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
Barcodes oder widersprüchliche unbekannte Barcodes ohne jede OCR-Kapazität)
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
stillschweigend übernommen, sondern zur Fortsetzung oder zum Verwerfen
angeboten (`askResumeSession` in `src/ui/resume-dialog.js`, verdrahtet über
`initResumeOffer()` in `src/main.js`). Erst „fortsetzen“ stellt Stapel,
Zähler und die „Zuletzt“-Zeile wieder her; „verwerfen“ löscht den
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
sichtbare Schaltfläche „Bild auswählen“ öffnet stattdessen die native
Dateiauswahl (`setFilePickerVisible` in `src/ui/scan-view.js`). Die App
@@ -213,22 +266,26 @@ korrekt funktioniert.
npm test
```
154 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
165 Tests, alle grün (Stand dieses Dokuments). Getestet werden ausschließlich
die reinen Module ohne Browser-Zugriff: Teilenummer-Decoder, Spec-Normalisierung
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert) und
-Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline samt Zeitgrenzen,
Mehrdeutigkeitsbehandlung und dem additiven OCR-Rohtext (`rawText` wird nur
zurückgegeben, wenn die Texterkennung tatsächlich lief, und beeinflusst
nachweislich nie die Stapelzuordnung), die Sitzungssicherung samt Prüfung
eines wiederhergestellten Zustands (einschließlich `entry.rawText`), die drei
Scan-Funktionen (`scan-modes.js`
Codearten je Funktion, DataMatrix/Aztec/PDF417 in „QR-Code") sowie die je Funktion
und toleranter Vergleich, OCR-Bildaufbereitung (Otsu-Schwellwert, jetzt in
`threshold.js`) und -Feldextraktion, Stapel-Zuweisung, die Erkennungs-Pipeline
samt Zeitgrenzen, Mehrdeutigkeitsbehandlung und dem additiven OCR-Rohtext
(`rawText` wird nur zurückgegeben, wenn die Texterkennung tatsächlich lief,
und beeinflusst nachweislich nie die Stapelzuordnung), die Sitzungssicherung
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"). 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.
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). 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
@@ -293,14 +350,16 @@ worauf du bei der Veröffentlichung achten solltest.
| `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/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 drei Scan-Funktionen (Strichcode, QR-Code, Text erkennen): Kennung, deutsche Beschriftung, gelesene Codearten, laufende Suche ja/nein, Texterkennung ja/nein — einzige Quelle, sowohl für die Auswahl-Oberfläche als auch für `main.js` |
| `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 |
| `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-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/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/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 **und** für „Modul scannen" in „Text erkennen"), 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** die laufende Zählung 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) 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), 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: drei große Flächen, eine je Funktion aus `src/scan-modes.js` |
| `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/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/resume-dialog.js` | Dialog beim Start: gesicherte Sitzung fortsetzen oder verwerfen — läuft vor der Funktionswahl |
@@ -312,10 +371,11 @@ worauf du bei der Veröffentlichung achten solltest.
| `.vch/deploy.yaml` | Bau- und Startbefehl sowie Gesundheitspfad für die Hosting-Umgebung |
`spec`, `pn-decoder`, `ocr-extract`, `session`, `pipeline`, `storage`,
`scan-modes` und `scan-recognition` 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 Browser und werden nur manuell geprüft.
`scan-modes`, `scan-recognition`, `threshold` und `count-objects` 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
Browser und werden nur manuell geprüft.
### Sitzungssicherung im Detail
@@ -390,6 +450,43 @@ Eintrag und fließt in keine Stapel-Berechnung ein.
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
@@ -433,10 +530,11 @@ werden:
- Datei-Auswahl als Ersatzweg (ohne Kamera) öffnet den nativen Dialog und
hinterlässt keine leere Fläche im Layout.
**Funktionswahl (neu)**
- Erster Start ohne gesicherte Sitzung: Auswahl mit drei großen Flächen
(„Strichcode", „QR-Code", „Text erkennen") erscheint, *bevor* die Kamera
startet — kein Kamerabild sichtbar, solange keine Funktion gewählt ist.
**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/…,
@@ -488,6 +586,23 @@ werden:
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 (neu)**
- „Zählen" wählen, Kamera auf ausgelegte Kleinteile richten (z. B. silberne
Festplattenschrauben auf dunklem Untergrund): Eine große Zahl erscheint im
Bild und aktualisiert sich laufend, ohne dass etwas angetippt werden muss.
Kein „Modul scannen"-Knopf, keine Treffer-Rückmeldung, kein roter Dialog,
keine Stapel-Zuweisung — der Zielrahmen bleibt sichtbar und zeigt weiterhin,
was gezählt wird.
- Die angezeigte Zahl aus normalem Bedienabstand (Armlänge) lesbar prüfen.
- Ein paar Teile wegnehmen bzw. hinzufügen, während die Kamera läuft: Die
Zahl folgt ohne spürbare Verzögerung.
- Zugänge zur Sitzungsliste und zum Funktionswechsel bleiben auch in dieser
Funktion erreichbar (Anzeige der Funktion antippen, Stapel-Leiste antippen).
- 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
lädt per CDN), danach mit wiederhergestellter Verbindung erneut scannen:
Das muss einen echten neuen Ladeversuch auslösen statt dauerhaft mit
+395
View File
@@ -0,0 +1,395 @@
// 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.
* @param {number[]} values nicht-leer
* @returns {number}
*/
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).
* @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, significantCount: number} | null}
* null, wenn keine Flaeche dieser Polaritaet uebrig bleibt (kein Rauschen
* ausgenommen, oder gar keine gefunden)
*/
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));
}
if (rawAreas.length === 0) {
return null;
}
// Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen.
const noiseThreshold = Math.max(8, 0.00002 * totalPixels);
const afterNoise = rawAreas.filter((area) => area >= noiseThreshold);
if (afterNoise.length === 0) {
return null;
}
// Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten -
// bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert.
const largestArea = Math.max(...afterNoise);
const scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST;
const significantAreas = afterNoise.filter((area) => area >= scaleThreshold);
if (significantAreas.length === 0) {
return null;
}
// Schritt 9: typische Einzelgroesse.
const typicalArea = median(significantAreas);
// Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte
// Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als
// zwei zaehlen).
let count = 0;
let totalArea = 0;
for (const area of significantAreas) {
count += Math.max(1, Math.round(area / typicalArea));
totalArea += area;
}
return { count, totalArea, significantCount: significantAreas.length };
}
/**
* Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
* Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion -
* erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{count: number}} count: ermittelte, bereits gerundete Anzahl -
* 0, wenn keine Objektflaeche gefunden wurde (leerer Ausschnitt, reiner
* Untergrund ohne Kontrast oder ein Ausschnitt ohne Bildpunkte).
*/
export function countObjects(imageData) {
if (imageData.width * imageData.height === 0) {
return { count: 0 };
}
// 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);
if (!bright && !dark) {
return { count: 0 };
}
if (!dark) {
return { count: bright.count };
}
if (!bright) {
return { count: dark.count };
}
// Beide Polaritaeten haben etwas gefunden - eine muss gewinnen. 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.
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 };
}
+47 -4
View File
@@ -3,6 +3,7 @@
import { startCamera, grabFrameRegion, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js';
import { countObjects } from './count-objects.js';
import { recognize } from './pipeline.js';
import { normalizeToken } from './spec.js';
import { getScanMode } from './scan-modes.js';
@@ -92,12 +93,17 @@ function applyMode(modeId) {
currentModeId = modeId;
const mode = getScanMode(modeId);
view.setMode(mode.label);
// "Modul scannen" hat nur dort etwas zu tun, wo es keine laufende Suche
// gibt (Funktion 3) - siehe Kommentar in scan-view.js/setCaptureVisible.
view.setCaptureVisible(!mode.continuousSearch);
// "Modul scannen" hat nur dort etwas zu tun, wo es weder laufende
// Codesuche noch laufende Zaehlung gibt (Funktion 3) - siehe Kommentar in
// scan-view.js/setCaptureVisible.
view.setCaptureVisible(!mode.continuousSearch && !mode.continuousCount);
if (!mode.continuousSearch) {
view.setFrameDetected(false);
}
// Die grosse Zaehlanzeige gehoert ausschliesslich zu Funktion "Zählen" -
// beim Verlassen sofort ausblenden, statt auf den naechsten (dann gar
// nicht mehr laufenden) Durchlauf der Dauerzaehlung zu warten.
view.setCountVisible(mode.continuousCount);
}
/**
@@ -351,15 +357,52 @@ async function runSearchAttempt() {
await processCapture(() => Promise.resolve(region));
}
/**
* Ein einzelner Durchlauf der Dauerzaehlung (Funktion "Zählen"): Ausschnitt
* in nativer Aufloesung holen, Fundstuecke zaehlen (siehe count-objects.js)
* und die grosse Anzeige aktualisieren. Anders als runSearchAttempt() wird
* hier nie gebucht und nichts gesperrt - reine Anzeige, busy bleibt
* 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.
if (searchPaused()) return;
let region;
try {
region = grabFrameRegion(view.video);
} catch {
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf
// versucht es erneut.
return;
}
const { count } = countObjects(region);
view.setCount(count);
}
/**
* Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
* der vorherige vollstaendig fertig ist - nie ueberlappend.
* der vorherige vollstaendig fertig ist - nie ueberlappend. Fuehrt pro
* Durchlauf sowohl den Barcode- als auch den Zaehl-Versuch aus; jeder der
* beiden bricht selbst sofort ab, wenn die gewaehlte Funktion nicht zu ihm
* passt (siehe runSearchAttempt()/runCountAttempt()) - so bleibt es bei
* genau einem Zeitgeber und derselben Ueberlappungssperre fuer alle
* fortlaufenden Funktionen, statt einen zweiten, parallelen Mechanismus
* fuer "Zählen" danebenzustellen.
*/
async function continuousSearchLoop() {
for (;;) {
const started = performance.now();
try {
await runSearchAttempt();
await runCountAttempt();
} catch {
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
// dauerhaft abbrechen.
+6 -64
View File
@@ -1,4 +1,5 @@
import { createWorker } from 'tesseract.js';
import { computeOtsuThreshold } from './threshold.js';
// Der einmal erzeugte Erkennungs-Arbeiter wird als Versprechen zwischen-
// gespeichert, damit sich auch mehrere schnell hintereinander gestartete
@@ -16,65 +17,15 @@ let workerPromise = null;
// Oberflaeche verschwinden lassen - siehe Bericht, Abschnitt Selbstpruefung.
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.
* Glaenzende Metalletiketten liefern flaue, kontrastarme Bilder mit
* Lichtreflexen als Normalfall; ohne diese Aufbereitung liest Tesseract dort
* kaum etwas Brauchbares. Der Schwellwert wird aus der Helligkeitsverteilung
* des Bildes selbst bestimmt (Otsu), statt aus einer Kontrastspreizung
* zwischen dunkelstem und hellstem Pixel - ein einzelner Reflex kann so
* nicht mehr Schrift und Untergrund gemeinsam auf dieselbe Seite eines
* festen Schwellwerts ziehen.
* des Bildes selbst bestimmt (Otsu, siehe threshold.js), statt aus einer
* Kontrastspreizung zwischen dunkelstem und hellstem Pixel - ein einzelner
* Reflex kann so nicht mehr Schrift und Untergrund gemeinsam auf dieselbe
* Seite eines festen Schwellwerts ziehen.
* Rein rechnend - erzeugt kein Canvas, fasst kein DOM an und ist damit
* ohne Browser testbar.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
@@ -82,16 +33,7 @@ function otsuThreshold(histogram, totalPixels) {
*/
export function preprocess(imageData) {
const { width, height, data } = imageData;
const gray = new Uint8ClampedArray(width * height);
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 { gray, threshold } = computeOtsuThreshold(imageData);
const out = new Uint8ClampedArray(data.length);
for (let p = 0; p < gray.length; p += 1) {
const value = gray[p] >= threshold ? 255 : 0;
+21 -1
View File
@@ -1,4 +1,4 @@
// Die drei Scan-Funktionen, die der Nutzer vor dem Scannen waehlt. Reines
// 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
@@ -11,6 +11,12 @@
// 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`, obwohl sie (wie die Barcode-Dauersuche) fortwaehrend
// laeuft, solange die Kamera aktiv ist. Dafuer eigenes Merkmal
// `continuousCount`, siehe main.js.
/**
* @typedef {object} ScanMode
@@ -22,6 +28,10 @@
* @property {boolean} continuousSearch Ob waehrend die Kamera laeuft
* fortwaehrend nach Codes gesucht wird (statt nur auf Knopfdruck).
* @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[]} */
@@ -56,6 +66,16 @@ export const SCAN_MODES = [
continuousSearch: false,
useOcr: true,
},
{
id: 'count',
label: 'Zählen',
// Keine Codes, keine Texterkennung - reine Bildauswertung (siehe
// count-objects.js), die nichts bucht.
barcodeFormats: [],
continuousSearch: false,
useOcr: false,
continuousCount: true,
},
];
/**
+7 -1
View File
@@ -10,6 +10,12 @@
// 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: kein Knopf, keine Dauersuche im Sinne
// dieses Moduls); 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.
import { getScanMode } from './scan-modes.js';
@@ -24,7 +30,7 @@ export function buildRecognitionAdapters(modeId, adapters) {
const mode = getScanMode(modeId);
return {
decodeBarcodes: mode.useOcr
decodeBarcodes: (mode.useOcr || mode.continuousCount)
? async () => []
: (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats),
runOcr: mode.useOcr
+19
View File
@@ -52,6 +52,25 @@ body {
border-color: var(--green);
}
/* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt
ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert.
pointer-events: none, damit sie den Rahmen/die Kamera nicht fuer
Beruehrungen blockiert - diese Funktion hat ohnehin keinen Knopf. Riesige
Schriftgroesse mit Schlagschatten statt Kontrastfarbe, damit die Zahl auf
jedem Untergrund (hell wie dunkel) aus Armlaenge lesbar bleibt. */
.count-display {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
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: none;
}
.stacks {
display: flex;
gap: 8px;
+91
View File
@@ -0,0 +1,91 @@
// Otsu-Schwellwertbestimmung: reines Modul ohne jeden Browser-Zugriff (kein
// window/document/canvas). Vorher steckte das ausschließlich in `preprocess`
// in ocr.js - das aber tesseract.js einbindet und damit nichts mit reiner
// Bildauswertung zu tun hat. Sowohl `preprocess` (ocr.js, für die
// Texterkennung) als auch `countObjects` (count-objects.js, für die
// Zähl-Funktion) brauchen denselben ersten Schritt - Graustufen bilden und
// die Helligkeitsverteilung in zwei Klassen teilen - und lesen ihn deshalb
// beide von hier.
/**
* Bestimmt den Otsu-Schwellwert aus einem Histogramm der 256 Helligkeits-
* stufen. Otsu sucht den Schwellwert, der die Pixel in zwei Gruppen teilt
* (dunkler / heller) und dabei die Streuung innerhalb jeder Gruppe minimiert
* bzw. die Streuung zwischen den Gruppen maximiert - beides ist aequivalent
* und wird hier ueber die Zwischen-Klassen-Varianz berechnet.
* Da der Schwellwert aus der gesamten Verteilung stammt, verschiebt ein
* einzelnes Ausreisserpixel (z. B. ein Lichtreflex) das Histogramm kaum und
* damit auch den Schwellwert kaum - anders als eine Min/Max-Spreizung, die
* von genau diesem einen Pixel dominiert wird.
* @param {Uint32Array | number[]} histogram 256 Eintraege, einer je Helligkeitsstufe
* @param {number} totalPixels Anzahl der Pixel, aus denen das Histogramm stammt
* @returns {number} Schwellwert (0-255): Helligkeit darunter wird schwarz, ab hier weiss
*/
export function otsuThreshold(histogram, totalPixels) {
if (totalPixels === 0) return 128;
let sumAll = 0;
for (let level = 0; level < 256; level += 1) {
sumAll += level * histogram[level];
}
let sumBackground = 0;
let weightBackground = 0;
let bestVariance = -1;
let bestThreshold = 128;
for (let level = 0; level < 256; level += 1) {
weightBackground += histogram[level];
if (weightBackground === 0) continue;
const weightForeground = totalPixels - weightBackground;
if (weightForeground === 0) break;
sumBackground += level * histogram[level];
const meanBackground = sumBackground / weightBackground;
const meanForeground = (sumAll - sumBackground) / weightForeground;
const meanDiff = meanBackground - meanForeground;
const betweenClassVariance = weightBackground * weightForeground * meanDiff * meanDiff;
if (betweenClassVariance > bestVariance) {
bestVariance = betweenClassVariance;
bestThreshold = level + 1;
}
}
return bestThreshold;
}
/**
* Graustufen (gewichtetes RGB, wie menschliche Helligkeitswahrnehmung) und
* das zugehörige 256-stufige Helligkeitshistogramm - der gemeinsame erste
* Schritt für `preprocess` (ocr.js) und `countObjects` (count-objects.js).
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{gray: Uint8ClampedArray, histogram: Uint32Array}}
*/
export function grayscaleHistogram(imageData) {
const { data } = imageData;
const gray = new Uint8ClampedArray(data.length / 4);
const histogram = new Uint32Array(256);
for (let i = 0, p = 0; i < data.length; i += 4, p += 1) {
const value = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
gray[p] = value;
histogram[value] += 1;
}
return { gray, histogram };
}
/**
* Graustufen und Otsu-Schwellwert in einem Schritt - der gemeinsame
* Ausgangspunkt für `preprocess` und `countObjects`.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{gray: Uint8ClampedArray, threshold: number}}
*/
export function computeOtsuThreshold(imageData) {
const { gray, histogram } = grayscaleHistogram(imageData);
const threshold = otsuThreshold(histogram, gray.length);
return { gray, threshold };
}
+1
View File
@@ -6,6 +6,7 @@ import { SCAN_MODES } from '../scan-modes.js';
*/
function describeMode(mode) {
if (mode.useOcr) return 'Auf Knopfdruck';
if (mode.continuousCount) return 'Zählt fortlaufend, solange die Kamera läuft';
return 'Läuft von selbst, sobald ein Code im Bild ist';
}
+24
View File
@@ -10,6 +10,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
<div class="camera">
<video id="preview" playsinline muted></video>
<div class="frame"></div>
<div class="count-display" id="count-display" hidden aria-live="polite"></div>
</div>
<div class="status" id="status"></div>
<button class="action capture" id="capture" hidden>Modul scannen</button>
@@ -30,6 +31,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
const frameEl = root.querySelector('.frame');
const captureEl = root.querySelector('#capture');
const modeButtonEl = root.querySelector('#mode-button');
const countDisplayEl = root.querySelector('#count-display');
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
@@ -129,5 +131,27 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
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.
* @param {boolean} visible
*/
setCountVisible(visible) {
countDisplayEl.hidden = !visible;
if (!visible) countDisplayEl.textContent = '';
},
/**
* Zeigt die zuletzt ermittelte Anzahl gefundener Fundstuecke (siehe
* count-objects.js) - gross und aus Armlaenge lesbar, aktualisiert sich
* mit jedem Durchlauf der Dauerzaehlung.
* @param {number} count
*/
setCount(count) {
countDisplayEl.textContent = String(count);
},
};
}
+57
View File
@@ -0,0 +1,57 @@
// Prueffall am echten Foto des Auftraggebers (siehe
// .superpowers/sdd/counting-rework-report.md): Otsu allein scheiterte hier,
// weil die Schrauben unter einem Prozent der Bildflaeche belegen - Otsu
// rutscht in so einem Fall auf den hoechsten Helligkeitswert und erklaert nur
// die hellsten Punkte der Ledernarbung zum Objekt. Die fruehere Fassung
// meldete an genau diesem Bild 76712 statt der rund zwanzig tatsaechlich
// ausgelegten Schrauben. Dieser Test schuetzt vor einem Rueckfall in dieses
// Verhalten.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { countObjects } from '../src/count-objects.js';
import {
SCREWS_WIDTH,
SCREWS_HEIGHT,
SCREWS_GRAY_BASE64,
} from './fixtures/screws-photo.js';
/**
* Baut aus dem Base64-kodierten Graustufenraster ein ImageData-artiges
* Objekt: Graustufenwert auf R, G und B gelegt, Alpha 255 - wie eine echte
* Kameraaufnahme ohne Farbinformation.
*/
function grayscaleImage(width, height, base64) {
const gray = Buffer.from(base64, 'base64');
const data = new Uint8ClampedArray(width * height * 4);
for (let p = 0, i = 0; p < width * height; p += 1, i += 4) {
const value = gray[p];
data[i] = value;
data[i + 1] = value;
data[i + 2] = value;
data[i + 3] = 255;
}
return { width, height, data };
}
test('echtes Foto (~20 Festplattenschrauben auf Leder) ergibt eine Zahl zwischen 15 und 24', () => {
const imageData = grayscaleImage(SCREWS_WIDTH, SCREWS_HEIGHT, SCREWS_GRAY_BASE64);
const start = performance.now();
const result = countObjects(imageData);
const durationMs = performance.now() - start;
// Nur zur Beobachtung im Testlauf, keine Zusicherung ueber die Konsole -
// die Laufzeit selbst wird unten geprueft.
console.log(`countObjects(screws-photo) dauerte ${durationMs.toFixed(2)} ms`);
assert.ok(
result.count >= 15 && result.count <= 24,
`erwartet 15..24, erhalten ${result.count} (fruehere Fassung meldete 76712)`,
);
// Soll mehrmals je Sekunde laufen koennen (laufende Anzeige waehrend die
// Kamera laeuft) - grosszuegige Grenze, die auf langsamerer Hardware nicht
// faelschlich anschlaegt, aber ein grundlegend zu langsames Verfahren
// (z. B. eine versehentlich quadratische Rechnung) auffangen wuerde.
assert.ok(durationMs < 500, `erwartet unter 500 ms, gemessen ${durationMs.toFixed(2)} ms`);
});
+160
View File
@@ -0,0 +1,160 @@
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');
});
+10
View File
File diff suppressed because one or more lines are too long
+13 -4
View File
@@ -2,9 +2,9 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SCAN_MODES, getScanMode } from '../src/scan-modes.js';
test('genau drei Funktionen in fester Reihenfolge', () => {
assert.equal(SCAN_MODES.length, 3);
assert.deepEqual(SCAN_MODES.map((mode) => mode.id), ['barcode', 'qrcode', 'ocr']);
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', () => {
@@ -28,7 +28,7 @@ test('Funktion 2 (QR-Code): QR-Varianten und DataMatrix, Aztec, PDF417', () => {
assert.equal(mode.useOcr, false);
});
test('die drei Funktionen haben paarweise verschiedene Kennungen und Beschriftungen', () => {
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');
@@ -43,6 +43,15 @@ test('Funktion 3 (Text erkennen): keine Codearten, keine laufende Suche, Texterk
assert.equal(mode.useOcr, true);
});
test('Funktion 4 (Zählen): keine Codearten, keine laufende Codesuche, keine Texterkennung, dafuer laufende Zaehlung', () => {
const mode = getScanMode('count');
assert.equal(mode.label, 'Zählen');
assert.deepEqual(mode.barcodeFormats, []);
assert.equal(mode.continuousSearch, false);
assert.equal(mode.useOcr, false);
assert.equal(mode.continuousCount, true);
});
test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => {
assert.throws(() => getScanMode('unbekannt'));
});
+13
View File
@@ -67,6 +67,19 @@ test('decodeBarcodes bekommt die Codearten der jeweiligen Funktion uebergeben',
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', {