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>
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Das neue Verfahren (siehe count-objects.js) braucht oertlichen Kontrast:
|
|
// jeder Bildpunkt wird gegen den Mittelwert seiner Umgebung verglichen
|
|
// (Fensterradius max(Breite, Hoehe) / 12). Damit dieses Fenster an jeder
|
|
// Objektstelle noch echten Untergrund sieht (statt nur das Objekt selbst
|
|
// nochmal zu mitteln), muss der Untergrund eine deutlich groessere Flaeche
|
|
// einnehmen als jedes einzelne Fundstueck - anders als beim alten,
|
|
// Otsu-basierten Verfahren, wo ein 40x40- oder 80x80-Bild mit 10x10-
|
|
// Quadraten genuegte. Die Testbilder hier sind deshalb groesser (300x300
|
|
// bzw. 600x600) im Verhaeltnis zu den gemalten Flaechen.
|
|
|
|
test('leeres Bild (kein Objekt, gleichmaessiger Untergrund) ergibt null', () => {
|
|
const rows = grid(300, 300, 60);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 0);
|
|
});
|
|
|
|
test('eine einzelne Flaeche ergibt eins', () => {
|
|
const rows = grid(300, 300, 30);
|
|
paint(rows, 140, 140, 20, 20, 220);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 1);
|
|
});
|
|
|
|
test('mehrere getrennte, gleich grosse Flaechen ergeben ihre Anzahl', () => {
|
|
const rows = grid(300, 300, 30);
|
|
paint(rows, 40, 40, 20, 20, 220);
|
|
paint(rows, 200, 40, 20, 20, 220);
|
|
paint(rows, 40, 200, 20, 20, 220);
|
|
paint(rows, 200, 200, 20, 20, 220);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 4);
|
|
});
|
|
|
|
test('winzige Stoerflaechen (Staub, Reflexe) werden nicht mitgezaehlt', () => {
|
|
const rows = grid(300, 300, 30);
|
|
paint(rows, 40, 40, 20, 20, 220);
|
|
paint(rows, 200, 40, 20, 20, 220);
|
|
paint(rows, 40, 200, 20, 20, 220);
|
|
paint(rows, 200, 200, 20, 20, 220);
|
|
// Einzelpixel- bis Doppelpixel-Stoerflaechen, weit entfernt von den
|
|
// echten Flaechen und voneinander - weit unter der Rauschschwelle
|
|
// (max(8, 0.00002 * Bildpunktzahl), hier 8 Bildpunkte).
|
|
rows[150][150] = 220;
|
|
rows[160][160] = 220;
|
|
rows[160][161] = 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', () => {
|
|
// Groessere Quadrate und ein groesseres Bild als beim Rauschtest: die
|
|
// Glaettung (Schritt 3) dehnt jede Flaeche an ihrem Rand etwas auf - bei
|
|
// sehr kleinen Quadraten faellt diese Randzugabe relativ zur Flaeche viel
|
|
// staerker ins Gewicht als bei groesseren und wuerde das exakte
|
|
// Vierfache verzerren.
|
|
const rows = grid(600, 600, 30);
|
|
paint(rows, 60, 60, 40, 40, 220); // typische Groesse
|
|
paint(rows, 400, 60, 40, 40, 220);
|
|
paint(rows, 60, 400, 40, 40, 220);
|
|
// Verschmolzene Flaeche mehrerer beruehrender Teile: 80x80 = 4x 40x40.
|
|
paint(rows, 400, 400, 80, 80, 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(600, 600, 30);
|
|
paint(rows, 60, 60, 40, 40, 220);
|
|
paint(rows, 400, 60, 40, 40, 220);
|
|
paint(rows, 60, 400, 40, 40, 220);
|
|
// Rund 30% groesser als die anderen (44x48=2112 statt 1600) - haeufiger
|
|
// Fall, wenn ein Teil im Bild etwas naeher an der Kamera liegt.
|
|
paint(rows, 400, 400, 44, 48, 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(300, 300, 220);
|
|
paint(darkOnLight, 40, 40, 20, 20, 30);
|
|
paint(darkOnLight, 200, 40, 20, 20, 30);
|
|
paint(darkOnLight, 40, 200, 20, 20, 30);
|
|
paint(darkOnLight, 200, 200, 20, 20, 30);
|
|
|
|
const lightOnDark = grid(300, 300, 30);
|
|
paint(lightOnDark, 40, 40, 20, 20, 220);
|
|
paint(lightOnDark, 200, 40, 20, 20, 220);
|
|
paint(lightOnDark, 40, 200, 20, 20, 220);
|
|
paint(lightOnDark, 200, 200, 20, 20, 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 nahe, aber nur diagonal benachbarte Flaechen zaehlen getrennt (4er-Nachbarschaft)', () => {
|
|
const rows = grid(300, 300, 30);
|
|
// Zwei 15x15-Quadrate an ihrer naechsten Ecke durch eine 3 Bildpunkte
|
|
// breite Luecke getrennt - nah genug, um wie ein einziges, diagonal
|
|
// ausgerichtetes Fundstueckpaar auszusehen, aber tatsaechlich getrennt.
|
|
// Die Luecke ist bewusst breiter als beim alten, Otsu-basierten Test
|
|
// (dort genuegte ein einzelnes gemeinsames Eckpixel): Die Glaettung
|
|
// (Schritt 3) dehnt jede Flaeche an ihrem Rand um ein bis zwei Bildpunkte
|
|
// auf, ein einzelnes gemeinsames Eckpixel wuerde die beiden Flaechen nach
|
|
// der Glaettung faelschlich zu einer verschmelzen lassen.
|
|
paint(rows, 130, 130, 15, 15, 220);
|
|
paint(rows, 148, 148, 15, 15, 220);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 2, 'nur diagonal benachbarte Flaechen bleiben getrennt');
|
|
});
|
|
|
|
// --- Diagnosewerte (siehe Modul-Doku von countObjects()) ----------------
|
|
//
|
|
// Die Diagnoseanzeige (ui/scan-view.js, verdrahtet in main.js) liest diese
|
|
// Zwischenwerte vom Bildschirm ab, wenn beim Nutzer die Zaehlung schwankt -
|
|
// sie muessen deshalb auch dann sinnvoll belegt sein, wenn nichts gefunden
|
|
// wurde (count === 0), sonst zeigt die Anzeige in genau dem Fall nichts, in
|
|
// dem sie am dringendsten gebraucht wird.
|
|
|
|
test('ein Bild ohne Bildpunkte belegt alle Diagnosewerte mit 0/null', () => {
|
|
const result = countObjects({ width: 0, height: 0, data: new Uint8ClampedArray(0) });
|
|
assert.equal(result.count, 0);
|
|
assert.equal(result.width, 0);
|
|
assert.equal(result.height, 0);
|
|
assert.equal(result.regionsFound, 0);
|
|
assert.equal(result.regionsKept, 0);
|
|
assert.equal(result.largestArea, 0);
|
|
assert.equal(result.typicalArea, 0);
|
|
assert.equal(result.polarity, null);
|
|
});
|
|
|
|
test('leeres Bild (kein Objekt) belegt die Diagnosewerte trotzdem sinnvoll', () => {
|
|
const rows = grid(300, 300, 60);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 0);
|
|
// Kleiner als TARGET_WIDTH (800) - bleibt bei der Normierung unveraendert.
|
|
assert.equal(result.width, 300);
|
|
assert.equal(result.height, 300);
|
|
assert.equal(result.regionsFound, 0);
|
|
assert.equal(result.regionsKept, 0);
|
|
assert.equal(result.largestArea, 0);
|
|
assert.equal(result.typicalArea, 0);
|
|
// Keine Flaeche gefunden - trotzdem muss eine der beiden Polaritaeten als
|
|
// Diagnosewert benannt sein, nicht null (das bleibt dem Fall "gar kein
|
|
// Bildpunkt" vorbehalten, siehe Test oben).
|
|
assert.ok(result.polarity === 'hell' || result.polarity === 'dunkel');
|
|
});
|
|
|
|
test('eine einzelne Flaeche liefert plausible Diagnosewerte und die richtige Polaritaet', () => {
|
|
const rows = grid(300, 300, 30);
|
|
paint(rows, 140, 140, 20, 20, 220); // heller als der Untergrund
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 1);
|
|
assert.equal(result.width, 300);
|
|
assert.equal(result.height, 300);
|
|
assert.equal(result.polarity, 'hell', 'helle Flaeche auf dunklerem Grund');
|
|
assert.equal(result.regionsFound, 1);
|
|
assert.equal(result.regionsKept, 1);
|
|
assert.ok(result.largestArea > 0);
|
|
// Bei genau einer signifikanten Flaeche ist der Median (die typische
|
|
// Groesse) exakt diese eine Flaeche.
|
|
assert.equal(result.typicalArea, result.largestArea);
|
|
});
|
|
|
|
test('dunkle Flaeche auf hellem Grund meldet die Polaritaet "dunkel"', () => {
|
|
const rows = grid(300, 300, 220);
|
|
paint(rows, 140, 140, 20, 20, 30);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 1);
|
|
assert.equal(result.polarity, 'dunkel');
|
|
});
|
|
|
|
test('regionsKept zaehlt Flaechen, nicht die hochgerechneten Fundstuecke', () => {
|
|
// Wie der Test "eine Flaeche von etwa vierfacher Einzelgroesse wird als
|
|
// vier gezaehlt" oben: drei Einzelflaechen + eine verschmolzene Flaeche
|
|
// vierfacher Groesse. count rechnet die verschmolzene Flaeche auf vier
|
|
// Fundstuecke hoch (macht 7 insgesamt) - regionsKept zaehlt aber die
|
|
// tatsaechlich gefundenen, zusammenhaengenden Flaechen (4), nicht die
|
|
// hochgerechneten Fundstuecke.
|
|
const rows = grid(600, 600, 30);
|
|
paint(rows, 60, 60, 40, 40, 220);
|
|
paint(rows, 400, 60, 40, 40, 220);
|
|
paint(rows, 60, 400, 40, 40, 220);
|
|
paint(rows, 400, 400, 80, 80, 220);
|
|
const result = countObjects(image(rows));
|
|
assert.equal(result.count, 7);
|
|
assert.equal(result.regionsFound, 4);
|
|
assert.equal(result.regionsKept, 4);
|
|
assert.ok(result.largestArea > result.typicalArea, 'die verschmolzene Flaeche ist die groesste');
|
|
});
|