diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ed6c3..1fe4886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,14 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - "Scan image" button replaced with a floating action button at the bottom-right of the viewport, using a Material Symbols Outlined camera glyph (`photo_camera`) for consistent rendering across platforms (was a small text button in the input pane header). - Crop modal: - No default crop box. User drags on the image to draw the region; falls back to full image if they confirm without drawing. - - Rotate left / Rotate right / Reset / Use this buttons replaced with Material Symbols icons (`rotate_left`, `rotate_right`, `restart_alt`, `check`). Cancel kept as text. + - Buttons are now Material Symbols icons: `rotate_left`, `rotate_right`, `invert_colors` (new — see below), `check`. Cancel kept as text. The `check` (Use this) button is larger and more prominent. The Reset button was removed (rarely useful with no default box). - Inner padding bumped to 2 rem so cropperjs's corner handles never sit on the right/bottom edges where Android's edge-swipe back gesture fires. iOS safe-area insets honoured on the backdrop and FAB. - Header text is now a hint ("Drag on the image to select the text region") instead of a title. +### Added +- Camera viewfinder modal on mobile (via `navigator.mediaDevices.getUserMedia` with `facingMode: environment`) — replaces the `` flow. Tapping the shutter goes directly to the crop modal, skipping the OS-level "retake / use" confirm step. Falls back to the file picker if camera access is denied or unavailable; a "Pick from file" icon (`photo_library`) inside the camera modal also routes to the picker on demand. +- Invert-colours toggle (`invert_colors`) in the crop modal. Tap to flip light-on-dark text into the dark-on-light polarity Tesseract expects. The grayscale buffer is inverted before adaptive thresholding so the binarised output is correct. + ### 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 81b989d..cf3b714 100644 --- a/static/index.html +++ b/static/index.html @@ -269,13 +269,36 @@ .modal-btn .material-symbols-outlined { font-size: 20px; } .modal-btn.icon { padding: 0.35rem 0.55rem; min-width: 2.4rem; } .modal-btn:hover { border-color: var(--accent); color: var(--fg); } + .modal-btn.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); + } .modal-btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); } + .modal-btn.primary.icon { + padding: 0.55rem 1.1rem; + min-width: 3.2rem; + } + .modal-btn.primary.icon .material-symbols-outlined { font-size: 24px; } .modal-btn.primary:hover { color: #fff; } + /* Camera viewfinder modal */ + .cam-body { + display: flex; + align-items: center; + justify-content: center; + padding: 0 !important; + } + .cam-body video { + max-width: 100%; + max-height: 70vh; + display: block; + } + /* CodeMirror editor host — matches .output styling so input & output align */ .cm-editor { min-height: 180px; @@ -330,7 +353,7 @@
- +
@@ -443,7 +466,7 @@
- +
@@ -465,13 +488,15 @@ checkOrientation: true, }); + let invert = false; + const cleanup = () => { cropper.destroy(); URL.revokeObjectURL(url); backdrop.remove(); document.removeEventListener("keydown", onKey); }; - const finish = (blob) => { cleanup(); resolve(blob); }; + const finish = (result) => { cleanup(); resolve(result); }; const onKey = (e) => { if (e.key === "Escape") finish(null); @@ -479,10 +504,13 @@ }; document.addEventListener("keydown", onKey); - function emit(act) { + function emit(act, btn) { if (act === "rotL") cropper.rotate(-90); else if (act === "rotR") cropper.rotate(90); - else if (act === "reset") cropper.reset(); + else if (act === "invert") { + invert = !invert; + btn?.classList.toggle("active", invert); + } else if (act === "cancel") finish(null); else if (act === "use") { // With autoCrop:false, if the user never drew a region, force a @@ -492,14 +520,14 @@ maxWidth: OCR_MAX_DIM * 2, maxHeight: OCR_MAX_DIM * 2, }); - if (canvas) canvas.toBlob(blob => finish(blob), "image/png"); + if (canvas) canvas.toBlob(blob => finish({ blob, invert }), "image/png"); else finish(null); } } modal.addEventListener("click", (e) => { - const act = e.target.closest("[data-act]")?.dataset.act; - if (act) emit(act); + const btn = e.target.closest("[data-act]"); + if (btn) emit(btn.dataset.act, btn); }); // Click on backdrop (outside the modal) = cancel backdrop.addEventListener("click", (e) => { @@ -516,7 +544,7 @@ const OCR_MAX_DIM = 1600; const OCR_CONFIDENCE_MIN = 60; // word-level confidence (0-100) - async function preprocessForOCR(file) { + async function preprocessForOCR(file, { invert = false } = {}) { const bitmap = await createImageBitmap(file); const long = Math.max(bitmap.width, bitmap.height); const scale = Math.min(1, OCR_MAX_DIM / long); @@ -530,7 +558,7 @@ ctx.drawImage(bitmap, 0, 0, w, h); bitmap.close?.(); - binarizeAdaptive(ctx, w, h); + binarizeAdaptive(ctx, w, h, { invert }); // PNG, not JPEG — JPEG would re-blur the freshly-sharp B/W edges. const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png")); @@ -540,7 +568,9 @@ // Bradley/Roth adaptive threshold: compare each pixel against the // mean of a local window. Computed in O(N) total via an integral // image. Output is a 3-channel B/W image (R=G=B=0 or 255). - function binarizeAdaptive(ctx, w, h) { + // invert=true flips the grayscale before thresholding so white-on-dark + // text comes out as the expected black-on-white for Tesseract. + function binarizeAdaptive(ctx, w, h, { invert = false } = {}) { const imgData = ctx.getImageData(0, 0, w, h); const data = imgData.data; @@ -549,6 +579,9 @@ for (let i = 0, j = 0; i < data.length; i += 4, j++) { gray[j] = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) | 0; } + if (invert) { + for (let i = 0; i < gray.length; i++) gray[i] = 255 - gray[i]; + } // 2. Integral image (summed-area table) const integral = new Float64Array(w * h); @@ -608,9 +641,9 @@ scanBtn.disabled = true; setScanStatus("Loading crop editor…"); - let cropped; + let cropResult; try { - cropped = await openCropper(file); + cropResult = await openCropper(file); } catch (e) { console.error(e); setScanStatus(`Crop editor failed: ${e.message || e}`, { error: true }); @@ -618,19 +651,20 @@ scanInput.value = ""; return; } - if (!cropped) { + if (!cropResult) { // User cancelled setScanStatus(""); scanBtn.disabled = false; scanInput.value = ""; return; } + const { blob: cropped, invert } = cropResult; setScanStatus("Preparing image…"); const t0 = performance.now(); try { - const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped); + const { blob, w, h, scaled, origLong } = await preprocessForOCR(cropped, { invert }); if (scaled) { setScanStatus(`Preprocessed ${origLong}px → ${Math.max(w, h)}px (binarised)`); } else { @@ -670,7 +704,93 @@ } } - scanBtn.addEventListener("click", () => scanInput.click()); + // Live camera capture via getUserMedia — bypasses the OS camera's + // "retake / use" confirm step that shows. + // Tap the shutter → frame is captured and crop modal opens immediately. + // Returns the captured Blob, "file" if the user wants the file picker, + // or null on cancel. + async function captureFromCamera() { + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: "environment" } }, + audio: false, + }); + } catch (e) { + console.warn("Camera unavailable:", e); + return "file"; // fall through to file picker + } + return new Promise((resolve) => { + const backdrop = document.createElement("div"); + backdrop.className = "modal-backdrop"; + const modal = document.createElement("div"); + modal.className = "modal"; + modal.innerHTML = ` + + + + `; + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + const video = modal.querySelector("video"); + video.srcObject = stream; + + const cleanup = () => { + stream.getTracks().forEach(t => t.stop()); + video.srcObject = null; + backdrop.remove(); + document.removeEventListener("keydown", onKey); + }; + const finish = (r) => { cleanup(); resolve(r); }; + + const onKey = (e) => { + if (e.key === "Escape") finish(null); + }; + document.addEventListener("keydown", onKey); + + modal.addEventListener("click", (e) => { + const act = e.target.closest("[data-act]")?.dataset.act; + if (act === "cancel") finish(null); + else if (act === "file") finish("file"); + else if (act === "shoot") { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d").drawImage(video, 0, 0); + canvas.toBlob(blob => finish(blob), "image/jpeg", 0.92); + } + }); + backdrop.addEventListener("click", (e) => { + if (e.target === backdrop) finish(null); + }); + }); + } + + async function startScan() { + // On a touch device with getUserMedia, go straight to the live + // viewfinder. On desktop / no-camera, use the file picker. + if (isCoarsePointer && navigator.mediaDevices?.getUserMedia) { + const result = await captureFromCamera(); + if (result === "file") { + scanInput.click(); + } else if (result) { + runOCR(result); + } + return; + } + scanInput.click(); + } + + scanBtn.addEventListener("click", startScan); scanInput.addEventListener("change", (e) => { const file = e.target.files?.[0]; if (file) runOCR(file);