diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 55fa014084..c87e9aa893 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -298,3 +298,19 @@ deis Coeff Multistep multistep +dbnet +softmaxed +unclip +EasyOCR +ppocrv +dewarp +onehot +letterboxed +redetections +doclayout +rowspan +slanet +dewarped +nums +ocrv +unclips 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', }} /> + router.navigate('keypoint/')}> Keypoint Detection + router.navigate('ocr/')}> + OCR + router.navigate('inspect/')}> Model Inspector diff --git a/apps/computer-vision/app/ocr/index.tsx b/apps/computer-vision/app/ocr/index.tsx new file mode 100644 index 0000000000..a528185db0 --- /dev/null +++ b/apps/computer-vision/app/ocr/index.tsx @@ -0,0 +1,501 @@ +import React, { useMemo, useState } from 'react'; +import { View, Text, StyleSheet, ScrollView, Platform, Switch } from 'react-native'; +import { commonStyles, ColorPalette } from '../../theme'; +import { useImage, Skia, ColorType, AlphaType, type SkImage } from '@shopify/react-native-skia'; +import { + useOcr, + models, + type OcrDetection, + type DocumentBlock, + type OcrModel, + type DocLayoutLabel, +} 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'; + +type BackendKey = 'XNNPACK' | 'VULKAN' | 'COREML'; +const ALL_MODELS: { label: string; backend: BackendKey; base: OcrModel; platforms: string[] }[] = [ + { + label: 'PaddleOCR (XNNPACK)', + backend: 'XNNPACK', + base: models.ocr.PADDLE.PPOCRV6_SMALL.XNNPACK, + platforms: ['ios', 'android'], + }, + { + label: 'PaddleOCR (Vulkan)', + backend: 'VULKAN', + base: models.ocr.PADDLE.PPOCRV6_SMALL.VULKAN, + platforms: ['android'], + }, + { + label: 'PaddleOCR (CoreML)', + backend: 'COREML', + base: models.ocr.PADDLE.PPOCRV6_SMALL.COREML, + platforms: ['ios'], + }, + { + label: 'EasyOCR English (XNNPACK)', + backend: 'XNNPACK', + base: models.ocr.EASYOCR.ENGLISH.XNNPACK, + platforms: ['ios', 'android'], + }, + { + label: 'EasyOCR English (Vulkan)', + backend: 'VULKAN', + base: models.ocr.EASYOCR.ENGLISH.VULKAN, + platforms: ['android'], + }, + { + label: 'EasyOCR English (CoreML)', + backend: 'COREML', + base: models.ocr.EASYOCR.ENGLISH.COREML, + platforms: ['ios'], + }, +]; + +const OCR_MODELS = ALL_MODELS.filter((m) => m.platforms.includes(Platform.OS)); +const MODEL_OPTIONS: ModelOption[] = OCR_MODELS.map((m, i) => ({ label: m.label, value: i })); + +type Cell = { text: string; colspan: number }; + +// Parse the filled SLANet structure HTML into rows of cells for rendering. +// A rowspan becomes spacer cells on the rows below so columns stay aligned. +function parseTable(html: string): Cell[][] { + const raw: { text: string; colspan: number; rowspan: number }[][] = []; + const trRe = /([\s\S]*?)<\/tr>/g; + let tr: RegExpExecArray | null; + while ((tr = trRe.exec(html))) { + const cells: { text: string; colspan: number; rowspan: number }[] = []; + const tdRe = /]*)>([\s\S]*?)<\/td>/g; + let td: RegExpExecArray | null; + while ((td = tdRe.exec(tr[1]!))) { + cells.push({ + text: td[2] ?? '', + colspan: Number(/colspan="(\d+)"/.exec(td[1] ?? '')?.[1] ?? 1), + rowspan: Number(/rowspan="(\d+)"/.exec(td[1] ?? '')?.[1] ?? 1), + }); + } + raw.push(cells); + } + const out: Cell[][] = raw.map(() => []); + const occupied: boolean[][] = []; + for (let r = 0; r < raw.length; r++) { + let c = 0; + const spacer = () => { + out[r]!.push({ text: '', colspan: 1 }); + c++; + }; + for (const cell of raw[r]!) { + while (occupied[r]?.[c]) spacer(); + out[r]!.push({ text: cell.text, colspan: cell.colspan }); + for (let dr = 1; dr < cell.rowspan; dr++) { + occupied[r + dr] ??= []; + for (let dc = 0; dc < cell.colspan; dc++) { + occupied[r + dr]![c + dc] = true; + } + } + c += cell.colspan; + } + while (occupied[r]?.[c]) spacer(); + } + return out; +} + +function TableView({ html }: { html: string }) { + const rows = parseTable(html); + if (rows.length === 0) { + return {html}; + } + return ( + + + {rows.map((cells, r) => ( + + {cells.map((c, i) => ( + + {c.text} + + ))} + + ))} + + + ); +} + +function OCRContent() { + const [selectedIdx, setSelectedIdx] = useState(0); + const [vertical, setVertical] = useState(false); + const [layoutOn, setLayoutOn] = useState(false); + const [documentOn, setDocumentOn] = useState(false); + const [orientation, setOrientation] = useState(true); + const [tables, setTables] = useState(true); + const [dewarp, setDewarp] = useState(false); // off: only helps warped photos + const [imageUri, setImageUri] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [detections, setDetections] = useState([]); + const [blocks, setBlocks] = useState[]>([]); + // The corrected frame the result boxes are relative to (for the overlay). + const [processed, setProcessed] = useState(null); + 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 config = { + ...selected.base, + ...(layoutOn ? { layout: models.layoutDetection.PP_DOCLAYOUT[selected.backend] } : {}), + ...(documentOn ? { documentModels: models.documentModels.PP_HELPERS[selected.backend] } : {}), + }; + + const { isReady, downloadProgress, error: loadError, runOcr } = useOcr(config); + + const resetResults = () => { + setDetections([]); + setBlocks([]); + setProcessed(null); + 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, + }, + { + vertical, + orientation: documentOn && orientation, + dewarp: documentOn && dewarp, + tables: documentOn && tables, + } + ); + setWallMs(Date.now() - start); + setDetections(out.detections); + setBlocks(out.blocks); + const frame = out.image; + const frameImage = Skia.Image.MakeImage( + { + width: frame.width, + height: frame.height, + colorType: ColorType.RGBA_8888, + // Match the AlphaType readPixels used above — the pipeline keeps alpha + // as-is, so a mismatched mode here would tint anything with transparency. + alphaType: AlphaType.Unpremul, + }, + Skia.Data.fromBytes(frame.data), + frame.width * 4 + ); + setProcessed(frameImage ?? null); + } 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]); + const showBlocks = layoutOn && blocks.length > 0; + const busy = isProcessing || !isReady; + + return ( + + + Detect and recognize text on-device. Turn on Layout to group text into reading-ordered + blocks, and Document helpers for orientation, table structure and dewarp. + + + { + setSelectedIdx(idx); + resetResults(); + setError(null); + }} + /> + + + + + {documentOn && ( + <> + + + + + )} + + + + handlePick(false)} + /> + + +