v0.3.0: OCR via camera, FAB UX, side drawer #4
@@ -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 `<input type=file capture>` 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.
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<div>
|
||||
<label for="input">Input</label>
|
||||
<div id="input"></div>
|
||||
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="scan-input" accept="image/*" hidden>
|
||||
<div id="scan-status" class="scan-status"></div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -443,7 +466,7 @@
|
||||
<div class="group">
|
||||
<button class="modal-btn icon" data-act="rotL" title="Rotate left"><span class="material-symbols-outlined">rotate_left</span></button>
|
||||
<button class="modal-btn icon" data-act="rotR" title="Rotate right"><span class="material-symbols-outlined">rotate_right</span></button>
|
||||
<button class="modal-btn icon" data-act="reset" title="Reset"><span class="material-symbols-outlined">restart_alt</span></button>
|
||||
<button class="modal-btn icon" data-act="invert" title="Invert colours (for light text on dark background)"><span class="material-symbols-outlined">invert_colors</span></button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button class="modal-btn" data-act="cancel">Cancel</button>
|
||||
@@ -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 <input type=file capture> 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 = `
|
||||
<div class="modal-header">Aim at the text and tap the shutter</div>
|
||||
<div class="modal-body cam-body"><video autoplay playsinline muted></video></div>
|
||||
<div class="modal-footer">
|
||||
<div class="group">
|
||||
<button class="modal-btn icon" data-act="file" title="Pick from file instead"><span class="material-symbols-outlined">photo_library</span></button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button class="modal-btn" data-act="cancel">Cancel</button>
|
||||
<button class="modal-btn primary icon" data-act="shoot" title="Capture"><span class="material-symbols-outlined">photo_camera</span></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user