/** * 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 [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}`);