Skip to content

Commit 62ac55d

Browse files
feat: add integration with vision camera v5 (#810)
## Description This PR adds real-time camera frame processing capabilities to React Native ExecuTorch by integrating with VisionCamera v5. It introduces a new VisionModule base class and extends ObjectDetectionModule to support three input methods: image URLs/paths, raw pixel data (PixelData), and live camera frames. This PR includes: - a dedicated screen for testing object detection + vision camera - new tests for added methods ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [ ] Bug fix (change which fixes an issue) - [x] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [x] Android ### Testing instructions Run the computer vision example app and test object detection and object detection (live) ### Screenshots <!-- Add screenshots here, if applicable --> ### Related issues <!-- Link related issues here using #issue-number --> ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes --------- Co-authored-by: Mateusz Słuszniak <mateusz.sluszniak@swmansion.com> Co-authored-by: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com>
1 parent 00fd99c commit 62ac55d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+3593
-2291
lines changed

.cspell-wordlist.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,7 @@ basemodule
118118
IMAGENET
119119
lraspp
120120
LRASPP
121+
worklet
122+
worklets
123+
BGRA
124+
RGBA

apps/computer-vision/app.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,23 @@
2525
"foregroundImage": "./assets/icons/adaptive-icon.png",
2626
"backgroundColor": "#ffffff"
2727
},
28-
"package": "com.anonymous.computervision"
28+
"package": "com.anonymous.computervision",
29+
"permissions": ["android.permission.CAMERA"]
2930
},
3031
"web": {
3132
"favicon": "./assets/icons/favicon.png"
3233
},
33-
"plugins": ["expo-font", "expo-router"]
34+
"plugins": [
35+
"expo-font",
36+
"expo-router",
37+
[
38+
"expo-build-properties",
39+
{
40+
"android": {
41+
"minSdkVersion": 26
42+
}
43+
}
44+
]
45+
]
3446
}
3547
}

apps/computer-vision/app/_layout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ export default function _layout() {
8383
headerTitleStyle: { color: ColorPalette.primary },
8484
}}
8585
/>
86+
<Drawer.Screen
87+
name="object_detection_live/index"
88+
options={{
89+
drawerLabel: 'Object Detection (Live)',
90+
title: 'Object Detection (Live)',
91+
headerTitleStyle: { color: ColorPalette.primary },
92+
}}
93+
/>
8694
<Drawer.Screen
8795
name="ocr/index"
8896
options={{

apps/computer-vision/app/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export default function Home() {
2929
>
3030
<Text style={styles.buttonText}>Object Detection</Text>
3131
</TouchableOpacity>
32+
<TouchableOpacity
33+
style={styles.button}
34+
onPress={() => router.navigate('object_detection_live/')}
35+
>
36+
<Text style={styles.buttonText}>Object Detection Live</Text>
37+
</TouchableOpacity>
3238
<TouchableOpacity
3339
style={styles.button}
3440
onPress={() => router.navigate('ocr/')}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import React, {
2+
useCallback,
3+
useContext,
4+
useEffect,
5+
useMemo,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
import {
10+
StatusBar,
11+
StyleSheet,
12+
Text,
13+
TouchableOpacity,
14+
View,
15+
} from 'react-native';
16+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
17+
18+
import {
19+
Camera,
20+
getCameraFormat,
21+
Templates,
22+
useCameraDevices,
23+
useCameraPermission,
24+
useFrameOutput,
25+
} from 'react-native-vision-camera';
26+
import { scheduleOnRN } from 'react-native-worklets';
27+
import {
28+
Detection,
29+
SSDLITE_320_MOBILENET_V3_LARGE,
30+
useObjectDetection,
31+
} from 'react-native-executorch';
32+
import { GeneratingContext } from '../../context';
33+
import Spinner from '../../components/Spinner';
34+
import ColorPalette from '../../colors';
35+
36+
export default function ObjectDetectionLiveScreen() {
37+
const insets = useSafeAreaInsets();
38+
39+
const model = useObjectDetection({ model: SSDLITE_320_MOBILENET_V3_LARGE });
40+
const { setGlobalGenerating } = useContext(GeneratingContext);
41+
42+
useEffect(() => {
43+
setGlobalGenerating(model.isGenerating);
44+
}, [model.isGenerating, setGlobalGenerating]);
45+
const [detectionCount, setDetectionCount] = useState(0);
46+
const [fps, setFps] = useState(0);
47+
const lastFrameTimeRef = useRef(Date.now());
48+
49+
const cameraPermission = useCameraPermission();
50+
const devices = useCameraDevices();
51+
const device = devices.find((d) => d.position === 'back') ?? devices[0];
52+
53+
const format = useMemo(() => {
54+
if (device == null) return undefined;
55+
try {
56+
return getCameraFormat(device, Templates.FrameProcessing);
57+
} catch {
58+
return undefined;
59+
}
60+
}, [device]);
61+
62+
const updateStats = useCallback((results: Detection[]) => {
63+
setDetectionCount(results.length);
64+
const now = Date.now();
65+
const timeDiff = now - lastFrameTimeRef.current;
66+
if (timeDiff > 0) {
67+
setFps(Math.round(1000 / timeDiff));
68+
}
69+
lastFrameTimeRef.current = now;
70+
}, []);
71+
72+
const frameOutput = useFrameOutput({
73+
pixelFormat: 'rgb',
74+
dropFramesWhileBusy: true,
75+
onFrame(frame) {
76+
'worklet';
77+
if (!model.runOnFrame) {
78+
frame.dispose();
79+
return;
80+
}
81+
try {
82+
const result = model.runOnFrame(frame, 0.5);
83+
if (result) {
84+
scheduleOnRN(updateStats, result);
85+
}
86+
} catch {
87+
// ignore frame errors
88+
} finally {
89+
frame.dispose();
90+
}
91+
},
92+
});
93+
94+
if (!model.isReady) {
95+
return (
96+
<Spinner
97+
visible={!model.isReady}
98+
textContent={`Loading the model ${(model.downloadProgress * 100).toFixed(0)} %`}
99+
/>
100+
);
101+
}
102+
103+
if (!cameraPermission.hasPermission) {
104+
return (
105+
<View style={styles.centered}>
106+
<Text style={styles.message}>Camera access needed</Text>
107+
<TouchableOpacity
108+
onPress={() => cameraPermission.requestPermission()}
109+
style={styles.button}
110+
>
111+
<Text style={styles.buttonText}>Grant Permission</Text>
112+
</TouchableOpacity>
113+
</View>
114+
);
115+
}
116+
117+
if (device == null) {
118+
return (
119+
<View style={styles.centered}>
120+
<Text style={styles.message}>No camera device found</Text>
121+
</View>
122+
);
123+
}
124+
125+
return (
126+
<View style={styles.container}>
127+
<StatusBar barStyle="light-content" translucent />
128+
129+
<Camera
130+
style={StyleSheet.absoluteFill}
131+
device={device}
132+
outputs={[frameOutput]}
133+
isActive={true}
134+
format={format}
135+
/>
136+
137+
<View
138+
style={[styles.bottomBarWrapper, { paddingBottom: insets.bottom + 12 }]}
139+
pointerEvents="none"
140+
>
141+
<View style={styles.bottomBar}>
142+
<View style={styles.statItem}>
143+
<Text style={styles.statValue}>{detectionCount}</Text>
144+
<Text style={styles.statLabel}>objects</Text>
145+
</View>
146+
<View style={styles.statDivider} />
147+
<View style={styles.statItem}>
148+
<Text style={styles.statValue}>{fps}</Text>
149+
<Text style={styles.statLabel}>fps</Text>
150+
</View>
151+
</View>
152+
</View>
153+
</View>
154+
);
155+
}
156+
157+
const styles = StyleSheet.create({
158+
container: {
159+
flex: 1,
160+
backgroundColor: 'black',
161+
},
162+
centered: {
163+
flex: 1,
164+
backgroundColor: 'black',
165+
justifyContent: 'center',
166+
alignItems: 'center',
167+
gap: 16,
168+
},
169+
message: {
170+
color: 'white',
171+
fontSize: 18,
172+
},
173+
button: {
174+
paddingHorizontal: 24,
175+
paddingVertical: 12,
176+
backgroundColor: ColorPalette.primary,
177+
borderRadius: 24,
178+
},
179+
buttonText: {
180+
color: 'white',
181+
fontSize: 15,
182+
fontWeight: '600',
183+
letterSpacing: 0.3,
184+
},
185+
bottomBarWrapper: {
186+
position: 'absolute',
187+
bottom: 0,
188+
left: 0,
189+
right: 0,
190+
alignItems: 'center',
191+
},
192+
bottomBar: {
193+
flexDirection: 'row',
194+
alignItems: 'center',
195+
backgroundColor: 'rgba(0, 0, 0, 0.55)',
196+
borderRadius: 24,
197+
paddingHorizontal: 28,
198+
paddingVertical: 10,
199+
gap: 24,
200+
},
201+
statItem: {
202+
alignItems: 'center',
203+
},
204+
statValue: {
205+
color: 'white',
206+
fontSize: 22,
207+
fontWeight: '700',
208+
letterSpacing: -0.5,
209+
},
210+
statLabel: {
211+
color: 'rgba(255,255,255,0.55)',
212+
fontSize: 11,
213+
fontWeight: '500',
214+
textTransform: 'uppercase',
215+
letterSpacing: 0.8,
216+
},
217+
statDivider: {
218+
width: 1,
219+
height: 32,
220+
backgroundColor: 'rgba(255,255,255,0.2)',
221+
},
222+
});

apps/computer-vision/metro.config.js

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
// Learn more https://docs.expo.io/guides/customizing-metro
22
const { getDefaultConfig } = require('expo/metro-config');
3-
const path = require('path');
4-
5-
const monorepoRoot = path.resolve(__dirname, '../..');
63

74
/** @type {import('expo/metro-config').MetroConfig} */
85
const config = getDefaultConfig(__dirname);
96

107
const { transformer, resolver } = config;
118

12-
config.watchFolders = [monorepoRoot];
13-
149
config.transformer = {
1510
...transformer,
1611
babelTransformerPath: require.resolve('react-native-svg-transformer/expo'),
@@ -19,23 +14,6 @@ config.resolver = {
1914
...resolver,
2015
assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'),
2116
sourceExts: [...resolver.sourceExts, 'svg'],
22-
nodeModulesPaths: [
23-
path.resolve(__dirname, 'node_modules'),
24-
path.resolve(monorepoRoot, 'node_modules'),
25-
],
26-
// Always resolve react and react-native from the monorepo root so that
27-
// workspace packages with their own nested node_modules (e.g.
28-
// packages/react-native-executorch/node_modules/react) don't create a
29-
// second React instance and trigger "Invalid hook call".
30-
resolveRequest: (context, moduleName, platform) => {
31-
if (moduleName === 'react' || moduleName === 'react-native') {
32-
return {
33-
filePath: require.resolve(moduleName, { paths: [monorepoRoot] }),
34-
type: 'sourceFile',
35-
};
36-
}
37-
return context.resolveRequest(context, moduleName, platform);
38-
},
3917
};
4018

4119
config.resolver.assetExts.push('pte');

apps/computer-vision/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@react-navigation/native": "^7.1.28",
1818
"@shopify/react-native-skia": "2.4.21",
1919
"expo": "^54.0.27",
20+
"expo-build-properties": "~1.0.10",
2021
"expo-constants": "~18.0.11",
2122
"expo-font": "~14.0.10",
2223
"expo-linking": "~8.0.10",
@@ -30,17 +31,20 @@
3031
"react-native-gesture-handler": "~2.28.0",
3132
"react-native-image-picker": "^7.2.2",
3233
"react-native-loading-spinner-overlay": "^3.0.1",
34+
"react-native-nitro-image": "^0.12.0",
35+
"react-native-nitro-modules": "^0.33.9",
3336
"react-native-reanimated": "~4.2.2",
3437
"react-native-safe-area-context": "~5.6.0",
3538
"react-native-screens": "~4.16.0",
3639
"react-native-svg": "15.15.3",
3740
"react-native-svg-transformer": "^1.5.3",
41+
"react-native-vision-camera": "5.0.0-beta.2",
3842
"react-native-worklets": "0.7.4"
3943
},
4044
"devDependencies": {
4145
"@babel/core": "^7.29.0",
4246
"@types/pngjs": "^6.0.5",
43-
"@types/react": "~19.1.10"
47+
"@types/react": "~19.2.0"
4448
},
4549
"private": true
4650
}

apps/llm/metro.config.js

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@ const { getDefaultConfig } = require('expo/metro-config');
22
const {
33
wrapWithAudioAPIMetroConfig,
44
} = require('react-native-audio-api/metro-config');
5-
const path = require('path');
65

7-
const monorepoRoot = path.resolve(__dirname, '../..');
86
const config = getDefaultConfig(__dirname);
97

108
const { transformer, resolver } = config;
119

12-
config.watchFolders = [monorepoRoot];
13-
1410
config.transformer = {
1511
...transformer,
1612
babelTransformerPath: require.resolve('react-native-svg-transformer/expo'),
@@ -19,23 +15,6 @@ config.resolver = {
1915
...resolver,
2016
assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'),
2117
sourceExts: [...resolver.sourceExts, 'svg'],
22-
nodeModulesPaths: [
23-
path.resolve(__dirname, 'node_modules'),
24-
path.resolve(monorepoRoot, 'node_modules'),
25-
],
26-
// Always resolve react and react-native from the monorepo root so that
27-
// workspace packages with their own nested node_modules (e.g.
28-
// packages/react-native-executorch/node_modules/react) don't create a
29-
// second React instance and trigger "Invalid hook call".
30-
resolveRequest: (context, moduleName, platform) => {
31-
if (moduleName === 'react' || moduleName === 'react-native') {
32-
return {
33-
filePath: require.resolve(moduleName, { paths: [monorepoRoot] }),
34-
type: 'sourceFile',
35-
};
36-
}
37-
return context.resolveRequest(context, moduleName, platform);
38-
},
3918
};
4019

4120
config.resolver.assetExts.push('pte');

0 commit comments

Comments
 (0)