diff --git a/src/count-capture.js b/src/count-capture.js new file mode 100644 index 0000000..fe04827 --- /dev/null +++ b/src/count-capture.js @@ -0,0 +1,39 @@ +// Bildet aus den Einzelmessungen einer Zaehl-Aufnahme (Funktion "Zählen", +// siehe main.js) das anzuzeigende Endergebnis. main.js nimmt je Antippen der +// Schaltflaeche "Zählen"/"Nochmal zählen" binnen rund einer Sekunde mehrere +// Bilder aus dem Zielrahmen auf und zaehlt jedes einzeln (countObjects() in +// count-objects.js, unveraendert) - ein einzelnes verwackeltes oder mitten in +// eine Fokusregelung fallendes Bild soll das Ergebnis nicht verzerren. Frueher +// glaettete ein gleitender Median ueber die letzten neun Durchlaeufe einer +// Dauerzaehlung (siehe .superpowers/sdd/counting-capture-report.md); das ist +// entfallen, weil eine Aufnahme mit mehreren Bildern dieselbe Robustheit +// bereits in sich selbst leistet - zwei Glaettungen uebereinander waeren nur +// zusaetzliche Traegheit ohne zusaetzlichen Nutzen. +// +// Der Median der Einzelmessungen ist robust gegen einen einzelnen groben +// Ausreisser (anders als deren Mittelwert) - siehe median() in +// count-objects.js fuer dieselbe Ueberlegung bei den dortigen +// Flaechengroessen. +// +// Reines, browserfreies Modul wie count-objects.js - kennt weder Kamera noch +// Bilddaten, nur die Zahlenreihe der Einzelmessungen selbst. +import { median } from './count-objects.js'; + +/** + * Bildet den Median der Einzelmessungen einer Aufnahme (siehe Moduldoku + * oben). Bleibt auch dann sinnvoll, wenn eine Aufnahme aus weniger als den + * vorgesehenen fuenf Bildern besteht (z. B. weil das Kamerabild waehrend + * eines Versuchs kurz nicht bereit war, siehe grabFrameRegion() in + * camera.js) - nur eine vollstaendig leere Aufnahme (kein einziges + * verwertbares Bild) ist kein regulaerer Ausgang und wirft; main.js prueft + * diesen Fall vorher selbst und ruft diese Funktion dann gar nicht erst auf. + * @param {number[]} counts Einzelmessungen dieser Aufnahme - nicht-leer. + * @returns {number} + * @throws {Error} wenn counts leer ist. + */ +export function combineCaptureCounts(counts) { + if (counts.length === 0) { + throw new Error('combineCaptureCounts: keine Messung vorhanden'); + } + return median(counts); +} diff --git a/src/count-history.js b/src/count-history.js deleted file mode 100644 index 2a978a6..0000000 --- a/src/count-history.js +++ /dev/null @@ -1,50 +0,0 @@ -// Verwaltet den gleitenden Verlauf der letzten Zaehlmessungen der Funktion -// "Zählen" (siehe count-objects.js, verdrahtet in main.js). Die App zeigt -// zusaetzlich zur rohen letzten Messung deren Median ueber die letzten -// HISTORY_WINDOW Durchlaeufe als grosse Zahl an: ein einzelner Ausreisser -// (Bewegungsunschaerfe, nachregelnder Autofokus, kurzzeitig verdeckte Teile) -// verschwindet dadurch, ohne dass die Anzeige traege wird - eine echte, -// anhaltende Aenderung (der Nutzer nimmt tatsaechlich Teile weg) schlaegt -// nach genuegend neuen Messungen weiterhin durch. -// -// Reines, browserfreies Modul wie count-objects.js - kennt weder Bilddaten -// noch die Oberflaeche, nur die Zahlenreihe selbst. Verwendet denselben -// robusten Median wie die Flaechengroessen dort (siehe median() in -// count-objects.js). -import { median } from './count-objects.js'; - -// Fenstergroesse fuer den gleitenden Median (siehe Moduldoku oben) - neun -// Messungen bei rund fuenf Durchlaeufen pro Sekunde (siehe -// SEARCH_INTERVAL_MS in main.js) sind knapp zwei Sekunden Verlauf: kurz -// genug, um eine tatsaechliche Aenderung zuegig zu uebernehmen, lang genug, -// um einen einzelnen Ausreisser zuverlaessig zu unterdruecken. -export const HISTORY_WINDOW = 9; - -/** - * Erstellt einen leeren Verlauf. Wird verwendet, wenn die Funktion "Zählen" - * (neu) betreten wird oder der Nutzer die Scan-Funktion wechselt - der - * Verlauf einer anderen Situation darf nicht in die neue hinueberwirken - * (siehe main.js/applyMode()). - * @returns {number[]} - */ -export function createCountHistory() { - return []; -} - -/** - * Haengt eine neue rohe Messung an den Verlauf an (behaelt hoechstens die - * letzten HISTORY_WINDOW Messungen, die aeltesten fallen zuerst heraus) und - * bildet daraus den Median. Reine Funktion - veraendert `history` selbst - * nicht, sondern liefert einen neuen Verlauf zurueck; der Aufrufer haelt - * seinen eigenen Zustand (siehe main.js). - * @param {number[]} history bisheriger Verlauf, aelteste Messung zuerst - * @param {number} rawCount rohe, gerade gemessene Anzahl - * @returns {{history: number[], median: number}} - */ -export function pushCount(history, rawCount) { - const updated = [...history, rawCount]; - if (updated.length > HISTORY_WINDOW) { - updated.shift(); - } - return { history: updated, median: median(updated) }; -} diff --git a/src/main.js b/src/main.js index 7ed64dc..18f71b4 100644 --- a/src/main.js +++ b/src/main.js @@ -4,7 +4,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 { createCountHistory, pushCount } from './count-history.js'; +import { combineCaptureCounts } from './count-capture.js'; import { recognize } from './pipeline.js'; import { normalizeToken } from './spec.js'; import { getScanMode } from './scan-modes.js'; @@ -51,23 +51,33 @@ let cameraUnavailableMessage = null; // ohnehin unerreichbar. let currentModeId = null; -// Gleitender Verlauf der letzten Zaehlmessungen (Funktion "Zählen", siehe -// count-history.js) - Grundlage der beruhigten grossen Zahl (Median der -// letzten neun Messungen). Wird bei jedem Aufruf von applyMode() neu -// angelegt: sowohl ein Wechsel weg von "Zählen" als auch ein (erneuter) -// Eintritt in die Funktion soll mit einem leeren Verlauf beginnen, damit die -// Anzeige nie Werte einer anderen Situation (anderes Teil, andere Stelle) -// zeigt. -let countHistory = createCountHistory(); +// Ob die aktuelle Sitzung in Funktion "Zählen" bereits eine Aufnahme gemacht +// hat - traegt die Beschriftung der Aufnahme-Schaltflaeche ("Zählen" vor der +// ersten Aufnahme seit Betreten der Funktion, "Nochmal zählen" danach, siehe +// updateCountCaptureLabel() unten). Wird bei jedem Aufruf von applyMode() +// zurueckgesetzt: sowohl ein Wechsel weg von "Zählen" als auch ein (erneuter) +// Eintritt in die Funktion beginnt wieder bei "Zählen" - siehe auch +// view.setCountVisible() (loescht das stehende Ergebnis samt Diagnose beim +// Verlassen). +let hasCountResult = false; const view = renderScanView(app, { - // "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe - // applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort - // einen Ausschnitt, den grabFrame() (Vollbild, herunterskaliert) bisher gar - // nicht einloeste. grabFrameRegion() liest genau den Rahmenausschnitt, in - // voller Aufloesung - derselbe Ausschnitt, den auch die Barcode-Dauersuche + // Eine gemeinsame Schaltflaeche fuer beide Knopfdruck-Funktionen (siehe + // scan-view.js/setCaptureLabel()): in Funktion "Text erkennen" loest sie + // processCapture() aus (Erkennung + Buchung), in Funktion "Zählen" + // stattdessen runCountCapture() (Aufnahme mehrerer Bilder, keine Buchung). + // Der Zielrahmen suggeriert dem Nutzer in beiden Faellen einen Ausschnitt, + // den grabFrame() (Vollbild, herunterskaliert) nicht einloeste - + // grabFrameRegion() liest genau den Rahmenausschnitt, in voller + // Aufloesung - derselbe Ausschnitt, den auch die Barcode-Dauersuche // benutzt (siehe runSearchAttempt() weiter unten). - onCapture: () => processCapture(() => grabFrameRegion(view.video)), + onCapture: () => { + if (currentModeId === 'count') { + runCountCapture(); + } else { + processCapture(() => grabFrameRegion(view.video)); + } + }, onUndo: () => { // Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen // werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen, @@ -93,32 +103,38 @@ const view = renderScanView(app, { }); /** - * Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit von - * "Modul scannen" und - bei einem Wechsel weg von laufender Codesuche - der - * Zielrahmen wird sofort zurueckgesetzt, statt auf den naechsten Durchlauf - * der Dauersuche zu warten. + * Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit und + * Beschriftung der Aufnahme-Schaltflaeche und - bei einem Wechsel weg von + * laufender Codesuche - der Zielrahmen wird sofort zurueckgesetzt, statt auf + * den naechsten Durchlauf der Dauersuche zu warten. * @param {string} modeId */ function applyMode(modeId) { currentModeId = modeId; - // Verlauf der Dauerzaehlung zuruecksetzen (siehe Kommentar bei der - // Deklaration oben) - unabhaengig davon, ob die neue Funktion "Zählen" - // ist: ein Wechsel weg davon soll den Verlauf ebenso wenig ueberleben wie - // ein erneuter Eintritt mit alten Werten. - countHistory = createCountHistory(); const mode = getScanMode(modeId); view.setMode(mode.label); - // "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); + // Die Aufnahme-Schaltflaeche hat nur dort etwas zu tun, wo es keine + // laufende Codesuche gibt (Funktion 3 "Text erkennen" und Funktion 4 + // "Zählen") - siehe Kommentar in scan-view.js/setCaptureVisible. + view.setCaptureVisible(!mode.continuousSearch); if (!mode.continuousSearch) { view.setFrameDetected(false); } + // "Zählen" beginnt bei jedem (erneuten) Betreten wieder bei der + // Beschriftung "Zählen" statt "Nochmal zählen" - ein Wechsel weg von der + // Funktion soll das ebenso wenig ueberleben wie ein erneuter Eintritt mit + // altem Zustand (siehe Deklaration von hasCountResult oben). + hasCountResult = false; + if (mode.id === 'count') { + updateCountCaptureLabel(); + } else { + view.setCaptureLabel(mode.useOcr ? 'Modul scannen' : ''); + } // Die grosse Zaehlanzeige gehoert ausschliesslich zu Funktion "Zählen" - - // beim Verlassen sofort ausblenden, statt auf den naechsten (dann gar - // nicht mehr laufenden) Durchlauf der Dauerzaehlung zu warten. - view.setCountVisible(mode.continuousCount); + // beim Verlassen sofort ausblenden (loescht dabei auch ein stehendes + // Ergebnis samt Diagnose, siehe scan-view.js/setCountVisible), statt auf + // die naechste Aufnahme zu warten. + view.setCountVisible(mode.id === 'count'); } /** @@ -143,6 +159,25 @@ function describeEntry(spec, stackId) { return `${describeSpec(spec)} → Stapel ${stackId}`; } +/** + * Stellt die Statuszeile nach einem erfolgreich abgeschlossenen Durchlauf + * (Erkennung wie Zaehl-Aufnahme) wieder her - von processCapture() und + * runCountCapture() gemeinsam genutzt, statt denselben Vorrang zweimal + * hinzuschreiben. Der Hinweis auf eine nicht verfuegbare Kamera hat Vorrang + * vor der OCR-Verfuegbarkeitsanzeige und bleibt dauerhaft stehen. + */ +function restoreIdleStatus() { + if (cameraUnavailableMessage) { + view.setStatus(cameraUnavailableMessage, true); + } else { + const available = isOcrAvailable(); + view.setStatus( + available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', + !available, + ); + } +} + /** * Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung * nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch, @@ -241,19 +276,8 @@ async function processCapture(getFrame) { } finally { busy = false; // Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer - // noch lesen muss, nicht ueberschreiben. Der Hinweis auf eine nicht - // verfuegbare Kamera hat Vorrang vor beidem und bleibt dauerhaft stehen. - if (!failed) { - if (cameraUnavailableMessage) { - view.setStatus(cameraUnavailableMessage, true); - } else { - const available = isOcrAvailable(); - view.setStatus( - available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen', - !available, - ); - } - } + // noch lesen muss, nicht ueberschreiben. + if (!failed) restoreIdleStatus(); } } @@ -384,95 +408,147 @@ function describePolarity(polarity) { * ui/scan-view.js/setCountDiagnostics()). Kurze, unfachliche Bezeichnungen * in eigenen Zeilen statt einer einzigen langen Zeile - der Nutzer soll sie * bei Bedarf jemandem am Telefon vorlesen koennen, ohne sich zu verhaspeln. - * Enthaelt bewusst sowohl die rohe letzte Messung (zeigt die tatsaechliche - * Schwankung, die die grosse - geglaettete - Zahl sonst verbergen wuerde) - * als auch den Zeitbedarf des letzten Zaehldurchlaufs (zeigt, ob das Geraet - * ueberhaupt hinterherkommt). - * @param {ReturnType} result - * @param {number} durationMs + * Enthaelt zusaetzlich zu den schon vorhandenen Werten die fuenf + * Einzelmessungen dieser Aufnahme (`counts`) - daran erkennen Nutzer und + * Entwickler sofort, ob das Ergebnis belastbar ist (fuenf Messungen von 17 + * bis 19 bedeuten etwas anderes als fuenf Messungen von 4 bis 50). Die + * uebrigen Werte (`last`) beziehen sich weiterhin auf die *letzte* der + * fuenf Einzelmessungen - der gleitende Median ueber mehrere Durchlaeufe + * (frueher: count-history.js) entfaellt, die Aufnahme mit ihren fuenf + * Bildern leistet die noetige Beruhigung bereits selbst (siehe + * count-capture.js). + * @param {number[]} counts die (bis zu fuenf) Einzelmessungen dieser Aufnahme + * @param {ReturnType} last Ergebnis der letzten der + * fuenf Einzelmessungen + * @param {number} durationMs Zeitbedarf der letzten Einzelmessung */ -function describeCountDiagnostics(result, durationMs) { +function describeCountDiagnostics(counts, last, durationMs) { return [ - `Roh: ${result.count}`, - `Bild: ${result.width}×${result.height}`, - `Flächen: ${result.regionsFound} → ${result.regionsKept}`, - `Größte Fläche: ${result.largestArea} Bildpunkte`, - `Typische Größe: ${result.typicalArea} Bildpunkte`, - `Art: ${describePolarity(result.polarity)}`, + `Roh: ${last.count}`, + `Messungen: ${counts.join(', ')}`, + `Bild: ${last.width}×${last.height}`, + `Flächen: ${last.regionsFound} → ${last.regionsKept}`, + `Größte Fläche: ${last.largestArea} Bildpunkte`, + `Typische Größe: ${last.typicalArea} Bildpunkte`, + `Art: ${describePolarity(last.polarity)}`, `Dauer: ${durationMs.toFixed(0)} ms`, ].join('\n'); } +// Anzahl der Bilder je Zaehl-Aufnahme (siehe runCountCapture() unten): ein +// einzelnes Videobild kann verwackelt sein oder mitten in eine +// Fokusregelung fallen - fuenf einzeln gezaehlte Bilder, zusammengefasst +// ueber ihren Median (count-capture.js), sind robuster als eines allein. +const CAPTURE_SAMPLE_SIZE = 5; + +// Zeitspanne, ueber die die CAPTURE_SAMPLE_SIZE Bilder einer Aufnahme verteilt +// aufgenommen werden - kurz genug, dass der Nutzer die Kamera dafuer ohne +// Muehe ruhig haelt, lang genug, dass aufeinanderfolgende Bilder sich +// tatsaechlich unterscheiden (statt praktisch identische Wiederholungen +// desselben Videobilds zu zaehlen). +const CAPTURE_DURATION_MS = 1000; + /** - * 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). - * - * Die grosse Zahl selbst zeigt nicht die rohe Messung, sondern den Median - * der letzten neun Messungen (siehe count-history.js): ein einzelner - * Ausreisser - Bewegungsunschaerfe, nachregelnder Autofokus, ein kurz - * verdecktes Teil - verschwindet damit, ohne dass die Anzeige traege wird. - * Die rohe Messung selbst geht nicht verloren, sondern steht (zusammen mit - * den uebrigen Zwischenwerten) in der Diagnosezeile - siehe - * describeCountDiagnostics() oben. + * Setzt die Beschriftung der Aufnahme-Schaltflaeche in Funktion "Zählen" + * anhand von hasCountResult (siehe Deklaration oben): "Zählen" vor der + * ersten Aufnahme seit Betreten der Funktion, "Nochmal zählen" danach. */ -async function runCountAttempt() { - // Nur in Funktion "Zählen" aktiv - siehe scan-modes.js. - if (!getScanMode(currentModeId).continuousCount) return; +function updateCountCaptureLabel() { + view.setCaptureLabel(hasCountResult ? 'Nochmal zählen' : 'Zählen'); +} - // Dieselbe Pausenbedingung wie bei der Barcode-Dauersuche (laufende - // Erkennung oder offenes Vollbild-Overlay) - siehe searchPaused() oben. - if (searchPaused()) return; +/** + * Eine Aufnahme der Funktion "Zählen" (ausgeloest durch die Schaltflaeche + * "Zählen"/"Nochmal zählen"): nimmt binnen rund CAPTURE_DURATION_MS + * insgesamt CAPTURE_SAMPLE_SIZE Bilder aus dem Zielrahmen des laufenden + * Videobilds auf (`grabFrameRegion`, derselbe Ausschnitt wie ueberall sonst + * - bewusst *nicht* die native Fotoaufnahme des Geraets, deren + * Bildausschnitt von der Vorschau abweichen kann), zaehlt jedes einzeln + * (`countObjects`, unveraendert) und zeigt als Ergebnis deren Median + * (`combineCaptureCounts`, count-capture.js) - robust gegen ein einzelnes + * verwackeltes oder unscharfes Bild. Waehrend der Aufnahme zeigt die + * Statuszeile "zähle …" (dasselbe Muster wie "erkenne …" in + * processCapture()), damit der Nutzer das Handy ruhig haelt und den + * Knopfdruck nicht fuer wirkungslos haelt. + * + * Anders als frueher (Dauerzaehlung) bleibt das Ergebnis danach stehen, bis + * der Nutzer erneut antippt - eine Zaehlung ist eine Schaetzung, die durch + * staendiges Neuanzeigen nicht praeziser wird, nur unruhig. Bucht nichts in + * die Sitzung, sperrt aber wie processCapture() ueber `busy` waehrend sie + * laeuft (Undo, Sitzungsliste, Funktionswechsel bleiben so lange + * unerreichbar - dieselbe kurze, unproblematische Sperre wie bei jedem + * anderen Scan-Durchlauf). + */ +async function runCountCapture() { + if (busy) return; + busy = true; + view.setStatus('zähle …'); + let failed = false; - let region; try { - region = grabFrameRegion(view.video); - } catch { - // Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf - // versucht es erneut. - return; + const counts = []; + let last = null; + + for (let i = 0; i < CAPTURE_SAMPLE_SIZE; i += 1) { + if (i > 0) { + await sleep(CAPTURE_DURATION_MS / (CAPTURE_SAMPLE_SIZE - 1)); + } + + let region; + try { + region = grabFrameRegion(view.video); + } catch { + // Kamerabild in diesem Augenblick nicht bereit - dieses Einzelbild + // faellt aus, die Aufnahme laeuft mit den uebrigen weiter (siehe + // combineCaptureCounts() fuer den Fall weniger als fuenf Bilder). + continue; + } + + const start = performance.now(); + const result = countObjects(region); + const durationMs = performance.now() - start; + counts.push(result.count); + last = { result, durationMs }; + } + + if (last === null) { + // Kein einziges Bild dieser Aufnahme war verwertbar - kein regulaeres + // Ergebnis, siehe combineCaptureCounts() (count-capture.js). Bleibt + // wie ein Fehlschlag stehen, statt sofort von restoreIdleStatus() + // ueberschrieben zu werden. + failed = true; + view.setStatus('Zählen nicht möglich — Kamerabild noch nicht bereit', true); + return; + } + + const finalCount = combineCaptureCounts(counts); + view.setCount(Math.round(finalCount)); + view.setCountDiagnostics(describeCountDiagnostics(counts, last.result, last.durationMs)); + hasCountResult = true; + updateCountCaptureLabel(); + } catch (error) { + failed = true; + view.setStatus(`Fehler: ${error.message}`, true); + } finally { + busy = false; + if (!failed) restoreIdleStatus(); } - - // Nur der eigentliche Zaehldurchlauf wird gestoppt (nicht das Holen des - // Kamera-Ausschnitts) - so sieht man in der Diagnosezeile gezielt, ob das - // Zaehlverfahren selbst mit dem Geraet hinterherkommt (siehe - // test/count-objects-photo.test.js fuer dieselbe Messweise). - const start = performance.now(); - const result = countObjects(region); - const durationMs = performance.now() - start; - - const { history, median: smoothedCount } = pushCount(countHistory, result.count); - countHistory = history; - - // Bei einer noch geraden Verlaufslaenge (bis der Verlauf HISTORY_WINDOW - // erreicht) liegt der Median genau zwischen zwei ganzen Messungen - die - // Zaehlanzeige selbst zeigt trotzdem stets eine ganze Zahl (siehe - // Rundung Schritt 10 in count-objects.js, hier fuer die Anzeige ebenso - // gehandhabt). - view.setCount(Math.round(smoothedCount)); - view.setCountDiagnostics(describeCountDiagnostics(result, durationMs)); } /** * Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn * 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. + * Durchlauf die Barcode-Dauersuche aus; sie bricht selbst sofort ab, wenn + * die gewaehlte Funktion keine laufende Suche vorsieht (siehe + * runSearchAttempt()). Funktion "Zählen" laeuft seit dem Umbau auf + * Aufnahmen statt Dauerbetrieb (siehe runCountCapture()) und braucht hier + * deshalb keinen eigenen Aufruf mehr. */ 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 1edab0b..c1b255e 100644 --- a/src/scan-modes.js +++ b/src/scan-modes.js @@ -14,9 +14,13 @@ // // 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. +// `useOcr: false`. Anders als frueher laeuft sie nicht mehr fortwaehrend, +// solange die Kamera aktiv ist, sondern - wie Funktion 3 ("Text erkennen") - +// auf Knopfdruck: eine Zaehlung ist eine Schaetzung, die durch staendiges +// Neuanzeigen nicht praeziser wird, nur unruhig. Ein eigenes Merkmal dafuer +// braucht es nicht mehr - `continuousSearch: false` allein genuegt bereits, +// um main.js (siehe applyMode()) und scan-recognition.js dieselbe +// Knopfdruck-Behandlung wie bei Funktion 3 zukommen zu lassen. /** * @typedef {object} ScanMode @@ -26,12 +30,11 @@ * Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine * Barcode-Dekodierung nutzt. * @property {boolean} continuousSearch Ob waehrend die Kamera laeuft - * fortwaehrend nach Codes gesucht wird (statt nur auf Knopfdruck). + * fortwaehrend nach Codes gesucht wird (statt nur auf Knopfdruck). Bei + * Funktion "Zaehlen" ebenso `false` wie bei "Text erkennen" - siehe + * count-objects.js fuer das Zaehlverfahren selbst und main.js + * (runCountCapture()) fuer den Knopfdruck-Ablauf. * @property {boolean} useOcr Ob die Texterkennung (Tesseract) genutzt wird. - * @property {boolean} 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[]} */ @@ -70,11 +73,11 @@ export const SCAN_MODES = [ id: 'count', label: 'Zählen', // Keine Codes, keine Texterkennung - reine Bildauswertung (siehe - // count-objects.js), die nichts bucht. + // count-objects.js), die nichts bucht. Auf Knopfdruck wie Funktion 3, + // siehe Moduldoku oben. barcodeFormats: [], continuousSearch: false, useOcr: false, - continuousCount: true, }, ]; diff --git a/src/scan-recognition.js b/src/scan-recognition.js index 54d3554..4850dbb 100644 --- a/src/scan-recognition.js +++ b/src/scan-recognition.js @@ -12,10 +12,16 @@ // 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 +// nicht aufgerufen (siehe main.js: die Schaltflaeche "Zählen"/"Nochmal +// zählen" ruft stattdessen runCountCapture(), das direkt countObjects() +// verwendet); dieselbe leere Ruecklieferung wie in Funktion 3 dient hier nur +// als Absicherung fuer den seltenen Fall, dass der Ersatzweg ohne Kamera // (Dateiauswahl) waehrend gewaehlter Funktion "Zaehlen" ausgeloest wird. +// +// Sowohl Funktion 3 als auch Funktion 4 haben `barcodeFormats: []` (siehe +// scan-modes.js) - eine leere Codeartenliste ist deshalb das gemeinsame, +// einzige Merkmal, an dem sich "keine Barcode-Dekodierung noetig" ablesen +// laesst, statt je Funktion einzeln nachzufragen. import { getScanMode } from './scan-modes.js'; @@ -30,7 +36,7 @@ export function buildRecognitionAdapters(modeId, adapters) { const mode = getScanMode(modeId); return { - decodeBarcodes: (mode.useOcr || mode.continuousCount) + decodeBarcodes: mode.barcodeFormats.length === 0 ? async () => [] : (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats), runOcr: mode.useOcr diff --git a/src/ui/mode-dialog.js b/src/ui/mode-dialog.js index 80b0cd6..1a41774 100644 --- a/src/ui/mode-dialog.js +++ b/src/ui/mode-dialog.js @@ -5,8 +5,11 @@ import { SCAN_MODES } from '../scan-modes.js'; * scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion. */ function describeMode(mode) { - if (mode.useOcr) return 'Auf Knopfdruck'; - if (mode.continuousCount) return 'Zählt fortlaufend, solange die Kamera läuft'; + // "Text erkennen" und "Zählen" laufen beide auf Knopfdruck (siehe + // scan-modes.js) - `continuousSearch: false` ist bei beiden das + // gemeinsame Merkmal dafuer, eine Fallunterscheidung je Funktion braucht + // es hier nicht. + if (!mode.continuousSearch) return 'Auf Knopfdruck'; return 'Läuft von selbst, sobald ein Code im Bild ist'; } diff --git a/src/ui/scan-view.js b/src/ui/scan-view.js index 1d03e0a..18ed6ac 100644 --- a/src/ui/scan-view.js +++ b/src/ui/scan-view.js @@ -17,7 +17,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
- +
@@ -115,15 +115,30 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }, /** - * "Modul scannen" hat nur in Funktionen mit Texterkennung auf Knopfdruck - * etwas zu tun (Funktion 3) - in Funktionen mit laufender Codesuche sucht - * die App ohnehin durchgehend, ein Knopf dafuer waere wirkungslos. + * Diese eine Schaltflaeche bedient beide Knopfdruck-Funktionen ("Modul + * scannen" in Funktion 3 "Text erkennen", "Zählen"/"Nochmal zählen" in + * Funktion 4 "Zählen") - nur ihr Wortlaut und ihr Rueckruf unterscheiden + * sich, siehe setCaptureLabel() unten und main.js/applyMode(). In + * Funktionen mit laufender Codesuche sucht die App ohnehin durchgehend, + * ein Knopf dafuer waere wirkungslos. * @param {boolean} visible */ setCaptureVisible(visible) { captureEl.hidden = !visible; }, + /** + * Setzt den Wortlaut der Aufnahme-Schaltflaeche - siehe setCaptureVisible() + * oben fuer den Grund, warum es dafuer nur eine gemeinsame Flaeche statt + * einer zweiten daneben gibt. main.js setzt hier "Modul scannen" (Funktion + * "Text erkennen"), "Zählen" (Funktion "Zählen", vor der ersten Aufnahme + * seit Betreten der Funktion) bzw. "Nochmal zählen" (danach). + * @param {string} text + */ + setCaptureLabel(text) { + captureEl.textContent = text; + }, + /** @param {string} text @param {boolean} warn */ setStatus(text, warn = false) { statusEl.textContent = text; @@ -162,7 +177,10 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile * Diagnosezeile: sie faellt dabei wieder in ihren Ausgangszustand * (verborgen, leer) zurueck, damit sie nach einem Funktionswechsel nicht * ungefragt (weil sie beim letzten Mal offen war) oder mit veralteten - * Werten wieder auftaucht. + * Werten wieder auftaucht. Das gilt sowohl beim Verlassen der Funktion + * "Zählen" als auch beim erneuten Betreten - ein stehendes Ergebnis + * einer frueheren Aufnahme (andere Teile, andere Stelle) darf nicht + * hinueberwirken (siehe main.js/applyMode()). * @param {boolean} visible */ setCountVisible(visible) { @@ -176,11 +194,13 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile }, /** - * Zeigt die (bereits ueber die letzten Messungen geglaettete) Anzahl - * gefundener Fundstuecke (siehe count-history.js) - gross und aus - * Armlaenge lesbar, aktualisiert sich mit jedem Durchlauf der - * Dauerzaehlung. Ein Antippen blendet die Diagnosezeile darunter ein - * oder aus (siehe Klick-Handler oben). + * Zeigt das Ergebnis der zuletzt angetippten Zaehl-Aufnahme (Median der + * fuenf Einzelmessungen dieser Aufnahme, siehe count-capture.js und + * runCountCapture() in main.js) - gross und aus Armlaenge lesbar. Bleibt + * unveraendert stehen, bis der Nutzer erneut auf die Schaltflaeche + * "Nochmal zählen" tippt (keine fortlaufende Aktualisierung mehr). Ein + * Antippen der Zahl selbst blendet die Diagnosezeile darunter ein oder + * aus (siehe Klick-Handler oben). * @param {number} count */ setCount(count) { @@ -189,7 +209,8 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile /** * Setzt den Text der kleinen Diagnosezeile unter der grossen Zahl (siehe - * main.js fuer den genauen Wortlaut) - unabhaengig davon, ob sie gerade + * main.js fuer den genauen Wortlaut, u. a. die fuenf Einzelmessungen der + * zuletzt angetippten Aufnahme) - unabhaengig davon, ob sie gerade * eingeblendet ist. Reine Textaktualisierung, die Sichtbarkeit steuert * ausschliesslich der Klick auf die grosse Zahl (siehe oben). * @param {string} text diff --git a/test/count-capture.test.js b/test/count-capture.test.js new file mode 100644 index 0000000..2b3b38c --- /dev/null +++ b/test/count-capture.test.js @@ -0,0 +1,44 @@ +// Reine Rechenlogik der Zaehl-Aufnahme (Funktion "Zählen", siehe main.js): +// aus den (bis zu fuenf) Einzelmessungen einer Aufnahme wird deren Median +// gebildet. Siehe count-capture.js fuer die Begruendung (robust gegen einen +// einzelnen Ausreisser, ohne wie eine fortlaufende Glaettung traege zu sein). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { combineCaptureCounts } from '../src/count-capture.js'; + +test('fuenf gleiche Messungen liefern diese Zahl', () => { + assert.equal(combineCaptureCounts([18, 18, 18, 18, 18]), 18); +}); + +test('fuenf verschiedene Messungen liefern deren Median (ungerade Anzahl)', () => { + assert.equal(combineCaptureCounts([17, 19, 18, 20, 18]), 18); +}); + +test('ein einzelner grober Ausreisser unter fuenf Messungen schlaegt nicht durch', () => { + // Vier stabile Messungen um 18 herum, eine grob daneben (z. B. + // Bewegungsunschaerfe oder Autofokus mitten in der Aufnahme) - der Median + // bleibt bei 18, ein Mittelwert waere durch den Ausreisser spuerbar verzerrt. + assert.equal(combineCaptureCounts([18, 18, 3, 18, 60]), 18); +}); + +test('weniger als fuenf Messungen (eine Kameraaufnahme scheiterte) liefern trotzdem ein Ergebnis', () => { + // Gerade Anzahl (vier statt fuenf) - Median ist der Mittelwert der beiden + // mittleren Werte. + const result = combineCaptureCounts([17, 18, 18, 19]); + assert.equal(result, 18); +}); + +test('nur eine einzige verwertbare Messung ist ihr eigener Median', () => { + assert.equal(combineCaptureCounts([21]), 21); +}); + +test('eine leere Messreihe wirft, statt stillschweigend ein sinnloses Ergebnis (NaN) zu liefern', () => { + assert.throws(() => combineCaptureCounts([])); +}); + +test('combineCaptureCounts veraendert die uebergebene Liste nicht (reine Funktion)', () => { + const counts = [5, 1, 3]; + const untouched = [...counts]; + combineCaptureCounts(counts); + assert.deepEqual(counts, untouched); +}); diff --git a/test/count-history.test.js b/test/count-history.test.js deleted file mode 100644 index 48b2ec8..0000000 --- a/test/count-history.test.js +++ /dev/null @@ -1,71 +0,0 @@ -// Gleitender Verlauf der letzten Zaehlmessungen (Funktion "Zählen") - die -// grosse Anzeige zeigt zusaetzlich zur rohen Messung deren Median ueber die -// letzten neun Durchlaeufe, damit ein einzelner Ausreisser (Bewegungs- -// unschaerfe, nachregelnder Autofokus) nicht sofort die Anzeige springen -// laesst. Reines, browserfreies Modul wie count-objects.js - hier ohne -// jeden Bezug zu Bilddaten, nur die Zahlenreihe selbst. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { createCountHistory, pushCount, HISTORY_WINDOW } from '../src/count-history.js'; - -test('leerer Verlauf: die erste Messung ist zugleich ihr eigener Median', () => { - const history = createCountHistory(); - const result = pushCount(history, 18); - assert.deepEqual(result.history, [18]); - assert.equal(result.median, 18); -}); - -test('ein einzelner Ausreisser wird vom Median unterdrueckt', () => { - let history = createCountHistory(); - // Acht stabile Messungen ... - for (let i = 0; i < 8; i += 1) { - ({ history } = pushCount(history, 18)); - } - // ... und ein einzelner Ausreisser (z. B. Bewegungsunschaerfe oder - // nachregelnder Autofokus, siehe Modul-Doku). - const result = pushCount(history, 60); - assert.equal( - result.median, - 18, - 'ein einzelner Ausreisser darf die geglaettete Anzeige nicht verschieben', - ); -}); - -test('nach genuegend gleichbleibenden neuen Messungen folgt der Median (nicht traege)', () => { - // Der Median glaettet nur einzelne Ausreisser, nicht eine echte, - // anhaltende Aenderung (z. B. weil der Nutzer tatsaechlich Teile - // weggenommen hat) - sonst waere die Anzeige "beruhigt", aber nicht mehr - // ehrlich. - let history = createCountHistory(); - for (let i = 0; i < 8; i += 1) { - ({ history } = pushCount(history, 18)); - } - let result; - for (let i = 0; i < HISTORY_WINDOW; i += 1) { - result = pushCount(history, 5); - history = result.history; - } - assert.equal( - result.median, - 5, - 'nach HISTORY_WINDOW gleichen neuen Messungen ist der alte Wert vollstaendig aus dem Fenster verdraengt', - ); -}); - -test('das Fenster behaelt hoechstens HISTORY_WINDOW Messungen - aeltere fallen heraus', () => { - let history = createCountHistory(); - for (let i = 1; i <= HISTORY_WINDOW + 3; i += 1) { - ({ history } = pushCount(history, i)); - } - assert.equal(history.length, HISTORY_WINDOW); - // Die drei aeltesten (1, 2, 3) sind bereits herausgefallen. - assert.deepEqual(history, [4, 5, 6, 7, 8, 9, 10, 11, 12]); -}); - -test('pushCount veraendert den uebergebenen Verlauf nicht (reine Funktion)', () => { - const history = createCountHistory(); - const before = pushCount(history, 3).history; - const untouched = [...history]; - pushCount(before, 4); - assert.deepEqual(history, untouched, 'der urspruengliche Verlauf bleibt unangetastet'); -}); diff --git a/test/scan-modes.test.js b/test/scan-modes.test.js index aa6c637..b855799 100644 --- a/test/scan-modes.test.js +++ b/test/scan-modes.test.js @@ -43,13 +43,12 @@ 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', () => { +test('Funktion 4 (Zählen): keine Codearten, keine laufende Codesuche, keine Texterkennung - auf Knopfdruck wie Funktion 3', () => { 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', () => {