@souscheflabs/ml-vision 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +15 -0
  3. package/README.md +50 -5
  4. package/dist/{components → cjs/components}/DetectionOverlay.js +1 -0
  5. package/dist/cjs/components/DetectionOverlay.js.map +1 -0
  6. package/dist/{components → cjs/components}/index.js +1 -0
  7. package/dist/cjs/components/index.js.map +1 -0
  8. package/dist/{core → cjs/core}/CacheManager.js +1 -0
  9. package/dist/cjs/core/CacheManager.js.map +1 -0
  10. package/dist/{core → cjs/core}/MLVisionProvider.js +4 -2
  11. package/dist/cjs/core/MLVisionProvider.js.map +1 -0
  12. package/dist/{core → cjs/core}/ServerClient.js +33 -13
  13. package/dist/cjs/core/ServerClient.js.map +1 -0
  14. package/dist/{core → cjs/core}/index.js +1 -0
  15. package/dist/cjs/core/index.js.map +1 -0
  16. package/dist/cjs/hooks/classLabels.js +178 -0
  17. package/dist/cjs/hooks/classLabels.js.map +1 -0
  18. package/dist/{hooks → cjs/hooks}/classLabelsCoco.js +1 -0
  19. package/dist/cjs/hooks/classLabelsCoco.js.map +1 -0
  20. package/dist/{hooks → cjs/hooks}/index.js +1 -0
  21. package/dist/cjs/hooks/index.js.map +1 -0
  22. package/dist/{hooks → cjs/hooks}/useMultiBarcodeScanner.js +6 -3
  23. package/dist/cjs/hooks/useMultiBarcodeScanner.js.map +1 -0
  24. package/dist/{hooks → cjs/hooks}/useProductDetector.js +168 -97
  25. package/dist/cjs/hooks/useProductDetector.js.map +1 -0
  26. package/dist/{hooks → cjs/hooks}/useReceiptScanner.js +60 -52
  27. package/dist/cjs/hooks/useReceiptScanner.js.map +1 -0
  28. package/dist/{hooks → cjs/hooks}/useVideoScanner.js +8 -5
  29. package/dist/cjs/hooks/useVideoScanner.js.map +1 -0
  30. package/dist/{index.js → cjs/index.js} +8 -4
  31. package/dist/cjs/index.js.map +1 -0
  32. package/dist/{processors → cjs/processors}/detectionProcessor.js +1 -0
  33. package/dist/cjs/processors/detectionProcessor.js.map +1 -0
  34. package/dist/{processors → cjs/processors}/index.js +1 -0
  35. package/dist/cjs/processors/index.js.map +1 -0
  36. package/dist/{processors → cjs/processors}/tfliteFrameProcessor.js +108 -25
  37. package/dist/cjs/processors/tfliteFrameProcessor.js.map +1 -0
  38. package/dist/{types → cjs/types}/barcode.js +1 -0
  39. package/dist/cjs/types/barcode.js.map +1 -0
  40. package/dist/{types → cjs/types}/detection.js +1 -0
  41. package/dist/cjs/types/detection.js.map +1 -0
  42. package/dist/{types → cjs/types}/index.js +1 -0
  43. package/dist/cjs/types/index.js.map +1 -0
  44. package/dist/{types → cjs/types}/ocr.js +1 -0
  45. package/dist/cjs/types/ocr.js.map +1 -0
  46. package/dist/{utils → cjs/utils}/imagePreprocessor.js +30 -25
  47. package/dist/cjs/utils/imagePreprocessor.js.map +1 -0
  48. package/dist/cjs/utils/logger.js +99 -0
  49. package/dist/cjs/utils/logger.js.map +1 -0
  50. package/dist/{utils → cjs/utils}/yoloProcessor.js +1 -0
  51. package/dist/cjs/utils/yoloProcessor.js.map +1 -0
  52. package/dist/esm/components/DetectionOverlay.js +130 -0
  53. package/dist/esm/components/DetectionOverlay.js.map +1 -0
  54. package/dist/esm/components/index.js +5 -0
  55. package/dist/esm/components/index.js.map +1 -0
  56. package/dist/esm/core/CacheManager.js +327 -0
  57. package/dist/esm/core/CacheManager.js.map +1 -0
  58. package/dist/esm/core/MLVisionProvider.js +185 -0
  59. package/dist/esm/core/MLVisionProvider.js.map +1 -0
  60. package/dist/esm/core/ServerClient.js +305 -0
  61. package/dist/esm/core/ServerClient.js.map +1 -0
  62. package/dist/esm/core/index.js +7 -0
  63. package/dist/esm/core/index.js.map +1 -0
  64. package/dist/esm/hooks/classLabels.js +174 -0
  65. package/dist/esm/hooks/classLabels.js.map +1 -0
  66. package/dist/esm/hooks/classLabelsCoco.js +98 -0
  67. package/dist/esm/hooks/classLabelsCoco.js.map +1 -0
  68. package/dist/esm/hooks/index.js +9 -0
  69. package/dist/esm/hooks/index.js.map +1 -0
  70. package/dist/esm/hooks/useMultiBarcodeScanner.js +290 -0
  71. package/dist/esm/hooks/useMultiBarcodeScanner.js.map +1 -0
  72. package/dist/esm/hooks/useProductDetector.js +714 -0
  73. package/dist/esm/hooks/useProductDetector.js.map +1 -0
  74. package/dist/esm/hooks/useReceiptScanner.js +407 -0
  75. package/dist/esm/hooks/useReceiptScanner.js.map +1 -0
  76. package/dist/esm/hooks/useVideoScanner.js +383 -0
  77. package/dist/esm/hooks/useVideoScanner.js.map +1 -0
  78. package/dist/esm/index.js +93 -0
  79. package/dist/esm/index.js.map +1 -0
  80. package/dist/esm/processors/detectionProcessor.js +121 -0
  81. package/dist/esm/processors/detectionProcessor.js.map +1 -0
  82. package/dist/esm/processors/index.js +7 -0
  83. package/dist/esm/processors/index.js.map +1 -0
  84. package/dist/esm/processors/tfliteFrameProcessor.js +292 -0
  85. package/dist/esm/processors/tfliteFrameProcessor.js.map +1 -0
  86. package/dist/esm/types/barcode.js +17 -0
  87. package/dist/esm/types/barcode.js.map +1 -0
  88. package/dist/esm/types/detection.js +8 -0
  89. package/dist/esm/types/detection.js.map +1 -0
  90. package/dist/esm/types/index.js +10 -0
  91. package/dist/esm/types/index.js.map +1 -0
  92. package/dist/esm/types/ocr.js +8 -0
  93. package/dist/esm/types/ocr.js.map +1 -0
  94. package/dist/esm/utils/imagePreprocessor.js +268 -0
  95. package/dist/esm/utils/imagePreprocessor.js.map +1 -0
  96. package/dist/esm/utils/logger.js +94 -0
  97. package/dist/esm/utils/logger.js.map +1 -0
  98. package/dist/esm/utils/yoloProcessor.js +151 -0
  99. package/dist/esm/utils/yoloProcessor.js.map +1 -0
  100. package/dist/{components → types/components}/DetectionOverlay.d.ts +1 -0
  101. package/dist/types/components/DetectionOverlay.d.ts.map +1 -0
  102. package/dist/{components → types/components}/index.d.ts +1 -0
  103. package/dist/types/components/index.d.ts.map +1 -0
  104. package/dist/{core → types/core}/CacheManager.d.ts +1 -0
  105. package/dist/types/core/CacheManager.d.ts.map +1 -0
  106. package/dist/{core → types/core}/MLVisionProvider.d.ts +1 -0
  107. package/dist/types/core/MLVisionProvider.d.ts.map +1 -0
  108. package/dist/{core → types/core}/ServerClient.d.ts +1 -0
  109. package/dist/types/core/ServerClient.d.ts.map +1 -0
  110. package/dist/{core → types/core}/index.d.ts +1 -0
  111. package/dist/types/core/index.d.ts.map +1 -0
  112. package/dist/{hooks → types/hooks}/classLabels.d.ts +5 -4
  113. package/dist/types/hooks/classLabels.d.ts.map +1 -0
  114. package/dist/{hooks → types/hooks}/classLabelsCoco.d.ts +1 -0
  115. package/dist/types/hooks/classLabelsCoco.d.ts.map +1 -0
  116. package/dist/{hooks → types/hooks}/index.d.ts +1 -0
  117. package/dist/types/hooks/index.d.ts.map +1 -0
  118. package/dist/{hooks → types/hooks}/useMultiBarcodeScanner.d.ts +1 -0
  119. package/dist/types/hooks/useMultiBarcodeScanner.d.ts.map +1 -0
  120. package/dist/{hooks → types/hooks}/useProductDetector.d.ts +1 -0
  121. package/dist/types/hooks/useProductDetector.d.ts.map +1 -0
  122. package/dist/{hooks → types/hooks}/useReceiptScanner.d.ts +2 -1
  123. package/dist/types/hooks/useReceiptScanner.d.ts.map +1 -0
  124. package/dist/{hooks → types/hooks}/useVideoScanner.d.ts +1 -0
  125. package/dist/types/hooks/useVideoScanner.d.ts.map +1 -0
  126. package/dist/types/index.d.ts +58 -124
  127. package/dist/types/index.d.ts.map +1 -0
  128. package/dist/{processors → types/processors}/detectionProcessor.d.ts +1 -0
  129. package/dist/types/processors/detectionProcessor.d.ts.map +1 -0
  130. package/dist/{processors → types/processors}/index.d.ts +1 -0
  131. package/dist/types/processors/index.d.ts.map +1 -0
  132. package/dist/{processors → types/processors}/tfliteFrameProcessor.d.ts +5 -4
  133. package/dist/types/processors/tfliteFrameProcessor.d.ts.map +1 -0
  134. package/dist/types/{barcode.d.ts → types/barcode.d.ts} +1 -0
  135. package/dist/types/types/barcode.d.ts.map +1 -0
  136. package/dist/types/{detection.d.ts → types/detection.d.ts} +1 -0
  137. package/dist/types/types/detection.d.ts.map +1 -0
  138. package/dist/types/types/index.d.ts +127 -0
  139. package/dist/types/types/index.d.ts.map +1 -0
  140. package/dist/types/{ocr.d.ts → types/ocr.d.ts} +1 -0
  141. package/dist/types/types/ocr.d.ts.map +1 -0
  142. package/dist/{utils → types/utils}/imagePreprocessor.d.ts +1 -0
  143. package/dist/types/utils/imagePreprocessor.d.ts.map +1 -0
  144. package/dist/types/utils/logger.d.ts +52 -0
  145. package/dist/types/utils/logger.d.ts.map +1 -0
  146. package/dist/{utils → types/utils}/yoloProcessor.d.ts +1 -0
  147. package/dist/types/utils/yoloProcessor.d.ts.map +1 -0
  148. package/package.json +62 -21
  149. package/dist/hooks/classLabels.js +0 -439
  150. package/dist/index.d.ts +0 -58
@@ -0,0 +1,714 @@
1
+ /**
2
+ * useProductDetector Hook
3
+ *
4
+ * React hook for visual product detection using TFLite models.
5
+ * Detects products in photos or camera frames using YOLOv8.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { useProductDetector } from '@souschef/ml-vision';
10
+ *
11
+ * function FridgeScanner() {
12
+ * const {
13
+ * isModelLoaded,
14
+ * isDetecting,
15
+ * detections,
16
+ * detectProducts,
17
+ * } = useProductDetector({
18
+ * minConfidence: 0.5,
19
+ * onDetected: (products) => console.log('Found:', products),
20
+ * });
21
+ *
22
+ * const handleCapture = async (photoUri: string) => {
23
+ * const results = await detectProducts(photoUri);
24
+ * // results contain detected products with bounding boxes
25
+ * };
26
+ *
27
+ * return (
28
+ * // Your camera UI
29
+ * );
30
+ * }
31
+ * ```
32
+ */
33
+ import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
34
+ import { Platform, Image } from 'react-native';
35
+ import { useMLVisionContext } from '../core/MLVisionProvider';
36
+ import { CLASS_LABELS, getClassInfo, NUM_CLASSES } from './classLabels';
37
+ import { NUM_CLASSES_COCO, getCocoClassInfo } from './classLabelsCoco';
38
+ import { preprocessImage, isSkiaAvailable, initSkia } from '../utils/imagePreprocessor';
39
+ import { processYoloOutput as processYoloOutputUtil, scaleDetections } from '../utils/yoloProcessor';
40
+ import { logger } from '../utils/logger';
41
+ /**
42
+ * Singleton class for managing TFLite model lifecycle
43
+ * Ensures only one model instance exists and handles cleanup properly
44
+ */
45
+ class TFLiteModelManager {
46
+ constructor() {
47
+ this.tfliteModule = null;
48
+ this.model = null;
49
+ this.loadPromise = null;
50
+ this.refCount = 0;
51
+ }
52
+ static getInstance() {
53
+ if (!TFLiteModelManager.instance) {
54
+ TFLiteModelManager.instance = new TFLiteModelManager();
55
+ }
56
+ return TFLiteModelManager.instance;
57
+ }
58
+ /**
59
+ * Load the TFLite module
60
+ */
61
+ async loadModule() {
62
+ if (this.tfliteModule)
63
+ return this.tfliteModule;
64
+ try {
65
+ this.tfliteModule = await import('react-native-fast-tflite');
66
+ return this.tfliteModule;
67
+ }
68
+ catch (error) {
69
+ logger.error('Failed to load react-native-fast-tflite:', error);
70
+ return null;
71
+ }
72
+ }
73
+ /**
74
+ * Get or load the TFLite model
75
+ */
76
+ async getModel(modelAsset, delegate) {
77
+ // If already loaded, increment ref count and return
78
+ if (this.model) {
79
+ this.refCount++;
80
+ return this.model;
81
+ }
82
+ // If loading in progress, wait for it
83
+ if (this.loadPromise) {
84
+ const result = await this.loadPromise;
85
+ if (result)
86
+ this.refCount++;
87
+ return result;
88
+ }
89
+ // Start loading
90
+ this.loadPromise = this.loadModelInternal(modelAsset, delegate);
91
+ const result = await this.loadPromise;
92
+ this.loadPromise = null;
93
+ if (result) {
94
+ this.model = result;
95
+ this.refCount = 1;
96
+ }
97
+ return result;
98
+ }
99
+ async loadModelInternal(modelAsset, delegate) {
100
+ const tflite = await this.loadModule();
101
+ if (!tflite)
102
+ return null;
103
+ try {
104
+ logger.debug('Loading TFLite model...');
105
+ let model;
106
+ try {
107
+ model = await tflite.loadTensorflowModel(modelAsset, delegate);
108
+ }
109
+ catch (loadErr) {
110
+ logger.warn('Primary model load failed:', loadErr.message);
111
+ // Fallback for Android assets
112
+ if (Platform.OS === 'android') {
113
+ logger.debug('Trying Android asset fallback...');
114
+ model = await tflite.loadTensorflowModel({ url: 'file:///android_asset/souschef_products_v2_int8.tflite' }, delegate);
115
+ logger.info('Android asset fallback succeeded');
116
+ }
117
+ else {
118
+ throw loadErr;
119
+ }
120
+ }
121
+ logger.info('TFLite model loaded successfully');
122
+ logger.debug('Model inputs:', JSON.stringify(model.inputs));
123
+ logger.debug('Model outputs:', JSON.stringify(model.outputs));
124
+ return model;
125
+ }
126
+ catch (error) {
127
+ logger.error('Failed to load TFLite model:', error);
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * Release a reference to the model
133
+ * Model is unloaded when ref count reaches 0
134
+ */
135
+ releaseModel() {
136
+ if (this.refCount > 0) {
137
+ this.refCount--;
138
+ logger.debug(`Model ref count: ${this.refCount}`);
139
+ }
140
+ // Note: react-native-fast-tflite doesn't expose a dispose method
141
+ // The model will be garbage collected when no longer referenced
142
+ // We keep the model cached for performance (hot reload scenarios)
143
+ }
144
+ /**
145
+ * Force unload the model (for testing or memory pressure)
146
+ */
147
+ forceUnload() {
148
+ this.model = null;
149
+ this.refCount = 0;
150
+ logger.debug('Model force unloaded');
151
+ }
152
+ /**
153
+ * Check if model is loaded
154
+ */
155
+ isLoaded() {
156
+ return this.model !== null;
157
+ }
158
+ /**
159
+ * Get current ref count (for debugging)
160
+ */
161
+ getRefCount() {
162
+ return this.refCount;
163
+ }
164
+ }
165
+ // Export singleton instance for use in hook
166
+ const modelManager = TFLiteModelManager.getInstance();
167
+ // ============================================================================
168
+ // Constants
169
+ // ============================================================================
170
+ /** Model input size (must match training config) */
171
+ const MODEL_INPUT_SIZE = 640;
172
+ /**
173
+ * Model asset - loaded via Metro bundler
174
+ * The require() returns an asset ID that react-native-fast-tflite can resolve
175
+ *
176
+ * Using SousChef products v2 model (43 food classes)
177
+ */
178
+ const MODEL_ASSET = require('../../models/souschef_products_v2_int8.tflite');
179
+ /** Whether we're using the COCO model (80 classes) or custom food model (43 classes) */
180
+ const USE_COCO_MODEL = false;
181
+ /** Get the appropriate number of classes based on model */
182
+ const getNumClasses = () => USE_COCO_MODEL ? NUM_CLASSES_COCO : NUM_CLASSES;
183
+ /** Model version identifier */
184
+ const MODEL_VERSION = '2.0.0';
185
+ /**
186
+ * Simple hash function for image URIs
187
+ * For a real implementation, use a perceptual hash or file content hash
188
+ */
189
+ function simpleHash(str) {
190
+ let hash = 5381;
191
+ for (let i = 0; i < str.length; i++) {
192
+ hash = (hash * 33 + str.charCodeAt(i)) % 2147483647;
193
+ }
194
+ return `img_${Math.abs(hash).toString(36)}`;
195
+ }
196
+ /**
197
+ * Process YOLOv8 output tensor to get detections
198
+ *
199
+ * YOLOv8 output format: [1, 34, 8400] where:
200
+ * - 34 = 4 (bbox) + 30 (class scores)
201
+ * - 8400 = number of anchor boxes (80x80 + 40x40 + 20x20)
202
+ *
203
+ * @deprecated Use processYoloOutput from utils/yoloProcessor instead
204
+ */
205
+ function _processYoloOutput(output, numClasses = NUM_CLASSES, confThreshold = 0.25, iouThreshold = 0.45) {
206
+ const numAnchors = 8400;
207
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
208
+ const numOutputs = 4 + numClasses; // x, y, w, h + class scores
209
+ const detections = [];
210
+ // Process each anchor
211
+ for (let i = 0; i < numAnchors; i++) {
212
+ // Extract bbox: [x_center, y_center, width, height] (normalized 0-1)
213
+ const x = output[0 * numAnchors + i];
214
+ const y = output[1 * numAnchors + i];
215
+ const w = output[2 * numAnchors + i];
216
+ const h = output[3 * numAnchors + i];
217
+ // Find max class score and index
218
+ let maxScore = 0;
219
+ let maxClassIndex = 0;
220
+ for (let c = 0; c < numClasses; c++) {
221
+ const score = output[(4 + c) * numAnchors + i];
222
+ if (score > maxScore) {
223
+ maxScore = score;
224
+ maxClassIndex = c;
225
+ }
226
+ }
227
+ // Filter by confidence
228
+ if (maxScore >= confThreshold) {
229
+ detections.push({
230
+ x: x - w / 2, // Convert from center to top-left
231
+ y: y - h / 2,
232
+ width: w,
233
+ height: h,
234
+ classIndex: maxClassIndex,
235
+ confidence: maxScore,
236
+ });
237
+ }
238
+ }
239
+ // Apply Non-Maximum Suppression (NMS)
240
+ return applyNMS(detections, iouThreshold);
241
+ }
242
+ /**
243
+ * Non-Maximum Suppression to remove overlapping detections
244
+ */
245
+ function applyNMS(detections, iouThreshold) {
246
+ if (detections.length === 0)
247
+ return [];
248
+ // Sort by confidence (descending)
249
+ const sorted = [...detections].sort((a, b) => b.confidence - a.confidence);
250
+ const kept = [];
251
+ while (sorted.length > 0) {
252
+ const best = sorted.shift();
253
+ kept.push(best);
254
+ // Remove overlapping detections of same class
255
+ for (let i = sorted.length - 1; i >= 0; i--) {
256
+ if (sorted[i].classIndex === best.classIndex) {
257
+ const iou = calculateIoU(best, sorted[i]);
258
+ if (iou > iouThreshold) {
259
+ sorted.splice(i, 1);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ return kept;
265
+ }
266
+ /**
267
+ * Calculate Intersection over Union (IoU) between two boxes
268
+ */
269
+ function calculateIoU(box1, box2) {
270
+ const x1 = Math.max(box1.x, box2.x);
271
+ const y1 = Math.max(box1.y, box2.y);
272
+ const x2 = Math.min(box1.x + box1.width, box2.x + box2.width);
273
+ const y2 = Math.min(box1.y + box1.height, box2.y + box2.height);
274
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
275
+ const area1 = box1.width * box1.height;
276
+ const area2 = box2.width * box2.height;
277
+ const union = area1 + area2 - intersection;
278
+ return union > 0 ? intersection / union : 0;
279
+ }
280
+ // ============================================================================
281
+ // Hook Implementation
282
+ // ============================================================================
283
+ /**
284
+ * Hook for visual product detection using TFLite
285
+ */
286
+ export function useProductDetector(options = {}) {
287
+ const { model: modelType = 'fast', maxDetections = 20, minConfidence = 0.5, categories, serverFallback = true, serverFallbackThreshold = 0.4, serverUrl, onDetected, onError, hapticFeedback = true, } = options;
288
+ // Context
289
+ const context = useMLVisionContext();
290
+ const { serverClient, serverAvailable, config, cacheManager } = context;
291
+ // State
292
+ const [isModelLoaded, setIsModelLoaded] = useState(false);
293
+ const [isDetecting, setIsDetecting] = useState(false);
294
+ const [detections, setDetections] = useState([]);
295
+ const [error, setError] = useState(null);
296
+ // Refs
297
+ const modelRef = useRef(null);
298
+ const mountedRef = useRef(true);
299
+ // Computed server URL
300
+ const effectiveServerUrl = serverUrl || config.serverUrl;
301
+ // ============================================================================
302
+ // Model Loading
303
+ // ============================================================================
304
+ useEffect(() => {
305
+ mountedRef.current = true;
306
+ async function loadModel() {
307
+ if (modelType !== 'fast') {
308
+ // Server-only mode
309
+ setIsModelLoaded(true);
310
+ return;
311
+ }
312
+ try {
313
+ logger.debug('Loading TFLite model via singleton manager...');
314
+ logger.debug('MODEL_ASSET:', MODEL_ASSET, typeof MODEL_ASSET);
315
+ // CoreML is iOS-only, use default CPU delegate on Android for stability
316
+ const delegate = config.enableGPUDelegate && Platform.OS === 'ios'
317
+ ? 'core-ml'
318
+ : 'default';
319
+ logger.debug('Using delegate:', delegate);
320
+ // Use singleton model manager
321
+ const model = await modelManager.getModel(MODEL_ASSET, delegate);
322
+ if (!model) {
323
+ throw new Error('Failed to load TFLite model');
324
+ }
325
+ modelRef.current = model;
326
+ if (mountedRef.current) {
327
+ setIsModelLoaded(true);
328
+ logger.info('Model ready for inference');
329
+ }
330
+ }
331
+ catch (err) {
332
+ const loadError = err instanceof Error ? err : new Error('Failed to load model');
333
+ logger.error('Model loading error:', loadError);
334
+ if (mountedRef.current) {
335
+ setError(loadError);
336
+ onError?.(loadError);
337
+ }
338
+ }
339
+ }
340
+ loadModel();
341
+ return () => {
342
+ mountedRef.current = false;
343
+ // Release model reference on unmount
344
+ modelManager.releaseModel();
345
+ };
346
+ }, [modelType, config.enableGPUDelegate, onError]);
347
+ // ============================================================================
348
+ // Detection Functions
349
+ // ============================================================================
350
+ /**
351
+ * Detect products in a photo
352
+ */
353
+ const detectProducts = useCallback(async (uri) => {
354
+ if (isDetecting) {
355
+ logger.warn('Detection already in progress');
356
+ return [];
357
+ }
358
+ setIsDetecting(true);
359
+ setError(null);
360
+ try {
361
+ let results = [];
362
+ const imageHash = simpleHash(uri);
363
+ // Check cache first
364
+ if (cacheManager) {
365
+ const cached = cacheManager.getProductRecognition(imageHash);
366
+ if (cached && cached.modelVersion === MODEL_VERSION) {
367
+ logger.debug('[Detection] Using cached results');
368
+ // Convert cached data to ProductDetectionResult format
369
+ results = cached.detections.map((det, idx) => ({
370
+ id: `cached_${imageHash}_${idx}`,
371
+ type: 'product',
372
+ confidence: det.confidence,
373
+ boundingBox: det.boundingBox,
374
+ data: {
375
+ category: getClassInfo(parseInt(Object.entries(CLASS_LABELS).find(([, v]) => v.label === det.classLabel)?.[0] ?? '0', 10)).category,
376
+ classLabel: det.classLabel,
377
+ classIndex: parseInt(Object.entries(CLASS_LABELS).find(([, v]) => v.label === det.classLabel)?.[0] ?? '0', 10),
378
+ name: det.classLabel.replace(/_/g, ' '),
379
+ },
380
+ source: 'on_device',
381
+ processingTimeMs: 0, // Cached, no processing time
382
+ timestamp: Date.now(),
383
+ }));
384
+ if (mountedRef.current) {
385
+ setDetections(results);
386
+ setIsDetecting(false);
387
+ }
388
+ onDetected?.(results);
389
+ return results;
390
+ }
391
+ }
392
+ // Get image dimensions
393
+ const imageSize = await new Promise((resolve, reject) => {
394
+ Image.getSize(uri, (width, height) => resolve({ width, height }), reject);
395
+ });
396
+ // Try on-device detection first
397
+ if (modelType === 'fast' && modelRef.current) {
398
+ try {
399
+ results = await runOnDeviceDetection(uri, imageSize.width, imageSize.height);
400
+ }
401
+ catch {
402
+ // On-device detection failed (e.g., preprocessing not implemented)
403
+ // This is expected for photo-based detection - silently fall back to server
404
+ logger.debug('[Detection] On-device detection unavailable, will use server');
405
+ }
406
+ }
407
+ // Server fallback for:
408
+ // - Server-only mode (modelType === 'accurate')
409
+ // - On-device detection failed or returned no results
410
+ // - Low confidence results
411
+ const shouldFallback = modelType === 'accurate' ||
412
+ (serverFallback &&
413
+ effectiveServerUrl &&
414
+ serverAvailable &&
415
+ (results.length === 0 ||
416
+ results.every((r) => r.confidence < serverFallbackThreshold)));
417
+ if (shouldFallback && serverClient) {
418
+ logger.debug('[Detection] Using server detection');
419
+ try {
420
+ const serverResults = await runServerDetection(uri);
421
+ // Merge results, preferring server for duplicates
422
+ results = mergeDetections(results, serverResults);
423
+ }
424
+ catch (serverErr) {
425
+ logger.error('[Detection] Server detection failed:', serverErr);
426
+ // If server fails too, we have no results - throw the error
427
+ if (results.length === 0) {
428
+ throw new Error('Detection failed: Server unavailable and on-device preprocessing not implemented');
429
+ }
430
+ }
431
+ }
432
+ // Filter by confidence and categories
433
+ results = results
434
+ .filter((r) => r.confidence >= minConfidence)
435
+ .filter((r) => !categories || categories.includes(r.data.category))
436
+ .slice(0, maxDetections);
437
+ // Update state
438
+ if (mountedRef.current) {
439
+ setDetections(results);
440
+ }
441
+ // Cache results
442
+ if (cacheManager && results.length > 0) {
443
+ const cacheData = {
444
+ imageHash,
445
+ detections: results.map((r) => ({
446
+ classLabel: r.data.classLabel,
447
+ confidence: r.confidence,
448
+ boundingBox: r.boundingBox,
449
+ })),
450
+ modelVersion: MODEL_VERSION,
451
+ };
452
+ cacheManager.setProductRecognition(imageHash, cacheData);
453
+ }
454
+ // Callbacks
455
+ if (results.length > 0) {
456
+ onDetected?.(results);
457
+ if (hapticFeedback) {
458
+ // Trigger haptic feedback (implement based on your haptic service)
459
+ // HapticService.success();
460
+ }
461
+ }
462
+ return results;
463
+ }
464
+ catch (err) {
465
+ const detectError = err instanceof Error ? err : new Error('Detection failed');
466
+ logger.error('[Detection] Detection error:', detectError);
467
+ if (mountedRef.current) {
468
+ setError(detectError);
469
+ }
470
+ onError?.(detectError);
471
+ return [];
472
+ }
473
+ finally {
474
+ if (mountedRef.current) {
475
+ setIsDetecting(false);
476
+ }
477
+ }
478
+ },
479
+ // eslint-disable-next-line react-hooks/exhaustive-deps
480
+ [
481
+ isDetecting,
482
+ modelType,
483
+ serverFallback,
484
+ serverFallbackThreshold,
485
+ effectiveServerUrl,
486
+ serverAvailable,
487
+ serverClient,
488
+ cacheManager,
489
+ minConfidence,
490
+ categories,
491
+ maxDetections,
492
+ onDetected,
493
+ onError,
494
+ hapticFeedback,
495
+ // Note: runOnDeviceDetection and runServerDetection are intentionally excluded
496
+ // They are stable helper functions defined within this hook
497
+ ]);
498
+ /**
499
+ * Run detection on device using TFLite
500
+ *
501
+ * Requires @shopify/react-native-skia for image preprocessing.
502
+ * Install with: npm install @shopify/react-native-skia
503
+ *
504
+ * For real-time detection (VisionCamera frame processor), use the
505
+ * vision-camera-resize-plugin which provides direct pixel access.
506
+ */
507
+ async function runOnDeviceDetection(uri, imageWidth, imageHeight) {
508
+ const model = modelRef.current;
509
+ if (!model) {
510
+ throw new Error('Model not loaded');
511
+ }
512
+ const startTime = Date.now();
513
+ // Try to initialize Skia for preprocessing
514
+ await initSkia();
515
+ if (!isSkiaAvailable()) {
516
+ logger.debug('[Detection] On-device detection requires @shopify/react-native-skia');
517
+ logger.debug('[Detection] Install with: npm install @shopify/react-native-skia');
518
+ logger.debug('[Detection] Falling back to server detection...');
519
+ throw new Error('On-device photo detection requires @shopify/react-native-skia. ' +
520
+ 'Install it or use server fallback.');
521
+ }
522
+ logger.debug('[Detection] Running on-device detection...');
523
+ logger.debug('[Detection] Image:', imageWidth, 'x', imageHeight);
524
+ try {
525
+ // Preprocess image: resize to 640x640 and normalize to [0, 1]
526
+ const preprocessed = await preprocessImage(uri, {
527
+ width: MODEL_INPUT_SIZE,
528
+ height: MODEL_INPUT_SIZE,
529
+ normalize: true,
530
+ letterbox: true,
531
+ });
532
+ logger.debug('[Detection] Preprocessed image, running inference...');
533
+ // Run TFLite inference
534
+ const output = await model.run([preprocessed.data]);
535
+ const rawOutput = output[0];
536
+ const inferenceTime = Date.now() - startTime;
537
+ logger.debug('[Detection] Inference complete:', inferenceTime, 'ms');
538
+ // Process YOLO output
539
+ const numClasses = getNumClasses();
540
+ const rawDetections = processYoloOutputUtil(rawOutput, numClasses, 0.25, 0.45);
541
+ // Scale detections back to original image coordinates
542
+ const scaledDetections = scaleDetections(rawDetections, MODEL_INPUT_SIZE, imageWidth, imageHeight);
543
+ logger.debug('[Detection] Found', scaledDetections.length, 'detections');
544
+ // Convert to ProductDetectionResult format
545
+ const processingTimeMs = Date.now() - startTime;
546
+ return scaledDetections.map((det) => toProductDetectionResult(det, imageWidth, imageHeight, processingTimeMs));
547
+ }
548
+ catch (preprocessError) {
549
+ logger.error('[Detection] On-device detection failed:', preprocessError);
550
+ throw preprocessError;
551
+ }
552
+ }
553
+ /**
554
+ * Convert raw detection to ProductDetectionResult
555
+ */
556
+ function toProductDetectionResult(det, imageWidth, imageHeight, processingTimeMs) {
557
+ // Get class info based on model type
558
+ const classInfo = USE_COCO_MODEL
559
+ ? getCocoClassInfo(det.classIndex)
560
+ : getClassInfo(det.classIndex);
561
+ return {
562
+ id: `det_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
563
+ type: 'product',
564
+ confidence: det.confidence,
565
+ boundingBox: {
566
+ x: det.x,
567
+ y: det.y,
568
+ width: det.width,
569
+ height: det.height,
570
+ },
571
+ data: {
572
+ category: classInfo.category,
573
+ classLabel: classInfo.label,
574
+ classIndex: det.classIndex,
575
+ name: classInfo.label.replace(/_/g, ' '),
576
+ },
577
+ source: 'on_device',
578
+ processingTimeMs,
579
+ timestamp: Date.now(),
580
+ };
581
+ }
582
+ /**
583
+ * Run detection on server
584
+ */
585
+ async function runServerDetection(uri) {
586
+ logger.debug('[Detection] runServerDetection called');
587
+ logger.debug('[Detection] Server client available:', !!serverClient);
588
+ logger.debug('[Detection] Server URL:', effectiveServerUrl);
589
+ logger.debug('[Detection] Image URI:', uri);
590
+ if (!serverClient) {
591
+ logger.error('[Detection] Server client not available!');
592
+ throw new Error('Server client not available');
593
+ }
594
+ // Convert URI to ImageUploadData format
595
+ const imageData = {
596
+ image: uri,
597
+ isBase64: false,
598
+ };
599
+ logger.debug('[Detection] Calling serverClient.detectProducts...');
600
+ try {
601
+ const results = await serverClient.detectProducts(imageData, {
602
+ maxDetections,
603
+ minConfidence: minConfidence * 0.8, // Slightly lower threshold for server
604
+ });
605
+ logger.debug('[Detection] Server returned', results?.length || 0, 'detections');
606
+ logger.debug('[Detection] Server results:', JSON.stringify(results));
607
+ // Server already returns ProductDetectionResult[] with all required fields
608
+ return results;
609
+ }
610
+ catch (serverErr) {
611
+ logger.error('[Detection] Server request failed:', serverErr);
612
+ throw serverErr;
613
+ }
614
+ }
615
+ /**
616
+ * Merge on-device and server detections
617
+ */
618
+ function mergeDetections(onDevice, server) {
619
+ // Simple merge: include all server detections, add on-device only if no overlap
620
+ const merged = [...server];
621
+ for (const det of onDevice) {
622
+ const overlaps = server.some((s) => s.data.classLabel === det.data.classLabel &&
623
+ calculateIoU({
624
+ x: det.boundingBox.x,
625
+ y: det.boundingBox.y,
626
+ width: det.boundingBox.width,
627
+ height: det.boundingBox.height,
628
+ classIndex: det.data.classIndex,
629
+ confidence: det.confidence,
630
+ }, {
631
+ x: s.boundingBox.x,
632
+ y: s.boundingBox.y,
633
+ width: s.boundingBox.width,
634
+ height: s.boundingBox.height,
635
+ classIndex: s.data.classIndex,
636
+ confidence: s.confidence,
637
+ }) > 0.5);
638
+ if (!overlaps) {
639
+ merged.push(det);
640
+ }
641
+ }
642
+ return merged;
643
+ }
644
+ /**
645
+ * Detect products in current camera frame (placeholder)
646
+ */
647
+ const detectInFrame = useCallback(async () => {
648
+ // This would be called from a frame processor
649
+ // For now, return current detections
650
+ return detections;
651
+ }, [detections]);
652
+ /**
653
+ * Update model (check for new version)
654
+ */
655
+ const updateModel = useCallback(async () => {
656
+ // TODO: Implement model update checking
657
+ logger.debug('[Detection] Model update check not implemented');
658
+ }, []);
659
+ /**
660
+ * Clear all detections
661
+ */
662
+ const clearDetections = useCallback(() => {
663
+ setDetections([]);
664
+ setError(null);
665
+ }, []);
666
+ // ============================================================================
667
+ // Frame Processor (placeholder)
668
+ // ============================================================================
669
+ // Frame processor for real-time detection would be implemented here
670
+ // This requires worklet functions from react-native-vision-camera
671
+ const frameProcessor = useMemo(() => {
672
+ // Placeholder - actual implementation needs VisionCamera worklets
673
+ return null;
674
+ }, []);
675
+ // ============================================================================
676
+ // Model Info
677
+ // ============================================================================
678
+ const modelInfo = useMemo(() => {
679
+ if (!isModelLoaded)
680
+ return null;
681
+ return {
682
+ name: 'souschef_products_v2_int8',
683
+ version: MODEL_VERSION,
684
+ sizeBytes: 26392350, // ~25 MB (43 food classes, YOLOv8m)
685
+ lastUpdated: new Date('2026-01-19'),
686
+ type: 'detection',
687
+ isLoaded: isModelLoaded,
688
+ };
689
+ }, [isModelLoaded]);
690
+ // ============================================================================
691
+ // Return Value
692
+ // ============================================================================
693
+ return {
694
+ // State
695
+ isModelLoaded,
696
+ isDetecting,
697
+ detections,
698
+ error,
699
+ modelVersion: MODEL_VERSION,
700
+ // Model (for frame processor use)
701
+ model: modelRef.current,
702
+ // Actions
703
+ detectProducts,
704
+ detectInFrame,
705
+ updateModel,
706
+ clearDetections,
707
+ // Frame processor
708
+ frameProcessor,
709
+ // Model info
710
+ modelInfo,
711
+ };
712
+ }
713
+ export default useProductDetector;
714
+ //# sourceMappingURL=useProductDetector.js.map