// import 'dart:io'; // Not needed for OCR service import 'package:flutter/foundation.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import '../core/constants/app_constants.dart'; class OCRService { static final OCRService _instance = OCRService._internal(); factory OCRService() => _instance; OCRService._internal(); final TextRecognizer _textRecognizer = TextRecognizer(); // Extract text from image Future extractTextFromImage(String imagePath) async { try { final inputImage = InputImage.fromFilePath(imagePath); final recognizedText = await _textRecognizer.processImage(inputImage); if (recognizedText.text.isEmpty) { throw Exception('Tidak ada teks yang terdeteksi dalam gambar'); } // Process and clean the extracted text final processedText = _processExtractedText(recognizedText); final menuItems = _parseMenuItems(processedText); return OCRResult( rawText: recognizedText.text, processedText: processedText, menuItems: menuItems, confidence: _calculateAverageConfidence(recognizedText), isSuccess: true, ); } catch (e) { debugPrint('OCR extraction error: $e'); return OCRResult( rawText: '', processedText: '', menuItems: [], confidence: 0.0, isSuccess: false, error: e.toString(), ); } } // Process and clean extracted text by merging lines on the same Y axis String _processExtractedText(RecognizedText recognizedText) { List allLines = []; // Kumpulkan semua baris dari semua blok for (TextBlock block in recognizedText.blocks) { for (TextLine line in block.lines) { if (line.text.trim().isNotEmpty && _getLineConfidence(line) > AppConstants.ocrConfidenceThreshold) { allLines.add(line); } } } if (allLines.isEmpty) return ''; // Urutkan semua baris dari atas ke bawah (berdasarkan koordinat Y / top) allLines.sort((a, b) => a.boundingBox.top.compareTo(b.boundingBox.top)); List mergedLines = []; List currentLineGroup = [allLines.first]; for (int i = 1; i < allLines.length; i++) { final line = allLines[i]; final prevLine = currentLineGroup.first; // Pakai patokan baris pertama di grup // Jika selisih Y (top) kurang dari 15 pixel, berarti mereka di baris yang sama (sejajar) if ((line.boundingBox.top - prevLine.boundingBox.top).abs() < 15) { currentLineGroup.add(line); } else { // Selesai satu baris, urutkan dari kiri ke kanan (berdasarkan X / left) currentLineGroup.sort((a, b) => a.boundingBox.left.compareTo(b.boundingBox.left)); String merged = currentLineGroup.map((e) => e.text.trim()).join(' '); mergedLines.add(_cleanOCRText(merged)); currentLineGroup = [line]; } } // Tambahkan grup terakhir if (currentLineGroup.isNotEmpty) { currentLineGroup.sort((a, b) => a.boundingBox.left.compareTo(b.boundingBox.left)); String merged = currentLineGroup.map((e) => e.text.trim()).join(' '); mergedLines.add(_cleanOCRText(merged)); } return mergedLines.join('\n'); } // Clean common OCR errors String _cleanOCRText(String text) { // Memperbaiki OCR yang salah membaca huruf menjadi angka (atau sebaliknya) pada KATA, // tapi hindari merusak harga. Kita hapus replace 0 ke O karena fatal merusak angka harga. text = text.replaceAll(RegExp(r'[|]'), 'I'); // Remove excessive whitespace text = text.replaceAll(RegExp(r'\s+'), ' '); // Fix common price patterns text = text.replaceAll(RegExp(r'Rp\s*(\d)'), 'Rp \$1'); text = text.replaceAll(RegExp(r'(\d)\s*k'), '\$1.000'); return text.trim(); } // Parse menu items from processed text List _parseMenuItems(String text) { List menuItems = []; List lines = text.split('\n'); for (int i = 0; i < lines.length; i++) { String line = lines[i].trim(); // Skip empty lines if (line.isEmpty) continue; // Try to identify menu items with prices MenuItemOCR? menuItem = _parseMenuItemLine(line); if (menuItem != null) { // Look for description in next line if available if (i + 1 < lines.length) { String nextLine = lines[i + 1].trim(); if (!_containsPrice(nextLine) && nextLine.length > 10) { menuItem = menuItem.copyWith(description: nextLine); i++; // Skip the description line in next iteration } } menuItems.add(menuItem); } } return menuItems; } // Parse individual menu item line MenuItemOCR? _parseMenuItemLine(String line) { // Pattern for menu items with prices (with or without Rp) // Matches: Rp 20.000, 20.000, 25.000, 20000, 25k, 25K final pricePattern = RegExp( r'(?:Rp\.?\s*)?(\d{1,3}(?:[.,]\d{3})+(?:[.,]\d{2})?|\d{4,7}|\d{1,3}\s*[kK])\b', caseSensitive: false ); final match = pricePattern.firstMatch(line); if (match != null) { String priceStr = match.group(1)!; double price = _parsePrice(priceStr); // Extract menu name (text before price) String name = line.substring(0, match.start).trim(); name = name.replaceAll(RegExp(r'[^\w\s]$'), ''); // Remove trailing punctuation if (name.isNotEmpty && name.length > 2) { return MenuItemOCR( name: name, price: price, description: '', category: _guessCategory(name), ); } } return null; } // Parse price string to double double _parsePrice(String priceStr) { // Remove dots and commas, then parse String cleanPrice = priceStr.replaceAll(RegExp(r'[.,]'), ''); // Handle 'k' suffix (thousands) if (priceStr.toLowerCase().contains('k')) { cleanPrice = cleanPrice.replaceAll(RegExp(r'k', caseSensitive: false), '000'); } return double.tryParse(cleanPrice) ?? 0.0; } // Check if line contains price bool _containsPrice(String line) { return RegExp( r'(?:Rp\.?\s*)?(?:\d{1,3}(?:[.,]\d{3})+|\d{4,7}|\d{1,3}\s*[kK])\b', caseSensitive: false ).hasMatch(line); } // Guess category based on menu name String _guessCategory(String name) { String lowerName = name.toLowerCase(); if (lowerName.contains('kopi') || lowerName.contains('coffee') || lowerName.contains('espresso') || lowerName.contains('latte') || lowerName.contains('cappuccino') || lowerName.contains('americano')) { return 'Kopi'; } else if (lowerName.contains('teh') || lowerName.contains('tea') || lowerName.contains('matcha') || lowerName.contains('green tea')) { return 'Teh'; } else if (lowerName.contains('jus') || lowerName.contains('juice') || lowerName.contains('smoothie') || lowerName.contains('milkshake')) { return 'Minuman'; } else if (lowerName.contains('nasi') || lowerName.contains('mie') || lowerName.contains('ayam') || lowerName.contains('sate') || lowerName.contains('gado') || lowerName.contains('soto')) { return 'Makanan Utama'; } else if (lowerName.contains('cake') || lowerName.contains('kue') || lowerName.contains('roti') || lowerName.contains('donat') || lowerName.contains('cookies') || lowerName.contains('pie')) { return 'Dessert'; } else if (lowerName.contains('snack') || lowerName.contains('keripik') || lowerName.contains('gorengan') || lowerName.contains('bakso')) { return 'Snack'; } return 'Lainnya'; } // Calculate average confidence from recognized text double _calculateAverageConfidence(RecognizedText recognizedText) { if (recognizedText.blocks.isEmpty) return 0.0; double totalConfidence = 0.0; int elementCount = 0; for (TextBlock block in recognizedText.blocks) { for (TextLine line in block.lines) { totalConfidence += _getLineConfidence(line); elementCount++; } } return elementCount > 0 ? totalConfidence / elementCount : 0.0; } // Get confidence for a text line double _getLineConfidence(TextLine line) { // MLKit doesn't provide confidence directly, so we estimate based on text quality String text = line.text; // Basic heuristics for text quality double confidence = 1.0; // Penalize very short text if (text.length < 3) confidence *= 0.5; // Penalize text with many special characters int specialChars = RegExp(r'[^\w\s]').allMatches(text).length; if (specialChars > text.length * 0.3) confidence *= 0.7; // Penalize text with inconsistent case if (RegExp(r'[a-z][A-Z]').hasMatch(text)) confidence *= 0.8; return confidence.clamp(0.0, 1.0); } // Validate OCR result bool validateOCRResult(OCRResult result) { if (!result.isSuccess) { return false; } // If we have extracted text but no menu items, still allow processing // The AI can work with raw text even without structured menu items if (result.rawText.isNotEmpty && result.rawText.length > 10) { return true; } // Check if we have reasonable menu items int validItems = result.menuItems.where((item) => item.name.length > 2 && item.price > 0 && item.price < 1000000 // Reasonable price limit ).length; return validItems >= 1; } // Dispose resources void dispose() { _textRecognizer.close(); } } // OCR Result model class OCRResult { final String rawText; final String processedText; final List menuItems; final double confidence; final bool isSuccess; final String? error; OCRResult({ required this.rawText, required this.processedText, required this.menuItems, required this.confidence, required this.isSuccess, this.error, }); bool get hasMenuItems => menuItems.isNotEmpty; int get menuItemCount => menuItems.length; String get summary { if (!isSuccess) return 'OCR gagal: ${error ?? "Unknown error"}'; return 'Ditemukan ${menuItems.length} item menu dengan confidence ${(confidence * 100).toStringAsFixed(1)}%'; } } // Menu Item from OCR class MenuItemOCR { final String name; final double price; final String description; final String category; MenuItemOCR({ required this.name, required this.price, required this.description, required this.category, }); MenuItemOCR copyWith({ String? name, double? price, String? description, String? category, }) { return MenuItemOCR( name: name ?? this.name, price: price ?? this.price, description: description ?? this.description, category: category ?? this.category, ); } String get formattedPrice { return 'Rp ${price.toStringAsFixed(0).replaceAllMapped( RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.', )}'; } Map toJson() { return { 'name': name, 'price': price, 'description': description, 'category': category, }; } }