diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 964521ab13..6656114ca7 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -314,3 +314,16 @@ Partitioner denoised ttfa TTFA +dbnet +softmaxed +unclip +EasyOCR +ppocrv +letterboxed +nums +ocrv +unclips +NHWC +SVTR +binarizes +unshrunk diff --git a/apps/computer-vision/app/_layout.tsx b/apps/computer-vision/app/_layout.tsx index 9be87a6759..2ee132c0fb 100644 --- a/apps/computer-vision/app/_layout.tsx +++ b/apps/computer-vision/app/_layout.tsx @@ -76,6 +76,13 @@ export default function Layout() { title: 'Instance Segmentation', }} /> + + // The list is taller than the viewport on shorter phones, so it scrolls + // rather than running under the system navigation bar. + Select a demo model @@ -38,20 +45,27 @@ export default function Home() { router.navigate('keypoint/')}> Keypoint Detection + router.navigate('ocr/')}> + OCR + router.navigate('inspect/')}> Model Inspector - + ); } const styles = StyleSheet.create({ - container: { + screen: { flex: 1, + backgroundColor: '#fff', + }, + container: { + flexGrow: 1, justifyContent: 'center', alignItems: 'center', - backgroundColor: '#fff', + paddingTop: 20, }, headerText: { fontSize: 18, diff --git a/apps/computer-vision/app/inspect/index.tsx b/apps/computer-vision/app/inspect/index.tsx index b1505028bd..c22bb5a832 100644 --- a/apps/computer-vision/app/inspect/index.tsx +++ b/apps/computer-vision/app/inspect/index.tsx @@ -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'; @@ -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(null); @@ -85,7 +87,10 @@ function InspectContent() { }; return ( - + Model URL Inspector @@ -191,7 +196,6 @@ const styles = StyleSheet.create({ }, contentContainer: { padding: 16, - paddingBottom: 40, }, card: { backgroundColor: '#ffffff', diff --git a/apps/computer-vision/app/ocr/index.tsx b/apps/computer-vision/app/ocr/index.tsx new file mode 100644 index 0000000000..c5b966f6fa --- /dev/null +++ b/apps/computer-vision/app/ocr/index.tsx @@ -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(null); + const [isProcessing, setIsProcessing] = useState(false); + const [detections, setDetections] = useState([]); + const [wallMs, setWallMs] = useState(null); + const [error, setError] = useState(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 ( + + + Detect and recognize text on-device: every text line is located, cropped and read, and the + results come back in reading order. + + + { + setSelectedIdx(idx); + resetResults(); + setError(null); + }} + /> + + + + handlePick(false)} + /> + + +