Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,4 @@ imgproc
c10
probas
Probas
Skia
2 changes: 1 addition & 1 deletion apps/computer-vision/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Drawer } from 'expo-router/drawer';
import ColorPalette from '../colors';
import { ColorPalette } from '../theme';
import React from 'react';

export default function Layout() {
Expand Down
316 changes: 95 additions & 221 deletions apps/computer-vision/app/classification/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Image,
TouchableOpacity,
ScrollView,
ActivityIndicator,
Platform,
} from 'react-native';
import { Skia } from '@shopify/react-native-skia';
import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native';
import { commonStyles, ColorPalette } from '../../theme';
import { type SkImage as SkiaImageType } from '@shopify/react-native-skia';
import { useClassifier, models } from 'react-native-executorch';
import ScreenWrapper from '../../ScreenWrapper';
import ColorPalette from '../../colors';
import { getImage } from '../../utils';
import ScreenWrapper from '../../components/ScreenWrapper';
import { getImage, loadSkImage } from '../../utils';
import { ModelPicker, type ModelOption } from '../../components/ModelPicker';
import { ImageViewport } from '../../components/ImageViewport';
import { ModelStatus } from '../../components/ModelStatus';
import { LatencyIndicator } from '../../components/LatencyIndicator';
import { Button } from '../../components/Button';

const MODEL_OPTIONS: ModelOption[] = [
{
Expand All @@ -32,25 +27,10 @@ const MODEL_OPTIONS: ModelOption[] = [
},
];

async function loadImageBuffer(uri: string) {
const data = await Skia.Data.fromURI(uri);
const img = Skia.Image.MakeImageFromEncoded(data);
if (!img) {
throw new Error('Failed to decode image using Skia');
}
return {
data: img.readPixels() as Uint8Array,
width: img.width(),
height: img.height(),
format: 'rgba' as const,
layout: 'hwc' as const,
};
}

export default function ClassificationScreen() {
function ClassificationContent() {
const [selectedModel, setSelectedModel] = useState<any>(MODEL_OPTIONS[0].value);
const [imageUri, setImageUri] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [skiaImage, setSkiaImage] = useState<SkiaImageType | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [results, setResults] = useState<{ label: string; confidence: number }[]>([]);
const [latency, setLatency] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
Expand All @@ -66,226 +46,120 @@ export default function ClassificationScreen() {
const handlePickImage = async (useCamera: boolean) => {
const asset = await getImage(useCamera);
if (asset?.uri) {
setImageUri(asset.uri);
const img = await loadSkImage(asset.uri);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadSkImage can reject (manipulate/decode failure, or the explicit throw), but handlePickImage has no try/catch — the error escapes uncaught instead of reaching the setError UI. Previously decoding happened inside runClassification's try/catch. Wrap this in try/catch and setError.

setSkiaImage(img);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skiaImage is a native SkImage, but the previous one is never dispose()d when replaced here (nor on unmount), leaking decoded bitmaps on repeated picks.

setResults([]);
setLatency(null);
setError(null);
}
};

const runClassification = async (sync: boolean) => {
if (!imageUri || !classify || !classifyWorklet) return;
if (!sync) setLoading(true);
if (!skiaImage || !classify || !classifyWorklet) return;
if (!sync) setIsProcessing(true);
setError(null);
try {
const inputBuffer = await loadImageBuffer(imageUri);
const buffer = {
data: skiaImage.readPixels() as Uint8Array,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readPixels() is typed Uint8Array | null; the cast hides a possible null reaching the sync classifyWorklet path, which bypasses the try/catch. Pre-existing, but worth a guard.

width: skiaImage.width(),
height: skiaImage.height(),
format: 'rgba' as const,
layout: 'hwc' as const,
};
const start = Date.now();
const output = sync
? classifyWorklet(inputBuffer, { topk: 5 })
: await classify(inputBuffer, { topk: 5 });
? classifyWorklet(buffer, { topk: 5 })
: await classify(buffer, { topk: 5 });

setLatency(Date.now() - start);
setResults(output);
} catch (e: any) {
setError(e.message || String(e));
} finally {
if (!sync) setLoading(false);
if (!sync) setIsProcessing(false);
}
};

const activeError = loadError ? String(loadError) : error;

return (
<ScreenWrapper>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Image Classification</Text>

<ModelPicker
label="Model"
options={MODEL_OPTIONS}
selectedValue={selectedModel}
onValueChange={(model) => {
setSelectedModel(model);
setResults([]);
setLatency(null);
setError(null);
}}
<ScrollView
style={commonStyles.container}
contentContainerStyle={commonStyles.contentContainer}
>
<Text style={commonStyles.description}>
Upload or capture an image to identify objects using a classifier.
</Text>

<ModelPicker
label="Model"
options={MODEL_OPTIONS}
selectedValue={selectedModel}
onValueChange={(model) => {
setSelectedModel(model);
setResults([]);
setLatency(null);
setError(null);
}}
/>

<ModelStatus
isReady={isReady}
downloadProgress={downloadProgress}
error={activeError}
modelTypeLabel="classification model"
/>

<ImageViewport skiaImage={skiaImage} onPressPlaceholder={() => handlePickImage(false)} />

<View style={commonStyles.buttonRow}>
<Button title="Gallery" onPress={() => handlePickImage(false)} variant="secondary" />
<Button title="Camera" onPress={() => handlePickImage(true)} variant="secondary" />
</View>

<View style={commonStyles.buttonRow}>
<Button
title="Run Async"
onPress={() => runClassification(false)}
disabled={!skiaImage || !isReady || isProcessing}
loading={isProcessing}
/>

{!isReady && !activeError && (
<View style={styles.progressContainer}>
<ActivityIndicator size="small" color={ColorPalette.primary} />
<Text style={styles.progressText}>
Downloading Model... {downloadProgress ? `${Math.round(downloadProgress)}%` : '0%'}
</Text>
</View>
)}

{activeError && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{activeError}</Text>
</View>
)}

<View style={styles.imageCard}>
{imageUri ? (
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="contain" />
) : (
<View style={styles.imagePlaceholder}>
<Text style={styles.placeholderText}>No image selected</Text>
<Button
title="Run Sync"
onPress={() => runClassification(true)}
disabled={!skiaImage || !isReady || isProcessing}
variant="accent"
/>
</View>

<LatencyIndicator latency={latency} />

{results.length > 0 && (
<View style={styles.resultsContainer}>
<Text style={styles.resultsTitle}>Results</Text>
{results.map((res, idx) => (
<View key={idx} style={styles.resultRow}>
<Text style={styles.resultLabel} numberOfLines={1}>
{res.label}
</Text>
<Text style={styles.resultConfidence}>{Math.round(res.confidence * 100)}%</Text>
</View>
)}
</View>

<View style={styles.buttonRow}>
<TouchableOpacity style={styles.btnSecondary} onPress={() => handlePickImage(false)}>
<Text style={styles.btnTextSecondary}>Gallery</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.btnSecondary} onPress={() => handlePickImage(true)}>
<Text style={styles.btnTextSecondary}>Camera</Text>
</TouchableOpacity>
</View>

<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.btnPrimary, (!imageUri || !isReady || loading) && styles.btnDisabled]}
onPress={() => runClassification(false)}
disabled={!imageUri || !isReady || loading}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.btnTextPrimary}>Run Async</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.btnSecondary, (!imageUri || !isReady || loading) && styles.btnDisabled]}
onPress={() => runClassification(true)}
disabled={!imageUri || !isReady || loading}
>
<Text style={styles.btnTextSecondary}>Run Sync</Text>
</TouchableOpacity>
))}
</View>
)}
</ScrollView>
);
}

{latency !== null && (
<Text style={styles.latencyText}>Inference Latency: {latency} ms</Text>
)}

{results.length > 0 && (
<View style={styles.resultsContainer}>
<Text style={styles.resultsTitle}>Results</Text>
{results.map((res, idx) => (
<View key={idx} style={styles.resultRow}>
<Text style={styles.resultLabel} numberOfLines={1}>
{res.label}
</Text>
<Text style={styles.resultConfidence}>{Math.round(res.confidence * 100)}%</Text>
</View>
))}
</View>
)}
</ScrollView>
export default function ClassificationScreen() {
return (
<ScreenWrapper>
<ClassificationContent />
</ScreenWrapper>
);
}

const styles = StyleSheet.create({
container: {
padding: 16,
alignItems: 'center',
},
title: {
fontSize: 22,
fontWeight: '700',
color: ColorPalette.strongPrimary,
marginBottom: 16,
},
progressContainer: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 12,
gap: 8,
},
progressText: {
fontSize: 14,
color: '#666',
},
errorContainer: {
backgroundColor: '#ffe3e3',
padding: 12,
borderRadius: 8,
marginVertical: 8,
alignSelf: 'stretch',
},
errorText: {
color: '#d63031',
fontSize: 14,
textAlign: 'center',
},
imageCard: {
width: '100%',
height: 250,
backgroundColor: '#f1f3f5',
borderRadius: 12,
marginVertical: 16,
overflow: 'hidden',
borderWidth: 1,
borderColor: '#e9ecef',
justifyContent: 'center',
alignItems: 'center',
},
image: {
width: '100%',
height: '100%',
},
imagePlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: {
color: '#868e96',
fontSize: 14,
},
buttonRow: {
flexDirection: 'row',
gap: 8,
width: '100%',
marginBottom: 12,
},
btnPrimary: {
flex: 1,
backgroundColor: ColorPalette.primary,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
btnSecondary: {
flex: 1,
borderWidth: 1,
borderColor: ColorPalette.primary,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
btnDisabled: {
opacity: 0.5,
},
btnTextPrimary: {
color: '#fff',
fontSize: 15,
fontWeight: '600',
},
btnTextSecondary: {
color: ColorPalette.primary,
fontSize: 15,
fontWeight: '600',
},
latencyText: {
fontSize: 14,
color: '#666',
marginVertical: 12,
},
resultsContainer: {
width: '100%',
backgroundColor: '#fff',
Expand Down
2 changes: 1 addition & 1 deletion apps/computer-vision/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRouter } from 'expo-router';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import ColorPalette from '../colors';
import { ColorPalette } from '../theme';
import ExecutorchLogo from '../assets/icons/executorch.svg';

export default function Home() {
Expand Down
Loading