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>
This commit is contained in:
2026-07-29 15:47:44 +02:00
co-authored by Claude Opus 5
parent 5e64d260c5
commit 8c761c6fb5
8 changed files with 145 additions and 10 deletions
+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.
+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;
+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);
},
};
}
+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', {