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
+143 -55
View File
@@ -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<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
* 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,
};
}