LiangLabUMB's picture
Upload 6 files
e5921f5 verified
Raw History Blame Contribute Delete
11.8 kB
// 4-corner crop canvas, one instance per <div class="cp-root" data-key="...">.
//
// All corner work happens in the browser: clicking, dragging, zoom and pan
// never call the server, so Gradio never swaps the image for a progress bar.
// The server is contacted only to load an image and, on request, to run grid
// auto-detection, whose proposal comes back as 4 ordinary draggable corners.
//
// Python <-> canvas:
// CP.load(payloadJSON) image or corner proposal for payload.key
// CP.get(key) JSON [[x,y] x4] in FULL-RES pixels, or "" if unset
// CP.seed(key) JSON [x,y] full-res auto-detect seed, or ""
(function () {
"use strict";
const CORNER_HIT_PX = 12; // screen px: grab radius for a handle
const DRAG_PX = 4; // movement beyond this turns a click into a pan
const MAX_ZOOM = 40;
const ST = {}; // key -> state; survives a re-mounted element
const PENDING = {}; // key -> payload that arrived before mounting
function state(key) {
if (!ST[key]) {
ST[key] = { key, root: null, cv: null, ctx: null, el: {},
img: null, w: 0, h: 0, scale: 1, corners: [],
view: { s: 1, tx: 0, ty: 0 }, fitted: false,
armed: false, seed: null, busy: false, auto: false,
down: null, drag: -1, panning: false, seq: null,
dirty: true };
}
return ST[key];
}
// ------------------------------------------------------------- mounting
function bind(root) {
const S = state(root.dataset.key);
if (S.root === root) return;
S.root = root;
S.cv = root.querySelector("canvas");
S.ctx = S.cv.getContext("2d");
for (const n of ["stage", "info", "auto", "reset", "fit", "empty"]) {
S.el[n] = root.querySelector(".cp-" + n);
}
const cv = S.cv;
cv.addEventListener("pointerdown", (e) => onDown(S, e));
cv.addEventListener("pointermove", (e) => onMove(S, e));
cv.addEventListener("pointerup", (e) => onUp(S, e));
cv.addEventListener("pointercancel", (e) => onUp(S, e));
cv.addEventListener("wheel", (e) => onWheel(S, e), { passive: false });
cv.addEventListener("contextmenu", (e) => e.preventDefault());
S.el.auto.addEventListener("click", () => arm(S));
S.el.reset.addEventListener("click", () => {
S.corners = []; S.auto = false; S.armed = false; ui(S); dirty(S);
});
S.el.fit.addEventListener("click", () => { fit(S); dirty(S); });
new ResizeObserver(() => { size(S); dirty(S); }).observe(S.el.stage);
size(S);
ui(S);
if (PENDING[S.key]) { const p = PENDING[S.key]; delete PENDING[S.key]; load(p); }
}
function scan() {
document.querySelectorAll(".cp-root").forEach(bind);
}
function size(S) {
const dpr = window.devicePixelRatio || 1;
const w = S.el.stage.clientWidth, h = S.el.stage.clientHeight;
if (!w || !h) return; // inside a closed accordion
S.cv.width = Math.round(w * dpr);
S.cv.height = Math.round(h * dpr);
if (!S.fitted && S.img) fit(S);
}
// ------------------------------------------------------------- loading
function loadImage(src) {
return new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im); im.onerror = rej; im.src = src;
});
}
async function load(payload) {
if (!payload) return;
let p;
try { p = typeof payload === "string" ? JSON.parse(payload) : payload; }
catch (e) { return; }
if (!p || !p.key) return;
const S = state(p.key);
if (p.seq && p.seq === S.seq) return; // same payload re-delivered
scan();
if (!S.root) { PENDING[p.key] = p; return; }
S.seq = p.seq;
if (p.mode === "image") {
S.img = p.img ? await loadImage(p.img) : null;
S.w = p.w || 0; S.h = p.h || 0; S.scale = p.scale || 1;
S.corners = []; S.auto = false; S.armed = false; S.busy = false;
S.fitted = false; fit(S);
} else if (p.mode === "corners") {
S.busy = false;
if (p.corners && p.corners.length === 4) {
S.corners = p.corners.map(([x, y]) => [x * S.scale, y * S.scale]);
S.auto = true;
}
S.msg = p.msg || "";
}
ui(S); dirty(S);
}
// ------------------------------------------------------------- view
function fit(S) {
const cw = S.el.stage.clientWidth, ch = S.el.stage.clientHeight;
if (!S.img || !cw || !ch) return;
const s = Math.min(cw / S.w, ch / S.h) * 0.97;
S.view = { s, tx: (cw - S.w * s) / 2, ty: (ch - S.h * s) / 2 };
S.fitted = true;
}
function pos(S, e) {
const r = S.cv.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
function toImg(S, sx, sy) {
return [(sx - S.view.tx) / S.view.s, (sy - S.view.ty) / S.view.s];
}
function toScr(S, ix, iy) {
return [ix * S.view.s + S.view.tx, iy * S.view.s + S.view.ty];
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function onWheel(S, e) {
if (!S.img) return;
e.preventDefault();
const [sx, sy] = pos(S, e);
const fitS = Math.min(S.el.stage.clientWidth / S.w,
S.el.stage.clientHeight / S.h);
const s = clamp(S.view.s * Math.pow(1.0015, -e.deltaY), fitS * 0.5, MAX_ZOOM);
const [ix, iy] = toImg(S, sx, sy);
S.view = { s, tx: sx - ix * s, ty: sy - iy * s };
dirty(S);
}
// ------------------------------------------------------------- pointer
function cornerAt(S, sx, sy) {
let best = -1, bd = CORNER_HIT_PX;
S.corners.forEach(([x, y], k) => {
const [px, py] = toScr(S, x, y);
const d = Math.hypot(px - sx, py - sy);
if (d <= bd) { bd = d; best = k; }
});
return best;
}
function onDown(S, e) {
if (!S.img) return;
S.cv.setPointerCapture(e.pointerId);
const [sx, sy] = pos(S, e);
S.down = { sx, sy, tx: S.view.tx, ty: S.view.ty, b: e.button };
S.panning = e.button !== 0;
S.drag = e.button === 0 && !S.armed ? cornerAt(S, sx, sy) : -1;
}
function onMove(S, e) {
const [sx, sy] = pos(S, e);
if (!S.down) {
const over = S.img && !S.armed && cornerAt(S, sx, sy) >= 0;
S.cv.style.cursor = over ? "grab" : "crosshair";
return;
}
const dx = sx - S.down.sx, dy = sy - S.down.sy;
if (S.drag >= 0) {
const [ix, iy] = toImg(S, sx, sy);
S.corners[S.drag] = [clamp(ix, 0, S.w - 1), clamp(iy, 0, S.h - 1)];
S.auto = false; S.msg = "";
S.cv.style.cursor = "grabbing";
ui(S); dirty(S);
return;
}
if (!S.panning && Math.hypot(dx, dy) > DRAG_PX) S.panning = true;
if (S.panning) {
S.view.tx = S.down.tx + dx; S.view.ty = S.down.ty + dy; dirty(S);
}
}
function onUp(S, e) {
if (!S.down) return;
const [sx, sy] = pos(S, e);
const click = !S.panning && S.drag < 0 && S.down.b === 0 &&
e.type !== "pointercancel";
S.down = null; S.panning = false; S.drag = -1;
if (!click) return;
const [ix, iy] = toImg(S, sx, sy);
if (ix < 0 || iy < 0 || ix >= S.w || iy >= S.h) return;
if (S.armed) { detect(S, ix, iy); return; }
if (S.corners.length >= 4) {
S.msg = "All 4 corners set — drag a handle to adjust, or Reset.";
ui(S); return;
}
S.corners.push([ix, iy]); S.auto = false; S.msg = "";
ui(S); dirty(S);
}
// ------------------------------------------------------------- auto-detect
function arm(S) {
if (!S.img || S.busy) return;
S.armed = !S.armed;
ui(S); dirty(S);
}
function detect(S, ix, iy) {
S.armed = false; S.busy = true;
S.seed = [ix / S.scale, iy / S.scale];
S.msg = "Detecting the grid…";
ui(S); dirty(S);
// Python runs on a hidden Gradio button; its js pre-step reads CP.seed.
let b = document.getElementById("cp-detect-" + S.key);
if (b && b.tagName !== "BUTTON") b = b.querySelector("button");
if (b) b.click();
else { S.busy = false; S.msg = "Auto-detect unavailable."; ui(S); }
}
// ------------------------------------------------------------- text
function ui(S) {
if (!S.root) return;
S.el.empty.style.display = S.img ? "none" : "";
S.el.auto.classList.toggle("cp-on", S.armed);
S.el.auto.disabled = !S.img || S.busy;
S.el.reset.disabled = !S.corners.length;
let t;
if (!S.img) t = "No image";
else if (S.busy) t = S.msg || "Detecting…";
else if (S.armed) t = "Click the CENTRE of the 4×4 block to auto-detect";
else if (S.corners.length < 4) {
t = S.corners.length + " / 4 corners — click corner " +
(S.corners.length + 1) + " (any order), or ⌖ Auto";
if (S.msg) t = S.msg + " · " + t;
} else {
t = (S.auto ? "✓ Auto-detected" : "✓ 4 corners set") +
" — drag a handle to adjust";
if (S.msg) t = S.msg;
}
S.el.info.textContent = t;
S.el.info.classList.toggle("cp-ok", !!S.img && S.corners.length === 4 && !S.busy);
}
// ------------------------------------------------------------- draw
function dirty(S) { S.dirty = true; }
function frame() {
requestAnimationFrame(frame);
for (const k in ST) {
const S = ST[k];
if (S.dirty && S.ctx) { S.dirty = false; draw(S); }
}
}
function ordered(c) {
if (c.length !== 4) return c;
const mx = c.reduce((a, p) => a + p[0], 0) / 4;
const my = c.reduce((a, p) => a + p[1], 0) / 4;
return c.slice().sort((a, b) => Math.atan2(a[1] - my, a[0] - mx) -
Math.atan2(b[1] - my, b[0] - mx));
}
function draw(S) {
const ctx = S.ctx, dpr = window.devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "#1b1d22";
ctx.fillRect(0, 0, S.cv.width, S.cv.height);
if (!S.img) return;
const { s, tx, ty } = S.view;
ctx.setTransform(dpr * s, 0, 0, dpr * s, dpr * tx, dpr * ty);
ctx.imageSmoothingEnabled = s < 2;
ctx.drawImage(S.img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pts = ordered(S.corners).map(([x, y]) => toScr(S, x, y));
const col = S.auto ? "#3ddc84" : "#4aaaff";
ctx.lineWidth = 2; ctx.strokeStyle = col;
if (pts.length >= 2) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (const p of pts.slice(1)) ctx.lineTo(p[0], p[1]);
if (pts.length === 4) {
ctx.closePath();
ctx.fillStyle = S.auto ? "rgba(61,220,132,0.12)" : "rgba(74,170,255,0.12)";
ctx.fill();
}
ctx.stroke();
}
for (const [x, y] of S.corners) {
const [px, py] = toScr(S, x, y);
ctx.beginPath(); ctx.arc(px, py, 9, 0, 2 * Math.PI);
ctx.fillStyle = col; ctx.globalAlpha = 0.85; ctx.fill(); ctx.globalAlpha = 1;
ctx.strokeStyle = "#fff"; ctx.stroke();
ctx.beginPath();
ctx.moveTo(px - 4, py); ctx.lineTo(px + 4, py);
ctx.moveTo(px, py - 4); ctx.lineTo(px, py + 4);
ctx.stroke();
}
if (S.seed && S.busy) {
const [px, py] = toScr(S, S.seed[0] * S.scale, S.seed[1] * S.scale);
ctx.strokeStyle = "#ffcc00"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(px, py, 12, 0, 2 * Math.PI); ctx.stroke();
}
}
// ------------------------------------------------------------- API
function get(key) {
const S = ST[key];
if (!S || !S.img || S.corners.length !== 4) return "";
return JSON.stringify(S.corners.map(([x, y]) => [x / S.scale, y / S.scale]));
}
function seed(key) {
const S = ST[key];
return S && S.seed ? JSON.stringify(S.seed) : "";
}
function fail(key, msg) { // detection finished without corners
const S = ST[key];
if (S) { S.busy = false; S.msg = msg || ""; ui(S); dirty(S); }
}
window.CP = { load, get, seed, fail, _st: ST };
setInterval(scan, 300);
requestAnimationFrame(frame);
})();