431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
/**
|
|
* Polish every image in ~/Desktop/ftp-images-new for eye-catching menu quality.
|
|
* - Sweets: premium white-bg plate compositing (rebuilt from source)
|
|
* - Dishes: vibrance, contrast, and sharpness boost in-place
|
|
*/
|
|
|
|
import { removeBackground } from '@imgly/background-removal-node';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import sharp from 'sharp';
|
|
|
|
const OUT_ROOT = path.join(process.env.HOME ?? '', 'Desktop', 'ftp-images-new');
|
|
const SRC_ROOT = path.join(__dirname, '..', 'ftp-images');
|
|
const SWEETS_MARKER = `${path.sep}sweets${path.sep}`;
|
|
|
|
const CANVAS_W = 1600;
|
|
const CANVAS_H = 1200;
|
|
const PLATE_CX = CANVAS_W / 2;
|
|
const PLATE_CY = CANVAS_H / 2 + 20;
|
|
const PLATE_R = 440;
|
|
|
|
const VIGNETTE_FALLBACK = new Set([
|
|
'bilder-bp/sweets/namak-paray/bild/IMG_0209.jpg',
|
|
]);
|
|
|
|
const CENTER_CROP_FALLBACK = new Set([
|
|
'bilder-bp/sweets/patisa/bild/IMG_1400.jpg',
|
|
]);
|
|
|
|
function plateSvg(seed: number): string {
|
|
const specks: string[] = [];
|
|
let s = seed + 11;
|
|
const rand = () => {
|
|
s = (s * 16807) % 2147483647;
|
|
return s / 2147483647;
|
|
};
|
|
|
|
const colors = ['#4A7EBB', '#E8843C', '#C4A882', '#6B9E78', '#D4A574', '#9B7CB8'];
|
|
for (let i = 0; i < 88; i++) {
|
|
const angle = rand() * Math.PI * 2;
|
|
const dist = rand() * (PLATE_R - 55);
|
|
const x = PLATE_CX + Math.cos(angle) * dist;
|
|
const y = PLATE_CY + Math.sin(angle) * dist;
|
|
const r = 2.5 + rand() * 10;
|
|
specks.push(
|
|
`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r.toFixed(1)}" fill="${colors[i % colors.length]}" opacity="0.62"/>`
|
|
);
|
|
}
|
|
|
|
return `<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<radialGradient id="plateGrad" cx="50%" cy="40%" r="60%">
|
|
<stop offset="0%" stop-color="#FFFDF8"/>
|
|
<stop offset="100%" stop-color="#F0E8DA"/>
|
|
</radialGradient>
|
|
<clipPath id="plateClip">
|
|
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}"/>
|
|
</clipPath>
|
|
<filter id="plateShadow" x="-40%" y="-40%" width="180%" height="180%">
|
|
<feDropShadow dx="0" dy="14" stdDeviation="28" flood-color="#C8C0B4" flood-opacity="0.28"/>
|
|
</filter>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="#FFFFFF"/>
|
|
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 16}" fill="#FFFFFF" filter="url(#plateShadow)"/>
|
|
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R + 11}" fill="none" stroke="#C9A227" stroke-width="9"/>
|
|
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="url(#plateGrad)"/>
|
|
<g clip-path="url(#plateClip)">${specks.join('')}</g>
|
|
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R - 22}" fill="none" stroke="#E8DCC8" stroke-width="1.5" opacity="0.85"/>
|
|
<circle cx="${PLATE_CX}" cy="${PLATE_CY}" r="${PLATE_R}" fill="none" stroke="#B8922A" stroke-width="4.5"/>
|
|
</svg>`;
|
|
}
|
|
|
|
function collectImages(dir: string): string[] {
|
|
if (!fs.existsSync(dir)) return [];
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) return collectImages(full);
|
|
if (/\.(jpe?g|png|webp)$/i.test(entry.name)) return [full];
|
|
return [];
|
|
});
|
|
}
|
|
|
|
function findSource(destPath: string): string | null {
|
|
const rel = path.relative(OUT_ROOT, destPath);
|
|
const dir = path.dirname(path.join(SRC_ROOT, rel));
|
|
const base = path.basename(destPath, path.extname(destPath));
|
|
if (!fs.existsSync(dir)) return null;
|
|
for (const name of fs.readdirSync(dir)) {
|
|
if (name.startsWith(base) && /\.(jpe?g|png|webp)$/i.test(name)) {
|
|
return path.join(dir, name);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function enhanceFromSource(src: string): Promise<Buffer> {
|
|
const rotated = sharp(src, { failOn: 'none' }).rotate();
|
|
const { data, info } = await rotated.toColorspace('srgb').removeAlpha().toBuffer({ resolveWithObject: true });
|
|
|
|
const w = info.width;
|
|
const h = info.height;
|
|
const aspect = 4 / 3;
|
|
const sourceAspect = w / h;
|
|
|
|
let cropW = w;
|
|
let cropH = h;
|
|
let left = 0;
|
|
let top = 0;
|
|
|
|
if (sourceAspect > aspect) {
|
|
cropW = Math.round(h * aspect);
|
|
left = Math.round((w - cropW) / 2);
|
|
} else if (sourceAspect < aspect) {
|
|
cropH = Math.round(w / aspect);
|
|
top = Math.round((h - cropH) / 2);
|
|
}
|
|
|
|
cropW = Math.min(cropW, w - left);
|
|
cropH = Math.min(cropH, h - top);
|
|
const minDim = Math.min(cropW, cropH);
|
|
|
|
let pipe = sharp(data)
|
|
.extract({ left, top, width: cropW, height: cropH })
|
|
.normalize()
|
|
.modulate({ brightness: 1.05, saturation: 1.22 })
|
|
.gamma(1.04);
|
|
|
|
pipe =
|
|
minDim < 900
|
|
? pipe.sharpen({ sigma: 1.1, m1: 0.7, m2: 0.35 })
|
|
: pipe.sharpen({ sigma: 0.85, m1: 0.55, m2: 0.28 });
|
|
|
|
return pipe
|
|
.resize(CANVAS_W, CANVAS_H, { fit: 'fill', kernel: sharp.kernel.lanczos3 })
|
|
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
|
.toBuffer();
|
|
}
|
|
|
|
async function cutout(imagePath: string, model: 'small' | 'medium'): Promise<Buffer> {
|
|
const blob = await removeBackground(imagePath, { model });
|
|
return Buffer.from(await blob.arrayBuffer());
|
|
}
|
|
|
|
async function cutoutValid(cutout: Buffer): Promise<boolean> {
|
|
const { data, info } = await sharp(cutout).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
const w = info.width ?? 1;
|
|
const h = info.height ?? 1;
|
|
const x0 = Math.floor(w * 0.2);
|
|
const y0 = Math.floor(h * 0.2);
|
|
const x1 = Math.floor(w * 0.8);
|
|
const y1 = Math.floor(h * 0.8);
|
|
|
|
let opaque = 0;
|
|
let total = 0;
|
|
for (let y = y0; y < y1; y++) {
|
|
for (let x = x0; x < x1; x++) {
|
|
if (data[(y * w + x) * 4 + 3] > 45) opaque++;
|
|
total++;
|
|
}
|
|
}
|
|
return opaque / total > 0.07;
|
|
}
|
|
|
|
async function beautifyFood(cutout: Buffer, maxW: number, maxH: number): Promise<Buffer> {
|
|
const meta = await sharp(cutout).metadata();
|
|
const cw = meta.width ?? 1;
|
|
const ch = meta.height ?? 1;
|
|
const scale = Math.min(maxW / cw, maxH / ch, 1);
|
|
return sharp(cutout)
|
|
.resize(Math.round(cw * scale), Math.round(ch * scale), { kernel: sharp.kernel.lanczos3 })
|
|
.modulate({ brightness: 1.04, saturation: 1.18 })
|
|
.sharpen({ sigma: 0.7, m1: 0.5, m2: 0.25 })
|
|
.png()
|
|
.toBuffer();
|
|
}
|
|
|
|
async function stylizeWithCenterCrop(photo: Buffer, index: number): Promise<Buffer> {
|
|
const meta = await sharp(photo).metadata();
|
|
const w = meta.width ?? CANVAS_W;
|
|
const h = meta.height ?? CANVAS_H;
|
|
const cropW = Math.round(w * 0.58);
|
|
const cropH = Math.round(h * 0.58);
|
|
const left = Math.round((w - cropW) / 2);
|
|
const top = Math.round((h - cropH) / 2);
|
|
|
|
const cropped = await sharp(photo)
|
|
.extract({ left, top, width: cropW, height: cropH })
|
|
.modulate({ brightness: 1.04, saturation: 1.2 })
|
|
.sharpen({ sigma: 0.9 })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const maskSvg = `<svg width="${cropW}" height="${cropH}">
|
|
<defs>
|
|
<radialGradient id="m" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="white"/>
|
|
<stop offset="78%" stop-color="white" stop-opacity="0.92"/>
|
|
<stop offset="100%" stop-color="white" stop-opacity="0"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#m)"/>
|
|
</svg>`;
|
|
|
|
const masked = await sharp(cropped)
|
|
.composite([{ input: await sharp(Buffer.from(maskSvg)).blur(12).png().toBuffer(), blend: 'dest-in' }])
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const fm = await sharp(masked).metadata();
|
|
const fw = fm.width ?? cropW;
|
|
const fh = fm.height ?? cropH;
|
|
const scale = Math.min((PLATE_R * 1.25) / fw, (PLATE_R * 1.05) / fh);
|
|
const food = await sharp(masked)
|
|
.resize(Math.round(fw * scale), Math.round(fh * scale), { kernel: sharp.kernel.lanczos3 })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const f2 = await sharp(food).metadata();
|
|
const posL = Math.round(PLATE_CX - (f2.width ?? 0) / 2);
|
|
const posT = Math.round(PLATE_CY - (f2.height ?? 0) / 2 + 6);
|
|
|
|
const shadow = await sharp(
|
|
Buffer.from(`<svg width="${CANVAS_W}" height="${CANVAS_H}">
|
|
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + (f2.height ?? 0) * 0.16}" rx="${(f2.width ?? 0) * 0.36}" ry="${(f2.height ?? 0) * 0.08}" fill="#8A8278" opacity="0.18"/>
|
|
</svg>`)
|
|
)
|
|
.png()
|
|
.toBuffer();
|
|
|
|
return sharp(Buffer.from(plateSvg(index)))
|
|
.composite([
|
|
{ input: shadow, top: 0, left: 0 },
|
|
{ input: food, top: posT, left: posL },
|
|
])
|
|
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
|
.toBuffer();
|
|
}
|
|
|
|
async function stylizeWithVignette(photo: Buffer, index: number): Promise<Buffer> {
|
|
const plateBuf = await sharp(Buffer.from(plateSvg(index))).png().toBuffer();
|
|
|
|
const maskSvg = `<svg width="${CANVAS_W}" height="${CANVAS_H}">
|
|
<defs>
|
|
<radialGradient id="f" cx="50%" cy="50%" r="42%">
|
|
<stop offset="0%" stop-color="white"/>
|
|
<stop offset="68%" stop-color="white" stop-opacity="0.95"/>
|
|
<stop offset="100%" stop-color="white" stop-opacity="0"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#f)"/>
|
|
</svg>`;
|
|
|
|
const masked = await sharp(photo)
|
|
.resize(CANVAS_W, CANVAS_H, { fit: 'cover', position: 'centre' })
|
|
.composite([{ input: await sharp(Buffer.from(maskSvg)).blur(18).png().toBuffer(), blend: 'dest-in' }])
|
|
.modulate({ brightness: 1.03, saturation: 1.15 })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const fm = await sharp(masked).metadata();
|
|
const fw = fm.width ?? CANVAS_W;
|
|
const fh = fm.height ?? CANVAS_H;
|
|
const scale = Math.min((PLATE_R * 1.35) / fw, (PLATE_R * 1.15) / fh, 0.88);
|
|
const food = await sharp(masked)
|
|
.resize(Math.round(fw * scale), Math.round(fh * scale), { kernel: sharp.kernel.lanczos3 })
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const f2 = await sharp(food).metadata();
|
|
const left = Math.round(PLATE_CX - (f2.width ?? 0) / 2);
|
|
const top = Math.round(PLATE_CY - (f2.height ?? 0) / 2 + 8);
|
|
|
|
const shadow = await sharp(
|
|
Buffer.from(`<svg width="${CANVAS_W}" height="${CANVAS_H}">
|
|
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + 58}" rx="${PLATE_R * 0.5}" ry="${PLATE_R * 0.1}" fill="#A09890" opacity="0.18"/>
|
|
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + (f2.height ?? 0) * 0.15}" rx="${(f2.width ?? 0) * 0.38}" ry="${(f2.height ?? 0) * 0.08}" fill="#8A8278" opacity="0.16"/>
|
|
</svg>`)
|
|
)
|
|
.png()
|
|
.toBuffer();
|
|
|
|
return sharp(Buffer.from(plateSvg(index)))
|
|
.composite([
|
|
{ input: shadow, top: 0, left: 0 },
|
|
{ input: food, top, left },
|
|
])
|
|
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
|
.toBuffer();
|
|
}
|
|
|
|
async function stylizeSweet(destPath: string, rel: string, index: number): Promise<void> {
|
|
const src = findSource(destPath);
|
|
if (!src) throw new Error('missing source');
|
|
|
|
const enhanced = await enhanceFromSource(src);
|
|
|
|
if (CENTER_CROP_FALLBACK.has(rel)) {
|
|
const out = await stylizeWithCenterCrop(enhanced, index);
|
|
fs.writeFileSync(destPath, out);
|
|
return;
|
|
}
|
|
|
|
const useVignette = VIGNETTE_FALLBACK.has(rel);
|
|
|
|
if (useVignette) {
|
|
const out = await stylizeWithVignette(enhanced, index);
|
|
fs.writeFileSync(destPath, out);
|
|
return;
|
|
}
|
|
|
|
const tmp = path.join(OUT_ROOT, `.tmp-cut-${index}.jpg`);
|
|
fs.writeFileSync(tmp, enhanced);
|
|
|
|
let cutoutBuf: Buffer | null = null;
|
|
try {
|
|
for (const model of ['medium', 'small'] as const) {
|
|
const attempt = await cutout(tmp, model);
|
|
if (await cutoutValid(attempt)) {
|
|
cutoutBuf = attempt;
|
|
break;
|
|
}
|
|
}
|
|
} finally {
|
|
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
}
|
|
|
|
if (!cutoutBuf) {
|
|
const out = await stylizeWithVignette(enhanced, index);
|
|
fs.writeFileSync(destPath, out);
|
|
return;
|
|
}
|
|
|
|
const plateBuf = await sharp(Buffer.from(plateSvg(index))).png().toBuffer();
|
|
const food = await beautifyFood(cutoutBuf, PLATE_R * 1.42, PLATE_R * 1.18);
|
|
const fm = await sharp(food).metadata();
|
|
const fw = fm.width ?? 1;
|
|
const fh = fm.height ?? 1;
|
|
const left = Math.round(PLATE_CX - fw / 2);
|
|
const top = Math.round(PLATE_CY - fh / 2 + 6);
|
|
|
|
const shadow = await sharp(
|
|
Buffer.from(`<svg width="${CANVAS_W}" height="${CANVAS_H}">
|
|
<ellipse cx="${PLATE_CX}" cy="${PLATE_CY + fh * 0.18}" rx="${fw * 0.4}" ry="${fh * 0.09}" fill="#8A8278" opacity="0.2"/>
|
|
</svg>`)
|
|
)
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const out = await sharp(plateBuf)
|
|
.composite([
|
|
{ input: shadow, top: 0, left: 0 },
|
|
{ input: food, top, left },
|
|
])
|
|
.sharpen({ sigma: 0.35, m1: 0.3, m2: 0.15 })
|
|
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
|
.toBuffer();
|
|
|
|
fs.writeFileSync(destPath, out);
|
|
}
|
|
|
|
async function polishDish(imagePath: string): Promise<void> {
|
|
const tmp = `${imagePath}.polish.jpg`;
|
|
await sharp(imagePath, { failOn: 'none' })
|
|
.rotate()
|
|
.toColorspace('srgb')
|
|
.normalize()
|
|
.modulate({ brightness: 1.04, saturation: 1.16 })
|
|
.gamma(1.03)
|
|
.sharpen({ sigma: 0.75, m1: 0.5, m2: 0.25 })
|
|
.jpeg({ quality: 94, mozjpeg: true, chromaSubsampling: '4:4:4' })
|
|
.toFile(tmp);
|
|
|
|
fs.renameSync(tmp, imagePath);
|
|
}
|
|
|
|
async function polishPoster(imagePath: string): Promise<void> {
|
|
const tmp = `${imagePath}.polish.jpg`;
|
|
await sharp(imagePath, { failOn: 'none' })
|
|
.rotate()
|
|
.toColorspace('srgb')
|
|
.normalize()
|
|
.modulate({ brightness: 1.02, saturation: 1.1 })
|
|
.sharpen({ sigma: 0.5 })
|
|
.jpeg({ quality: 95, mozjpeg: true })
|
|
.toFile(tmp);
|
|
fs.renameSync(tmp, imagePath);
|
|
}
|
|
|
|
async function main() {
|
|
const onlyArg = process.argv.find((a) => a.startsWith('--only='));
|
|
const onlyFilter = onlyArg?.slice('--only='.length);
|
|
|
|
let images = collectImages(OUT_ROOT).sort();
|
|
if (onlyFilter) {
|
|
images = images.filter((p) => path.relative(OUT_ROOT, p).includes(onlyFilter));
|
|
}
|
|
let sweets = 0;
|
|
let dishes = 0;
|
|
let posters = 0;
|
|
|
|
console.log(`Polishing ${images.length} images in ${OUT_ROOT}\n`);
|
|
|
|
for (let i = 0; i < images.length; i++) {
|
|
const img = images[i];
|
|
const rel = path.relative(OUT_ROOT, img);
|
|
try {
|
|
if (rel.includes(SWEETS_MARKER)) {
|
|
await stylizeSweet(img, rel, i);
|
|
sweets++;
|
|
console.log(` ✓ sweet ${rel}`);
|
|
} else if (rel.includes('/others/')) {
|
|
await polishPoster(img);
|
|
posters++;
|
|
console.log(` ✓ poster ${rel}`);
|
|
} else {
|
|
await polishDish(img);
|
|
dishes++;
|
|
console.log(` ✓ dish ${rel}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(` ✗ ${rel}: ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
console.log(`\nDone: ${sweets} sweets restyled, ${dishes} dishes polished, ${posters} posters`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
}); |