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:
2025-12-25 22:11:50 +01:00
parent afe9197776
commit 75461d7c30
22 changed files with 2050 additions and 146 deletions

View File

@@ -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,
};
}
}

View File

@@ -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');

View File

@@ -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;
}