Astro 7 mit Tailwind 4 und Node-Adapter. Alle Seiten werden vorgerendert, der Node-Server ist dabei, damit spaeter einzelne Routen (Booking-Formular) ohne Umbau am Deployment dynamisch werden koennen. Inhalt: - Fullscreen-Hero mit freigestelltem Render, animierten Lasern und Genre-Laufband; Typografie als HTML statt eingebranntem Bildtext - About-Sektion mit Genre-Karten und Eckdaten - Impressum und Datenschutzerklaerung - Zentrale Inhaltsdatei src/data/site.ts Technik: - Selbst gehostete Schriften, keine Cookies, kein Tracking, keine externen Requests - Docker Multi-Stage (238 MB) hinter Caddy mit automatischem HTTPS, Security-Headern und IP-maskierten Logs - scripts/cutout.mjs stellt den Render ueber eine Sobel-Kantenkarte frei und entfernt eingeschlossene Hintergrundflaechen Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
10 KiB
JavaScript
316 lines
10 KiB
JavaScript
/**
|
|
* Stellt den DJ-NIZZ-Render frei.
|
|
*
|
|
* Der Render ist eine Vektor-Illustration: das Motiv ist durchgehend von dunkler
|
|
* Linework umschlossen, der Hintergrund ist ein sehr weicher Verlauf. Ein reiner
|
|
* Farb-Flood-Fill laeuft durch die hellen Hemd- und Hosenpartien durch (die sind
|
|
* farblich fast identisch mit dem Hintergrund). Deshalb wird der Fill hier an einer
|
|
* Sobel-Kantenkarte gestoppt: starke Kanten = Wand, alles was vom Bildrand aus ohne
|
|
* Wandkontakt erreichbar ist = Hintergrund.
|
|
*
|
|
* Aufruf: node scripts/cutout.mjs <input> <output.png> [edgeThreshold]
|
|
*/
|
|
import sharp from 'sharp';
|
|
|
|
const SRC = process.argv[2];
|
|
const OUT = process.argv[3];
|
|
const EDGE_T = Number(process.argv[4] ?? 20);
|
|
|
|
const { data, info } = await sharp(SRC)
|
|
.removeAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
const { width: W, height: H, channels: C } = info;
|
|
console.log(`source ${W}x${H} channels=${C} edgeThreshold=${EDGE_T}`);
|
|
|
|
// --- Luminanz -------------------------------------------------------------
|
|
const grey = new Float32Array(W * H);
|
|
for (let i = 0; i < W * H; i++) {
|
|
const p = i * C;
|
|
grey[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
|
|
}
|
|
|
|
// --- Sobel-Kantenkarte ----------------------------------------------------
|
|
const wall = new Uint8Array(W * H);
|
|
let wallCount = 0;
|
|
for (let y = 1; y < H - 1; y++) {
|
|
for (let x = 1; x < W - 1; x++) {
|
|
const i = y * W + x;
|
|
const tl = grey[i - W - 1], t = grey[i - W], tr = grey[i - W + 1];
|
|
const l = grey[i - 1], r = grey[i + 1];
|
|
const bl = grey[i + W - 1], b = grey[i + W], br = grey[i + W + 1];
|
|
const gx = tl + 2 * l + bl - tr - 2 * r - br;
|
|
const gy = tl + 2 * t + tr - bl - 2 * b - br;
|
|
if (Math.abs(gx) + Math.abs(gy) > EDGE_T) {
|
|
wall[i] = 1;
|
|
wallCount++;
|
|
}
|
|
}
|
|
}
|
|
console.log(`edge pixels: ${wallCount} (${((wallCount / (W * H)) * 100).toFixed(1)}%)`);
|
|
|
|
// --- Flood-Fill vom Bildrand, an Kanten blockiert --------------------------
|
|
const bg = new Uint8Array(W * H);
|
|
const queue = new Int32Array(W * H);
|
|
let qh = 0;
|
|
let qt = 0;
|
|
const push = (i) => {
|
|
if (!bg[i] && !wall[i]) {
|
|
bg[i] = 1;
|
|
queue[qt++] = i;
|
|
}
|
|
};
|
|
|
|
for (let x = 0; x < W; x++) {
|
|
push(x);
|
|
push((H - 1) * W + x);
|
|
}
|
|
for (let y = 0; y < H; y++) {
|
|
push(y * W);
|
|
push(y * W + W - 1);
|
|
}
|
|
|
|
while (qh < qt) {
|
|
const i = queue[qh++];
|
|
const x = i % W;
|
|
const y = (i / W) | 0;
|
|
if (x > 0) push(i - 1);
|
|
if (x < W - 1) push(i + 1);
|
|
if (y > 0) push(i - W);
|
|
if (y < H - 1) push(i + W);
|
|
}
|
|
|
|
let bgCount = 0;
|
|
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
|
|
console.log(`vom Rand erreicht: ${((bgCount / bg.length) * 100).toFixed(1)}%`);
|
|
|
|
// --- Eingeschlossene Hintergrundflaechen ------------------------------------
|
|
// Flaechen wie zwischen Arm und Oberkoerper (Hand in der Hosentasche) sind
|
|
// ringsum von Linework umschlossen und damit vom Bildrand aus nicht
|
|
// erreichbar. Sie werden ueber ihre Farbe erkannt: liegt der Mittelwert einer
|
|
// eingeschlossenen Flaeche im Farbbereich des Hintergrunds, ist es Hintergrund.
|
|
// Der Hintergrund ist ein Verlauf - ein globaler Mittelwert waere so unscharf,
|
|
// dass auch die cremefarbenen Shorts hineinfielen. Deshalb wird pro Bildzeile
|
|
// gemittelt und eine Tasche gegen den Hintergrund *auf ihrer eigenen Hoehe*
|
|
// geprueft.
|
|
const rowSum = [new Float64Array(H), new Float64Array(H), new Float64Array(H)];
|
|
const rowCount = new Float64Array(H);
|
|
for (let y = 0; y < H; y++) {
|
|
for (let x = 0; x < W; x++) {
|
|
const i = y * W + x;
|
|
if (!bg[i]) continue;
|
|
rowCount[y]++;
|
|
for (let c = 0; c < 3; c++) rowSum[c][y] += data[i * C + c];
|
|
}
|
|
}
|
|
|
|
const BAND = 40; // +/- Zeilen, ueber die der lokale Hintergrund gemittelt wird
|
|
const HOLE_TOL = Number(process.env.CUTOUT_HOLE_TOL ?? 22); // 0 = Taschensuche aus
|
|
const MIN_HOLE = 400; // kleiner = Bildrauschen bzw. Details im Motiv
|
|
|
|
// Obergrenze relativ zur Motivflaeche. Noetig, weil die cremefarbenen Shorts
|
|
// dem Hintergrund auf ihrer Hoehe farblich fast exakt entsprechen
|
|
// (Abweichung nur 10/1/3) und ueber die Farbe allein nicht zu trennen sind.
|
|
// Groessenordnung im Ausgangsbild: Armluecken ~3.000-7.000 px,
|
|
// Shorts ~38.000-48.000 px bei ~713.000 px Motivflaeche.
|
|
const subjectPixels = W * H - bgCount;
|
|
const MAX_HOLE = Math.round(subjectPixels * Number(process.env.CUTOUT_MAX_HOLE ?? 0.03));
|
|
console.log(`Taschengroesse zulaessig: ${MIN_HOLE} .. ${MAX_HOLE} px`);
|
|
|
|
/** Mittlere Hintergrundfarbe rund um Zeile `y`, oder null bei zu wenig Daten. */
|
|
const localBackground = (y) => {
|
|
const from = Math.max(0, y - BAND);
|
|
const to = Math.min(H - 1, y + BAND);
|
|
let n = 0;
|
|
const sum = [0, 0, 0];
|
|
for (let r = from; r <= to; r++) {
|
|
n += rowCount[r];
|
|
for (let c = 0; c < 3; c++) sum[c] += rowSum[c][r];
|
|
}
|
|
return n < 500 ? null : sum.map((s) => s / n);
|
|
};
|
|
const seen = new Uint8Array(W * H);
|
|
const pocket = new Int32Array(W * H);
|
|
let holePixels = 0;
|
|
let holeCount = 0;
|
|
|
|
for (let seed = 0; seed < W * H; seed++) {
|
|
if (bg[seed] || wall[seed] || seen[seed]) continue;
|
|
|
|
let ph = 0;
|
|
let pt = 0;
|
|
seen[seed] = 1;
|
|
pocket[pt++] = seed;
|
|
const sum = [0, 0, 0];
|
|
let rowTotal = 0;
|
|
let colTotal = 0;
|
|
|
|
while (ph < pt) {
|
|
const i = pocket[ph++];
|
|
for (let c = 0; c < 3; c++) sum[c] += data[i * C + c];
|
|
rowTotal += (i / W) | 0;
|
|
colTotal += i % W;
|
|
const x = i % W;
|
|
const y = (i / W) | 0;
|
|
const visit = (n) => {
|
|
if (!bg[n] && !wall[n] && !seen[n]) {
|
|
seen[n] = 1;
|
|
pocket[pt++] = n;
|
|
}
|
|
};
|
|
if (x > 0) visit(i - 1);
|
|
if (x < W - 1) visit(i + 1);
|
|
if (y > 0) visit(i - W);
|
|
if (y < H - 1) visit(i + W);
|
|
}
|
|
|
|
if (pt < MIN_HOLE) continue;
|
|
|
|
const reference = localBackground(Math.round(rowTotal / pt));
|
|
if (!reference) continue;
|
|
const mean = sum.map((s) => s / pt);
|
|
const looksLikeBackground =
|
|
pt <= MAX_HOLE && mean.every((m, c) => Math.abs(m - reference[c]) <= HOLE_TOL);
|
|
|
|
if (process.env.CUTOUT_DEBUG) {
|
|
const fmt = (v) => v.map((n) => n.toFixed(0).padStart(3)).join(',');
|
|
console.log(
|
|
` Tasche x=${String(Math.round(colTotal / pt)).padStart(4)} y=${String(Math.round(rowTotal / pt)).padStart(4)} ${String(pt).padStart(6)} px` +
|
|
` mean=(${fmt(mean)}) bg=(${fmt(reference)})` +
|
|
` d=(${fmt(mean.map((m, c) => Math.abs(m - reference[c])))})` +
|
|
` -> ${looksLikeBackground ? 'HINTERGRUND' : 'Motiv'}`,
|
|
);
|
|
}
|
|
|
|
if (!looksLikeBackground) continue;
|
|
|
|
for (let k = 0; k < pt; k++) bg[pocket[k]] = 1;
|
|
holePixels += pt;
|
|
holeCount++;
|
|
}
|
|
|
|
console.log(`eingeschlossene Hintergrundflaechen: ${holeCount} (${holePixels} px)`);
|
|
|
|
bgCount = 0;
|
|
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
|
|
console.log(`background: ${((bgCount / bg.length) * 100).toFixed(1)}% subject: ${(100 - (bgCount / bg.length) * 100).toFixed(1)}%`);
|
|
|
|
// --- Morphologisches Opening ----------------------------------------------
|
|
// Erodieren trennt duenne Anhaengsel (Horizontlinie am Schuh, Schattenreste) vom
|
|
// Koerper ab, damit sie der Groesste-Komponente-Filter danach verwirft.
|
|
const solid = Buffer.alloc(W * H);
|
|
for (let i = 0; i < W * H; i++) solid[i] = bg[i] ? 0 : 255;
|
|
|
|
const morph = async (buf, blurSigma, threshold) => {
|
|
const out = await sharp(buf, { raw: { width: W, height: H, channels: 1 } })
|
|
.blur(blurSigma)
|
|
// Harte Schwelle bei `threshold` (0..1). linear() rechnet auf 0..255,
|
|
// die Verschiebung muss also mit 255*255 skaliert werden.
|
|
.linear(255, -threshold * 255 * 255)
|
|
.toColourspace('b-w')
|
|
.raw()
|
|
.toBuffer();
|
|
if (out.length !== W * H) throw new Error(`Maske hat ${out.length} Bytes, erwartet ${W * H}`);
|
|
return out;
|
|
};
|
|
|
|
const opened = await morph(solid, 2.0, 0.86); // erodiert ~2 px
|
|
|
|
// --- Nur die groesste zusammenhaengende Motivflaeche behalten ---------------
|
|
// Entfernt Kantenrauschen im Hintergrund (z. B. Reste des Bodenschattens).
|
|
for (let i = 0; i < W * H; i++) bg[i] = opened[i] > 127 ? 0 : 1;
|
|
|
|
const label = new Int32Array(W * H).fill(-1);
|
|
let best = -1;
|
|
let bestSize = 0;
|
|
let current = 0;
|
|
for (let seed = 0; seed < W * H; seed++) {
|
|
if (bg[seed] || label[seed] !== -1) continue;
|
|
let size = 0;
|
|
qh = 0;
|
|
qt = 0;
|
|
label[seed] = current;
|
|
queue[qt++] = seed;
|
|
while (qh < qt) {
|
|
const i = queue[qh++];
|
|
size++;
|
|
const x = i % W;
|
|
const y = (i / W) | 0;
|
|
const visit = (n) => {
|
|
if (!bg[n] && label[n] === -1) {
|
|
label[n] = current;
|
|
queue[qt++] = n;
|
|
}
|
|
};
|
|
if (x > 0) visit(i - 1);
|
|
if (x < W - 1) visit(i + 1);
|
|
if (y > 0) visit(i - W);
|
|
if (y < H - 1) visit(i + W);
|
|
}
|
|
if (size > bestSize) {
|
|
bestSize = size;
|
|
best = current;
|
|
}
|
|
current++;
|
|
}
|
|
console.log(`components: ${current}, largest: ${bestSize} px`);
|
|
|
|
const largest = Buffer.alloc(W * H);
|
|
for (let i = 0; i < W * H; i++) largest[i] = label[i] === best ? 255 : 0;
|
|
|
|
// Opening abschliessen: wieder dilatieren und mit der Originalmaske schneiden,
|
|
// damit die Silhouette exakt bleibt, die abgetrennten Anhaengsel aber weg sind.
|
|
const dilated = await morph(largest, 2.0, 0.14);
|
|
const mask = Buffer.alloc(W * H);
|
|
for (let i = 0; i < W * H; i++) mask[i] = dilated[i] > 127 && solid[i] > 127 ? 255 : 0;
|
|
|
|
// --- Kante glaetten und minimal erodieren ----------------------------------
|
|
// Weicher Verlauf statt harter Schwelle -> antialiaste Silhouette ohne hellen Saum.
|
|
const alpha = await sharp(mask, { raw: { width: W, height: H, channels: 1 } })
|
|
.blur(1.1)
|
|
.linear(3, -1.7 * 255)
|
|
.toColourspace('b-w')
|
|
.raw()
|
|
.toBuffer();
|
|
|
|
if (alpha.length !== W * H) {
|
|
throw new Error(`Alpha hat ${alpha.length} Bytes, erwartet ${W * H}`);
|
|
}
|
|
|
|
// --- Bounding Box ----------------------------------------------------------
|
|
let minX = W, minY = H, maxX = -1, maxY = -1;
|
|
for (let y = 0; y < H; y++) {
|
|
for (let x = 0; x < W; x++) {
|
|
if (alpha[y * W + x] > 8) {
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
}
|
|
}
|
|
console.log(`bbox: x ${minX}..${maxX} y ${minY}..${maxY}`);
|
|
|
|
const rgba = Buffer.alloc(W * H * 4);
|
|
for (let i = 0; i < W * H; i++) {
|
|
rgba[i * 4] = data[i * C];
|
|
rgba[i * 4 + 1] = data[i * C + 1];
|
|
rgba[i * 4 + 2] = data[i * C + 2];
|
|
rgba[i * 4 + 3] = alpha[i];
|
|
}
|
|
|
|
const pad = 4;
|
|
const left = Math.max(0, minX - pad);
|
|
const top = Math.max(0, minY - pad);
|
|
await sharp(rgba, { raw: { width: W, height: H, channels: 4 } })
|
|
.extract({
|
|
left,
|
|
top,
|
|
width: Math.min(W - 1, maxX + pad) - left,
|
|
height: Math.min(H - 1, maxY + pad) - top,
|
|
})
|
|
.png({ compressionLevel: 9 })
|
|
.toFile(OUT);
|
|
|
|
console.log(`written ${OUT}`);
|