Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions src/ocr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,29 @@ origin as the page (avoids cross-origin wasm restrictions).
If you upgrade `onnxruntime-web` or `@gutenye/ocr-models`, refresh the
copies under `public/ocr-models/` to match.

## Forcing a provider for testing
## Picking a provider

Append `?ocr=<name>` to `/contribute.html` to pin a specific provider —
useful while we're still benchmarking OCR options against real spines.
The pinned provider runs alone (no fallback), so timing and accuracy
aren't muddied by the chain. The flag is a no-op on other pages, since
they don't import the OCR module.
On boot the OCR module walks a three-step cascade:

1. `?ocr=<name>` URL parameter (highest priority, persisted to
`localStorage`).
2. Previously-chosen provider stored under `localStorage['obm.ocr']`.
3. **Auto-pick based on this device**: if `navigator.gpu` is present
(WebGPU) → `paddle`; otherwise → `tesseract`. Logged to the console
at load: `OCR: auto-picked tesseract (no WebGPU).`

The contribute page surfaces a compact picker at the top of the upload
card — `OCR: **Paddle** [auto] · 2.4s last run · [Switch to Tesseract]`.
Tapping the switch button writes the chosen provider to `localStorage`
(and rewrites `?ocr=` if it's in the URL) so subsequent uploads use it.

### Forcing a provider for testing

`?ocr=<name>` still works for ad-hoc A/B testing without code changes:

- `?ocr=paddle` — PaddleOCR only.
- `?ocr=tesseract` — Tesseract only.
- `?ocr=default` — clear the override; restore the paddle→tesseract chain.
- `?ocr=default` — clear the override; restore the auto-pick.

The choice persists in `localStorage` under `obm.ocr`, so subsequent
navigations keep the override until you pass `?ocr=default` (or clear
Expand Down
287 changes: 215 additions & 72 deletions src/ocr/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,65 +13,148 @@
* A contributor reviews and confirms each title before we save it. Never
* insert unconfirmed titles directly.
*
* Default provider: PaddleOCR via onnxruntime-web (more accurate on rotated /
* stylized spines than Tesseract). Tesseract.js is kept as an automatic
* fallback for browsers where ONNX wasm fails to load.
* Boot-time selection cascade (highest priority first):
* 1. ?ocr=<name> URL param (persisted to localStorage)
* 2. localStorage previous choice
* 3. Auto-pick based on navigator.gpu (WebGPU → paddle, else → tesseract)
*
* This matters because we run primarily on smartphone browsers. iOS Safari
* before 18 has no WebGPU and paddle on single-threaded WASM is painfully
* slow there. Auto-pick gives a sane default; the UI exposes a manual
* switch wired via `setOcrProviderByName`.
*/
import { extractTitles as paddleExtract } from './paddle-provider.js';
import { extractTitles as tesseractExtract } from './tesseract-provider.js';

const STORAGE_KEY = 'obm.ocr';
const TIMING_KEY = 'obm.ocr.timings';

// Names accepted by `?ocr=<name>` and persisted in localStorage. A named
// provider runs alone (no fallback) so A/B comparison is clean; `default`
// restores the paddle→tesseract chain.
const NAMED_PROVIDERS = {
paddle: paddleExtract,
tesseract: tesseractExtract,
};

let active = defaultProvider();
const state = {
/** @type {'paddle' | 'tesseract' | null} */
name: null,
/** @type {'url' | 'storage' | 'auto' | 'manual' | 'default'} */
source: 'default',
/** @type {(image: Blob, opts?: object) => Promise<{raw: string, titles: string[]}>} */
fn: defaultProvider(),
};

function defaultProvider() {
return withFallback(paddleExtract, tesseractExtract);
/**
* Run OCR on a single image. Wraps the active provider with a per-provider
* timing measurement (persisted to localStorage so the UI can show
* "2.4s last run" without keeping it in memory).
*/
export async function extractTitles(image, opts) {
const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
const result = await state.fn(image, opts);
if (state.name && typeof window !== 'undefined') {
const ms = Math.round(
(typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0,
);
try {
const timings = readTimings();
timings[state.name] = ms;
window.localStorage?.setItem(TIMING_KEY, JSON.stringify(timings));
} catch {
// Storage may be disabled (private mode, sandboxed iframe).
}
}
return result;
}

/**
* Swap in a different OCR provider at runtime (e.g. a vision-LLM fallback).
* Pass `null` to reset to the default.
* @param {((image: Blob, opts?: object) => Promise<{raw: string, titles: string[]}>) | null} fn
* Inspect the currently-active OCR provider — used by UI to render the
* picker indicator.
* @returns {{name: 'paddle'|'tesseract'|null, source: 'url'|'storage'|'auto'|'manual'|'default'}}
*/
export function setOcrProvider(fn) {
active = fn ?? defaultProvider();
export function getActiveOcr() {
return { name: state.name, source: state.source };
}

/**
* Run OCR on a single image.
* @param {Blob | File} image
* @param {{signal?: AbortSignal}} [opts]
* Most recent measured runtime per provider, in ms. Empty until the user
* runs OCR at least once with that provider in this browser.
* @returns {{paddle?: number, tesseract?: number}}
*/
export function extractTitles(image, opts) {
return active(image, opts);
export function getOcrTimings() {
if (typeof window === 'undefined') return {};
return readTimings();
}

/**
* Build a provider that tries `primary` first and silently falls back to
* `secondary` on any error. The fallback latches: once we hit an error,
* subsequent calls go straight to `secondary` to avoid re-paying the cost
* of a failing primary on every photo.
* Swap in a named provider. Optionally persist the choice to localStorage
* (so the next page load re-uses it) and update the URL `?ocr=` param if
* one is present (so a pinned URL stays in sync with the user's switch).
* @param {'paddle' | 'tesseract'} name
* @param {{persist?: boolean, source?: 'manual' | 'auto' | 'url' | 'storage'}} [opts]
* @returns {boolean} true if the name was recognized.
*/
function withFallback(primary, secondary) {
let primaryFailed = false;
return async function fallback(image, opts) {
if (primaryFailed) return secondary(image, opts);
export function setOcrProviderByName(name, opts = {}) {
const { persist = false, source = 'manual' } = opts;
const fn = NAMED_PROVIDERS[name];
if (!fn) return false;
state.fn = fn;
state.name = name;
state.source = source;
if (persist && typeof window !== 'undefined') {
try {
return await primary(image, opts);
} catch (err) {
console.error('Primary OCR provider failed; falling back', err);
primaryFailed = true;
return secondary(image, opts);
window.localStorage?.setItem(STORAGE_KEY, name);
} catch {
// Storage may be disabled.
}
};
try {
const url = new URL(window.location.href);
if (url.searchParams.has('ocr')) {
url.searchParams.set('ocr', name);
window.history.replaceState({}, '', url);
}
} catch {
// URL mutation isn't safe in every embedding context.
}
}
return true;
}

/**
* Low-level escape hatch — pass a custom async fn (e.g. a vision-LLM
* provider in a test). Pass null to reset to the auto-picked default.
* @param {((image: Blob, opts?: object) => Promise<{raw: string, titles: string[]}>) | null} fn
*/
export function setOcrProvider(fn) {
if (fn) {
state.fn = fn;
state.name = null;
state.source = 'manual';
} else {
autoPick();
}
}

/**
* Clear URL `?ocr=` and localStorage override, then re-pick the provider
* based on this device. Used by the contribute UI's "Reset to auto" path.
*/
export function resetOcrOverride() {
if (typeof window === 'undefined') return;
try {
window.localStorage?.removeItem(STORAGE_KEY);
} catch {
// ignore
}
try {
const url = new URL(window.location.href);
if (url.searchParams.has('ocr')) {
url.searchParams.delete('ocr');
window.history.replaceState({}, '', url);
}
} catch {
// ignore
}
autoPick();
}

/**
Expand Down Expand Up @@ -101,52 +184,112 @@ export function splitOcrIntoTitles(raw) {
return out;
}

// Boot-time provider selection for testing. Reads `?ocr=<name>` (and
// persists it to localStorage so the choice survives navigation), or the
// stored value if no URL param. `?ocr=default` clears the override.
// Browser-only — vitest imports this module in a node env.
if (typeof window !== 'undefined') applyProviderOverride();
// ---------- internals -------------------------------------------------

function defaultProvider() {
return withFallback(paddleExtract, tesseractExtract);
}

function applyProviderOverride() {
let choice;
let source;
/**
* Build a provider that tries `primary` first and silently falls back to
* `secondary` on any error. The fallback latches: once we hit an error,
* subsequent calls go straight to `secondary` to avoid re-paying the cost
* of a failing primary on every photo.
*/
function withFallback(primary, secondary) {
let primaryFailed = false;
return async function fallback(image, opts) {
if (primaryFailed) return secondary(image, opts);
try {
return await primary(image, opts);
} catch (err) {
console.error('Primary OCR provider failed; falling back', err);
primaryFailed = true;
return secondary(image, opts);
}
};
}

/**
* navigator.gpu present → paddle (more accurate, GPU-accelerated)
* navigator.gpu absent → tesseract (lighter, faster on phones without WebGPU)
*
* Single binary signal is intentionally KISS. A timed benchmark would be
* more honest but adds 0.5–1s to first load; WebGPU presence captures the
* load-bearing distinction for the device classes we actually see.
*/
function pickProviderForDevice() {
if (typeof navigator !== 'undefined' && navigator.gpu) return 'paddle';
return 'tesseract';
}

function autoPick() {
const picked = pickProviderForDevice();
setOcrProviderByName(picked, { source: 'auto' });
const hasGpu = typeof navigator !== 'undefined' && !!navigator.gpu;
// Use warn so the boot decision is visible without violating the
// project's no-console-log rule (eslint allows warn / error only).
console.warn(`OCR: auto-picked ${picked} (${hasGpu ? 'WebGPU available' : 'no WebGPU'}).`);
}

function readTimings() {
try {
const raw = window.localStorage?.getItem(TIMING_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}

// ---------- boot ------------------------------------------------------

// Browser-only — vitest imports this module in a node env where window
// is undefined; the default fallback chain at the top of `state.fn`
// suffices for tests that don't actually run OCR.
if (typeof window !== 'undefined') initOcr();

function initOcr() {
// 1. URL `?ocr=<name>` — highest priority, persisted to storage.
let urlChoice = null;
try {
const params = new URLSearchParams(window.location.search);
if (params.has('ocr')) {
choice = params.get('ocr');
source = 'url';
try {
if (choice && choice !== 'default') {
window.localStorage?.setItem(STORAGE_KEY, choice);
} else {
window.localStorage?.removeItem(STORAGE_KEY);
}
} catch {
// localStorage may be disabled (private mode, sandboxed iframe).
}
} else {
choice = window.localStorage?.getItem(STORAGE_KEY) ?? undefined;
source = 'storage';
if (params.has('ocr')) urlChoice = (params.get('ocr') || '').trim();
} catch {
// ignore — fall through to next step
}

if (urlChoice === 'default') {
try {
window.localStorage?.removeItem(STORAGE_KEY);
} catch {
// ignore
}
} catch (err) {
console.warn('OCR provider override: failed to read URL/localStorage', err);
autoPick();
return;
}

if (!choice || choice === 'default') return;

const validNames = [...Object.keys(NAMED_PROVIDERS), 'default'].join(', ');
const provider = NAMED_PROVIDERS[choice];
if (provider) {
setOcrProvider(provider);
console.warn(
`OCR provider override: ${choice} (from ${source}). ` +
`Use ?ocr=default to reset. Valid: ${validNames}.`,
);
} else {
if (urlChoice && NAMED_PROVIDERS[urlChoice]) {
setOcrProviderByName(urlChoice, { persist: true, source: 'url' });
console.warn(`OCR: pinned to ${urlChoice} via URL. Use ?ocr=default to reset.`);
return;
}
if (urlChoice) {
console.warn(
`Unknown OCR provider "${choice}" (from ${source}). ` +
`Valid: ${validNames}. Using default.`,
`OCR: unknown provider "${urlChoice}". Valid: ${Object.keys(NAMED_PROVIDERS).join(', ')}. Falling back to auto.`,
);
}

// 2. localStorage previous choice (set last time the user switched).
let stored = null;
try {
stored = window.localStorage?.getItem(STORAGE_KEY);
} catch {
// ignore
}
if (stored && NAMED_PROVIDERS[stored]) {
setOcrProviderByName(stored, { source: 'storage' });
return;
}

// 3. Auto-pick based on this device's WebGPU support.
autoPick();
}
Loading
Loading