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>
484 lines
20 KiB
JavaScript
484 lines
20 KiB
JavaScript
// 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.
|
|
//
|
|
// Otsu (threshold.js) taugt nur, wenn Objekt- und Untergrundflaeche etwa
|
|
// gleich gross sind. Am echten Foto des Auftraggebers (rund zwanzig
|
|
// Festplattenschrauben auf dunklem Leder) belegen die Schrauben unter einem
|
|
// Prozent der Flaeche - Otsu rutscht dann auf den hoechsten Helligkeitswert
|
|
// und erklaert nur die hellsten Punkte der Ledernarbung zum Objekt
|
|
// (gemessen: 76712 statt rund zwanzig, siehe
|
|
// test/count-objects-photo.test.js). Otsu betrachtet zudem nur die absolute
|
|
// Helligkeit ueber das ganze Bild hinweg; am echten Foto sind Schraube und
|
|
// Untergrund darueber gar nicht trennbar (Median 114, 90. Perzentil 221,
|
|
// 99. Perzentil 254 - deutliche Ueberlappung).
|
|
//
|
|
// Dieses Modul verwendet stattdessen oertlichen Kontrast (siehe
|
|
// .superpowers/sdd/counting-rework-report.md fuer die Herleitung und die
|
|
// Messungen am echten Foto):
|
|
// 1. Auf 800 Bildpunkte Breite normieren (Seitenverhaeltnis erhalten) -
|
|
// macht alle folgenden Groessen unabhaengig von der Kameraaufloesung und
|
|
// die Rechnung schneller.
|
|
// 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).
|
|
|
|
// Zielbreite fuer die Normierung (Schritt 1). Schmalere Bilder bleiben
|
|
// unveraendert.
|
|
const TARGET_WIDTH = 800;
|
|
|
|
// Kontrastmarge fuer die Maskenbildung (Schritt 5): ein Bildpunkt muss um
|
|
// mehr als diesen Betrag heller/dunkler sein als sein oertlicher Hintergrund,
|
|
// um als Objekt zu gelten - unterdrueckt Sensorrauschen und feine
|
|
// Resthelligkeitsschwankungen, die die Glaettung (Schritt 3) nicht restlos
|
|
// entfernt.
|
|
const LOCAL_CONTRAST_MARGIN = 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
|
|
* Teile) oder sehr kleine (Rauschen) Ausreisser, anders als der Mittelwert.
|
|
* Exportiert, weil dieselbe Robustheit die Diagnoseanzeige braucht, um aus
|
|
* den letzten Zaehlmessungen eine ruhigere Zahl zu bilden (siehe
|
|
* count-history.js) - ein einzelner Ausreisser (Bewegungsunschaerfe,
|
|
* nachregelnder Autofokus) soll dort ebenso wenig durchschlagen wie hier bei
|
|
* den Flaechengroessen.
|
|
* @param {number[]} values nicht-leer
|
|
* @returns {number}
|
|
*/
|
|
export 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];
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* 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
|
|
* (und beide Polaritaeten) wiederverwendet.
|
|
* @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;
|
|
}
|
|
|
|
/**
|
|
* Fuehrt Schritte 6-10 (siehe Moduldoku oben) fuer eine Polaritaet aus
|
|
* (helle Objekte auf dunklerem Grund oder umgekehrt). Liefert - anders als
|
|
* die fruehere Fassung - nie `null`, sondern immer ein vollstaendig belegtes
|
|
* Ergebnis, auch wenn keine (oder keine signifikante) Flaeche uebrig bleibt:
|
|
* die Diagnoseanzeige muss gerade dann zeigen koennen, *woran* es gescheitert
|
|
* ist (gar nichts gefunden? nur Rauschen? nur zu kleine Flaechen?). An der
|
|
* eigentlichen Zaehlung (Schritte 6-10 selbst) aendert das nichts - `count`
|
|
* ist bei einer leeren `significantAreas`-Liste weiterhin 0, exakt wie beim
|
|
* frueheren `null`.
|
|
* @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, rawCount: number, filteredCount: number, significantCount: number, largestArea: number, typicalArea: number}}
|
|
*/
|
|
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));
|
|
}
|
|
|
|
// Schritt 7: grobes Rauschen (Staub, Kratzer, Lichtreflexe) verwerfen. Auf
|
|
// einer leeren rawAreas-Liste bleibt auch afterNoise leer - kein
|
|
// Sonderfall noetig.
|
|
const noiseThreshold = Math.max(8, 0.00002 * totalPixels);
|
|
const afterNoise = rawAreas.filter((area) => area >= noiseThreshold);
|
|
|
|
// Schritt 8: Massstab aus der groessten verbliebenen Flaeche ableiten -
|
|
// bewusst relativ (siehe Moduldoku), nicht als fester Bildpunktwert. Bleibt
|
|
// nach der Rauschfilterung nichts uebrig, gibt es auch keinen Massstab
|
|
// (largestArea 0) und folglich auch keine signifikanten Flaechen.
|
|
const largestArea = afterNoise.length > 0 ? Math.max(...afterNoise) : 0;
|
|
const scaleThreshold = largestArea * SCALE_FRACTION_OF_LARGEST;
|
|
const significantAreas = afterNoise.filter((area) => area >= scaleThreshold);
|
|
|
|
// Schritt 9: typische Einzelgroesse - nur bestimmbar, wenn ueberhaupt eine
|
|
// Flaeche beide Filter uebersteht (median() verlangt eine nicht-leere Liste).
|
|
const typicalArea = significantAreas.length > 0 ? median(significantAreas) : 0;
|
|
|
|
// Schritt 10: zaehlen, dabei rundet auf statt abzuschneiden (siehe alte
|
|
// Fassung: ein nur leicht groesseres Einzelteil soll nicht faelschlich als
|
|
// zwei zaehlen). Bleibt keine Flaeche uebrig, durchlaeuft die Schleife kein
|
|
// einziges Mal - count und totalArea bleiben bei 0.
|
|
let count = 0;
|
|
let totalArea = 0;
|
|
for (const area of significantAreas) {
|
|
count += Math.max(1, Math.round(area / typicalArea));
|
|
totalArea += area;
|
|
}
|
|
|
|
return {
|
|
count,
|
|
totalArea,
|
|
rawCount: rawAreas.length,
|
|
filteredCount: afterNoise.length,
|
|
significantCount: significantAreas.length,
|
|
largestArea,
|
|
typicalArea,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Waehlt die gewinnende Polaritaet (siehe ausfuehrliche Begruendung im
|
|
* Kommentar in countObjects() unten). Massgeblich ist, welche Polaritaet
|
|
* mehr signifikante (Rausch- und Groessenfilter ueberstehende) Flaechen
|
|
* behalten hat; bei Gleichstand entscheidet die kleinere Gesamtflaeche.
|
|
* Haben beide Polaritaeten keine einzige signifikante Flaeche behalten (die
|
|
* Zaehlung ergibt in jedem Fall 0), entscheidet ersatzweise, welche
|
|
* ueberhaupt mehr Struktur gefunden hat (erst nach, dann vor der
|
|
* Rauschfilterung) - das aendert nichts mehr am Ergebnis (0), belegt aber
|
|
* die Diagnosewerte weiterhin sinnvoll statt mit einer willkuerlichen Wahl.
|
|
* @param {ReturnType<typeof analyzePolarity>} bright
|
|
* @param {ReturnType<typeof analyzePolarity>} dark
|
|
* @returns {ReturnType<typeof analyzePolarity> & {polarity: 'hell' | 'dunkel'}}
|
|
*/
|
|
function pickWinner(bright, dark) {
|
|
if (bright.significantCount === 0 && dark.significantCount === 0) {
|
|
if (dark.filteredCount > bright.filteredCount) return { ...dark, polarity: 'dunkel' };
|
|
if (dark.filteredCount === bright.filteredCount && dark.rawCount > bright.rawCount) {
|
|
return { ...dark, polarity: 'dunkel' };
|
|
}
|
|
return { ...bright, polarity: 'hell' };
|
|
}
|
|
if (dark.significantCount === 0) return { ...bright, polarity: 'hell' };
|
|
if (bright.significantCount === 0) return { ...dark, polarity: 'dunkel' };
|
|
if (bright.significantCount !== dark.significantCount) {
|
|
return bright.significantCount > dark.significantCount
|
|
? { ...bright, polarity: 'hell' }
|
|
: { ...dark, polarity: 'dunkel' };
|
|
}
|
|
return bright.totalArea <= dark.totalArea
|
|
? { ...bright, polarity: 'hell' }
|
|
: { ...dark, polarity: 'dunkel' };
|
|
}
|
|
|
|
/**
|
|
* Zaehlt die Fundstuecke (z. B. ausgelegte Schrauben) in einem
|
|
* Kamera-Ausschnitt anhand oertlichen Kontrasts. Reine Anzeigefunktion -
|
|
* erzeugt keinen Zustand, bucht nichts, veraendert keine Sitzung.
|
|
*
|
|
* Die Rueckgabe traegt neben der Anzahl selbst (unveraendert in Bedeutung)
|
|
* additiv die Zwischenwerte, die eine Diagnoseanzeige braucht, um am
|
|
* Nutzergeraet zu erkennen, *woran* eine abweichende Zaehlung liegt (siehe
|
|
* ui/scan-view.js und main.js) - allesamt auch dann sinnvoll belegt, wenn
|
|
* count 0 ergibt.
|
|
* @param {{width: number, height: number, data: Uint8ClampedArray}} imageData
|
|
* @returns {{
|
|
* count: number,
|
|
* width: number,
|
|
* height: number,
|
|
* regionsFound: number,
|
|
* regionsKept: number,
|
|
* largestArea: number,
|
|
* typicalArea: number,
|
|
* polarity: 'hell' | 'dunkel' | null,
|
|
* }}
|
|
* count: ermittelte, bereits gerundete Anzahl - 0, wenn keine
|
|
* Objektflaeche gefunden wurde (leerer Ausschnitt, reiner Untergrund
|
|
* ohne Kontrast oder ein Ausschnitt ohne Bildpunkte).
|
|
* width, height: Groesse des tatsaechlich ausgewerteten Bildes, also nach
|
|
* der Normierung auf feste Breite (Schritt 1) - 0/0, wenn der
|
|
* Ausschnitt selbst schon keine Bildpunkte hatte (dann fand keine
|
|
* Normierung statt).
|
|
* regionsFound: Anzahl zusammenhaengender Flaechen der gewinnenden
|
|
* Polaritaet vor jeder Filterung (Schritt 6, vor Schritt 7).
|
|
* regionsKept: Anzahl der Flaechen, die nach vollstaendiger Filterung
|
|
* (Rauschen Schritt 7, Groesse Schritt 8) uebrig bleiben.
|
|
* largestArea: Groesse der groessten verbliebenen Flaeche in Bildpunkten.
|
|
* typicalArea: als typisch bestimmte Einzelgroesse (Median, Schritt 9) in
|
|
* Bildpunkten - 0, wenn keine Flaeche uebrig blieb.
|
|
* polarity: welche Polaritaet gewonnen hat ('hell': helle Objekte auf
|
|
* dunklerem Grund, 'dunkel': umgekehrt) - null nur, wenn der Ausschnitt
|
|
* selbst schon keine Bildpunkte hatte und daher gar keine Polaritaet
|
|
* berechnet wurde.
|
|
*/
|
|
export function countObjects(imageData) {
|
|
if (imageData.width * imageData.height === 0) {
|
|
return {
|
|
count: 0,
|
|
width: 0,
|
|
height: 0,
|
|
regionsFound: 0,
|
|
regionsKept: 0,
|
|
largestArea: 0,
|
|
typicalArea: 0,
|
|
polarity: null,
|
|
};
|
|
}
|
|
|
|
// Schritt 1: auf feste Breite normieren.
|
|
const resized = resizeToWidth(imageData, TARGET_WIDTH);
|
|
const { width, height } = resized;
|
|
const totalPixels = width * height;
|
|
|
|
// Schritt 2: Graustufen.
|
|
const gray = toGrayscale(resized);
|
|
|
|
// Schritt 3: glaetten (unterdrueckt feine Untergrundnarbung).
|
|
const smoothRadius = Math.max(2, Math.round(width * 0.0025));
|
|
const smoothed = boxBlur(gray, width, height, smoothRadius);
|
|
|
|
// Schritt 4: oertlichen Hintergrund bestimmen (deutlich groesseres
|
|
// Fenster als der Glaettungsradius - faengt ungleichmaessige Ausleuchtung
|
|
// 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 stack = new Int32Array(totalPixels);
|
|
|
|
// Beide Polaritaeten: helle Objekte auf dunklerem Grund ...
|
|
const isBrightObject = (p) => smoothed[p] > localBackground[p] + LOCAL_CONTRAST_MARGIN;
|
|
const bright = analyzePolarity(isBrightObject, width, height, totalPixels, visited, stack);
|
|
|
|
// ... 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);
|
|
|
|
// Eine Polaritaet muss gewinnen (siehe pickWinner() oben fuer den
|
|
// Sonderfall "beide leer"). Am echten Foto (silberne, glaenzende
|
|
// Schrauben, einseitig beleuchtet) traegt jede Schraube sowohl eine helle
|
|
// Reflexflaeche als auch einen dunklen Schlagschatten - beide Polaritaeten
|
|
// finden also echte, nicht zufaellige Struktur, und ihre Gesamtflaechen
|
|
// liegen dicht beieinander (am Pruefbild rund 2826 zu 2140 Bildpunkte).
|
|
// Die Gesamtflaeche allein (kleinere gewinnt) ist in diesem Fall kein
|
|
// verlaessliches Kriterium: der Schlagschatten jeder Schraube ist schmaler
|
|
// als ihre Reflexflaeche und summiert sich deshalb zu einer kleineren
|
|
// Gesamtflaeche, obwohl die Reflexflaechen die tatsaechlichen Fundstuecke
|
|
// vollstaendiger und stabiler nachzeichnen (siehe
|
|
// .superpowers/sdd/counting-rework-report.md fuer die Messung).
|
|
// Ausschlaggebend ist deshalb, welche Polaritaet mehr 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.
|
|
const winner = pickWinner(bright, dark);
|
|
|
|
return {
|
|
count: winner.count,
|
|
width,
|
|
height,
|
|
regionsFound: winner.rawCount,
|
|
regionsKept: winner.significantCount,
|
|
largestArea: winner.largestArea,
|
|
typicalArea: winner.typicalArea,
|
|
polarity: winner.polarity,
|
|
};
|
|
}
|