Skip to content

Commit 7ec5627

Browse files
authored
[RNE Rewrite] feat: add object detection task pipeline (#1284)
## Description Adds object detection pipeline implementation and corresponding example screen in computer-vision app. ### 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 - [ ] Android ### Testing instructions - [ ] Build the computer-vision example app on both Android and iOS. - [ ] Test the newly added object detection screen. - [ ] Check the updated HuggingFace repos: - https://huggingface.co/software-mansion/react-native-executorch-yolo26 ### Screenshots <!-- Add screenshots here, if applicable --> ### Related issues Closes #1239 ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes <!-- Include any additional information, assumptions, or context that reviewers might need to understand this PR. -->
1 parent bf00556 commit 7ec5627

9 files changed

Lines changed: 878 additions & 0 deletions

File tree

.cspell-wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,4 @@ pushd
244244
popd
245245
yolov
246246
YOLOV
247+
XLARGE

apps/computer-vision/app/_layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ export default function Layout() {
2727
title: 'Classification',
2828
}}
2929
/>
30+
<Drawer.Screen
31+
name="detection/index"
32+
options={{
33+
drawerLabel: 'Object Detection',
34+
title: 'Object Detection',
35+
}}
36+
/>
3037
<Drawer.Screen
3138
name="styleTransfer/index"
3239
options={{
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import React, { useState } from 'react';
2+
import { View, Text, StyleSheet, ScrollView, Dimensions, Platform } from 'react-native';
3+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
4+
import { commonStyles, theme } from '../../theme';
5+
import { useImage } from '@shopify/react-native-skia';
6+
import { useObjectDetector, models, type ObjectDetection } from 'react-native-executorch';
7+
import ScreenWrapper from '../../components/ScreenWrapper';
8+
import { getImage } from '../../utils';
9+
import { ModelPicker, type ModelOption } from '../../components/ModelPicker';
10+
import { ImageViewport } from '../../components/ImageViewport';
11+
import { ModelStatus } from '../../components/ModelStatus';
12+
import { LatencyIndicator } from '../../components/LatencyIndicator';
13+
import { Button } from '../../components/Button';
14+
import { BoundingBox } from '../../components/BoundingBox';
15+
16+
const MODEL_OPTIONS: ModelOption[] = [
17+
{
18+
label: 'SSDLite 320 MobileNet V3 Large (XNNPACK FP32)',
19+
value: models.objectDetection.SSDLITE320_MOBILENET_V3_LARGE,
20+
},
21+
{
22+
label: 'RF-DETR Nano (XNNPACK FP32)',
23+
value: models.objectDetection.RFDETR_NANO,
24+
},
25+
{
26+
label: 'RF-DETR Nano (CoreML INT8)',
27+
value: models.objectDetection.RFDETR_NANO.COREML_INT8,
28+
disabled: Platform.OS !== 'ios',
29+
},
30+
{
31+
label: 'YOLO26 Nano 384 (XNNPACK FP32)',
32+
value: models.objectDetection.YOLO26.NANO.SIZE_384.XNNPACK_FP32,
33+
},
34+
{
35+
label: 'YOLO26 Nano 640 (XNNPACK FP32)',
36+
value: models.objectDetection.YOLO26.NANO.SIZE_640.XNNPACK_FP32,
37+
},
38+
{
39+
label: 'YOLO26 Small 640 (XNNPACK FP32)',
40+
value: models.objectDetection.YOLO26.SMALL.SIZE_640.XNNPACK_FP32,
41+
},
42+
{
43+
label: 'YOLO26 Medium 640 (XNNPACK FP32)',
44+
value: models.objectDetection.YOLO26.MEDIUM.SIZE_640.XNNPACK_FP32,
45+
},
46+
{
47+
label: 'YOLO26 Large 640 (XNNPACK FP32)',
48+
value: models.objectDetection.YOLO26.LARGE.SIZE_640.XNNPACK_FP32,
49+
},
50+
{
51+
label: 'YOLO26 X-Large 640 (XNNPACK FP32)',
52+
value: models.objectDetection.YOLO26.XLARGE.SIZE_640.XNNPACK_FP32,
53+
},
54+
];
55+
56+
const VIEW_WIDTH = Dimensions.get('window').width - 32;
57+
const VIEW_HEIGHT = Math.round((VIEW_WIDTH * 16) / 9);
58+
59+
function DetectionContent() {
60+
const insets = useSafeAreaInsets();
61+
const [selectedModel, setSelectedModel] = useState<any>(MODEL_OPTIONS[0].value);
62+
const [imageUri, setImageUri] = useState<string | null>(null);
63+
const [isProcessing, setIsProcessing] = useState(false);
64+
const [results, setResults] = useState<ObjectDetection<'xyxy', string>[]>([]);
65+
const [latency, setLatency] = useState<number | null>(null);
66+
const [error, setError] = useState<string | null>(null);
67+
68+
const skiaImage = useImage(imageUri, (err) => setError(err.message || String(err)));
69+
70+
const {
71+
isReady,
72+
downloadProgress,
73+
error: loadError,
74+
detectObjects,
75+
detectObjectsWorklet,
76+
} = useObjectDetector(selectedModel);
77+
78+
const handlePickImage = async (useCamera: boolean) => {
79+
setError(null);
80+
try {
81+
const uri = await getImage(useCamera);
82+
if (uri) {
83+
setImageUri(uri);
84+
setResults([]);
85+
setLatency(null);
86+
}
87+
} catch (e: any) {
88+
setError(e.message || String(e));
89+
}
90+
};
91+
92+
const runDetection = async (sync: boolean) => {
93+
if (!skiaImage || !detectObjects || !detectObjectsWorklet) return;
94+
if (!sync) setIsProcessing(true);
95+
setError(null);
96+
try {
97+
const pixels = skiaImage.readPixels();
98+
if (!pixels) {
99+
throw new Error('Failed to read pixels from image');
100+
}
101+
if (!(pixels instanceof Uint8Array)) {
102+
throw new Error('Expected Uint8Array from readPixels');
103+
}
104+
const buffer = {
105+
data: pixels,
106+
width: skiaImage.width(),
107+
height: skiaImage.height(),
108+
format: 'rgba' as const,
109+
layout: 'hwc' as const,
110+
};
111+
const start = Date.now();
112+
const output = (
113+
sync ? detectObjectsWorklet(buffer) : await detectObjects(buffer)
114+
) as ObjectDetection<'xyxy', string>[];
115+
116+
setLatency(Date.now() - start);
117+
setResults(output);
118+
} catch (e: any) {
119+
setError(e.message || String(e));
120+
} finally {
121+
if (!sync) setIsProcessing(false);
122+
}
123+
};
124+
125+
let scaleX = 1;
126+
let scaleY = 1;
127+
let offsetX = 0;
128+
let offsetY = 0;
129+
130+
if (skiaImage) {
131+
const imgW = skiaImage.width();
132+
const imgH = skiaImage.height();
133+
const scale = Math.min(VIEW_WIDTH / imgW, VIEW_HEIGHT / imgH);
134+
const displayedW = imgW * scale;
135+
const displayedH = imgH * scale;
136+
offsetX = (VIEW_WIDTH - displayedW) / 2;
137+
offsetY = (VIEW_HEIGHT - displayedH) / 2;
138+
scaleX = scale;
139+
scaleY = scale;
140+
}
141+
142+
const activeError = loadError ? String(loadError) : error;
143+
144+
return (
145+
<ScrollView
146+
style={commonStyles.container}
147+
contentContainerStyle={[
148+
commonStyles.contentContainer,
149+
{ paddingBottom: insets.bottom + theme.spacing.large },
150+
]}
151+
>
152+
<Text style={commonStyles.description}>
153+
Upload or capture an image to run object detection.
154+
</Text>
155+
156+
<ModelPicker
157+
label="Model"
158+
options={MODEL_OPTIONS}
159+
selectedValue={selectedModel}
160+
onValueChange={(model) => {
161+
setSelectedModel(model);
162+
setResults([]);
163+
setLatency(null);
164+
setError(null);
165+
}}
166+
/>
167+
168+
<ModelStatus
169+
isReady={isReady}
170+
downloadProgress={downloadProgress}
171+
error={activeError}
172+
modelTypeLabel="detector model"
173+
/>
174+
175+
<ImageViewport skiaImage={skiaImage} onPressPlaceholder={() => handlePickImage(false)}>
176+
{skiaImage && results.length > 0 && (
177+
<View style={styles.overlayContainer} pointerEvents="none">
178+
{results.map((det, index: number) => {
179+
const strokeColor = '#00ff00';
180+
const bgColor = 'rgba(0, 255, 0, 0.15)';
181+
182+
const left = offsetX + det.box.xmin * scaleX;
183+
const top = offsetY + det.box.ymin * scaleY;
184+
const width = (det.box.xmax - det.box.xmin) * scaleX;
185+
const height = (det.box.ymax - det.box.ymin) * scaleY;
186+
187+
return (
188+
<BoundingBox
189+
key={index}
190+
left={left}
191+
top={top}
192+
width={width}
193+
height={height}
194+
borderColor={strokeColor}
195+
backgroundColor={bgColor}
196+
label={`${det.label} ${Math.round(det.confidence * 100)}%`}
197+
labelTextColor="#fff"
198+
/>
199+
);
200+
})}
201+
</View>
202+
)}
203+
</ImageViewport>
204+
205+
<View style={commonStyles.buttonRow}>
206+
<Button title="Gallery" onPress={() => handlePickImage(false)} variant="secondary" />
207+
<Button title="Camera" onPress={() => handlePickImage(true)} variant="secondary" />
208+
</View>
209+
210+
<View style={commonStyles.buttonRow}>
211+
<Button
212+
title="Run Async"
213+
onPress={() => runDetection(false)}
214+
disabled={!skiaImage || !isReady || isProcessing}
215+
loading={isProcessing}
216+
/>
217+
<Button
218+
title="Run Sync"
219+
onPress={() => runDetection(true)}
220+
disabled={!skiaImage || !isReady || isProcessing}
221+
variant="accent"
222+
/>
223+
</View>
224+
225+
<LatencyIndicator latency={latency} />
226+
</ScrollView>
227+
);
228+
}
229+
230+
export default function DetectionScreen() {
231+
return (
232+
<ScreenWrapper>
233+
<DetectionContent />
234+
</ScreenWrapper>
235+
);
236+
}
237+
238+
const styles = StyleSheet.create({
239+
overlayContainer: {
240+
position: 'absolute',
241+
left: 0,
242+
right: 0,
243+
top: 0,
244+
bottom: 0,
245+
overflow: 'hidden',
246+
},
247+
});

apps/computer-vision/app/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export default function Home() {
1414
<TouchableOpacity style={styles.button} onPress={() => router.navigate('classification/')}>
1515
<Text style={styles.buttonText}>Classification</Text>
1616
</TouchableOpacity>
17+
<TouchableOpacity style={styles.button} onPress={() => router.navigate('detection/')}>
18+
<Text style={styles.buttonText}>Object Detection</Text>
19+
</TouchableOpacity>
1720
<TouchableOpacity style={styles.button} onPress={() => router.navigate('styleTransfer/')}>
1821
<Text style={styles.buttonText}>Style Transfer</Text>
1922
</TouchableOpacity>

0 commit comments

Comments
 (0)