← back to Wine Finder Next

lib/services/wineLabelVerifier.js

365 lines

const axios = require('axios');
const FormData = require('form-data');

/**
 * Wine Label Verification Service
 *
 * Uses Roboflow API for wine label detection and verification
 * Falls back to local database matching if API unavailable
 */

class WineLabelVerifier {
  constructor() {
    // Roboflow API configuration
    this.roboflowApiKey = process.env.ROBOFLOW_API_KEY || null;
    this.roboflowModelUrl = 'https://detect.roboflow.com/wine-label-detection/1';

    // Confidence thresholds
    this.confidenceThresholds = {
      high: 0.80,      // Definitely authentic
      medium: 0.60,    // Likely authentic
      low: 0.40        // Unknown/suspicious
    };

    // Cache for verification results
    this.verificationCache = new Map();
  }

  /**
   * Main verification method - accepts image and returns authentication result
   */
  async verifyWineLabel(imageData, options = {}) {
    try {
      const {
        useCache = true,
        confidenceThreshold = 0.40,
        crossCheckDatabase = true
      } = options;

      // Check cache first
      if (useCache) {
        const cached = this.getCachedResult(imageData);
        if (cached) {
          return { ...cached, fromCache: true };
        }
      }

      // Step 1: Detect label using Roboflow (if API key available)
      let roboflowResult = null;
      if (this.roboflowApiKey) {
        roboflowResult = await this.detectWithRoboflow(imageData, confidenceThreshold);
      }

      // Step 2: Extract text using basic pattern matching (fallback)
      const extractedText = this.extractTextFromPredictions(roboflowResult);

      // Step 3: Parse wine information
      const wineInfo = this.parseWineInfo(extractedText, roboflowResult);

      // Step 4: Cross-check with database (if enabled)
      let databaseMatch = null;
      if (crossCheckDatabase && wineInfo.name) {
        databaseMatch = await this.checkDatabase(wineInfo);
      }

      // Step 5: Calculate confidence score
      const confidence = this.calculateConfidence(roboflowResult, databaseMatch, wineInfo);

      // Step 6: Determine verification status
      const status = this.determineStatus(confidence);

      // Build result
      const result = {
        success: true,
        verification: {
          status,
          confidence,
          wine: wineInfo,
          details: {
            labelDetected: roboflowResult ? true : false,
            apiUsed: this.roboflowApiKey ? 'Roboflow' : 'Local',
            textExtracted: extractedText,
            databaseMatch: databaseMatch ? true : false,
            matchedWines: databaseMatch?.wines || [],
            predictions: roboflowResult?.predictions || []
          }
        },
        timestamp: new Date().toISOString()
      };

      // Cache result
      if (useCache) {
        this.cacheResult(imageData, result);
      }

      return result;

    } catch (error) {
      console.error('Wine label verification error:', error.message);
      return {
        success: false,
        error: error.message,
        verification: {
          status: 'error',
          confidence: 0,
          wine: {},
          details: {
            error: error.message
          }
        }
      };
    }
  }

  /**
   * Detect wine label using Roboflow API
   */
  async detectWithRoboflow(imageData, confidence = 40) {
    if (!this.roboflowApiKey) {
      console.warn('Roboflow API key not configured');
      return null;
    }

    try {
      // Convert base64 to buffer if needed
      let imageBuffer;
      if (imageData.startsWith('data:image')) {
        // Remove data URL prefix
        const base64Data = imageData.replace(/^data:image\/\w+;base64,/, '');
        imageBuffer = Buffer.from(base64Data, 'base64');
      } else if (Buffer.isBuffer(imageData)) {
        imageBuffer = imageData;
      } else {
        imageBuffer = Buffer.from(imageData, 'base64');
      }

      // Call Roboflow API
      const response = await axios({
        method: 'POST',
        url: `${this.roboflowModelUrl}`,
        params: {
          api_key: this.roboflowApiKey,
          confidence: confidence
        },
        data: imageBuffer,
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        timeout: 30000
      });

      return response.data;

    } catch (error) {
      console.error('Roboflow API error:', error.message);
      return null;
    }
  }

  /**
   * Extract text from Roboflow predictions
   */
  extractTextFromPredictions(roboflowResult) {
    if (!roboflowResult || !roboflowResult.predictions) {
      return [];
    }

    const extractedText = [];
    roboflowResult.predictions.forEach(prediction => {
      if (prediction.class && prediction.confidence > 0.5) {
        extractedText.push(prediction.class);
      }
    });

    return extractedText;
  }

  /**
   * Parse wine information from extracted text and predictions
   */
  parseWineInfo(textArray, roboflowResult) {
    const info = {
      name: '',
      vintage: '',
      winery: '',
      region: '',
      appellation: '',
      type: '',
      alcohol: '',
      organic: false,
      sustainable: false
    };

    // Parse from Roboflow predictions if available
    if (roboflowResult && roboflowResult.predictions) {
      roboflowResult.predictions.forEach(pred => {
        const className = pred.class.toLowerCase();

        // Extract vintage (year)
        const yearMatch = className.match(/\b(19|20)\d{2}\b/);
        if (yearMatch && !info.vintage) {
          info.vintage = yearMatch[0];
        }

        // Detect wine type
        if (className.includes('cabernet') || className.includes('merlot') ||
            className.includes('pinot') || className.includes('chardonnay')) {
          info.type = className;
        }

        // Detect certifications
        if (className.includes('organic')) {
          info.organic = true;
        }
        if (className.includes('sustainable')) {
          info.sustainable = true;
        }

        // Extract appellation
        if (className.includes('aoc') || className.includes('aop') ||
            className.includes('doc') || className.includes('ava')) {
          info.appellation = className;
        }
      });
    }

    // Parse from text array
    textArray.forEach(text => {
      const textLower = text.toLowerCase();

      // Try to find winery/wine name (usually longest text)
      if (text.length > info.name.length && !textLower.match(/\b(19|20)\d{2}\b/)) {
        info.name = text;
      }

      // Extract alcohol percentage
      const alcoholMatch = text.match(/(\d+\.?\d*)\s*%/);
      if (alcoholMatch) {
        info.alcohol = alcoholMatch[1] + '%';
      }
    });

    return info;
  }

  /**
   * Check wine against existing databases
   */
  async checkDatabase(wineInfo) {
    // This would query your existing wine databases
    // For now, return mock structure
    try {
      // TODO: Implement actual database search
      // const results = await wineAggregator.searchAll(wineInfo.name);

      return {
        found: false,
        wines: [],
        sources: []
      };
    } catch (error) {
      console.error('Database check error:', error.message);
      return null;
    }
  }

  /**
   * Calculate overall confidence score
   */
  calculateConfidence(roboflowResult, databaseMatch, wineInfo) {
    let confidence = 0;

    // Roboflow detection confidence (40% weight)
    if (roboflowResult && roboflowResult.predictions && roboflowResult.predictions.length > 0) {
      const avgConfidence = roboflowResult.predictions.reduce((sum, p) => sum + p.confidence, 0) / roboflowResult.predictions.length;
      confidence += avgConfidence * 0.4;
    }

    // Database match (40% weight)
    if (databaseMatch && databaseMatch.found) {
      confidence += 0.4;
    }

    // Wine info completeness (20% weight)
    const infoFields = [wineInfo.name, wineInfo.vintage, wineInfo.winery, wineInfo.region];
    const filledFields = infoFields.filter(f => f && f.length > 0).length;
    confidence += (filledFields / infoFields.length) * 0.2;

    return Math.min(confidence, 1.0);
  }

  /**
   * Determine verification status based on confidence
   */
  determineStatus(confidence) {
    if (confidence >= this.confidenceThresholds.high) {
      return 'authentic';
    } else if (confidence >= this.confidenceThresholds.medium) {
      return 'likely_authentic';
    } else if (confidence >= this.confidenceThresholds.low) {
      return 'unknown';
    } else {
      return 'suspicious';
    }
  }

  /**
   * Cache verification result
   */
  cacheResult(imageData, result) {
    // Create simple hash of image data
    const hash = this.hashImage(imageData);
    this.verificationCache.set(hash, {
      result,
      timestamp: Date.now()
    });

    // Limit cache size
    if (this.verificationCache.size > 100) {
      const firstKey = this.verificationCache.keys().next().value;
      this.verificationCache.delete(firstKey);
    }
  }

  /**
   * Get cached verification result
   */
  getCachedResult(imageData) {
    const hash = this.hashImage(imageData);
    const cached = this.verificationCache.get(hash);

    if (cached) {
      // Check if cache is still valid (1 hour)
      const age = Date.now() - cached.timestamp;
      if (age < 3600000) {
        return cached.result.verification;
      } else {
        this.verificationCache.delete(hash);
      }
    }

    return null;
  }

  /**
   * Simple image hash for caching
   */
  hashImage(imageData) {
    const crypto = require('crypto');
    return crypto.createHash('md5').update(imageData.substring(0, 1000)).digest('hex');
  }

  /**
   * Get service statistics
   */
  getStats() {
    return {
      apiConfigured: this.roboflowApiKey ? true : false,
      cacheSize: this.verificationCache.size,
      confidenceThresholds: this.confidenceThresholds
    };
  }
}

module.exports = new WineLabelVerifier();