@switchlabs/verify-ai-react-native 2.4.23 → 2.5.1
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/lib/components/VerifyAIScanner.js +142 -29
- package/lib/ml/featureExtractor.d.ts +1 -1
- package/lib/ml/featureExtractor.js +12 -4
- package/lib/ml/modelManager.d.ts +15 -2
- package/lib/ml/modelManager.js +20 -3
- package/lib/ml/policyEngine.js +71 -0
- package/lib/ml/types.d.ts +17 -1
- package/lib/telemetry/TelemetryReporter.js +1 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +6 -1
- package/src/components/VerifyAIScanner.tsx +183 -42
- package/src/ml/featureExtractor.ts +12 -4
- package/src/ml/modelManager.ts +26 -3
- package/src/ml/policyEngine.ts +86 -0
- package/src/ml/types.ts +34 -1
- package/src/telemetry/TelemetryReporter.ts +1 -0
- package/src/version.ts +1 -1
|
@@ -20,8 +20,12 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
|
20
20
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
21
21
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
22
22
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
23
|
+
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
24
|
+
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
25
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
26
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
27
|
+
const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
|
|
28
|
+
const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
|
|
25
29
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
26
30
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
27
31
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -205,6 +209,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
205
209
|
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
206
210
|
const [cameraReady, setCameraReady] = useState(false);
|
|
207
211
|
const [cameraKey, setCameraKey] = useState(0);
|
|
212
|
+
const [cameraMounted, setCameraMounted] = useState(true);
|
|
208
213
|
const terminalResultRef = useRef(null);
|
|
209
214
|
const terminalResultTimerRef = useRef(null);
|
|
210
215
|
const terminalResultDeliveredRef = useRef(false);
|
|
@@ -217,6 +222,11 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
217
222
|
const lastAppStateRemountAtRef = useRef(null);
|
|
218
223
|
const lastOrientationRemountAtRef = useRef(null);
|
|
219
224
|
const lastCaptureRetryAtRef = useRef(null);
|
|
225
|
+
const cameraStartupStartedAtRef = useRef(Date.now());
|
|
226
|
+
const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
|
|
227
|
+
const cameraStartupSlowTrackedRef = useRef(false);
|
|
228
|
+
const cameraStartupReadyTrackedRef = useRef(false);
|
|
229
|
+
const startupRemountTimerRef = useRef(null);
|
|
220
230
|
const cameraRemountCountRef = useRef(0);
|
|
221
231
|
const startupWatchdogRemountCountRef = useRef(0);
|
|
222
232
|
const telemetryRef = useRef(telemetry);
|
|
@@ -258,7 +268,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
258
268
|
camera_ever_ready: cameraEverReadyRef.current,
|
|
259
269
|
camera_init_failed: cameraInitFailedRef.current,
|
|
260
270
|
camera_key: cameraKey,
|
|
271
|
+
camera_mounted: cameraMounted ? 1 : 0,
|
|
261
272
|
camera_remount_count: cameraRemountCountRef.current,
|
|
273
|
+
camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
|
|
274
|
+
camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
|
|
262
275
|
terminated,
|
|
263
276
|
exhausted,
|
|
264
277
|
app_state: appStateRef.current,
|
|
@@ -276,6 +289,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
276
289
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
277
290
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
278
291
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
292
|
+
requested_torch_enabled: enableTorch ? 1 : 0,
|
|
279
293
|
android_native_orientation_subscription_active: 0,
|
|
280
294
|
android_native_orientation_event_count: 0,
|
|
281
295
|
android_native_orientation_change_count: 0,
|
|
@@ -312,7 +326,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
312
326
|
...extra,
|
|
313
327
|
}), [
|
|
314
328
|
cameraKey,
|
|
329
|
+
cameraMounted,
|
|
315
330
|
exhausted,
|
|
331
|
+
enableTorch,
|
|
316
332
|
isLandscape,
|
|
317
333
|
overlayRotationDeg,
|
|
318
334
|
physicalOrientation,
|
|
@@ -356,6 +372,37 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
356
372
|
void activeTelemetry?.flush();
|
|
357
373
|
};
|
|
358
374
|
}, []);
|
|
375
|
+
const requestCameraRemount = useCallback((reason, opts = {}) => {
|
|
376
|
+
if (startupRemountTimerRef.current) {
|
|
377
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
378
|
+
startupRemountTimerRef.current = null;
|
|
379
|
+
}
|
|
380
|
+
setCameraReady(false);
|
|
381
|
+
cameraReadyRef.current = false;
|
|
382
|
+
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
383
|
+
if (opts.log) {
|
|
384
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
385
|
+
`backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`);
|
|
386
|
+
}
|
|
387
|
+
const remount = () => {
|
|
388
|
+
setCameraKey((k) => k + 1);
|
|
389
|
+
setCameraMounted(true);
|
|
390
|
+
startupRemountTimerRef.current = null;
|
|
391
|
+
};
|
|
392
|
+
if ((opts.backoffMs ?? 0) > 0) {
|
|
393
|
+
setCameraMounted(false);
|
|
394
|
+
startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
setCameraMounted(true);
|
|
398
|
+
remount();
|
|
399
|
+
}, [enableTorch]);
|
|
400
|
+
useEffect(() => () => {
|
|
401
|
+
if (startupRemountTimerRef.current) {
|
|
402
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
403
|
+
startupRemountTimerRef.current = null;
|
|
404
|
+
}
|
|
405
|
+
}, []);
|
|
359
406
|
useEffect(() => {
|
|
360
407
|
if (Platform.OS !== 'android')
|
|
361
408
|
return;
|
|
@@ -572,16 +619,9 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
572
619
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
573
620
|
},
|
|
574
621
|
});
|
|
575
|
-
|
|
576
|
-
cameraReadyRef.current = false;
|
|
577
|
-
// Delay remount so iOS rotation animation completes before the new
|
|
578
|
-
// AVCaptureVideoPreviewLayer initializes with the final frame bounds.
|
|
579
|
-
const timer = setTimeout(() => {
|
|
580
|
-
setCameraKey((k) => k + 1);
|
|
581
|
-
}, 400);
|
|
582
|
-
return () => clearTimeout(timer);
|
|
622
|
+
requestCameraRemount('orientation_change', { backoffMs: 400 });
|
|
583
623
|
}
|
|
584
|
-
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
624
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
585
625
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
586
626
|
useEffect(() => {
|
|
587
627
|
if (terminated)
|
|
@@ -609,12 +649,10 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
609
649
|
remount_requested_at: at,
|
|
610
650
|
}),
|
|
611
651
|
});
|
|
612
|
-
|
|
613
|
-
cameraReadyRef.current = false;
|
|
614
|
-
setCameraKey((k) => k + 1);
|
|
652
|
+
requestCameraRemount('appstate_active');
|
|
615
653
|
});
|
|
616
654
|
return () => subscription.remove();
|
|
617
|
-
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
655
|
+
}, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
618
656
|
const pausePreview = useCallback(() => {
|
|
619
657
|
cameraRef.current?.pausePreview?.().catch(() => { });
|
|
620
658
|
}, []);
|
|
@@ -668,13 +706,34 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
668
706
|
}, [onClose, result]);
|
|
669
707
|
// Camera init callbacks
|
|
670
708
|
const onCameraReady = useCallback(() => {
|
|
671
|
-
|
|
709
|
+
const readyAt = new Date().toISOString();
|
|
710
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
711
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
712
|
+
lastCameraReadyAtRef.current = readyAt;
|
|
672
713
|
setCameraReady(true);
|
|
673
714
|
cameraReadyRef.current = true;
|
|
674
715
|
cameraEverReadyRef.current = true;
|
|
675
716
|
cameraInitFailedRef.current = false;
|
|
676
717
|
startupWatchdogRemountCountRef.current = 0;
|
|
677
|
-
|
|
718
|
+
if (!cameraStartupReadyTrackedRef.current) {
|
|
719
|
+
cameraStartupReadyTrackedRef.current = true;
|
|
720
|
+
console.log(`VerifyAI[${SDK_VERSION}]: camera ready ` +
|
|
721
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
722
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
723
|
+
`torchRequested=${enableTorch ? 1 : 0}`);
|
|
724
|
+
telemetry?.track('camera_ready', {
|
|
725
|
+
component: 'scanner',
|
|
726
|
+
error: 'camera_ready',
|
|
727
|
+
metadata: buildScannerTelemetryMetadata({
|
|
728
|
+
camera_ready_at: readyAt,
|
|
729
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
730
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
731
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
732
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
|
|
678
737
|
const onMountError = useCallback((event) => {
|
|
679
738
|
const error = new Error(event.message || 'Camera mount error');
|
|
680
739
|
error.code = CAMERA_INIT_ERROR_CODE;
|
|
@@ -695,12 +754,58 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
695
754
|
}),
|
|
696
755
|
});
|
|
697
756
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
757
|
+
useEffect(() => {
|
|
758
|
+
if (!permission?.granted ||
|
|
759
|
+
terminated ||
|
|
760
|
+
currentAppState !== 'active' ||
|
|
761
|
+
!cameraMounted ||
|
|
762
|
+
cameraReadyRef.current ||
|
|
763
|
+
cameraInitFailedRef.current ||
|
|
764
|
+
cameraStartupSlowTrackedRef.current) {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const timer = setTimeout(() => {
|
|
768
|
+
if (appStateRef.current !== 'active' ||
|
|
769
|
+
cameraReadyRef.current ||
|
|
770
|
+
cameraInitFailedRef.current ||
|
|
771
|
+
cameraStartupSlowTrackedRef.current) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
cameraStartupSlowTrackedRef.current = true;
|
|
775
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
776
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
777
|
+
console.warn(`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
|
|
778
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
779
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
780
|
+
`torchRequested=${enableTorch ? 1 : 0}`);
|
|
781
|
+
telemetry?.track('camera_startup_slow', {
|
|
782
|
+
component: 'scanner',
|
|
783
|
+
error: 'camera_startup_slow',
|
|
784
|
+
metadata: buildScannerTelemetryMetadata({
|
|
785
|
+
startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
|
|
786
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
787
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
788
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
789
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
790
|
+
}),
|
|
791
|
+
});
|
|
792
|
+
}, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
|
|
793
|
+
return () => clearTimeout(timer);
|
|
794
|
+
}, [
|
|
795
|
+
permission?.granted,
|
|
796
|
+
terminated,
|
|
797
|
+
currentAppState,
|
|
798
|
+
cameraMounted,
|
|
799
|
+
buildScannerTelemetryMetadata,
|
|
800
|
+
enableTorch,
|
|
801
|
+
telemetry,
|
|
802
|
+
]);
|
|
698
803
|
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
699
804
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
700
805
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
701
806
|
// recreate the CameraView, which starts a fresh native session.
|
|
702
807
|
useEffect(() => {
|
|
703
|
-
if (!permission?.granted || terminated || currentAppState !== 'active')
|
|
808
|
+
if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted)
|
|
704
809
|
return;
|
|
705
810
|
const watchdogMs = Platform.OS === 'android'
|
|
706
811
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
@@ -732,11 +837,14 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
732
837
|
remount_reason: 'startup_watchdog',
|
|
733
838
|
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
734
839
|
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
840
|
+
startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
841
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
735
842
|
}),
|
|
736
843
|
});
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
844
|
+
requestCameraRemount('startup_watchdog', {
|
|
845
|
+
backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
846
|
+
log: true,
|
|
847
|
+
});
|
|
740
848
|
}
|
|
741
849
|
}
|
|
742
850
|
}, watchdogMs);
|
|
@@ -745,9 +853,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
745
853
|
permission?.granted,
|
|
746
854
|
terminated,
|
|
747
855
|
currentAppState,
|
|
856
|
+
cameraMounted,
|
|
748
857
|
cameraKey,
|
|
749
858
|
buildScannerTelemetryMetadata,
|
|
859
|
+
enableTorch,
|
|
750
860
|
onMountError,
|
|
861
|
+
requestCameraRemount,
|
|
751
862
|
telemetry,
|
|
752
863
|
]);
|
|
753
864
|
// Track permission denied
|
|
@@ -880,11 +991,12 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
880
991
|
const retryAt = new Date().toISOString();
|
|
881
992
|
lastCaptureRetryAtRef.current = retryAt;
|
|
882
993
|
cameraRemountCountRef.current++;
|
|
883
|
-
|
|
884
|
-
cameraReadyRef.current = false;
|
|
885
|
-
setCameraKey((key) => key + 1);
|
|
994
|
+
requestCameraRemount('capture_retry');
|
|
886
995
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
887
|
-
|
|
996
|
+
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
997
|
+
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
998
|
+
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
999
|
+
captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
|
|
888
1000
|
telemetry?.track('camera_capture_retry', {
|
|
889
1001
|
component: 'scanner',
|
|
890
1002
|
error: normalized,
|
|
@@ -894,7 +1006,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
894
1006
|
capture_retry_attempted: captureRetryAttempted,
|
|
895
1007
|
capture_retry_ready: captureRetryReady,
|
|
896
1008
|
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
897
|
-
capture_retry_ready_timeout_ms:
|
|
1009
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
898
1010
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
899
1011
|
}),
|
|
900
1012
|
});
|
|
@@ -1051,7 +1163,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1051
1163
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1052
1164
|
}
|
|
1053
1165
|
}
|
|
1054
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1166
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1055
1167
|
// Expose capture to parent via ref
|
|
1056
1168
|
if (captureRef) {
|
|
1057
1169
|
captureRef.current = handleCapture;
|
|
@@ -1063,7 +1175,8 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1063
1175
|
return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
|
|
1064
1176
|
}
|
|
1065
1177
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1066
|
-
|
|
1178
|
+
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
|
|
1179
|
+
return (_jsx(View, { style: [styles.container, style], children: cameraMounted && (_jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: shouldEnableTorch, onCameraReady: onCameraReady, onMountError: onMountError, responsiveOrientationWhenOrientationLocked: true, onResponsiveOrientationChanged: (event) => {
|
|
1067
1180
|
setPhysicalOrientation(event.orientation);
|
|
1068
1181
|
}, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: [styles.topBar, isLandscape && styles.topBarLandscape], children: _jsx(Text, { style: [
|
|
1069
1182
|
styles.titleText,
|
|
@@ -1134,7 +1247,7 @@ export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose,
|
|
|
1134
1247
|
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
1135
1248
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
1136
1249
|
styles.captureButtonDisabled,
|
|
1137
|
-
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey) }));
|
|
1250
|
+
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey)) }));
|
|
1138
1251
|
}
|
|
1139
1252
|
const CORNER_SIZE = 30;
|
|
1140
1253
|
const CORNER_THICKNESS = 3;
|
|
@@ -6,7 +6,7 @@ import type { FeatureVector } from './types';
|
|
|
6
6
|
/** Ontology class name mapping (must match ontology.ts) */
|
|
7
7
|
export declare const ONTOLOGY_CLASS_NAMES: Record<number, string>;
|
|
8
8
|
/** Detection model class index -> ontology class ID mapping. */
|
|
9
|
-
export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 20, 21, 22, 23, 24, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
|
|
9
|
+
export declare const MODEL_OUTPUT_CLASS_IDS: readonly [1, 2, 3, 4, 5, 6, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 60, 61, 62, 63, 64, 65, 66, 80, 81, 82, 83, 84, 85, 86, 87];
|
|
10
10
|
interface RawDetection {
|
|
11
11
|
classId: number;
|
|
12
12
|
confidence: number;
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
* Feature extractor — converts raw detections into a structured FeatureVector.
|
|
3
3
|
* Must produce identical output to the Dart and server TypeScript implementations.
|
|
4
4
|
*/
|
|
5
|
-
const SCHEMA_VERSION = '
|
|
5
|
+
const SCHEMA_VERSION = '2.0.0';
|
|
6
6
|
/** Vehicle class IDs (must match ontology.ts) */
|
|
7
|
-
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
|
|
7
|
+
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
|
|
8
8
|
/** Surface class IDs */
|
|
9
9
|
const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
|
|
10
10
|
/** Ontology class name mapping (must match ontology.ts) */
|
|
11
11
|
export const ONTOLOGY_CLASS_NAMES = {
|
|
12
|
-
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
|
|
12
|
+
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
|
|
13
13
|
20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
|
|
14
|
+
// Car panels (ontology v2 — damage intelligence)
|
|
15
|
+
25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
|
|
16
|
+
28: 'car_front_bumper', 29: 'car_rear_bumper',
|
|
17
|
+
30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
|
|
18
|
+
34: 'car_fender_fl', 35: 'car_fender_fr',
|
|
19
|
+
36: 'car_quarter_rl', 37: 'car_quarter_rr',
|
|
20
|
+
38: 'car_rocker', 39: 'car_mirror',
|
|
14
21
|
40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
|
|
15
22
|
44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
|
|
16
23
|
60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
|
|
@@ -20,8 +27,9 @@ export const ONTOLOGY_CLASS_NAMES = {
|
|
|
20
27
|
};
|
|
21
28
|
/** Detection model class index -> ontology class ID mapping. */
|
|
22
29
|
export const MODEL_OUTPUT_CLASS_IDS = [
|
|
23
|
-
1, 2, 3, 4, 5,
|
|
30
|
+
1, 2, 3, 4, 5, 6,
|
|
24
31
|
20, 21, 22, 23, 24,
|
|
32
|
+
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
|
25
33
|
40, 41, 42, 43, 44, 45, 46, 47, 48,
|
|
26
34
|
60, 61, 62, 63, 64, 65, 66,
|
|
27
35
|
80, 81, 82, 83, 84, 85, 86, 87,
|
package/lib/ml/modelManager.d.ts
CHANGED
|
@@ -15,8 +15,21 @@ export declare class ModelManager {
|
|
|
15
15
|
private _currentBundle;
|
|
16
16
|
constructor(config: ModelManagerConfig);
|
|
17
17
|
get currentBundle(): BundleManifest | null;
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Check for bundle updates from the server.
|
|
20
|
+
*
|
|
21
|
+
* `opts` lets callers narrow which bundle they receive when the server
|
|
22
|
+
* has per-asset / per-region / per-app-version targeted bundles
|
|
23
|
+
* configured (see `app/api/v1/models/latest`). All fields are optional;
|
|
24
|
+
* omitting them keeps the legacy behaviour of receiving the wildcard
|
|
25
|
+
* bundle for the policy.
|
|
26
|
+
*/
|
|
27
|
+
checkForUpdates(policyId: string, platform: 'ios' | 'android', opts?: {
|
|
28
|
+
assetType?: string;
|
|
29
|
+
region?: string;
|
|
30
|
+
appVersion?: string;
|
|
31
|
+
deviceId?: string;
|
|
32
|
+
}): Promise<BundleManifest | null>;
|
|
20
33
|
/** Download model artifacts and verify integrity. */
|
|
21
34
|
private downloadArtifacts;
|
|
22
35
|
private readFileSha256;
|
package/lib/ml/modelManager.js
CHANGED
|
@@ -26,8 +26,16 @@ export class ModelManager {
|
|
|
26
26
|
get currentBundle() {
|
|
27
27
|
return this._currentBundle;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Check for bundle updates from the server.
|
|
31
|
+
*
|
|
32
|
+
* `opts` lets callers narrow which bundle they receive when the server
|
|
33
|
+
* has per-asset / per-region / per-app-version targeted bundles
|
|
34
|
+
* configured (see `app/api/v1/models/latest`). All fields are optional;
|
|
35
|
+
* omitting them keeps the legacy behaviour of receiving the wildcard
|
|
36
|
+
* bundle for the policy.
|
|
37
|
+
*/
|
|
38
|
+
async checkForUpdates(policyId, platform, opts) {
|
|
31
39
|
try {
|
|
32
40
|
const headers = {
|
|
33
41
|
'X-API-Key': this.apiKey,
|
|
@@ -36,7 +44,16 @@ export class ModelManager {
|
|
|
36
44
|
if (this.cachedETag) {
|
|
37
45
|
headers['If-None-Match'] = this.cachedETag;
|
|
38
46
|
}
|
|
39
|
-
const
|
|
47
|
+
const qs = new URLSearchParams({ policy: policyId, platform });
|
|
48
|
+
if (opts?.assetType)
|
|
49
|
+
qs.set('asset_type', opts.assetType);
|
|
50
|
+
if (opts?.region)
|
|
51
|
+
qs.set('region', opts.region);
|
|
52
|
+
if (opts?.appVersion)
|
|
53
|
+
qs.set('app_version', opts.appVersion);
|
|
54
|
+
if (opts?.deviceId)
|
|
55
|
+
qs.set('device_id', opts.deviceId);
|
|
56
|
+
const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
|
|
40
57
|
const response = await fetch(url, { headers });
|
|
41
58
|
if (response.status === 304) {
|
|
42
59
|
return this._currentBundle;
|
package/lib/ml/policyEngine.js
CHANGED
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
* This file shares the same logic as lib/verify-ai/ml/policy-engine.ts
|
|
6
6
|
* but is self-contained for the SDK package.
|
|
7
7
|
*/
|
|
8
|
+
// Severity ordering for `severity_gte`. Must match server TS + Dart impls.
|
|
9
|
+
const SEVERITY_ORDER = ['none', 'light', 'medium', 'severe'];
|
|
10
|
+
function severityRank(s) {
|
|
11
|
+
if (typeof s !== 'string')
|
|
12
|
+
return -1;
|
|
13
|
+
return SEVERITY_ORDER.indexOf(s);
|
|
14
|
+
}
|
|
15
|
+
function compareNumber(a, op, b) {
|
|
16
|
+
switch (op) {
|
|
17
|
+
case 'eq': return a === b;
|
|
18
|
+
case 'neq': return a !== b;
|
|
19
|
+
case 'gt': return a > b;
|
|
20
|
+
case 'gte': return a >= b;
|
|
21
|
+
case 'lt': return a < b;
|
|
22
|
+
case 'lte': return a <= b;
|
|
23
|
+
default: return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
8
26
|
// ─── Field Resolution ───
|
|
9
27
|
function resolveField(features, field) {
|
|
10
28
|
const parts = field.split('.');
|
|
@@ -67,9 +85,62 @@ function applyOperator(fieldValue, operator, value) {
|
|
|
67
85
|
}
|
|
68
86
|
}
|
|
69
87
|
function evaluateCondition(features, condition) {
|
|
88
|
+
if (condition.operator === 'severity_gte' ||
|
|
89
|
+
condition.operator === 'panel_has_damage' ||
|
|
90
|
+
condition.operator === 'aggregate_count') {
|
|
91
|
+
return applyDamageOperator(features, condition);
|
|
92
|
+
}
|
|
70
93
|
const fieldValue = resolveField(features, condition.field);
|
|
71
94
|
return applyOperator(fieldValue, condition.operator, condition.value);
|
|
72
95
|
}
|
|
96
|
+
function applyDamageOperator(features, condition) {
|
|
97
|
+
const findings = Array.isArray(features.damage_findings)
|
|
98
|
+
? features.damage_findings
|
|
99
|
+
: [];
|
|
100
|
+
switch (condition.operator) {
|
|
101
|
+
case 'severity_gte': {
|
|
102
|
+
const minRank = severityRank(condition.value);
|
|
103
|
+
if (minRank < 0)
|
|
104
|
+
return false;
|
|
105
|
+
if (condition.field && condition.field !== 'damage_findings') {
|
|
106
|
+
const f = resolveField(features, condition.field);
|
|
107
|
+
return severityRank(f) >= minRank;
|
|
108
|
+
}
|
|
109
|
+
return findings.some((finding) => severityRank(finding.severity) >= minRank);
|
|
110
|
+
}
|
|
111
|
+
case 'panel_has_damage': {
|
|
112
|
+
const panelName = typeof condition.value === 'string'
|
|
113
|
+
? condition.value
|
|
114
|
+
: typeof condition.field === 'string' &&
|
|
115
|
+
condition.field !== 'damage_findings' &&
|
|
116
|
+
condition.field !== ''
|
|
117
|
+
? condition.field
|
|
118
|
+
: null;
|
|
119
|
+
if (!panelName)
|
|
120
|
+
return false;
|
|
121
|
+
return findings.some((finding) => finding.panel === panelName && finding.severity !== 'none');
|
|
122
|
+
}
|
|
123
|
+
case 'aggregate_count': {
|
|
124
|
+
const v = condition.value;
|
|
125
|
+
if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op)
|
|
126
|
+
return false;
|
|
127
|
+
const where = v.where || {};
|
|
128
|
+
const minSeverityRank = where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
|
|
129
|
+
const count = findings.filter((finding) => {
|
|
130
|
+
if (where.panel && finding.panel !== where.panel)
|
|
131
|
+
return false;
|
|
132
|
+
if (where.damage_type && finding.damage_type !== where.damage_type)
|
|
133
|
+
return false;
|
|
134
|
+
if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank)
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}).length;
|
|
138
|
+
return compareNumber(count, v.op, v.value);
|
|
139
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
73
144
|
function evaluateRule(features, rule) {
|
|
74
145
|
const passed = rule.conditions.every((c) => evaluateCondition(features, c));
|
|
75
146
|
return {
|
package/lib/ml/types.d.ts
CHANGED
|
@@ -32,6 +32,18 @@ export interface ImageQuality {
|
|
|
32
32
|
is_dark: boolean;
|
|
33
33
|
has_vehicle: boolean;
|
|
34
34
|
}
|
|
35
|
+
export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
|
|
36
|
+
export type DamageType = 'scratch' | 'dent' | 'paint_chip' | 'crack' | 'broken' | 'missing' | 'rust' | 'tear' | 'stain' | 'glass_damage' | 'other';
|
|
37
|
+
export interface DamageFinding {
|
|
38
|
+
finding_id: string;
|
|
39
|
+
panel: string;
|
|
40
|
+
damage_type: DamageType;
|
|
41
|
+
severity: DamageSeverity;
|
|
42
|
+
severity_score: number;
|
|
43
|
+
bbox: [number, number, number, number];
|
|
44
|
+
area_pct: number;
|
|
45
|
+
confidence: number;
|
|
46
|
+
}
|
|
35
47
|
export interface FeatureVector {
|
|
36
48
|
schema_version: string;
|
|
37
49
|
detections: Detection[];
|
|
@@ -39,8 +51,12 @@ export interface FeatureVector {
|
|
|
39
51
|
image_quality: ImageQuality;
|
|
40
52
|
vehicle_on_surface: string | null;
|
|
41
53
|
vehicle_near: string[];
|
|
54
|
+
/** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
|
|
55
|
+
damage_findings?: DamageFinding[];
|
|
56
|
+
/** Panels visible in this frame (schema v2). */
|
|
57
|
+
panel_inventory?: string[];
|
|
42
58
|
}
|
|
43
|
-
export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps';
|
|
59
|
+
export type ComparisonOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'not_contains' | 'exists' | 'not_exists' | 'in' | 'not_in' | 'overlaps' | 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
|
|
44
60
|
export interface Condition {
|
|
45
61
|
field: string;
|
|
46
62
|
operator: ComparisonOperator;
|
|
@@ -13,6 +13,7 @@ const TELEMETRY_PERSIST_KEY = '@verifyai/telemetry_buffer';
|
|
|
13
13
|
const CRITICAL_EVENTS = new Set([
|
|
14
14
|
'camera_init_failure',
|
|
15
15
|
'camera_preview_timeout',
|
|
16
|
+
'camera_startup_slow',
|
|
16
17
|
'camera_permission_denied',
|
|
17
18
|
'camera_scanner_mounted',
|
|
18
19
|
'camera_scanner_disposed',
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.
|
|
1
|
+
export declare const SDK_VERSION = "2.5.1";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.
|
|
1
|
+
export const SDK_VERSION = '2.5.1';
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchlabs/verify-ai-react-native",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
4
4
|
"description": "React Native SDK for Verify AI - photo verification with AI vision processing",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/levyelectric/verify.git",
|
|
8
|
+
"directory": "packages/verify-ai-react-native"
|
|
9
|
+
},
|
|
5
10
|
"main": "./lib/index.js",
|
|
6
11
|
"types": "./lib/index.d.ts",
|
|
7
12
|
"exports": {
|
|
@@ -45,8 +45,12 @@ const CAMERA_INIT_ERROR_CODE = 'ERR_CAMERA_INIT_FAILED';
|
|
|
45
45
|
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
46
46
|
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
47
47
|
const CAMERA_CAPTURE_RETRY_DELAY_MS = 350;
|
|
48
|
-
const
|
|
49
|
-
const
|
|
48
|
+
const IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 3000;
|
|
49
|
+
const ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS = 10000;
|
|
50
|
+
const IOS_CAMERA_STARTUP_WATCHDOG_MS = 8000;
|
|
51
|
+
const ANDROID_CAMERA_STARTUP_WATCHDOG_MS = 10000;
|
|
52
|
+
const CAMERA_STARTUP_SLOW_TELEMETRY_MS = 3000;
|
|
53
|
+
const CAMERA_STARTUP_REMOUNT_BACKOFF_MS = 350;
|
|
50
54
|
const CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS = 5;
|
|
51
55
|
const ANDROID_ORIENTATION_NO_EVENT_TIMEOUT_MS = 3000;
|
|
52
56
|
const ANDROID_ORIENTATION_SAMPLE_TELEMETRY_MS = 3000;
|
|
@@ -307,6 +311,7 @@ export function VerifyAIScanner({
|
|
|
307
311
|
const [currentAppState, setCurrentAppState] = useState(AppState.currentState);
|
|
308
312
|
const [cameraReady, setCameraReady] = useState(false);
|
|
309
313
|
const [cameraKey, setCameraKey] = useState(0);
|
|
314
|
+
const [cameraMounted, setCameraMounted] = useState(true);
|
|
310
315
|
const terminalResultRef = useRef<VerificationResult | null>(null);
|
|
311
316
|
const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
312
317
|
const terminalResultDeliveredRef = useRef(false);
|
|
@@ -319,6 +324,11 @@ export function VerifyAIScanner({
|
|
|
319
324
|
const lastAppStateRemountAtRef = useRef<string | null>(null);
|
|
320
325
|
const lastOrientationRemountAtRef = useRef<string | null>(null);
|
|
321
326
|
const lastCaptureRetryAtRef = useRef<string | null>(null);
|
|
327
|
+
const cameraStartupStartedAtRef = useRef(Date.now());
|
|
328
|
+
const cameraStartupAttemptStartedAtRef = useRef(cameraStartupStartedAtRef.current);
|
|
329
|
+
const cameraStartupSlowTrackedRef = useRef(false);
|
|
330
|
+
const cameraStartupReadyTrackedRef = useRef(false);
|
|
331
|
+
const startupRemountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
322
332
|
const cameraRemountCountRef = useRef(0);
|
|
323
333
|
const startupWatchdogRemountCountRef = useRef(0);
|
|
324
334
|
const telemetryRef = useRef<TelemetryReporter | null | undefined>(telemetry);
|
|
@@ -369,7 +379,10 @@ export function VerifyAIScanner({
|
|
|
369
379
|
camera_ever_ready: cameraEverReadyRef.current,
|
|
370
380
|
camera_init_failed: cameraInitFailedRef.current,
|
|
371
381
|
camera_key: cameraKey,
|
|
382
|
+
camera_mounted: cameraMounted ? 1 : 0,
|
|
372
383
|
camera_remount_count: cameraRemountCountRef.current,
|
|
384
|
+
camera_startup_total_elapsed_ms: Date.now() - cameraStartupStartedAtRef.current,
|
|
385
|
+
camera_startup_attempt_elapsed_ms: Date.now() - cameraStartupAttemptStartedAtRef.current,
|
|
373
386
|
terminated,
|
|
374
387
|
exhausted,
|
|
375
388
|
app_state: appStateRef.current,
|
|
@@ -387,6 +400,7 @@ export function VerifyAIScanner({
|
|
|
387
400
|
last_appstate_remount_at: lastAppStateRemountAtRef.current,
|
|
388
401
|
last_orientation_remount_at: lastOrientationRemountAtRef.current,
|
|
389
402
|
last_capture_retry_at: lastCaptureRetryAtRef.current,
|
|
403
|
+
requested_torch_enabled: enableTorch ? 1 : 0,
|
|
390
404
|
android_native_orientation_subscription_active: 0,
|
|
391
405
|
android_native_orientation_event_count: 0,
|
|
392
406
|
android_native_orientation_change_count: 0,
|
|
@@ -423,7 +437,9 @@ export function VerifyAIScanner({
|
|
|
423
437
|
...extra,
|
|
424
438
|
}), [
|
|
425
439
|
cameraKey,
|
|
440
|
+
cameraMounted,
|
|
426
441
|
exhausted,
|
|
442
|
+
enableTorch,
|
|
427
443
|
isLandscape,
|
|
428
444
|
overlayRotationDeg,
|
|
429
445
|
physicalOrientation,
|
|
@@ -474,6 +490,52 @@ export function VerifyAIScanner({
|
|
|
474
490
|
};
|
|
475
491
|
}, []);
|
|
476
492
|
|
|
493
|
+
const requestCameraRemount = useCallback((
|
|
494
|
+
reason: string,
|
|
495
|
+
opts: {
|
|
496
|
+
backoffMs?: number;
|
|
497
|
+
log?: boolean;
|
|
498
|
+
} = {},
|
|
499
|
+
) => {
|
|
500
|
+
if (startupRemountTimerRef.current) {
|
|
501
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
502
|
+
startupRemountTimerRef.current = null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setCameraReady(false);
|
|
506
|
+
cameraReadyRef.current = false;
|
|
507
|
+
cameraStartupAttemptStartedAtRef.current = Date.now();
|
|
508
|
+
|
|
509
|
+
if (opts.log) {
|
|
510
|
+
console.warn(
|
|
511
|
+
`VerifyAI[${SDK_VERSION}]: remounting camera reason=${reason} ` +
|
|
512
|
+
`backoffMs=${opts.backoffMs ?? 0} torchRequested=${enableTorch ? 1 : 0}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const remount = () => {
|
|
517
|
+
setCameraKey((k) => k + 1);
|
|
518
|
+
setCameraMounted(true);
|
|
519
|
+
startupRemountTimerRef.current = null;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
if ((opts.backoffMs ?? 0) > 0) {
|
|
523
|
+
setCameraMounted(false);
|
|
524
|
+
startupRemountTimerRef.current = setTimeout(remount, opts.backoffMs);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
setCameraMounted(true);
|
|
529
|
+
remount();
|
|
530
|
+
}, [enableTorch]);
|
|
531
|
+
|
|
532
|
+
useEffect(() => () => {
|
|
533
|
+
if (startupRemountTimerRef.current) {
|
|
534
|
+
clearTimeout(startupRemountTimerRef.current);
|
|
535
|
+
startupRemountTimerRef.current = null;
|
|
536
|
+
}
|
|
537
|
+
}, []);
|
|
538
|
+
|
|
477
539
|
useEffect(() => {
|
|
478
540
|
if (Platform.OS !== 'android') return;
|
|
479
541
|
|
|
@@ -726,17 +788,9 @@ export function VerifyAIScanner({
|
|
|
726
788
|
to: windowWidth > windowHeight ? 'landscape' : 'portrait',
|
|
727
789
|
},
|
|
728
790
|
});
|
|
729
|
-
|
|
730
|
-
cameraReadyRef.current = false;
|
|
731
|
-
|
|
732
|
-
// Delay remount so iOS rotation animation completes before the new
|
|
733
|
-
// AVCaptureVideoPreviewLayer initializes with the final frame bounds.
|
|
734
|
-
const timer = setTimeout(() => {
|
|
735
|
-
setCameraKey((k) => k + 1);
|
|
736
|
-
}, 400);
|
|
737
|
-
return () => clearTimeout(timer);
|
|
791
|
+
requestCameraRemount('orientation_change', { backoffMs: 400 });
|
|
738
792
|
}
|
|
739
|
-
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
793
|
+
}, [windowWidth, windowHeight, terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
740
794
|
|
|
741
795
|
// Resume camera when app returns from background/inactive (e.g. notification bar)
|
|
742
796
|
useEffect(() => {
|
|
@@ -767,13 +821,11 @@ export function VerifyAIScanner({
|
|
|
767
821
|
remount_requested_at: at,
|
|
768
822
|
}),
|
|
769
823
|
});
|
|
770
|
-
|
|
771
|
-
cameraReadyRef.current = false;
|
|
772
|
-
setCameraKey((k) => k + 1);
|
|
824
|
+
requestCameraRemount('appstate_active');
|
|
773
825
|
});
|
|
774
826
|
|
|
775
827
|
return () => subscription.remove();
|
|
776
|
-
}, [terminated, buildScannerTelemetryMetadata, telemetry]);
|
|
828
|
+
}, [terminated, buildScannerTelemetryMetadata, requestCameraRemount, telemetry]);
|
|
777
829
|
|
|
778
830
|
const pausePreview = useCallback(() => {
|
|
779
831
|
cameraRef.current?.pausePreview?.().catch(() => {});
|
|
@@ -834,13 +886,36 @@ export function VerifyAIScanner({
|
|
|
834
886
|
|
|
835
887
|
// Camera init callbacks
|
|
836
888
|
const onCameraReady = useCallback(() => {
|
|
837
|
-
|
|
889
|
+
const readyAt = new Date().toISOString();
|
|
890
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
891
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
892
|
+
lastCameraReadyAtRef.current = readyAt;
|
|
838
893
|
setCameraReady(true);
|
|
839
894
|
cameraReadyRef.current = true;
|
|
840
895
|
cameraEverReadyRef.current = true;
|
|
841
896
|
cameraInitFailedRef.current = false;
|
|
842
897
|
startupWatchdogRemountCountRef.current = 0;
|
|
843
|
-
|
|
898
|
+
if (!cameraStartupReadyTrackedRef.current) {
|
|
899
|
+
cameraStartupReadyTrackedRef.current = true;
|
|
900
|
+
console.log(
|
|
901
|
+
`VerifyAI[${SDK_VERSION}]: camera ready ` +
|
|
902
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
903
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
904
|
+
`torchRequested=${enableTorch ? 1 : 0}`,
|
|
905
|
+
);
|
|
906
|
+
telemetry?.track('camera_ready', {
|
|
907
|
+
component: 'scanner',
|
|
908
|
+
error: 'camera_ready',
|
|
909
|
+
metadata: buildScannerTelemetryMetadata({
|
|
910
|
+
camera_ready_at: readyAt,
|
|
911
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
912
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
913
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
914
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
915
|
+
}),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}, [buildScannerTelemetryMetadata, enableTorch, telemetry]);
|
|
844
919
|
|
|
845
920
|
const onMountError = useCallback((event: { message?: string }) => {
|
|
846
921
|
const error = new Error(event.message || 'Camera mount error') as ErrorWithDetails;
|
|
@@ -863,12 +938,68 @@ export function VerifyAIScanner({
|
|
|
863
938
|
});
|
|
864
939
|
}, [buildScannerTelemetryMetadata, onError, telemetry]);
|
|
865
940
|
|
|
941
|
+
useEffect(() => {
|
|
942
|
+
if (
|
|
943
|
+
!permission?.granted ||
|
|
944
|
+
terminated ||
|
|
945
|
+
currentAppState !== 'active' ||
|
|
946
|
+
!cameraMounted ||
|
|
947
|
+
cameraReadyRef.current ||
|
|
948
|
+
cameraInitFailedRef.current ||
|
|
949
|
+
cameraStartupSlowTrackedRef.current
|
|
950
|
+
) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const timer = setTimeout(() => {
|
|
955
|
+
if (
|
|
956
|
+
appStateRef.current !== 'active' ||
|
|
957
|
+
cameraReadyRef.current ||
|
|
958
|
+
cameraInitFailedRef.current ||
|
|
959
|
+
cameraStartupSlowTrackedRef.current
|
|
960
|
+
) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
cameraStartupSlowTrackedRef.current = true;
|
|
965
|
+
const startupTotalElapsedMs = Date.now() - cameraStartupStartedAtRef.current;
|
|
966
|
+
const startupAttemptElapsedMs = Date.now() - cameraStartupAttemptStartedAtRef.current;
|
|
967
|
+
console.warn(
|
|
968
|
+
`VerifyAI[${SDK_VERSION}]: camera startup slow ` +
|
|
969
|
+
`startupTotalMs=${startupTotalElapsedMs} ` +
|
|
970
|
+
`startupAttemptMs=${startupAttemptElapsedMs} ` +
|
|
971
|
+
`torchRequested=${enableTorch ? 1 : 0}`,
|
|
972
|
+
);
|
|
973
|
+
telemetry?.track('camera_startup_slow', {
|
|
974
|
+
component: 'scanner',
|
|
975
|
+
error: 'camera_startup_slow',
|
|
976
|
+
metadata: buildScannerTelemetryMetadata({
|
|
977
|
+
startup_slow_threshold_ms: CAMERA_STARTUP_SLOW_TELEMETRY_MS,
|
|
978
|
+
startup_total_elapsed_ms: startupTotalElapsedMs,
|
|
979
|
+
startup_attempt_elapsed_ms: startupAttemptElapsedMs,
|
|
980
|
+
startup_watchdog_remount_count: startupWatchdogRemountCountRef.current,
|
|
981
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
982
|
+
}),
|
|
983
|
+
});
|
|
984
|
+
}, CAMERA_STARTUP_SLOW_TELEMETRY_MS);
|
|
985
|
+
|
|
986
|
+
return () => clearTimeout(timer);
|
|
987
|
+
}, [
|
|
988
|
+
permission?.granted,
|
|
989
|
+
terminated,
|
|
990
|
+
currentAppState,
|
|
991
|
+
cameraMounted,
|
|
992
|
+
buildScannerTelemetryMetadata,
|
|
993
|
+
enableTorch,
|
|
994
|
+
telemetry,
|
|
995
|
+
]);
|
|
996
|
+
|
|
866
997
|
// Startup watchdog — if camera hasn't fired onCameraReady in time, force remount.
|
|
867
998
|
// On iOS the native AVCaptureSession sometimes fails to start its preview layer on first
|
|
868
999
|
// mount (e.g. during navigation transitions). Changing the key forces React to destroy and
|
|
869
1000
|
// recreate the CameraView, which starts a fresh native session.
|
|
870
1001
|
useEffect(() => {
|
|
871
|
-
if (!permission?.granted || terminated || currentAppState !== 'active') return;
|
|
1002
|
+
if (!permission?.granted || terminated || currentAppState !== 'active' || !cameraMounted) return;
|
|
872
1003
|
|
|
873
1004
|
const watchdogMs = Platform.OS === 'android'
|
|
874
1005
|
? ANDROID_CAMERA_STARTUP_WATCHDOG_MS
|
|
@@ -902,11 +1033,14 @@ export function VerifyAIScanner({
|
|
|
902
1033
|
remount_reason: 'startup_watchdog',
|
|
903
1034
|
startup_watchdog_remount_count: startupWatchdogRemountCount,
|
|
904
1035
|
startup_watchdog_max_remounts: CAMERA_STARTUP_WATCHDOG_MAX_REMOUNTS,
|
|
1036
|
+
startup_remount_backoff_ms: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
1037
|
+
torch_deferred_until_ready: enableTorch ? 1 : 0,
|
|
905
1038
|
}),
|
|
906
1039
|
});
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1040
|
+
requestCameraRemount('startup_watchdog', {
|
|
1041
|
+
backoffMs: CAMERA_STARTUP_REMOUNT_BACKOFF_MS,
|
|
1042
|
+
log: true,
|
|
1043
|
+
});
|
|
910
1044
|
}
|
|
911
1045
|
}
|
|
912
1046
|
}, watchdogMs);
|
|
@@ -916,9 +1050,12 @@ export function VerifyAIScanner({
|
|
|
916
1050
|
permission?.granted,
|
|
917
1051
|
terminated,
|
|
918
1052
|
currentAppState,
|
|
1053
|
+
cameraMounted,
|
|
919
1054
|
cameraKey,
|
|
920
1055
|
buildScannerTelemetryMetadata,
|
|
1056
|
+
enableTorch,
|
|
921
1057
|
onMountError,
|
|
1058
|
+
requestCameraRemount,
|
|
922
1059
|
telemetry,
|
|
923
1060
|
]);
|
|
924
1061
|
|
|
@@ -1075,11 +1212,12 @@ export function VerifyAIScanner({
|
|
|
1075
1212
|
const retryAt = new Date().toISOString();
|
|
1076
1213
|
lastCaptureRetryAtRef.current = retryAt;
|
|
1077
1214
|
cameraRemountCountRef.current++;
|
|
1078
|
-
|
|
1079
|
-
cameraReadyRef.current = false;
|
|
1080
|
-
setCameraKey((key) => key + 1);
|
|
1215
|
+
requestCameraRemount('capture_retry');
|
|
1081
1216
|
await sleep(CAMERA_CAPTURE_RETRY_DELAY_MS);
|
|
1082
|
-
|
|
1217
|
+
const captureRetryReadyTimeoutMs = Platform.OS === 'android'
|
|
1218
|
+
? ANDROID_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS
|
|
1219
|
+
: IOS_CAMERA_CAPTURE_RETRY_READY_TIMEOUT_MS;
|
|
1220
|
+
captureRetryReady = await waitForCameraReady(captureRetryReadyTimeoutMs);
|
|
1083
1221
|
telemetry?.track('camera_capture_retry', {
|
|
1084
1222
|
component: 'scanner',
|
|
1085
1223
|
error: normalized,
|
|
@@ -1089,7 +1227,7 @@ export function VerifyAIScanner({
|
|
|
1089
1227
|
capture_retry_attempted: captureRetryAttempted,
|
|
1090
1228
|
capture_retry_ready: captureRetryReady,
|
|
1091
1229
|
capture_retry_delay_ms: CAMERA_CAPTURE_RETRY_DELAY_MS,
|
|
1092
|
-
capture_retry_ready_timeout_ms:
|
|
1230
|
+
capture_retry_ready_timeout_ms: captureRetryReadyTimeoutMs,
|
|
1093
1231
|
last_native_capture_error: lastNativeCaptureErrorMessage,
|
|
1094
1232
|
}),
|
|
1095
1233
|
});
|
|
@@ -1280,7 +1418,7 @@ export function VerifyAIScanner({
|
|
|
1280
1418
|
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
1281
1419
|
}
|
|
1282
1420
|
}
|
|
1283
|
-
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1421
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, buildScannerTelemetryMetadata, requestCameraRemount, telemetry, terminated, physicalOrientation, overlayRotationDeg]);
|
|
1284
1422
|
|
|
1285
1423
|
// Expose capture to parent via ref
|
|
1286
1424
|
if (captureRef) {
|
|
@@ -1303,22 +1441,24 @@ export function VerifyAIScanner({
|
|
|
1303
1441
|
}
|
|
1304
1442
|
|
|
1305
1443
|
const showBottomCard = status === 'success' || status === 'error';
|
|
1444
|
+
const shouldEnableTorch = !terminated && cameraReady && !!enableTorch;
|
|
1306
1445
|
|
|
1307
1446
|
return (
|
|
1308
1447
|
<View style={[styles.container, style]}>
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1448
|
+
{cameraMounted && (
|
|
1449
|
+
<CameraView
|
|
1450
|
+
key={cameraKey}
|
|
1451
|
+
ref={cameraRef}
|
|
1452
|
+
style={styles.camera}
|
|
1453
|
+
facing="back"
|
|
1454
|
+
enableTorch={shouldEnableTorch}
|
|
1455
|
+
onCameraReady={onCameraReady}
|
|
1456
|
+
onMountError={onMountError}
|
|
1457
|
+
responsiveOrientationWhenOrientationLocked
|
|
1458
|
+
onResponsiveOrientationChanged={(event) => {
|
|
1459
|
+
setPhysicalOrientation(event.orientation);
|
|
1460
|
+
}}
|
|
1461
|
+
>
|
|
1322
1462
|
{/* Overlay */}
|
|
1323
1463
|
<View style={styles.overlay}>
|
|
1324
1464
|
{overlay?.title && (
|
|
@@ -1522,7 +1662,8 @@ export function VerifyAIScanner({
|
|
|
1522
1662
|
</TouchableOpacity>
|
|
1523
1663
|
)}
|
|
1524
1664
|
</View>
|
|
1525
|
-
|
|
1665
|
+
</CameraView>
|
|
1666
|
+
)}
|
|
1526
1667
|
</View>
|
|
1527
1668
|
);
|
|
1528
1669
|
}
|
|
@@ -5,17 +5,24 @@
|
|
|
5
5
|
|
|
6
6
|
import type { FeatureVector, Detection, ImageQuality } from './types';
|
|
7
7
|
|
|
8
|
-
const SCHEMA_VERSION = '
|
|
8
|
+
const SCHEMA_VERSION = '2.0.0';
|
|
9
9
|
|
|
10
10
|
/** Vehicle class IDs (must match ontology.ts) */
|
|
11
|
-
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5]);
|
|
11
|
+
const VEHICLE_CLASS_IDS = new Set([1, 2, 3, 4, 5, 6]);
|
|
12
12
|
/** Surface class IDs */
|
|
13
13
|
const SURFACE_CLASS_IDS = new Set([60, 61, 62, 63, 64, 65, 66]);
|
|
14
14
|
|
|
15
15
|
/** Ontology class name mapping (must match ontology.ts) */
|
|
16
16
|
export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
|
|
17
|
-
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle',
|
|
17
|
+
1: 'scooter', 2: 'bicycle', 3: 'ebike', 4: 'moped', 5: 'motorcycle', 6: 'car',
|
|
18
18
|
20: 'kickstand', 21: 'handlebar', 22: 'wheel', 23: 'saddle', 24: 'basket',
|
|
19
|
+
// Car panels (ontology v2 — damage intelligence)
|
|
20
|
+
25: 'car_hood', 26: 'car_roof', 27: 'car_trunk',
|
|
21
|
+
28: 'car_front_bumper', 29: 'car_rear_bumper',
|
|
22
|
+
30: 'car_door_fl', 31: 'car_door_fr', 32: 'car_door_rl', 33: 'car_door_rr',
|
|
23
|
+
34: 'car_fender_fl', 35: 'car_fender_fr',
|
|
24
|
+
36: 'car_quarter_rl', 37: 'car_quarter_rr',
|
|
25
|
+
38: 'car_rocker', 39: 'car_mirror',
|
|
19
26
|
40: 'bike_rack', 41: 'curb', 42: 'parking_bay', 43: 'green_bay',
|
|
20
27
|
44: 'tactile_paving', 45: 'bollard', 46: 'fence', 47: 'pole', 48: 'yellow_lines',
|
|
21
28
|
60: 'sidewalk', 61: 'road', 62: 'grass', 63: 'crosswalk',
|
|
@@ -26,8 +33,9 @@ export const ONTOLOGY_CLASS_NAMES: Record<number, string> = {
|
|
|
26
33
|
|
|
27
34
|
/** Detection model class index -> ontology class ID mapping. */
|
|
28
35
|
export const MODEL_OUTPUT_CLASS_IDS = [
|
|
29
|
-
1, 2, 3, 4, 5,
|
|
36
|
+
1, 2, 3, 4, 5, 6,
|
|
30
37
|
20, 21, 22, 23, 24,
|
|
38
|
+
25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
|
31
39
|
40, 41, 42, 43, 44, 45, 46, 47, 48,
|
|
32
40
|
60, 61, 62, 63, 64, 65, 66,
|
|
33
41
|
80, 81, 82, 83, 84, 85, 86, 87,
|
package/src/ml/modelManager.ts
CHANGED
|
@@ -62,8 +62,25 @@ export class ModelManager {
|
|
|
62
62
|
return this._currentBundle;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Check for bundle updates from the server.
|
|
67
|
+
*
|
|
68
|
+
* `opts` lets callers narrow which bundle they receive when the server
|
|
69
|
+
* has per-asset / per-region / per-app-version targeted bundles
|
|
70
|
+
* configured (see `app/api/v1/models/latest`). All fields are optional;
|
|
71
|
+
* omitting them keeps the legacy behaviour of receiving the wildcard
|
|
72
|
+
* bundle for the policy.
|
|
73
|
+
*/
|
|
74
|
+
async checkForUpdates(
|
|
75
|
+
policyId: string,
|
|
76
|
+
platform: 'ios' | 'android',
|
|
77
|
+
opts?: {
|
|
78
|
+
assetType?: string;
|
|
79
|
+
region?: string;
|
|
80
|
+
appVersion?: string;
|
|
81
|
+
deviceId?: string;
|
|
82
|
+
},
|
|
83
|
+
): Promise<BundleManifest | null> {
|
|
67
84
|
try {
|
|
68
85
|
const headers: Record<string, string> = {
|
|
69
86
|
'X-API-Key': this.apiKey,
|
|
@@ -74,7 +91,13 @@ export class ModelManager {
|
|
|
74
91
|
headers['If-None-Match'] = this.cachedETag;
|
|
75
92
|
}
|
|
76
93
|
|
|
77
|
-
const
|
|
94
|
+
const qs = new URLSearchParams({ policy: policyId, platform });
|
|
95
|
+
if (opts?.assetType) qs.set('asset_type', opts.assetType);
|
|
96
|
+
if (opts?.region) qs.set('region', opts.region);
|
|
97
|
+
if (opts?.appVersion) qs.set('app_version', opts.appVersion);
|
|
98
|
+
if (opts?.deviceId) qs.set('device_id', opts.deviceId);
|
|
99
|
+
|
|
100
|
+
const url = `${this.baseUrl}/models/latest?${qs.toString()}`;
|
|
78
101
|
const response = await fetch(url, { headers });
|
|
79
102
|
|
|
80
103
|
if (response.status === 304) {
|
package/src/ml/policyEngine.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
|
+
DamageFinding,
|
|
11
|
+
DamageSeverity,
|
|
10
12
|
FeatureVector,
|
|
11
13
|
PolicyAST,
|
|
12
14
|
PolicyResult,
|
|
@@ -17,6 +19,35 @@ import type {
|
|
|
17
19
|
ComparisonOperator,
|
|
18
20
|
} from './types';
|
|
19
21
|
|
|
22
|
+
// Severity ordering for `severity_gte`. Must match server TS + Dart impls.
|
|
23
|
+
const SEVERITY_ORDER: DamageSeverity[] = ['none', 'light', 'medium', 'severe'];
|
|
24
|
+
function severityRank(s: unknown): number {
|
|
25
|
+
if (typeof s !== 'string') return -1;
|
|
26
|
+
return SEVERITY_ORDER.indexOf(s as DamageSeverity);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface AggregateCountValue {
|
|
30
|
+
op: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte';
|
|
31
|
+
value: number;
|
|
32
|
+
where?: {
|
|
33
|
+
panel?: string;
|
|
34
|
+
damage_type?: string;
|
|
35
|
+
severity_gte?: DamageSeverity;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareNumber(a: number, op: AggregateCountValue['op'], b: number): boolean {
|
|
40
|
+
switch (op) {
|
|
41
|
+
case 'eq': return a === b;
|
|
42
|
+
case 'neq': return a !== b;
|
|
43
|
+
case 'gt': return a > b;
|
|
44
|
+
case 'gte': return a >= b;
|
|
45
|
+
case 'lt': return a < b;
|
|
46
|
+
case 'lte': return a <= b;
|
|
47
|
+
default: return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
// ─── Field Resolution ───
|
|
21
52
|
|
|
22
53
|
function resolveField(features: FeatureVector, field: string): unknown {
|
|
@@ -77,10 +108,65 @@ function applyOperator(fieldValue: unknown, operator: ComparisonOperator, value:
|
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
function evaluateCondition(features: FeatureVector, condition: Condition): boolean {
|
|
111
|
+
if (
|
|
112
|
+
condition.operator === 'severity_gte' ||
|
|
113
|
+
condition.operator === 'panel_has_damage' ||
|
|
114
|
+
condition.operator === 'aggregate_count'
|
|
115
|
+
) {
|
|
116
|
+
return applyDamageOperator(features, condition);
|
|
117
|
+
}
|
|
80
118
|
const fieldValue = resolveField(features, condition.field);
|
|
81
119
|
return applyOperator(fieldValue, condition.operator, condition.value);
|
|
82
120
|
}
|
|
83
121
|
|
|
122
|
+
function applyDamageOperator(features: FeatureVector, condition: Condition): boolean {
|
|
123
|
+
const findings: DamageFinding[] = Array.isArray(features.damage_findings)
|
|
124
|
+
? features.damage_findings
|
|
125
|
+
: [];
|
|
126
|
+
|
|
127
|
+
switch (condition.operator) {
|
|
128
|
+
case 'severity_gte': {
|
|
129
|
+
const minRank = severityRank(condition.value);
|
|
130
|
+
if (minRank < 0) return false;
|
|
131
|
+
if (condition.field && condition.field !== 'damage_findings') {
|
|
132
|
+
const f = resolveField(features, condition.field);
|
|
133
|
+
return severityRank(f) >= minRank;
|
|
134
|
+
}
|
|
135
|
+
return findings.some((finding) => severityRank(finding.severity) >= minRank);
|
|
136
|
+
}
|
|
137
|
+
case 'panel_has_damage': {
|
|
138
|
+
const panelName =
|
|
139
|
+
typeof condition.value === 'string'
|
|
140
|
+
? condition.value
|
|
141
|
+
: typeof condition.field === 'string' &&
|
|
142
|
+
condition.field !== 'damage_findings' &&
|
|
143
|
+
condition.field !== ''
|
|
144
|
+
? condition.field
|
|
145
|
+
: null;
|
|
146
|
+
if (!panelName) return false;
|
|
147
|
+
return findings.some(
|
|
148
|
+
(finding) => finding.panel === panelName && finding.severity !== 'none',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
case 'aggregate_count': {
|
|
152
|
+
const v = condition.value as AggregateCountValue | null | undefined;
|
|
153
|
+
if (!v || typeof v !== 'object' || typeof v.value !== 'number' || !v.op) return false;
|
|
154
|
+
const where = v.where || {};
|
|
155
|
+
const minSeverityRank =
|
|
156
|
+
where.severity_gte !== undefined ? severityRank(where.severity_gte) : -1;
|
|
157
|
+
const count = findings.filter((finding) => {
|
|
158
|
+
if (where.panel && finding.panel !== where.panel) return false;
|
|
159
|
+
if (where.damage_type && finding.damage_type !== where.damage_type) return false;
|
|
160
|
+
if (minSeverityRank >= 0 && severityRank(finding.severity) < minSeverityRank) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}).length;
|
|
163
|
+
return compareNumber(count, v.op, v.value);
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
84
170
|
function evaluateRule(features: FeatureVector, rule: Rule): RuleResult {
|
|
85
171
|
const passed = rule.conditions.every((c) => evaluateCondition(features, c));
|
|
86
172
|
return {
|
package/src/ml/types.ts
CHANGED
|
@@ -39,6 +39,33 @@ export interface ImageQuality {
|
|
|
39
39
|
has_vehicle: boolean;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// ─── Damage Findings (schema v2) ───
|
|
43
|
+
|
|
44
|
+
export type DamageSeverity = 'none' | 'light' | 'medium' | 'severe';
|
|
45
|
+
export type DamageType =
|
|
46
|
+
| 'scratch'
|
|
47
|
+
| 'dent'
|
|
48
|
+
| 'paint_chip'
|
|
49
|
+
| 'crack'
|
|
50
|
+
| 'broken'
|
|
51
|
+
| 'missing'
|
|
52
|
+
| 'rust'
|
|
53
|
+
| 'tear'
|
|
54
|
+
| 'stain'
|
|
55
|
+
| 'glass_damage'
|
|
56
|
+
| 'other';
|
|
57
|
+
|
|
58
|
+
export interface DamageFinding {
|
|
59
|
+
finding_id: string;
|
|
60
|
+
panel: string;
|
|
61
|
+
damage_type: DamageType;
|
|
62
|
+
severity: DamageSeverity;
|
|
63
|
+
severity_score: number;
|
|
64
|
+
bbox: [number, number, number, number];
|
|
65
|
+
area_pct: number;
|
|
66
|
+
confidence: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
export interface FeatureVector {
|
|
43
70
|
schema_version: string;
|
|
44
71
|
detections: Detection[];
|
|
@@ -46,6 +73,10 @@ export interface FeatureVector {
|
|
|
46
73
|
image_quality: ImageQuality;
|
|
47
74
|
vehicle_on_surface: string | null;
|
|
48
75
|
vehicle_near: string[];
|
|
76
|
+
/** Damage observations (schema v2). Defaults to [] when damage mode disabled. */
|
|
77
|
+
damage_findings?: DamageFinding[];
|
|
78
|
+
/** Panels visible in this frame (schema v2). */
|
|
79
|
+
panel_inventory?: string[];
|
|
49
80
|
}
|
|
50
81
|
|
|
51
82
|
// ─── Policy AST Types ───
|
|
@@ -54,7 +85,9 @@ export type ComparisonOperator =
|
|
|
54
85
|
| 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
|
|
55
86
|
| 'contains' | 'not_contains'
|
|
56
87
|
| 'exists' | 'not_exists'
|
|
57
|
-
| 'in' | 'not_in' | 'overlaps'
|
|
88
|
+
| 'in' | 'not_in' | 'overlaps'
|
|
89
|
+
// AST v1.1 — damage operators
|
|
90
|
+
| 'severity_gte' | 'panel_has_damage' | 'aggregate_count';
|
|
58
91
|
|
|
59
92
|
export interface Condition {
|
|
60
93
|
field: string;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.
|
|
1
|
+
export const SDK_VERSION = '2.5.1';
|