Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<channel>/<id>`, cached for a day (`immutable`). Entries are `mode:"single"`, `combatFallback:"effects"`,
`source:"viewer"`, `license:"Uploaded by <name>"` and carry `owner`. Only the owner may wear one; the page lists only
Expand Down
65 changes: 64 additions & 1 deletion src/autosprite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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);
}
38 changes: 33 additions & 5 deletions src/sprite-maker.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<SpriteDone>) => {
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() {
Expand Down
12 changes: 12 additions & 0 deletions src/sprite-worker.ts
Original file line number Diff line number Diff line change
@@ -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<SpriteJob>) =>
scope.postMessage({ id: data.id, sprite: spritify(data.img, { removeBg: data.removeBg }) } satisfies SpriteDone);
38 changes: 38 additions & 0 deletions tests/spritify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down
Loading