Beer Scanner
Learn how to use the beer scanner feature to quickly add beers to your collection using camera integration and AI-powered recognition.
The Beer Scanner feature allows you to quickly add beers to your collection by taking photos of beer labels, cans, or bottles. Using advanced computer vision and machine learning, the scanner can identify beers and automatically populate your collection with accurate information.
How the Scanner Works
The scanning process involves several steps, from camera integration to beer matching and addition to your collection.
Camera Integration
The scanner integrates with your device’s camera to provide a seamless photo capture experience.
Camera Component
<!-- src/components/scanner/Camera.svelte -->
<script>
let { onCapture, onError } = $props();
let videoElement = $state();
let canvasElement = $state();
let stream = $state();
let isStreaming = $state(false);
let error = $state('');
async function startCamera() {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
if (videoElement) {
videoElement.srcObject = stream;
isStreaming = true;
}
} catch (err) {
error = 'Camera access denied or unavailable';
onError?.(err);
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
isStreaming = false;
}
}
function captureImage() {
if (!videoElement || !canvasElement) return;
const context = canvasElement.getContext('2d');
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
context.drawImage(videoElement, 0, 0);
canvasElement.toBlob((blob) => {
onCapture?.(blob);
}, 'image/jpeg', 0.8);
}
// Auto-start camera when component mounts
$effect(() => {
startCamera();
return () => {
stopCamera();
};
});
</script>
<div class="camera-container">
{#if error}
<div class="error-message">
<p>{error}</p>
<button onclick={startCamera}>Try Again</button>
</div>
{:else}
<div class="camera-view">
<video bind:this={videoElement} autoplay playsinline muted></video>
<canvas bind:this={canvasElement} style="display: none;"></canvas>
{#if isStreaming}
<div class="camera-overlay">
<div class="scan-frame">
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
<button class="capture-btn" onclick={captureImage}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
{/if}
</div>
{/if}
</div>
<style>
.camera-container {
position: relative;
width: 100%;
max-width: 400px;
margin: 0 auto;
}
.camera-view {
position: relative;
background: #000;
border-radius: 12px;
overflow: hidden;
}
video {
width: 100%;
height: auto;
display: block;
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 2rem;
}
.scan-frame {
position: absolute;
top: 20%;
left: 10%;
right: 10%;
bottom: 30%;
border: 2px solid var(--primary);
border-radius: 8px;
}
.corner {
position: absolute;
width: 20px;
height: 20px;
border-color: var(--primary);
border-style: solid;
}
.top-left {
top: -2px;
left: -2px;
border-width: 4px 0 0 4px;
border-radius: 8px 0 0 0;
}
.top-right {
top: -2px;
right: -2px;
border-width: 4px 4px 0 0;
border-radius: 0 8px 0 0;
}
.bottom-left {
bottom: -2px;
left: -2px;
border-width: 0 0 4px 4px;
border-radius: 0 0 0 8px;
}
.bottom-right {
bottom: -2px;
right: -2px;
border-width: 0 4px 4px 0;
border-radius: 0 0 8px 0;
}
.capture-btn {
background: var(--primary);
color: white;
border: none;
border-radius: 50%;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.capture-btn:hover {
transform: scale(1.05);
}
.error-message {
text-align: center;
padding: 2rem;
background: var(--error-bg);
border-radius: 8px;
color: var(--error);
}
</style>Vision API Integration
Once an image is captured, it’s sent to Google’s Vision API for text recognition and beer identification.
Image Processing Service
<!-- src/lib/scanner/vision-service.js -->
export class VisionService {
constructor(apiKey) {
this.apiKey = apiKey;
}
async analyzeImage(imageBlob) {
const base64Image = await this.blobToBase64(imageBlob);
const response = await fetch(`https://vision.googleapis.com/v1/images:annotate?key=${this.apiKey}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requests: [{
image: {
content: base64Image
},
features: [
{
type: 'TEXT_DETECTION',
maxResults: 50
},
{
type: 'LOGO_DETECTION',
maxResults: 10
},
{
type: 'LABEL_DETECTION',
maxResults: 20
}
]
}]
})
});
if (!response.ok) {
throw new Error('Vision API request failed');
}
const data = await response.json();
return this.parseVisionResponse(data);
}
async blobToBase64(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result.split(',')[1];
resolve(base64);
};
reader.readAsDataURL(blob);
});
}
parseVisionResponse(data) {
const response = data.responses[0];
return {
text: response.textAnnotations?.[0]?.description || '',
logos: response.logoAnnotations || [],
labels: response.labelAnnotations || [],
fullText: response.fullTextAnnotation?.text || ''
};
}
}Beer Matching Algorithm
The extracted text and labels are processed through a matching algorithm that searches the beer database for potential matches.
Beer Matcher
<!-- src/lib/scanner/beer-matcher.js -->
export class BeerMatcher {
constructor(beerDatabase) {
this.beerDatabase = beerDatabase;
}
async findMatches(visionData) {
const { text, logos, labels } = visionData;
// Extract potential beer information from text
const extractedInfo = this.extractBeerInfo(text);
// Search database for matches
const matches = await this.searchDatabase(extractedInfo);
// Score and rank matches
const scoredMatches = matches.map(match => ({
...match,
score: this.calculateMatchScore(match, extractedInfo, logos, labels)
}));
// Sort by score (highest first)
scoredMatches.sort((a, b) => b.score - a.score);
return scoredMatches.slice(0, 5); // Return top 5 matches
}
extractBeerInfo(text) {
const lines = text.split('\n').map(line => line.trim()).filter(line => line);
const info = {
name: '',
brewery: '',
style: '',
abv: null,
ibu: null,
volume: ''
};
// Simple pattern matching for beer information
for (const line of lines) {
// Look for ABV
const abvMatch = line.match(/(d+(?:.d+)?)s*%?s*ABV/i);
if (abvMatch) {
info.abv = parseFloat(abvMatch[1]);
}
// Look for IBU
const ibuMatch = line.match(/(d+)s*IBU/i);
if (ibuMatch) {
info.ibu = parseInt(ibuMatch[1]);
}
// Look for volume
const volumeMatch = line.match(/(d+(?:.d+)?)s*(ml|l|cl)/i);
if (volumeMatch) {
info.volume = volumeMatch[0];
}
// Try to identify brewery vs beer name
// This is a simplified approach - real implementation would use ML
if (line.length > 3 && line.length < 50) {
if (!info.name) {
info.name = line;
} else if (!info.brewery && line !== info.name) {
info.brewery = line;
}
}
}
return info;
}
async searchDatabase(extractedInfo) {
// This would query your beer database
// Simplified version for demonstration
const query = {
name: extractedInfo.name ? { $regex: extractedInfo.name, $options: 'i' } : undefined,
brewery: extractedInfo.brewery ? { $regex: extractedInfo.brewery, $options: 'i' } : undefined,
abv: extractedInfo.abv ? { $gte: extractedInfo.abv - 0.5, $lte: extractedInfo.abv + 0.5 } : undefined
};
// Remove undefined fields
Object.keys(query).forEach(key => query[key] === undefined && delete query[key]);
// In a real app, this would be a database query
return this.beerDatabase.find(query).limit(20);
}
calculateMatchScore(beer, extractedInfo, logos, labels) {
let score = 0;
// Name match (highest weight)
if (extractedInfo.name && beer.name) {
const nameSimilarity = this.calculateStringSimilarity(
extractedInfo.name.toLowerCase(),
beer.name.toLowerCase()
);
score += nameSimilarity * 40;
}
// Brewery match
if (extractedInfo.brewery && beer.brewery) {
const brewerySimilarity = this.calculateStringSimilarity(
extractedInfo.brewery.toLowerCase(),
beer.brewery.toLowerCase()
);
score += brewerySimilarity * 30;
}
// ABV match
if (extractedInfo.abv && beer.abv) {
const abvDiff = Math.abs(extractedInfo.abv - beer.abv);
if (abvDiff <= 0.5) {
score += 20;
} else if (abvDiff <= 1) {
score += 10;
}
}
// Logo detection boost
if (logos.some(logo =>
beer.brewery && logo.description.toLowerCase().includes(beer.brewery.toLowerCase())
)) {
score += 10;
}
return score;
}
calculateStringSimilarity(str1, str2) {
// Simple Levenshtein distance-based similarity
const longer = str1.length > str2.length ? str1 : str2;
const shorter = str1.length > str2.length ? str2 : str1;
if (longer.length === 0) return 1.0;
const distance = this.levenshteinDistance(longer, shorter);
return (longer.length - distance) / longer.length;
}
levenshteinDistance(str1, str2) {
const matrix = [];
for (let i = 0; i <= str2.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str1.length; j++) {
matrix[0][j] = j;
}
for (let i = 1; i <= str2.length; i++) {
for (let j = 1; j <= str1.length; j++) {
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j] + 1
);
}
}
}
return matrix[str2.length][str1.length];
}
}Adding Scanned Beers
Once a match is found, users can review and confirm the beer details before adding it to their collection.
Scan Result Component
<!-- src/components/scanner/ScanResult.svelte -->
<script>
let { matches, visionData, onAddBeer, onManualEntry } = $props();
let selectedMatch = $state(null);
let isAdding = $state(false);
async function addSelectedBeer() {
if (!selectedMatch) return;
isAdding = true;
try {
await onAddBeer(selectedMatch);
} finally {
isAdding = false;
}
}
</script>
<div class="scan-results">
{#if matches.length > 0}
<h3>Potential Matches</h3>
<p>Found {matches.length} possible beer{matches.length !== 1 ? 's' : ''}:</p>
<div class="matches-list">
{#each matches as match, index}
<div class="match-card" class:selected={selectedMatch?.id === match.id} onclick={() => selectedMatch = match}>
<div class="match-score">Match: {match.score.toFixed(0)}%</div>
<h4>{match.name}</h4>
<p>{match.brewery}</p>
<div class="beer-details">
{#if match.style}<span>{match.style}</span>{/if}
{#if match.abv}<span>{match.abv}% ABV</span>{/if}
{#if match.ibu}<span>{match.ibu} IBU</span>{/if}
</div>
</div>
{/each}
</div>
{#if selectedMatch}
<div class="selected-beer">
<h4>Add to Collection</h4>
<div class="beer-summary">
<strong>{selectedMatch.name}</strong> by {selectedMatch.brewery}
{#if selectedMatch.style} - {selectedMatch.style}{/if}
</div>
<button onclick={addSelectedBeer} disabled={isAdding}>
{#if isAdding}
Adding...
{:else}
Add to Collection
{/if}
</button>
</div>
{/if}
{:else}
<div class="no-matches">
<h3>No Matches Found</h3>
<p>We couldn't identify this beer from the image. The extracted text was:</p>
<blockquote>{visionData.text || 'No text detected'}</blockquote>
<button onclick={onManualEntry}>Enter Manually</button>
</div>
{/if}
</div>
<style>
.scan-results {
padding: 1rem;
}
.matches-list {
display: grid;
gap: 0.5rem;
margin: 1rem 0;
}
.match-card {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.match-card:hover {
border-color: var(--primary);
background: var(--bg-hover);
}
.match-card.selected {
border-color: var(--primary);
background: var(--primary-bg);
}
.match-score {
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.beer-details {
display: flex;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.selected-beer {
margin-top: 1rem;
padding: 1rem;
background: var(--card-bg);
border-radius: 8px;
}
.beer-summary {
margin-bottom: 1rem;
}
.no-matches {
text-align: center;
padding: 2rem;
}
blockquote {
font-style: italic;
color: var(--text-secondary);
margin: 1rem 0;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
}
</style>Next Steps
- Collection Guide - Manual beer addition and management
- Ratings Guide - Rate and review scanned beers
- Analytics Dashboard - Track scanning statistics