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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user