Spaces:
Running
Running
| // Port of the pixel primitives in app/onboard.py (fit_512, inpaint_region, | |
| // _ink_color, snap_to_ink). Pixel work goes through ImageData typed arrays; | |
| // canvas API is used only for whole-image ops (drawImage scaling), never in | |
| // per-pixel loops. Shared low-level helpers are exported for onboard.js/derive.js. | |
| import { CANVAS } from "./config.js?v=20260729.6"; | |
| export function newCanvas(w, h) { | |
| const c = document.createElement("canvas"); | |
| c.width = w; | |
| c.height = h; | |
| return c; | |
| } | |
| export function getData(canvas) { | |
| return canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height); | |
| } | |
| /** Wrap a Uint8ClampedArray RGBA buffer as a canvas. */ | |
| /** [x0,y0,x1,y1] with x1,y1 exclusive (max+1), or null when fully transparent. */ | |
| export function bboxAlpha(imgData) { | |
| const { data, width: w, height: h } = imgData; | |
| let x0 = w, y0 = h, x1 = -1, y1 = -1; | |
| for (let y = 0; y < h; y++) { | |
| for (let x = 0; x < w; x++) { | |
| if (data[(y * w + x) * 4 + 3] > 0) { | |
| if (x < x0) x0 = x; | |
| if (x > x1) x1 = x; | |
| if (y < y0) y0 = y; | |
| if (y > y1) y1 = y; | |
| } | |
| } | |
| } | |
| return x1 < 0 ? null : [x0, y0, x1 + 1, y1 + 1]; | |
| } | |
| export function canvasFromData(buf, w, h) { | |
| const c = newCanvas(w, h); | |
| c.getContext("2d").putImageData(new ImageData(buf, w, h), 0, 0); | |
| return c; | |
| } | |
| export function hexToRgb(hex) { | |
| const h = hex.replace(/^#/, ""); | |
| return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; | |
| } | |
| function rgbToHex(r, g, b) { | |
| const h = (v) => v.toString(16).padStart(2, "0"); | |
| return `#${h(r)}${h(g)}${h(b)}`; | |
| } | |
| export const rad = (deg) => (deg * Math.PI) / 180; | |
| /** Round half-to-even, matching python's built-in round() (Math.round is half-up). */ | |
| export function bankersRound(v) { | |
| const r = Math.round(v); | |
| return Math.abs(v - Math.trunc(v)) === 0.5 && r % 2 !== 0 ? r - 1 : r; | |
| } | |
| /** Median floored to int — matches python int(statistics.median(...)). */ | |
| export function median(arr) { | |
| const n = arr.length; | |
| if (!n) return 0; | |
| const a = [...arr].sort((p, q) => p - q); | |
| const m = n >> 1; | |
| const v = n % 2 ? a[m] : (a[m - 1] + a[m]) / 2; | |
| return Math.floor(v); | |
| } | |
| function edgeMedian(canvas) { | |
| const { data, width: w, height: h } = getData(canvas); | |
| const R = [], G = [], B = []; | |
| const push = (x, y) => { const i = (y * w + x) * 4; R.push(data[i]); G.push(data[i + 1]); B.push(data[i + 2]); }; | |
| for (let x = 0; x < w; x++) for (const y of [0, 1, h - 2, h - 1]) push(x, y); | |
| for (let y = 0; y < h; y++) for (const x of [0, 1, w - 2, w - 1]) push(x, y); | |
| return [median(R), median(G), median(B)]; | |
| } | |
| /** Port of fit_512: fit + center onto a 512 canvas, padded with the source's border-median color. */ | |
| export function fitTo(imgSource, size) { | |
| const sw = imgSource.naturalWidth || imgSource.width; | |
| const sh = imgSource.naturalHeight || imgSource.height; | |
| const src = newCanvas(sw, sh); | |
| src.getContext("2d").drawImage(imgSource, 0, 0); | |
| const [br, bg, bb] = edgeMedian(src); | |
| const s = Math.min(size / sw, size / sh); | |
| const w = Math.floor(sw * s), h = Math.floor(sh * s); | |
| const out = newCanvas(size, size); | |
| const ctx = out.getContext("2d"); | |
| ctx.fillStyle = `rgb(${br},${bg},${bb})`; | |
| ctx.fillRect(0, 0, size, size); | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = "high"; | |
| ctx.drawImage(src, 0, 0, sw, sh, Math.floor((size - w) / 2), Math.floor((size - h) / 2), w, h); | |
| return out; | |
| } | |
| export function fit512(imgSource) { | |
| return fitTo(imgSource, CANVAS); | |
| } | |
| /** Port of snap_to_ink: centroid of ink pixels (r+g+b < 300) within ±r box. */ | |
| export function snapToInk(canvas, cx, cy, r) { | |
| const { data, width: w, height: h } = getData(canvas); | |
| const x0 = Math.max(0, cx - r), y0 = Math.max(0, cy - r); | |
| const x1 = Math.min(w, cx + r), y1 = Math.min(h, cy + r); | |
| let sx = 0, sy = 0, n = 0; | |
| for (let y = y0; y < y1; y++) { | |
| for (let x = x0; x < x1; x++) { | |
| const i = (y * w + x) * 4; | |
| if (data[i] + data[i + 1] + data[i + 2] < 300) { sx += x - x0; sy += y - y0; n++; } | |
| } | |
| } | |
| if (!n) return [cx, cy]; | |
| return [x0 + Math.floor(sx / n), y0 + Math.floor(sy / n)]; | |
| } | |
| /** Port of inpaint_region: erase EXACTLY the box in-place with the surrounding | |
| * skin tone (median of non-ink ring pixels). Fills only the box — the open-eye | |
| * sprite covers the same box, so neutral stays pixel-perfect. Mutates canvas. */ | |
| export function inpaintRegion(canvas, box, ring = 6) { | |
| const ctx = canvas.getContext("2d"); | |
| const w = canvas.width, h = canvas.height; | |
| const img = ctx.getImageData(0, 0, w, h); | |
| const d = img.data; | |
| const [x0, y0, x1, y1] = box.map((v) => Math.round(v)); | |
| // skin = median of non-ink ring pixels (median, not mean: robust to the few | |
| // feature-edge pixels — e.g. lip red — that survive the ink cut) | |
| const R = [], G = [], B = [], AR = [], AG = [], AB = []; | |
| for (let y = Math.max(0, y0 - ring); y < Math.min(h, y1 + ring); y++) { | |
| for (let x = Math.max(0, x0 - ring); x < Math.min(w, x1 + ring); x++) { | |
| const i = (y * w + x) * 4; | |
| AR.push(d[i]); AG.push(d[i + 1]); AB.push(d[i + 2]); | |
| if (d[i] + d[i + 1] + d[i + 2] > 300) { R.push(d[i]); G.push(d[i + 1]); B.push(d[i + 2]); } | |
| } | |
| } | |
| const skin = R.length ? [median(R), median(G), median(B)] : [median(AR), median(AG), median(AB)]; | |
| for (let y = Math.max(0, y0); y < Math.min(h, y1); y++) { | |
| for (let x = Math.max(0, x0); x < Math.min(w, x1); x++) { | |
| const i = (y * w + x) * 4; | |
| d[i] = skin[0]; d[i + 1] = skin[1]; d[i + 2] = skin[2]; | |
| } | |
| } | |
| ctx.putImageData(img, 0, 0); | |
| } | |
| /** Port of _ink_color: median of dark (r+g+b < 300) pixels in box, else #1a1a1a. */ | |
| export function inkColor(canvas, box) { | |
| const { data, width: w, height: h } = getData(canvas); | |
| const x0 = Math.max(0, box[0]), y0 = Math.max(0, box[1]); | |
| const x1 = Math.min(w, box[2]), y1 = Math.min(h, box[3]); | |
| const R = [], G = [], B = []; | |
| for (let y = y0; y < y1; y++) { | |
| for (let x = x0; x < x1; x++) { | |
| const i = (y * w + x) * 4; | |
| if (data[i] + data[i + 1] + data[i + 2] < 300) { R.push(data[i]); G.push(data[i + 1]); B.push(data[i + 2]); } | |
| } | |
| } | |
| if (!R.length) return "#1a1a1a"; | |
| return rgbToHex(median(R), median(G), median(B)); | |
| } | |
| // Grow a mouth box until it covers the whole connected ink blob (deep open | |
| // mouths drawn with an interior otherwise get erased only partially and the | |
| // leftover reads as a "second mouth"). Growth is capped so a mouth stroke | |
| // touching the face outline cannot swallow the face. | |
| export function expandBoxToInk(canvas, box, pad = 4, capScale = 2.5, maxIter = 8) { | |
| const { data, width, height } = getData(canvas); | |
| const ink = (x, y) => { | |
| const i = (y * width + x) * 4; | |
| return data[i] + data[i + 1] + data[i + 2] < 300; | |
| }; | |
| let [x0, y0, x1, y1] = box.map(Math.round); | |
| const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; | |
| const maxW = Math.max(24, (x1 - x0) * capScale), maxH = Math.max(24, (y1 - y0) * capScale); | |
| const bound = [cx - maxW / 2, cy - maxH / 2, cx + maxW / 2, cy + maxH / 2].map(Math.round); | |
| for (let it = 0; it < maxIter; it++) { | |
| let nx0 = Infinity, ny0 = Infinity, nx1 = -Infinity, ny1 = -Infinity; | |
| const sx = Math.max(0, Math.max(bound[0], x0 - pad)); | |
| const sy = Math.max(0, Math.max(bound[1], y0 - pad)); | |
| const ex = Math.min(width - 1, Math.min(bound[2], x1 + pad)); | |
| const ey = Math.min(height - 1, Math.min(bound[3], y1 + pad)); | |
| for (let y = sy; y <= ey; y++) { | |
| for (let x = sx; x <= ex; x++) { | |
| if (ink(x, y)) { | |
| if (x < nx0) nx0 = x; | |
| if (y < ny0) ny0 = y; | |
| if (x > nx1) nx1 = x; | |
| if (y > ny1) ny1 = y; | |
| } | |
| } | |
| } | |
| if (nx0 === Infinity) break; // no ink at all — keep the clicked box | |
| const grown = [Math.min(nx0, x0), Math.min(ny0, y0), Math.max(nx1, x1), Math.max(ny1, y1)]; | |
| if (grown[0] === x0 && grown[1] === y0 && grown[2] === x1 && grown[3] === y1) break; | |
| [x0, y0, x1, y1] = grown; | |
| } | |
| return [x0 - 2, y0 - 2, x1 + 2, y1 + 2]; | |
| } | |