diff --git a/src/count-history.js b/src/count-history.js new file mode 100644 index 0000000..2a978a6 --- /dev/null +++ b/src/count-history.js @@ -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) }; +} diff --git a/src/count-objects.js b/src/count-objects.js index ecce441..f87b141 100644 --- a/src/count-objects.js +++ b/src/count-objects.js @@ -69,10 +69,15 @@ const SCALE_FRACTION_OF_LARGEST = 0.2; /** * Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene * 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 * @returns {number} */ -function median(values) { +export function median(values) { const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); 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 - * (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 {number} width * @param {number} height * @param {number} totalPixels * @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher * @param {Int32Array} stack wiederverwendeter Arbeitsspeicher - * @returns {{count: number, totalArea: number, significantCount: number} | null} - * null, wenn keine Flaeche dieser Polaritaet uebrig bleibt (kein Rauschen - * ausgenommen, oder gar keine gefunden) + * @returns {{count: number, totalArea: number, rawCount: number, filteredCount: number, significantCount: number, largestArea: number, typicalArea: number}} */ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) { const rawAreas = []; @@ -277,32 +287,29 @@ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, sta if (visited[p] || !isObjectPixel(p)) continue; 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 afterNoise = rawAreas.filter((area) => area >= noiseThreshold); - if (afterNoise.length === 0) { - return null; - } // Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten - - // bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. - const largestArea = Math.max(...afterNoise); + // bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. Bleibt + // 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 significantAreas = afterNoise.filter((area) => area >= scaleThreshold); - if (significantAreas.length === 0) { - return null; - } - // Schritt 9: typische Einzelgroesse. - const typicalArea = median(significantAreas); + // Schritt 9: typische Einzelgroesse - nur bestimmbar, wenn ueberhaupt eine + // 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 // 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 totalArea = 0; for (const area of significantAreas) { @@ -310,21 +317,103 @@ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, sta 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} bright + * @param {ReturnType} dark + * @returns {ReturnType & {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 * Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion - * 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 - * @returns {{count: number}} count: ermittelte, bereits gerundete Anzahl - - * 0, wenn keine Objektflaeche gefunden wurde (leerer Ausschnitt, reiner - * Untergrund ohne Kontrast oder ein Ausschnitt ohne Bildpunkte). + * @returns {{ + * count: number, + * 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) { 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. @@ -361,35 +450,34 @@ export function countObjects(imageData) { const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN; const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack); - if (!bright && !dark) { - return { count: 0 }; - } - if (!dark) { - return { count: bright.count }; - } - if (!bright) { - return { count: dark.count }; - } + // Eine Polaritaet muss gewinnen (siehe pickWinner() oben fuer den + // Sonderfall "beide leer"). Am echten Foto (silberne, glaenzende + // Schrauben, einseitig beleuchtet) traegt jede Schraube sowohl eine helle + // Reflexflaeche als auch einen dunklen Schlagschatten - beide Polaritaeten + // finden also echte, nicht zufaellige Struktur, und ihre Gesamtflaechen + // liegen dicht beieinander (am Pruefbild rund 2826 zu 2140 Bildpunkte). + // Die Gesamtflaeche allein (kleinere gewinnt) ist in diesem Fall kein + // 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 - // Foto (silberne, glaenzende Schrauben, einseitig beleuchtet) traegt jede - // Schraube sowohl eine helle Reflexflaeche als auch einen dunklen - // Schlagschatten - beide Polaritaeten finden also echte, nicht zufaellige - // Struktur, und ihre Gesamtflaechen liegen dicht beieinander (am - // Pruefbild rund 2826 zu 2140 Bildpunkte). Die Gesamtflaeche allein - // (kleinere gewinnt) ist in diesem Fall kein 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. - 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 }; + return { + count: winner.count, + width, + height, + regionsFound: winner.rawCount, + regionsKept: winner.significantCount, + largestArea: winner.largestArea, + typicalArea: winner.typicalArea, + polarity: winner.polarity, + }; } diff --git a/src/main.js b/src/main.js index 24aa5b3..7ed64dc 100644 --- a/src/main.js +++ b/src/main.js @@ -4,6 +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 { recognize } from './pipeline.js'; import { normalizeToken } from './spec.js'; import { getScanMode } from './scan-modes.js'; @@ -50,6 +51,15 @@ 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(); + const view = renderScanView(app, { // "Modul scannen" ist nur in Funktion 3 ("Text erkennen") sichtbar (siehe // applyMode()/setCaptureVisible) - der Zielrahmen suggeriert dem Nutzer dort @@ -91,6 +101,11 @@ const view = renderScanView(app, { */ 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 @@ -357,6 +372,37 @@ async function runSearchAttempt() { 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} 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 * 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 * "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() { // Nur in Funktion "Zählen" aktiv - siehe scan-modes.js. @@ -383,8 +437,24 @@ async function runCountAttempt() { return; } - const { count } = countObjects(region); - view.setCount(count); + // 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)); } /** diff --git a/src/styles.css b/src/styles.css index b22eb12..4a692d3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -54,20 +54,58 @@ body { /* Grosse Zaehlanzeige der Funktion "Zählen" (siehe count-objects.js) - liegt ueber dem Kamerabild, aber unterhalb des Zielrahmens optisch zentriert. - pointer-events: none, damit sie den Rahmen/die Kamera nicht fuer - Beruehrungen blockiert - diese Funktion hat ohnehin keinen Knopf. Riesige - Schriftgroesse mit Schlagschatten statt Kontrastfarbe, damit die Zahl auf - jedem Untergrund (hell wie dunkel) aus Armlaenge lesbar bleibt. */ + Der Container selbst blockiert keine Beruehrungen (pointer-events: none); + die grosse Zahl darunter (.count-number) ist die einzige antippbare + Flaeche darin - sie deckt den ganzen Container ab, ist also weit ueber + den geforderten 56 Bildpunkten Mindestgroesse. */ .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; inset: 0; display: flex; align-items: center; justify-content: center; + width: 100%; + border: none; + padding: 0; + background: none; font-size: min(35vw, 220px); font-weight: 800; color: #fff; text-shadow: 0 0 12px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.9); + pointer-events: 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; } diff --git a/src/ui/scan-view.js b/src/ui/scan-view.js index 13f31e1..1d03e0a 100644 --- a/src/ui/scan-view.js +++ b/src/ui/scan-view.js @@ -10,7 +10,11 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile
- +
@@ -32,6 +36,14 @@ export function renderScanView(root, { onCapture, onUndo, onOpenList, onPickFile const captureEl = root.querySelector('#capture'); const modeButtonEl = root.querySelector('#mode-button'); 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 // 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]); 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 { 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 * "Zählen" sichtbar, siehe applyMode() in main.js). Beim Ausblenden wird * die zuletzt angezeigte Zahl geloescht, damit beim naechsten Wechsel in - * diese Funktion nicht kurz eine veraltete Zahl aufblitzt. + * 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 */ setCountVisible(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 - * count-objects.js) - gross und aus Armlaenge lesbar, aktualisiert sich - * mit jedem Durchlauf der Dauerzaehlung. + * 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). * @param {number} 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; }, }; } diff --git a/test/count-history.test.js b/test/count-history.test.js new file mode 100644 index 0000000..48b2ec8 --- /dev/null +++ b/test/count-history.test.js @@ -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'); +}); diff --git a/test/count-objects.test.js b/test/count-objects.test.js index 2acc1af..dc1dfb8 100644 --- a/test/count-objects.test.js +++ b/test/count-objects.test.js @@ -158,3 +158,83 @@ test('zwei nahe, aber nur diagonal benachbarte Flaechen zaehlen getrennt (4er-Na const result = countObjects(image(rows)); 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'); +});