/* =================================================================== Stellt den DogFather-Logokopf frei — einmal, als Vorlage für alle App-Symbole. WOZU. Jede App an dogfather-universe.com soll dasselbe Logo tragen, aber in einer eigenen Farbe (Wunsch Filipe, 04.09.2026: "alle sollen das Logo haben, aber durch Farben anders aussehen"). Dafür braucht es den Kopf OHNE Hintergrund — sonst klebt an jedem Symbol das alte Hellblau. DAS VORHANDENE MATERIAL taugt so nicht: assets/img/icon-512.png Kopf, aber auf hellblauem Grund assets/img/logo-dogfather-*.png Kopf MIT Schriftzug, auf Weiss assets/img/marke/dogfather-husky.png die Maskottchen-Figur im Hoodie, kein Logo Gebraucht wird der Kopf aus dem ersten, ohne dessen Hintergrund. WARUM FLUTFUELLUNG UND KEIN FARBFILTER. Der naheliegende Weg — "alle hellblauen Bildpunkte durchsichtig machen" — zerstört das Logo: Im Gesicht liegen dieselben hellen Töne (Wange, Schnauze, Ohrinnenseite). Die Füllung startet stattdessen an den vier Ecken und frisst sich nur so weit nach innen, wie die Farbe zusammenhängt. Was vom Rand nicht erreichbar ist, bleibt stehen — auch wenn es dieselbe Farbe hat. WEICHE KANTE. Ein harter Schnitt ergibt Treppenstufen, die man bei 192 px deutlich sieht. Deshalb wird der Rand über die Farbnähe abgestuft: fast gleich = ganz durchsichtig, nah dran = teilweise. Gerechnet im Browser, der ohnehin für die Prüfungen da ist — kein Bildprogramm, keine neue Abhängigkeit, 0 EUR. Derselbe Weg wie bei tools/buehne-bauen.mjs. Aufrufen mit: node tools/logo-freistellen.mjs =================================================================== */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const WURZEL = join(dirname(fileURLToPath(import.meta.url)), ".."); const QUELLE = join(WURZEL, "assets", "img", "icon-512.png"); const ZIEL_ORDNER = join(WURZEL, "assets", "img", "marke"); const ZIEL = join(ZIEL_ORDNER, "logo-kopf-frei.png"); if (!existsSync(QUELLE)) { console.error(`Quelle fehlt: ${QUELLE}`); process.exit(1); } if (!existsSync(ZIEL_ORDNER)) mkdirSync(ZIEL_ORDNER, { recursive: true }); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const seite = await (await browser.newContext()).newPage(); const alsDatenAdresse = (pfad) => "data:image/png;base64," + readFileSync(pfad).toString("base64"); const ergebnis = await seite.evaluate(async (quelle) => { const bild = new Image(); bild.src = quelle; await bild.decode(); const n = bild.naturalWidth; const leinwand = document.createElement("canvas"); leinwand.width = n; leinwand.height = n; const stift = leinwand.getContext("2d", { willReadFrequently: true }); stift.drawImage(bild, 0, 0); const daten = stift.getImageData(0, 0, n, n); const p = daten.data; /* DEN GRUNDTON AUS DEM HAEUFIGSTEN RANDTON LESEN, nicht aus den Ecken. Erster Versuch: die vier Ecken mitteln. Ergebnis war rgb(0,0,0) und ein Bild, aus dem nichts entfernt wurde. Der Grund ist die Form des Symbols: `icon-512.png` hat ABGERUNDETE Ecken, dort ist es durchsichtig -- und durchsichtige Punkte tragen die Farbwerte 0,0,0. Gemittelt wurde also viermal Schwarz, und gesucht wurde danach nach schwarzem Hintergrund, den es nicht gibt. Jetzt wird der ganze Rand abgelaufen, undurchsichtige Punkte werden grob nach Farbe gebuendelt, und der groesste Haufen gewinnt. Das ueberlebt runde Ecken, einen Rahmen und einzelne Ausreisser. */ const haufen = new Map(); const merke = (x, y) => { const i = (y * n + x) * 4; if (p[i + 3] < 200) return; // durchsichtig zaehlt nicht mit const schluessel = `${p[i] >> 3},${p[i + 1] >> 3},${p[i + 2] >> 3}`; const e = haufen.get(schluessel) || { n: 0, r: 0, g: 0, b: 0 }; e.n++; e.r += p[i]; e.g += p[i + 1]; e.b += p[i + 2]; haufen.set(schluessel, e); }; for (let x = 0; x < n; x++) { merke(x, 0); merke(x, n - 1); } for (let y = 0; y < n; y++) { merke(0, y); merke(n - 1, y); } if (!haufen.size) return { fehler: "Der ganze Rand ist durchsichtig — nichts abzulesen." }; const groesster = [...haufen.values()].sort((a, b) => b.n - a.n)[0]; const randPunkte = [...haufen.values()].reduce((s, e) => s + e.n, 0); if (groesster.n / randPunkte < 0.5) { return { fehler: `Der Rand ist nicht einfarbig genug ` + `(haeufigster Ton nur ${Math.round(groesster.n / randPunkte * 100)} % des Randes).` }; } const grund = [ Math.round(groesster.r / groesster.n), Math.round(groesster.g / groesster.n), Math.round(groesster.b / groesster.n), ]; const abstand = (i) => Math.sqrt( (p[i] - grund[0]) ** 2 + (p[i + 1] - grund[1]) ** 2 + (p[i + 2] - grund[2]) ** 2); /* NAH bis hierher gilt ein Punkt als Hintergrund WEIT ab hier gilt er als Logo; dazwischen wird weich abgestuft */ const NAH = 42, WEIT = 96; /* Flutfuellung von den Raendern. Eine eigene Liste statt Rekursion -- bei 512x512 liefe der Aufrufstapel sonst über. */ const besucht = new Uint8Array(n * n); const liste = []; for (let x = 0; x < n; x++) { liste.push(x, x + (n - 1) * n); } for (let y = 0; y < n; y++) { liste.push(y * n, n - 1 + y * n); } let entfernt = 0, weich = 0; while (liste.length) { const feld = liste.pop(); if (besucht[feld]) continue; besucht[feld] = 1; const i = feld * 4; /* Schon durchsichtige Punkte (die runden Ecken) sind bereits Hintergrund -- durch sie hindurch muss die Fuellung weiterlaufen, sonst kommt sie gar nicht erst an den hellblauen Grund heran. */ if (p[i + 3] < 40) { const x0 = feld % n, y0 = (feld - x0) / n; if (x0 > 0) liste.push(feld - 1); if (x0 < n - 1) liste.push(feld + 1); if (y0 > 0) liste.push(feld - n); if (y0 < n - 1) liste.push(feld + n); continue; } const a = abstand(i); if (a > WEIT) continue; // gehört zum Logo, hier ist Schluss if (a <= NAH) { p[i + 3] = 0; entfernt++; } else { /* Zwischenzone: je näher am Grundton, desto durchsichtiger. */ p[i + 3] = Math.round(255 * ((a - NAH) / (WEIT - NAH))); weich++; } const x = feld % n, y = (feld - x) / n; if (x > 0) liste.push(feld - 1); if (x < n - 1) liste.push(feld + 1); if (y > 0) liste.push(feld - n); if (y < n - 1) liste.push(feld + n); } /* ---- Zweiter Durchgang: die EINGESCHLOSSENEN Reste -------------------- Die Flutfuellung kommt nur an das heran, was mit dem Rand zusammenhaengt. In den Ohren, am Auge und an der Schnauze liegen aber Flaechen desselben Hellblau, die ringsum von schwarzer Kontur umschlossen sind -- die Fuellung erreicht sie nie. Im ersten Ergebnis blieben sie als blaue Flecken stehen und haetten auf jedem eingefaerbten Symbol als Fremdkoerper geleuchtet. Im Original-Logo (assets/img/marke/logo.png, auf Weiss) sind genau diese Stellen WEISS -- sie waren also nie blau, dort schien beim Bau des Symbols der Hintergrund durch. Sie gehoeren damit ebenfalls weg. Hier ist ein reiner Farbvergleich gefahrlos, und das laesst sich nachrechnen statt hoffen: Der Abstand von Weiss (255,255,255) zum Grundton betraegt rund 120 -- deutlich mehr als die Schwelle NAH (42). Das Fell kann also nicht versehentlich mitgeloescht werden. Genau diese Zahl steht unten in der Ausgabe, damit sie beim naechsten Logo nachgeprueft wird, statt geglaubt zu werden. */ const abstandWeiss = Math.sqrt( (255 - grund[0]) ** 2 + (255 - grund[1]) ** 2 + (255 - grund[2]) ** 2); let eingeschlossen = 0; if (abstandWeiss > NAH * 1.8) { for (let i = 0; i < p.length; i += 4) { if (p[i + 3] === 0) continue; if (abstand(i) <= NAH) { p[i + 3] = 0; eingeschlossen++; } } } stift.putImageData(daten, 0, 0); /* Wie viel Bild ist übrig? Bleibt fast nichts stehen, hat die Füllung das Logo mitgefressen -- dann ist das Ergebnis wertlos und darf nicht gespeichert werden. */ let sichtbar = 0; for (let i = 3; i < p.length; i += 4) if (p[i] > 40) sichtbar++; return { png: leinwand.toDataURL("image/png"), grund, entfernt, weich, sichtbar, gesamt: n * n, groesse: n, eingeschlossen, abstandWeiss: Math.round(abstandWeiss), }; }, alsDatenAdresse(QUELLE)); await browser.close(); if (ergebnis.fehler) { console.error("Abgebrochen:", ergebnis.fehler); process.exit(1); } const anteil = ergebnis.sichtbar / ergebnis.gesamt; console.log(`Grundton erkannt: rgb(${ergebnis.grund.join(", ")})`); console.log(`ganz entfernt: ${ergebnis.entfernt} Punkte`); console.log(`weiche Kante: ${ergebnis.weich} Punkte`); console.log(`eingeschlossen: ${ergebnis.eingeschlossen} Punkte ` + `(Abstand Weiss-zu-Grundton: ${ergebnis.abstandWeiss}, Schwelle 42 — Fell bleibt sicher)`); console.log(`Logo übrig: ${ergebnis.sichtbar} Punkte (${(anteil * 100).toFixed(1)} %)`); /* Die Anzahl gehört in die Bedingung: Ein leeres Bild wäre sonst das sauberste Ergebnis von allen. */ if (anteil < 0.12) { console.error(`\nFEHLER: Nur ${(anteil * 100).toFixed(1)} % übrig — die Füllung hat das Logo` + ` mitgenommen. Nichts gespeichert.`); process.exit(1); } if (anteil > 0.92) { console.error(`\nFEHLER: ${(anteil * 100).toFixed(1)} % übrig — es wurde praktisch nichts` + ` entfernt. Nichts gespeichert.`); process.exit(1); } writeFileSync(ZIEL, Buffer.from(ergebnis.png.split(",")[1], "base64")); console.log(`\nGespeichert: ${ZIEL.replace(WURZEL, ".")} (${ergebnis.groesse}x${ergebnis.groesse})`);