ocr-math-captcha-web / index.html
arkhabbazan's picture
Upload folder using huggingface_hub
b63faf1 verified
Raw
History Blame Contribute Delete
8.68 kB
<!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}`;
// ---- safe arithmetic: tokenise, shunting-yard, evaluate. Never eval(). ----
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 {
// unary minus / plus at start or after another operator
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;
}
// ---- model ----
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.`;
}
}
// ---- input wiring ----
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);
});
// ---- sample images from the model repo ----
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>