| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>OCR Math Captcha</title> |
| <style> |
| :root { --bg:#0f1117; --fg:#e6e8ee; --dim:#8b93a7; --acc:#7c8cff; --line:#242838; } |
| @media (prefers-color-scheme: light) { |
| :root { --bg:#fbfbfd; --fg:#1a1c24; --dim:#666e85; --acc:#4a5bd4; --line:#e3e5ee; } |
| } |
| * { box-sizing:border-box; } |
| body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg); |
| font:16px/1.55 ui-sans-serif,system-ui,-apple-system,sans-serif; } |
| main { max-width:640px; margin:0 auto; } |
| h1 { font-size:1.5rem; margin:0 0 .35rem; letter-spacing:-.02em; } |
| p.sub { color:var(--dim); margin:0 0 1.75rem; font-size:.925rem; } |
| a { color:var(--acc); } |
| #drop { border:1.5px dashed var(--line); border-radius:12px; padding:2rem 1rem; |
| text-align:center; cursor:pointer; transition:border-color .15s, background .15s; } |
| #drop:hover, #drop.over { border-color:var(--acc); background:color-mix(in srgb, var(--acc) 6%, transparent); } |
| #drop p { margin:0; color:var(--dim); font-size:.9rem; } |
| #preview { max-width:100%; margin:1.25rem 0 0; border-radius:8px; image-rendering:pixelated; |
| border:1px solid var(--line); display:none; } |
| .out { margin-top:1.5rem; display:grid; grid-template-columns:1fr 1fr; gap:.85rem; } |
| .card { border:1px solid var(--line); border-radius:10px; padding:.85rem 1rem; } |
| .card span { display:block; font-size:.72rem; text-transform:uppercase; |
| letter-spacing:.08em; color:var(--dim); margin-bottom:.3rem; } |
| .card strong { font:1.4rem/1.2 ui-monospace,monospace; word-break:break-all; } |
| #status { margin-top:1.25rem; font-size:.875rem; color:var(--dim); min-height:1.4em; } |
| #samples { display:flex; gap:.6rem; flex-wrap:wrap; margin-top:1.5rem; } |
| #samples img { height:38px; border:1px solid var(--line); border-radius:6px; |
| cursor:pointer; background:#fff; padding:2px; } |
| #samples img:hover { border-color:var(--acc); } |
| footer { margin-top:2.5rem; padding-top:1.25rem; border-top:1px solid var(--line); |
| color:var(--dim); font-size:.82rem; } |
| .row { display:flex; align-items:center; gap:.6rem; margin-top:1.25rem; |
| font-size:.85rem; color:var(--dim); } |
| select { background:transparent; color:var(--fg); border:1px solid var(--line); |
| border-radius:6px; padding:.3rem .5rem; font-size:.85rem; } |
| </style> |
| </head> |
| <body> |
| <main> |
| <h1>🔢 OCR Math Captcha</h1> |
| <p class="sub"> |
| A TrOCR fine-tune that reads short arithmetic captchas like <code>26+7=?</code>, |
| then evaluates what it read. Runs entirely in your browser via |
| transformers.js — no server, nothing uploaded. |
| Model: <a id="modellink" href="#">loading…</a> |
| </p> |
|
|
| <div id="drop"> |
| <p><strong>Drop a captcha image here</strong><br>or click to choose a file</p> |
| <input type="file" id="file" accept="image/*" hidden /> |
| </div> |
|
|
| <div id="samples"></div> |
|
|
| <img id="preview" alt="selected captcha" /> |
|
|
| <div class="row"> |
| <label for="dtype">Weights</label> |
| <select id="dtype"> |
| <option value="q8">quantized int8 (smaller, faster)</option> |
| <option value="fp32">fp32 (matches PyTorch exactly)</option> |
| </select> |
| </div> |
|
|
| <div class="out"> |
| <div class="card"><span>Transcribed</span><strong id="expr">—</strong></div> |
| <div class="card"><span>Evaluates to</span><strong id="answer">—</strong></div> |
| </div> |
|
|
| <div id="status">Model loads on first use (one-time download, then cached).</div> |
|
|
| <footer> |
| <strong>Limitations.</strong> Training data is primarily synthetic; unseen fonts |
| and layouts reduce accuracy. Built for short expressions at 130×30, not |
| document OCR. Only use it on images you are authorized to process. |
| </footer> |
| </main> |
|
|
| <script type="module"> |
| import { pipeline, env } from |
| "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.7.5"; |
| |
| const MODEL_ID = "arkhabbazan/ocr-math-captcha"; |
| env.allowLocalModels = false; |
| |
| const $ = (id) => document.getElementById(id); |
| $("modellink").textContent = MODEL_ID; |
| $("modellink").href = `https://huggingface.co/${MODEL_ID}`; |
| |
| |
| function solve(text) { |
| let s = text.split("=")[0].trim() |
| .replace(/[×xX*]/g, "*").replace(/[÷/]/g, "/").replace(/[−–—]/g, "-"); |
| if (!s || !/^[0-9+\-*/().\s]+$/.test(s)) return null; |
| |
| const tokens = s.match(/\d+\.?\d*|[+\-*/()]/g); |
| if (!tokens) return null; |
| |
| const prec = { "+": 1, "-": 1, "*": 2, "/": 2 }; |
| const out = [], ops = []; |
| let prev = null; |
| for (const t of tokens) { |
| if (/^\d/.test(t)) { out.push(parseFloat(t)); } |
| else if (t === "(") { ops.push(t); } |
| else if (t === ")") { |
| while (ops.length && ops.at(-1) !== "(") out.push(ops.pop()); |
| if (!ops.length) return null; |
| ops.pop(); |
| } else { |
| |
| if ((t === "-" || t === "+") && (prev === null || prev in prec || prev === "(")) { |
| out.push(0); |
| } |
| while (ops.length && ops.at(-1) !== "(" && prec[ops.at(-1)] >= prec[t]) { |
| out.push(ops.pop()); |
| } |
| ops.push(t); |
| } |
| prev = t; |
| } |
| while (ops.length) { const o = ops.pop(); if (o === "(") return null; out.push(o); } |
| |
| const st = []; |
| for (const t of out) { |
| if (typeof t === "number") { st.push(t); continue; } |
| const b = st.pop(), a = st.pop(); |
| if (a === undefined || b === undefined) return null; |
| if (t === "/" && b === 0) return null; |
| st.push(t === "+" ? a + b : t === "-" ? a - b : t === "*" ? a * b : a / b); |
| } |
| if (st.length !== 1 || !isFinite(st[0])) return null; |
| return Number.isInteger(st[0]) ? st[0] : Math.round(st[0] * 1e4) / 1e4; |
| } |
| |
| |
| let readerPromise = null, loadedDtype = null; |
| |
| function getReader(dtype) { |
| if (readerPromise && loadedDtype === dtype) return readerPromise; |
| loadedDtype = dtype; |
| $("status").textContent = `Downloading ${dtype} weights…`; |
| readerPromise = pipeline("image-to-text", MODEL_ID, { |
| dtype, |
| progress_callback: (p) => { |
| if (p.status === "progress" && p.total) { |
| const pct = ((p.loaded / p.total) * 100).toFixed(0); |
| $("status").textContent = `Downloading ${p.file} — ${pct}%`; |
| } |
| }, |
| }).catch((e) => { |
| readerPromise = null; |
| throw e; |
| }); |
| return readerPromise; |
| } |
| |
| async function run(src) { |
| $("preview").src = src; |
| $("preview").style.display = "block"; |
| $("expr").textContent = "…"; |
| $("answer").textContent = "…"; |
| try { |
| const reader = await getReader($("dtype").value); |
| $("status").textContent = "Reading…"; |
| const t0 = performance.now(); |
| const out = await reader(src, { num_beams: 4, max_new_tokens: 32 }); |
| const raw = (out?.[0]?.generated_text ?? "").replace(/\s+/g, ""); |
| const val = solve(raw); |
| $("expr").textContent = raw || "(empty)"; |
| $("answer").textContent = val === null ? "— not arithmetic" : String(val); |
| $("status").textContent = `Done in ${((performance.now() - t0) / 1000).toFixed(2)}s`; |
| } catch (err) { |
| console.error(err); |
| $("expr").textContent = "error"; |
| $("answer").textContent = "—"; |
| $("status").textContent = `Failed: ${err.message}. See the browser console.`; |
| } |
| } |
| |
| |
| const drop = $("drop"), fileInput = $("file"); |
| drop.addEventListener("click", () => fileInput.click()); |
| fileInput.addEventListener("change", (e) => { |
| const f = e.target.files?.[0]; |
| if (f) run(URL.createObjectURL(f)); |
| }); |
| ["dragenter", "dragover"].forEach((ev) => |
| drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add("over"); }) |
| ); |
| ["dragleave", "drop"].forEach((ev) => |
| drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove("over"); }) |
| ); |
| drop.addEventListener("drop", (e) => { |
| const f = e.dataTransfer.files?.[0]; |
| if (f) run(URL.createObjectURL(f)); |
| }); |
| document.addEventListener("paste", (e) => { |
| const item = [...(e.clipboardData?.items ?? [])].find((i) => i.type.startsWith("image/")); |
| if (item) run(URL.createObjectURL(item.getAsFile())); |
| }); |
| $("dtype").addEventListener("change", () => { |
| if ($("preview").src) run($("preview").src); |
| }); |
| |
| |
| for (let i = 1; i <= 4; i++) { |
| const url = |
| `https://huggingface.co/${MODEL_ID}/resolve/main/assets/captcha-sample-${i}.png`; |
| const img = new Image(); |
| img.src = url; |
| img.alt = `sample ${i}`; |
| img.title = "Click to try"; |
| img.onclick = () => run(url); |
| img.onerror = () => img.remove(); |
| $("samples").appendChild(img); |
| } |
| </script> |
| </body> |
| </html> |
|
|