v0.3.0: OCR via camera, FAB UX, side drawer #4
@@ -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.
|
||||
|
||||
@@ -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 = `
|
||||
<div class="modal-header">Crop & rotate before OCR</div>
|
||||
<div class="modal-body"><img alt="" /></div>
|
||||
<div class="modal-footer">
|
||||
<div class="group">
|
||||
<button class="modal-btn" data-act="rotL">Rotate left</button>
|
||||
<button class="modal-btn" data-act="rotR">Rotate right</button>
|
||||
<button class="modal-btn" data-act="reset">Reset</button>
|
||||
</div>
|
||||
<div class="group">
|
||||
<button class="modal-btn" data-act="cancel">Cancel</button>
|
||||
<button class="modal-btn primary" data-act="use">Use this</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user