feat: remove background
This commit is contained in:
parent
94dee90da4
commit
4c707cfa38
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -12,6 +12,7 @@ npm run build:css:watch # Watch mode for CSS
|
|||
npm run preview # Serve HTML at localhost:3000
|
||||
npm run dev # CSS watch + preview server (concurrent)
|
||||
npm run pdf -- <file> # Convert HTML file to PDF
|
||||
npm run remove-bg -- <file|dir> # Remove white background from PNG icons
|
||||
```
|
||||
|
||||
Generate images via Banatie API:
|
||||
|
|
@ -30,6 +31,7 @@ src/
|
|||
scripts/
|
||||
generate-pdf.mjs — HTML → PDF via Puppeteer
|
||||
banatie.mjs — Banatie API client for image generation
|
||||
remove-bg.mjs — Remove white background from PNGs (flood fill)
|
||||
tasks/ — JSON task definition files
|
||||
assets/
|
||||
hero-images/ — spaceship1-6.jpeg (header hero images)
|
||||
|
|
@ -154,6 +156,20 @@ REST API for generating images.
|
|||
|
||||
Configuration is in `src/scripts/banatie.mjs`. Set the `BANATIE_API_KEY` environment variable for authentication.
|
||||
|
||||
## Background Removal
|
||||
|
||||
Script `src/scripts/remove-bg.mjs` removes white backgrounds from PNG icons using flood-fill from edges (like magic wand in Photoshop). Uses sharp. White areas inside objects are preserved.
|
||||
|
||||
```bash
|
||||
node src/scripts/remove-bg.mjs <file|dir> [--threshold N] [--fuzz N]
|
||||
```
|
||||
|
||||
- `--threshold` — whiteness threshold for R,G,B (default: 230, lower = more aggressive)
|
||||
- `--fuzz` — anti-alias radius in pixels at the boundary (default: 0)
|
||||
- Default input: `assets/icons/pack2/`
|
||||
|
||||
This is optional — not all icons need it. After generating/splitting new icons, ask the user if they want background removal on specific files.
|
||||
|
||||
## PDF Generation
|
||||
|
||||
Puppeteer settings for A4 worksheets:
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 77 KiB |
|
|
@ -10,7 +10,8 @@
|
|||
"preview": "npx serve output/html --cors -l 3000",
|
||||
"pdf": "node src/scripts/generate-pdf.mjs",
|
||||
"dev": "concurrently \"npm run build:css:watch\" \"npm run preview\"",
|
||||
"split-sprites": "node src/scripts/split-sprites.mjs"
|
||||
"split-sprites": "node src/scripts/split-sprites.mjs",
|
||||
"remove-bg": "node src/scripts/remove-bg.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import sharp from 'sharp';
|
||||
import { resolve, basename, extname, join } from 'path';
|
||||
import { existsSync, readdirSync, statSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const PROJECT_ROOT = resolve(fileURLToPath(import.meta.url), '../../..');
|
||||
const DEFAULT_INPUT = resolve(PROJECT_ROOT, 'assets/icons/pack2');
|
||||
const DEFAULT_THRESHOLD = 230;
|
||||
const DEFAULT_FUZZ = 0;
|
||||
const PNG_EXTENSION = /\.png$/i;
|
||||
|
||||
function parseArgs(args) {
|
||||
const result = { input: DEFAULT_INPUT, threshold: DEFAULT_THRESHOLD, fuzz: DEFAULT_FUZZ };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--threshold') result.threshold = parseInt(args[++i], 10);
|
||||
else if (args[i] === '--fuzz') result.fuzz = parseInt(args[++i], 10);
|
||||
else if (!args[i].startsWith('--')) result.input = resolve(args[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectFiles(input) {
|
||||
if (statSync(input).isFile()) return [input];
|
||||
return readdirSync(input)
|
||||
.filter(f => PNG_EXTENSION.test(f))
|
||||
.sort()
|
||||
.map(f => join(input, f));
|
||||
}
|
||||
|
||||
function isWhitish(data, offset, threshold) {
|
||||
return data[offset] >= threshold && data[offset + 1] >= threshold && data[offset + 2] >= threshold;
|
||||
}
|
||||
|
||||
function floodFillFromEdges(data, width, height, threshold) {
|
||||
const total = width * height;
|
||||
const visited = new Uint8Array(total);
|
||||
const transparent = new Uint8Array(total);
|
||||
const queue = [];
|
||||
let head = 0;
|
||||
let count = 0;
|
||||
|
||||
const seed = (x, y) => {
|
||||
const idx = y * width + x;
|
||||
if (visited[idx]) return;
|
||||
visited[idx] = 1;
|
||||
const off = idx * 4;
|
||||
if (data[off + 3] === 0 || isWhitish(data, off, threshold)) {
|
||||
queue.push(idx);
|
||||
}
|
||||
};
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
seed(x, 0);
|
||||
seed(x, height - 1);
|
||||
}
|
||||
for (let y = 1; y < height - 1; y++) {
|
||||
seed(0, y);
|
||||
seed(width - 1, y);
|
||||
}
|
||||
|
||||
const dx = [-1, 1, 0, 0];
|
||||
const dy = [0, 0, -1, 1];
|
||||
|
||||
while (head < queue.length) {
|
||||
const idx = queue[head++];
|
||||
transparent[idx] = 1;
|
||||
data[idx * 4 + 3] = 0;
|
||||
count++;
|
||||
|
||||
const x = idx % width;
|
||||
const y = (idx - x) / width;
|
||||
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const nx = x + dx[d];
|
||||
const ny = y + dy[d];
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
||||
const nIdx = ny * width + nx;
|
||||
if (visited[nIdx]) continue;
|
||||
visited[nIdx] = 1;
|
||||
const off = nIdx * 4;
|
||||
if (data[off + 3] === 0 || isWhitish(data, off, threshold)) {
|
||||
queue.push(nIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { transparent, count };
|
||||
}
|
||||
|
||||
function applyAntiAlias(data, width, height, transparent, fuzz) {
|
||||
if (fuzz <= 0) return;
|
||||
|
||||
const total = width * height;
|
||||
const dist = new Float32Array(total);
|
||||
const visited = new Uint8Array(total);
|
||||
const queue = [];
|
||||
let head = 0;
|
||||
const dx = [-1, 1, 0, 0];
|
||||
const dy = [0, 0, -1, 1];
|
||||
|
||||
for (let idx = 0; idx < total; idx++) {
|
||||
if (transparent[idx]) continue;
|
||||
const x = idx % width;
|
||||
const y = (idx - x) / width;
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const nx = x + dx[d];
|
||||
const ny = y + dy[d];
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
||||
if (transparent[ny * width + nx]) {
|
||||
dist[idx] = 1;
|
||||
visited[idx] = 1;
|
||||
queue.push(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (head < queue.length) {
|
||||
const idx = queue[head++];
|
||||
data[idx * 4 + 3] = Math.round(255 * dist[idx] / (fuzz + 1));
|
||||
|
||||
if (dist[idx] >= fuzz) continue;
|
||||
const x = idx % width;
|
||||
const y = (idx - x) / width;
|
||||
for (let d = 0; d < 4; d++) {
|
||||
const nx = x + dx[d];
|
||||
const ny = y + dy[d];
|
||||
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
|
||||
const nIdx = ny * width + nx;
|
||||
if (visited[nIdx] || transparent[nIdx]) continue;
|
||||
visited[nIdx] = 1;
|
||||
dist[nIdx] = dist[idx] + 1;
|
||||
queue.push(nIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBackground(filePath, threshold, fuzz) {
|
||||
const { data, info } = await sharp(filePath)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
const { width, height } = info;
|
||||
const { transparent, count } = floodFillFromEdges(data, width, height, threshold);
|
||||
applyAntiAlias(data, width, height, transparent, fuzz);
|
||||
|
||||
await sharp(data, { raw: { width, height, channels: 4 } })
|
||||
.png()
|
||||
.toFile(filePath);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { input, threshold, fuzz } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!existsSync(input)) {
|
||||
console.error(`Not found: ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const files = collectFiles(input);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(`No PNG files found in: ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Removing backgrounds from ${files.length} image(s) (threshold: ${threshold}, fuzz: ${fuzz})...`);
|
||||
|
||||
const results = await Promise.all(
|
||||
files.map(async (filePath) => {
|
||||
const removed = await removeBackground(filePath, threshold, fuzz);
|
||||
console.log(` ${basename(filePath)} -> ${removed} pixels made transparent`);
|
||||
return removed;
|
||||
})
|
||||
);
|
||||
|
||||
const totalPixels = results.reduce((sum, r) => sum + r, 0);
|
||||
console.log(`Done. ${totalPixels} total pixels made transparent across ${files.length} file(s).`);
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in New Issue