Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
a9bed27
Merge branch 'rne-rewrite' into ocr_dynamic
msluszniak Aug 20, 2026
3b0714f
[RNE Rewrite] refactor(ocr): address review on the native ops and the…
msluszniak Aug 21, 2026
3011897
[RNE Rewrite] refactor(ocr): collapse the OCR task into one PaddleOCR…
msluszniak Aug 22, 2026
729b664
[RNE Rewrite] docs(ocr): spell out rectifyQuad's tensor shapes
msluszniak Aug 22, 2026
0e51b6c
[RNE Rewrite] refactor(ocr): second review round on the OCR surface
msluszniak Aug 24, 2026
8205920
[RNE Rewrite] refactor(ocr): default PPOCRV6_SMALL to its XNNPACK var…
msluszniak Aug 24, 2026
63b61de
[RNE Rewrite] feat(math): add a gather op and decode CTC with it
msluszniak Aug 24, 2026
cd21b11
[RNE Rewrite] refactor(ocr): rewrite createPaddleOcr in the task style
msluszniak Aug 24, 2026
d0d9838
Merge branch 'rne-rewrite' into ocr_dynamic
msluszniak Aug 24, 2026
4ed38b7
[RNE Rewrite] refactor(ocr): apply the approval-round review notes
msluszniak Aug 24, 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 @@ -319,5 +319,18 @@ TTFA
agentic
phonemized
subsentences
dbnet
softmaxed
unclip
EasyOCR
ppocrv
letterboxed
nums
ocrv
unclips
NHWC
SVTR
binarizes
unshrunk
DEEPSEEK
LLMKV
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
262 changes: 262 additions & 0 deletions apps/computer-vision/app/ocr/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
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 {
useOpticalCharacterRecognizer,
models,
type OcrDetection,
type PaddleOcrModel,
} 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: PaddleOcrModel; 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,
recognizeCharacters,
} = useOpticalCharacterRecognizer(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 || !recognizeCharacters) 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 recognizeCharacters({
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