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