Spaces:
Running
Running
File size: 8,006 Bytes
4635d45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | // 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];
}
|