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'); });