diff --git a/src/ocr/README.md b/src/ocr/README.md index d915e60..2deb534 100644 --- a/src/ocr/README.md +++ b/src/ocr/README.md @@ -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=` 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=` 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=` 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 diff --git a/src/ocr/index.js b/src/ocr/index.js index cc0dcf8..aece9d4 100644 --- a/src/ocr/index.js +++ b/src/ocr/index.js @@ -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= 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=` 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(); } /** @@ -101,52 +184,112 @@ export function splitOcrIntoTitles(raw) { return out; } -// Boot-time provider selection for testing. Reads `?ocr=` (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=` — 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(); } diff --git a/src/pages/contribute.js b/src/pages/contribute.js index 0864f8b..9abe7d5 100644 --- a/src/pages/contribute.js +++ b/src/pages/contribute.js @@ -9,7 +9,13 @@ import { supabase, STORAGE_BUCKET } from '../lib/supabase.js'; import { strings } from '../lib/strings.js'; import { escapeHtml, escapeAttr, normalizeTitle } from '../lib/format.js'; import { showError, showSuccess, clearStatus } from '../lib/errors.js'; -import { extractTitles } from '../ocr/index.js'; +import { + extractTitles, + getActiveOcr, + getOcrTimings, + setOcrProviderByName, + resetOcrOverride, +} from '../ocr/index.js'; import { makeDerivatives } from '../images/compress.js'; import { installAnalytics } from '../lib/analytics.js'; @@ -109,6 +115,7 @@ function renderUploadView(user) {
+ ${renderOcrPickerLine()}