perf: Remove Tesseract OCR - saves ~45MB on mobile
- Removed Tesseract.js files from precache (~45MB) - Scanner now uses only Gemini AI (more accurate, less data) - Offline scans queued for later processing when online - App download from ~50MB to ~5MB BREAKING: Local offline OCR no longer available Use Gemini AI instead (requires network for scanning)
This commit is contained in:
15
public/sw.js
15
public/sw.js
@@ -1,22 +1,13 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v19-offline';
|
const CACHE_NAME = 'whisky-vault-v20-offline';
|
||||||
|
|
||||||
// CONFIG: Assets
|
// CONFIG: Assets - Only essential files, no heavy OCR (~2MB instead of ~50MB)
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icon-192.png',
|
'/icon-192.png',
|
||||||
'/icon-512.png',
|
'/icon-512.png',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
'/lib/browser-image-compression.js',
|
'/lib/browser-image-compression.js',
|
||||||
// Tesseract OCR files for offline scanning (ALL variants for browser compatibility)
|
// NOTE: Tesseract OCR removed - Gemini AI is more accurate and data-efficient
|
||||||
'/tessdata/worker.min.js',
|
|
||||||
'/tessdata/tesseract-core.wasm.js',
|
|
||||||
'/tessdata/tesseract-core-simd.wasm.js',
|
|
||||||
'/tessdata/tesseract-core-lstm.wasm.js',
|
|
||||||
'/tessdata/tesseract-core-simd-lstm.wasm.js',
|
|
||||||
'/tessdata/tesseract-core-relaxedsimd.wasm.js',
|
|
||||||
'/tessdata/tesseract-core-relaxedsimd-lstm.wasm.js',
|
|
||||||
'/tessdata/eng.traineddata',
|
|
||||||
'/tessdata/eng.traineddata.gz',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const CORE_PAGES = [
|
const CORE_PAGES = [
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
public/tessdata/worker.min.js
vendored
3
public/tessdata/worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Loader2, Sparkles, AlertCircle, Clock, Eye, Cloud, Cpu } from 'lucide-react';
|
import { X, Loader2, Sparkles, AlertCircle, Clock, Cloud } from 'lucide-react';
|
||||||
import TastingEditor from './TastingEditor';
|
import TastingEditor from './TastingEditor';
|
||||||
import SessionBottomSheet from './SessionBottomSheet';
|
import SessionBottomSheet from './SessionBottomSheet';
|
||||||
import ResultCard from './ResultCard';
|
import ResultCard from './ResultCard';
|
||||||
@@ -40,26 +40,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
const [isEnriching, setIsEnriching] = useState(false);
|
const [isEnriching, setIsEnriching] = useState(false);
|
||||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||||
|
|
||||||
// Use the new hybrid scanner hook
|
// Use the Gemini-only scanner hook
|
||||||
const scanner = useScanner({
|
const scanner = useScanner({
|
||||||
locale,
|
locale,
|
||||||
onLocalComplete: (localResult) => {
|
onComplete: (cloudResult) => {
|
||||||
console.log('[ScanFlow] Local OCR complete, updating preview:', localResult);
|
console.log('[ScanFlow] Gemini complete:', cloudResult);
|
||||||
// Immediately update bottleMetadata with local results for optimistic UI
|
|
||||||
setBottleMetadata(prev => ({
|
|
||||||
...prev,
|
|
||||||
name: localResult.name || prev?.name || null,
|
|
||||||
distillery: localResult.distillery || prev?.distillery || null,
|
|
||||||
abv: localResult.abv ?? prev?.abv ?? null,
|
|
||||||
age: localResult.age ?? prev?.age ?? null,
|
|
||||||
vintage: localResult.vintage || prev?.vintage || null,
|
|
||||||
is_whisky: true,
|
|
||||||
confidence: 50,
|
|
||||||
} as BottleMetadata));
|
|
||||||
},
|
|
||||||
onCloudComplete: (cloudResult) => {
|
|
||||||
console.log('[ScanFlow] Cloud vision complete:', cloudResult);
|
|
||||||
// Update with cloud results (this is the "truth")
|
|
||||||
setBottleMetadata(cloudResult);
|
setBottleMetadata(cloudResult);
|
||||||
|
|
||||||
// Trigger background enrichment if we have name and distillery
|
// Trigger background enrichment if we have name and distillery
|
||||||
@@ -153,30 +138,20 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
|
|
||||||
if (scannerStatus === 'idle') {
|
if (scannerStatus === 'idle') {
|
||||||
// Don't change state on idle
|
// Don't change state on idle
|
||||||
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing_local') {
|
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing') {
|
||||||
setState('SCANNING');
|
setState('SCANNING');
|
||||||
} else if (scannerStatus === 'analyzing_cloud') {
|
|
||||||
// Show EDITOR immediately when we have local results - don't wait for cloud
|
|
||||||
if (scanner.localResult || scanner.mergedResult) {
|
|
||||||
setState('EDITOR');
|
|
||||||
} else {
|
|
||||||
setState('SCANNING');
|
|
||||||
}
|
|
||||||
} else if (scannerStatus === 'complete' || scannerStatus === 'queued') {
|
} else if (scannerStatus === 'complete' || scannerStatus === 'queued') {
|
||||||
// Use merged result from scanner
|
|
||||||
if (scanner.mergedResult) {
|
if (scanner.mergedResult) {
|
||||||
setBottleMetadata(scanner.mergedResult);
|
setBottleMetadata(scanner.mergedResult);
|
||||||
}
|
}
|
||||||
setState('EDITOR');
|
setState('EDITOR');
|
||||||
|
|
||||||
// If this was a queued offline scan, mark as fallback
|
|
||||||
if (scannerStatus === 'queued') {
|
if (scannerStatus === 'queued') {
|
||||||
setAiFallbackActive(true);
|
setAiFallbackActive(true);
|
||||||
setIsOffline(true);
|
setIsOffline(true);
|
||||||
}
|
}
|
||||||
} else if (scannerStatus === 'error') {
|
} else if (scannerStatus === 'error') {
|
||||||
if (scanner.mergedResult) {
|
if (scanner.mergedResult) {
|
||||||
// We have partial results, show editor anyway
|
|
||||||
setBottleMetadata(scanner.mergedResult);
|
setBottleMetadata(scanner.mergedResult);
|
||||||
setState('EDITOR');
|
setState('EDITOR');
|
||||||
setAiFallbackActive(true);
|
setAiFallbackActive(true);
|
||||||
@@ -185,7 +160,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
setState('ERROR');
|
setState('ERROR');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [scanner.status, scanner.mergedResult, scanner.localResult, scanner.error]);
|
}, [scanner.status, scanner.mergedResult, scanner.error]);
|
||||||
|
|
||||||
const handleScan = async (file: File) => {
|
const handleScan = async (file: File) => {
|
||||||
setState('SCANNING');
|
setState('SCANNING');
|
||||||
@@ -206,7 +181,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
const tempId = `temp_${Date.now()}`;
|
const tempId = `temp_${Date.now()}`;
|
||||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||||
|
|
||||||
// Check for existing pending scan with same image
|
|
||||||
const existingScan = await db.pending_scans
|
const existingScan = await db.pending_scans
|
||||||
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
||||||
.first();
|
.first();
|
||||||
@@ -226,7 +200,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save pending tasting linked to temp bottle
|
|
||||||
await db.pending_tastings.add({
|
await db.pending_tastings.add({
|
||||||
pending_bottle_id: currentTempId,
|
pending_bottle_id: currentTempId,
|
||||||
data: {
|
data: {
|
||||||
@@ -258,13 +231,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||||
setIsOffline(true);
|
setIsOffline(true);
|
||||||
// Retry save in offline mode
|
|
||||||
return handleSaveTasting(formData);
|
return handleSaveTasting(formData);
|
||||||
}
|
}
|
||||||
throw authError;
|
throw authError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save Bottle
|
|
||||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||||
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id);
|
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id);
|
||||||
if (!bottleResult.success || !bottleResult.data) {
|
if (!bottleResult.success || !bottleResult.data) {
|
||||||
@@ -273,7 +244,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
|
|
||||||
const bottleId = bottleResult.data.id;
|
const bottleId = bottleResult.data.id;
|
||||||
|
|
||||||
// Save Tasting
|
|
||||||
const tastingNote = {
|
const tastingNote = {
|
||||||
...formData,
|
...formData,
|
||||||
bottle_id: bottleId,
|
bottle_id: bottleId,
|
||||||
@@ -314,14 +284,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map scanner status to display text
|
|
||||||
const getScanStatusDisplay = (status: ScanStatus): { text: string; icon: React.ReactNode } => {
|
const getScanStatusDisplay = (status: ScanStatus): { text: string; icon: React.ReactNode } => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'compressing':
|
case 'compressing':
|
||||||
return { text: 'Bild optimieren...', icon: <Loader2 size={12} className="animate-spin" /> };
|
return { text: 'Bild optimieren...', icon: <Loader2 size={12} className="animate-spin" /> };
|
||||||
case 'analyzing_local':
|
case 'analyzing':
|
||||||
return { text: 'Lokale OCR-Analyse...', icon: <Cpu size={12} /> };
|
|
||||||
case 'analyzing_cloud':
|
|
||||||
return { text: 'KI-Vision-Analyse...', icon: <Cloud size={12} /> };
|
return { text: 'KI-Vision-Analyse...', icon: <Cloud size={12} /> };
|
||||||
default:
|
default:
|
||||||
return { text: 'Analysiere Etikett...', icon: <Loader2 size={12} className="animate-spin" /> };
|
return { text: 'Analysiere Etikett...', icon: <Loader2 size={12} className="animate-spin" /> };
|
||||||
@@ -373,13 +340,10 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
</p>
|
</p>
|
||||||
{/* Show scan stage indicators */}
|
{/* Show scan stage indicators */}
|
||||||
<div className="flex items-center justify-center gap-2 mt-4">
|
<div className="flex items-center justify-center gap-2 mt-4">
|
||||||
<div className={`w-2 h-2 rounded-full transition-colors ${['compressing', 'analyzing_local', 'analyzing_cloud', 'complete'].includes(scanner.status)
|
<div className={`w-2 h-2 rounded-full transition-colors ${['compressing', 'analyzing', 'complete'].includes(scanner.status)
|
||||||
? 'bg-orange-500' : 'bg-zinc-700'
|
? 'bg-orange-500' : 'bg-zinc-700'
|
||||||
}`} />
|
}`} />
|
||||||
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing_local', 'analyzing_cloud', 'complete'].includes(scanner.status)
|
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing', 'complete'].includes(scanner.status)
|
||||||
? 'bg-orange-500' : 'bg-zinc-700'
|
|
||||||
}`} />
|
|
||||||
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing_cloud', 'complete'].includes(scanner.status)
|
|
||||||
? 'bg-orange-500' : 'bg-zinc-700'
|
? 'bg-orange-500' : 'bg-zinc-700'
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
@@ -389,15 +353,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
{/* Admin perf metrics */}
|
{/* Admin perf metrics */}
|
||||||
{isAdmin && scanner.perf && (
|
{isAdmin && scanner.perf && (
|
||||||
<div className="mt-8 p-6 bg-zinc-950/80 backdrop-blur-xl rounded-3xl border border-orange-500/20 text-[10px] font-mono text-zinc-400 animate-in fade-in slide-in-from-bottom-4 shadow-2xl">
|
<div className="mt-8 p-6 bg-zinc-950/80 backdrop-blur-xl rounded-3xl border border-orange-500/20 text-[10px] font-mono text-zinc-400 animate-in fade-in slide-in-from-bottom-4 shadow-2xl">
|
||||||
<div className="grid grid-cols-3 gap-6 text-center">
|
<div className="grid grid-cols-2 gap-6 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Compress</p>
|
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Compress</p>
|
||||||
<p className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</p>
|
<p className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Local OCR</p>
|
|
||||||
<p className="text-orange-500 font-bold">{scanner.perf.localOcr.toFixed(0)}ms</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Cloud Vision</p>
|
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">Cloud Vision</p>
|
||||||
<p className="text-orange-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</p>
|
<p className="text-orange-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</p>
|
||||||
@@ -454,22 +414,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Local preview indicator */}
|
|
||||||
{scanner.status === 'analyzing_cloud' && scanner.localResult && (
|
|
||||||
<div className="bg-blue-500/10 border-b border-blue-500/20 p-3">
|
|
||||||
<div className="max-w-2xl mx-auto flex items-center gap-3">
|
|
||||||
<Eye size={14} className="text-blue-500" />
|
|
||||||
<p className="text-xs font-bold text-blue-500 uppercase tracking-wider flex items-center gap-2">
|
|
||||||
Lokale Vorschau
|
|
||||||
<span className="flex items-center gap-1 text-zinc-400 font-normal normal-case">
|
|
||||||
<Loader2 size={10} className="animate-spin" />
|
|
||||||
KI-Vision verfeinert Ergebnisse...
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TastingEditor
|
<TastingEditor
|
||||||
bottleMetadata={bottleMetadata}
|
bottleMetadata={bottleMetadata}
|
||||||
image={scanner.processedImage?.base64 || null}
|
image={scanner.processedImage?.base64 || null}
|
||||||
@@ -481,7 +425,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
defaultExpanded={true}
|
defaultExpanded={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin perf overlay - positioned at bottom */}
|
{/* Admin perf overlay */}
|
||||||
{isAdmin && scanner.perf && (
|
{isAdmin && scanner.perf && (
|
||||||
<div className="fixed bottom-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
<div className="fixed bottom-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||||
<div className="flex items-center justify-between gap-4 whitespace-nowrap">
|
<div className="flex items-center justify-between gap-4 whitespace-nowrap">
|
||||||
@@ -490,10 +434,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
|||||||
<span className="text-zinc-500">COMPRESS:</span>
|
<span className="text-zinc-500">COMPRESS:</span>
|
||||||
<span className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
|
<span className="text-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-zinc-500">LOCAL:</span>
|
|
||||||
<span className="text-blue-500 font-bold">{scanner.perf.localOcr.toFixed(0)}ms</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-500">CLOUD:</span>
|
<span className="text-zinc-500">CLOUD:</span>
|
||||||
<span className="text-green-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</span>
|
<span className="text-green-500 font-bold">{scanner.perf.cloudVision.toFixed(0)}ms</span>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import { BottleMetadata } from '@/types/whisky';
|
import { BottleMetadata } from '@/types/whisky';
|
||||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
||||||
import { analyzeLocalOcr, LocalOcrResult, terminateOcrWorker } from '@/lib/ocr/local-engine';
|
|
||||||
import { isTesseractReady, isOnline } from '@/lib/ocr/scanner-utils';
|
|
||||||
import { analyzeLabelWithGemini } from '@/app/actions/gemini-vision';
|
import { analyzeLabelWithGemini } from '@/app/actions/gemini-vision';
|
||||||
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
@@ -12,22 +10,19 @@ import { db } from '@/lib/db';
|
|||||||
export type ScanStatus =
|
export type ScanStatus =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'compressing'
|
| 'compressing'
|
||||||
| 'analyzing_local'
|
| 'analyzing'
|
||||||
| 'analyzing_cloud'
|
|
||||||
| 'complete'
|
| 'complete'
|
||||||
| 'queued'
|
| 'queued'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
status: ScanStatus;
|
status: ScanStatus;
|
||||||
localResult: Partial<BottleMetadata> | null;
|
|
||||||
cloudResult: BottleMetadata | null;
|
cloudResult: BottleMetadata | null;
|
||||||
mergedResult: BottleMetadata | null;
|
mergedResult: BottleMetadata | null;
|
||||||
processedImage: ProcessedImage | null;
|
processedImage: ProcessedImage | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
perf: {
|
perf: {
|
||||||
compression: number;
|
compression: number;
|
||||||
localOcr: number;
|
|
||||||
cloudVision: number;
|
cloudVision: number;
|
||||||
total: number;
|
total: number;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -35,25 +30,24 @@ export interface ScanResult {
|
|||||||
|
|
||||||
export interface UseScannerOptions {
|
export interface UseScannerOptions {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
onLocalComplete?: (result: Partial<BottleMetadata>) => void;
|
onComplete?: (result: BottleMetadata) => void;
|
||||||
onCloudComplete?: (result: BottleMetadata) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook for the hybrid Local OCR + Cloud Vision scanning flow
|
* React hook for Gemini Vision scanning
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Compress image (browser-side)
|
* 1. Compress image (browser-side)
|
||||||
* 2. Run local OCR (tesseract.js) → immediate preview
|
* 2. Run cloud vision (Gemini) → accurate results
|
||||||
* 3. Run cloud vision (Gemini) → refined results
|
* 3. Show editor for user refinement
|
||||||
* 4. Merge results (cloud overrides local, except user-edited fields)
|
*
|
||||||
|
* No local OCR - saves ~45MB of Tesseract files
|
||||||
*/
|
*/
|
||||||
export function useScanner(options: UseScannerOptions = {}) {
|
export function useScanner(options: UseScannerOptions = {}) {
|
||||||
const { locale = 'en', onLocalComplete, onCloudComplete } = options;
|
const { locale = 'en', onComplete } = options;
|
||||||
|
|
||||||
const [result, setResult] = useState<ScanResult>({
|
const [result, setResult] = useState<ScanResult>({
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
localResult: null,
|
|
||||||
cloudResult: null,
|
cloudResult: null,
|
||||||
mergedResult: null,
|
mergedResult: null,
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
@@ -64,69 +58,25 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
// Track which fields the user has manually edited
|
// Track which fields the user has manually edited
|
||||||
const dirtyFieldsRef = useRef<Set<string>>(new Set());
|
const dirtyFieldsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark a field as user-edited (won't be overwritten by cloud results)
|
|
||||||
*/
|
|
||||||
const markFieldDirty = useCallback((field: string) => {
|
const markFieldDirty = useCallback((field: string) => {
|
||||||
dirtyFieldsRef.current.add(field);
|
dirtyFieldsRef.current.add(field);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all dirty field markers
|
|
||||||
*/
|
|
||||||
const clearDirtyFields = useCallback(() => {
|
const clearDirtyFields = useCallback(() => {
|
||||||
dirtyFieldsRef.current.clear();
|
dirtyFieldsRef.current.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge local and cloud results, respecting user edits
|
|
||||||
*/
|
|
||||||
const mergeResults = useCallback((
|
|
||||||
local: Partial<BottleMetadata> | null,
|
|
||||||
cloud: BottleMetadata | null,
|
|
||||||
dirtyFields: Set<string>
|
|
||||||
): BottleMetadata | null => {
|
|
||||||
if (!cloud && !local) return null;
|
|
||||||
if (!cloud) {
|
|
||||||
return {
|
|
||||||
name: local?.name || null,
|
|
||||||
distillery: local?.distillery || null,
|
|
||||||
abv: local?.abv || null,
|
|
||||||
age: local?.age || null,
|
|
||||||
vintage: local?.vintage || null,
|
|
||||||
is_whisky: true,
|
|
||||||
confidence: 50,
|
|
||||||
} as BottleMetadata;
|
|
||||||
}
|
|
||||||
if (!local) return cloud;
|
|
||||||
|
|
||||||
// Start with cloud result as base
|
|
||||||
const merged = { ...cloud };
|
|
||||||
|
|
||||||
// For each field, keep local value if user edited it
|
|
||||||
for (const field of dirtyFields) {
|
|
||||||
if (field in local && (local as any)[field] !== undefined) {
|
|
||||||
(merged as any)[field] = (local as any)[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main scan handler
|
* Main scan handler
|
||||||
*/
|
*/
|
||||||
const handleScan = useCallback(async (file: File) => {
|
const handleScan = useCallback(async (file: File) => {
|
||||||
const perfStart = performance.now();
|
const perfStart = performance.now();
|
||||||
let perfCompression = 0;
|
let perfCompression = 0;
|
||||||
let perfLocalOcr = 0;
|
|
||||||
let perfCloudVision = 0;
|
let perfCloudVision = 0;
|
||||||
|
|
||||||
// Reset state
|
|
||||||
clearDirtyFields();
|
clearDirtyFields();
|
||||||
setResult({
|
setResult({
|
||||||
status: 'compressing',
|
status: 'compressing',
|
||||||
localResult: null,
|
|
||||||
cloudResult: null,
|
cloudResult: null,
|
||||||
mergedResult: null,
|
mergedResult: null,
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
@@ -140,23 +90,11 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
const processedImage = await processImageForAI(file);
|
const processedImage = await processImageForAI(file);
|
||||||
perfCompression = performance.now() - compressStart;
|
perfCompression = performance.now() - compressStart;
|
||||||
|
|
||||||
setResult(prev => ({
|
// Step 2: Check if offline
|
||||||
...prev,
|
if (!navigator.onLine) {
|
||||||
status: 'analyzing_local',
|
console.log('[useScanner] Offline → queuing for later');
|
||||||
processedImage,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Step 2: Check if we're offline or tesseract isn't ready
|
|
||||||
const online = isOnline();
|
|
||||||
const tesseractReady = await isTesseractReady();
|
|
||||||
|
|
||||||
// If offline and tesseract not ready, show editor with dummy data
|
|
||||||
// Queue image for later processing when back online
|
|
||||||
if (!online && !tesseractReady) {
|
|
||||||
console.log('[useScanner] Offline + no tesseract cache → showing editor with dummy data');
|
|
||||||
const dummyMetadata = generateDummyMetadata(file);
|
const dummyMetadata = generateDummyMetadata(file);
|
||||||
|
|
||||||
// Queue for later processing
|
|
||||||
await db.pending_scans.add({
|
await db.pending_scans.add({
|
||||||
temp_id: `temp_${Date.now()}`,
|
temp_id: `temp_${Date.now()}`,
|
||||||
imageBase64: processedImage.base64,
|
imageBase64: processedImage.base64,
|
||||||
@@ -165,77 +103,26 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
metadata: dummyMetadata as any,
|
metadata: dummyMetadata as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show editor with dummy data (status: complete so editor opens!)
|
setResult({
|
||||||
setResult(prev => ({
|
status: 'queued',
|
||||||
...prev,
|
cloudResult: null,
|
||||||
status: 'complete',
|
|
||||||
mergedResult: dummyMetadata,
|
mergedResult: dummyMetadata,
|
||||||
|
processedImage,
|
||||||
|
error: null,
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
localOcr: 0,
|
|
||||||
cloudVision: 0,
|
cloudVision: 0,
|
||||||
total: performance.now() - perfStart,
|
total: performance.now() - perfStart,
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Run local OCR (testing new line-by-line matching)
|
// Step 3: Run Gemini Vision
|
||||||
let localResult: Partial<BottleMetadata> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localStart = performance.now();
|
|
||||||
console.log('[useScanner] Running local OCR...');
|
|
||||||
const ocrResult = await analyzeLocalOcr(processedImage.file, 10000);
|
|
||||||
perfLocalOcr = performance.now() - localStart;
|
|
||||||
|
|
||||||
if (ocrResult.rawText && ocrResult.rawText.length > 10) {
|
|
||||||
localResult = {
|
|
||||||
name: ocrResult.name || undefined,
|
|
||||||
distillery: ocrResult.distillery || undefined,
|
|
||||||
abv: ocrResult.abv || undefined,
|
|
||||||
age: ocrResult.age || undefined,
|
|
||||||
vintage: ocrResult.vintage || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update state with local results
|
|
||||||
const localMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
|
|
||||||
setResult(prev => ({
|
setResult(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
localResult,
|
status: 'analyzing',
|
||||||
mergedResult: localMerged,
|
processedImage,
|
||||||
}));
|
|
||||||
|
|
||||||
onLocalComplete?.(localResult);
|
|
||||||
console.log('[useScanner] Local OCR complete:', localResult);
|
|
||||||
}
|
|
||||||
} catch (ocrError) {
|
|
||||||
console.warn('[useScanner] Local OCR failed:', ocrError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: If offline, use local results only
|
|
||||||
if (!online) {
|
|
||||||
console.log('[useScanner] Offline → using local results only');
|
|
||||||
const offlineMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
|
|
||||||
|
|
||||||
setResult(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'complete',
|
|
||||||
mergedResult: offlineMerged || generateDummyMetadata(file),
|
|
||||||
perf: {
|
|
||||||
compression: perfCompression,
|
|
||||||
localOcr: perfLocalOcr,
|
|
||||||
cloudVision: 0,
|
|
||||||
total: performance.now() - perfStart,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Run cloud vision analysis
|
|
||||||
setResult(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'analyzing_cloud',
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const cloudStart = performance.now();
|
const cloudStart = performance.now();
|
||||||
@@ -244,40 +131,38 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
|
|
||||||
if (cloudResponse.success && cloudResponse.data) {
|
if (cloudResponse.success && cloudResponse.data) {
|
||||||
const cloudResult = cloudResponse.data;
|
const cloudResult = cloudResponse.data;
|
||||||
const finalMerged = mergeResults(localResult, cloudResult, dirtyFieldsRef.current);
|
onComplete?.(cloudResult);
|
||||||
|
console.log('[useScanner] Gemini complete:', cloudResult);
|
||||||
|
|
||||||
onCloudComplete?.(cloudResult);
|
setResult({
|
||||||
console.log('[useScanner] Cloud vision complete:', cloudResult);
|
|
||||||
|
|
||||||
setResult(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
cloudResult,
|
cloudResult,
|
||||||
mergedResult: finalMerged,
|
mergedResult: cloudResult,
|
||||||
|
processedImage,
|
||||||
|
error: null,
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
localOcr: perfLocalOcr,
|
|
||||||
cloudVision: perfCloudVision,
|
cloudVision: perfCloudVision,
|
||||||
total: performance.now() - perfStart,
|
total: performance.now() - perfStart,
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
} else {
|
} else {
|
||||||
// Cloud failed, fall back to local results
|
// Gemini failed - show editor with dummy data
|
||||||
console.warn('[useScanner] Cloud vision failed:', cloudResponse.error);
|
console.warn('[useScanner] Gemini failed:', cloudResponse.error);
|
||||||
const fallbackMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
|
const fallback = generateDummyMetadata(file);
|
||||||
|
|
||||||
setResult(prev => ({
|
setResult({
|
||||||
...prev,
|
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
|
cloudResult: null,
|
||||||
|
mergedResult: fallback,
|
||||||
|
processedImage,
|
||||||
error: cloudResponse.error || null,
|
error: cloudResponse.error || null,
|
||||||
mergedResult: fallbackMerged || generateDummyMetadata(file),
|
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
localOcr: perfLocalOcr,
|
|
||||||
cloudVision: perfCloudVision,
|
cloudVision: perfCloudVision,
|
||||||
total: performance.now() - perfStart,
|
total: performance.now() - perfStart,
|
||||||
},
|
},
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -288,22 +173,17 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
error: error.message || 'Scan failed',
|
error: error.message || 'Scan failed',
|
||||||
perf: {
|
perf: {
|
||||||
compression: perfCompression,
|
compression: perfCompression,
|
||||||
localOcr: perfLocalOcr,
|
|
||||||
cloudVision: perfCloudVision,
|
cloudVision: perfCloudVision,
|
||||||
total: performance.now() - perfStart,
|
total: performance.now() - perfStart,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [locale, mergeResults, clearDirtyFields, onLocalComplete, onCloudComplete]);
|
}, [locale, clearDirtyFields, onComplete]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset scanner state
|
|
||||||
*/
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
clearDirtyFields();
|
clearDirtyFields();
|
||||||
setResult({
|
setResult({
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
localResult: null,
|
|
||||||
cloudResult: null,
|
cloudResult: null,
|
||||||
mergedResult: null,
|
mergedResult: null,
|
||||||
processedImage: null,
|
processedImage: null,
|
||||||
@@ -312,11 +192,7 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
});
|
});
|
||||||
}, [clearDirtyFields]);
|
}, [clearDirtyFields]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the merged result (for user edits)
|
|
||||||
*/
|
|
||||||
const updateMergedResult = useCallback((updates: Partial<BottleMetadata>) => {
|
const updateMergedResult = useCallback((updates: Partial<BottleMetadata>) => {
|
||||||
// Mark updated fields as dirty
|
|
||||||
Object.keys(updates).forEach(key => {
|
Object.keys(updates).forEach(key => {
|
||||||
dirtyFieldsRef.current.add(key);
|
dirtyFieldsRef.current.add(key);
|
||||||
});
|
});
|
||||||
@@ -327,12 +203,8 @@ export function useScanner(options: UseScannerOptions = {}) {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
// No cleanup needed without Tesseract
|
||||||
* Cleanup (terminate OCR worker)
|
const cleanup = useCallback(() => { }, []);
|
||||||
*/
|
|
||||||
const cleanup = useCallback(() => {
|
|
||||||
terminateOcrWorker();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
/**
|
|
||||||
* Local OCR Engine
|
|
||||||
* Client-side OCR using Tesseract.js with Fuse.js fuzzy matching
|
|
||||||
*
|
|
||||||
* Optimized for whisky label scanning with:
|
|
||||||
* - Image preprocessing (grayscale, binarization, center crop)
|
|
||||||
* - PSM 11 (Sparse text mode)
|
|
||||||
* - Character whitelisting
|
|
||||||
* - Bag-of-words fuzzy matching
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Tesseract from 'tesseract.js';
|
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
import { extractNumbers, ExtractedNumbers, preprocessImageForOCR } from './scanner-utils';
|
|
||||||
import distilleries from '@/data/distilleries.json';
|
|
||||||
|
|
||||||
export interface LocalOcrResult {
|
|
||||||
distillery: string | null;
|
|
||||||
distilleryRegion: string | null;
|
|
||||||
name: string | null;
|
|
||||||
age: number | null;
|
|
||||||
abv: number | null;
|
|
||||||
vintage: string | null;
|
|
||||||
rawText: string;
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fuse.js configuration for fuzzy matching distillery names
|
|
||||||
// Balanced matching to catch partial OCR errors while avoiding false positives
|
|
||||||
const fuseOptions = {
|
|
||||||
keys: ['name'],
|
|
||||||
threshold: 0.35, // 0 = exact match, 0.35 = allow some fuzziness
|
|
||||||
distance: 50, // Characters between matched chars
|
|
||||||
includeScore: true,
|
|
||||||
minMatchCharLength: 4, // Minimum chars to match
|
|
||||||
};
|
|
||||||
|
|
||||||
const distilleryFuse = new Fuse(distilleries, fuseOptions);
|
|
||||||
|
|
||||||
// Tesseract worker singleton (reused across scans)
|
|
||||||
let tesseractWorker: Tesseract.Worker | null = null;
|
|
||||||
|
|
||||||
// Character whitelist for whisky labels ("Pattern Hack")
|
|
||||||
// Restricts Tesseract to only whisky-relevant characters:
|
|
||||||
// - Letters: A-Z, a-z
|
|
||||||
// - Numbers: 0-9
|
|
||||||
// - Essential punctuation: .,%&-/ (for ABV "46.5%", names like "No. 1")
|
|
||||||
// - Space: for word separation
|
|
||||||
// This prevents garbage like ~, _, ^, {, § from appearing
|
|
||||||
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,%&-/ ';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize or get the Tesseract worker
|
|
||||||
* Uses local files from /public/tessdata for offline capability
|
|
||||||
*/
|
|
||||||
async function getWorker(): Promise<Tesseract.Worker> {
|
|
||||||
if (tesseractWorker) {
|
|
||||||
return tesseractWorker;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[LocalOCR] Initializing Tesseract worker with local files...');
|
|
||||||
|
|
||||||
// Use local files from /public/tessdata for full offline support
|
|
||||||
tesseractWorker = await Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
|
|
||||||
workerPath: '/tessdata/worker.min.js', // Local worker for offline
|
|
||||||
corePath: '/tessdata/',
|
|
||||||
langPath: '/tessdata/',
|
|
||||||
logger: (m) => {
|
|
||||||
if (m.status === 'recognizing text') {
|
|
||||||
console.log(`[LocalOCR] Progress: ${Math.round(m.progress * 100)}%`);
|
|
||||||
} else {
|
|
||||||
console.log(`[LocalOCR] ${m.status}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configure Tesseract for whisky label OCR
|
|
||||||
await tesseractWorker.setParameters({
|
|
||||||
tessedit_pageseg_mode: Tesseract.PSM.SINGLE_BLOCK, // PSM 6 - treat as single block of text
|
|
||||||
tessedit_char_whitelist: CHAR_WHITELIST,
|
|
||||||
preserve_interword_spaces: '1', // Keep word spacing
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[LocalOCR] Tesseract worker ready (PSM: SINGLE_BLOCK, Whitelist enabled)');
|
|
||||||
return tesseractWorker;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run OCR on an image and extract whisky metadata
|
|
||||||
*
|
|
||||||
* @param imageSource - File, Blob, or base64 string of the image
|
|
||||||
* @param timeoutMs - Maximum time to wait for OCR (default 10000ms)
|
|
||||||
* @returns LocalOcrResult with extracted metadata
|
|
||||||
*/
|
|
||||||
export async function analyzeLocalOcr(
|
|
||||||
imageSource: File | Blob | string,
|
|
||||||
timeoutMs: number = 10000
|
|
||||||
): Promise<LocalOcrResult> {
|
|
||||||
const result: LocalOcrResult = {
|
|
||||||
distillery: null,
|
|
||||||
distilleryRegion: null,
|
|
||||||
name: null,
|
|
||||||
age: null,
|
|
||||||
abv: null,
|
|
||||||
vintage: null,
|
|
||||||
rawText: '',
|
|
||||||
confidence: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Step 1: Preprocess the image for better OCR
|
|
||||||
let processedImage: string;
|
|
||||||
if (typeof imageSource === 'string') {
|
|
||||||
// Already a data URL, use as-is (can't preprocess string)
|
|
||||||
processedImage = imageSource;
|
|
||||||
console.log('[LocalOCR] Using raw image (string input)');
|
|
||||||
} else {
|
|
||||||
// Preprocess File/Blob: grayscale + sharpen + contrast boost
|
|
||||||
console.log('[LocalOCR] Preprocessing image...');
|
|
||||||
processedImage = await preprocessImageForOCR(imageSource);
|
|
||||||
// Uses defaults: 5% edge crop, 1200px height, sharpen=true, 1.3x contrast
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a timeout promise
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error('OCR timeout')), timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Race OCR against timeout
|
|
||||||
const worker = await getWorker();
|
|
||||||
const ocrResult = await Promise.race([
|
|
||||||
worker.recognize(processedImage),
|
|
||||||
timeoutPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
result.rawText = ocrResult.data.text;
|
|
||||||
result.confidence = ocrResult.data.confidence / 100; // Normalize to 0-1
|
|
||||||
|
|
||||||
// Extract numbers using regex (this works reliably)
|
|
||||||
const numbers = extractNumbers(result.rawText);
|
|
||||||
result.abv = numbers.abv;
|
|
||||||
result.age = numbers.age;
|
|
||||||
result.vintage = numbers.vintage;
|
|
||||||
|
|
||||||
// NOTE: Distillery fuzzy matching disabled - causes too many false positives
|
|
||||||
// with noisy OCR text. Let Gemini Vision handle distillery identification.
|
|
||||||
// const distilleryMatch = findDistillery(result.rawText);
|
|
||||||
// if (distilleryMatch) {
|
|
||||||
// result.distillery = distilleryMatch.name;
|
|
||||||
// result.distilleryRegion = distilleryMatch.region;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Fuzzy match distillery (new algorithm with sanity checks)
|
|
||||||
const distilleryMatch = findDistillery(result.rawText);
|
|
||||||
if (distilleryMatch) {
|
|
||||||
result.distillery = distilleryMatch.name;
|
|
||||||
result.distilleryRegion = distilleryMatch.region;
|
|
||||||
|
|
||||||
// Use contextual age if regex extraction failed
|
|
||||||
if (!result.age && distilleryMatch.contextualAge) {
|
|
||||||
result.age = distilleryMatch.contextualAge;
|
|
||||||
console.log(`[LocalOCR] Using contextual age: ${result.age}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Name extraction disabled - Tesseract too noisy for full bottle names
|
|
||||||
// Let Gemini Vision handle the name field
|
|
||||||
// result.name = extractName(result.rawText, result.distillery);
|
|
||||||
result.name = null;
|
|
||||||
|
|
||||||
// Detailed logging for debugging
|
|
||||||
const cleanedText = result.rawText
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line.length > 0)
|
|
||||||
.join(' | ');
|
|
||||||
|
|
||||||
console.log('[LocalOCR] ========== OCR RESULTS ==========');
|
|
||||||
console.log('[LocalOCR] Raw Text:\n', result.rawText);
|
|
||||||
console.log('[LocalOCR] Cleaned Text:', cleanedText);
|
|
||||||
console.log('[LocalOCR] Confidence:', (result.confidence * 100).toFixed(1) + '%');
|
|
||||||
console.log('[LocalOCR] Extracted Data:', {
|
|
||||||
distillery: result.distillery,
|
|
||||||
distilleryRegion: result.distilleryRegion,
|
|
||||||
name: result.name,
|
|
||||||
age: result.age,
|
|
||||||
abv: result.abv,
|
|
||||||
vintage: result.vintage,
|
|
||||||
});
|
|
||||||
console.log('[LocalOCR] ===================================');
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[LocalOCR] Analysis failed:', error);
|
|
||||||
return result; // Return partial/empty result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a distillery name in OCR text using fuzzy matching
|
|
||||||
*
|
|
||||||
* Strategy:
|
|
||||||
* 1. Normalize whitespace (fix Tesseract's formatting gaps)
|
|
||||||
* 2. Split OCR text into lines, filter garbage
|
|
||||||
* 3. "Strip & Match": Remove numbers before Fuse matching (helps with "N NEVIS 27")
|
|
||||||
* 4. Sanity check: match length must be reasonable
|
|
||||||
* 5. Contextual age: if distillery found, look for age in original line
|
|
||||||
*/
|
|
||||||
function findDistillery(text: string): { name: string; region: string; contextualAge?: number } | null {
|
|
||||||
// Split into lines, normalize whitespace, and filter garbage
|
|
||||||
const lines = text
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim().replace(/\s+/g, ' ')) // Normalize whitespace
|
|
||||||
.filter(line => {
|
|
||||||
// Minimum 4 characters
|
|
||||||
if (line.length < 4) return false;
|
|
||||||
// Must have at least 40% letters (lowered to allow lines with numbers)
|
|
||||||
const letters = line.replace(/[^a-zA-Z]/g, '');
|
|
||||||
return letters.length >= line.length * 0.4;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[LocalOCR] Lines for distillery matching:', lines.length);
|
|
||||||
|
|
||||||
// Blacklist common whisky words that shouldn't match distillery names
|
|
||||||
const blacklistedWords = new Set([
|
|
||||||
'reserve', 'malt', 'single', 'whisky', 'whiskey', 'scotch', 'bourbon',
|
|
||||||
'blended', 'irish', 'aged', 'years', 'edition', 'cask', 'barrel',
|
|
||||||
'distillery', 'vintage', 'special', 'limited', 'rare', 'old', 'gold',
|
|
||||||
'spirit', 'spirits', 'proof', 'strength', 'batch', 'select', 'finish'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Try to match each line using sliding word windows
|
|
||||||
for (const originalLine of lines) {
|
|
||||||
// STRIP & MATCH: Remove numbers for cleaner Fuse matching
|
|
||||||
const textOnlyLine = originalLine.replace(/[0-9]/g, '').replace(/\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
if (textOnlyLine.length < 4) continue;
|
|
||||||
|
|
||||||
// Split into words for window matching
|
|
||||||
const words = textOnlyLine.split(' ').filter(w => w.length >= 2);
|
|
||||||
|
|
||||||
// Try different window sizes (1-3 words) to find distillery within garbage
|
|
||||||
// E.g., "ge OO BEN NEVIS" → try "BEN NEVIS", "OO BEN", "BEN", etc.
|
|
||||||
for (let windowSize = Math.min(3, words.length); windowSize >= 1; windowSize--) {
|
|
||||||
for (let i = 0; i <= words.length - windowSize; i++) {
|
|
||||||
const phrase = words.slice(i, i + windowSize).join(' ');
|
|
||||||
|
|
||||||
if (phrase.length < 4) continue;
|
|
||||||
|
|
||||||
// Skip blacklisted common words
|
|
||||||
if (blacklistedWords.has(phrase.toLowerCase())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = distilleryFuse.search(phrase);
|
|
||||||
|
|
||||||
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.3) {
|
|
||||||
const match = results[0].item;
|
|
||||||
const matchScore = results[0].score;
|
|
||||||
|
|
||||||
// SANITY CHECK: Length ratio should be reasonable (0.6 - 1.5)
|
|
||||||
const lengthRatio = phrase.length / match.name.length;
|
|
||||||
if (lengthRatio < 0.6 || lengthRatio > 1.5) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CONTEXTUAL AGE DETECTION: Look for 2-digit number (3-60) in ORIGINAL line
|
|
||||||
let contextualAge: number | undefined;
|
|
||||||
const ageMatch = originalLine.match(/\b(\d{1,2})\b/);
|
|
||||||
if (ageMatch) {
|
|
||||||
const potentialAge = parseInt(ageMatch[1], 10);
|
|
||||||
if (potentialAge >= 3 && potentialAge <= 60) {
|
|
||||||
contextualAge = potentialAge;
|
|
||||||
console.log(`[LocalOCR] Contextual age detected: ${potentialAge} years`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[LocalOCR] Distillery match: "${phrase}" → ${match.name} (score: ${matchScore.toFixed(3)}, original: "${originalLine}")`);
|
|
||||||
return {
|
|
||||||
name: match.name,
|
|
||||||
region: match.region,
|
|
||||||
contextualAge,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract a potential bottle name from OCR text
|
|
||||||
*/
|
|
||||||
function extractName(text: string, distillery: string | null): string | null {
|
|
||||||
const lines = text
|
|
||||||
.split('\n')
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(line => {
|
|
||||||
// Minimum 5 characters
|
|
||||||
if (line.length < 5) return false;
|
|
||||||
// Must have at least 60% letters (filter out garbage like "ee" or "4 . .")
|
|
||||||
const letters = line.replace(/[^a-zA-Z]/g, '');
|
|
||||||
if (letters.length < line.length * 0.6) return false;
|
|
||||||
// Skip lines that are just punctuation/numbers
|
|
||||||
if (/^[\d\s.,\-'"]+$/.test(line)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Skip lines that are just the distillery name
|
|
||||||
const candidates = lines.filter(line => {
|
|
||||||
if (distillery && line.toLowerCase().includes(distillery.toLowerCase())) {
|
|
||||||
// Only skip if the line IS the distillery name (not contains more)
|
|
||||||
return line.length > distillery.length + 5;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return the first substantial line (likely the bottle name)
|
|
||||||
for (const line of candidates) {
|
|
||||||
// Skip lines that look like numbers only
|
|
||||||
if (/^\d+[\s%]+/.test(line)) continue;
|
|
||||||
// Skip lines that are just common whisky words
|
|
||||||
if (/^(single|malt|scotch|whisky|whiskey|aged|years?|proof|edition|distilled|distillery)$/i.test(line)) continue;
|
|
||||||
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminate the Tesseract worker (call on cleanup)
|
|
||||||
*/
|
|
||||||
export async function terminateOcrWorker(): Promise<void> {
|
|
||||||
if (tesseractWorker) {
|
|
||||||
await tesseractWorker.terminate();
|
|
||||||
tesseractWorker = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,440 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scanner Utilities
|
|
||||||
* Cache checking and helper functions for client-side OCR
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Tesseract.js is ready to run
|
|
||||||
* When online, tesseract will auto-download from CDN, so return true
|
|
||||||
* When offline, check if files are cached
|
|
||||||
* @returns Promise<boolean> - true if OCR can run
|
|
||||||
*/
|
|
||||||
export async function isTesseractReady(): Promise<boolean> {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If online, tesseract.js will auto-download what it needs
|
|
||||||
if (navigator.onLine) {
|
|
||||||
console.log('[Scanner] Online - tesseract will use CDN');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If offline, check cache
|
|
||||||
if (!('caches' in window)) {
|
|
||||||
console.log('[Scanner] Offline + no cache API - tesseract not ready');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check for the core files in cache
|
|
||||||
// Try to find files in any cache (not just default)
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
console.log('[Scanner] Available caches:', cacheNames);
|
|
||||||
|
|
||||||
let wasmMatch = false;
|
|
||||||
let langMatch = false;
|
|
||||||
|
|
||||||
for (const cacheName of cacheNames) {
|
|
||||||
const cache = await caches.open(cacheName);
|
|
||||||
const keys = await cache.keys();
|
|
||||||
|
|
||||||
for (const request of keys) {
|
|
||||||
const url = request.url;
|
|
||||||
if (url.includes('tesseract-core') && url.includes('.wasm')) {
|
|
||||||
wasmMatch = true;
|
|
||||||
}
|
|
||||||
if (url.includes('eng.traineddata')) {
|
|
||||||
langMatch = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = wasmMatch && langMatch;
|
|
||||||
console.log('[Scanner] Offline cache check:', { wasmMatch, langMatch, ready, cacheCount: cacheNames.length });
|
|
||||||
return ready;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[Scanner] Cache check failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract numeric values from OCR text using regex patterns
|
|
||||||
*/
|
|
||||||
export interface ExtractedNumbers {
|
|
||||||
abv: number | null;
|
|
||||||
age: number | null;
|
|
||||||
vintage: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractNumbers(text: string): ExtractedNumbers {
|
|
||||||
const result: ExtractedNumbers = {
|
|
||||||
abv: null,
|
|
||||||
age: null,
|
|
||||||
vintage: null
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!text) return result;
|
|
||||||
|
|
||||||
// ========== ABV EXTRACTION (Enhanced) ==========
|
|
||||||
// Step 1: Normalize text for common Tesseract OCR mistakes
|
|
||||||
let normalizedText = text
|
|
||||||
// Fix % misread as numbers or text
|
|
||||||
.replace(/96/g, '%') // Tesseract often reads % as 96
|
|
||||||
.replace(/o\/o/gi, '%') // o/o → %
|
|
||||||
.replace(/°\/o/gi, '%') // °/o → %
|
|
||||||
.replace(/0\/0/g, '%') // 0/0 → %
|
|
||||||
// Fix common letter/number confusions
|
|
||||||
.replace(/[oO](?=\d)/g, '0') // O before digit → 0 (e.g., "O5" → "05")
|
|
||||||
.replace(/(?<=\d)[oO]/g, '0') // O after digit → 0 (e.g., "5O" → "50")
|
|
||||||
.replace(/[lI](?=\d)/g, '1') // l/I before digit → 1
|
|
||||||
.replace(/(?<=\d)[lI]/g, '1') // l/I after digit → 1
|
|
||||||
// Normalize decimal separators
|
|
||||||
.replace(/,/g, '.');
|
|
||||||
|
|
||||||
// Step 2: ABV patterns - looking for number before % or Vol
|
|
||||||
const abvPatterns = [
|
|
||||||
/(\d{2}\.?\d{0,2})\s*%/, // 43%, 43.5%, 57.1%
|
|
||||||
/(\d{2}\.?\d{0,2})\s*(?:vol|alc)/i, // 43 vol, 43.5 alc
|
|
||||||
/(?:abv|alc|vol)[:\s]*(\d{2}\.?\d{0,2})/i, // ABV: 43, vol. 43.5
|
|
||||||
/(\d{2}\.?\d{0,2})\s*(?:percent|prozent)/i, // 43 percent/prozent
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of abvPatterns) {
|
|
||||||
const match = normalizedText.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const value = parseFloat(match[1]);
|
|
||||||
// STRICT RANGE GUARD: Only accept 35.0 - 75.0
|
|
||||||
// This prevents misidentifying years (1996) or volumes (700ml)
|
|
||||||
if (value >= 35.0 && value <= 75.0) {
|
|
||||||
result.abv = value;
|
|
||||||
console.log(`[ABV] Detected: ${value}% from pattern: ${pattern.source}`);
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
console.log(`[ABV] Rejected ${value} - outside 35-75 range`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== AGE & VINTAGE (unchanged but use normalized text) ==========
|
|
||||||
|
|
||||||
// Age patterns: "12 years", "12 year old", "12 YO", "aged 12"
|
|
||||||
const agePatterns = [
|
|
||||||
/(\d{1,2})\s*(?:years?|yrs?|y\.?o\.?|jahre?)/i,
|
|
||||||
/aged\s*(\d{1,2})/i,
|
|
||||||
/(\d{1,2})\s*year\s*old/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of agePatterns) {
|
|
||||||
const match = text.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const value = parseInt(match[1], 10);
|
|
||||||
if (value >= 3 && value <= 60) { // Reasonable whisky age range
|
|
||||||
result.age = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vintage patterns: "1990", "Vintage 1990", "Distilled 1990"
|
|
||||||
const vintagePatterns = [
|
|
||||||
/(?:vintage|distilled|dist\.?)\s*(19\d{2}|20[0-2]\d)/i,
|
|
||||||
/\b(19[789]\d|20[0-2]\d)\b/, // Years 1970-2029
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of vintagePatterns) {
|
|
||||||
const match = text.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const year = parseInt(match[1], 10);
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
if (year >= 1970 && year <= currentYear) {
|
|
||||||
result.vintage = match[1];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an image blob to base64 string
|
|
||||||
*/
|
|
||||||
export function imageToBase64(blob: Blob): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
if (typeof reader.result === 'string') {
|
|
||||||
resolve(reader.result);
|
|
||||||
} else {
|
|
||||||
reject(new Error('Failed to convert image to base64'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the browser is online
|
|
||||||
*/
|
|
||||||
export function isOnline(): boolean {
|
|
||||||
return typeof navigator !== 'undefined' && navigator.onLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for image preprocessing
|
|
||||||
*/
|
|
||||||
export interface PreprocessOptions {
|
|
||||||
/** Crop left/right edges (0-0.25) to remove bottle curves. Default: 0.05 */
|
|
||||||
edgeCrop?: number;
|
|
||||||
/** Target height for resizing. Default: 1200 */
|
|
||||||
targetHeight?: number;
|
|
||||||
/** Apply simple binarization (hard black/white). Default: false */
|
|
||||||
binarize?: boolean;
|
|
||||||
/** Apply adaptive thresholding (better for uneven lighting). Default: true */
|
|
||||||
adaptiveThreshold?: boolean;
|
|
||||||
/** Contrast boost factor (1.0 = no change). Default: 1.3 */
|
|
||||||
contrastBoost?: number;
|
|
||||||
/** Apply sharpening. Default: false */
|
|
||||||
sharpen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Preprocess an image for better OCR results
|
|
||||||
*
|
|
||||||
* Applies:
|
|
||||||
* 1. Center crop (removes curved bottle edges)
|
|
||||||
* 2. Resize to optimal OCR size
|
|
||||||
* 3. Grayscale conversion
|
|
||||||
* 4. Sharpening (helps with blurry text)
|
|
||||||
* 5. Contrast enhancement
|
|
||||||
* 6. Optional binarization
|
|
||||||
*
|
|
||||||
* @param imageSource - File, Blob, or HTMLImageElement
|
|
||||||
* @param options - Preprocessing options
|
|
||||||
* @returns Promise<string> - Preprocessed image as data URL
|
|
||||||
*/
|
|
||||||
export async function preprocessImageForOCR(
|
|
||||||
imageSource: File | Blob | HTMLImageElement,
|
|
||||||
options: PreprocessOptions = {}
|
|
||||||
): Promise<string> {
|
|
||||||
const {
|
|
||||||
edgeCrop = 0.05, // Remove 5% from each edge (minimal)
|
|
||||||
targetHeight = 1200, // High resolution
|
|
||||||
binarize = false, // Simple binarization (global threshold)
|
|
||||||
adaptiveThreshold = true, // Adaptive thresholding (local threshold) - better for uneven lighting
|
|
||||||
contrastBoost = 1.3, // 30% contrast boost (only if not using adaptive)
|
|
||||||
sharpen = false, // Disabled - creates noise on photos
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// Load image into an HTMLImageElement if not already
|
|
||||||
let img: HTMLImageElement;
|
|
||||||
|
|
||||||
if (imageSource instanceof HTMLImageElement) {
|
|
||||||
img = imageSource;
|
|
||||||
} else {
|
|
||||||
img = await loadImageFromBlob(imageSource as Blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create canvas
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
|
||||||
|
|
||||||
// Calculate crop dimensions (remove edges to focus on center)
|
|
||||||
const cropX = Math.floor(img.width * edgeCrop);
|
|
||||||
const cropWidth = img.width - (cropX * 2);
|
|
||||||
const cropHeight = img.height;
|
|
||||||
|
|
||||||
// Calculate resize dimensions (maintain aspect ratio)
|
|
||||||
const scale = targetHeight / cropHeight;
|
|
||||||
const newWidth = Math.floor(cropWidth * scale);
|
|
||||||
const newHeight = targetHeight;
|
|
||||||
|
|
||||||
canvas.width = newWidth;
|
|
||||||
canvas.height = newHeight;
|
|
||||||
|
|
||||||
// Draw cropped & resized image
|
|
||||||
ctx.drawImage(
|
|
||||||
img,
|
|
||||||
cropX, 0, cropWidth, cropHeight, // Source: center crop
|
|
||||||
0, 0, newWidth, newHeight // Destination: full canvas
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get pixel data for processing
|
|
||||||
const imageData = ctx.getImageData(0, 0, newWidth, newHeight);
|
|
||||||
const data = imageData.data;
|
|
||||||
|
|
||||||
// First pass: Convert to grayscale
|
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
|
||||||
const r = data[i];
|
|
||||||
const g = data[i + 1];
|
|
||||||
const b = data[i + 2];
|
|
||||||
const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
||||||
data[i] = data[i + 1] = data[i + 2] = gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply sharpening using a 3x3 kernel
|
|
||||||
if (sharpen) {
|
|
||||||
const tempData = new Uint8ClampedArray(data);
|
|
||||||
// Sharpen kernel: enhances edges
|
|
||||||
// [ 0, -1, 0]
|
|
||||||
// [-1, 5, -1]
|
|
||||||
// [ 0, -1, 0]
|
|
||||||
const kernel = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
|
||||||
|
|
||||||
for (let y = 1; y < newHeight - 1; y++) {
|
|
||||||
for (let x = 1; x < newWidth - 1; x++) {
|
|
||||||
let sum = 0;
|
|
||||||
for (let ky = -1; ky <= 1; ky++) {
|
|
||||||
for (let kx = -1; kx <= 1; kx++) {
|
|
||||||
const idx = ((y + ky) * newWidth + (x + kx)) * 4;
|
|
||||||
const ki = (ky + 1) * 3 + (kx + 1);
|
|
||||||
sum += tempData[idx] * kernel[ki];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const idx = (y * newWidth + x) * 4;
|
|
||||||
const clamped = Math.min(255, Math.max(0, sum));
|
|
||||||
data[idx] = data[idx + 1] = data[idx + 2] = clamped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put processed data back (after grayscale conversion)
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
// Apply adaptive or simple binarization/contrast
|
|
||||||
if (adaptiveThreshold) {
|
|
||||||
// ========== ADAPTIVE THRESHOLDING ==========
|
|
||||||
// Uses integral image for efficient local mean calculation
|
|
||||||
// Better for uneven lighting on curved bottles
|
|
||||||
const adaptiveData = ctx.getImageData(0, 0, newWidth, newHeight);
|
|
||||||
const pixels = adaptiveData.data;
|
|
||||||
|
|
||||||
// Window size: ~1/20th of image width, minimum 11, must be odd
|
|
||||||
let windowSize = Math.max(11, Math.floor(newWidth / 20));
|
|
||||||
if (windowSize % 2 === 0) windowSize++;
|
|
||||||
const halfWindow = Math.floor(windowSize / 2);
|
|
||||||
|
|
||||||
// Sauvola-style constant: lower = more sensitive to text
|
|
||||||
const k = 0.15;
|
|
||||||
|
|
||||||
// Build integral image for fast local sum calculation
|
|
||||||
const integral = new Float64Array((newWidth + 1) * (newHeight + 1));
|
|
||||||
const integralSq = new Float64Array((newWidth + 1) * (newHeight + 1));
|
|
||||||
|
|
||||||
for (let y = 0; y < newHeight; y++) {
|
|
||||||
let rowSum = 0;
|
|
||||||
let rowSumSq = 0;
|
|
||||||
for (let x = 0; x < newWidth; x++) {
|
|
||||||
const idx = (y * newWidth + x) * 4;
|
|
||||||
const gray = pixels[idx];
|
|
||||||
rowSum += gray;
|
|
||||||
rowSumSq += gray * gray;
|
|
||||||
|
|
||||||
const iIdx = (y + 1) * (newWidth + 1) + (x + 1);
|
|
||||||
const iIdxAbove = y * (newWidth + 1) + (x + 1);
|
|
||||||
integral[iIdx] = rowSum + integral[iIdxAbove];
|
|
||||||
integralSq[iIdx] = rowSumSq + integralSq[iIdxAbove];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply adaptive threshold
|
|
||||||
const output = new Uint8ClampedArray(pixels.length);
|
|
||||||
for (let y = 0; y < newHeight; y++) {
|
|
||||||
for (let x = 0; x < newWidth; x++) {
|
|
||||||
// Calculate local window bounds
|
|
||||||
const x1 = Math.max(0, x - halfWindow);
|
|
||||||
const y1 = Math.max(0, y - halfWindow);
|
|
||||||
const x2 = Math.min(newWidth - 1, x + halfWindow);
|
|
||||||
const y2 = Math.min(newHeight - 1, y + halfWindow);
|
|
||||||
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
|
|
||||||
|
|
||||||
// Get local sum and sum of squares using integral image
|
|
||||||
const i11 = y1 * (newWidth + 1) + x1;
|
|
||||||
const i12 = y1 * (newWidth + 1) + (x2 + 1);
|
|
||||||
const i21 = (y2 + 1) * (newWidth + 1) + x1;
|
|
||||||
const i22 = (y2 + 1) * (newWidth + 1) + (x2 + 1);
|
|
||||||
|
|
||||||
const sum = integral[i22] - integral[i21] - integral[i12] + integral[i11];
|
|
||||||
const sumSq = integralSq[i22] - integralSq[i21] - integralSq[i12] + integralSq[i11];
|
|
||||||
|
|
||||||
const mean = sum / count;
|
|
||||||
const variance = (sumSq / count) - (mean * mean);
|
|
||||||
const stddev = Math.sqrt(Math.max(0, variance));
|
|
||||||
|
|
||||||
// Sauvola threshold: T = mean * (1 + k * (stddev/R - 1))
|
|
||||||
// R = dynamic range = 128 for grayscale
|
|
||||||
const threshold = mean * (1 + k * (stddev / 128 - 1));
|
|
||||||
|
|
||||||
const idx = (y * newWidth + x) * 4;
|
|
||||||
const pixel = pixels[idx];
|
|
||||||
const binaryValue = pixel < threshold ? 0 : 255;
|
|
||||||
|
|
||||||
output[idx] = output[idx + 1] = output[idx + 2] = binaryValue;
|
|
||||||
output[idx + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy output back
|
|
||||||
for (let i = 0; i < pixels.length; i++) {
|
|
||||||
pixels[i] = output[i];
|
|
||||||
}
|
|
||||||
ctx.putImageData(adaptiveData, 0, 0);
|
|
||||||
|
|
||||||
console.log('[PreprocessOCR] Adaptive thresholding applied:', {
|
|
||||||
windowSize,
|
|
||||||
k,
|
|
||||||
imageSize: `${newWidth}x${newHeight}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Simple contrast enhancement + optional global binarization
|
|
||||||
const simpleData = ctx.getImageData(0, 0, newWidth, newHeight);
|
|
||||||
const pixels = simpleData.data;
|
|
||||||
|
|
||||||
for (let i = 0; i < pixels.length; i += 4) {
|
|
||||||
let gray = pixels[i];
|
|
||||||
gray = ((gray - 128) * contrastBoost) + 128;
|
|
||||||
gray = Math.min(255, Math.max(0, gray));
|
|
||||||
|
|
||||||
if (binarize) {
|
|
||||||
gray = gray >= 128 ? 255 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
pixels[i] = pixels[i + 1] = pixels[i + 2] = gray;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.putImageData(simpleData, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[PreprocessOCR] Image preprocessed:', {
|
|
||||||
original: `${img.width}x${img.height}`,
|
|
||||||
cropped: `${cropWidth}x${cropHeight} (${(edgeCrop * 100).toFixed(0)}% edge crop)`,
|
|
||||||
final: `${newWidth}x${newHeight}`,
|
|
||||||
sharpen,
|
|
||||||
mode: adaptiveThreshold ? 'adaptive-threshold' : (binarize ? 'binarized' : 'grayscale+contrast'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return canvas.toDataURL('image/png');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load an image from a Blob/File into an HTMLImageElement
|
|
||||||
*/
|
|
||||||
function loadImageFromBlob(blob: Blob): Promise<HTMLImageElement> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve(img);
|
|
||||||
};
|
|
||||||
img.onerror = () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
reject(new Error('Failed to load image'));
|
|
||||||
};
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -33,16 +33,9 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Debug: Log all cookies
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const allCookies = request.cookies.getAll();
|
|
||||||
const sbCookies = allCookies.filter(c => c.name.startsWith('sb-'));
|
|
||||||
if (!url.pathname.startsWith('/_next') && !url.pathname.includes('.')) {
|
|
||||||
console.log('[Proxy] Cookies:', sbCookies.map(c => `${c.name}=${c.value.slice(0, 20)}...`));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
||||||
|
|
||||||
if (!isStatic) {
|
if (!isStatic) {
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ export async function createSplit(data: CreateSplitData): Promise<{
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
console.log('[createSplit] Auth result:', { userId: user?.id, authError });
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return { success: false, error: 'Nicht autorisiert' };
|
return { success: false, error: 'Nicht autorisiert' };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user