Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e24b2f2
[RNE Rewrite] feat(cv): native OCR + image ops (CRAFT/DBNet decode, C…
benITo47 Jul 20, 2026
f5f75d4
[RNE Rewrite] feat(ocr): pluggable detector extractors (CRAFT, DBNet)
benITo47 Jul 20, 2026
c806ec3
[RNE Rewrite] feat(ocr): document models — orientation, dewarp, table
benITo47 Jul 20, 2026
30049c4
[RNE Rewrite] feat(ocr): two-stage OCR pipeline + reading order + app…
benITo47 Jul 20, 2026
41dd105
[RNE Rewrite] feat(demo): merge OCR + document screens into one progr…
benITo47 Jul 20, 2026
573bd32
[RNE Rewrite] feat(ocr): make table recognition per-run optional (Run…
benITo47 Jul 20, 2026
17d4b14
[RNE Rewrite] fix(ocr): cornerMean recognizer padding for EasyOCR (tr…
benITo47 Jul 20, 2026
118ca47
[RNE Rewrite] docs(demo): clarify dewarp hint (curved pages, not angl…
benITo47 Jul 20, 2026
0e455c8
[RNE Rewrite] fix(ocr): recognize vertical glyphs per-glyph instead o…
benITo47 Jul 20, 2026
6491dba
[RNE Rewrite] feat(ocr): configurable verticalMinConfidence (default 0)
benITo47 Jul 20, 2026
501057b
[RNE Rewrite] feat(ocr): default verticalMinConfidence to 0.4
benITo47 Jul 20, 2026
d8a195c
[RNE Rewrite] refactor(ocr): drop dead argmaxRange params, hoist snap…
benITo47 Jul 20, 2026
109a477
[RNE Rewrite] refactor(ocr): correctness, type-safety and cleanup pass
benITo47 Jul 20, 2026
f437ba8
Merge branch 'rne-rewrite' into ocr_dynamic
msluszniak Aug 11, 2026
30f5c64
[RNE Rewrite] refactor(ocr): port the OCR pipeline to the get_model_s…
msluszniak Aug 11, 2026
b134052
[RNE Rewrite] style(ocr): apply prettier and add NHWC/SVTR to the wor…
msluszniak Aug 11, 2026
7cb15b9
[RNE Rewrite] fix(models): point the OCR presets at the restructured …
msluszniak Aug 12, 2026
67bf445
[RNE Rewrite] fix(ocr): drop layout regions contained in a larger one
msluszniak Aug 12, 2026
6cc57d4
[RNE Rewrite] fix(cv-app): respect the Android navigation bar, list e…
msluszniak Aug 12, 2026
2557edf
Merge branch 'rne-rewrite' into ocr_dynamic
msluszniak Aug 12, 2026
afef3d7
[RNE Rewrite] refactor(ocr): align with the .agents skill conventions
msluszniak Aug 12, 2026
3f83e48
[RNE Rewrite] fix(cv): raise coded errors from the OCR native ops
msluszniak Aug 12, 2026
c6dd561
[RNE Rewrite] refactor(ocr)!: scope the pipeline to detect + recognize
msluszniak Aug 13, 2026
a7823b6
[RNE Rewrite] refactor(cv): apply OCR review feedback
msluszniak Aug 13, 2026
ead2160
[RNE Rewrite] refactor(cv): drop unexercised OCR code paths
msluszniak Aug 13, 2026
1371765
[RNE Rewrite] refactor(ocr)!: fetch the charset beside the model
msluszniak Aug 13, 2026
e64cebd
[RNE Rewrite] docs(ocr): say which EasyOCR backend is for speed and w…
msluszniak Aug 13, 2026
f52dbc3
[RNE Rewrite] feat(ocr): PP-OCRv6 detects in int8 on XNNPACK
msluszniak Aug 13, 2026
ed41fb1
[RNE Rewrite] refactor(ocr): read the charset through the fetcher
msluszniak Aug 13, 2026
f007b1b
[RNE Rewrite] fix(ocr): charset.json, and a LOAD_FAILED for an unread…
msluszniak Aug 13, 2026
0f005d6
[RNE Rewrite] feat(ocr): drop EasyOCR, leaving PP-OCRv6 as the OCR model
msluszniak Aug 14, 2026
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
13 changes: 13 additions & 0 deletions .cspell-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,16 @@ Partitioner
denoised
ttfa
TTFA
dbnet
softmaxed
unclip
EasyOCR
ppocrv
letterboxed
nums
ocrv
unclips
NHWC
SVTR
binarizes
unshrunk
7 changes: 7 additions & 0 deletions apps/computer-vision/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export default function Layout() {
title: 'Instance Segmentation',
}}
/>
<Drawer.Screen
name="ocr/index"
options={{
drawerLabel: 'OCR',
title: 'OCR',
}}
/>
<Drawer.Screen
name="inspect/index"
options={{
Expand Down
24 changes: 19 additions & 5 deletions apps/computer-vision/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { useRouter } from 'expo-router';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ColorPalette } from '../theme';
import ExecutorchLogo from '../assets/icons/executorch.svg';

export default function Home() {
const router = useRouter();
const insets = useSafeAreaInsets();

return (
<View style={styles.container}>
// The list is taller than the viewport on shorter phones, so it scrolls
// rather than running under the system navigation bar.
<ScrollView
style={styles.screen}
contentContainerStyle={[styles.container, { paddingBottom: insets.bottom + 20 }]}
>
<ExecutorchLogo width={64} height={64} />
<Text style={styles.headerText}>Select a demo model</Text>
<View style={styles.buttonContainer}>
Expand Down Expand Up @@ -38,20 +45,27 @@ export default function Home() {
<TouchableOpacity style={styles.button} onPress={() => router.navigate('keypoint/')}>
<Text style={styles.buttonText}>Keypoint Detection</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => router.navigate('ocr/')}>
<Text style={styles.buttonText}>OCR</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.button} onPress={() => router.navigate('inspect/')}>
<Text style={styles.buttonText}>Model Inspector</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
);
}

const styles = StyleSheet.create({
container: {
screen: {
flex: 1,
backgroundColor: '#fff',
},
container: {
flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
paddingTop: 20,
},
headerText: {
fontSize: 18,
Expand Down
8 changes: 6 additions & 2 deletions apps/computer-vision/app/inspect/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ActivityIndicator,
Alert,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { inspectModel, type ConcreteDim, type ParamSpec } from 'react-native-executorch';
import ScreenWrapper from '../../components/ScreenWrapper';
import { ColorPalette } from '../../theme';
Expand All @@ -29,6 +30,7 @@ const formatDim = (dim: ConcreteDim): string => {
};

function InspectContent() {
const insets = useSafeAreaInsets();
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<InspectionResult | null>(null);
Expand Down Expand Up @@ -85,7 +87,10 @@ function InspectContent() {
};

return (
<ScrollView style={styles.container} contentContainerStyle={styles.contentContainer}>
<ScrollView
style={styles.container}
contentContainerStyle={[styles.contentContainer, { paddingBottom: insets.bottom + 40 }]}
>
<View style={styles.card}>
<Text style={styles.cardTitle}>Model URL Inspector</Text>
<Text style={styles.cardDescription}>
Expand Down Expand Up @@ -191,7 +196,6 @@ const styles = StyleSheet.create({
},
contentContainer: {
padding: 16,
paddingBottom: 40,
},
card: {
backgroundColor: '#ffffff',
Expand Down
252 changes: 252 additions & 0 deletions apps/computer-vision/app/ocr/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import React, { useMemo, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { commonStyles, ColorPalette, theme } from '../../theme';
import { useImage, ColorType, AlphaType } from '@shopify/react-native-skia';
import { useOcr, models, type OcrDetection, type OcrModel } from 'react-native-executorch';
import ScreenWrapper from '../../components/ScreenWrapper';
import { getImage } from '../../utils';
import { ModelPicker, type ModelOption } from '../../components/ModelPicker';
import { ImageViewport } from '../../components/ImageViewport';
import { ModelStatus } from '../../components/ModelStatus';
import { Button } from '../../components/Button';

// Every variant is listed on both platforms; the ones the platform can't run are
// shown disabled (CoreML is Apple-only, Vulkan is the Android GPU delegate).
const isIos = Platform.OS === 'ios';
const OCR_MODELS: { label: string; base: OcrModel; disabled: boolean }[] = [
{
label: 'PaddleOCR (XNNPACK)',
base: models.ocr.PADDLE.PPOCRV6_SMALL.XNNPACK,
disabled: false,
},
{
label: 'PaddleOCR (Vulkan)',
base: models.ocr.PADDLE.PPOCRV6_SMALL.VULKAN,
disabled: isIos,
},
{
label: 'PaddleOCR (CoreML)',
base: models.ocr.PADDLE.PPOCRV6_SMALL.COREML,
disabled: !isIos,
},
];

const MODEL_OPTIONS: ModelOption[] = OCR_MODELS.map((m, i) => ({
label: m.label,
value: i,
disabled: m.disabled,
}));

function OCRContent() {
const insets = useSafeAreaInsets();
const [selectedIdx, setSelectedIdx] = useState(0);
const [imageUri, setImageUri] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [detections, setDetections] = useState<OcrDetection[]>([]);
const [wallMs, setWallMs] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);

const selected = OCR_MODELS[selectedIdx]!;
const skiaImage = useImage(imageUri, (err) => setError(err.message || String(err)));

const { isReady, downloadProgress, error: loadError, runOcr } = useOcr(selected.base);

const resetResults = () => {
setDetections([]);
setWallMs(null);
};

const handlePick = async (useCamera: boolean) => {
setError(null);
try {
const uri = await getImage(useCamera);
if (uri) {
setImageUri(uri);
resetResults();
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};

const run = async () => {
if (!skiaImage || !runOcr) return;
setIsProcessing(true);
setError(null);
try {
const pixels = skiaImage.readPixels(0, 0, {
width: skiaImage.width(),
height: skiaImage.height(),
colorType: ColorType.RGBA_8888,
alphaType: AlphaType.Unpremul,
});
if (!(pixels instanceof Uint8Array)) throw new Error('Expected Uint8Array from readPixels');
const start = Date.now();
const out = await runOcr({
data: pixels,
width: skiaImage.width(),
height: skiaImage.height(),
format: 'rgba' as const,
layout: 'hwc' as const,
});
setWallMs(Date.now() - start);
setDetections(out);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsProcessing(false);
}
};

const activeError = loadError ? String(loadError) : error;
const boxes = useMemo(() => detections.map((d) => d.quad), [detections]);

return (
<ScrollView
style={commonStyles.container}
contentContainerStyle={[
commonStyles.contentContainer,
{ paddingBottom: insets.bottom + theme.spacing.large },
]}
>
<Text style={commonStyles.description}>
Detect and recognize text on-device: every text line is located, cropped and read, and the
results come back in reading order.
</Text>

<ModelPicker
label="Model"
options={MODEL_OPTIONS}
selectedValue={selectedIdx}
onValueChange={(idx) => {
setSelectedIdx(idx);
resetResults();
setError(null);
}}
/>

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

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

<View style={commonStyles.buttonRow}>
<Button title="Gallery" onPress={() => handlePick(false)} variant="secondary" />
<Button title="Camera" onPress={() => handlePick(true)} variant="secondary" />
</View>
<View style={commonStyles.buttonRow}>
<Button
title="Run OCR"
onPress={run}
disabled={!skiaImage || !isReady || isProcessing}
loading={isProcessing}
/>
</View>

{wallMs !== null && (
<View style={styles.statsCard}>
<Text style={styles.statsTitle}>Performance</Text>
<View style={styles.statTiles}>
<View style={styles.tile}>
<Text style={styles.tileValue}>
{wallMs}
<Text style={styles.tileUnit}> ms</Text>
</Text>
<Text style={styles.tileLabel}>Wall time</Text>
</View>
<View style={styles.tile}>
<Text style={styles.tileValue}>{detections.length}</Text>
<Text style={styles.tileLabel}>Regions read</Text>
</View>
</View>
</View>
)}

{detections.length > 0 && (
<View style={styles.results}>
<Text style={styles.resultsTitle}>Detected text ({detections.length})</Text>
{detections.map((d, i) => (
<View key={i} style={styles.resultRow}>
<Text style={styles.resultLabel} numberOfLines={1}>
{d.text}
</Text>
<Text style={styles.resultConfidence}>{Math.round(d.confidence * 100)}%</Text>
</View>
))}
</View>
)}
</ScrollView>
);
}

export default function OCRScreen() {
return (
<ScreenWrapper>
<OCRContent />
</ScreenWrapper>
);
}

const styles = StyleSheet.create({
statsCard: {
width: '100%',
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginVertical: 16,
borderWidth: 1,
borderColor: '#e9ecef',
},
statsTitle: {
fontSize: 12,
fontWeight: '700',
letterSpacing: 1,
color: '#868e96',
textTransform: 'uppercase',
marginBottom: 12,
},
statTiles: { flexDirection: 'row', gap: 12 },
tile: {
flex: 1,
backgroundColor: '#f2f4ff',
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 14,
},
tileValue: { fontSize: 24, fontWeight: '800', color: '#001A72', fontVariant: ['tabular-nums'] },
tileUnit: { fontSize: 14, fontWeight: '600', color: '#6b73a3' },
tileLabel: { fontSize: 11, color: '#868e96', marginTop: 4 },
results: {
width: '100%',
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: '#e9ecef',
},
resultsTitle: {
fontSize: 16,
fontWeight: '600',
color: ColorPalette.strongPrimary,
marginBottom: 12,
},
resultRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#f1f3f5',
},
resultLabel: { fontSize: 14, color: '#333', flex: 1, marginRight: 8 },
resultConfidence: { fontSize: 14, fontWeight: '600', color: '#2b8a3e' },
});
Loading