diff --git a/AGENTS.md b/AGENTS.md index 8862c97..ae40731 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ Each one has happened here. Check for it by name before calling work done. |LIVE_FIX.md: release flow, owner page fix/deploy/rollback, backups |ASSET_LICENSES.md, CHARACTER_RESERVE.md: character and font sources|PROGRESSION_PLAN.md: historical [Code]|server/: worker.ts routes, auth glue|channel.ts ChannelRoom DO (state, bot commands, alarms, broadcast)|game.ts rules, DEFAULT_CONFIG, validateConfigPatch|eventsub.ts webhooks, slimEvent, sendChat|auth.ts sessions, Helix mod checks|channels.ts registry|botcommands.ts|streamelements.ts|uploads.ts|sprites.ts|developer.ts|security.ts|ratelimit.ts|dedupe.ts|upgrades.ts pets.ts cosmetics.ts -|src/: admin.ts mod page|dashboard.ts channel page|start.ts sign-up|setup.ts|intro/ home page 3D|scrub.ts|sprite-maker.ts spritify.ts|fighter3d.ts voxthumb.ts +|src/: admin.ts mod page|dashboard.ts channel page|start.ts sign-up|setup.ts|intro/ home page 3D|scrub.ts|sprite-maker.ts spritify.ts autosprite.ts|fighter3d.ts voxthumb.ts |src/served/: overlay.ts|arena-client.ts|chat.ts IRC|public/: overlay.html|dashboard.css tokens |tests/: *.test.ts unit|fakes.ts doubles|port/ port tools|smoke.ts|ui.ts arena-browser.ts|e2e-local.ts|chrome.ts |scripts/: port-check.ts port-rename.ts lib/port.ts|devtools.ts|release.ts|live-chat-e2e.ts|test-all.ts|configure-twitch.ps1 diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index fc7afb8..7d59a06 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -414,7 +414,9 @@ Custom characters: Viewer sprites (server/sprites.ts): - A signed-in viewer turns a picture into a sprite on the fighter page ("Your own sprite"). The browser always pixelates it - (src/spritify.ts: background removal, crop, 56 px tall, 14 colors, outline, doubled). An optional AI redraw goes + with no AI (src/spritify.ts): src/autosprite.ts finds the subject (border colors on the top edge that stay off the + bottom edge count as sky; a picture with no clear subject, such as a landscape, keeps all of it; one that already has a cut-out keeps its own), then the page crops it to 56 px tall with + at most 14 Oklab colors, adds a shaded outline and doubles the pixels. An optional AI redraw goes through `/api/sprite/:channel/redraw` first. A mod approves it on `/admin/` (Characters tab). - Ids match `^v-[a-z0-9-]{1,48}$`; each approval makes a new id, so the overlay refetches the catalog. `url` is `/api/assets//`, cached for a day (`immutable`). Entries are `mode:"single"`, `combatFallback:"effects"`, diff --git a/src/autosprite.ts b/src/autosprite.ts new file mode 100644 index 0000000..6ac7b43 --- /dev/null +++ b/src/autosprite.ts @@ -0,0 +1,625 @@ +// Finds the subject of a picture with no AI and no input from the viewer, for src/spritify.ts. On a copy at most +// SEG_MAX a side: the border's colors that sit on the top or both sides are the background, a geodesic distance +// through color change plus the color distance to that background gives a first guess, a bust's body is widened +// below the head, and a graph cut with color models (GrabCut style) refines it. A scene with no clear subject keeps +// the whole picture. Every `!` in this file reads a typed array or list at an index its loop or bounds check keeps +// in range. +import type { Img } from "./spritify.ts"; + +const SEG_MAX = 160; + +// ---------- color: Oklab, so distances match what the eye sees ---------- +const lin = (c: number) => ((c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +const gam = (c: number) => 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); +export function toLab(r: number, g: number, b: number): [number, number, number] { + const R = lin(r), + G = lin(g), + B = lin(b); + const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B), + m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B), + s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} +export function toRgb(L: number, a: number, b: number): [number, number, number] { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3, + m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3, + s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + const c = (v: number) => Math.round(Math.min(255, Math.max(0, gam(v)))); + return [ + c(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + c(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + c(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ]; +} +// Each pixel's Oklab color, 3 floats a pixel. +export function labOf(img: Img) { + const n = img.width * img.height, + out = new Float32Array(n * 3), + d = img.data; + for (let p = 0; p < n; p++) out.set(toLab(d[p * 4]!, d[p * 4 + 1]!, d[p * 4 + 2]!), p * 3); + return out; +} +// Distance between the colors at x[i..i+2] and y[j..j+2]. +export const d3 = (x: ArrayLike, i: number, y: ArrayLike, j: number) => + Math.hypot(x[i]! - y[j]!, x[i + 1]! - y[j + 1]!, x[i + 2]! - y[j + 2]!); + +// k-means on weighted Lab points with deterministic k-means++ seeding (the same picture always gives the same result). +export function kmeans(pts: Float32Array, wts: Float32Array, k: number, iters = 12) { + const n = wts.length; + if (!n) return { centers: [] as number[], label: new Int32Array(0) }; + let first = 0; + for (let i = 1; i < n; i++) if (wts[i]! > wts[first]!) first = i; + const centers: number[] = [...pts.subarray(first * 3, first * 3 + 3)]; + const near = new Float32Array(n).fill(Infinity); + while (centers.length < k * 3) { + const c = centers.length - 3; + let best = -1, + bv = 0; + for (let i = 0; i < n; i++) { + near[i] = Math.min(near[i]!, d3(pts, i * 3, centers, c) ** 2); + if (near[i]! * wts[i]! > bv) { + bv = near[i]! * wts[i]!; + best = i; + } + } + if (best < 0 || bv < 1e-7) break; + centers.push(...pts.subarray(best * 3, best * 3 + 3)); + } + const K = centers.length / 3, + label = new Int32Array(n); + for (let it = 0; it < iters; it++) { + const sum = new Float64Array(K * 4); + for (let i = 0; i < n; i++) { + let bk = 0, + bd = Infinity; + for (let c = 0; c < K; c++) { + const v = d3(pts, i * 3, centers, c * 3); + if (v < bd) { + bd = v; + bk = c; + } + } + label[i] = bk; + const w = wts[i]!; + for (let j = 0; j < 3; j++) sum[bk * 4 + j]! += pts[i * 3 + j]! * w; + sum[bk * 4 + 3]! += w; + } + for (let c = 0; c < K; c++) + if (sum[c * 4 + 3]! > 0) for (let j = 0; j < 3; j++) centers[c * 3 + j] = sum[c * 4 + j]! / sum[c * 4 + 3]!; + } + return { centers, label }; +} + +// Area-average resize (used to shrink only). +function resize(img: Img, width: number, height: number): Img { + const out = new Uint8ClampedArray(width * height * 4), + sx = img.width / width, + sy = img.height / height, + d = img.data; + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) { + const acc = [0, 0, 0, 0]; + let n = 0; + const ya = Math.floor(y * sy), + yb = Math.max(ya + 1, Math.floor((y + 1) * sy)), + xa = Math.floor(x * sx), + xb = Math.max(xa + 1, Math.floor((x + 1) * sx)); + for (let yy = ya; yy < yb && yy < img.height; yy++) + for (let xx = xa; xx < xb && xx < img.width; xx++) { + const i = (yy * img.width + xx) * 4; + for (let c = 0; c < 4; c++) acc[c]! += d[i + c]!; + n++; + } + for (let c = 0; c < 4; c++) out[(y * width + x) * 4 + c] = acc[c]! / n; + } + return { width, height, data: out }; +} + +// Max flow (Dinic) on edge lists. run() returns BFS levels: level >= 0 is the source side of the minimum cut. +class Flow { + head: Int32Array; + to: number[] = []; + next: number[] = []; + cap: number[] = []; + readonly n: number; + constructor(n: number) { + this.n = n; + this.head = new Int32Array(n).fill(-1); + } + add(u: number, v: number, c: number, rc = 0) { + this.to.push(v, u); + this.cap.push(c, rc); + this.next.push(this.head[u]!, this.head[v]!); + this.head[u] = this.to.length - 2; + this.head[v] = this.to.length - 1; + } + run(s: number, t: number) { + const { n, to, next, cap, head } = this, + level = new Int32Array(n), + it = new Int32Array(n), + q = new Int32Array(n); + const bfs = () => { + level.fill(-1); + level[s] = 0; + let h = 0, + tl = 0; + q[tl++] = s; + while (h < tl) { + const u = q[h++]!; + for (let e = head[u]!; e >= 0; e = next[e]!) + if (cap[e]! > 1e-9 && level[to[e]!]! < 0) { + level[to[e]!] = level[u]! + 1; + q[tl++] = to[e]!; + } + } + return level[t]! >= 0; + }; + // an iterative DFS, so a large grid can't overflow the stack + const stack = new Int32Array(n), + via = new Int32Array(n); + const dfs = () => { + let pushed = 0; + for (;;) { + let sp = 0, + found = false; + stack[0] = s; + while (sp >= 0) { + const u = stack[sp]!; + if (u === t) { + found = true; + break; + } + let e = it[u]!; + for (; e >= 0; e = next[e]!) if (cap[e]! > 1e-9 && level[to[e]!] === level[u]! + 1) break; + it[u] = e; + if (e < 0) { + level[u] = -1; // a dead end + sp--; + if (sp >= 0) it[stack[sp]!] = next[it[stack[sp]!]!]!; + continue; + } + via[sp] = e; + stack[++sp] = to[e]!; + } + if (!found) return pushed; + let f = Infinity; + for (let i = 0; i < sp; i++) f = Math.min(f, cap[via[i]!]!); + for (let i = 0; i < sp; i++) { + cap[via[i]!]! -= f; + cap[via[i]! ^ 1]! += f; + } + pushed += f; + } + }; + while (bfs()) { + it.set(head); + if (!dfs()) break; + } + bfs(); + return level; + } +} + +// Otsu's threshold for values in 0-1. +function otsu(v: Float32Array) { + const bins = 64, + hist = new Float64Array(bins); + for (const x of v) hist[Math.min(bins - 1, Math.floor(x * bins))]!++; + const total = v.length; + let sum = 0; + for (let i = 0; i < bins; i++) sum += i * hist[i]!; + let wb = 0, + sb = 0, + best = 0, + bt = 0.5; + for (let i = 0; i < bins; i++) { + wb += hist[i]!; + if (!wb || wb === total) continue; + sb += i * hist[i]!; + const between = wb * (total - wb) * (sb / wb - (sum - sb) / (total - wb)) ** 2; + if (between > best) { + best = between; + bt = (i + 1) / bins; + } + } + return bt; +} + +// A mixture of round Gaussians in Lab fitted to the picked pixels, as a -log likelihood function. +function colorModel(lab: Float32Array, pick: (p: number) => boolean, n: number, k = 5) { + const idx: number[] = []; + for (let p = 0; p < n; p++) if (pick(p)) idx.push(p); + const pts = new Float32Array(idx.length * 3); + idx.forEach((p, i) => pts.set(lab.subarray(p * 3, p * 3 + 3), i * 3)); + const { centers, label } = kmeans(pts, new Float32Array(idx.length).fill(1), k, 6); + const K = centers.length / 3, + cnt = new Float64Array(K), + v = new Float64Array(K); + label.forEach((c, i) => { + cnt[c]!++; + v[c]! += d3(pts, i * 3, centers, c * 3) ** 2; + }); + const comps = [...Array(K).keys()] + .filter((c) => cnt[c]! > 0) + .map((c) => ({ c, pi: cnt[c]! / idx.length, s2: Math.max(4e-4, v[c]! / cnt[c]! / 3) })); + return (p: number) => { + let like = 1e-12; + for (const { c, pi, s2 } of comps) + like += (pi / (2 * Math.PI * s2) ** 1.5) * Math.exp(-(d3(lab, p * 3, centers, c * 3) ** 2) / (2 * s2)); + return -Math.log(like); + }; +} + +// The 4-connected pieces of mask === value: an id per pixel (-1 elsewhere), and each piece's size, box and whether it +// touches the image's edge. +function components(mask: Uint8Array, w: number, h: number, value: number) { + const id = new Int32Array(w * h).fill(-1), + sizes: number[] = [], + boxes: [number, number, number, number][] = [], + edge: boolean[] = [], + q = new Int32Array(w * h); + for (let p0 = 0; p0 < w * h; p0++) { + if (mask[p0] !== value || id[p0]! >= 0) continue; + const c = sizes.length; + let hd = 0, + tl = 0, + x0 = w, + y0 = h, + x1 = 0, + y1 = 0, + touches = false; + q[tl++] = p0; + id[p0] = c; + while (hd < tl) { + const p = q[hd++]!, + x = p % w, + y = (p / w) | 0; + x0 = Math.min(x0, x); + x1 = Math.max(x1, x); + y0 = Math.min(y0, y); + y1 = Math.max(y1, y); + if (!x || !y || x === w - 1 || y === h - 1) touches = true; + for (const r of [x > 0 ? p - 1 : -1, x < w - 1 ? p + 1 : -1, y > 0 ? p - w : -1, y < h - 1 ? p + w : -1]) + if (r >= 0 && mask[r] === value && id[r]! < 0) { + id[r] = c; + q[tl++] = r; + } + } + sizes.push(tl); + boxes.push([x0, y0, x1, y1]); + edge.push(touches); + } + return { id, sizes, boxes, edge }; +} + +// Keeps the biggest piece and the pieces inside its box (an emote sheet gives one emote, not four). +function keepMain(fg: Uint8Array, w: number, h: number) { + const fc = components(fg, w, h, 1); + if (!fc.sizes.length) return; + const big = fc.sizes.indexOf(Math.max(...fc.sizes)), + [bx0, by0, bx1, by1] = fc.boxes[big]!; + const keep = fc.sizes.map((s, c) => { + const [x0, y0, x1, y1] = fc.boxes[c]!; + return c === big || (s >= 0.02 * fc.sizes[big]! && x1 > bx0 && x0 < bx1 && y1 > by0 && y0 < by1); + }); + for (let p = 0; p < w * h; p++) fg[p] = fc.id[p]! >= 0 && keep[fc.id[p]!] ? 1 : 0; +} + +// Morphological opening (erode, then dilate) with a square of radius r: cuts thin streaks off the subject. +function open(m: Uint8Array, w: number, h: number, r: number) { + const pass = (src: Uint8Array, keep: 0 | 1) => { + const out = new Uint8Array(src.length); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + let v = keep; + for (let dy = -r; dy <= r && v === keep; dy++) + for (let dx = -r; dx <= r; dx++) { + const xx = x + dx, + yy = y + dy; + if (xx >= 0 && yy >= 0 && xx < w && yy < h && src[yy * w + xx] !== keep) { + v = keep ? 0 : 1; + break; + } + } + out[y * w + x] = v; + } + return out; + }; + return pass(pass(m, 1), 0); +} + +// A w x h 0/1 mask scaled bilinearly to W x H, then cut at one half. +function upsample(m: Uint8Array, w: number, h: number, W: number, H: number) { + const out = new Uint8Array(W * H); + for (let y = 0; y < H; y++) { + const fy = Math.min(h - 1, Math.max(0, ((y + 0.5) * h) / H - 0.5)), + y0 = Math.floor(fy), + y1 = Math.min(h - 1, y0 + 1), + ty = fy - y0; + for (let x = 0; x < W; x++) { + const fx = Math.min(w - 1, Math.max(0, ((x + 0.5) * w) / W - 0.5)), + x0 = Math.floor(fx), + x1 = Math.min(w - 1, x0 + 1), + tx = fx - x0; + const v = + (m[y0 * w + x0]! * (1 - tx) + m[y0 * w + x1]! * tx) * (1 - ty) + + (m[y1 * w + x0]! * (1 - tx) + m[y1 * w + x1]! * tx) * ty; + out[y * W + x] = v >= 0.5 ? 1 : 0; + } + } + return out; +} + +// A binary heap of pixel ids ordered by key[p]. +function heapOf(key: Float32Array) { + const heap: number[] = []; + const swap = (i: number, j: number) => ([heap[i], heap[j]] = [heap[j]!, heap[i]!]); + return { + get size() { + return heap.length; + }, + push(p: number) { + heap.push(p); + for (let i = heap.length - 1; i > 0;) { + const j = (i - 1) >> 1; + if (key[heap[j]!]! <= key[heap[i]!]!) break; + swap(i, j); + i = j; + } + }, + pop() { + const top = heap[0]!, + last = heap.pop()!; + if (!heap.length) return top; + heap[0] = last; + for (let i = 0; ;) { + const l = 2 * i + 1, + r = l + 1; + let m = i; + if (l < heap.length && key[heap[l]!]! < key[heap[m]!]!) m = l; + if (r < heap.length && key[heap[r]!]! < key[heap[m]!]!) m = r; + if (m === i) break; + swap(i, m); + i = m; + } + return top; + }, + }; +} + +/** The subject at the image's own size (1 = subject, 0 = background). whole: no clear subject, so all of it. */ +export type Subject = { mask: Uint8Array; whole: boolean }; + +export function subjectMask(img: Img): Subject { + const k0 = Math.min(1, SEG_MAX / Math.max(img.width, img.height)), + w = Math.max(8, Math.round(img.width * k0)), + h = Math.max(8, Math.round(img.height * k0)), + small = resize(img, w, h), + n = w * h, + lab = labOf(small), + up = (m: Uint8Array) => upsample(m, w, h, img.width, img.height); + const border: number[] = [], + side: number[] = []; // 0 top, 1 bottom, 2 left, 3 right + for (let x = 0; x < w; x++) { + border.push(x, (h - 1) * w + x); + side.push(0, 1); + } + for (let y = 1; y < h - 1; y++) { + border.push(y * w, y * w + w - 1); + side.push(2, 3); + } + + // background colors: border clusters on the top edge that also reach a side, fill half the top or stay off the + // bottom edge, or on both side edges without filling the bottom edge (a bust's body fills the bottom and its + // corners, the sky or wall behind it fills the top) + const bpts = new Float32Array(border.length * 3); + border.forEach((p, i) => bpts.set(lab.subarray(p * 3, p * 3 + 3), i * 3)); + const { centers: bc, label: bl } = kmeans(bpts, new Float32Array(border.length).fill(1), 8); + const K = bc.length / 3, + share = new Float64Array(K), + sideHits = [...Array(K)].map(() => new Float64Array(4)), + sideLen = [w, w, h - 2, h - 2]; + bl.forEach((c, i) => { + share[c]!++; + sideHits[c]![side[i]!]!++; + }); + const hits = (c: number, s: number) => sideHits[c]![s]! / sideLen[s]!; + const bg = [...Array(K).keys()].filter( + (c) => + (hits(c, 0) >= 0.05 && (hits(c, 2) >= 0.08 || hits(c, 3) >= 0.08 || hits(c, 0) >= 0.5 || hits(c, 1) < 0.05)) || + (hits(c, 2) >= 0.08 && hits(c, 3) >= 0.08 && hits(c, 1) < 0.5), + ); + if (!bg.length) bg.push([...Array(K).keys()].sort((a, b) => share[b]! - share[a]!)[0]!); + const bgSet = new Set(bg); + + // geodesic distance from the background's part of the border, through color change. The bottom edge and the low + // sides are where a bust is cut off, so they seed only in a color that also fills the top edge (sky over water). + const geo = new Float32Array(n).fill(Infinity), + heap = heapOf(geo), + seed = new Uint8Array(n); + border.forEach((p, i) => { + const c = bl[i]!, + low = side[i] === 1 || p >= 0.6 * h * w; + if (bgSet.has(c) && (!low || hits(c, 0) >= 0.2)) { + seed[p] = 1; + geo[p] = 0; + heap.push(p); + } + }); + const done = new Uint8Array(n); + while (heap.size) { + const p = heap.pop(); + if (done[p]) continue; + done[p] = 1; + const x = p % w; + for (const r of [x > 0 ? p - 1 : -1, x < w - 1 ? p + 1 : -1, p - w, p + w]) { + if (r < 0 || r >= n || done[r]) continue; + const g = geo[p]! + d3(lab, p * 3, lab, r * 3) + 0.002; + if (g < geo[r]!) { + geo[r] = g; + heap.push(r); + } + } + } + // the color distance to the nearest background color + const con = new Float32Array(n); + for (let p = 0; p < n; p++) con[p] = Math.min(...bg.map((c) => d3(lab, p * 3, bc, c * 3))); + const norm = (v: Float32Array) => { + const hi = [...v].sort((a, b) => a - b)[Math.floor(v.length * 0.98)]! || 1; + return v.map((x) => Math.min(1, x / hi)); + }; + const gN = norm(geo), + cN = norm(con), + sal = new Float32Array(n); + for (let p = 0; p < n; p++) sal[p] = 0.55 * gN[p]! + 0.45 * cN[p]!; + const t = otsu(sal), + sal0 = sal.slice(); + + // nothing differs from the background in color: a blank picture has no subject at all, and a scene whose every + // color is background (sky over a field) keeps the whole picture + if (Math.max(...con) < 0.03) { + let spread = 0; + for (let p = 0; p < n; p++) spread = Math.max(spread, d3(lab, p * 3, bc, bg[0]! * 3)); + const blank = spread < 0.03; + return { mask: new Uint8Array(img.width * img.height).fill(blank ? 0 : 1), whole: !blank }; + } + let mean = 0; + for (const v of sal0) mean += v / n; + let tv = 0; + for (const v of sal0) tv += (v - mean) ** 2; + if (tv < 1e-9) return { mask: new Uint8Array(img.width * img.height).fill(1), whole: true }; + + // body prior: when the first guess reaches the bottom edge near its middle (a bust), the body widens below the + // head and runs to the bottom even where it matches the background's color + { + const g0 = new Uint8Array(n); + for (let p = 0; p < n; p++) g0[p] = sal[p]! > t ? 1 : 0; + keepMain(g0, w, h); + let x0 = w, + x1 = -1, + y0 = h, + bottom = 0, + bx = 0; + for (let p = 0; p < n; p++) + if (g0[p]) { + const x = p % w, + y = (p / w) | 0; + x0 = Math.min(x0, x); + x1 = Math.max(x1, x); + y0 = Math.min(y0, y); + if (y === h - 1) { + bottom++; + bx += x; + } + } + bx /= Math.max(1, bottom); + const span = x1 - x0; + if (bottom >= 0.04 * w && span > 0 && bx > x0 + 0.25 * span && bx < x1 - 0.25 * span) { + // the head row: where the first guess is widest + const rowW = new Float32Array(h); + for (let p = 0; p < n; p++) if (g0[p]) rowW[(p / w) | 0]!++; + let headY = y0, + best = 0; + for (let y = y0; y < h * 0.85; y++) + if (rowW[y]! > best) { + best = rowW[y]!; + headY = y; + } + const cx = (x0 + x1) / 2, + half = span / 2; + if (headY < 0.6 * h) + for (let p = (headY + 1) * w; p < n; p++) { + const x = p % w, + y = (p / w) | 0, + reach = half * (0.7 + (0.6 * (y - headY)) / Math.max(1, h - 1 - headY)); + if (Math.abs(x - cx) < reach) sal[p] = Math.max(sal[p]!, t + 0.25 * (1 - Math.abs(x - cx) / reach)); + } + } + } + + // graph cut, refitting the color models a few times + let fg = new Uint8Array(n); + for (let p = 0; p < n; p++) fg[p] = sal[p]! > t ? 1 : 0; + const right = (p: number) => d3(lab, p * 3, lab, p * 3 + 3) ** 2, + below = (p: number) => d3(lab, p * 3, lab, (p + w) * 3) ** 2; + let beta = 0, + pairs = 0; + for (let p = 0; p < n; p++) { + if (p % w < w - 1) { + beta += right(p); + pairs++; + } + if (p + w < n) { + beta += below(p); + pairs++; + } + } + beta = 1 / (2 * Math.max(1e-6, beta / pairs)); + const gamma = 6; + for (let round = 0; round < 3; round++) { + const cur = fg; + if (cur.every((v) => v) || !cur.some((v) => v)) break; + const Ufg = colorModel(lab, (p) => cur[p] === 1, n), + Ubg = colorModel(lab, (p) => cur[p] === 0, n); + const g = new Flow(n + 2), + S = n, + T = n + 1; + for (let p = 0; p < n; p++) { + // saliency keeps the cut from flipping whole regions the colors alone can't tell apart + const prior = 2.5 * (sal[p]! - t), + cf = Ufg(p) - prior, + cb = Ubg(p) + prior; + if (seed[p]) g.add(p, T, 1e9); + else if (cb > cf) g.add(S, p, cb - cf); + else g.add(p, T, cf - cb); + if (p % w < w - 1) { + const v = gamma * Math.exp(-beta * right(p)); + g.add(p, p + 1, v, v); + } + if (p + w < n) { + const v = gamma * Math.exp(-beta * below(p)); + g.add(p, p + w, v, v); + } + } + const level = g.run(S, T); + fg = Uint8Array.from(level.subarray(0, n), (v) => (v >= 0 ? 1 : 0)); + } + + // cut thin streaks (cloud wisps, light rays) loose so keepMain drops them, then fill small holes + fg = open(fg, w, h, Math.max(1, Math.round(Math.min(w, h) / 80))); + keepMain(fg, w, h); + const area = fg.reduce((s, v) => s + v, 0), + holes = components(fg, w, h, 0); + for (let p = 0; p < n; p++) { + const c = holes.id[p]!; + if (c >= 0 && !holes.edge[c] && holes.sizes[c]! < 0.06 * area) fg[p] = 1; + } + + // nothing sensible found (a scene with no subject): keep the whole picture. Signs: tiny or near-total cover, a cut + // running along the top edge, rows in the upper 60% cut edge to edge (a horizon), or a weak split of the saliency + // (Otsu's between-class share of its variance) + let cover = 0, + top = 0, + m1 = 0, + c1 = 0, + wide = 0; + for (let p = 0; p < n; p++) { + cover += fg[p]!; + if (p < w) top += fg[p]!; + if (sal0[p]! > t) { + m1 += sal0[p]!; + c1++; + } + } + const m0 = (mean * n - m1) / Math.max(1, n - c1); + m1 /= Math.max(1, c1); + const sep = (c1 * (n - c1) * (m1 - m0) ** 2) / n / tv; + for (let y = 0; y < 0.6 * h; y++) if (fg[y * w] && fg[y * w + w - 1]) wide++; + cover /= n; + const whole = cover < 0.04 || cover > 0.65 || top > 0.25 * w || wide > 0.09 * h || sep < 0.68; + return { mask: whole ? new Uint8Array(img.width * img.height).fill(1) : up(fg), whole }; +} diff --git a/src/spritify.ts b/src/spritify.ts index d3a6f11..3b13862 100644 --- a/src/spritify.ts +++ b/src/spritify.ts @@ -1,26 +1,22 @@ -// Turns any picture into a PixFray sprite in the browser: cut out the subject, shrink it to fighter size, cut the -// colors to a small palette, add a dark outline and double the pixels. The result is a PNG of at most 128x128 that -// server/sprites.ts accepts. The steps work on plain {width, height, data} images, so tests run them without a DOM. +// Turns any picture into a PixFray sprite in the browser, with no AI: find the subject (src/autosprite.ts), shrink it +// to fighter size with a small Oklab palette, add a dark outline in a shade of the colors it borders and double the +// pixels. The result is a PNG of at most 128x128 that server/sprites.ts accepts. The steps work on plain {width, height, data} images, so tests run them without a DOM. +import { subjectMask, labOf, kmeans, d3, toRgb } from "./autosprite.ts"; + export const SPRITE_MAX = 128; export const SOURCE_MAX = 512; // pictures are decoded at most this size before any step export const REDRAW_MAX = 504; // the AI model takes images under 512 px a side export const FILE_MAX = 10 * 1024 * 1024; -/** A color as [red, green, blue], each 0-255. */ -export type RGB = [number, number, number]; /** A plain RGBA image in ImageData's shape (4 bytes a pixel), so tests run the steps without a DOM. */ export type Img = { width: number; height: number; data: Uint8ClampedArray }; /** A rectangle inside an image, in pixels. */ export type Box = { x: number; y: number; width: number; height: number }; -export const OUTLINE: RGB = [24, 18, 30]; const image = (width: number, height: number): Img => ({ width, height, data: new Uint8ClampedArray(width * height * 4), }); -// d[...]!: in range, i is the first byte of a pixel -const dist2 = (d: Uint8ClampedArray, i: number, r: number, g: number, b: number) => - (d[i]! - r) ** 2 + (d[i + 1]! - g) ** 2 + (d[i + 2]! - b) ** 2; // The largest size within max x max that keeps the aspect ratio (never enlarges). export function fitSize(width: number, height: number, max: number) { @@ -48,65 +44,15 @@ export function hasCutout(img: Img) { return clear / n > 0.3; } -// Clears the background: the border's main colors (each on at least 15% of the border) are the background, and a -// flood fill from the border clears every connected pixel close to one of them. tolerance is a color distance -// (0-441). Pictures that already have a cut-out keep theirs. -export function removeBackground(img: Img, tolerance = 40) { +// Clears the background: the subject finder in src/autosprite.ts decides what is subject, and everything else turns +// see-through. A scene with no clear subject keeps the whole picture, and a picture that already has a cut-out keeps +// its own. +export function removeBackground(img: Img) { if (hasCutout(img)) return img; - const { width: w, height: h, data: src } = img, - d = new Uint8ClampedArray(src), - t2 = tolerance * tolerance; - const border: number[] = []; - for (let x = 0; x < w; x++) border.push(x, (h - 1) * w + x); - for (let y = 1; y < h - 1; y++) border.push(y * w, y * w + w - 1); - const ring = { width: border.length, height: 1, data: new Uint8ClampedArray(border.length * 4) }; - border.forEach((p, k) => { - ring.data.set(src.subarray(p * 4, p * 4 + 3), k * 4); - ring.data[k * 4 + 3] = 255; - }); - const pal = palette(ring, 4), - share = pal.map(() => 0); - const nearest = (i: number, data: Uint8ClampedArray) => { - let best = -1, - bd = t2; - pal.forEach((c, k) => { - const v = dist2(data, i, c[0], c[1], c[2]); - if (v <= bd) { - bd = v; - best = k; - } - }); - return best; - }; - for (let k = 0; k < border.length; k++) { - const n = nearest(k * 4, ring.data); - if (n >= 0) share[n]!++; // in range: n is a palette index - } - const keep = new Set(pal.map((_, k) => k).filter((k) => share[k]! >= border.length * 0.15)); // in range: k is a palette index - const background = (i: number) => src[i + 3]! < 128 || keep.has(nearest(i, src)); // in range: i is a pixel's first byte - const seen = new Uint8Array(w * h), - queue = new Int32Array(w * h); - let head = 0, - tail = 0; - const push = (p: number) => { - if (!seen[p]) { - seen[p] = 1; - queue[tail++] = p; - } - }; - for (const p of border) push(p); - while (head < tail) { - const p = queue[head++]!, // in range: head < tail, the queue's filled length - i = p * 4; - if (!background(i)) continue; // the subject's edge - d[i + 3] = 0; - const x = p % w; - if (x > 0) push(p - 1); - if (x < w - 1) push(p + 1); - if (p >= w) push(p - w); - if (p < w * (h - 1)) push(p + w); - } - return { width: w, height: h, data: d }; + const { mask } = subjectMask(img), + d = new Uint8ClampedArray(img.data); + for (let p = 0; p < mask.length; p++) if (!mask[p]) d[p * 4 + 3] = 0; + return { width: img.width, height: img.height, data: d }; } // The smallest box around the solid pixels, or null when there are none. @@ -128,140 +74,6 @@ export function contentBox(img: Img, alphaMin = 128): Box | null { return x1 < 0 ? null : { x: x0, y: y0, width: x1 - x0 + 1, height: y1 - y0 + 1 }; } -export function crop(img: Img, box: Box) { - const out = image(box.width, box.height); - for (let y = 0; y < box.height; y++) { - const from = ((box.y + y) * img.width + box.x) * 4; - out.data.set(img.data.subarray(from, from + box.width * 4), y * box.width * 4); - } - return out; -} - -// Area-average shrink to width x height. Colors are weighted by alpha so edges don't pick up the cleared background; -// alpha ends up fully on or off (sprites have hard edges). -export function shrink(img: Img, width: number, height: number) { - const out = image(width, height), - sx = img.width / width, - sy = img.height / height, - d = img.data; - for (let y = 0; y < height; y++) - for (let x = 0; x < width; x++) { - let r = 0, - g = 0, - b = 0, - a = 0, - n = 0; - const ya = Math.floor(y * sy), - yb = Math.max(ya + 1, Math.floor((y + 1) * sy)), - xa = Math.floor(x * sx), - xb = Math.max(xa + 1, Math.floor((x + 1) * sx)); - for (let yy = ya; yy < yb && yy < img.height; yy++) - for (let xx = xa; xx < xb && xx < img.width; xx++) { - // d[...]!: in range, xx and yy stay inside the image - const i = (yy * img.width + xx) * 4, - w = d[i + 3]!; - r += d[i]! * w; // in range: as above - g += d[i + 1]! * w; // in range: as above - b += d[i + 2]! * w; // in range: as above - a += w; - n++; - } - const o = (y * width + x) * 4; - if (a / n >= 110) { - out.data[o] = r / a; - out.data[o + 1] = g / a; - out.data[o + 2] = b / a; - out.data[o + 3] = 255; - } - } - return out; -} - -// Median cut: a palette of at most `colors` from the solid pixels. -export function palette(img: Img, colors: number) { - const d = img.data, - px: RGB[] = []; - // d[...]!: in range, i is a pixel's first byte - for (let i = 0; i < d.length; i += 4) if (d[i + 3]) px.push([d[i]!, d[i + 1]!, d[i + 2]!]); - if (!px.length) return []; - const boxes = [px]; - while (boxes.length < colors) { - let best = -1, - bestRange = 0, - bestCh = 0; - boxes.forEach((box, k) => { - if (box.length < 2) return; - for (let c = 0; c < 3; c++) { - let lo = 255, - hi = 0; - for (const p of box) { - if (p[c]! < lo) lo = p[c]!; // in range: c is a channel, 0-2 - if (p[c]! > hi) hi = p[c]!; // in range: c is a channel, 0-2 - } - if (hi - lo > bestRange) { - bestRange = hi - lo; - best = k; - bestCh = c; - } - } - }); - if (best < 0 || bestRange < 6) break; // what's left is one color already - // in range: best is an index into boxes and bestCh a channel, 0-2 - const box = boxes[best]!.sort((a, b) => a[bestCh]! - b[bestCh]!), - mid = box.length >> 1; - boxes.splice(best, 1, box.slice(0, mid), box.slice(mid)); - } - // as RGB: the map keeps the three channels; p[c]!: c is a channel, 0-2 - return boxes.map((box) => [0, 1, 2].map((c) => Math.round(box.reduce((s, p) => s + p[c]!, 0) / box.length)) as RGB); -} - -export function quantize(img: Img, colors: number) { - const pal = palette(img, colors), - out = { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) }, - d = out.data; - for (let i = 0; i < d.length; i += 4) { - if (!d[i + 3]) continue; - let best = pal[0]!, // this pixel is solid, so the palette has at least one color - bd = Infinity; - for (const p of pal) { - const v = dist2(d, i, p[0], p[1], p[2]); - if (v < bd) { - bd = v; - best = p; - } - } - d[i] = best[0]; - d[i + 1] = best[1]; - d[i + 2] = best[2]; - } - return out; -} - -// One pixel of dark outline around the solid pixels (the image grows by 1 on each side). -export function outline(img: Img, color: RGB = OUTLINE) { - const w = img.width + 2, - h = img.height + 2, - out = image(w, h), - // in range: the bounds are checked before the read - solid = (x: number, y: number) => - x >= 0 && y >= 0 && x < img.width && y < img.height && img.data[(y * img.width + x) * 4 + 3]! > 0; - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) { - const o = (y * w + x) * 4; - if (solid(x - 1, y - 1)) { - out.data.set(img.data.subarray(((y - 1) * img.width + x - 1) * 4, ((y - 1) * img.width + x) * 4), o); - continue; - } - if (solid(x - 2, y - 1) || solid(x, y - 1) || solid(x - 1, y - 2) || solid(x - 1, y)) { - out.data[o] = color[0]; - out.data[o + 1] = color[1]; - out.data[o + 2] = color[2]; - out.data[o + 3] = 255; - } - } - return out; -} - export function enlarge(img: Img, k: number) { if (k === 1) return img; const out = image(img.width * k, img.height * k); @@ -273,33 +85,182 @@ export function enlarge(img: Img, k: number) { return out; } +// The most common value in a non-empty list. +function mode(v: number[]) { + const seen = new Map(); + let best = v[0]!, // the callers pass at least one value + bc = 0; + for (const x of v) { + const c = (seen.get(x) ?? 0) + 1; + seen.set(x, c); + if (c > bc) { + bc = c; + best = x; + } + } + return best; +} + // The whole recipe. height = the subject's height in sprite pixels before doubling (the overlay scales sprites to the -// same bulk anyway). Returns null when nothing is left after the background goes. +// same bulk anyway). Returns null when nothing is left after the background goes. Every `!` below reads a typed array +// or list at an index its loop or bounds check keeps in range. export function spritify( img: Img, { removeBg = true, - tolerance = 40, height = 56, colors = 14, edge = true, - }: { removeBg?: boolean; tolerance?: number; height?: number; colors?: number; edge?: boolean } = {}, + }: { removeBg?: boolean; height?: number; colors?: number; edge?: boolean } = {}, ) { - const cut = removeBg ? removeBackground(img, tolerance) : img; + const cut = removeBg ? removeBackground(img) : img; const box = contentBox(cut); if (!box) return null; - const subject = crop(cut, box), + const W = cut.width, + solid = (p: number) => cut.data[p * 4 + 3]! >= 128, pad = edge ? 2 : 0, - room = SPRITE_MAX / 2 - pad; - const k = Math.min(height / subject.height, room / subject.width, room / subject.height, 1); - const small = shrink( - subject, - Math.max(1, Math.round(subject.width * k)), - Math.max(1, Math.round(subject.height * k)), - ); - if (!contentBox(small)) return null; - const done = edge ? outline(quantize(small, colors)) : quantize(small, colors); - return enlarge(done, 2); + room = SPRITE_MAX / 2 - pad, + k = Math.min(height / box.height, room / box.width, room / box.height, 1), + ow = Math.max(1, Math.round(box.width * k)), + oh = Math.max(1, Math.round(box.height * k)), + x1 = box.x + box.width - 1, + y1 = box.y + box.height - 1; + + // palette: k-means in Oklab over the subject's colors, binned to 5 bits a channel so it stays quick + const lab = labOf(cut), + binW = new Float32Array(32768), + binSum = new Float64Array(32768 * 3); + for (let y = box.y; y <= y1; y++) + for (let x = box.x; x <= x1; x++) { + const p = y * W + x, + i = p * 4; + if (!solid(p)) continue; + const b = ((cut.data[i]! >> 3) << 10) | ((cut.data[i + 1]! >> 3) << 5) | (cut.data[i + 2]! >> 3); + binW[b]!++; + for (let j = 0; j < 3; j++) binSum[b * 3 + j]! += lab[p * 3 + j]!; + } + const used: number[] = []; + for (let b = 0; b < 32768; b++) if (binW[b]) used.push(b); + const pts = new Float32Array(used.length * 3), + wts = new Float32Array(used.length); + used.forEach((b, i) => { + wts[i] = binW[b]!; + for (let j = 0; j < 3; j++) pts[i * 3 + j] = binSum[b * 3 + j]! / binW[b]!; + }); + const { centers } = kmeans(pts, wts, colors), + K = centers.length / 3; + // a little more chroma: pixel art palettes read brighter than photos + for (let c = 0; c < K; c++) { + centers[c * 3 + 1]! *= 1.12; + centers[c * 3 + 2]! *= 1.12; + } + const nearest = (p: number) => { + let bk = 0, + bd = Infinity; + for (let c = 0; c < K; c++) { + const v = d3(lab, p * 3, centers, c * 3); + if (v < bd) { + bd = v; + bk = c; + } + } + return bk; + }; + + // each sprite cell at least 45% covered takes the palette color with the most pixels, boosted when its lightness + // stands out from the cell's (thin dark lines and bright eyes survive the shrink) + const cells = new Int32Array(ow * oh).fill(-1), + sx = box.width / ow, + sy = box.height / oh, + cnt = new Float64Array(K); + for (let y = 0; y < oh; y++) + for (let x = 0; x < ow; x++) { + cnt.fill(0); + let cover = 0, + tot = 0, + Lsum = 0; + const ya = box.y + Math.floor(y * sy), + yb = box.y + Math.max(Math.floor(y * sy) + 1, Math.floor((y + 1) * sy)), + xa = box.x + Math.floor(x * sx), + xb = box.x + Math.max(Math.floor(x * sx) + 1, Math.floor((x + 1) * sx)); + for (let yy = ya; yy < yb && yy <= y1; yy++) + for (let xx = xa; xx < xb && xx <= x1; xx++) { + const p = yy * W + xx; + tot++; + if (!solid(p)) continue; + cover++; + cnt[nearest(p)]!++; + Lsum += lab[p * 3]!; + } + if (!tot || cover / tot < 0.45) continue; + const Lm = Lsum / cover; + let bk = 0, + bs = -1; + for (let c = 0; c < K; c++) { + const v = cnt[c]! * (1 + 4 * Math.abs(centers[c * 3]! - Lm)); + if (v > bs) { + bs = v; + bk = c; + } + } + cells[y * ow + x] = bk; + } + + // cleanup: drop lone pixels, fill pinholes, and give a pixel unlike every neighbor their color unless its + // lightness stands out (eyes, highlights) + const at = (x: number, y: number) => (x < 0 || y < 0 || x >= ow || y >= oh ? -1 : cells[y * ow + x]!); + const fixed = Int32Array.from(cells); + for (let y = 0; y < oh; y++) + for (let x = 0; x < ow; x++) { + const c = at(x, y), + nb = [at(x - 1, y), at(x + 1, y), at(x, y - 1), at(x, y + 1)], + solidN = nb.filter((v) => v >= 0).length; + if (c >= 0 && solidN <= 1 && ow * oh > 1) fixed[y * ow + x] = -1; + else if (c < 0 && solidN >= 3) fixed[y * ow + x] = mode(nb.filter((v) => v >= 0)); + else if (c >= 0) { + const ring = [...nb, at(x - 1, y - 1), at(x + 1, y - 1), at(x - 1, y + 1), at(x + 1, y + 1)].filter( + (v) => v >= 0, + ); + if (!ring.length || ring.includes(c)) continue; + const m = mode(ring); + if (Math.abs(centers[c * 3]! - centers[m * 3]!) < 0.18) fixed[y * ow + x] = m; + } + } + if (!fixed.some((c) => c >= 0)) return null; + + // draw, with a one-pixel outline in a dark shade of the colors it borders (corners stay clear: a rounder outline) + const p0 = pad / 2, + OW = ow + pad, + OH = oh + pad, + out = image(OW, OH), + rgb = [...Array(K).keys()].map((c) => toRgb(centers[c * 3]!, centers[c * 3 + 1]!, centers[c * 3 + 2]!)), + get = (x: number, y: number) => (x < 0 || y < 0 || x >= ow || y >= oh ? -1 : fixed[y * ow + x]!); + for (let y = 0; y < OH; y++) + for (let x = 0; x < OW; x++) { + const o = (y * OW + x) * 4, + cx = x - p0, + cy = y - p0, + c = get(cx, cy); + if (c >= 0) { + out.data.set(rgb[c]!, o); + out.data[o + 3] = 255; + continue; + } + if (!edge) continue; + const nb = [get(cx - 1, cy), get(cx + 1, cy), get(cx, cy - 1), get(cx, cy + 1)].filter((v) => v >= 0); + if (!nb.length) continue; + let L = 0, + a = 0, + b = 0; + for (const v of nb) { + L += centers[v * 3]!; + a += centers[v * 3 + 1]!; + b += centers[v * 3 + 2]!; + } + out.data.set(toRgb(Math.min(0.22, (L / nb.length) * 0.3), (a / nb.length) * 0.5, (b / nb.length) * 0.5), o); + out.data[o + 3] = 255; + } + return enlarge(out, 2); } // ---- browser only below ---- diff --git a/tests/spritify.test.ts b/tests/spritify.test.ts index 2ac6733..256054c 100644 --- a/tests/spritify.test.ts +++ b/tests/spritify.test.ts @@ -1,19 +1,7 @@ // The browser's picture-to-sprite steps (src/spritify.ts) on plain images, and the server checks they must pass. import test from "node:test"; import assert from "node:assert/strict"; -import { - fitSize, - hasCutout, - removeBackground, - contentBox, - palette, - quantize, - outline, - enlarge, - spritify, - SPRITE_MAX, - OUTLINE, -} from "../src/spritify.ts"; +import { fitSize, hasCutout, removeBackground, contentBox, enlarge, spritify, SPRITE_MAX } from "../src/spritify.ts"; import { validateSprite, validateRedrawImage, jpegSize, spriteDay, SPRITE_LIMITS } from "../server/sprites.ts"; import type { Img } from "../src/spritify.ts"; import { makePng, b64 } from "./upload-helpers.ts"; @@ -46,7 +34,7 @@ test("spritify: sizes, cut-outs and the background fill", () => { const cut = removeBackground(img); assert.equal(at(cut, 0, 0)[3], 0); assert.equal(at(cut, 150, 100)[3], 255); - assert.deepEqual(contentBox(cut), { x: 91, y: 41, width: 119, height: 119 }); + assert.deepEqual(contentBox(cut), { x: 92, y: 41, width: 118, height: 120 }); // A picture that is already cut out is left alone. const png = picture(20, 20, (x) => (x > 5 && x < 15 ? [0, 0, 0, 255] : [255, 255, 255, 0])); assert.equal(hasCutout(png), true); @@ -57,24 +45,48 @@ test("spritify: sizes, cut-outs and the background fill", () => { assert.equal(at(kept, 50, 0)[3], 255); assert.equal(at(kept, 10, 50)[3], 0); assert.equal(contentBox(picture(4, 4, () => [0, 0, 0, 0])), null); + // A scene with no clear subject (sky over hills) keeps the whole picture instead of a random piece of it. + const scene = picture(120, 80, (x, y) => + y < 24 + 6 * Math.sin(x / 9) ? [90 + y, 150 + y, 230, 255] : [40, 110 + ((x * 7 + y * 3) % 30), 50, 255], + ); + const whole = removeBackground(scene); + assert.equal(at(whole, 0, 0)[3], 255); + assert.equal(at(whole, 119, 79)[3], 255); + assert.deepEqual(contentBox(whole), { x: 0, y: 0, width: 120, height: 80 }); + // A bust under a sky with a pink cloud that touches only the top edge and the head: the cloud goes with the sky. + const bust = picture(120, 120, (x, y) => { + const head = (x - 60) ** 2 + (y - 45) ** 2 < 28 ** 2, + body = y > 70 && Math.abs(x - 60) < 40 + (y - 70) / 2, + cloud = y < 18 && x > 64 && x < 100 + 4 * Math.sin(y); + if (head) return [240, 170, 40, 255]; + if (body) return [30, 30, 60, 255]; + return cloud ? [230, 120, 200, 255] : [80, 110, 200 + (y % 7), 255]; + }); + const cutBust = removeBackground(bust); + assert.deepEqual( + [at(cutBust, 85, 5)[3], at(cutBust, 5, 5)[3], at(cutBust, 60, 45)[3], at(cutBust, 60, 110)[3]], + [0, 0, 255, 255], + ); }); -test("spritify: palette, outline and pixel doubling", () => { - const stripes = picture(16, 4, (x) => [x * 16, 255 - x * 16, 0, 255]); - assert.ok(palette(stripes, 4).length <= 4); - const q = quantize(stripes, 4), - colors = new Set(); - for (let i = 0; i < q.data.length; i += 4) colors.add(q.data.slice(i, i + 3).join()); - assert.ok(colors.size <= 4); - const dot = picture(1, 1, () => [200, 0, 0, 255]), - ring = outline(dot); - assert.deepEqual([ring.width, ring.height], [3, 3]); - assert.deepEqual(at(ring, 1, 1), [200, 0, 0, 255]); - assert.deepEqual(at(ring, 1, 0), [...OUTLINE, 255]); - assert.equal(at(ring, 0, 0)[3], 0); // corners stay clear: a rounder outline - const big = enlarge(ring, 2); - assert.deepEqual([big.width, big.height], [6, 6]); - assert.deepEqual(at(big, 3, 3), [200, 0, 0, 255]); +test("spritify: shaded outline and pixel doubling", () => { + // One red pixel: a dark red outline around it, corners clear (a rounder outline), then every pixel doubled. + const dot = must( + spritify( + picture(1, 1, () => [200, 0, 0, 255]), + { removeBg: false }, + ), + ); + assert.deepEqual([dot.width, dot.height], [6, 6]); + assert.deepEqual(at(dot, 2, 2), [209, 0, 0, 255]); + assert.deepEqual(at(dot, 2, 0), [47, 0, 0, 255]); + assert.equal(at(dot, 0, 0)[3], 0); + const big = enlarge( + picture(1, 1, () => [1, 2, 3, 255]), + 2, + ); + assert.deepEqual([big.width, big.height], [2, 2]); + assert.deepEqual(at(big, 1, 1), [1, 2, 3, 255]); }); test("spritify: a photo becomes a small hard-edged sprite inside 128 px that the server accepts", () => {