Zaehlung auf oertlichen Kontrast umgestellt (Otsu versagte am echten Foto)
Otsu (bisheriges Verfahren) meldete am echten Foto des Auftraggebers (~20 Schrauben auf Leder, unter 1% Flaechenanteil) 76712 statt rund zwanzig - Otsu taugt nur bei etwa gleich grossen Objekt-/ Untergrundflaechen. countObjects() vergleicht jetzt jeden Bildpunkt mit einem oertlichen Hintergrund (Kastenfilter ueber Summenbild, Radius max(Breite,Hoehe)/12), was Ausleuchtungsunterschiede abfaengt und am echten Foto 18 statt 76712 liefert (Test in test/count-objects-photo.test.js, ~30-49ms Laufzeit). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ffb2409c24
commit
75c479860f
3 files changed
+353
-78
No files matched your search
+286
-78
@@ -1,37 +1,70 @@
|
|||||||
// Zaehlt zusammenhaengende Objektflaechen in einem Kamera-Ausschnitt - die
|
// Zaehlt zusammenhaengende Objektflaechen in einem Kamera-Ausschnitt - die
|
||||||
// vierte Scan-Funktion "Zaehlen" (siehe scan-modes.js). Reine Berechnung auf
|
// vierte Scan-Funktion "Zaehlen" (siehe scan-modes.js). Reine Berechnung auf
|
||||||
// Bilddaten, ohne jeden Browser-Zugriff (kein window/document/canvas) und
|
// Bilddaten, ohne jeden Browser-Zugriff (kein window/document/canvas) und
|
||||||
// ohne neue Abhaengigkeit: derselbe Otsu-Schwellwert wie bei der
|
// ohne neue Abhaengigkeit.
|
||||||
// Texterkennung (siehe threshold.js), kein Bilderkennungsmodell.
|
|
||||||
//
|
//
|
||||||
// Ablauf, siehe Bericht (.superpowers/sdd/counting-report.md) fuer die
|
// Otsu (threshold.js) taugt nur, wenn Objekt- und Untergrundflaeche etwa
|
||||||
// Begruendung der einzelnen Schwellen:
|
// gleich gross sind. Am echten Foto des Auftraggebers (rund zwanzig
|
||||||
// 1. Otsu-Schwellwert trennt den Ausschnitt in zwei Klassen (threshold.js -
|
// Festplattenschrauben auf dunklem Leder) belegen die Schrauben unter einem
|
||||||
// dasselbe Modul, das auch preprocess() in ocr.js nutzt).
|
// Prozent der Flaeche - Otsu rutscht dann auf den hoechsten Helligkeitswert
|
||||||
// 2. Die flaechenmaessig kleinere der beiden Klassen gilt als Objekt - die
|
// und erklaert nur die hellsten Punkte der Ledernarbung zum Objekt
|
||||||
// ausgelegten Teile nehmen fast immer weniger Flaeche ein als der
|
// (gemessen: 76712 statt rund zwanzig, siehe
|
||||||
// Untergrund, egal ob sie heller oder dunkler sind als dieser. Kein
|
// test/count-objects-photo.test.js). Otsu betrachtet zudem nur die absolute
|
||||||
// Umschalten durch den Nutzer noetig.
|
// Helligkeit ueber das ganze Bild hinweg; am echten Foto sind Schraube und
|
||||||
// 3. Connected-Component-Labeling mit 4er-Nachbarschaft (oben/unten/links/
|
// Untergrund darueber gar nicht trennbar (Median 114, 90. Perzentil 221,
|
||||||
// rechts) ueber eine eigene Arbeitsliste statt Rekursion - der Ausschnitt
|
// 99. Perzentil 254 - deutliche Ueberlappung).
|
||||||
// hat rund eine Million Bildpunkte, ein rekursiver Aufruf je Bildpunkt
|
//
|
||||||
// liefe in den Stapelueberlauf. 4er- statt 8er-Nachbarschaft, damit zwei
|
// Dieses Modul verwendet stattdessen oertlichen Kontrast (siehe
|
||||||
// nur diagonal beruehrende, tatsaechlich getrennte Teile nicht ueber ein
|
// .superpowers/sdd/counting-rework-report.md fuer die Herleitung und die
|
||||||
// einzelnes Eckpixel hinweg zu einer Flaeche verschmelzen.
|
// Messungen am echten Foto):
|
||||||
// 4. Winzige Flaechen (Staub, Kratzer, Lichtreflexe) werden verworfen: alles
|
// 1. Auf 800 Bildpunkte Breite normieren (Seitenverhaeltnis erhalten) -
|
||||||
// deutlich kleiner als die (vorlaeufige) typische Fundstueckgroesse.
|
// macht alle folgenden Groessen unabhaengig von der Kameraaufloesung und
|
||||||
// 5. Auffaellig grosse Flaechen (mehrere beruehrende Teile) werden auf ein
|
// die Rechnung schneller.
|
||||||
// Vielfaches der typischen Groesse hochgerechnet.
|
// 2. Graustufen (uebliche Gewichte 0,299 / 0,587 / 0,114).
|
||||||
|
// 3. Glaetten mit einem Kastenfilter (unterdrueckt feine Untergrundnarbung,
|
||||||
|
// ohne Teile zu verschmieren) ueber ein Summenbild, damit die
|
||||||
|
// Mittelwertbildung unabhaengig vom Radius in gleicher Zeit laeuft.
|
||||||
|
// 4. Oertlichen Hintergrund ueber einen zweiten, deutlich groesseren
|
||||||
|
// Kastenmittelwert bestimmen - faengt ungleichmaessige Ausleuchtung ab
|
||||||
|
// und bleibt auch dann noch "Untergrund", wenn ein einzelnes Teil den
|
||||||
|
// Mittelwert an seiner eigenen Stelle nicht dominieren kann (das Fenster
|
||||||
|
// ist viel groesser als ein einzelnes Teil).
|
||||||
|
// 5. Maske: Bildpunkt geglaettet vs. oertlicher Hintergrund +/- Marge.
|
||||||
|
// 6. Connected-Component-Labeling mit 4er-Nachbarschaft ueber eine eigene
|
||||||
|
// Arbeitsliste statt Rekursion (rund eine Viertelmillion Bildpunkte
|
||||||
|
// wuerden einen rekursiven Aufruf je Bildpunkt in den Stapelueberlauf
|
||||||
|
// laufen lassen).
|
||||||
|
// 7. Grobes Rauschen verwerfen (Staub, Kratzer, Lichtreflexe).
|
||||||
|
// 8. Massstab aus der groessten verbliebenen Flaeche ableiten: nur Flaechen
|
||||||
|
// ab einem festen Anteil der groessten Flaeche behalten - bewusst
|
||||||
|
// relativ, nicht absolut, damit ein naeher oder weiter weg gehaltenes
|
||||||
|
// Kamerabild die Zaehlung nicht verstellt.
|
||||||
|
// 9. Typische Einzelgroesse: Median der verbliebenen Flaechen.
|
||||||
|
// 10. Zaehlen: jede Flaeche zaehlt max(1, gerundet(flaeche / typischeGroesse)).
|
||||||
|
//
|
||||||
|
// Beide Polaritaeten (helle Objekte auf dunklerem Grund, dunkle auf
|
||||||
|
// hellerem) werden berechnet; es gewinnt die mit den meisten behaltenen
|
||||||
|
// Flaechen (siehe Begruendung und Messung am echten Foto in
|
||||||
|
// countObjects() unten und im Bericht) - nur bei Gleichstand entscheidet
|
||||||
|
// ersatzweise die kleinere Gesamtflaeche (Objekte belegen in aller Regel
|
||||||
|
// weniger Flaeche als der Untergrund).
|
||||||
|
|
||||||
import { computeOtsuThreshold } from './threshold.js';
|
// Zielbreite fuer die Normierung (Schritt 1). Schmalere Bilder bleiben
|
||||||
|
// unveraendert.
|
||||||
|
const TARGET_WIDTH = 800;
|
||||||
|
|
||||||
// Flaechen unterhalb dieses Anteils der vorlaeufigen Medianflaeche gelten
|
// Kontrastmarge fuer die Maskenbildung (Schritt 5): ein Bildpunkt muss um
|
||||||
// als Rauschen, nicht als eigenes Fundstueck. Ein Viertel der typischen
|
// mehr als diesen Betrag heller/dunkler sein als sein oertlicher Hintergrund,
|
||||||
// Groesse laesst echten Teilen deutlichen Spielraum (z. B. ein Teil, das nur
|
// um als Objekt zu gelten - unterdrueckt Sensorrauschen und feine
|
||||||
// zum groessten Teil im Rahmen liegt), verwirft aber zuverlaessig die
|
// Resthelligkeitsschwankungen, die die Glaettung (Schritt 3) nicht restlos
|
||||||
// deutlich kleineren Einzelpixel- bis Handvoll-Pixel-Flaechen von Staub,
|
// entfernt.
|
||||||
// Kratzern und Reflexen. Siehe Bericht fuer die Abwaegung.
|
const LOCAL_CONTRAST_MARGIN = 25;
|
||||||
const NOISE_AREA_FRACTION = 0.25;
|
|
||||||
|
// Anteil der groessten Flaeche, ab dem eine kleinere Flaeche noch als
|
||||||
|
// eigenes Fundstueck zaehlt (Schritt 8). Bewusst relativ zur groessten
|
||||||
|
// gefundenen Flaeche - siehe Moduldoku oben. Am echten Foto ist das Ergebnis
|
||||||
|
// zwischen 15 und 25 Prozent unveraendert.
|
||||||
|
const SCALE_FRACTION_OF_LARGEST = 0.2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene
|
* Median einer Zahlenliste - robust gegen einzelne sehr grosse (verschmolzene
|
||||||
@@ -47,14 +80,124 @@ function median(values) {
|
|||||||
: sorted[middle];
|
: sorted[middle];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normiert einen Bildausschnitt auf `targetWidth` Bildpunkte Breite
|
||||||
|
* (Seitenverhaeltnis erhalten, naechster Nachbar). Ist der Ausschnitt bereits
|
||||||
|
* schmaler oder gleich breit, wird er unveraendert zurueckgegeben - macht
|
||||||
|
* alle nachfolgenden Groessen (Glaettungsradius, Hintergrundradius,
|
||||||
|
* Rauschschwelle) unabhaengig von der Kameraaufloesung.
|
||||||
|
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
||||||
|
* @param {number} targetWidth
|
||||||
|
* @returns {{width: number, height: number, data: Uint8ClampedArray}}
|
||||||
|
*/
|
||||||
|
function resizeToWidth(imageData, targetWidth) {
|
||||||
|
const { width, height, data } = imageData;
|
||||||
|
if (width <= targetWidth) {
|
||||||
|
return imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = targetWidth / width;
|
||||||
|
const newHeight = Math.max(1, Math.round(height * scale));
|
||||||
|
const newData = new Uint8ClampedArray(targetWidth * newHeight * 4);
|
||||||
|
|
||||||
|
for (let y = 0; y < newHeight; y += 1) {
|
||||||
|
const srcY = Math.min(height - 1, Math.floor((y + 0.5) / scale));
|
||||||
|
for (let x = 0; x < targetWidth; x += 1) {
|
||||||
|
const srcX = Math.min(width - 1, Math.floor((x + 0.5) / scale));
|
||||||
|
const srcIndex = (srcY * width + srcX) * 4;
|
||||||
|
const dstIndex = (y * targetWidth + x) * 4;
|
||||||
|
newData[dstIndex] = data[srcIndex];
|
||||||
|
newData[dstIndex + 1] = data[srcIndex + 1];
|
||||||
|
newData[dstIndex + 2] = data[srcIndex + 2];
|
||||||
|
newData[dstIndex + 3] = data[srcIndex + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width: targetWidth, height: newHeight, data: newData };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graustufen (uebliche Gewichte) aus RGBA-Bilddaten.
|
||||||
|
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
||||||
|
* @returns {Float64Array} ein Eintrag je Bildpunkt
|
||||||
|
*/
|
||||||
|
function toGrayscale({ width, height, data }) {
|
||||||
|
const gray = new Float64Array(width * height);
|
||||||
|
for (let p = 0, i = 0; p < width * height; p += 1, i += 4) {
|
||||||
|
gray[p] = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
||||||
|
}
|
||||||
|
return gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summenbild (Integral Image): jede Zelle enthaelt die Summe aller
|
||||||
|
* Bildpunkte oben links davon. Erlaubt es, die Summe ueber ein beliebiges
|
||||||
|
* Rechteck in konstanter Zeit zu bestimmen - Grundlage fuer den
|
||||||
|
* radius-unabhaengigen Kastenmittelwert unten.
|
||||||
|
* @param {Float64Array} values width*height Eintraege
|
||||||
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @returns {Float64Array} (width+1)*(height+1) Eintraege
|
||||||
|
*/
|
||||||
|
function buildIntegralImage(values, width, height) {
|
||||||
|
const stride = width + 1;
|
||||||
|
const integral = new Float64Array(stride * (height + 1));
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
let rowSum = 0;
|
||||||
|
const rowAbove = y * stride;
|
||||||
|
const rowHere = (y + 1) * stride;
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
rowSum += values[y * width + x];
|
||||||
|
integral[rowHere + x + 1] = integral[rowAbove + x + 1] + rowSum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return integral;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kastenmittelwert ueber `values` mit gegebenem Radius, berechnet aus einem
|
||||||
|
* zuvor gebauten Summenbild - unabhaengig vom Radius in derselben Zeit (nur
|
||||||
|
* vier Zugriffe auf das Summenbild je Bildpunkt). Am Bildrand wird das
|
||||||
|
* Fenster auf das Bild geklemmt und durch die tatsaechliche (kleinere)
|
||||||
|
* Fensterflaeche geteilt, statt den Rand mit Nullen aufzufuellen.
|
||||||
|
* @param {Float64Array} values width*height Eintraege
|
||||||
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @param {number} radius
|
||||||
|
* @returns {Float64Array} width*height Eintraege
|
||||||
|
*/
|
||||||
|
function boxBlur(values, width, height, radius) {
|
||||||
|
const integral = buildIntegralImage(values, width, height);
|
||||||
|
const stride = width + 1;
|
||||||
|
const out = new Float64Array(width * height);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const y0 = Math.max(0, y - radius);
|
||||||
|
const y1 = Math.min(height, y + radius + 1);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const x0 = Math.max(0, x - radius);
|
||||||
|
const x1 = Math.min(width, x + radius + 1);
|
||||||
|
const sum =
|
||||||
|
integral[y1 * stride + x1] -
|
||||||
|
integral[y0 * stride + x1] -
|
||||||
|
integral[y1 * stride + x0] +
|
||||||
|
integral[y0 * stride + x0];
|
||||||
|
const count = (x1 - x0) * (y1 - y0);
|
||||||
|
out[y * width + x] = sum / count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flutet eine zusammenhaengende Objektflaeche ausgehend von startIndex ueber
|
* Flutet eine zusammenhaengende Objektflaeche ausgehend von startIndex ueber
|
||||||
* eine eigene Arbeitsliste (kein Rekursionsaufruf je Bildpunkt) und markiert
|
* eine eigene Arbeitsliste (kein Rekursionsaufruf je Bildpunkt) und markiert
|
||||||
* dabei jeden erreichten Bildpunkt in `visited`. Der Stapelspeicher `stack`
|
* dabei jeden erreichten Bildpunkt in `visited`. Der Stapelspeicher `stack`
|
||||||
* wird vom Aufrufer einmal pro Bild angelegt und fuer alle Flaechen
|
* wird vom Aufrufer einmal pro Bild angelegt und fuer alle Flaechen
|
||||||
* wiederverwendet - bei mehrmals pro Sekunde laufender Zaehlung auf rund
|
* (und beide Polaritaeten) wiederverwendet.
|
||||||
* einer Million Bildpunkten spart das die wiederholte Neuanlage eines
|
|
||||||
* Arrays je Flaeche.
|
|
||||||
* @param {number} startIndex
|
* @param {number} startIndex
|
||||||
* @param {(index: number) => boolean} isObjectPixel
|
* @param {(index: number) => boolean} isObjectPixel
|
||||||
* @param {Uint8Array} visited
|
* @param {Uint8Array} visited
|
||||||
@@ -115,73 +258,138 @@ function floodFill(startIndex, isObjectPixel, visited, width, height, stack) {
|
|||||||
return area;
|
return area;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus
|
||||||
|
* (helle Objekte auf dunklerem Grund oder umgekehrt).
|
||||||
|
* @param {(index: number) => boolean} isObjectPixel
|
||||||
|
* @param {number} width
|
||||||
|
* @param {number} height
|
||||||
|
* @param {number} totalPixels
|
||||||
|
* @param {Uint8Array} visited wiederverwendeter, bei Aufruf bereits genullter Arbeitsspeicher
|
||||||
|
* @param {Int32Array} stack wiederverwendeter Arbeitsspeicher
|
||||||
|
* @returns {{count: number, totalArea: number, significantCount: number} | null}
|
||||||
|
* null, wenn keine Flaeche dieser Polaritaet uebrig bleibt (kein Rauschen
|
||||||
|
* ausgenommen, oder gar keine gefunden)
|
||||||
|
*/
|
||||||
|
function analyzePolarity(isObjectPixel, width, height, totalPixels, visited, stack) {
|
||||||
|
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 null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen.
|
||||||
|
const noiseThreshold = Math.max(8, 0.00002 * totalPixels);
|
||||||
|
const afterNoise = rawAreas.filter((area) => area >= noiseThreshold);
|
||||||
|
if (afterNoise.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten -
|
||||||
|
// bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert.
|
||||||
|
const largestArea = Math.max(...afterNoise);
|
||||||
|
const scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST;
|
||||||
|
const significantAreas = afterNoise.filter((area) => area >= scaleThreshold);
|
||||||
|
if (significantAreas.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 9: typische Einzelgroesse.
|
||||||
|
const typicalArea = median(significantAreas);
|
||||||
|
|
||||||
|
// Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte
|
||||||
|
// Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als
|
||||||
|
// zwei zaehlen).
|
||||||
|
let count = 0;
|
||||||
|
let totalArea = 0;
|
||||||
|
for (const area of significantAreas) {
|
||||||
|
count += Math.max(1, Math.round(area / typicalArea));
|
||||||
|
totalArea += area;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count, totalArea, significantCount: significantAreas.length };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
|
* Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
|
||||||
* Kamera-Ausschnitt. Reine Anzeigefunktion - erzeugt keinen Zustand, bucht
|
* Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion -
|
||||||
* nichts, veraendert keine Sitzung.
|
* erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung.
|
||||||
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
||||||
* @returns {{count: number}} count: ermittelte, bereits gerundete Anzahl -
|
* @returns {{count: number}} count: ermittelte, bereits gerundete Anzahl -
|
||||||
* 0, wenn keine Objektflaeche gefunden wurde (leerer Ausschnitt, reiner
|
* 0, wenn keine Objektflaeche gefunden wurde (leerer Ausschnitt, reiner
|
||||||
* Untergrund ohne Kontrast oder ein Ausschnitt ohne Bildpunkte).
|
* Untergrund ohne Kontrast oder ein Ausschnitt ohne Bildpunkte).
|
||||||
*/
|
*/
|
||||||
export function countObjects(imageData) {
|
export function countObjects(imageData) {
|
||||||
const { width, height } = imageData;
|
if (imageData.width * imageData.height === 0) {
|
||||||
const totalPixels = width * height;
|
|
||||||
if (totalPixels === 0) {
|
|
||||||
return { count: 0 };
|
return { count: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { gray, threshold } = computeOtsuThreshold(imageData);
|
// Schritt 1: auf feste Breite normieren.
|
||||||
|
const resized = resizeToWidth(imageData, TARGET_WIDTH);
|
||||||
|
const { width, height } = resized;
|
||||||
|
const totalPixels = width * height;
|
||||||
|
|
||||||
let brightPixels = 0;
|
// Schritt 2: Graustufen.
|
||||||
for (let p = 0; p < totalPixels; p += 1) {
|
const gray = toGrayscale(resized);
|
||||||
if (gray[p] >= threshold) brightPixels += 1;
|
|
||||||
}
|
// Schritt 3: glaetten (unterdrueckt feine Untergrundnarbung).
|
||||||
const darkPixels = totalPixels - brightPixels;
|
const smoothRadius = Math.max(2, Math.round(width * 0.0025));
|
||||||
// Die Objekte belegen in aller Regel weniger Flaeche als der Untergrund -
|
const smoothed = boxBlur(gray, width, height, smoothRadius);
|
||||||
// die flaechenmaessig kleinere Klasse gilt deshalb als Objekt, unabhaengig
|
|
||||||
// davon, ob sie heller oder dunkler ist als die andere.
|
// Schritt 4: oertlichen Hintergrund bestimmen (deutlich groesseres
|
||||||
const objectIsBright = brightPixels < darkPixels;
|
// Fenster als der Glaettungsradius - faengt ungleichmaessige Ausleuchtung
|
||||||
const isObjectPixel = (p) => (gray[p] >= threshold) === objectIsBright;
|
// ab, ohne von einem einzelnen Fundstueck selbst dominiert zu werden). Ein
|
||||||
|
// Mindestradius von 1 verhindert ein entartetes Ein-Bildpunkt-Fenster bei
|
||||||
|
// sehr kleinen Ausschnitten, in dem der "Hintergrund" schlicht der
|
||||||
|
// geglaettete Wert selbst waere.
|
||||||
|
const backgroundRadius = Math.max(1, Math.round(Math.max(width, height) / 12));
|
||||||
|
const localBackground = boxBlur(smoothed, width, height, backgroundRadius);
|
||||||
|
|
||||||
const visited = new Uint8Array(totalPixels);
|
const visited = new Uint8Array(totalPixels);
|
||||||
const stack = new Int32Array(totalPixels);
|
const stack = new Int32Array(totalPixels);
|
||||||
const rawAreas = [];
|
|
||||||
|
|
||||||
for (let p = 0; p < totalPixels; p += 1) {
|
// Beide Polaritaeten: helle Objekte auf dunklerem Grund ...
|
||||||
if (visited[p] || !isObjectPixel(p)) continue;
|
const isBrightObject = (p) => smoothed[p] > localBackground[p] + LOCAL_CONTRAST_MARGIN;
|
||||||
rawAreas.push(floodFill(p, isObjectPixel, visited, width, height, stack));
|
const bright = analyzePolarity(isBrightObject, width, height, totalPixels, visited, stack);
|
||||||
}
|
|
||||||
|
|
||||||
if (rawAreas.length === 0) {
|
// ... und dunkle Objekte auf hellerem Grund. Arbeitsspeicher wird
|
||||||
|
// wiederverwendet, muss dafuer zurueckgesetzt werden.
|
||||||
|
visited.fill(0);
|
||||||
|
const isDarkObject = (p) => smoothed[p] < localBackground[p] - LOCAL_CONTRAST_MARGIN;
|
||||||
|
const dark = analyzePolarity(isDarkObject, width, height, totalPixels, visited, stack);
|
||||||
|
|
||||||
|
if (!bright && !dark) {
|
||||||
return { count: 0 };
|
return { count: 0 };
|
||||||
}
|
}
|
||||||
|
if (!dark) {
|
||||||
// Vorlaeufiger Massstab (siehe NOISE_AREA_FRACTION oben), um Rauschen zu
|
return { count: bright.count };
|
||||||
// verwerfen, bevor der endgueltige Massstab fuer die Hochrechnung bestimmt
|
}
|
||||||
// wird - Rauschflaechen sollen diesen Massstab selbst nicht mehr verzerren.
|
if (!bright) {
|
||||||
const provisionalTypicalArea = median(rawAreas);
|
return { count: dark.count };
|
||||||
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
|
// Beide Polaritaeten haben etwas gefunden - eine muss gewinnen. Am echten
|
||||||
// Flaechen - robust gegen die verbliebenen Ausreisser (z. B. eine grosse,
|
// Foto (silberne, glaenzende Schrauben, einseitig beleuchtet) traegt jede
|
||||||
// aus mehreren beruehrenden Teilen verschmolzene Flaeche).
|
// Schraube sowohl eine helle Reflexflaeche als auch einen dunklen
|
||||||
const typicalArea = median(significantAreas);
|
// Schlagschatten - beide Polaritaeten finden also echte, nicht zufaellige
|
||||||
|
// Struktur, und ihre Gesamtflaechen liegen dicht beieinander (am
|
||||||
let count = 0;
|
// Pruefbild rund 2826 zu 2140 Bildpunkte). Die Gesamtflaeche allein
|
||||||
for (const area of significantAreas) {
|
// (kleinere gewinnt) ist in diesem Fall kein verlaessliches Kriterium: der
|
||||||
// Runden statt Abschneiden: Eine Flaeche, die nur wenig groesser ist als
|
// Schlagschatten jeder Schraube ist schmaler als ihre Reflexflaeche und
|
||||||
// der Median (z. B. 1,3-fach, weil ein Teil zufaellig etwas naeher an
|
// summiert sich deshalb zu einer kleineren Gesamtflaeche, obwohl die
|
||||||
// der Kamera oder etwas groesser im Bild liegt), rundet auf 1 ab und wird
|
// Reflexflaechen die tatsaechlichen Fundstuecke vollstaendiger und
|
||||||
// nicht faelschlich als zwei gezaehlt - erst deutlich ueber dem
|
// stabiler nachzeichnen (siehe .superpowers/sdd/counting-rework-report.md
|
||||||
// 1,5-fachen rundet auf 2 usw. Jede Flaeche zaehlt mindestens einmal.
|
// fuer die Messung). Ausschlaggebend ist deshalb, welche Polaritaet mehr
|
||||||
count += Math.max(1, Math.round(area / typicalArea));
|
// der (bekanntermassen um die zwanzig) tatsaechlichen Fundstuecke als
|
||||||
|
// eigene, den Rausch- und Groessenfilter ueberstehende Flaeche auflaesst -
|
||||||
|
// die Polaritaet mit den meisten behaltenen Flaechen gewinnt. Nur bei
|
||||||
|
// gleich vielen behaltenen Flaechen entscheidet die kleinere
|
||||||
|
// Gesamtflaeche (die urspruengliche Regel) als Ausweichkriterium.
|
||||||
|
if (bright.significantCount !== dark.significantCount) {
|
||||||
|
return { count: bright.significantCount > dark.significantCount ? bright.count : dark.count };
|
||||||
}
|
}
|
||||||
|
return { count: bright.totalArea <= dark.totalArea ? bright.count : dark.count };
|
||||||
return { count };
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Prueffall am echten Foto des Auftraggebers (siehe
|
||||||
|
// .superpowers/sdd/counting-rework-report.md): Otsu allein scheiterte hier,
|
||||||
|
// weil die Schrauben unter einem Prozent der Bildflaeche belegen - Otsu
|
||||||
|
// rutscht in so einem Fall auf den hoechsten Helligkeitswert und erklaert nur
|
||||||
|
// die hellsten Punkte der Ledernarbung zum Objekt. Die fruehere Fassung
|
||||||
|
// meldete an genau diesem Bild 76712 statt der rund zwanzig tatsaechlich
|
||||||
|
// ausgelegten Schrauben. Dieser Test schuetzt vor einem Rueckfall in dieses
|
||||||
|
// Verhalten.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { countObjects } from '../src/count-objects.js';
|
||||||
|
import {
|
||||||
|
SCREWS_WIDTH,
|
||||||
|
SCREWS_HEIGHT,
|
||||||
|
SCREWS_GRAY_BASE64,
|
||||||
|
} from './fixtures/screws-photo.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut aus dem Base64-kodierten Graustufenraster ein ImageData-artiges
|
||||||
|
* Objekt: Graustufenwert auf R, G und B gelegt, Alpha 255 - wie eine echte
|
||||||
|
* Kameraaufnahme ohne Farbinformation.
|
||||||
|
*/
|
||||||
|
function grayscaleImage(width, height, base64) {
|
||||||
|
const gray = Buffer.from(base64, 'base64');
|
||||||
|
const data = new Uint8ClampedArray(width * height * 4);
|
||||||
|
for (let p = 0, i = 0; p < width * height; p += 1, i += 4) {
|
||||||
|
const value = gray[p];
|
||||||
|
data[i] = value;
|
||||||
|
data[i + 1] = value;
|
||||||
|
data[i + 2] = value;
|
||||||
|
data[i + 3] = 255;
|
||||||
|
}
|
||||||
|
return { width, height, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('echtes Foto (~20 Festplattenschrauben auf Leder) ergibt eine Zahl zwischen 15 und 24', () => {
|
||||||
|
const imageData = grayscaleImage(SCREWS_WIDTH, SCREWS_HEIGHT, SCREWS_GRAY_BASE64);
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const result = countObjects(imageData);
|
||||||
|
const durationMs = performance.now() - start;
|
||||||
|
|
||||||
|
// Nur zur Beobachtung im Testlauf, keine Zusicherung ueber die Konsole -
|
||||||
|
// die Laufzeit selbst wird unten geprueft.
|
||||||
|
console.log(`countObjects(screws-photo) dauerte ${durationMs.toFixed(2)} ms`);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.count >= 15 && result.count <= 24,
|
||||||
|
`erwartet 15..24, erhalten ${result.count} (fruehere Fassung meldete 76712)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Soll mehrmals je Sekunde laufen koennen (laufende Anzeige waehrend die
|
||||||
|
// Kamera laeuft) - grosszuegige Grenze, die auf langsamerer Hardware nicht
|
||||||
|
// faelschlich anschlaegt, aber ein grundlegend zu langsames Verfahren
|
||||||
|
// (z. B. eine versehentlich quadratische Rechnung) auffangen wuerde.
|
||||||
|
assert.ok(durationMs < 500, `erwartet unter 500 ms, gemessen ${durationMs.toFixed(2)} ms`);
|
||||||
|
});
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
// Pruefdaten: Ausschnitt eines echten Fotos - etwa zwanzig Festplattenschrauben
|
||||||
|
// auf dunklem Leder, einige davon einander beruehrend. Genau an diesem Bild ist
|
||||||
|
// eine fruehere Fassung der Zaehlung gescheitert (sie meldete 76712 statt rund
|
||||||
|
// zwanzig), weil die Ledernarbung zehntausende winziger heller Flecken erzeugt.
|
||||||
|
// Graustufen, 600 x 321, als Base64.
|
||||||
|
|
||||||
|
export const SCREWS_WIDTH = 600;
|
||||||
|
export const SCREWS_HEIGHT = 321;
|
||||||
|
export const SCREWS_GRAY_BASE64 =
|
||||||
|
'fniSc6a4htOe046j98SMgWmuqZOvoG+gxYBxhKKTpn/D5JKJlrjVoKCTv8iTndeXeZjYsZa8qqbf1pGIl4ajmZyb97+icL/Opr6ogpqVfKHmvXmf1J6tkbfPxZiwuu2hvHt+eWtydq61uqKU5LOD09+U6azRw/GWZIZ9o5eDa3tzt6iQssDHh7p6bpzfpM3sl5fEsLK2k8SbgchoqJ66gXu52aSnvNqtaeLgfJq+vZCuedKWtJuhg8HjvpzApZiMk3SHsZG/y4tso66Bv2x+1oubY8d/fnquec7bdMKS2Gp7iNeGiGaf73nXn2is0JdktYKlo6mjaYWny2aaopBZoMFdq4qBr5qCe3J7pJHEeKlju393pZFbbcKgbKhmbmJujXV0s3JqWys4OERYbkBCi4GMrsJ0j1N3t5HHa3mIc6FymXiib6WKZ2d8o6t9cUyji4OHr2dlp5RUrHGsa5praZlqfWNrtIBYYoCRjWVtdIpwaU1uf3yiT3mOTXKcYIl7cYNnk3BdfmhpaI2BoFmJj19jnXiBfHZ6Y3OXYVNhXmJynVJgiGKeYlpgd3Nqf3BYbltXZmh6a2JpcmhccK9pWVNsgIpbbl+IhFeJS2dman9sU1JZd1WsYk9Rm2xKeGc+W1CLXlFHXY1rX1pdbn5MZlZkZ15JSzhRVEtbYXpRP2tlcodhQ1GIT0hVTFRWRXplP1RlR0lOSFNpW0t/U1E3X2hXREExfHFNNjFcY0JHSTJIZDtAaVNcblFRUEBwXUJdlWJDTlpHWHtST0xRcXRFSY1XQVCMVGRU07iJe96Se8/htH2PrZqed5/O35V5mojg46CxdXOIv3m6uZyKvdqEbYmv2oGp6+qUwq62wNHpwJfVkJfPtIXY3o6WvNnhlrzsmKuDhNvqqKe9kqWEy7emb4KNvqbRisa2qWSPqlhhW7Ksz8Kuub2LiLSa25qdqMfhbau6jMfWfJZmtMutynqSscDdk6fyuHvUp39mpsDI69GRfuCvV5vspWJ0c2qKga+eioiboY+hhJXPhrCgx6OchGeXrq+/enfFuqFaupFmmKuAyOh3xoN7y7vbZsqdsICqfXTKn9mUhGqJb4uGjHNmmZ2rjnWQoMxpsY25a5HchKh5yI6cp5ZxicR0rIStY6uQh49imHi1f7NzX5mbcaSDXIaPd8+PnVR01HNXlYh5rDhrUEBVXmlInrp/hcF8hHJmrm6VlKOPhoyfdGmWin+JgVOqk6xrmlqfqIlfnGdui8Vaq4uFXZx/YJdXmGpbeY2aaY5hqG6AjWxzZVKPd1izY2ubYmGSjnprcZyHhoBWbll4h2OPqWRjrnJer4FgjGGAblSBc1xnY11niWlSfWSjYWKTZFx/hXZOmH5SZF9YeXRocnFmWItsfWJSYIpsalhzeFd4WVxhZ3hXT1VobmOGXFZPlYZRZGNHZkltcE9HVaJgUkpNYI1KZ1JPgFJRWE9IZnFNTYVdQ150T3RxTVeLS0g2VG1OQGVsPGB4QkRWRVNDVkaEXFI4UnJgS0Y6gW9ARTdESD9mYjY9XzxJWU5LU1piUjhSY21kcVZBP21fUoJVTj9AboFGUqNePE2KVUQ+kdSBk7Ccfqh7b215xZ+2d4aLsYuNin+azrKKq8lzjYOguqzc2L+1rYuBsrjawrKg1sN/lKO7wId1bJCVhZK+tbiZiOu5gZLTjKDaf5THm3WboLSTxa7Zx3Cw+K/V2LaUdnWUvVxcXHCcmXqAqOfZiHl8hJi09q/xlY21tMumkOWCXoKovq1hiICil5ay2I6QgqeDa4x/vfKHsMG4f2yMtZ+Kw3KQl23Im4ZmYoXHz7bXkqBqjZF+npFcc5Hhq3Ky6Z1zqKCIY4yGudiih7KBoayuh93FmH+ox3+HhtrifmvPooB+r4SATaWmqWiJVo14rpfij1ayq6VwjsaPzoltlppnpZ7ClX2+d3hQxYtucsKXZna8aImuio9mZ6SAxXVYwY2FVLlofFV0clBZa2tnd86Td4F4soBxzI5tWa63c3DDd2uJmpd1imKlgp1nlp94hIxjmn2CaItUcn+Cg2eQsHJieGiIlJ+faGWJmWt3tW9vaFpxcliCfWyccGRap4V9XGWec4ljamZooGVnxnFcfqBWmoFmaaBxX190Y118Yl5pVGpsmVaNcVyca151aHlikIZWXmJPgH9we19aZHRYi25ZSVt1YU9ZVFhicVtqVWxVTlhxYWd8a2BPf3tTUVVYZ1dRcUpFS51oS1hgUV1WbFNTe2JQWH9MYJBJO3lfUVNoQVNha1eAT0kwUYtLL0ltUl59PDVuYVg8Q0hvZ1REUlmBYkE+VWBKR0NbUTRUcj07YUlMPU+GY0BiWztWXnNlYE9SRW91WIVNSUI8WE9JV2xaYExvXjhNv7XKnXzRvcd1iqmupYO0l5yqoYfJwnuRiodqiYiXvpVhmLmZnJmIvvO5bZCOdHyRmYaNdG12kKeN39mQt4qCkrHBndmuiNCQa4CgfpO3xKN4kIt2fIyxkpWp97STlHOZ2tqamXpqnGm+24Wxi7Wmlp6joXl3m7ypt62oyd+XjbuGh2aZsOKCea2Zd3Z7kMGx2q7HbJucXJeduJGGzYCIl7Swl8nZsnGIt9+Pn4ev4La+fZqAhOuqea1olHazoax/vpqql6CqmW2Ye3t/aGieeJ5nhHnJu56VwpjHkoTmmqalzIGQwo+caJbOxXioYnSCY6Slcnp2n7JwkKpxr6Bxm8SbjZJ+mnS4vHxbuM5nlYqdjprUhISUksOUXaB0ZKCDvH+1Wb+hVG6oZ5RirWNcgX7Rdplnyapcuot0an/Hg26CcYvDZbVuYHh7VYlzgI6GdGNlm3NvoItlfYyBd3divXdxcZnYsa+YZllqhp1Wp3VZeWtNbqtOY1uxYmRMpW6McVOIn2JYZ35cj3RWn4iVYXddmXGFVaiPXX2MWlV6Y29nS3JwkGVaYop1cl1kXX9vX2tbbGxTZHhueltaaodTeHKEZGeCeWRbQG1iT1xdU2V6YEtzYF5ydF9SW31XR1NcWWFEc0tIVoZ4RHKMSD1le2RpW3plUGZOaYJdQVxgaGBLQUNRYUVuTkhMRY9HMll7TmB5QDNwglI9SEtZdWhFWVlXXVJSQk9kRDBsY0RPWVBCe1hEQFKcaUFnXTlZSVRfcklfUE5HWHdRST88cEU6YWE/Wllnak9Yxuu1mbSdjHut3KKKfbjWw8Gznri62K6keHagvMHcjHORh3CU39SznZKy65p4ubmKo+Dqn462bpl4jrzY2/CqYYmUc7WzocfGgb3ynH2MyaFmpLu5n4NqmpF6r5+k25ehqK2GlK6i2a+a2qPizHqxtNCxyaaui4VxhMSfraKoiZ+1yIyQZbGxntDygoGVaYrD3+XZep3ZkqVljbOQhZXhnH6+fYq8n5tXcLuohHzXsa3aiL3Qn6PhlOGFqKamytGDdq/OltGZi1p/xNe6gG3AjauhtX9nhbeDg3uh1XmgnNSjuJDPg8OWnHqXkrSVmH7Ce4fQc5WlW5etYotgc4uKn6CblL6GpHF7toaJfah1tq9pjnLXiHGNe4+2f6KuZJWmp424kX2Qkl/Ae5JvjqKCvFakhbGceK9mkJGpkH2EtYqCaW7WepeEonyTWHamp3Z6ZG11r3ZksZhpkLagX7tgdLR8bMvpx9Gec12EfXxTsZ9ObH9SZbZrSnCOjVlSqmF8k2FTr3JgeGhihI1bbI6rbWNQfY5yYV+Cjmt4WE5tf6RRSoh6cWZSYsRqaFdWVmx3a1tag3dWX2Vdb29xW2RnZGaJftDNnHBgVm1eTmtkgmxrdFeGXlJXc3RbVWN8PVZYUmVAenBSYGF1SmSNXENtc191SHhoSmZQXGBPUGpPaoVPU0lNZVVVRFdeTXxqQGOJSU5UVFFQglFNYD1HWIRNSlFHZF5SV0xSVTpUXU9SRlFEWFdaR0t4V0pqbEdcOzVHfl5wTTEtRV1qWUNAi08vTFQvUV1jY0tH7bZ3s9jMfZag0NqidIGxxaeMh8/hq22irXqbxaGRwIWuxp6gi4Cdfb3UtneSgJF8rdvAd7fS2Zl6gqvkt3l7gtjguYW0q7L0i7S8i4i14uSaf4qU4NGO2uehinXP1ZGCfKiUYX6lgJKHvsPZ2oWdk6d2bI3QrYzZhoOXhaWxnOz1fqTXn4WN1rvdj32tmWWU2NOhc5HImsNrpea1qWS153W4w2Vw0Mh8mXqijmx6noiQho2onsy+y62Ml5Z4jZh1Ymlul+PN4H99kLTAgpzSommCoaCVW5fKbqiDamGEiXKEqo3AdK3MiLZlZrLOto2Eplq0nn+pYmrInZR3j2mBw4eUgLSMZHeFhoKXsICWbHNurFyme4OQeZCte2hwtYVibaNtnadew1+EvG6GaXiBoldzjIPFXGFeiI19uJiOlnZwdVebhn1mequBaIOZcYO1fpJUlINiuZVjeZXKWKx5Z6qNgIrWz5h0cWCIjm1dfY54YmZ1WoqEWrNnrH1WoGtbjWBJdqV8m15ca411Wni7anVOacttdmBZcF5iWFpwd3xXVXd0aF9tYaFlXFxQWXJxa25adm9ub1t3XoBwU2iWXFdlaJiXmZdmW3hhZ21cc3ZXXmeaYlVMZ4JwV0yBSktUU1pVXmt2X0thfV9weE9ZX0GFWWBzRXxTR25MTXlIWnRhYFVQXYhPRFZbQVqDSl1/SkA1RmdMYWxOWEI4O4FZOj9ii1A/aFs/YEpHUlVNU1pAVVVhRkpzYkpTdlNUOjc7dYiDPSwkN3plSjxJe2M4PU1Vc1J1eDxFp3ZvtMa3mHTA8eqTn6e2q4SpiLjfypTM34CDrXGW68LZrX2PjLyrfa17tKuzx7x2lsLasbnW9Z2x0penu6K8fnSV1X6TfoatjJrqwWmlweyghZeH6KW1xqqXunSoxIjJl+WzlLDElpytf42Wl59ykMbnnGt4t6CjlsPUk4ijusHHiKa238GPqp2jxcHIx4xtnNPHLine truncated
|
||||||
Reference in new issue
Block a user