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:
2026-07-29 15:47:33 +02:00
co-authored by Claude Opus 5
parent 500f9b6c3d
commit 5e64d260c5
2 changed files with 326 additions and 0 deletions
+187
View File
@@ -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 };
}