diff --git a/src/main.js b/src/main.js index 0497bf8..24aa5b3 100644 --- a/src/main.js +++ b/src/main.js @@ -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. diff --git a/src/scan-modes.js b/src/scan-modes.js index dddb5bd..1edab0b 100644 --- a/src/scan-modes.js +++ b/src/scan-modes.js @@ -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, + }, ]; /** diff --git a/src/scan-recognition.js b/src/scan-recognition.js index c2f03d1..54d3554 100644 --- a/src/scan-recognition.js +++ b/src/scan-recognition.js @@ -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 diff --git a/src/styles.css b/src/styles.css index 4612f92..b22eb12 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/ui/mode-dialog.js b/src/ui/mode-dialog.js index 68deb0d..80b0cd6 100644 --- a/src/ui/mode-dialog.js +++ b/src/ui/mode-dialog.js @@ -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'; } diff --git a/src/ui/scan-view.js b/src/ui/scan-view.js index d33b1d1..13f31e1 100644 --- a/src/ui/scan-view.js +++ b/src/ui/scan-view.js @@ -10,6 +10,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
+
@@ -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); + }, }; } diff --git a/test/scan-modes.test.js b/test/scan-modes.test.js index 4206d64..aa6c637 100644 --- a/test/scan-modes.test.js +++ b/test/scan-modes.test.js @@ -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')); }); diff --git a/test/scan-recognition.test.js b/test/scan-recognition.test.js index 778e382..44de3d4 100644 --- a/test/scan-recognition.test.js +++ b/test/scan-recognition.test.js @@ -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', {