// 4-corner crop canvas, one instance per
. // // 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); })();