@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.
- package/CHANGELOG.md +54 -0
- package/LICENSE +15 -0
- package/README.md +50 -5
- package/dist/{components → cjs/components}/DetectionOverlay.js +1 -0
- package/dist/cjs/components/DetectionOverlay.js.map +1 -0
- package/dist/{components → cjs/components}/index.js +1 -0
- package/dist/cjs/components/index.js.map +1 -0
- package/dist/{core → cjs/core}/CacheManager.js +1 -0
- package/dist/cjs/core/CacheManager.js.map +1 -0
- package/dist/{core → cjs/core}/MLVisionProvider.js +4 -2
- package/dist/cjs/core/MLVisionProvider.js.map +1 -0
- package/dist/{core → cjs/core}/ServerClient.js +33 -13
- package/dist/cjs/core/ServerClient.js.map +1 -0
- package/dist/{core → cjs/core}/index.js +1 -0
- package/dist/cjs/core/index.js.map +1 -0
- package/dist/cjs/hooks/classLabels.js +178 -0
- package/dist/cjs/hooks/classLabels.js.map +1 -0
- package/dist/{hooks → cjs/hooks}/classLabelsCoco.js +1 -0
- package/dist/cjs/hooks/classLabelsCoco.js.map +1 -0
- package/dist/{hooks → cjs/hooks}/index.js +1 -0
- package/dist/cjs/hooks/index.js.map +1 -0
- package/dist/{hooks → cjs/hooks}/useMultiBarcodeScanner.js +6 -3
- package/dist/cjs/hooks/useMultiBarcodeScanner.js.map +1 -0
- package/dist/{hooks → cjs/hooks}/useProductDetector.js +168 -97
- package/dist/cjs/hooks/useProductDetector.js.map +1 -0
- package/dist/{hooks → cjs/hooks}/useReceiptScanner.js +60 -52
- package/dist/cjs/hooks/useReceiptScanner.js.map +1 -0
- package/dist/{hooks → cjs/hooks}/useVideoScanner.js +8 -5
- package/dist/cjs/hooks/useVideoScanner.js.map +1 -0
- package/dist/{index.js → cjs/index.js} +8 -4
- package/dist/cjs/index.js.map +1 -0
- package/dist/{processors → cjs/processors}/detectionProcessor.js +1 -0
- package/dist/cjs/processors/detectionProcessor.js.map +1 -0
- package/dist/{processors → cjs/processors}/index.js +1 -0
- package/dist/cjs/processors/index.js.map +1 -0
- package/dist/{processors → cjs/processors}/tfliteFrameProcessor.js +108 -25
- package/dist/cjs/processors/tfliteFrameProcessor.js.map +1 -0
- package/dist/{types → cjs/types}/barcode.js +1 -0
- package/dist/cjs/types/barcode.js.map +1 -0
- package/dist/{types → cjs/types}/detection.js +1 -0
- package/dist/cjs/types/detection.js.map +1 -0
- package/dist/{types → cjs/types}/index.js +1 -0
- package/dist/cjs/types/index.js.map +1 -0
- package/dist/{types → cjs/types}/ocr.js +1 -0
- package/dist/cjs/types/ocr.js.map +1 -0
- package/dist/{utils → cjs/utils}/imagePreprocessor.js +30 -25
- package/dist/cjs/utils/imagePreprocessor.js.map +1 -0
- package/dist/cjs/utils/logger.js +99 -0
- package/dist/cjs/utils/logger.js.map +1 -0
- package/dist/{utils → cjs/utils}/yoloProcessor.js +1 -0
- package/dist/cjs/utils/yoloProcessor.js.map +1 -0
- package/dist/esm/components/DetectionOverlay.js +130 -0
- package/dist/esm/components/DetectionOverlay.js.map +1 -0
- package/dist/esm/components/index.js +5 -0
- package/dist/esm/components/index.js.map +1 -0
- package/dist/esm/core/CacheManager.js +327 -0
- package/dist/esm/core/CacheManager.js.map +1 -0
- package/dist/esm/core/MLVisionProvider.js +185 -0
- package/dist/esm/core/MLVisionProvider.js.map +1 -0
- package/dist/esm/core/ServerClient.js +305 -0
- package/dist/esm/core/ServerClient.js.map +1 -0
- package/dist/esm/core/index.js +7 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/hooks/classLabels.js +174 -0
- package/dist/esm/hooks/classLabels.js.map +1 -0
- package/dist/esm/hooks/classLabelsCoco.js +98 -0
- package/dist/esm/hooks/classLabelsCoco.js.map +1 -0
- package/dist/esm/hooks/index.js +9 -0
- package/dist/esm/hooks/index.js.map +1 -0
- package/dist/esm/hooks/useMultiBarcodeScanner.js +290 -0
- package/dist/esm/hooks/useMultiBarcodeScanner.js.map +1 -0
- package/dist/esm/hooks/useProductDetector.js +714 -0
- package/dist/esm/hooks/useProductDetector.js.map +1 -0
- package/dist/esm/hooks/useReceiptScanner.js +407 -0
- package/dist/esm/hooks/useReceiptScanner.js.map +1 -0
- package/dist/esm/hooks/useVideoScanner.js +383 -0
- package/dist/esm/hooks/useVideoScanner.js.map +1 -0
- package/dist/esm/index.js +93 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/processors/detectionProcessor.js +121 -0
- package/dist/esm/processors/detectionProcessor.js.map +1 -0
- package/dist/esm/processors/index.js +7 -0
- package/dist/esm/processors/index.js.map +1 -0
- package/dist/esm/processors/tfliteFrameProcessor.js +292 -0
- package/dist/esm/processors/tfliteFrameProcessor.js.map +1 -0
- package/dist/esm/types/barcode.js +17 -0
- package/dist/esm/types/barcode.js.map +1 -0
- package/dist/esm/types/detection.js +8 -0
- package/dist/esm/types/detection.js.map +1 -0
- package/dist/esm/types/index.js +10 -0
- package/dist/esm/types/index.js.map +1 -0
- package/dist/esm/types/ocr.js +8 -0
- package/dist/esm/types/ocr.js.map +1 -0
- package/dist/esm/utils/imagePreprocessor.js +268 -0
- package/dist/esm/utils/imagePreprocessor.js.map +1 -0
- package/dist/esm/utils/logger.js +94 -0
- package/dist/esm/utils/logger.js.map +1 -0
- package/dist/esm/utils/yoloProcessor.js +151 -0
- package/dist/esm/utils/yoloProcessor.js.map +1 -0
- package/dist/{components → types/components}/DetectionOverlay.d.ts +1 -0
- package/dist/types/components/DetectionOverlay.d.ts.map +1 -0
- package/dist/{components → types/components}/index.d.ts +1 -0
- package/dist/types/components/index.d.ts.map +1 -0
- package/dist/{core → types/core}/CacheManager.d.ts +1 -0
- package/dist/types/core/CacheManager.d.ts.map +1 -0
- package/dist/{core → types/core}/MLVisionProvider.d.ts +1 -0
- package/dist/types/core/MLVisionProvider.d.ts.map +1 -0
- package/dist/{core → types/core}/ServerClient.d.ts +1 -0
- package/dist/types/core/ServerClient.d.ts.map +1 -0
- package/dist/{core → types/core}/index.d.ts +1 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/classLabels.d.ts +5 -4
- package/dist/types/hooks/classLabels.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/classLabelsCoco.d.ts +1 -0
- package/dist/types/hooks/classLabelsCoco.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/index.d.ts +1 -0
- package/dist/types/hooks/index.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/useMultiBarcodeScanner.d.ts +1 -0
- package/dist/types/hooks/useMultiBarcodeScanner.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/useProductDetector.d.ts +1 -0
- package/dist/types/hooks/useProductDetector.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/useReceiptScanner.d.ts +2 -1
- package/dist/types/hooks/useReceiptScanner.d.ts.map +1 -0
- package/dist/{hooks → types/hooks}/useVideoScanner.d.ts +1 -0
- package/dist/types/hooks/useVideoScanner.d.ts.map +1 -0
- package/dist/types/index.d.ts +58 -124
- package/dist/types/index.d.ts.map +1 -0
- package/dist/{processors → types/processors}/detectionProcessor.d.ts +1 -0
- package/dist/types/processors/detectionProcessor.d.ts.map +1 -0
- package/dist/{processors → types/processors}/index.d.ts +1 -0
- package/dist/types/processors/index.d.ts.map +1 -0
- package/dist/{processors → types/processors}/tfliteFrameProcessor.d.ts +5 -4
- package/dist/types/processors/tfliteFrameProcessor.d.ts.map +1 -0
- package/dist/types/{barcode.d.ts → types/barcode.d.ts} +1 -0
- package/dist/types/types/barcode.d.ts.map +1 -0
- package/dist/types/{detection.d.ts → types/detection.d.ts} +1 -0
- package/dist/types/types/detection.d.ts.map +1 -0
- package/dist/types/types/index.d.ts +127 -0
- package/dist/types/types/index.d.ts.map +1 -0
- package/dist/types/{ocr.d.ts → types/ocr.d.ts} +1 -0
- package/dist/types/types/ocr.d.ts.map +1 -0
- package/dist/{utils → types/utils}/imagePreprocessor.d.ts +1 -0
- package/dist/types/utils/imagePreprocessor.d.ts.map +1 -0
- package/dist/types/utils/logger.d.ts +52 -0
- package/dist/types/utils/logger.d.ts.map +1 -0
- package/dist/{utils → types/utils}/yoloProcessor.d.ts +1 -0
- package/dist/types/utils/yoloProcessor.d.ts.map +1 -0
- package/package.json +62 -21
- package/dist/hooks/classLabels.js +0 -439
- 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
|