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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
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 SessionBottomSheet from './SessionBottomSheet';
|
||||
import ResultCard from './ResultCard';
|
||||
@@ -40,26 +40,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||
|
||||
// Use the new hybrid scanner hook
|
||||
// Use the Gemini-only scanner hook
|
||||
const scanner = useScanner({
|
||||
locale,
|
||||
onLocalComplete: (localResult) => {
|
||||
console.log('[ScanFlow] Local OCR complete, updating preview:', localResult);
|
||||
// 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")
|
||||
onComplete: (cloudResult) => {
|
||||
console.log('[ScanFlow] Gemini complete:', cloudResult);
|
||||
setBottleMetadata(cloudResult);
|
||||
|
||||
// Trigger background enrichment if we have name and distillery
|
||||
@@ -153,30 +138,20 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
|
||||
if (scannerStatus === 'idle') {
|
||||
// Don't change state on idle
|
||||
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing_local') {
|
||||
} else if (scannerStatus === 'compressing' || scannerStatus === 'analyzing') {
|
||||
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') {
|
||||
// Use merged result from scanner
|
||||
if (scanner.mergedResult) {
|
||||
setBottleMetadata(scanner.mergedResult);
|
||||
}
|
||||
setState('EDITOR');
|
||||
|
||||
// If this was a queued offline scan, mark as fallback
|
||||
if (scannerStatus === 'queued') {
|
||||
setAiFallbackActive(true);
|
||||
setIsOffline(true);
|
||||
}
|
||||
} else if (scannerStatus === 'error') {
|
||||
if (scanner.mergedResult) {
|
||||
// We have partial results, show editor anyway
|
||||
setBottleMetadata(scanner.mergedResult);
|
||||
setState('EDITOR');
|
||||
setAiFallbackActive(true);
|
||||
@@ -185,7 +160,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
setState('ERROR');
|
||||
}
|
||||
}
|
||||
}, [scanner.status, scanner.mergedResult, scanner.localResult, scanner.error]);
|
||||
}, [scanner.status, scanner.mergedResult, scanner.error]);
|
||||
|
||||
const handleScan = async (file: File) => {
|
||||
setState('SCANNING');
|
||||
@@ -206,7 +181,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
|
||||
// Check for existing pending scan with same image
|
||||
const existingScan = await db.pending_scans
|
||||
.filter(s => s.imageBase64 === scanner.processedImage!.base64)
|
||||
.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({
|
||||
pending_bottle_id: currentTempId,
|
||||
data: {
|
||||
@@ -258,13 +231,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
|
||||
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
|
||||
setIsOffline(true);
|
||||
// Retry save in offline mode
|
||||
return handleSaveTasting(formData);
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
|
||||
// Save Bottle
|
||||
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
|
||||
const bottleResult = await saveBottle(bottleDataToSave, scanner.processedImage.base64, user.id);
|
||||
if (!bottleResult.success || !bottleResult.data) {
|
||||
@@ -273,7 +244,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
|
||||
const bottleId = bottleResult.data.id;
|
||||
|
||||
// Save Tasting
|
||||
const tastingNote = {
|
||||
...formData,
|
||||
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 } => {
|
||||
switch (status) {
|
||||
case 'compressing':
|
||||
return { text: 'Bild optimieren...', icon: <Loader2 size={12} className="animate-spin" /> };
|
||||
case 'analyzing_local':
|
||||
return { text: 'Lokale OCR-Analyse...', icon: <Cpu size={12} /> };
|
||||
case 'analyzing_cloud':
|
||||
case 'analyzing':
|
||||
return { text: 'KI-Vision-Analyse...', icon: <Cloud size={12} /> };
|
||||
default:
|
||||
return { text: 'Analysiere Etikett...', icon: <Loader2 size={12} className="animate-spin" /> };
|
||||
@@ -373,13 +340,10 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
</p>
|
||||
{/* Show scan stage indicators */}
|
||||
<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'
|
||||
}`} />
|
||||
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing_local', 'analyzing_cloud', '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)
|
||||
<div className={`w-2 h-2 rounded-full transition-colors ${['analyzing', 'complete'].includes(scanner.status)
|
||||
? 'bg-orange-500' : 'bg-zinc-700'
|
||||
}`} />
|
||||
</div>
|
||||
@@ -389,15 +353,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
{/* Admin perf metrics */}
|
||||
{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="grid grid-cols-3 gap-6 text-center">
|
||||
<div className="grid grid-cols-2 gap-6 text-center">
|
||||
<div>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
@@ -454,22 +414,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
</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
|
||||
bottleMetadata={bottleMetadata}
|
||||
image={scanner.processedImage?.base64 || null}
|
||||
@@ -481,7 +425,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
|
||||
{/* Admin perf overlay - positioned at bottom */}
|
||||
{/* Admin perf overlay */}
|
||||
{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="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-orange-500 font-bold">{scanner.perf.compression.toFixed(0)}ms</span>
|
||||
</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">
|
||||
<span className="text-zinc-500">CLOUD:</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 { BottleMetadata } from '@/types/whisky';
|
||||
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 { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
|
||||
import { db } from '@/lib/db';
|
||||
@@ -12,22 +10,19 @@ import { db } from '@/lib/db';
|
||||
export type ScanStatus =
|
||||
| 'idle'
|
||||
| 'compressing'
|
||||
| 'analyzing_local'
|
||||
| 'analyzing_cloud'
|
||||
| 'analyzing'
|
||||
| 'complete'
|
||||
| 'queued'
|
||||
| 'error';
|
||||
|
||||
export interface ScanResult {
|
||||
status: ScanStatus;
|
||||
localResult: Partial<BottleMetadata> | null;
|
||||
cloudResult: BottleMetadata | null;
|
||||
mergedResult: BottleMetadata | null;
|
||||
processedImage: ProcessedImage | null;
|
||||
error: string | null;
|
||||
perf: {
|
||||
compression: number;
|
||||
localOcr: number;
|
||||
cloudVision: number;
|
||||
total: number;
|
||||
} | null;
|
||||
@@ -35,25 +30,24 @@ export interface ScanResult {
|
||||
|
||||
export interface UseScannerOptions {
|
||||
locale?: string;
|
||||
onLocalComplete?: (result: Partial<BottleMetadata>) => void;
|
||||
onCloudComplete?: (result: BottleMetadata) => void;
|
||||
onComplete?: (result: BottleMetadata) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for the hybrid Local OCR + Cloud Vision scanning flow
|
||||
* React hook for Gemini Vision scanning
|
||||
*
|
||||
* Flow:
|
||||
* 1. Compress image (browser-side)
|
||||
* 2. Run local OCR (tesseract.js) → immediate preview
|
||||
* 3. Run cloud vision (Gemini) → refined results
|
||||
* 4. Merge results (cloud overrides local, except user-edited fields)
|
||||
* 2. Run cloud vision (Gemini) → accurate results
|
||||
* 3. Show editor for user refinement
|
||||
*
|
||||
* No local OCR - saves ~45MB of Tesseract files
|
||||
*/
|
||||
export function useScanner(options: UseScannerOptions = {}) {
|
||||
const { locale = 'en', onLocalComplete, onCloudComplete } = options;
|
||||
const { locale = 'en', onComplete } = options;
|
||||
|
||||
const [result, setResult] = useState<ScanResult>({
|
||||
status: 'idle',
|
||||
localResult: null,
|
||||
cloudResult: null,
|
||||
mergedResult: null,
|
||||
processedImage: null,
|
||||
@@ -64,69 +58,25 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
// Track which fields the user has manually edited
|
||||
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) => {
|
||||
dirtyFieldsRef.current.add(field);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear all dirty field markers
|
||||
*/
|
||||
const clearDirtyFields = useCallback(() => {
|
||||
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
|
||||
*/
|
||||
const handleScan = useCallback(async (file: File) => {
|
||||
const perfStart = performance.now();
|
||||
let perfCompression = 0;
|
||||
let perfLocalOcr = 0;
|
||||
let perfCloudVision = 0;
|
||||
|
||||
// Reset state
|
||||
clearDirtyFields();
|
||||
setResult({
|
||||
status: 'compressing',
|
||||
localResult: null,
|
||||
cloudResult: null,
|
||||
mergedResult: null,
|
||||
processedImage: null,
|
||||
@@ -140,23 +90,11 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
const processedImage = await processImageForAI(file);
|
||||
perfCompression = performance.now() - compressStart;
|
||||
|
||||
setResult(prev => ({
|
||||
...prev,
|
||||
status: 'analyzing_local',
|
||||
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');
|
||||
// Step 2: Check if offline
|
||||
if (!navigator.onLine) {
|
||||
console.log('[useScanner] Offline → queuing for later');
|
||||
const dummyMetadata = generateDummyMetadata(file);
|
||||
|
||||
// Queue for later processing
|
||||
await db.pending_scans.add({
|
||||
temp_id: `temp_${Date.now()}`,
|
||||
imageBase64: processedImage.base64,
|
||||
@@ -165,77 +103,26 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
metadata: dummyMetadata as any,
|
||||
});
|
||||
|
||||
// Show editor with dummy data (status: complete so editor opens!)
|
||||
setResult(prev => ({
|
||||
...prev,
|
||||
status: 'complete',
|
||||
setResult({
|
||||
status: 'queued',
|
||||
cloudResult: null,
|
||||
mergedResult: dummyMetadata,
|
||||
processedImage,
|
||||
error: null,
|
||||
perf: {
|
||||
compression: perfCompression,
|
||||
localOcr: 0,
|
||||
cloudVision: 0,
|
||||
total: performance.now() - perfStart,
|
||||
},
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Run local OCR (testing new line-by-line matching)
|
||||
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 => ({
|
||||
...prev,
|
||||
localResult,
|
||||
mergedResult: localMerged,
|
||||
}));
|
||||
|
||||
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
|
||||
// Step 3: Run Gemini Vision
|
||||
setResult(prev => ({
|
||||
...prev,
|
||||
status: 'analyzing_cloud',
|
||||
status: 'analyzing',
|
||||
processedImage,
|
||||
}));
|
||||
|
||||
const cloudStart = performance.now();
|
||||
@@ -244,40 +131,38 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
|
||||
if (cloudResponse.success && cloudResponse.data) {
|
||||
const cloudResult = cloudResponse.data;
|
||||
const finalMerged = mergeResults(localResult, cloudResult, dirtyFieldsRef.current);
|
||||
onComplete?.(cloudResult);
|
||||
console.log('[useScanner] Gemini complete:', cloudResult);
|
||||
|
||||
onCloudComplete?.(cloudResult);
|
||||
console.log('[useScanner] Cloud vision complete:', cloudResult);
|
||||
|
||||
setResult(prev => ({
|
||||
...prev,
|
||||
setResult({
|
||||
status: 'complete',
|
||||
cloudResult,
|
||||
mergedResult: finalMerged,
|
||||
mergedResult: cloudResult,
|
||||
processedImage,
|
||||
error: null,
|
||||
perf: {
|
||||
compression: perfCompression,
|
||||
localOcr: perfLocalOcr,
|
||||
cloudVision: perfCloudVision,
|
||||
total: performance.now() - perfStart,
|
||||
},
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
// Cloud failed, fall back to local results
|
||||
console.warn('[useScanner] Cloud vision failed:', cloudResponse.error);
|
||||
const fallbackMerged = mergeResults(localResult, null, dirtyFieldsRef.current);
|
||||
// Gemini failed - show editor with dummy data
|
||||
console.warn('[useScanner] Gemini failed:', cloudResponse.error);
|
||||
const fallback = generateDummyMetadata(file);
|
||||
|
||||
setResult(prev => ({
|
||||
...prev,
|
||||
setResult({
|
||||
status: 'complete',
|
||||
cloudResult: null,
|
||||
mergedResult: fallback,
|
||||
processedImage,
|
||||
error: cloudResponse.error || null,
|
||||
mergedResult: fallbackMerged || generateDummyMetadata(file),
|
||||
perf: {
|
||||
compression: perfCompression,
|
||||
localOcr: perfLocalOcr,
|
||||
cloudVision: perfCloudVision,
|
||||
total: performance.now() - perfStart,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -288,22 +173,17 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
error: error.message || 'Scan failed',
|
||||
perf: {
|
||||
compression: perfCompression,
|
||||
localOcr: perfLocalOcr,
|
||||
cloudVision: perfCloudVision,
|
||||
total: performance.now() - perfStart,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [locale, mergeResults, clearDirtyFields, onLocalComplete, onCloudComplete]);
|
||||
}, [locale, clearDirtyFields, onComplete]);
|
||||
|
||||
/**
|
||||
* Reset scanner state
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
clearDirtyFields();
|
||||
setResult({
|
||||
status: 'idle',
|
||||
localResult: null,
|
||||
cloudResult: null,
|
||||
mergedResult: null,
|
||||
processedImage: null,
|
||||
@@ -312,11 +192,7 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
});
|
||||
}, [clearDirtyFields]);
|
||||
|
||||
/**
|
||||
* Update the merged result (for user edits)
|
||||
*/
|
||||
const updateMergedResult = useCallback((updates: Partial<BottleMetadata>) => {
|
||||
// Mark updated fields as dirty
|
||||
Object.keys(updates).forEach(key => {
|
||||
dirtyFieldsRef.current.add(key);
|
||||
});
|
||||
@@ -327,12 +203,8 @@ export function useScanner(options: UseScannerOptions = {}) {
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cleanup (terminate OCR worker)
|
||||
*/
|
||||
const cleanup = useCallback(() => {
|
||||
terminateOcrWorker();
|
||||
}, []);
|
||||
// No cleanup needed without Tesseract
|
||||
const cleanup = useCallback(() => { }, []);
|
||||
|
||||
return {
|
||||
...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 url = new URL(request.url);
|
||||
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
||||
|
||||
if (!isStatic) {
|
||||
|
||||
@@ -73,8 +73,7 @@ export async function createSplit(data: CreateSplitData): Promise<{
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
console.log('[createSplit] Auth result:', { userId: user?.id, authError });
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user