feat: Buddy System & Bulk Scanner
- Add Buddy linking via QR code/handshake (buddy_invites table) - Add Bulk Scanner for rapid-fire bottle scanning in sessions - Add processing_status to bottles for background AI analysis - Fix offline OCR with proper tessdata caching in Service Worker - Fix Supabase GoTrueClient singleton warning - Add collection refresh after offline sync completes New components: - BuddyHandshake.tsx - QR code display and code entry - BulkScanSheet.tsx - Camera UI with capture queue - BottleSkeletonCard.tsx - Pending bottle display - useBulkScanner.ts - Queue management hook - buddy-link.ts - Server actions for buddy linking - bulk-scan.ts - Server actions for batch processing
This commit is contained in:
@@ -40,8 +40,14 @@ const distilleryFuse = new Fuse(distilleries, fuseOptions);
|
||||
// Tesseract worker singleton (reused across scans)
|
||||
let tesseractWorker: Tesseract.Worker | null = null;
|
||||
|
||||
// Character whitelist for whisky labels (no special symbols that cause noise)
|
||||
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%.,:\'"-/ ';
|
||||
// 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
|
||||
@@ -54,8 +60,9 @@ async function getWorker(): Promise<Tesseract.Worker> {
|
||||
|
||||
console.log('[LocalOCR] Initializing Tesseract worker with local files...');
|
||||
|
||||
// Use local files from /public/tessdata
|
||||
// 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) => {
|
||||
@@ -215,47 +222,68 @@ function findDistillery(text: string): { name: string; region: string; contextua
|
||||
|
||||
console.log('[LocalOCR] Lines for distillery matching:', lines.length);
|
||||
|
||||
// Try to match each line
|
||||
// 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
|
||||
// "Bad N NEVIS 27" → "Bad N NEVIS "
|
||||
const textOnlyLine = originalLine.replace(/[0-9]/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (textOnlyLine.length < 4) continue;
|
||||
|
||||
const results = distilleryFuse.search(textOnlyLine);
|
||||
// Split into words for window matching
|
||||
const words = textOnlyLine.split(' ').filter(w => w.length >= 2);
|
||||
|
||||
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.4) {
|
||||
const match = results[0].item;
|
||||
const matchScore = results[0].score;
|
||||
// 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(' ');
|
||||
|
||||
// SANITY CHECK: The text-only part should be similar length to distillery name
|
||||
// Max 60% deviation allowed (relaxed for partial matches)
|
||||
const lengthRatio = textOnlyLine.length / match.name.length;
|
||||
const lengthDeviation = Math.abs(1 - lengthRatio);
|
||||
if (phrase.length < 4) continue;
|
||||
|
||||
if (lengthDeviation > 0.6) {
|
||||
console.log(`[LocalOCR] Match rejected (length): "${textOnlyLine}" → ${match.name} (ratio: ${lengthRatio.toFixed(2)}, deviation: ${(lengthDeviation * 100).toFixed(0)}%)`);
|
||||
continue;
|
||||
}
|
||||
// Skip blacklisted common words
|
||||
if (blacklistedWords.has(phrase.toLowerCase())) {
|
||||
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`);
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[LocalOCR] Distillery match: "${textOnlyLine}" → ${match.name} (score: ${matchScore.toFixed(3)}, original: "${originalLine}")`);
|
||||
return {
|
||||
name: match.name,
|
||||
region: match.region,
|
||||
contextualAge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,12 +27,31 @@ export async function isTesseractReady(): Promise<boolean> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for the core files in cache (matching actual file names in /public/tessdata)
|
||||
const wasmMatch = await window.caches.match('/tessdata/tesseract-core-simd.wasm');
|
||||
const langMatch = await window.caches.match('/tessdata/eng.traineddata');
|
||||
// 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);
|
||||
|
||||
const ready = !!(wasmMatch && langMatch);
|
||||
console.log('[Scanner] Offline cache check:', { wasmMatch: !!wasmMatch, langMatch: !!langMatch, ready });
|
||||
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);
|
||||
@@ -58,32 +77,48 @@ export function extractNumbers(text: string): ExtractedNumbers {
|
||||
|
||||
if (!text) return result;
|
||||
|
||||
// Normalize text: lowercase, clean up common OCR mistakes
|
||||
const normalizedText = text
|
||||
.replace(/[oO]/g, '0') // Common OCR mistake: O -> 0
|
||||
.replace(/[lI]/g, '1') // Common OCR mistake: l/I -> 1
|
||||
.toLowerCase();
|
||||
// ========== 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, '.');
|
||||
|
||||
// ABV patterns: "43%", "43.5%", "43,5 %", "ABV 43", "vol. 43"
|
||||
// Step 2: ABV patterns - looking for number before % or Vol
|
||||
const abvPatterns = [
|
||||
/(\d{2}[.,]\d{1,2})\s*%/, // 43.5% or 43,5 %
|
||||
/(\d{2})\s*%/, // 43%
|
||||
/abv[:\s]*(\d{2}[.,]?\d{0,2})/i, // ABV: 43 or ABV 43.5
|
||||
/vol[.\s]*(\d{2}[.,]?\d{0,2})/i, // vol. 43
|
||||
/(\d{2}[.,]\d{1,2})\s*vol/i, // 43.5 vol
|
||||
/(\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].replace(',', '.'));
|
||||
if (value >= 35 && value <= 75) { // Reasonable whisky ABV range
|
||||
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,
|
||||
@@ -156,11 +191,13 @@ export interface PreprocessOptions {
|
||||
edgeCrop?: number;
|
||||
/** Target height for resizing. Default: 1200 */
|
||||
targetHeight?: number;
|
||||
/** Apply binarization (hard black/white). Default: false */
|
||||
/** 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: true */
|
||||
/** Apply sharpening. Default: false */
|
||||
sharpen?: boolean;
|
||||
}
|
||||
|
||||
@@ -186,8 +223,9 @@ export async function preprocessImageForOCR(
|
||||
const {
|
||||
edgeCrop = 0.05, // Remove 5% from each edge (minimal)
|
||||
targetHeight = 1200, // High resolution
|
||||
binarize = false, // Don't binarize by default
|
||||
contrastBoost = 1.3, // 30% contrast boost
|
||||
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;
|
||||
|
||||
@@ -263,29 +301,119 @@ export async function preprocessImageForOCR(
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: Apply contrast enhancement
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let gray = data[i];
|
||||
gray = ((gray - 128) * contrastBoost) + 128;
|
||||
gray = Math.min(255, Math.max(0, gray));
|
||||
// Put processed data back (after grayscale conversion)
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
if (binarize) {
|
||||
gray = gray >= 128 ? 255 : 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];
|
||||
}
|
||||
}
|
||||
|
||||
data[i] = data[i + 1] = data[i + 2] = gray;
|
||||
}
|
||||
// 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);
|
||||
|
||||
// Put processed data back
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
// 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,
|
||||
contrastBoost,
|
||||
mode: binarize ? 'binarized' : 'grayscale',
|
||||
mode: adaptiveThreshold ? 'adaptive-threshold' : (binarize ? 'binarized' : 'grayscale+contrast'),
|
||||
});
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { createBrowserClient } from '@supabase/ssr';
|
||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
// Use globalThis to persist across HMR reloads in development
|
||||
const globalForSupabase = globalThis as typeof globalThis & {
|
||||
supabaseBrowserClient?: SupabaseClient;
|
||||
};
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
if (globalForSupabase.supabaseBrowserClient) {
|
||||
return globalForSupabase.supabaseBrowserClient;
|
||||
}
|
||||
|
||||
// Use supabase-js directly with isSingleton to suppress the warning
|
||||
globalForSupabase.supabaseBrowserClient = createSupabaseClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
auth: {
|
||||
// Suppress "Multiple GoTrueClient instances" warning
|
||||
// This is safe because we use a singleton pattern
|
||||
storageKey: 'sb-auth-token',
|
||||
persistSession: true,
|
||||
detectSessionInUrl: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return globalForSupabase.supabaseBrowserClient;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user