tools/sprite-silhouette: sprite alpha to CSS shape-outside polygon #8

Merged
libby merged 3 commits from libby/sprite-silhouette into main 2026-11-10 23:14:07 +00:00
5 changed files with 453 additions and 0 deletions

View file

@ -0,0 +1,68 @@
# sprite-silhouette
Turns a sprite's alpha channel into a simplified polygon in percent coordinates, for CSS
`shape-outside: polygon()`, so text wraps around the figure instead of its rectangle. Zero
dependencies: Node (tested on 24; `node:zlib` does the PNG inflate). Entry point: `silhouette.mjs`.
Live and tested; there is no packaging or release for it, it is a repo tool.
## First call
```
node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses --out silhouettes.json
node --test tools/sprite-silhouette/silhouette.test.mjs
```
The first argument is a poses directory holding `catalogue.json` (the cast-art catalogue); the
tool reads it and every PNG it names, and writes nothing there. Output is a JSON object keyed by
pose id, `mind/label` in catalogue order (`void/neutral`, `eureka/probe`, ...):
```json
{ "void/neutral": { "width": 605, "height": 461,
"points": [[42.98, 6.72], [49.59, 8.24], "..."],
"css": "polygon(42.98% 6.72%, 49.59% 8.24%, ...)" } }
```
Use the `css` string as the value of `shape-outside` on a floated image of the same aspect ratio
(percentages are of the image's box):
```css
img.pose { float: left; shape-outside: polygon(42.98% 6.72%, 49.59% 8.24%, ...); shape-margin: 0.5em; }
```
An empty sprite (nothing at or above the threshold) gets `points: []` and `css: "none"`.
From code: `silhouette(pngBuffer, { threshold, tolerance })` in `silhouette.mjs`.
## Options
- `--threshold 0.35`: a pixel is inside when `alpha/255 >= threshold` (0.35 is the Void mock-up's value).
- `--tolerance 1.5`: Douglas-Peucker distance in source pixels; the simplified polygon stays within
this of the traced edge in both directions (tested). 0 keeps every corner.
- `--out file`: write there instead of stdout.
## What it does and does not trace
Threshold, then the outer boundary of the largest 8-connected shape, by walking the pixel edges
(so vertices sit on pixel corners and the walk is exact), then simplification of that closed ring.
Holes are ignored (wrapping text goes around a figure, not through its gaps), and smaller
disconnected islands (a floating hand, a stray sparkle) are dropped: `shape-outside` takes one
polygon. The contour is a staircase of pixel edges, so below about 1 px the tolerance only
reproduces the staircase; there is no sub-pixel interpolation of the alpha ramp.
Input: 8-bit non-interlaced PNG (RGBA, grey+alpha, palette with tRNS, or opaque RGB/grey, which is
a rectangle). Anything else is refused with its name.
## Prior art
- **Marching squares** (Lorensen and Cline's cubes, 2D case) interpolates along the alpha ramp for
sub-pixel contours and one pass emits every contour, holes included. Not used: the pose art is
anti-aliased over a pixel or two, a 1.5 px tolerance swallows that, and we want exactly one outer ring.
- **Potrace** (Selinger, 2003) fits optimal polygons and then Bezier curves to a bitmap. CSS
`polygon()` has no curves, so only its idea of an optimal polygon applies; this tool keeps the
simpler greedy Douglas-Peucker and measures its error directly.
- **Douglas-Peucker** (1973; Ramer 1972) is the simplifier: keep the vertex farthest from the chord
if it is beyond the tolerance, recurse. Its guarantee (every dropped vertex within the tolerance
of the result) is what the tests assert. It is not topology-preserving; on all 99 current poses at
the defaults no output self-intersects (checked when this was written, not asserted by the suite).
- **Boundary following** (Moore-neighbour / square tracing) is the contour walk, with the
8-connected rule so diagonally touching pixels stay one shape.

View file

@ -0,0 +1,82 @@
// Minimal PNG reader: the alpha plane only. 8-bit, non-interlaced (every pose in cast-art is RGBA8).
// Anything else is refused by name, never guessed at.
import { inflateSync } from "node:zlib";
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
/** @returns {{ width: number, height: number, alpha: Uint8Array }} alpha is row-major, 0..255. */
export function decodeAlpha(buffer) {
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");
let header = null;
let palette = null;
let trns = null;
const idat = [];
for (let at = 8; at + 8 <= buffer.length; ) {
const length = buffer.readUInt32BE(at);
const type = buffer.toString("latin1", at + 4, at + 8);
const body = buffer.subarray(at + 8, at + 8 + length);
if (body.length !== length) throw new Error(`truncated ${type} chunk`);
at += 12 + length;
if (type === "IHDR") {
header = { width: body.readUInt32BE(0), height: body.readUInt32BE(4), depth: body[8], color: body[9], interlace: body[12] };
} else if (type === "PLTE") palette = body;
else if (type === "tRNS") trns = body;
else if (type === "IDAT") idat.push(body);
else if (type === "IEND") break;
}
if (!header) throw new Error("PNG has no IHDR");
const { width, height, depth, color, interlace } = header;
if (depth !== 8) throw new Error(`unsupported PNG bit depth ${depth} (8 only)`);
if (interlace !== 0) throw new Error("unsupported PNG: interlaced");
const channels = CHANNELS[color];
if (!channels) throw new Error(`unsupported PNG color type ${color}`);
if (color === 3 && !palette) throw new Error("indexed PNG has no PLTE");
const raw = inflateSync(Buffer.concat(idat));
const stride = width * channels;
if (raw.length !== (stride + 1) * height) throw new Error("PNG pixel data has the wrong length");
const alpha = new Uint8Array(width * height);
let prev = new Uint8Array(stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = Uint8Array.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
unfilter(filter, line, prev, channels);
for (let x = 0; x < width; x++) {
const p = x * channels;
let a = 255;
if (color === 6) a = line[p + 3];
else if (color === 4) a = line[p + 1];
else if (color === 3) a = trns && line[p] < trns.length ? trns[line[p]] : 255;
else if (trns && color === 0 && line[p] === trns.readUInt16BE(0)) a = 0;
else if (trns && color === 2 && line[p] === trns.readUInt16BE(0) && line[p + 1] === trns.readUInt16BE(2) && line[p + 2] === trns.readUInt16BE(4)) a = 0;
alpha[y * width + x] = a;
}
prev = line;
}
return { width, height, alpha };
}
function unfilter(filter, line, prev, bpp) {
for (let i = 0; i < line.length; i++) {
const a = i >= bpp ? line[i - bpp] : 0;
const b = prev[i];
const c = i >= bpp ? prev[i - bpp] : 0;
let add;
switch (filter) {
case 0: add = 0; break;
case 1: add = a; break;
case 2: add = b; break;
case 3: add = (a + b) >> 1; break;
case 4: {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
break;
}
default: throw new Error(`bad PNG filter ${filter}`);
}
line[i] = (line[i] + add) & 255;
}
}

View file

@ -0,0 +1,61 @@
#!/usr/bin/env node
// Sprite alpha silhouette -> simplified polygon in percent coordinates for CSS shape-outside: polygon().
// First call: node tools/sprite-silhouette/silhouette.mjs /opt/commons/cast-art/poses > silhouettes.json
import { readFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
import { decodeAlpha } from "./png.mjs";
import { largestContour, mask, simplifyRing } from "./trace.mjs";
export const DEFAULTS = { threshold: 0.35, tolerance: 1.5 };
const pct = (v, total) => Math.round((v / total) * 10000) / 100;
/** @returns {{ width: number, height: number, points: number[][], css: string }} points are [x%, y%]; css is "none" for an empty sprite. */
export function silhouette(png, { threshold = DEFAULTS.threshold, tolerance = DEFAULTS.tolerance } = {}) {
const { width, height, alpha } = decodeAlpha(png);
const ring = largestContour(mask(alpha, threshold), width, height);
if (!ring) return { width, height, points: [], css: "none" };
const points = simplifyRing(ring, tolerance).map(([x, y]) => [pct(x, width), pct(y, height)]);
const css = `polygon(${points.map(([x, y]) => `${x}% ${y}%`).join(", ")})`;
return { width, height, points, css };
}
/** Pose ids are `mind/label`, in catalogue order; the value is the path under the poses root. */
export function poseFiles(catalogue) {
const files = {};
for (const [mind, entry] of Object.entries(catalogue.minds)) {
for (const group of [entry.shared, entry.signatures]) {
for (const [label, path] of Object.entries(group ?? {})) files[`${mind}/${label}`] = path;
}
}
return files;
}
export function silhouettes(root, options) {
const catalogue = JSON.parse(readFileSync(join(root, "catalogue.json"), "utf8"));
const out = {};
for (const [id, path] of Object.entries(poseFiles(catalogue))) out[id] = silhouette(readFileSync(join(root, path)), options);
return out;
}
function main(argv) {
const options = { ...DEFAULTS };
let out = null, root = null;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--threshold") options.threshold = Number(argv[++i]);
else if (argv[i] === "--tolerance") options.tolerance = Number(argv[++i]);
else if (argv[i] === "--out") out = argv[++i];
else if (argv[i].startsWith("--")) throw new Error(`unknown option ${argv[i]}`);
else root = argv[i];
}
if (!root || !(options.threshold > 0 && options.threshold <= 1) || !(options.tolerance >= 0)) {
console.error("usage: silhouette.mjs <poses-dir> [--threshold 0.35] [--tolerance 1.5] [--out file.json]");
process.exit(2);
}
const text = JSON.stringify(silhouettes(root, options), null, 1) + "\n";
if (out) writeFileSync(out, text);
else process.stdout.write(text);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main(process.argv.slice(2));

View file

@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import test from "node:test";
import { deflateSync } from "node:zlib";
import { decodeAlpha } from "./png.mjs";
import { silhouette, silhouettes } from "./silhouette.mjs";
import { largestContour, mask, maxDistanceToRing, simplifyRing } from "./trace.mjs";
// An RGBA8 PNG built from an alpha function, every row encoded with the given PNG filter.
function crc32(buf) {
let c, crc = ~0;
for (let i = 0; i < buf.length; i++) {
c = (crc ^ buf[i]) & 255;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
crc = (crc >>> 8) ^ c;
}
return ~crc >>> 0;
}
function chunk(type, body) {
const head = Buffer.alloc(8);
head.writeUInt32BE(body.length, 0);
head.write(type, 4, "latin1");
const tail = Buffer.alloc(4);
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
return Buffer.concat([head, body, tail]);
}
function png(width, height, alphaAt, { filter = 0 } = {}) {
const rows = [];
let prev = Buffer.alloc(width * 4);
for (let y = 0; y < height; y++) {
const line = Buffer.alloc(width * 4);
for (let x = 0; x < width; x++) line.set([200, 10 * (x % 20), 30, alphaAt(x, y)], x * 4);
const out = Buffer.alloc(width * 4 + 1);
out[0] = filter;
for (let i = 0; i < line.length; i++) {
const a = i >= 4 ? line[i - 4] : 0, b = prev[i], c = i >= 4 ? prev[i - 4] : 0;
const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
const pred = [0, a, b, (a + b) >> 1, pa <= pb && pa <= pc ? a : pb <= pc ? b : c][filter];
out[i + 1] = (line[i] - pred) & 255;
}
rows.push(out);
prev = line;
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr), chunk("IDAT", deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0)),
]);
}
const disc = (cx, cy, r) => (x, y) => ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= r * r ? 255 : 0);
test("decodes alpha through every PNG row filter", () => {
const f = (x, y) => (x * 7 + y * 13) % 256;
for (const filter of [0, 1, 2, 3, 4]) {
const { width, height, alpha } = decodeAlpha(png(9, 7, f, { filter }));
assert.deepEqual([width, height], [9, 7]);
for (let y = 0; y < 7; y++) for (let x = 0; x < 9; x++) assert.equal(alpha[y * 9 + x], f(x, y), `filter ${filter} at ${x},${y}`);
}
});
test("refuses what it cannot read", () => {
assert.throws(() => decodeAlpha(Buffer.from("nope")), /not a PNG/);
const bad = png(2, 2, () => 255);
bad[24] = 16; // IHDR bit depth
assert.throws(() => decodeAlpha(bad), /bit depth 16/);
});
test("a fully transparent sprite has no shape", () => {
const s = silhouette(png(8, 8, () => 0));
assert.deepEqual(s.points, []);
assert.equal(s.css, "none");
});
test("a fully opaque sprite is its own bounding box", () => {
const s = silhouette(png(8, 4, () => 255));
assert.deepEqual(s.points, [[0, 0], [100, 0], [100, 100], [0, 100]]);
assert.equal(s.css, "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)");
});
test("threshold is alpha/255 >= 0.35: 89 is out, 90 is in", () => {
assert.deepEqual(silhouette(png(4, 4, () => 89)).points, []);
assert.equal(silhouette(png(4, 4, () => 90)).points.length, 4);
assert.deepEqual(silhouette(png(4, 4, () => 89), { threshold: 0.3 }).points.length, 4);
});
test("simplified shape stays within tolerance of the traced edge, both ways, and is smaller", () => {
const { width, height, alpha } = decodeAlpha(png(120, 100, disc(60, 50, 40)));
const ring = largestContour(mask(alpha, 0.35), width, height);
for (const tolerance of [0.5, 1.5, 4]) {
const simple = simplifyRing(ring, tolerance);
assert.ok(simple.length < ring.length && simple.length >= 4);
assert.ok(maxDistanceToRing(ring, simple) <= tolerance + 1e-9, `ring to simplified at ${tolerance}`);
assert.ok(maxDistanceToRing(simple, ring) <= tolerance + 1e-9, `simplified to ring at ${tolerance}`);
}
// And the traced edge is the true edge to within a pixel: every ring vertex sits within 1px of the disc's circle.
for (const [x, y] of ring) assert.ok(Math.abs(Math.hypot(x - 60, y - 50) - 40) <= 1.0, `${x},${y}`);
});
test("only the outside is traced: a hole is ignored", () => {
const ring = (x, y) => (Math.max(Math.abs(x - 10), Math.abs(y - 10)) <= 8 && Math.max(Math.abs(x - 10), Math.abs(y - 10)) > 3 ? 255 : 0);
const s = silhouette(png(20, 20, ring), { tolerance: 0 });
const xs = s.points.map((p) => p[0]), ys = s.points.map((p) => p[1]);
assert.deepEqual([Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)], [10, 95, 10, 95]);
assert.equal(s.points.length, 4);
});
test("pixels that only touch diagonally are one shape; a smaller island is dropped", () => {
const dots = (x, y) => (x === y && x < 4 ? 255 : 0);
const diag = silhouette(png(8, 8, dots), { tolerance: 0 });
const xs = diag.points.map((p) => p[0]);
assert.equal(Math.max(...xs), 50); // x 0..4 of 8
const island = (x, y) => ((x < 5 && y < 5) || (x === 8 && y === 8) ? 255 : 0);
const s = silhouette(png(10, 10, island));
assert.deepEqual(s.points, [[0, 0], [50, 0], [50, 50], [0, 50]]);
});
test("deterministic: same bytes, same polygon; coordinates are percent within the image", () => {
const bytes = png(90, 70, disc(45, 35, 30));
const a = silhouette(bytes), b = silhouette(Buffer.from(bytes));
assert.deepEqual(a, b);
for (const [x, y] of a.points) assert.ok(x >= 0 && x <= 100 && y >= 0 && y <= 100);
assert.match(a.css, /^polygon\((\d+(\.\d+)?% \d+(\.\d+)?%)(, \d+(\.\d+)?% \d+(\.\d+)?%){3,}\)$/);
});
const POSES = "/opt/commons/cast-art/poses";
test("every pose in the shared cast-art catalogue gets a polygon", { skip: !existsSync(`${POSES}/catalogue.json`) }, () => {
const all = silhouettes(POSES);
const ids = Object.keys(all);
assert.ok(ids.length > 90 && ids.includes("void/neutral"));
for (const id of ids) assert.ok(all[id].points.length >= 4 && all[id].points.length < 400, id);
assert.deepEqual(silhouettes(POSES), all);
});

View file

@ -0,0 +1,109 @@
// Silhouette of an alpha plane: threshold -> outer contour of the largest 8-connected shape -> Douglas-Peucker.
// Coordinates are pixel-corner lattice points: x in 0..width, y in 0..height.
/** A pixel is inside when alpha/255 >= threshold. */
export function mask(alpha, threshold) {
const m = new Uint8Array(alpha.length);
for (let i = 0; i < alpha.length; i++) m[i] = alpha[i] / 255 >= threshold ? 1 : 0;
return m;
}
/** Outer contour (lattice vertices at turns, clockwise on screen) of the 8-connected component with the most pixels, or null. */
export function largestContour(m, width, height) {
const seen = new Uint8Array(m.length);
let best = null;
for (let start = 0; start < m.length; start++) {
if (!m[start] || seen[start]) continue;
// Raster order: `start` is the component's first pixel, so the pixels above and up-left of it are empty
// or belong to another component.
let count = 0;
const stack = [start];
seen[start] = 1;
while (stack.length) {
const p = stack.pop();
count++;
const px = p % width, py = (p / width) | 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const x = px + dx, y = py + dy;
if (x < 0 || y < 0 || x >= width || y >= height) continue;
const q = y * width + x;
if (m[q] && !seen[q]) { seen[q] = 1; stack.push(q); }
}
}
if (!best || count > best.count) best = { count, start };
}
return best && traceOuter(m, width, height, best.start % width, (best.start / width) | 0);
}
// Directions: 0 east, 1 south, 2 west, 3 north. The shape is kept on the walker's right, so an
// outer boundary comes out clockwise; at each corner a filled left-ahead pixel turns left (which
// joins pixels that only touch diagonally), else a filled right-ahead pixel goes straight, else right.
const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1];
const AHEAD_LEFT = [[0, -1], [0, 0], [-1, 0], [-1, -1]];
const AHEAD_RIGHT = [[0, 0], [-1, 0], [-1, -1], [0, -1]];
function traceOuter(m, width, height, px, py) {
const at = (x, y) => x >= 0 && y >= 0 && x < width && y < height && m[y * width + x] === 1;
const vertices = [];
let x = px, y = py, dir = 3, first = true; // arrive at the start corner heading north, so it is counted as a turn
for (;;) {
const left = at(x + AHEAD_LEFT[dir][0], y + AHEAD_LEFT[dir][1]);
const right = at(x + AHEAD_RIGHT[dir][0], y + AHEAD_RIGHT[dir][1]);
const turn = left ? 3 : right ? 0 : 1;
const next = (dir + turn) & 3;
// Back at the start corner and about to leave east: the ring is closed.
if (!first && x === px && y === py && next === 0) break;
first = false;
if (turn !== 0) vertices.push([x, y]);
dir = next;
x += DX[dir]; y += DY[dir];
}
return vertices;
}
/** Douglas-Peucker on a closed ring: split at the vertex farthest from the first, simplify both halves. */
export function simplifyRing(ring, tolerance) {
if (ring.length <= 3 || tolerance <= 0) return ring.slice();
let far = 0, best = -1;
for (let i = 1; i < ring.length; i++) {
const d = (ring[i][0] - ring[0][0]) ** 2 + (ring[i][1] - ring[0][1]) ** 2;
if (d > best) { best = d; far = i; }
}
const a = dp(ring.slice(0, far + 1), tolerance);
const b = dp(ring.slice(far).concat([ring[0]]), tolerance);
return a.slice(0, -1).concat(b.slice(0, -1));
}
function dp(points, tolerance) {
const keep = new Uint8Array(points.length);
keep[0] = keep[points.length - 1] = 1;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [lo, hi] = stack.pop();
let worst = -1, at = -1;
for (let i = lo + 1; i < hi; i++) {
const d = distanceToSegment(points[i], points[lo], points[hi]);
if (d > worst) { worst = d; at = i; }
}
if (worst > tolerance) { keep[at] = 1; stack.push([lo, at], [at, hi]); }
}
return points.filter((_, i) => keep[i]);
}
export function distanceToSegment(p, a, b) {
const vx = b[0] - a[0], vy = b[1] - a[1];
const len2 = vx * vx + vy * vy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / len2));
return Math.hypot(p[0] - (a[0] + t * vx), p[1] - (a[1] + t * vy));
}
/** Largest distance from any point of ring `a`'s edges' vertices to ring `b`'s edges. */
export function maxDistanceToRing(points, ring) {
let worst = 0;
for (const p of points) {
let d = Infinity;
for (let i = 0; i < ring.length; i++) d = Math.min(d, distanceToSegment(p, ring[i], ring[(i + 1) % ring.length]));
worst = Math.max(worst, d);
}
return worst;
}