// Zaehlt zusammenhaengende Objektflaechen in einem Kamera-Ausschnitt - die // vierte Scan-Funktion "Zaehlen" (siehe scan-modes.js). Reine Berechnung auf // Bilddaten, ohne jeden Browser-Zugriff (kein window/document/canvas) und // ohne neue Abhaengigkeit. // // Otsu (threshold.js) taugt nur, wenn Objekt- und Untergrundflaeche etwa // gleich gross sind. Am echten Foto des Auftraggebers (rund zwanzig // Festplattenschrauben auf dunklem Leder) belegen die Schrauben unter einem // Prozent der Flaeche - Otsu rutscht dann auf den hoechsten Helligkeitswert // und erklaert nur die hellsten Punkte der Ledernarbung zum Objekt // (gemessen: 76712 statt rund zwanzig, siehe // test/count-objects-photo.test.js). Otsu betrachtet zudem nur die absolute // Helligkeit ueber das ganze Bild hinweg; am echten Foto sind Schraube und // Untergrund darueber gar nicht trennbar (Median 114, 90. Perzentil 221, // 99. Perzentil 254 - deutliche Ueberlappung). // // Dieses Modul verwendet stattdessen oertlichen Kontrast (siehe // .superpowers/sdd/counting-rework-report.md fuer die Herleitung und die // Messungen am echten Foto): // 1. Auf 800 Bildpunkte Breite normieren (Seitenverhaeltnis erhalten) - // macht alle folgenden Groessen unabhaengig von der Kameraaufloesung und // die Rechnung schneller. // 2. Graustufen (uebliche Gewichte 0,299 / 0,587 / 0,114). // 3. Glaetten mit einem Kastenfilter (unterdrueckt feine Untergrundnarbung, // ohne Teile zu verschmieren) ueber ein Summenbild, damit die // Mittelwertbildung unabhaengig vom Radius in gleicher Zeit laeuft. // 4. Oertlichen Hintergrund ueber einen zweiten, deutlich groesseren // Kastenmittelwert bestimmen - faengt ungleichmaessige Ausleuchtung ab // und bleibt auch dann noch "Untergrund", wenn ein einzelnes Teil den // Mittelwert an seiner eigenen Stelle nicht dominieren kann (das Fenster // ist viel groesser als ein einzelnes Teil). // 5. Maske: Bildpunkt geglaettet vs. oertlicher Hintergrund +/- Marge. // 6. Connected-Component-Labeling mit 4er-Nachbarschaft ueber eine eigene // Arbeitsliste statt Rekursion (rund eine Viertelmillion Bildpunkte // wuerden einen rekursiven Aufruf je Bildpunkt in den Stapelueberlauf // laufen lassen). // 7. Grobes Rauschen verwerfen (Staub, Kratzer, Lichtreflexe). // 8. Massstab aus der groessten verbliebenen Flaeche ableiten: nur Flaechen // ab einem festen Anteil der groessten Flaeche behalten - bewusst // relativ, nicht absolut, damit ein naeher oder weiter weg gehaltenes // Kamerabild die Zaehlung nicht verstellt. // 9. Typische Einzelgroesse: Median der verbliebenen Flaechen. // 10. Zaehlen: jede Flaeche zaehlt max(1, gerundet(flaeche / typischeGroesse)). // // Beide Polaritaeten (helle Objekte auf dunklerem Grund, dunkle auf // hellerem) werden berechnet; es gewinnt die mit den meisten behaltenen // Flaechen (siehe Begruendung und Messung am echten Foto in // countObjects() unten und im Bericht) - nur bei Gleichstand entscheidet // ersatzweise die kleinere Gesamtflaeche (Objekte belegen in aller Regel // weniger Flaeche als der Untergrund). // Zielbreite fuer die Normierung (Schritt 1). Schmalere Bilder bleiben // unveraendert. const TARGET_WIDTH = 800; // Kontrastmarge fuer die Maskenbildung (Schritt 5): ein Bildpunkt muss um // mehr als diesen Betrag heller/dunkler sein als sein oertlicher Hintergrund, // um als Objekt zu gelten - unterdrueckt Sensorrauschen und feine // Resthelligkeitsschwankungen, die die Glaettung (Schritt 3) nicht restlos // entfernt. const LOCAL_CONTRAST_MARGIN = 25; // Anteil der groessten Flaeche, ab dem eine kleinere Flaeche noch als // eigenes Fundstueck zaehlt (Schritt 8). Bewusst relativ zur groessten // gefundenen Flaeche - siehe Moduldoku oben. Am echten Foto ist das Ergebnis // zwischen 15 und 25 Prozent unveraendert. 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} */ export function median(values) { const sorted = [...values].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; } /** * Normiert einen Bildausschnitt auf `targetWidth` Bildpunkte Breite * (Seitenverhaeltnis erhalten, naechster Nachbar). Ist der Ausschnitt bereits * schmaler oder gleich breit, wird er unveraendert zurueckgegeben - macht * alle nachfolgenden Groessen (Glaettungsradius, Hintergrundradius, * Rauschschwelle) unabhaengig von der Kameraaufloesung. * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData * @param {number} targetWidth * @returns {{width: number, height: number, data: Uint8ClampedArray}} */ function resizeToWidth(imageData, targetWidth) { const { width, height, data } = imageData; if (width <= targetWidth) { return imageData; } const scale = targetWidth / width; const newHeight = Math.max(1, Math.round(height * scale)); const newData = new Uint8ClampedArray(targetWidth * newHeight * 4); for (let y = 0; y < newHeight; y += 1) { const srcY = Math.min(height - 1, Math.floor((y + 0.5) / scale)); for (let x = 0; x < targetWidth; x += 1) { const srcX = Math.min(width - 1, Math.floor((x + 0.5) / scale)); const srcIndex = (srcY * width + srcX) * 4; const dstIndex = (y * targetWidth + x) * 4; newData[dstIndex] = data[srcIndex]; newData[dstIndex + 1] = data[srcIndex + 1]; newData[dstIndex + 2] = data[srcIndex + 2]; newData[dstIndex + 3] = data[srcIndex + 3]; } } return { width: targetWidth, height: newHeight, data: newData }; } /** * Graustufen (uebliche Gewichte) aus RGBA-Bilddaten. * @param {{width: number, height: number, data: Uint8ClampedArray}} imageData * @returns {Float64Array} ein Eintrag je Bildpunkt */ function toGrayscale({ width, height, data }) { const gray = new Float64Array(width * height); for (let p = 0, i = 0; p < width * height; p += 1, i += 4) { gray[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; } return gray; } /** * Summenbild (Integral Image): jede Zelle enthaelt die Summe aller * Bildpunkte oben links davon. Erlaubt es, die Summe ueber ein beliebiges * Rechteck in konstanter Zeit zu bestimmen - Grundlage fuer den * radius-unabhaengigen Kastenmittelwert unten. * @param {Float64Array} values width*height Eintraege * @param {number} width * @param {number} height * @returns {Float64Array} (width+1)*(height+1) Eintraege */ function buildIntegralImage(values, width, height) { const stride = width + 1; const integral = new Float64Array(stride * (height + 1)); for (let y = 0; y < height; y += 1) { let rowSum = 0; const rowAbove = y * stride; const rowHere = (y + 1) * stride; for (let x = 0; x < width; x += 1) { rowSum += values[y * width + x]; integral[rowHere + x + 1] = integral[rowAbove + x + 1] + rowSum; } } return integral; } /** * Kastenmittelwert ueber `values` mit gegebenem Radius, berechnet aus einem * zuvor gebauten Summenbild - unabhaengig vom Radius in derselben Zeit (nur * vier Zugriffe auf das Summenbild je Bildpunkt). Am Bildrand wird das * Fenster auf das Bild geklemmt und durch die tatsaechliche (kleinere) * Fensterflaeche geteilt, statt den Rand mit Nullen aufzufuellen. * @param {Float64Array} values width*height Eintraege * @param {number} width * @param {number} height * @param {number} radius * @returns {Float64Array} width*height Eintraege */ function boxBlur(values, width, height, radius) { const integral = buildIntegralImage(values, width, height); const stride = width + 1; const out = new Float64Array(width * height); for (let y = 0; y < height; y += 1) { const y0 = Math.max(0, y - radius); const y1 = Math.min(height, y + radius + 1); for (let x = 0; x < width; x += 1) { const x0 = Math.max(0, x - radius); const x1 = Math.min(width, x + radius + 1); const sum = integral[y1 * stride + x1] - integral[y0 * stride + x1] - integral[y1 * stride + x0] + integral[y0 * stride + x0]; const count = (x1 - x0) * (y1 - y0); out[y * width + x] = sum / count; } } return out; } /** * Flutet eine zusammenhaengende Objektflaeche ausgehend von startIndex ueber * eine eigene Arbeitsliste (kein Rekursionsaufruf je Bildpunkt) und markiert * dabei jeden erreichten Bildpunkt in `visited`. Der Stapelspeicher `stack` * wird vom Aufrufer einmal pro Bild angelegt und fuer alle Flaechen * (und beide Polaritaeten) wiederverwendet. * @param {number} startIndex * @param {(index: number) => boolean} isObjectPixel * @param {Uint8Array} visited * @param {number} width * @param {number} height * @param {Int32Array} stack wiederverwendeter Arbeitsspeicher, mind. width*height gross * @returns {number} Anzahl der zur Flaeche gehoerenden Bildpunkte */ function floodFill(startIndex, isObjectPixel, visited, width, height, stack) { let top = 0; stack[top] = startIndex; top += 1; visited[startIndex] = 1; let area = 0; while (top > 0) { top -= 1; const index = stack[top]; area += 1; const x = index % width; const y = (index - x) / width; if (x > 0) { const n = index - 1; if (!visited[n] && isObjectPixel(n)) { visited[n] = 1; stack[top] = n; top += 1; } } if (x < width - 1) { const n = index + 1; if (!visited[n] && isObjectPixel(n)) { visited[n] = 1; stack[top] = n; top += 1; } } if (y > 0) { const n = index - width; if (!visited[n] && isObjectPixel(n)) { visited[n] = 1; stack[top] = n; top += 1; } } if (y < height - 1) { const n = index + width; if (!visited[n] && isObjectPixel(n)) { visited[n] = 1; stack[top] = n; top += 1; } } } return area; } /** * Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus * (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, rawCount: number, filteredCount: number, significantCount: number, largestArea: number, typicalArea: number}} */ function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) { const rawAreas = []; for (let p = 0; p < totalPixels; p += 1) { if (visited[p] || !isObjectPixel(p)) continue; rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack)); } // 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); // Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten - // 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); // 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). 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) { count += Math.max(1, Math.round(area / typicalArea)); totalArea += area; } 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, * 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, width: 0, height: 0, regionsFound: 0, regionsKept: 0, largestArea: 0, typicalArea: 0, polarity: null, }; } // Schritt 1: auf feste Breite normieren. const resized = resizeToWidth(imageData, TARGET_WIDTH); const { width, height } = resized; const totalPixels = width * height; // Schritt 2: Graustufen. const gray = toGrayscale(resized); // Schritt 3: glaetten (unterdrueckt feine Untergrundnarbung). const smoothRadius = Math.max(2, Math.round(width * 0.0025)); const smoothed = boxBlur(gray, width, height, smoothRadius); // Schritt 4: oertlichen Hintergrund bestimmen (deutlich groesseres // Fenster als der Glaettungsradius - faengt ungleichmaessige Ausleuchtung // ab, ohne von einem einzelnen Fundstueck selbst dominiert zu werden). Ein // Mindestradius von 1 verhindert ein entartetes Ein-Bildpunkt-Fenster bei // sehr kleinen Ausschnitten, in dem der "Hintergrund" schlicht der // geglaettete Wert selbst waere. const backgroundRadius = Math.max(1, Math.round(Math.max(width, height) / 12)); const localBackground = boxBlur(smoothed, width, height, backgroundRadius); const visited = new Uint8Array(totalPixels); const stack = new Int32Array(totalPixels); // Beide Polaritaeten: helle Objekte auf dunklerem Grund ... const isBrightObject = (p) => smoothed[p] > localBackground[p] + LOCAL_CONTRAST_MARGIN; const bright = analyzePolarity(isBrightObject, width, height, totalPixels, visited, stack); // ... und dunkle Objekte auf hellerem Grund. Arbeitsspeicher wird // wiederverwendet, muss dafuer zurueckgesetzt werden. visited.fill(0); const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN; const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack); // 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); return { count: winner.count, width, height, regionsFound: winner.rawCount, regionsKept: winner.significantCount, largestArea: winner.largestArea, typicalArea: winner.typicalArea, polarity: winner.polarity, }; }