Skip to content

Commit e6348e6

Browse files
committed
Proofe of concept ai nutrition label scanner.
implementet via Openfoodfacts nutrition-extractor model compiled to mlpackage for coreml.
1 parent deb6376 commit e6348e6

File tree

5 files changed

+2832
-66
lines changed

5 files changed

+2832
-66
lines changed

Trio.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5822B15939E719628E9FF7C /* SnoozeRootView.swift */; };
1212
09A169602EE302EB0026711C /* BarcodeAiDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695B2EE302EB0026711C /* BarcodeAiDataFlow.swift */; };
1313
09A169612EE302EB0026711C /* BarcodeScannerStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695E2EE302EB0026711C /* BarcodeScannerStateModel.swift */; };
14+
09A169622EE302EB0026711C /* NutritionLabelScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695D2EE302EB0026711C /* NutritionLabelScanner.swift */; };
15+
09A169632EE302EB0026711C /* NutritionModelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695F2EE302EB0026712C /* NutritionModelManager.swift */; };
1416
09A169642EE302EB0026711C /* BarcodeAiProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A1695C2EE302EB0026711C /* BarcodeAiProvider.swift */; };
1517
09A169652EE302EB0026711C /* BarcodeScannerRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169582EE302EB0026711C /* BarcodeScannerRootView.swift */; };
1618
09A169682EE3030D0026711C /* OpenBarcodeAIIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09A169662EE3030D0026711C /* OpenBarcodeAIIntent.swift */; };
@@ -847,7 +849,9 @@
847849
09A169582EE302EB0026711C /* BarcodeScannerRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerRootView.swift; sourceTree = "<group>"; };
848850
09A1695B2EE302EB0026711C /* BarcodeAiDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeAiDataFlow.swift; sourceTree = "<group>"; };
849851
09A1695C2EE302EB0026711C /* BarcodeAiProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeAiProvider.swift; sourceTree = "<group>"; };
852+
09A1695D2EE302EB0026711C /* NutritionLabelScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NutritionLabelScanner.swift; sourceTree = "<group>"; };
850853
09A1695E2EE302EB0026711C /* BarcodeScannerStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerStateModel.swift; sourceTree = "<group>"; };
854+
09A1695F2EE302EB0026712C /* NutritionModelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NutritionModelManager.swift; sourceTree = "<group>"; };
851855
09A169662EE3030D0026711C /* OpenBarcodeAIIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenBarcodeAIIntent.swift; sourceTree = "<group>"; };
852856
110AEDE02C5193D100615CC9 /* BolusIntent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntent.swift; sourceTree = "<group>"; };
853857
110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusIntentRequest.swift; sourceTree = "<group>"; };
@@ -1684,6 +1688,8 @@
16841688
09A1695A2EE302EB0026711C /* View */,
16851689
09A1695B2EE302EB0026711C /* BarcodeAiDataFlow.swift */,
16861690
09A1695C2EE302EB0026711C /* BarcodeAiProvider.swift */,
1691+
09A1695D2EE302EB0026711C /* NutritionLabelScanner.swift */,
1692+
09A1695F2EE302EB0026712C /* NutritionModelManager.swift */,
16871693
09A1695E2EE302EB0026711C /* BarcodeScannerStateModel.swift */,
16881694
);
16891695
path = BarcodeAi;
@@ -4666,6 +4672,8 @@
46664672
DD09D47D2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift in Sources */,
46674673
09A169602EE302EB0026711C /* BarcodeAiDataFlow.swift in Sources */,
46684674
09A169612EE302EB0026711C /* BarcodeScannerStateModel.swift in Sources */,
4675+
09A169622EE302EB0026711C /* NutritionLabelScanner.swift in Sources */,
4676+
09A169632EE302EB0026711C /* NutritionModelManager.swift in Sources */,
46694677
09A169642EE302EB0026711C /* BarcodeAiProvider.swift in Sources */,
46704678
09A169652EE302EB0026711C /* BarcodeScannerRootView.swift in Sources */,
46714679
DD09D47B2C5986D1003FEA5D /* CalendarEventSettingsDataFlow.swift in Sources */,

Trio/Sources/Modules/BarcodeAi/BarcodeScannerStateModel.swift

Lines changed: 208 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,48 @@ import Observation
55
import SwiftUI
66

77
extension BarcodeScanner {
8+
/// Scan mode for the barcode scanner
9+
enum ScanMode: String, CaseIterable {
10+
case barcode = "Barcode"
11+
case nutritionLabel = "Nutrition Label"
12+
13+
var icon: String {
14+
switch self {
15+
case .barcode: return "barcode.viewfinder"
16+
case .nutritionLabel: return "doc.text.viewfinder"
17+
}
18+
}
19+
20+
var localizedName: String {
21+
switch self {
22+
case .barcode: return String(localized: "Barcode")
23+
case .nutritionLabel: return String(localized: "Nutrition Label")
24+
}
25+
}
26+
}
27+
828
/// Represents a scanned product with user-entered amount.
929
struct ScannedProductItem: Identifiable, Equatable {
1030
let id: UUID
1131
let product: OpenFoodFactsProduct
1232
var amount: Double
1333
var isMlInput: Bool
34+
let isManualEntry: Bool
1435

15-
init(product: OpenFoodFactsProduct, amount: Double = 0, isMlInput: Bool = false) {
36+
init(product: OpenFoodFactsProduct, amount: Double = 0, isMlInput: Bool = false, isManualEntry: Bool = false) {
1637
id = UUID()
1738
self.product = product
1839
self.amount = amount
1940
self.isMlInput = isMlInput
41+
self.isManualEntry = isManualEntry
2042
}
2143

2244
static func == (lhs: ScannedProductItem, rhs: ScannedProductItem) -> Bool {
2345
lhs.id == rhs.id &&
2446
lhs.product == rhs.product &&
2547
lhs.amount == rhs.amount &&
26-
lhs.isMlInput == rhs.isMlInput
48+
lhs.isMlInput == rhs.isMlInput &&
49+
lhs.isManualEntry == rhs.isManualEntry
2750
}
2851
}
2952

@@ -36,7 +59,25 @@ extension BarcodeScanner {
3659
var errorMessage: String?
3760
var scannedProducts: [ScannedProductItem] = []
3861

62+
// Scan mode
63+
var scanMode: ScanMode = .barcode
64+
65+
// Nutrition label scanning
66+
var isCapturingPhoto = false
67+
var capturedImage: UIImage?
68+
var scannedNutritionData: NutritionLabelScanner.NutritionData?
69+
var isProcessingLabel = false
70+
var showNutritionEditor = false
71+
var editableNutritionName: String = ""
72+
var showCameraPicker = false
73+
74+
// AI Model for nutrition label extraction
75+
let modelManager = NutritionModelManager()
76+
var showModelFilePicker = false
77+
var useAIModel = true // Toggle between AI model and regex-based extraction
78+
3979
private let client = OpenFoodFactsClient()
80+
private let nutritionScanner = NutritionLabelScanner()
4081
private var lastScanTime: Date?
4182
private let scanCooldownSeconds: TimeInterval = 1.0
4283

@@ -46,6 +87,15 @@ extension BarcodeScanner {
4687

4788
func handleAppear() {
4889
refreshCameraStatus()
90+
modelManager.checkModelStatus()
91+
92+
// Auto-load model if downloaded but not ready
93+
if case .downloaded = modelManager.state {
94+
Task {
95+
try? await modelManager.loadModel()
96+
}
97+
}
98+
4999
switch cameraStatus {
50100
case .notDetermined:
51101
requestCameraAccess()
@@ -202,6 +252,162 @@ extension BarcodeScanner {
202252
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
203253
UIApplication.shared.open(url)
204254
}
255+
256+
// MARK: - Nutrition Label Scanning
257+
258+
/// Switches the scan mode
259+
func switchScanMode(to mode: ScanMode) {
260+
scanMode = mode
261+
errorMessage = nil
262+
263+
// Reset nutrition label specific state when switching away
264+
if mode == .barcode {
265+
capturedImage = nil
266+
scannedNutritionData = nil
267+
showNutritionEditor = false
268+
editableNutritionName = ""
269+
}
270+
}
271+
272+
/// Captures a photo for nutrition label scanning
273+
func capturePhoto() {
274+
print("📸 [StateModel] capturePhoto() triggered, setting isCapturingPhoto = true")
275+
isCapturingPhoto = true
276+
}
277+
278+
/// Called when a photo is captured from the camera
279+
func didCapturePhoto(_ image: UIImage) {
280+
print("📷 [StateModel] Photo captured!")
281+
print("📷 [StateModel] Image dimensions: \(image.size.width) x \(image.size.height)")
282+
isCapturingPhoto = false
283+
capturedImage = image
284+
isScanning = false
285+
processNutritionLabel(image)
286+
}
287+
288+
/// Processes a captured image to extract nutrition data
289+
private func processNutritionLabel(_ image: UIImage) {
290+
print("📸 [StateModel] Processing nutrition label...")
291+
print("📸 [StateModel] Image size: \(image.size)")
292+
print("📸 [StateModel] useAIModel: \(useAIModel), modelReady: \(modelManager.isReady)")
293+
294+
isProcessingLabel = true
295+
errorMessage = nil
296+
297+
Task {
298+
do {
299+
let data: NutritionLabelScanner.NutritionData
300+
301+
// Use AI model if available and enabled, otherwise fall back to regex-based OCR
302+
if useAIModel, modelManager.isReady {
303+
print("🤖 [StateModel] Using AI model for extraction...")
304+
data = try await nutritionScanner.scanWithAIModel(from: image, modelManager: modelManager)
305+
} else {
306+
print("📝 [StateModel] Using regex-based extraction...")
307+
// Fall back to regex-based OCR extraction
308+
data = try await nutritionScanner.scanNutritionLabel(from: image)
309+
}
310+
311+
print("✅ [StateModel] Extraction complete - hasData: \(data.hasAnyData)")
312+
print(
313+
"✅ [StateModel] Calories: \(String(describing: data.calories)), Carbs: \(String(describing: data.carbohydrates))"
314+
)
315+
316+
await MainActor.run {
317+
self.scannedNutritionData = data
318+
self.isProcessingLabel = false
319+
320+
if data.hasAnyData {
321+
print("✅ [StateModel] Showing nutrition editor")
322+
self.showNutritionEditor = true
323+
self.editableNutritionName = String(localized: "Scanned Label")
324+
} else {
325+
print("⚠️ [StateModel] No nutrition data found")
326+
self.errorMessage = String(localized: "No nutrition information found. Try taking a clearer photo.")
327+
}
328+
}
329+
} catch {
330+
print("❌ [StateModel] Extraction failed: \(error)")
331+
print("❌ [StateModel] Error: \(error.localizedDescription)")
332+
await MainActor.run {
333+
self.isProcessingLabel = false
334+
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
335+
}
336+
}
337+
}
338+
}
339+
340+
// MARK: - Model Management
341+
342+
/// Loads the AI model if downloaded
343+
func loadModelIfNeeded() {
344+
guard modelManager.state == .downloaded else { return }
345+
346+
Task {
347+
try? await modelManager.loadModel()
348+
}
349+
}
350+
351+
/// Deletes the downloaded model
352+
func deleteModel() {
353+
modelManager.deleteModel()
354+
}
355+
356+
/// Retakes the nutrition label photo
357+
func retakePhoto() {
358+
capturedImage = nil
359+
scannedNutritionData = nil
360+
showNutritionEditor = false
361+
errorMessage = nil
362+
isScanning = true
363+
}
364+
365+
/// Adds the scanned nutrition data as a product item
366+
func addScannedNutritionLabel() {
367+
guard let data = scannedNutritionData else { return }
368+
369+
let product = data
370+
.toProduct(name: editableNutritionName.isEmpty ? String(localized: "Scanned Label") : editableNutritionName)
371+
let item = ScannedProductItem(
372+
product: product,
373+
amount: data.servingSizeGrams ?? 100,
374+
isMlInput: false,
375+
isManualEntry: true
376+
)
377+
378+
scannedProducts.append(item)
379+
380+
// Reset for next scan
381+
capturedImage = nil
382+
scannedNutritionData = nil
383+
showNutritionEditor = false
384+
editableNutritionName = ""
385+
isScanning = true
386+
}
387+
388+
/// Updates the scanned nutrition data with edited values
389+
func updateScannedNutritionData(
390+
calories: Double?,
391+
carbohydrates: Double?,
392+
sugars: Double?,
393+
fat: Double?,
394+
protein: Double?,
395+
fiber: Double?,
396+
servingSizeGrams: Double?
397+
) {
398+
scannedNutritionData = NutritionLabelScanner.NutritionData(
399+
calories: calories,
400+
carbohydrates: carbohydrates,
401+
sugars: sugars,
402+
fat: fat,
403+
saturatedFat: scannedNutritionData?.saturatedFat,
404+
protein: protein,
405+
fiber: fiber,
406+
sodium: scannedNutritionData?.sodium,
407+
servingSize: scannedNutritionData?.servingSize,
408+
servingSizeGrams: servingSizeGrams
409+
)
410+
}
205411
}
206412
}
207413

0 commit comments

Comments
 (0)