From 5e64d260c5126e515299ead4c0ccb9573068e082 Mon Sep 17 00:00:00 2001 From: TanerUslu Date: Wed, 29 Jul 2026 15:47:33 +0200 Subject: [PATCH] Reine Zaehl-Berechnung fuer Fundstuecke in einem Bildausschnitt Neues Modul src/count-objects.js: Otsu-Schwellwert (threshold.js) teilt den Ausschnitt in zwei Klassen, die flaechenmaessig kleinere gilt als Objekt (funktioniert fuer helle wie dunkle Teile auf beliebigem Untergrund). Connected-Component-Labeling ueber eine eigene Arbeitsliste statt Rekursion (vermeidet Stapelueberlauf bei ~1 Mio. Bildpunkten), Rauschflaechen werden verworfen, beruehrende Teile anhand des Flaechen-Medians hochgerechnet. Test-Driven: leeres Bild, einzelne/mehrere getrennte Flaechen, verworfenes Rauschen, Hochrechnung ohne ein nur leicht groesseres Einzelteil zu verdoppeln, helle wie dunkle Objekte, Bild ohne Bildpunkte, diagonal statt flaechig beruehrende Teile bleiben getrennt. Co-Authored-By: Claude Opus 5 (1M context) --- src/count-objects.js | 187 +++++++++++++++++++++++++++++++++++++ test/count-objects.test.js | 139 +++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/count-objects.js create mode 100644 test/count-objects.test.js diff --git a/src/count-objects.js b/src/count-objects.js new file mode 100644 index 0000000..9b81b35 --- /dev/null +++ b/src/count-objects.js @@ -0,0 +1,187 @@ +// 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: derselbe Otsu-Schwellwert wie bei der +// Texterkennung (siehe threshold.js), kein Bilderkennungsmodell. +// +// Ablauf, siehe Bericht (.superpowers/sdd/counting-report.md) fuer die +// Begruendung der einzelnen Schwellen: +// 1. Otsu-Schwellwert trennt den Ausschnitt in zwei Klassen (threshold.js - +// dasselbe Modul, das auch preprocess() in ocr.js nutzt). +// 2. Die flaechenmaessig kleinere der beiden Klassen gilt als Objekt - die +// ausgelegten Teile nehmen fast immer weniger Flaeche ein als der +// Untergrund, egal ob sie heller oder dunkler sind als dieser. Kein +// Umschalten durch den Nutzer noetig. +// 3. Connected-Component-Labeling mit 4er-Nachbarschaft (oben/unten/links/ +// rechts) ueber eine eigene Arbeitsliste statt Rekursion - der Ausschnitt +// hat rund eine Million Bildpunkte, ein rekursiver Aufruf je Bildpunkt +// liefe in den Stapelueberlauf. 4er- statt 8er-Nachbarschaft, damit zwei +// nur diagonal beruehrende, tatsaechlich getrennte Teile nicht ueber ein +// einzelnes Eckpixel hinweg zu einer Flaeche verschmelzen. +// 4. Winzige Flaechen (Staub, Kratzer, Lichtreflexe) werden verworfen: alles +// deutlich kleiner als die (vorlaeufige) typische Fundstueckgroesse. +// 5. Auffaellig grosse Flaechen (mehrere beruehrende Teile) werden auf ein +// Vielfaches der typischen Groesse hochgerechnet. + +import { computeOtsuThreshold } from './threshold.js'; + +// Flaechen unterhalb dieses Anteils der vorlaeufigen Medianflaeche gelten +// als Rauschen, nicht als eigenes Fundstueck. Ein Viertel der typischen +// Groesse laesst echten Teilen deutlichen Spielraum (z. B. ein Teil, das nur +// zum groessten Teil im Rahmen liegt), verwirft aber zuverlaessig die +// deutlich kleineren Einzelpixel- bis Handvoll-Pixel-Flaechen von Staub, +// Kratzern und Reflexen. Siehe Bericht fuer die Abwaegung. +const NOISE_AREA_FRACTION = 0.25; + +/** + * Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene + * Teile) oder sehr kleine (Rauschen) Ausreisser, anders als der Mittelwert. + * @param {number[]} values nicht-leer + * @returns {number} + */ +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]; +} + +/** + * 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 + * wiederverwendet - bei mehrmals pro Sekunde laufender Zaehlung auf rund + * einer Million Bildpunkten spart das die wiederholte Neuanlage eines + * Arrays je Flaeche. + * @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; +} + +/** + * Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem + * Kamera-Ausschnitt. Reine Anzeigefunktion - erzeugt keinen Zustand, bucht + * nichts, veraendert keine Sitzung. + * @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). + */ +export function countObjects(imageData) { + const { width, height } = imageData; + const totalPixels = width * height; + if (totalPixels === 0) { + return { count: 0 }; + } + + const { gray, threshold } = computeOtsuThreshold(imageData); + + let brightPixels = 0; + for (let p = 0; p < totalPixels; p += 1) { + if (gray[p] >= threshold) brightPixels += 1; + } + const darkPixels = totalPixels - brightPixels; + // Die Objekte belegen in aller Regel weniger Flaeche als der Untergrund - + // die flaechenmaessig kleinere Klasse gilt deshalb als Objekt, unabhaengig + // davon, ob sie heller oder dunkler ist als die andere. + const objectIsBright = brightPixels < darkPixels; + const isObjectPixel = (p) => (gray[p] >= threshold) === objectIsBright; + + const visited = new Uint8Array(totalPixels); + const stack = new Int32Array(totalPixels); + 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)); + } + + if (rawAreas.length === 0) { + return { count: 0 }; + } + + // Vorlaeufiger Massstab (siehe NOISE_AREA_FRACTION oben), um Rauschen zu + // verwerfen, bevor der endgueltige Massstab fuer die Hochrechnung bestimmt + // wird - Rauschflaechen sollen diesen Massstab selbst nicht mehr verzerren. + const provisionalTypicalArea = median(rawAreas); + const noiseThreshold = provisionalTypicalArea * NOISE_AREA_FRACTION; + const significantAreas = rawAreas.filter((area) => area >= noiseThreshold); + + if (significantAreas.length === 0) { + return { count: 0 }; + } + + // Massstab fuer die Hochrechnung: Median der um Rauschen bereinigten + // Flaechen - robust gegen die verbliebenen Ausreisser (z. B. eine grosse, + // aus mehreren beruehrenden Teilen verschmolzene Flaeche). + const typicalArea = median(significantAreas); + + let count = 0; + for (const area of significantAreas) { + // Runden statt Abschneiden: Eine Flaeche, die nur wenig groesser ist als + // der Median (z. B. 1,3-fach, weil ein Teil zufaellig etwas naeher an + // der Kamera oder etwas groesser im Bild liegt), rundet auf 1 ab und wird + // nicht faelschlich als zwei gezaehlt - erst deutlich ueber dem + // 1,5-fachen rundet auf 2 usw. Jede Flaeche zaehlt mindestens einmal. + count += Math.max(1, Math.round(area / typicalArea)); + } + + return { count }; +} diff --git a/test/count-objects.test.js b/test/count-objects.test.js new file mode 100644 index 0000000..43438e3 --- /dev/null +++ b/test/count-objects.test.js @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { countObjects } from '../src/count-objects.js'; + +/** + * Baut ein ImageData-artiges Objekt aus einem Raster von Graustufenwerten + * (0-255 je Zelle) - derselbe minimale Ersatz wie in ocr-preprocess.test.js, + * hier zweidimensional, weil countObjects() auf zusammenhaengende Flaechen + * angewiesen ist. + * @param {number[][]} rows gleich lange Zeilen von Graustufenwerten + */ +function image(rows) { + const height = rows.length; + const width = height > 0 ? rows[0].length : 0; + const data = new Uint8ClampedArray(width * height * 4); + let i = 0; + for (const row of rows) { + for (const value of row) { + data[i] = value; + data[i + 1] = value; + data[i + 2] = value; + data[i + 3] = 255; + i += 4; + } + } + return { width, height, data }; +} + +/** Erzeugt ein width x height grosses Raster, komplett mit `background` gefuellt. */ +function grid(width, height, background) { + return Array.from({ length: height }, () => new Array(width).fill(background)); +} + +/** Malt ein width x height grosses Quadrat/Rechteck an Position (x, y) mit `value`. */ +function paint(rows, x, y, width, height, value) { + for (let row = y; row < y + height; row += 1) { + for (let col = x; col < x + width; col += 1) { + rows[row][col] = value; + } + } +} + +test('leeres Bild (kein Objekt, gleichmaessiger Untergrund) ergibt null', () => { + const rows = grid(20, 20, 60); + const result = countObjects(image(rows)); + assert.equal(result.count, 0); +}); + +test('eine einzelne Flaeche ergibt eins', () => { + const rows = grid(40, 40, 30); + paint(rows, 15, 15, 10, 10, 220); + const result = countObjects(image(rows)); + assert.equal(result.count, 1); +}); + +test('mehrere getrennte, gleich grosse Flaechen ergeben ihre Anzahl', () => { + const rows = grid(80, 80, 30); + paint(rows, 5, 5, 10, 10, 220); + paint(rows, 30, 5, 10, 10, 220); + paint(rows, 5, 30, 10, 10, 220); + paint(rows, 30, 30, 10, 10, 220); + const result = countObjects(image(rows)); + assert.equal(result.count, 4); +}); + +test('winzige Stoerflaechen (Staub, Reflexe) werden nicht mitgezaehlt', () => { + const rows = grid(80, 80, 30); + paint(rows, 5, 5, 10, 10, 220); + paint(rows, 30, 5, 10, 10, 220); + paint(rows, 5, 30, 10, 10, 220); + paint(rows, 30, 30, 10, 10, 220); + // Einzelpixel- bis Doppelpixel-Stoerflaechen, weit entfernt von den + // echten Flaechen und voneinander - deutlich unter einem Viertel der + // typischen Flaechengroesse (100 Bildpunkte). + rows[60][60] = 220; + rows[65][65] = 220; + rows[65][66] = 220; + rows[70][10] = 220; + const result = countObjects(image(rows)); + assert.equal(result.count, 4, 'Stoerflaechen duerfen die Anzahl nicht erhoehen'); +}); + +test('eine Flaeche von etwa vierfacher Einzelgroesse wird als vier gezaehlt', () => { + const rows = grid(80, 80, 30); + paint(rows, 5, 5, 10, 10, 220); // 100 Bildpunkte, typische Groesse + paint(rows, 30, 5, 10, 10, 220); // 100 + paint(rows, 5, 30, 10, 10, 220); // 100 + // Verschmolzene Flaeche mehrerer beruehrender Teile: 20x20 = 400 = 4x100. + paint(rows, 30, 30, 20, 20, 220); + const result = countObjects(image(rows)); + assert.equal(result.count, 7, '3 Einzelflaechen (je 1) + hochgerechnete Flaeche (4)'); +}); + +test('ein einzelnes, nur leicht groesseres Teil wird nicht als zwei gezaehlt', () => { + const rows = grid(80, 80, 30); + paint(rows, 5, 5, 10, 10, 220); // 100 + paint(rows, 30, 5, 10, 10, 220); // 100 + paint(rows, 5, 30, 10, 10, 220); // 100 + // Rund 30% groesser als die anderen (11x12=132 statt 100) - haeufiger + // Fall, wenn ein Teil im Bild etwas naeher an der Kamera liegt. + paint(rows, 30, 30, 11, 12, 220); + const result = countObjects(image(rows)); + assert.equal(result.count, 4, 'leicht groessere Einzelflaeche zaehlt weiterhin als eins'); +}); + +test('dunkle Objekte auf hellem Grund werden genauso gezaehlt wie helle auf dunklem', () => { + const darkOnLight = grid(80, 80, 220); + paint(darkOnLight, 5, 5, 10, 10, 30); + paint(darkOnLight, 30, 5, 10, 10, 30); + paint(darkOnLight, 5, 30, 10, 10, 30); + paint(darkOnLight, 30, 30, 10, 10, 30); + + const lightOnDark = grid(80, 80, 30); + paint(lightOnDark, 5, 5, 10, 10, 220); + paint(lightOnDark, 30, 5, 10, 10, 220); + paint(lightOnDark, 5, 30, 10, 10, 220); + paint(lightOnDark, 30, 30, 10, 10, 220); + + const darkResult = countObjects(image(darkOnLight)); + const lightResult = countObjects(image(lightOnDark)); + assert.equal(darkResult.count, 4); + assert.equal(lightResult.count, 4); +}); + +test('ein Bild ohne Bildpunkte stuerzt nicht ab', () => { + assert.doesNotThrow(() => { + const result = countObjects({ width: 0, height: 0, data: new Uint8ClampedArray(0) }); + assert.equal(result.count, 0); + }); +}); + +test('zwei aneinanderliegende, aber nur diagonal beruehrende Flaechen zaehlen getrennt (4er-Nachbarschaft)', () => { + const rows = grid(40, 40, 30); + // Zwei 5x5-Quadrate, die sich nur in einer Ecke (ein Bildpunkt) beruehren. + paint(rows, 10, 10, 5, 5, 220); + paint(rows, 15, 15, 5, 5, 220); + const result = countObjects(image(rows)); + assert.equal(result.count, 2, 'nur diagonal beruehrende Flaechen bleiben getrennt'); +});