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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user