Zählen: Aufnahme statt Dauerbetrieb

Die Zaehlanzeige sprang beim Nutzer im Dauerbetrieb wild zwischen etwa 3
und 60, obwohl dasselbe Verfahren an einem Standfoto stabil 18 lieferte -
eine Schaetzung wird durch staendiges Neuanzeigen nicht praeziser, nur
unruhig. "Zählen" laeuft jetzt wie "Text erkennen" auf Knopfdruck: eine
Aufnahme nimmt binnen rund einer Sekunde fuenf Bilder aus dem Zielrahmen
auf, zaehlt jedes einzeln (count-objects.js unveraendert) und zeigt deren
Median (neues Modul count-capture.js). Das Ergebnis bleibt stehen, bis
erneut angetippt wird; die Diagnosezeile zeigt zusaetzlich die fuenf
Einzelmessungen. Der gleitende Median ueber neun Durchlaeufe der
Dauerzaehlung (count-history.js) entfaellt, da die Aufnahme dieselbe
Robustheit bereits selbst leistet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:36:43 +02:00
co-authored by Claude Opus 5
parent d84c15e0d0
commit 4b6bb065b1
10 changed files with 330 additions and 260 deletions
+39
View File
@@ -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);
}
-50
View File
@@ -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) };
}
+186 -110
View File
@@ -4,7 +4,7 @@ import { startCamera, grabFrameRegion, imageDataFromFile } from './camera.js';
import { decodeBarcodes } from './barcode.js'; import { decodeBarcodes } from './barcode.js';
import { runOcr, isOcrAvailable } from './ocr.js'; import { runOcr, isOcrAvailable } from './ocr.js';
import { countObjects } from './count-objects.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 { recognize } from './pipeline.js';
import { normalizeToken } from './spec.js'; import { normalizeToken } from './spec.js';
import { getScanMode } from './scan-modes.js'; import { getScanMode } from './scan-modes.js';
@@ -51,23 +51,33 @@ let cameraUnavailableMessage = null;
// ohnehin unerreichbar. // ohnehin unerreichbar.
let currentModeId = null; let currentModeId = null;
// Gleitender Verlauf der letzten Zaehlmessungen (Funktion "Zählen", siehe // Ob die aktuelle Sitzung in Funktion "Zählen" bereits eine Aufnahme gemacht
// count-history.js) - Grundlage der beruhigten grossen Zahl (Median der // hat - traegt die Beschriftung der Aufnahme-Schaltflaeche ("Zählen" vor der
// letzten neun Messungen). Wird bei jedem Aufruf von applyMode() neu // ersten Aufnahme seit Betreten der Funktion, "Nochmal zählen" danach, siehe
// angelegt: sowohl ein Wechsel weg von "Zählen" als auch ein (erneuter) // updateCountCaptureLabel() unten). Wird bei jedem Aufruf von applyMode()
// Eintritt in die Funktion soll mit einem leeren Verlauf beginnen, damit die // zurueckgesetzt: sowohl ein Wechsel weg von "Zählen" als auch ein (erneuter)
// Anzeige nie Werte einer anderen Situation (anderes Teil, andere Stelle) // Eintritt in die Funktion beginnt wieder bei "Zählen" - siehe auch
// zeigt. // view.setCountVisible() (loescht das stehende Ergebnis samt Diagnose beim
let countHistory = createCountHistory(); // Verlassen).
let hasCountResult = false;
const view = renderScanView(app, { const view = renderScanView(app, {
// "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe // Eine gemeinsame Schaltflaeche fuer beide Knopfdruck-Funktionen (siehe
// applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort // scan-view.js/setCaptureLabel()): in Funktion "Text erkennen" loest sie
// einen Ausschnitt, den grabFrame() (Vollbild, herunterskaliert) bisher gar // processCapture() aus (Erkennung + Buchung), in Funktion "Zählen"
// nicht einloeste. grabFrameRegion() liest genau den Rahmenausschnitt, in // stattdessen runCountCapture() (Aufnahme mehrerer Bilder, keine Buchung).
// voller Aufloesung - derselbe Ausschnitt, den auch die Barcode-Dauersuche // 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). // benutzt (siehe runSearchAttempt() weiter unten).
onCapture: () => processCapture(() => grabFrameRegion(view.video)), onCapture: () => {
if (currentModeId === 'count') {
runCountCapture();
} else {
processCapture(() => grabFrameRegion(view.video));
}
},
onUndo: () => { onUndo: () => {
// Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen // Waehrend eine Erkennung laeuft, darf kein Eintrag zurueckgenommen
// werden - sonst koennte die Rueckgaengig-Flaeche einen Eintrag treffen, // 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 * Uebernimmt eine (neu gewaehlte) Scan-Funktion: Anzeige, Sichtbarkeit und
* "Modul scannen" und - bei einem Wechsel weg von laufender Codesuche - der * Beschriftung der Aufnahme-Schaltflaeche und - bei einem Wechsel weg von
* Zielrahmen wird sofort zurueckgesetzt, statt auf den naechsten Durchlauf * laufender Codesuche - der Zielrahmen wird sofort zurueckgesetzt, statt auf
* der Dauersuche zu warten. * den naechsten Durchlauf der Dauersuche zu warten.
* @param {string} modeId * @param {string} modeId
*/ */
function applyMode(modeId) { function applyMode(modeId) {
currentModeId = 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); const mode = getScanMode(modeId);
view.setMode(mode.label); view.setMode(mode.label);
// "Modul scannen" hat nur dort etwas zu tun, wo es weder laufende // Die Aufnahme-Schaltflaeche hat nur dort etwas zu tun, wo es keine
// Codesuche noch laufende Zaehlung gibt (Funktion 3) - siehe Kommentar in // laufende Codesuche gibt (Funktion 3 "Text erkennen" und Funktion 4
// scan-view.js/setCaptureVisible. // "Zählen") - siehe Kommentar in scan-view.js/setCaptureVisible.
view.setCaptureVisible(!mode.continuousSearch && !mode.continuousCount); view.setCaptureVisible(!mode.continuousSearch);
if (!mode.continuousSearch) { if (!mode.continuousSearch) {
view.setFrameDetected(false); 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" - // Die grosse Zaehlanzeige gehoert ausschliesslich zu Funktion "Zählen" -
// beim Verlassen sofort ausblenden, statt auf den naechsten (dann gar // beim Verlassen sofort ausblenden (loescht dabei auch ein stehendes
// nicht mehr laufenden) Durchlauf der Dauerzaehlung zu warten. // Ergebnis samt Diagnose, siehe scan-view.js/setCountVisible), statt auf
view.setCountVisible(mode.continuousCount); // die naechste Aufnahme zu warten.
view.setCountVisible(mode.id === 'count');
} }
/** /**
@@ -143,6 +159,25 @@ function describeEntry(spec, stackId) {
return `${describeSpec(spec)} → Stapel ${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 * Ein Scan-Durchlauf: Bild beschaffen, erkennen, bei unsicherer Erkennung
* nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch, * nachfragen, buchen, kurz rueckmelden. Laeuft ohne Bestaetigung durch,
@@ -241,19 +276,8 @@ async function processCapture(getFrame) {
} finally { } finally {
busy = false; busy = false;
// Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer // Die OCR-Verfuegbarkeitsanzeige darf eine Fehlermeldung, die der Nutzer
// noch lesen muss, nicht ueberschreiben. Der Hinweis auf eine nicht // noch lesen muss, nicht ueberschreiben.
// verfuegbare Kamera hat Vorrang vor beidem und bleibt dauerhaft stehen. if (!failed) restoreIdleStatus();
if (!failed) {
if (cameraUnavailableMessage) {
view.setStatus(cameraUnavailableMessage, true);
} else {
const available = isOcrAvailable();
view.setStatus(
available ? '' : 'Texterkennung nicht verfuegbar — nur Barcodes werden gelesen',
!available,
);
}
}
} }
} }
@@ -384,95 +408,147 @@ function describePolarity(polarity) {
* ui/scan-view.js/setCountDiagnostics()). Kurze, unfachliche Bezeichnungen * ui/scan-view.js/setCountDiagnostics()). Kurze, unfachliche Bezeichnungen
* in eigenen Zeilen statt einer einzigen langen Zeile - der Nutzer soll sie * in eigenen Zeilen statt einer einzigen langen Zeile - der Nutzer soll sie
* bei Bedarf jemandem am Telefon vorlesen koennen, ohne sich zu verhaspeln. * bei Bedarf jemandem am Telefon vorlesen koennen, ohne sich zu verhaspeln.
* Enthaelt bewusst sowohl die rohe letzte Messung (zeigt die tatsaechliche * Enthaelt zusaetzlich zu den schon vorhandenen Werten die fuenf
* Schwankung, die die grosse - geglaettete - Zahl sonst verbergen wuerde) * Einzelmessungen dieser Aufnahme (`counts`) - daran erkennen Nutzer und
* als auch den Zeitbedarf des letzten Zaehldurchlaufs (zeigt, ob das Geraet * Entwickler sofort, ob das Ergebnis belastbar ist (fuenf Messungen von 17
* ueberhaupt hinterherkommt). * bis 19 bedeuten etwas anderes als fuenf Messungen von 4 bis 50). Die
* @param {ReturnType<typeof countObjects>} result * uebrigen Werte (`last`) beziehen sich weiterhin auf die *letzte* der
* @param {number} durationMs * 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<typeof countObjects>} last Ergebnis der letzten der
* fuenf Einzelmessungen
* @param {number} durationMs Zeitbedarf der letzten Einzelmessung
*/ */
function describeCountDiagnostics(result, durationMs) { function describeCountDiagnostics(counts, last, durationMs) {
return [ return [
`Roh: ${result.count}`, `Roh: ${last.count}`,
`Bild: ${result.width}×${result.height}`, `Messungen: ${counts.join(', ')}`,
`Flächen: ${result.regionsFound}${result.regionsKept}`, `Bild: ${last.width}×${last.height}`,
`Größte Fläche: ${result.largestArea} Bildpunkte`, `Flächen: ${last.regionsFound} ${last.regionsKept}`,
`Typische Größe: ${result.typicalArea} Bildpunkte`, `Größte Fläche: ${last.largestArea} Bildpunkte`,
`Art: ${describePolarity(result.polarity)}`, `Typische Größe: ${last.typicalArea} Bildpunkte`,
`Art: ${describePolarity(last.polarity)}`,
`Dauer: ${durationMs.toFixed(0)} ms`, `Dauer: ${durationMs.toFixed(0)} ms`,
].join('\n'); ].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 * Setzt die Beschriftung der Aufnahme-Schaltflaeche in Funktion "Zählen"
* in nativer Aufloesung holen, Fundstuecke zaehlen (siehe count-objects.js) * anhand von hasCountResult (siehe Deklaration oben): "Zählen" vor der
* und die grosse Anzeige aktualisieren. Anders als runSearchAttempt() wird * ersten Aufnahme seit Betreten der Funktion, "Nochmal zählen" danach.
* 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.
*/ */
async function runCountAttempt() { function updateCountCaptureLabel() {
// Nur in Funktion "Zählen" aktiv - siehe scan-modes.js. view.setCaptureLabel(hasCountResult ? 'Nochmal zählen' : 'Zählen');
if (!getScanMode(currentModeId).continuousCount) return; }
// Dieselbe Pausenbedingung wie bei der Barcode-Dauersuche (laufende /**
// Erkennung oder offenes Vollbild-Overlay) - siehe searchPaused() oben. * Eine Aufnahme der Funktion "Zählen" (ausgeloest durch die Schaltflaeche
if (searchPaused()) return; * "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 { try {
region = grabFrameRegion(view.video); const counts = [];
} catch { let last = null;
// Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf
// versucht es erneut. for (let i = 0; i < CAPTURE_SAMPLE_SIZE; i += 1) {
return; 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 * Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn
* der vorherige vollstaendig fertig ist - nie ueberlappend. Fuehrt pro * der vorherige vollstaendig fertig ist - nie ueberlappend. Fuehrt pro
* Durchlauf sowohl den Barcode- als auch den Zaehl-Versuch aus; jeder der * Durchlauf die Barcode-Dauersuche aus; sie bricht selbst sofort ab, wenn
* beiden bricht selbst sofort ab, wenn die gewaehlte Funktion nicht zu ihm * die gewaehlte Funktion keine laufende Suche vorsieht (siehe
* passt (siehe runSearchAttempt()/runCountAttempt()) - so bleibt es bei * runSearchAttempt()). Funktion "Zählen" laeuft seit dem Umbau auf
* genau einem Zeitgeber und derselben Ueberlappungssperre fuer alle * Aufnahmen statt Dauerbetrieb (siehe runCountCapture()) und braucht hier
* fortlaufenden Funktionen, statt einen zweiten, parallelen Mechanismus * deshalb keinen eigenen Aufruf mehr.
* fuer "Zählen" danebenzustellen.
*/ */
async function continuousSearchLoop() { async function continuousSearchLoop() {
for (;;) { for (;;) {
const started = performance.now(); const started = performance.now();
try { try {
await runSearchAttempt(); await runSearchAttempt();
await runCountAttempt();
} catch { } catch {
// Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht // Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht
// dauerhaft abbrechen. // dauerhaft abbrechen.
+13 -10
View File
@@ -14,9 +14,13 @@
// //
// Funktion 4 ("Zaehlen") ist reine Anzeige: Sie liest weder Codes noch Text // Funktion 4 ("Zaehlen") ist reine Anzeige: Sie liest weder Codes noch Text
// und bucht nichts in die Sitzung - deshalb `continuousSearch: false` und // und bucht nichts in die Sitzung - deshalb `continuousSearch: false` und
// `useOcr: false`, obwohl sie (wie die Barcode-Dauersuche) fortwaehrend // `useOcr: false`. Anders als frueher laeuft sie nicht mehr fortwaehrend,
// laeuft, solange die Kamera aktiv ist. Dafuer eigenes Merkmal // solange die Kamera aktiv ist, sondern - wie Funktion 3 ("Text erkennen") -
// `continuousCount`, siehe main.js. // 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 * @typedef {object} ScanMode
@@ -26,12 +30,11 @@
* Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine * Codearten (zxing-wasm-Formatnamen). Leer, wenn die Funktion keine
* Barcode-Dekodierung nutzt. * Barcode-Dekodierung nutzt.
* @property {boolean} continuousSearch Ob waehrend die Kamera laeuft * @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} 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[]} */ /** @type {ScanMode[]} */
@@ -70,11 +73,11 @@ export const SCAN_MODES = [
id: 'count', id: 'count',
label: 'Zählen', label: 'Zählen',
// Keine Codes, keine Texterkennung - reine Bildauswertung (siehe // 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: [], barcodeFormats: [],
continuousSearch: false, continuousSearch: false,
useOcr: false, useOcr: false,
continuousCount: true,
}, },
]; ];
+10 -4
View File
@@ -12,10 +12,16 @@
// aufgerufen wird. // aufgerufen wird.
// Funktion 4 (Zaehlen): weder Barcode-Dekodierung noch Texterkennung - // Funktion 4 (Zaehlen): weder Barcode-Dekodierung noch Texterkennung -
// recognize() (pipeline.js) wird im normalen Ablauf fuer diese Funktion gar // recognize() (pipeline.js) wird im normalen Ablauf fuer diese Funktion gar
// nicht aufgerufen (siehe main.js: kein Knopf, keine Dauersuche im Sinne // nicht aufgerufen (siehe main.js: die Schaltflaeche "Zählen"/"Nochmal
// dieses Moduls); dieselbe leere Ruecklieferung wie in Funktion 3 dient hier // zählen" ruft stattdessen runCountCapture(), das direkt countObjects()
// nur als Absicherung fuer den seltenen Fall, dass der Ersatzweg ohne Kamera // 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. // (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'; import { getScanMode } from './scan-modes.js';
@@ -30,7 +36,7 @@ export function buildRecognitionAdapters(modeId, adapters) {
const mode = getScanMode(modeId); const mode = getScanMode(modeId);
return { return {
decodeBarcodes: (mode.useOcr || mode.continuousCount) decodeBarcodes: mode.barcodeFormats.length === 0
? async () => [] ? async () => []
: (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats), : (imageData) => adapters.decodeBarcodes(imageData, mode.barcodeFormats),
runOcr: mode.useOcr runOcr: mode.useOcr
+5 -2
View File
@@ -5,8 +5,11 @@ import { SCAN_MODES } from '../scan-modes.js';
* scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion. * scan-modes.js) - keine zweite Stelle mit eigenem Text je Funktion.
*/ */
function describeMode(mode) { function describeMode(mode) {
if (mode.useOcr) return 'Auf Knopfdruck'; // "Text erkennen" und "Zählen" laufen beide auf Knopfdruck (siehe
if (mode.continuousCount) return 'Zählt fortlaufend, solange die Kamera läuft'; // 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'; return 'Läuft von selbst, sobald ein Code im Bild ist';
} }
+32 -11
View File
@@ -17,7 +17,7 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
</div> </div>
</div> </div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
<button class="action capture" id="capture" hidden>Modul scannen</button> <button class="action capture" id="capture" hidden></button>
<button class="action secondary" id="pick-file-button" hidden>Bild auswählen</button> <button class="action secondary" id="pick-file-button" hidden>Bild auswählen</button>
<div class="stacks" id="stacks"></div> <div class="stacks" id="stacks"></div>
<div class="last"> <div class="last">
@@ -115,15 +115,30 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
}, },
/** /**
* "Modul scannen" hat nur in Funktionen mit Texterkennung auf Knopfdruck * Diese eine Schaltflaeche bedient beide Knopfdruck-Funktionen ("Modul
* etwas zu tun (Funktion 3) - in Funktionen mit laufender Codesuche sucht * scannen" in Funktion 3 "Text erkennen", "Zählen"/"Nochmal zählen" in
* die App ohnehin durchgehend, ein Knopf dafuer waere wirkungslos. * 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 * @param {boolean} visible
*/ */
setCaptureVisible(visible) { setCaptureVisible(visible) {
captureEl.hidden = !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 */ /** @param {string} text @param {boolean} warn */
setStatus(text, warn = false) { setStatus(text, warn = false) {
statusEl.textContent = text; statusEl.textContent = text;
@@ -162,7 +177,10 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
* Diagnosezeile: sie faellt dabei wieder in ihren Ausgangszustand * Diagnosezeile: sie faellt dabei wieder in ihren Ausgangszustand
* (verborgen, leer) zurueck, damit sie nach einem Funktionswechsel nicht * (verborgen, leer) zurueck, damit sie nach einem Funktionswechsel nicht
* ungefragt (weil sie beim letzten Mal offen war) oder mit veralteten * 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 * @param {boolean} visible
*/ */
setCountVisible(visible) { setCountVisible(visible) {
@@ -176,11 +194,13 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
}, },
/** /**
* Zeigt die (bereits ueber die letzten Messungen geglaettete) Anzahl * Zeigt das Ergebnis der zuletzt angetippten Zaehl-Aufnahme (Median der
* gefundener Fundstuecke (siehe count-history.js) - gross und aus * fuenf Einzelmessungen dieser Aufnahme, siehe count-capture.js und
* Armlaenge lesbar, aktualisiert sich mit jedem Durchlauf der * runCountCapture() in main.js) - gross und aus Armlaenge lesbar. Bleibt
* Dauerzaehlung. Ein Antippen blendet die Diagnosezeile darunter ein * unveraendert stehen, bis der Nutzer erneut auf die Schaltflaeche
* oder aus (siehe Klick-Handler oben). * "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 * @param {number} count
*/ */
setCount(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 * 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 * eingeblendet ist. Reine Textaktualisierung, die Sichtbarkeit steuert
* ausschliesslich der Klick auf die grosse Zahl (siehe oben). * ausschliesslich der Klick auf die grosse Zahl (siehe oben).
* @param {string} text * @param {string} text
+44
View File
@@ -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);
});
-71
View File
@@ -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');
});
+1 -2
View File
@@ -43,13 +43,12 @@ test('Funktion 3 (Text erkennen): keine Codearten, keine laufende Suche, Texterk
assert.equal(mode.useOcr, true); 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'); const mode = getScanMode('count');
assert.equal(mode.label, 'Zählen'); assert.equal(mode.label, 'Zählen');
assert.deepEqual(mode.barcodeFormats, []); assert.deepEqual(mode.barcodeFormats, []);
assert.equal(mode.continuousSearch, false); assert.equal(mode.continuousSearch, false);
assert.equal(mode.useOcr, false); assert.equal(mode.useOcr, false);
assert.equal(mode.continuousCount, true);
}); });
test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => { test('unbekannte Kennung wirft statt undefined zurueckzugeben', () => {