Compare commits
7
Commits
87ba439287
...
f0705a010c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0705a010c | ||
|
|
089d47a1ec | ||
|
|
75c479860f | ||
|
|
ffb2409c24 | ||
|
|
8c761c6fb5 | ||
|
|
5e64d260c5 | ||
|
|
500f9b6c3d |
@@ -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 1–4 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 1–3 und 5 unten)
|
||||
bleibt dabei unverändert und kennt keine Funktionen — die drei
|
||||
code-/textbasierten Funktionen unterscheiden sich ausschließlich darin,
|
||||
welche Adapter ihr übergeben werden. „Zählen" ruft diese Pipeline im
|
||||
normalen Betrieb gar nicht erst auf (siehe Punkt 4 unten) — es gibt dort ja
|
||||
nichts zu buchen, das leere Adapterpaar dient nur als Absicherung für den
|
||||
seltenen Ersatzweg ohne Kamera (Punkt 7).
|
||||
1. **Barcode-Dekodierung** (nur in „Strichcode" und „QR-Code"). Die zur
|
||||
Funktion gehörenden Codearten werden laufend aus dem Kamerabild dekodiert
|
||||
(`zxing-wasm`, `decodeBarcodes` in `src/barcode.js` bekommt die Codearten
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Prueffall am echten Foto des Auftraggebers (siehe
|
||||
// .superpowers/sdd/counting-rework-report.md): Otsu allein scheiterte hier,
|
||||
// weil die Schrauben unter einem Prozent der Bildflaeche belegen - Otsu
|
||||
// rutscht in so einem Fall auf den hoechsten Helligkeitswert und erklaert nur
|
||||
// die hellsten Punkte der Ledernarbung zum Objekt. Die fruehere Fassung
|
||||
// meldete an genau diesem Bild 76712 statt der rund zwanzig tatsaechlich
|
||||
// ausgelegten Schrauben. Dieser Test schuetzt vor einem Rueckfall in dieses
|
||||
// Verhalten.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countObjects } from '../src/count-objects.js';
|
||||
import {
|
||||
SCREWS_WIDTH,
|
||||
SCREWS_HEIGHT,
|
||||
SCREWS_GRAY_BASE64,
|
||||
} from './fixtures/screws-photo.js';
|
||||
|
||||
/**
|
||||
* Baut aus dem Base64-kodierten Graustufenraster ein ImageData-artiges
|
||||
* Objekt: Graustufenwert auf R, G und B gelegt, Alpha 255 - wie eine echte
|
||||
* Kameraaufnahme ohne Farbinformation.
|
||||
*/
|
||||
function grayscaleImage(width, height, base64) {
|
||||
const gray = Buffer.from(base64, 'base64');
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let p = 0, i = 0; p < width * height; p += 1, i += 4) {
|
||||
const value = gray[p];
|
||||
data[i] = value;
|
||||
data[i + 1] = value;
|
||||
data[i + 2] = value;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
test('echtes Foto (~20 Festplattenschrauben auf Leder) ergibt eine Zahl zwischen 15 und 24', () => {
|
||||
const imageData = grayscaleImage(SCREWS_WIDTH, SCREWS_HEIGHT, SCREWS_GRAY_BASE64);
|
||||
|
||||
const start = performance.now();
|
||||
const result = countObjects(imageData);
|
||||
const durationMs = performance.now() - start;
|
||||
|
||||
// Nur zur Beobachtung im Testlauf, keine Zusicherung ueber die Konsole -
|
||||
// die Laufzeit selbst wird unten geprueft.
|
||||
console.log(`countObjects(screws-photo) dauerte ${durationMs.toFixed(2)} ms`);
|
||||
|
||||
assert.ok(
|
||||
result.count >= 15 && result.count <= 24,
|
||||
`erwartet 15..24, erhalten ${result.count} (fruehere Fassung meldete 76712)`,
|
||||
);
|
||||
|
||||
// Soll mehrmals je Sekunde laufen koennen (laufende Anzeige waehrend die
|
||||
// Kamera laeuft) - grosszuegige Grenze, die auf langsamerer Hardware nicht
|
||||
// faelschlich anschlaegt, aber ein grundlegend zu langsames Verfahren
|
||||
// (z. B. eine versehentlich quadratische Rechnung) auffangen wuerde.
|
||||
assert.ok(durationMs < 500, `erwartet unter 500 ms, gemessen ${durationMs.toFixed(2)} ms`);
|
||||
});
|
||||
@@ -0,0 +1,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');
|
||||
});
|
||||
Vendored
+10
File diff suppressed because one or more lines are too long
+13
-4
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user