From 88e18670f9d955e4b96ac9d9986310b5a62d617d Mon Sep 17 00:00:00 2001 From: nesszerra <90105158+Finesssee@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:14:51 +0700 Subject: [PATCH] Sprite tool: drop sky stuck to the subject, build the sprite in a worker The subject cut could keep a cloud that touches the top or side edge, plus look-alike patches beside the head. dropEdgeLeak removes them unless that would take more than 35% of the subject. Building the sprite froze the page for up to a second; it now runs in a Web Worker and only the latest build is shown. --- docs/CONTRACTS.md | 7 +++-- src/autosprite.ts | 65 +++++++++++++++++++++++++++++++++++++++++- src/sprite-maker.ts | 38 ++++++++++++++++++++---- src/sprite-worker.ts | 12 ++++++++ tests/spritify.test.ts | 38 ++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 src/sprite-worker.ts diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 7d59a06..4e199e4 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -415,9 +415,12 @@ 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 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 + 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; sky the cut left stuck to the subject, such as a cloud + touching the top or side edge and the matching patches cut off beside the head, is dropped unless that would take more + than 35% of the subject), 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). + through `/api/sprite/:channel/redraw` first. The steps run in a Web Worker (src/sprite-worker.ts), so the page + stays responsive while a picture is turned into a sprite; only the latest build is shown. 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"`, `source:"viewer"`, `license:"Uploaded by "` and carry `owner`. Only the owner may wear one; the page lists only diff --git a/src/autosprite.ts b/src/autosprite.ts index 6ac7b43..14d8da1 100644 --- a/src/autosprite.ts +++ b/src/autosprite.ts @@ -7,6 +7,13 @@ import type { Img } from "./spritify.ts"; const SEG_MAX = 160; +// dropEdgeLeak: Lab distance to the edge colors, the share of max(w, h) that counts, the shortest edge run, the most +// of the subject it may take, the share of a split-off patch that must border the background +const LEAK_TAU = 0.06, + LEAK_MIN = 0.05, + LEAK_RUN = 3, + LEAK_MAX = 0.35, + LEAK_RIM = 0.3; // ---------- 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); @@ -592,6 +599,7 @@ export function subjectMask(img: Img): Subject { // 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++) { @@ -621,5 +629,60 @@ export function subjectMask(img: Img): Subject { 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 }; + if (whole) return { mask: new Uint8Array(img.width * img.height).fill(1), whole }; + dropEdgeLeak(fg, lab, w, h); + return { mask: up(fg), whole }; +} + +// Background the cut kept stuck to the subject (a cloud touching the head). Foreground running along the top edge +// or a side's upper part is background, so grow from those runs through pixels close to their colors; the subject's +// own colors stop the growth. A bust is cut off at the bottom, so that edge never seeds. Patches of the same color +// that the subject splits off (cloud on both sides of a head) go too when they sit no lower and are thin enough that +// much of them borders the background. Skipped when it would take a big share of the subject (the subject itself +// runs off the edge, like ice around a swimming duck). +export function dropEdgeLeak(fg: Uint8Array, lab: Float32Array, w: number, h: number) { + const n = w * h, + side = [...Array(Math.ceil(0.6 * h)).keys()].map((y) => y * w), + edge: number[] = []; + for (const line of [[...Array(w).keys()], side, side.map((p) => p + w - 1)]) + for (let i = 0; i < line.length;) { + let j = i; + while (j < line.length && fg[line[j]!]) j++; + if (j - i >= LEAK_RUN) edge.push(...line.slice(i, j)); + i = j + 1; + } + if (edge.length < LEAK_MIN * Math.max(w, h)) return; + const near = (p: number) => edge.some((q) => d3(lab, p * 3, lab, q * 3) < LEAK_TAU), + around = (p: number) => + [p % w ? p - 1 : -1, p % w < w - 1 ? p + 1 : -1, p - w, p + w].filter((r) => r >= 0 && r < n), + leak = new Uint8Array(n), + todo = [...edge]; + for (const p of edge) leak[p] = 1; + let low = 0; + while (todo.length) { + const p = todo.pop()!; // the loop runs while todo has items + low = Math.max(low, Math.floor(p / w)); + for (const r of around(p)) + if (fg[r] && !leak[r] && near(r)) { + leak[r] = 1; + todo.push(r); + } + } + const { id, sizes, boxes } = components( + fg.map((v, p) => (v && !leak[p] && near(p) ? 1 : 0)), + w, + h, + 1, + ), + rim = sizes.map(() => 0); + for (let p = 0; p < n; p++) if (id[p]! >= 0 && around(p).some((r) => !fg[r])) rim[id[p]!]!++; + for (let p = 0; p < n; p++) { + const c = id[p]!; + if (c >= 0 && sizes[c]! >= LEAK_MIN * Math.max(w, h) && boxes[c]![3] <= low && rim[c]! >= LEAK_RIM * sizes[c]!) + leak[p] = 1; + } + const area = (m: Uint8Array) => m.reduce((s, v) => s + v, 0), + kept = fg.map((v, p) => (leak[p] ? 0 : v)); + keepMain(kept, w, h); + if (area(kept) >= (1 - LEAK_MAX) * area(fg)) fg.set(kept); } diff --git a/src/sprite-maker.ts b/src/sprite-maker.ts index 54586d4..f5c5de9 100644 --- a/src/sprite-maker.ts +++ b/src/sprite-maker.ts @@ -1,8 +1,9 @@ // /play/ "Your own sprite": a picture becomes a pixel sprite in the browser (src/spritify.ts), optionally redrawn by // Workers AI first, and is sent to the mods (server/sprites.ts). Once approved it is in the viewer's character list. import { api, errorText, h, $, setStatus, CHANNEL } from "./ui.ts"; -import { decodeFile, decodeBase64, encode, fitSize, paint, spritify, REDRAW_MAX } from "./spritify.ts"; +import { decodeFile, decodeBase64, encode, fitSize, paint, REDRAW_MAX } from "./spritify.ts"; import type { Img } from "./spritify.ts"; +import type { SpriteDone, SpriteJob } from "./sprite-worker.ts"; // element types from the page HTML const box = $("#sprite-maker") as HTMLFieldSetElement, @@ -63,16 +64,43 @@ function render() { : ""; } -function rebuild() { - const from = s.redrawn || s.source; - s.sprite = from ? spritify(from, { removeBg: Boolean(s.redrawn) || bgInput.checked }) : null; - if (from && !s.sprite) +// Finding the subject can take a second, so the sprite is built in a worker and the page stays responsive. A newer +// build replaces an older one still running: only the answer to the latest job is used. +const worker = new Worker(new URL("./sprite-worker.ts", import.meta.url), { type: "module" }); +let job = 0; +worker.onmessage = ({ data }: MessageEvent) => { + if (data.id !== job) return; + s.busy = false; + s.sprite = data.sprite; + if (!s.sprite) setStatus( status, "Nothing was left after removing the background. Untick Remove the background, or try another picture.", "warning", ); render(); +}; +worker.onerror = () => { + job++; + s.busy = false; + setStatus(status, "The sprite couldn't be made. Reload the page and try again.", "error"); + render(); +}; + +function rebuild() { + const from = s.redrawn || s.source, + id = ++job; + s.sprite = null; + s.busy = Boolean(from); + if (from) { + const { width, height, data } = from; + worker.postMessage({ + id, + img: { width, height, data }, + removeBg: Boolean(s.redrawn) || bgInput.checked, + } satisfies SpriteJob); + } + render(); } function renderCurrent() { diff --git a/src/sprite-worker.ts b/src/sprite-worker.ts new file mode 100644 index 0000000..b59cbfb --- /dev/null +++ b/src/sprite-worker.ts @@ -0,0 +1,12 @@ +// Builds the sprite off the page's main thread for src/sprite-maker.ts: finding the subject takes up to a second, +// which froze the fighter page. +import { spritify } from "./spritify.ts"; +import type { Img } from "./spritify.ts"; + +export type SpriteJob = { id: number; img: Img; removeBg: boolean }; +export type SpriteDone = { id: number; sprite: Img | null }; + +// as: this file runs as a dedicated worker, whose postMessage takes no target origin (tsconfig.web.json has DOM types) +const scope = self as unknown as Worker; +scope.onmessage = ({ data }: MessageEvent) => + scope.postMessage({ id: data.id, sprite: spritify(data.img, { removeBg: data.removeBg }) } satisfies SpriteDone); diff --git a/tests/spritify.test.ts b/tests/spritify.test.ts index 256054c..10e2745 100644 --- a/tests/spritify.test.ts +++ b/tests/spritify.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; 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 { dropEdgeLeak, labOf } from "../src/autosprite.ts"; import { makePng, b64 } from "./upload-helpers.ts"; import { must } from "./fakes.ts"; @@ -69,6 +70,43 @@ test("spritify: sizes, cut-outs and the background fill", () => { ); }); +test("autosprite: background the cut kept stuck to the subject goes, the subject stays", () => { + // A cut that kept a pink cloud joined to the head and running off the top edge, plus a piece of the same cloud split + // off on the head's other side. + const shape = (x: number, y: number) => + (x - 55) ** 2 + (y - 50) ** 2 < 26 ** 2 + ? "head" + : y > 72 && Math.abs(x - 55) < 38 + (y - 72) / 2 + ? "body" + : (y < 30 && x > 62 && x < 110 && y > (x - 62) / 4 - 6) || (y > 20 && y < 28 && x > 14 && x < 46) + ? "cloud" + : "sky"; + const color = { + head: [240, 170, 40, 255], + body: [30, 30, 60, 255], + cloud: [235, 150, 205, 255], + sky: [80, 110, 210, 255], + }; + const img = picture(120, 120, (x, y) => color[shape(x, y)]); + const fg = new Uint8Array(120 * 120).map((_, p) => (shape(p % 120, Math.floor(p / 120)) === "sky" ? 0 : 1)); + dropEdgeLeak(fg, labOf(img), 120, 120); + const kept = (x: number, y: number) => fg[y * 120 + x]; + assert.deepEqual( + [kept(95, 3), kept(70, 20), kept(20, 24), kept(55, 50), kept(30, 50), kept(55, 110)], + [0, 0, 0, 1, 1, 1], + ); + // A subject that runs off the edge itself (a duck in ice) is left alone: taking the ice would take too much. + const ice = picture(60, 60, (x, y) => + Math.abs(x - 30) < 10 && y > 10 && y < 30 ? [240, 170, 40, 255] : [235, 240, 250, 255], + ); + const all = new Uint8Array(60 * 60).fill(1); + dropEdgeLeak(all, labOf(ice), 60, 60); + assert.equal( + all.reduce((s, v) => s + v, 0), + 3600, + ); +}); + 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(