Diagnoseanzeige fuer die Funktion "Zählen" ergaenzt

Der Nutzer meldet eine stark schwankende Zaehlanzeige im Livebetrieb
(3-60), obwohl dasselbe Verfahren an einem Standfoto stabil 18 liefert -
er kann aber keine Bilder hochladen und muss Messwerte daher vom
Bildschirm ablesen und durchgeben koennen.

- count-objects.js liefert additiv Zwischenwerte (Bildgroesse nach
  Normierung, Flaechen vor/nach Filterung, groesste Flaeche, typische
  Groesse, gewinnende Polaritaet) - auch bei count 0 sinnvoll belegt.
  Die eigentliche Zaehllogik bleibt unveraendert.
- count-history.js (neu): gleitender Median der letzten 9 Messungen,
  unterdrueckt einzelne Ausreisser ohne die Anzeige traege zu machen.
- main.js zeigt den Median als grosse Zahl, misst den Zeitbedarf des
  Zaehldurchlaufs und baut daraus die Diagnosezeile; der Verlauf wird
  bei jedem Funktionswechsel zurueckgesetzt.
- scan-view.js/styles.css: die grosse Zahl ist antippbar und blendet
  eine anfangs verborgene Diagnosezeile ein/aus, ohne selbst zu
  verrutschen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 08:56:26 +02:00
co-authored by Claude Opus 5
parent f0705a010c
commit d84c15e0d0
7 changed files with 509 additions and 68 deletions
+50
View File
@@ -0,0 +1,50 @@
// 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) };
}
+143 -55
View File
@@ -69,10 +69,15 @@ const SCALE_FRACTION_OF_LARGEST = 0.2;
/** /**
* Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene * Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene
* Teile) oder sehr kleine (Rauschen) Ausreisser, anders als der Mittelwert. * Teile) oder sehr kleine (Rauschen) Ausreisser, anders als der Mittelwert.
* Exportiert, weil dieselbe Robustheit die Diagnoseanzeige braucht, um aus
* den letzten Zaehlmessungen eine ruhigere Zahl zu bilden (siehe
* count-history.js) - ein einzelner Ausreisser (Bewegungsunschaerfe,
* nachregelnder Autofokus) soll dort ebenso wenig durchschlagen wie hier bei
* den Flaechengroessen.
* @param {number[]} values nicht-leer * @param {number[]} values nicht-leer
* @returns {number} * @returns {number}
*/ */
function median(values) { export function median(values) {
const sorted = [...values].sort((a, b) => a - b); const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2); const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 return sorted.length % 2 === 0
@@ -260,16 +265,21 @@ function floodFill(startIndex, isObjectPixel, visited, width, height, stack) {
/** /**
* Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus * Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus
* (helle Objekte auf dunklerem Grund oder umgekehrt). * (helle Objekte auf dunklerem Grund oder umgekehrt). Liefert - anders als
* die fruehere Fassung - nie `null`, sondern immer ein vollstaendig belegtes
* Ergebnis, auch wenn keine (oder keine signifikante) Flaeche uebrig bleibt:
* die Diagnoseanzeige muss gerade dann zeigen koennen, *woran* es gescheitert
* ist (gar nichts gefunden? nur Rauschen? nur zu kleine Flaechen?). An der
* eigentlichen Zaehlung (Schritte 6-10 selbst) aendert das nichts - `count`
* ist bei einer leeren `significantAreas`-Liste weiterhin 0, exakt wie beim
* frueheren `null`.
* @param {(index: number) => boolean} isObjectPixel * @param {(index: number) => boolean} isObjectPixel
* @param {number} width * @param {number} width
* @param {number} height * @param {number} height
* @param {number} totalPixels * @param {number} totalPixels
* @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher * @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher
* @param {Int32Array} stack wiederverwendeter Arbeitsspeicher * @param {Int32Array} stack wiederverwendeter Arbeitsspeicher
* @returns {{count: number, totalArea: number, significantCount: number} | null} * @returns {{count: number, totalArea: number, rawCount: number, filteredCount: number, significantCount: number, largestArea: number, typicalArea: number}}
* null, wenn keine Flaeche dieser Polaritaet uebrig bleibt (kein Rauschen
* ausgenommen, oder gar keine gefunden)
*/ */
function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) { function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) {
const rawAreas = []; const rawAreas = [];
@@ -277,32 +287,29 @@ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, sta
if (visited[p] || !isObjectPixel(p)) continue; if (visited[p] || !isObjectPixel(p)) continue;
rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack)); rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack));
} }
if (rawAreas.length === 0) {
return null;
}
// Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen. // Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen. Auf
// einer leeren rawAreas-Liste bleibt auch afterNoise leer - kein
// Sonderfall noetig.
const noiseThreshold = Math.max(8, 0.00002 * totalPixels); const noiseThreshold = Math.max(8, 0.00002 * totalPixels);
const afterNoise = rawAreas.filter((area) => area >= noiseThreshold); const afterNoise = rawAreas.filter((area) => area >= noiseThreshold);
if (afterNoise.length === 0) {
return null;
}
// Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten - // Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten -
// bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. // bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. Bleibt
const largestArea = Math.max(...afterNoise); // nach der Rauschfilterung nichts uebrig, gibt es auch keinen Massstab
// (largestArea 0) und folglich auch keine signifikanten Flaechen.
const largestArea = afterNoise.length > 0 ? Math.max(...afterNoise) : 0;
const scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST; const scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST;
const significantAreas = afterNoise.filter((area) => area >= scaleThreshold); const significantAreas = afterNoise.filter((area) => area >= scaleThreshold);
if (significantAreas.length === 0) {
return null;
}
// Schritt 9: typische Einzelgroesse. // Schritt 9: typische Einzelgroesse - nur bestimmbar, wenn ueberhaupt eine
const typicalArea = median(significantAreas); // Flaeche beide Filter uebersteht (median() verlangt eine nicht-leere Liste).
const typicalArea = significantAreas.length > 0 ? median(significantAreas) : 0;
// Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte // Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte
// Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als // Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als
// zwei zaehlen). // zwei zaehlen). Bleibt keine Flaeche uebrig, durchlaeuft die Schleife kein
// einziges Mal - count und totalArea bleiben bei 0.
let count = 0; let count = 0;
let totalArea = 0; let totalArea = 0;
for (const area of significantAreas) { for (const area of significantAreas) {
@@ -310,21 +317,103 @@ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, sta
totalArea += area; totalArea += area;
} }
return { count, totalArea, significantCount: significantAreas.length }; return {
count,
totalArea,
rawCount: rawAreas.length,
filteredCount: afterNoise.length,
significantCount: significantAreas.length,
largestArea,
typicalArea,
};
}
/**
* Waehlt die gewinnende Polaritaet (siehe ausfuehrliche Begruendung im
* Kommentar in countObjects() unten). Massgeblich ist, welche Polaritaet
* mehr signifikante (Rausch- und Groessenfilter ueberstehende) Flaechen
* behalten hat; bei Gleichstand entscheidet die kleinere Gesamtflaeche.
* Haben beide Polaritaeten keine einzige signifikante Flaeche behalten (die
* Zaehlung ergibt in jedem Fall 0), entscheidet ersatzweise, welche
* ueberhaupt mehr Struktur gefunden hat (erst nach, dann vor der
* Rauschfilterung) - das aendert nichts mehr am Ergebnis (0), belegt aber
* die Diagnosewerte weiterhin sinnvoll statt mit einer willkuerlichen Wahl.
* @param {ReturnType<typeof analyzePolarity>} bright
* @param {ReturnType<typeof analyzePolarity>} dark
* @returns {ReturnType<typeof analyzePolarity> & {polarity: 'hell' | 'dunkel'}}
*/
function pickWinner(bright, dark) {
if (bright.significantCount === 0 && dark.significantCount === 0) {
if (dark.filteredCount > bright.filteredCount) return { ...dark, polarity: 'dunkel' };
if (dark.filteredCount === bright.filteredCount && dark.rawCount > bright.rawCount) {
return { ...dark, polarity: 'dunkel' };
}
return { ...bright, polarity: 'hell' };
}
if (dark.significantCount === 0) return { ...bright, polarity: 'hell' };
if (bright.significantCount === 0) return { ...dark, polarity: 'dunkel' };
if (bright.significantCount !== dark.significantCount) {
return bright.significantCount > dark.significantCount
? { ...bright, polarity: 'hell' }
: { ...dark, polarity: 'dunkel' };
}
return bright.totalArea <= dark.totalArea
? { ...bright, polarity: 'hell' }
: { ...dark, polarity: 'dunkel' };
} }
/** /**
* Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem * Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
* Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion - * Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion -
* erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung. * erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung.
*
* Die Rueckgabe traegt neben der Anzahl selbst (unveraendert in Bedeutung)
* additiv die Zwischenwerte, die eine Diagnoseanzeige braucht, um am
* Nutzergeraet zu erkennen, *woran* eine abweichende Zaehlung liegt (siehe
* ui/scan-view.js und main.js) - allesamt auch dann sinnvoll belegt, wenn
* count 0 ergibt.
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
* @returns {{count: number}} count: ermittelte, bereits gerundete Anzahl - * @returns {{
* 0, wenn keine Objektflaeche gefunden wurde (leerer Ausschnitt, reiner * count: number,
* Untergrund ohne Kontrast oder ein Ausschnitt ohne Bildpunkte). * width: number,
* height: number,
* regionsFound: number,
* regionsKept: number,
* largestArea: number,
* typicalArea: number,
* polarity: 'hell' | 'dunkel' | null,
* }}
* count: ermittelte, bereits gerundete Anzahl - 0, wenn keine
* Objektflaeche gefunden wurde (leerer Ausschnitt, reiner Untergrund
* ohne Kontrast oder ein Ausschnitt ohne Bildpunkte).
* width, height: Groesse des tatsaechlich ausgewerteten Bildes, also nach
* der Normierung auf feste Breite (Schritt 1) - 0/0, wenn der
* Ausschnitt selbst schon keine Bildpunkte hatte (dann fand keine
* Normierung statt).
* regionsFound: Anzahl zusammenhaengender Flaechen der gewinnenden
* Polaritaet vor jeder Filterung (Schritt 6, vor Schritt 7).
* regionsKept: Anzahl der Flaechen, die nach vollstaendiger Filterung
* (Rauschen Schritt 7, Groesse Schritt 8) uebrig bleiben.
* largestArea: Groesse der groessten verbliebenen Flaeche in Bildpunkten.
* typicalArea: als typisch bestimmte Einzelgroesse (Median, Schritt 9) in
* Bildpunkten - 0, wenn keine Flaeche uebrig blieb.
* polarity: welche Polaritaet gewonnen hat ('hell': helle Objekte auf
* dunklerem Grund, 'dunkel': umgekehrt) - null nur, wenn der Ausschnitt
* selbst schon keine Bildpunkte hatte und daher gar keine Polaritaet
* berechnet wurde.
*/ */
export function countObjects(imageData) { export function countObjects(imageData) {
if (imageData.width * imageData.height === 0) { if (imageData.width * imageData.height === 0) {
return { count: 0 }; return {
count: 0,
width: 0,
height: 0,
regionsFound: 0,
regionsKept: 0,
largestArea: 0,
typicalArea: 0,
polarity: null,
};
} }
// Schritt 1: auf feste Breite normieren. // Schritt 1: auf feste Breite normieren.
@@ -361,35 +450,34 @@ export function countObjects(imageData) {
const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN; const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN;
const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack); const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack);
if (!bright && !dark) { // Eine Polaritaet muss gewinnen (siehe pickWinner() oben fuer den
return { count: 0 }; // Sonderfall "beide leer"). Am echten Foto (silberne, glaenzende
} // Schrauben, einseitig beleuchtet) traegt jede Schraube sowohl eine helle
if (!dark) { // Reflexflaeche als auch einen dunklen Schlagschatten - beide Polaritaeten
return { count: bright.count }; // finden also echte, nicht zufaellige Struktur, und ihre Gesamtflaechen
} // liegen dicht beieinander (am Pruefbild rund 2826 zu 2140 Bildpunkte).
if (!bright) { // Die Gesamtflaeche allein (kleinere gewinnt) ist in diesem Fall kein
return { count: dark.count }; // verlaessliches Kriterium: der Schlagschatten jeder Schraube ist schmaler
} // als ihre Reflexflaeche und summiert sich deshalb zu einer kleineren
// Gesamtflaeche, obwohl die Reflexflaechen die tatsaechlichen Fundstuecke
// vollstaendiger und stabiler nachzeichnen (siehe
// .superpowers/sdd/counting-rework-report.md fuer die Messung).
// Ausschlaggebend ist deshalb, welche Polaritaet mehr der (bekanntermassen
// um die zwanzig) tatsaechlichen Fundstuecke als eigene, den Rausch- und
// Groessenfilter ueberstehende Flaeche auflaesst - die Polaritaet mit den
// meisten behaltenen Flaechen gewinnt. Nur bei gleich vielen behaltenen
// Flaechen entscheidet die kleinere Gesamtflaeche (die urspruengliche
// Regel) als Ausweichkriterium.
const winner = pickWinner(bright, dark);
// Beide Polaritaeten haben etwas gefunden - eine muss gewinnen. Am echten return {
// Foto (silberne, glaenzende Schrauben, einseitig beleuchtet) traegt jede count: winner.count,
// Schraube sowohl eine helle Reflexflaeche als auch einen dunklen width,
// Schlagschatten - beide Polaritaeten finden also echte, nicht zufaellige height,
// Struktur, und ihre Gesamtflaechen liegen dicht beieinander (am regionsFound: winner.rawCount,
// Pruefbild rund 2826 zu 2140 Bildpunkte). Die Gesamtflaeche allein regionsKept: winner.significantCount,
// (kleinere gewinnt) ist in diesem Fall kein verlaessliches Kriterium: der largestArea: winner.largestArea,
// Schlagschatten jeder Schraube ist schmaler als ihre Reflexflaeche und typicalArea: winner.typicalArea,
// summiert sich deshalb zu einer kleineren Gesamtflaeche, obwohl die polarity: winner.polarity,
// Reflexflaechen die tatsaechlichen Fundstuecke vollstaendiger und };
// stabiler nachzeichnen (siehe .superpowers/sdd/counting-rework-report.md
// fuer die Messung). Ausschlaggebend ist deshalb, welche Polaritaet mehr
// der (bekanntermassen um die zwanzig) tatsaechlichen Fundstuecke als
// eigene, den Rausch- und Groessenfilter ueberstehende Flaeche auflaesst -
// die Polaritaet mit den meisten behaltenen Flaechen gewinnt. Nur bei
// gleich vielen behaltenen Flaechen entscheidet die kleinere
// Gesamtflaeche (die urspruengliche Regel) als Ausweichkriterium.
if (bright.significantCount !== dark.significantCount) {
return { count: bright.significantCount > dark.significantCount ? bright.count : dark.count };
}
return { count: bright.totalArea <= dark.totalArea ? bright.count : dark.count };
} }
+72 -2
View File
@@ -4,6 +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 { 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';
@@ -50,6 +51,15 @@ let cameraUnavailableMessage = null;
// ohnehin unerreichbar. // ohnehin unerreichbar.
let currentModeId = null; 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();
const view = renderScanView(app, { const view = renderScanView(app, {
// "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe // "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe
// applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort // applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort
@@ -91,6 +101,11 @@ const view = renderScanView(app, {
*/ */
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 // "Modul scannen" hat nur dort etwas zu tun, wo es weder laufende
@@ -357,6 +372,37 @@ async function runSearchAttempt() {
await processCapture(() => Promise.resolve(region)); await processCapture(() => Promise.resolve(region));
} }
/** Kurzbezeichnung der Polaritaet (siehe count-objects.js) fuer die Diagnosezeile. */
function describePolarity(polarity) {
if (polarity === 'hell') return 'hell auf dunkel';
if (polarity === 'dunkel') return 'dunkel auf hell';
return '';
}
/**
* Baut den Wortlaut der Diagnosezeile unter der grossen Zahl (siehe
* 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<typeof countObjects>} result
* @param {number} durationMs
*/
function describeCountDiagnostics(result, 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)}`,
`Dauer: ${durationMs.toFixed(0)} ms`,
].join('\n');
}
/** /**
* Ein einzelner Durchlauf der Dauerzaehlung (Funktion "Zählen"): Ausschnitt * Ein einzelner Durchlauf der Dauerzaehlung (Funktion "Zählen"): Ausschnitt
* in nativer Aufloesung holen, Fundstuecke zaehlen (siehe count-objects.js) * in nativer Aufloesung holen, Fundstuecke zaehlen (siehe count-objects.js)
@@ -365,6 +411,14 @@ async function runSearchAttempt() {
* unberuehrt, weil kein Ergebnis in die Sitzung geht und daher auch kein * unberuehrt, weil kein Ergebnis in die Sitzung geht und daher auch kein
* "Modul scannen", keine Treffer-Rueckmeldung und keine Stapel-Zuweisung * "Modul scannen", keine Treffer-Rueckmeldung und keine Stapel-Zuweisung
* dazugehoeren (siehe scan-modes.js). * 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() { async function runCountAttempt() {
// Nur in Funktion "Zählen" aktiv - siehe scan-modes.js. // Nur in Funktion "Zählen" aktiv - siehe scan-modes.js.
@@ -383,8 +437,24 @@ async function runCountAttempt() {
return; return;
} }
const { count } = countObjects(region); // Nur der eigentliche Zaehldurchlauf wird gestoppt (nicht das Holen des
view.setCount(count); // 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));
} }
/** /**
+42 -4
View File
@@ -54,20 +54,58 @@ body {
/* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt /* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt
ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert. ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert.
pointer-events: none, damit sie den Rahmen/die Kamera nicht fuer Der Container selbst blockiert keine Beruehrungen (pointer-events: none);
Beruehrungen blockiert - diese Funktion hat ohnehin keinen Knopf. Riesige die grosse Zahl darunter (.count-number) ist die einzige antippbare
Schriftgroesse mit Schlagschatten statt Kontrastfarbe, damit die Zahl auf Flaeche darin - sie deckt den ganzen Container ab, ist also weit ueber
jedem Untergrund (hell wie dunkel) aus Armlaenge lesbar bleibt. */ den geforderten 56 Bildpunkten Mindestgroesse. */
.count-display { .count-display {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Die Zahl selbst: riesige Schriftgroesse mit Schlagschatten statt
Kontrastfarbe, damit sie auf jedem Untergrund (hell wie dunkel) aus
Armlaenge lesbar bleibt. Als eigene, absolut positionierte Flaeche (statt
als Flex-Kind neben der Diagnosezeile) bleibt sie beim Ein-/Ausblenden der
Diagnosezeile darunter exakt an derselben Stelle stehen - sie "verrutscht"
beim Antippen nicht, auch wenn der Nutzer dabei nur eine Hand frei hat. */
.count-number {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%;
border: none;
padding: 0;
background: none;
font-size: min(35vw, 220px); font-size: min(35vw, 220px);
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
text-shadow: 0 0 12px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9); text-shadow: 0 0 12px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9);
pointer-events: auto;
}
/* Diagnosezeile (siehe main.js fuer den Wortlaut) - anfangs verborgen,
erscheint per Antippen der grossen Zahl. Am unteren Rand des
Kamerabilds verankert statt im Fluss neben der Zahl, damit sie beim
Ein-/Ausblenden nicht die Zahl selbst verschiebt (siehe .count-number
oben). Klein und unaufdringlich, aber mit demselben Schlagschatten-Trick
auf jedem Untergrund lesbar; white-space: pre-line setzt die einzelnen,
mit "\n" getrennten Werte (main.js) als eigene, kurze Zeilen. */
.count-diagnostics {
position: absolute;
left: 0;
right: 0;
bottom: 6%;
padding: 0 16px;
text-align: center;
font-size: 14px;
line-height: 1.6;
color: #fff;
text-shadow: 0 0 8px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9);
white-space: pre-line;
pointer-events: none; pointer-events: none;
} }
+51 -7
View File
@@ -10,7 +10,11 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
<div class="camera"> <div class="camera">
<video id="preview" playsinline muted></video> <video id="preview" playsinline muted></video>
<div class="frame"></div> <div class="frame"></div>
<div class="count-display" id="count-display" hidden aria-live="polite"></div> <div class="count-display" id="count-display" hidden>
<button class="count-number" id="count-number" type="button" aria-live="polite"
aria-label="Messwerte zur Zaehlung ein- oder ausblenden"></button>
<div class="count-diagnostics" id="count-diagnostics" hidden aria-live="polite"></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>Modul scannen</button>
@@ -32,6 +36,14 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
const captureEl = root.querySelector('#capture'); const captureEl = root.querySelector('#capture');
const modeButtonEl = root.querySelector('#mode-button'); const modeButtonEl = root.querySelector('#mode-button');
const countDisplayEl = root.querySelector('#count-display'); const countDisplayEl = root.querySelector('#count-display');
const countNumberEl = root.querySelector('#count-number');
const countDiagnosticsEl = root.querySelector('#count-diagnostics');
// Ob die Diagnosezeile unter der grossen Zahl gerade eingeblendet ist -
// rein lokaler Anzeigezustand dieser Ansicht, siehe setCountVisible()
// (Zuruecksetzen beim Verlassen der Funktion "Zählen") und den Klick-
// Handler auf die grosse Zahl unten (Umschalten).
let countDiagnosticsVisible = false;
// FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl // FRAME_INSET (camera.js) ist die einzige Quelle der Rahmenmasse - sowohl
// fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt // fuer diesen sichtbaren Rahmen als auch fuer den nativen Ausschnitt
@@ -52,6 +64,16 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
if (fileEl.files[0]) onPickFile(fileEl.files[0]); if (fileEl.files[0]) onPickFile(fileEl.files[0]);
fileEl.value = ''; fileEl.value = '';
}); });
// Antippen der grossen Zahl blendet die Diagnosezeile ein, nochmaliges
// Antippen wieder aus (siehe Moduldoku zu setCount()/setCountDiagnostics()
// unten) - reiner Anzeigezustand dieser Ansicht, main.js muss davon
// nichts wissen. Die grosse Zahl selbst bleibt dabei an derselben Stelle
// stehen: die Diagnosezeile ist eigenstaendig positioniert (siehe
// styles.css), ihr Ein-/Ausblenden nimmt der Zahl nicht den Platz weg.
countNumberEl.addEventListener('click', () => {
countDiagnosticsVisible = !countDiagnosticsVisible;
countDiagnosticsEl.hidden = !countDiagnosticsVisible;
});
return { return {
video: root.querySelector('#preview'), video: root.querySelector('#preview'),
@@ -136,22 +158,44 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
* Blendet die grosse Zaehlanzeige ein oder aus (nur in Funktion * Blendet die grosse Zaehlanzeige ein oder aus (nur in Funktion
* "Zählen" sichtbar, siehe applyMode() in main.js). Beim Ausblenden wird * "Zählen" sichtbar, siehe applyMode() in main.js). Beim Ausblenden wird
* die zuletzt angezeigte Zahl geloescht, damit beim naechsten Wechsel in * die zuletzt angezeigte Zahl geloescht, damit beim naechsten Wechsel in
* diese Funktion nicht kurz eine veraltete Zahl aufblitzt. * diese Funktion nicht kurz eine veraltete Zahl aufblitzt - ebenso die
* 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.
* @param {boolean} visible * @param {boolean} visible
*/ */
setCountVisible(visible) { setCountVisible(visible) {
countDisplayEl.hidden = !visible; countDisplayEl.hidden = !visible;
if (!visible) countDisplayEl.textContent = ''; if (!visible) {
countNumberEl.textContent = '';
countDiagnosticsEl.textContent = '';
countDiagnosticsVisible = false;
countDiagnosticsEl.hidden = true;
}
}, },
/** /**
* Zeigt die zuletzt ermittelte Anzahl gefundener Fundstuecke (siehe * Zeigt die (bereits ueber die letzten Messungen geglaettete) Anzahl
* count-objects.js) - gross und aus Armlaenge lesbar, aktualisiert sich * gefundener Fundstuecke (siehe count-history.js) - gross und aus
* mit jedem Durchlauf der Dauerzaehlung. * Armlaenge lesbar, aktualisiert sich mit jedem Durchlauf der
* Dauerzaehlung. Ein Antippen blendet die Diagnosezeile darunter ein
* oder aus (siehe Klick-Handler oben).
* @param {number} count * @param {number} count
*/ */
setCount(count) { setCount(count) {
countDisplayEl.textContent = String(count); countNumberEl.textContent = String(count);
},
/**
* Setzt den Text der kleinen Diagnosezeile unter der grossen Zahl (siehe
* main.js fuer den genauen Wortlaut) - unabhaengig davon, ob sie gerade
* eingeblendet ist. Reine Textaktualisierung, die Sichtbarkeit steuert
* ausschliesslich der Klick auf die grosse Zahl (siehe oben).
* @param {string} text
*/
setCountDiagnostics(text) {
countDiagnosticsEl.textContent = text;
}, },
}; };
} }
+71
View File
@@ -0,0 +1,71 @@
// 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');
});
+80
View File
@@ -158,3 +158,83 @@ test('zwei nahe, aber nur diagonal benachbarte Flaechen zaehlen getrennt (4er-Na
const result = countObjects(image(rows)); const result = countObjects(image(rows));
assert.equal(result.count, 2, 'nur diagonal benachbarte Flaechen bleiben getrennt'); assert.equal(result.count, 2, 'nur diagonal benachbarte Flaechen bleiben getrennt');
}); });
// --- Diagnosewerte (siehe Modul-Doku von countObjects()) ----------------
//
// Die Diagnoseanzeige (ui/scan-view.js, verdrahtet in main.js) liest diese
// Zwischenwerte vom Bildschirm ab, wenn beim Nutzer die Zaehlung schwankt -
// sie muessen deshalb auch dann sinnvoll belegt sein, wenn nichts gefunden
// wurde (count === 0), sonst zeigt die Anzeige in genau dem Fall nichts, in
// dem sie am dringendsten gebraucht wird.
test('ein Bild ohne Bildpunkte belegt alle Diagnosewerte mit 0/null', () => {
const result = countObjects({ width: 0, height: 0, data: new Uint8ClampedArray(0) });
assert.equal(result.count, 0);
assert.equal(result.width, 0);
assert.equal(result.height, 0);
assert.equal(result.regionsFound, 0);
assert.equal(result.regionsKept, 0);
assert.equal(result.largestArea, 0);
assert.equal(result.typicalArea, 0);
assert.equal(result.polarity, null);
});
test('leeres Bild (kein Objekt) belegt die Diagnosewerte trotzdem sinnvoll', () => {
const rows = grid(300, 300, 60);
const result = countObjects(image(rows));
assert.equal(result.count, 0);
// Kleiner als TARGET_WIDTH (800) - bleibt bei der Normierung unveraendert.
assert.equal(result.width, 300);
assert.equal(result.height, 300);
assert.equal(result.regionsFound, 0);
assert.equal(result.regionsKept, 0);
assert.equal(result.largestArea, 0);
assert.equal(result.typicalArea, 0);
// Keine Flaeche gefunden - trotzdem muss eine der beiden Polaritaeten als
// Diagnosewert benannt sein, nicht null (das bleibt dem Fall "gar kein
// Bildpunkt" vorbehalten, siehe Test oben).
assert.ok(result.polarity === 'hell' || result.polarity === 'dunkel');
});
test('eine einzelne Flaeche liefert plausible Diagnosewerte und die richtige Polaritaet', () => {
const rows = grid(300, 300, 30);
paint(rows, 140, 140, 20, 20, 220); // heller als der Untergrund
const result = countObjects(image(rows));
assert.equal(result.count, 1);
assert.equal(result.width, 300);
assert.equal(result.height, 300);
assert.equal(result.polarity, 'hell', 'helle Flaeche auf dunklerem Grund');
assert.equal(result.regionsFound, 1);
assert.equal(result.regionsKept, 1);
assert.ok(result.largestArea > 0);
// Bei genau einer signifikanten Flaeche ist der Median (die typische
// Groesse) exakt diese eine Flaeche.
assert.equal(result.typicalArea, result.largestArea);
});
test('dunkle Flaeche auf hellem Grund meldet die Polaritaet "dunkel"', () => {
const rows = grid(300, 300, 220);
paint(rows, 140, 140, 20, 20, 30);
const result = countObjects(image(rows));
assert.equal(result.count, 1);
assert.equal(result.polarity, 'dunkel');
});
test('regionsKept zaehlt Flaechen, nicht die hochgerechneten Fundstuecke', () => {
// Wie der Test "eine Flaeche von etwa vierfacher Einzelgroesse wird als
// vier gezaehlt" oben: drei Einzelflaechen + eine verschmolzene Flaeche
// vierfacher Groesse. count rechnet die verschmolzene Flaeche auf vier
// Fundstuecke hoch (macht 7 insgesamt) - regionsKept zaehlt aber die
// tatsaechlich gefundenen, zusammenhaengenden Flaechen (4), nicht die
// hochgerechneten Fundstuecke.
const rows = grid(600, 600, 30);
paint(rows, 60, 60, 40, 40, 220);
paint(rows, 400, 60, 40, 40, 220);
paint(rows, 60, 400, 40, 40, 220);
paint(rows, 400, 400, 80, 80, 220);
const result = countObjects(image(rows));
assert.equal(result.count, 7);
assert.equal(result.regionsFound, 4);
assert.equal(result.regionsKept, 4);
assert.ok(result.largestArea > result.typicalArea, 'die verschmolzene Flaeche ist die groesste');
});