From 74b88775508983fa12387fd7321b6cc01704d3d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro=20Battistella=20Nadas?= Date: Tue, 9 Jun 2026 19:02:51 +0200 Subject: [PATCH] feat(ocr): crop + rotate modal before recognition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After picking an image, a modal opens with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. Cropping to just the text region is the single biggest quality win for OCR on phone photos — strips out logos, lighting transitions, and background junk that bleed past the confidence filter. - Lazy-loads cropperjs + its CSS from esm.sh on first use (no bundling; same pattern as Tesseract.js and CodeMirror). - Honours EXIF orientation from phone shots (checkOrientation). - Keyboard: Esc cancels, Enter accepts. Clicking the backdrop also cancels. - Output goes through the existing preprocessForOCR pipeline (downscale to 1600px + Bradley adaptive threshold) before Tesseract sees it. --- CHANGELOG.md | 1 + static/index.html | 179 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edac168..57c9eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Images are downscaled to 1600 px on the long edge before OCR. A 12 MP phone photo (~4000 px) drops to ~1.3 MP, making Tesseract 4-8× faster with no real accuracy loss for printed text. Smaller-than-1600 px inputs are passed through unchanged. - Adaptive thresholding (Bradley's method with integral images) binarises the downscaled image before OCR — drops hallucinated text from shadows, gradients, and surface texture on phone photos. Output is pure black-on-white, much closer to the scanned-page input Tesseract was trained on. - Per-word confidence filter (≥ 60) drops the low-confidence noise Tesseract still emits. Re-assembles lines from kept words; status line reports how many words were dropped. +- Crop & rotate modal between image pick and OCR. After choosing an image, a modal pops up with cropperjs's drag-handle crop UI and Rotate left / Rotate right / Reset buttons. EXIF orientation is honoured automatically. Cancel / `Esc` / clicking the backdrop aborts; `Enter` accepts. Library lazy-loaded from esm.sh (no bundling). ### Fixed - "Hot: …" status no longer claims "unloads in X" when the server's `keep_alive` is `-1` (Ollama returns an absurd year-4001 `expires_at`). Threshold: any expiry >7 days drops the suffix and just shows the model name. diff --git a/static/index.html b/static/index.html index 7ddd98c..80a8aa2 100644 --- a/static/index.html +++ b/static/index.html @@ -190,6 +190,74 @@ } .scan-status.error { color: var(--error); } + /* Crop / rotate modal (cropperjs lives inside .modal-body) */ + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.78); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; + } + .modal { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + width: min(90vw, 900px); + max-height: 90vh; + display: flex; + flex-direction: column; + } + .modal-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.85rem; + color: var(--muted); + letter-spacing: 0.04em; + } + .modal-body { + flex: 1; + min-height: 0; + max-height: 70vh; + background: #000; + } + .modal-body img { display: block; max-width: 100%; } + .modal-footer { + padding: 0.75rem 1rem; + border-top: 1px solid var(--border); + display: flex; + gap: 0.5rem; + justify-content: space-between; + flex-wrap: wrap; + } + .modal-footer .group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .modal-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + padding: 0.4rem 0.9rem; + border-radius: 4px; + cursor: pointer; + margin: 0; + width: auto; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .modal-btn:hover { border-color: var(--accent); color: var(--fg); } + .modal-btn.primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); + } + .modal-btn.primary:hover { color: #fff; } + /* CodeMirror editor host — matches .output styling so input & output align */ .cm-editor { min-height: 180px; @@ -329,6 +397,95 @@ scanStatus.classList.toggle("error", error); } + // Crop + rotate modal. Lazy-loads cropperjs + its CSS from esm.sh on + // first use. Resolves with a Blob (cropped, rotation baked in) or null + // if the user cancelled. + let cropperCssLoaded = false; + async function openCropper(file) { + if (!cropperCssLoaded) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "https://esm.sh/cropperjs@1/dist/cropper.min.css"; + document.head.appendChild(link); + cropperCssLoaded = true; + } + const { default: Cropper } = await import("https://esm.sh/cropperjs@1"); + + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.className = "modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "modal"; + const url = URL.createObjectURL(file); + modal.innerHTML = ` + + + + `; + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + const img = modal.querySelector("img"); + img.src = url; + const cropper = new Cropper(img, { + viewMode: 1, + autoCropArea: 1, + background: false, + // Honour EXIF orientation from the phone shot. + checkOrientation: true, + }); + + const cleanup = () => { + cropper.destroy(); + URL.revokeObjectURL(url); + backdrop.remove(); + document.removeEventListener("keydown", onKey); + }; + const finish = (blob) => { cleanup(); resolve(blob); }; + + const onKey = (e) => { + if (e.key === "Escape") finish(null); + else if (e.key === "Enter") emit("use"); + }; + document.addEventListener("keydown", onKey); + + function emit(act) { + if (act === "rotL") cropper.rotate(-90); + else if (act === "rotR") cropper.rotate(90); + else if (act === "reset") cropper.reset(); + else if (act === "cancel") finish(null); + else if (act === "use") { + const canvas = cropper.getCroppedCanvas({ + // Hand off something Tesseract can chew on without being huge. + // preprocessForOCR will downscale further if needed. + maxWidth: OCR_MAX_DIM * 2, + maxHeight: OCR_MAX_DIM * 2, + }); + canvas.toBlob(blob => finish(blob), "image/png"); + } + } + + modal.addEventListener("click", (e) => { + const act = e.target.closest("[data-act]")?.dataset.act; + if (act) emit(act); + }); + // Click on backdrop (outside the modal) = cancel + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) finish(null); + }); + }); + } + // Downscale to at most OCR_MAX_DIM on the long edge AND binarise with // Bradley's adaptive threshold. Phone photos have gradients, shadows // and texture that Tesseract trained on scanned pages doesn't expect, @@ -427,11 +584,31 @@ const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO); scanBtn.disabled = true; + setScanStatus("Loading crop editor…"); + + let cropped; + try { + cropped = await openCropper(file); + } catch (e) { + console.error(e); + setScanStatus(`Crop editor failed: ${e.message || e}`, { error: true }); + scanBtn.disabled = false; + scanInput.value = ""; + return; + } + if (!cropped) { + // User cancelled + setScanStatus(""); + scanBtn.disabled = false; + scanInput.value = ""; + return; + } + setScanStatus("Preparing image…"); const t0 = performance.now(); try { - const { blob, w, h, scaled, origLong } = await preprocessForOCR(file); + const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped); if (scaled) { setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`); } else {