diff --git a/tools/sprite-silhouette/README.md b/tools/sprite-silhouette/README.md new file mode 100644 index 00000000..a959e193 --- /dev/null +++ b/tools/sprite-silhouette/README.md @@ -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. diff --git a/tools/sprite-silhouette/png.mjs b/tools/sprite-silhouette/png.mjs new file mode 100644 index 00000000..280e80ab --- /dev/null +++ b/tools/sprite-silhouette/png.mjs @@ -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; + } +} diff --git a/tools/sprite-silhouette/silhouette.mjs b/tools/sprite-silhouette/silhouette.mjs new file mode 100644 index 00000000..92cb56d7 --- /dev/null +++ b/tools/sprite-silhouette/silhouette.mjs @@ -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 [--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)); diff --git a/tools/sprite-silhouette/silhouette.test.mjs b/tools/sprite-silhouette/silhouette.test.mjs new file mode 100644 index 00000000..c8b4faca --- /dev/null +++ b/tools/sprite-silhouette/silhouette.test.mjs @@ -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); +}); diff --git a/tools/sprite-silhouette/trace.mjs b/tools/sprite-silhouette/trace.mjs new file mode 100644 index 00000000..49594a5b --- /dev/null +++ b/tools/sprite-silhouette/trace.mjs @@ -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; +}