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.

Loading diagram...

Camera Integration

The scanner integrates with your device’s camera to provide a seamless photo capture experience.

Camera Component

Svelte
<!-- 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

Svelte
<!-- 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

Svelte
<!-- 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

Svelte
<!-- 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