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)}
+ />
+
+
+
+
+
+
+
+ {wallMs !== null && (
+
+ Performance
+
+
+
+ {wallMs}
+ ms
+
+ Wall time
+
+
+ {detections.length}
+ Regions read
+
+
+
+ )}
+
+ {detections.length > 0 && (
+
+ Detected text ({detections.length})
+ {detections.map((d, i) => (
+
+
+ {d.text}
+
+ {Math.round(d.confidence * 100)}%
+
+ ))}
+
+ )}
+
+ );
+}
+
+export default function OCRScreen() {
+ return (
+
+
+
+ );
+}
+
+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' },
+});
diff --git a/apps/computer-vision/components/ImageViewport.tsx b/apps/computer-vision/components/ImageViewport.tsx
index 593133eaf6..80498d5b3b 100644
--- a/apps/computer-vision/components/ImageViewport.tsx
+++ b/apps/computer-vision/components/ImageViewport.tsx
@@ -1,9 +1,11 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
import {
Canvas,
Image as SkImage,
BlendColor,
+ Path,
+ Skia,
type SkImage as SkiaImageType,
} from '@shopify/react-native-skia';
@@ -12,6 +14,11 @@ import { theme } from '../theme';
const VIEW_WIDTH = Dimensions.get('window').width - 32;
const VIEW_HEIGHT = Math.round((VIEW_WIDTH * 16) / 9);
+/** A 2D point in the displayed image's pixel coordinates. */
+type Point = { readonly x: number; readonly y: number };
+/** A polygon (e.g. an OCR quad) in the displayed image's pixel coordinates. */
+type Polygon = readonly Point[];
+
export interface ImageViewportProps {
skiaImage: SkiaImageType | null;
overlayImage?: SkiaImageType | null;
@@ -20,6 +27,8 @@ export interface ImageViewportProps {
placeholderText?: string;
overlayOpacity?: number;
children?: React.ReactNode;
+ /** Polygons (in the displayed image's px) to stroke over the image, e.g. OCR quads. */
+ boxes?: readonly Polygon[];
}
export function ImageViewport({
@@ -30,7 +39,31 @@ export function ImageViewport({
placeholderText = 'Tap to select an image from gallery',
overlayOpacity = 0.8,
children,
+ boxes,
}: ImageViewportProps) {
+ // Map image-pixel polygons into canvas space using the same contain-fit
+ // transform Skia uses to draw the image, then build one stroked path.
+ const boxesPath = useMemo(() => {
+ if (!skiaImage || !boxes?.length) return null;
+ const ow = skiaImage.width();
+ const oh = skiaImage.height();
+ if (ow === 0 || oh === 0) return null;
+ const scale = Math.min(VIEW_WIDTH / ow, VIEW_HEIGHT / oh);
+ const dx = (VIEW_WIDTH - ow * scale) / 2;
+ const dy = (VIEW_HEIGHT - oh * scale) / 2;
+
+ const path = Skia.Path.Make();
+ for (const poly of boxes) {
+ if (poly.length < 2) continue;
+ path.moveTo(dx + poly[0]!.x * scale, dy + poly[0]!.y * scale);
+ for (let i = 1; i < poly.length; i++) {
+ path.lineTo(dx + poly[i]!.x * scale, dy + poly[i]!.y * scale);
+ }
+ path.close();
+ }
+ return path;
+ }, [skiaImage, boxes]);
+
if (!skiaImage) {
return (
@@ -76,6 +109,14 @@ export function ImageViewport({
))}
+ {boxesPath && (
+
+ )}
{children}
diff --git a/apps/computer-vision/theme.ts b/apps/computer-vision/theme.ts
index 765ed34ae1..3cf2b2f05f 100644
--- a/apps/computer-vision/theme.ts
+++ b/apps/computer-vision/theme.ts
@@ -22,6 +22,7 @@ export const theme = {
textSecondary: '#000000',
textMuted: '#666666',
textPlaceholder: '#868e96',
+ overlayStroke: '#39FF14',
},
radius: {
small: 8,
diff --git a/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp b/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp
index 992bc429bb..0bf59471c9 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp
+++ b/packages/react-native-executorch/cpp/extensions/cv/image_ops.cpp
@@ -1,6 +1,7 @@
#include "image_ops.h"
#include
+#include
#include
#include
#include
@@ -450,4 +451,74 @@ void install_applyColormap(jsi::Runtime &rt, jsi::Object &module) {
};
module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 3, error::guarded(fnBody)));
}
+
+// Perspective-crop an oriented quad of `src` into the `dst` canvas (crop +
+// resize-to-height + pad/align) — normalizes a detected text box for the recognizer.
+void install_rectifyQuad(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "rectifyQuad";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 4) {
+ throw error::InvalidArgument("Usage: rectifyQuad(src, dst, quad, options)");
+ }
+ using DType = rnexecutorch::core::types::DType;
+ auto src = tensor::fromJs(rt, "rectifyQuad: src", args[0], DType::uint8, {"H", "W", "C"});
+ auto dst = tensor::fromJs(rt, "rectifyQuad: dst", args[1], DType::uint8,
+ {"H'", "W'", src->shape_[2]});
+ tensor::checkNotSameTensor(rt, "rectifyQuad: src", src, "rectifyQuad: dst", dst);
+ const auto quadArr = conversions::asType(rt, "rectifyQuad: quad", args[2]);
+ const auto opts = conversions::asType(rt, "rectifyQuad: options", args[3]);
+ if (quadArr.length(rt) != 8) {
+ throw error::InvalidArgument("rectifyQuad: quad must have exactly 8 numbers (4 points)");
+ }
+
+ const int32_t channels = src->shape_[2];
+ const int32_t recH = dst->shape_[0];
+ const int32_t canvasW = dst->shape_[1];
+
+ const int32_t contentWidth = std::clamp(
+ conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "contentWidth"),
+ 1, canvasW);
+ const auto padValue = conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "padValue");
+ const auto align = conversions::getRequiredProperty(rt, "rectifyQuad: options", opts, "align");
+
+ std::array<::cv::Point2f, 4> quad;
+ for (std::size_t i = 0; i < 4; ++i) {
+ quad[i] = {conversions::asType(rt, "rectifyQuad: quad", quadArr.getValueAtIndex(rt, i * 2)),
+ conversions::asType(rt, "rectifyQuad: quad", quadArr.getValueAtIndex(rt, i * 2 + 1))};
+ }
+
+ auto srcLock = tensor::tryLockShared(rt, "rectifyQuad: src", src);
+ auto dstLock = tensor::tryLockUnique(rt, "rectifyQuad: dst", dst);
+
+ const int cvType = CV_MAKETYPE(CV_8U, channels);
+ ::cv::Mat srcMat(src->shape_[0], src->shape_[1], cvType, src->data_.get());
+ ::cv::Mat dstMat(recH, canvasW, cvType, dst->data_.get());
+
+ try {
+ const std::array<::cv::Point2f, 4> dstPts = {
+ ::cv::Point2f{0.0f, 0.0f},
+ {static_cast(contentWidth), 0.0f},
+ {static_cast(contentWidth), static_cast(recH)},
+ {0.0f, static_cast(recH)}};
+ const std::array<::cv::Point2f, 4> srcPts = {quad[0], quad[1], quad[2], quad[3]};
+ ::cv::Mat m = ::cv::getPerspectiveTransform(srcPts.data(), dstPts.data());
+ ::cv::Mat content;
+ ::cv::warpPerspective(srcMat, content, m, ::cv::Size(contentWidth, recH),
+ ::cv::INTER_CUBIC, ::cv::BORDER_REPLICATE);
+
+ dstMat.setTo(::cv::Scalar::all(padValue));
+ const int32_t offsetX = (align == "center") ? (canvasW - contentWidth) / 2 : 0;
+ const int32_t copyW = std::min(contentWidth, canvasW - offsetX);
+ content(::cv::Rect(0, 0, copyW, recH)).copyTo(dstMat(::cv::Rect(offsetX, 0, copyW, recH)));
+ } catch (const std::exception &e) {
+ throw error::ExecutionFailed(std::format("rectifyQuad: OpenCV error: {}", e.what()));
+ }
+ return jsi::Value(rt, args[1]);
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 4, error::guarded(fnBody)));
+}
+
} // namespace rnexecutorch::extensions::cv::image_ops
diff --git a/packages/react-native-executorch/cpp/extensions/cv/image_ops.h b/packages/react-native-executorch/cpp/extensions/cv/image_ops.h
index 893c0957e5..09b2417fe9 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/image_ops.h
+++ b/packages/react-native-executorch/cpp/extensions/cv/image_ops.h
@@ -9,4 +9,5 @@ void install_toChannelsFirst(facebook::jsi::Runtime &rt, facebook::jsi::Object &
void install_toChannelsLast(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
void install_normalize(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
void install_applyColormap(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_rectifyQuad(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
} // namespace rnexecutorch::extensions::cv::image_ops
diff --git a/packages/react-native-executorch/cpp/extensions/cv/install.cpp b/packages/react-native-executorch/cpp/extensions/cv/install.cpp
index 0540f45c9b..2aab78c4a0 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/install.cpp
+++ b/packages/react-native-executorch/cpp/extensions/cv/install.cpp
@@ -1,6 +1,7 @@
#include "install.h"
#include "box_ops.h"
#include "image_ops.h"
+#include "ocr_ops.h"
namespace rnexecutorch::extensions::cv {
namespace jsi = facebook::jsi;
@@ -14,10 +15,14 @@ void install(facebook::jsi::Runtime &rt, facebook::jsi::Object &module) {
image_ops::install_toChannelsLast(rt, cvModule);
image_ops::install_normalize(rt, cvModule);
image_ops::install_applyColormap(rt, cvModule);
+ image_ops::install_rectifyQuad(rt, cvModule);
box_ops::install_nms(rt, cvModule);
box_ops::install_restrictToBox(rt, cvModule);
+ ocr_ops::install_extractDbnetTextBoxes(rt, cvModule);
+ ocr_ops::install_ctcGreedyDecode(rt, cvModule);
+
module.setProperty(rt, "cv", cvModule);
}
} // namespace rnexecutorch::extensions::cv
diff --git a/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp
new file mode 100644
index 0000000000..d79b19b60b
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.cpp
@@ -0,0 +1,210 @@
+#include "ocr_ops.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "core/conversions.h"
+#include "core/dtype.h"
+#include "core/error.h"
+#include "core/tensor.h"
+#include "core/tensor_helpers.h"
+
+namespace error = rnexecutorch::core::error;
+
+namespace rnexecutorch::extensions::cv::ocr_ops {
+namespace jsi = facebook::jsi;
+namespace tensor = rnexecutorch::core::tensor;
+namespace conversions = rnexecutorch::core::conversions;
+using DType = rnexecutorch::core::types::DType;
+
+namespace {
+using Quad = std::array<::cv::Point2f, 4>;
+
+// ------------------------------ DBNet branch -------------------------------
+// DBNet prob map [H,W] -> oriented quads. The map must be post-sigmoid
+// probabilities — any activation is baked into the model's export.
+std::vector extractDbnet(const ::cv::Mat &prob, float binThreshold, float boxThreshold,
+ float unclipRatio, int32_t minBoxSide, int32_t maxCandidates) {
+ const int32_t w = prob.cols;
+ const int32_t h = prob.rows;
+
+ ::cv::Mat bitmap;
+ ::cv::threshold(prob, bitmap, static_cast(binThreshold), 255, ::cv::THRESH_BINARY);
+ bitmap.convertTo(bitmap, CV_8UC1);
+
+ std::vector> contours;
+ ::cv::findContours(bitmap, contours, ::cv::RETR_LIST, ::cv::CHAIN_APPROX_SIMPLE);
+
+ std::vector quads;
+ const int32_t maxN = static_cast(
+ std::min(contours.size(), static_cast(maxCandidates)));
+ for (int32_t i = 0; i < maxN; ++i) {
+ const auto &contour = contours[static_cast(i)];
+ if (contour.size() < 4) {
+ continue;
+ }
+ ::cv::RotatedRect rr = ::cv::minAreaRect(contour);
+ if (std::min(rr.size.width, rr.size.height) < static_cast(minBoxSide)) {
+ continue;
+ }
+ // Score inside the contour's bounding rect only — a full-frame mask per
+ // candidate would make scoring O(candidates · H · W).
+ const ::cv::Rect bounds = ::cv::boundingRect(contour);
+ ::cv::Mat mask = ::cv::Mat::zeros(bounds.size(), CV_8UC1);
+ ::cv::drawContours(mask, contours, i, ::cv::Scalar(255), ::cv::FILLED, ::cv::LINE_8,
+ ::cv::noArray(), 0, -bounds.tl());
+ const float score = static_cast(::cv::mean(prob(bounds), mask)[0]);
+ if (score < boxThreshold) {
+ continue;
+ }
+ const double area = static_cast(rr.size.width) * static_cast(rr.size.height);
+ const double perim =
+ 2.0 * (static_cast(rr.size.width) + static_cast(rr.size.height));
+ const double distance = perim > 0.0 ? area * static_cast(unclipRatio) / perim : 0.0;
+ const auto grow = static_cast(2.0 * distance);
+ ::cv::RotatedRect expanded(rr.center,
+ ::cv::Size2f(rr.size.width + grow, rr.size.height + grow),
+ rr.angle);
+ if (std::min(expanded.size.width, expanded.size.height) <
+ static_cast(minBoxSide + 2)) {
+ continue;
+ }
+ std::array<::cv::Point2f, 4> c;
+ expanded.points(c.data());
+ Quad q;
+ auto minX = static_cast(w);
+ auto minY = static_cast(h);
+ float maxX = 0;
+ float maxY = 0;
+ for (std::size_t k = 0; k < c.size(); ++k) {
+ // Clamp to the last valid pixel index (w-1/h-1), not w/h — a corner at
+ // exactly w or h is one past the last column/row.
+ const float px = std::clamp(c[k].x, 0.0f, static_cast(w - 1));
+ const float py = std::clamp(c[k].y, 0.0f, static_cast(h - 1));
+ q[k] = {px, py};
+ minX = std::min(minX, px);
+ minY = std::min(minY, py);
+ maxX = std::max(maxX, px);
+ maxY = std::max(maxY, py);
+ }
+ if (maxX - minX < 1.0f || maxY - minY < 1.0f) {
+ continue;
+ }
+ quads.push_back(q);
+ }
+ // Output order is unspecified — the TypeScript pipeline derives reading
+ // order geometrically for every result set.
+ return quads;
+}
+
+// Flatten quads to a Float32Array, 8 per box (x0,y0..x3,y3).
+jsi::Object quadsToArray(jsi::Runtime &rt, const std::vector &quads) {
+ std::vector flat;
+ flat.reserve(quads.size() * 8);
+ for (const auto &q : quads) {
+ for (const auto &p : q) {
+ flat.push_back(p.x);
+ flat.push_back(p.y);
+ }
+ }
+ return conversions::toJsiTypedArray(rt, flat);
+}
+
+} // namespace
+
+void install_extractDbnetTextBoxes(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "extractDbnetTextBoxes";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 2) {
+ throw error::InvalidArgument("Usage: extractDbnetTextBoxes(src, options)");
+ }
+ const char *ctx = "extractDbnetTextBoxes";
+ auto src = tensor::fromJs(rt, "extractDbnetTextBoxes: src", args[0], DType::float32, std::nullopt);
+ const auto opts = conversions::asType(rt, "extractDbnetTextBoxes: options", args[1]);
+ auto srcLock = tensor::tryLockShared(rt, "extractDbnetTextBoxes: src", src);
+ auto *dataPtr = reinterpret_cast(src->data_.get());
+
+ // src is [1,1,H,W] or [H,W] probability map (full-res).
+ const auto &s = src->shape_;
+ if (s.size() < 2) {
+ throw error::InvalidArgument("extractDbnetTextBoxes: src must be [..,H,W]");
+ }
+ const int32_t w = s[s.size() - 1];
+ const int32_t h = s[s.size() - 2];
+ if (static_cast(w) * static_cast(h) != src->numel_) {
+ throw error::InvalidArgument("extractDbnetTextBoxes: src H*W does not match numel");
+ }
+
+ std::vector quads;
+ try {
+ ::cv::Mat prob(h, w, CV_32F, dataPtr);
+ quads = extractDbnet(
+ prob, static_cast(conversions::getRequiredProperty(rt, ctx, opts, "binThreshold")),
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "boxThreshold")),
+ static_cast(conversions::getRequiredProperty(rt, ctx, opts, "unclipRatio")),
+ conversions::getRequiredProperty(rt, ctx, opts, "minBoxSide"),
+ conversions::getRequiredProperty(rt, ctx, opts, "maxCandidates"));
+ } catch (const std::exception &e) {
+ throw error::ExecutionFailed(std::format("extractDbnetTextBoxes: OpenCV error: {}", e.what()));
+ }
+ return quadsToArray(rt, quads);
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 2, error::guarded(fnBody)));
+}
+
+// --------------------------- ctcGreedyDecode -------------------------------
+// Per-timestep argmax + max value over a [..,T,V] tensor. The values are
+// returned as-is, so they are probabilities only when the recognizer exports a
+// softmaxed head — this op neither normalizes nor takes options.
+void install_ctcGreedyDecode(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "ctcGreedyDecode";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args,
+ size_t count) -> jsi::Value {
+ if (count != 1) {
+ throw error::InvalidArgument("Usage: ctcGreedyDecode(src)");
+ }
+ auto src = tensor::fromJs(rt, "ctcGreedyDecode: src", args[0], DType::float32, std::nullopt);
+ auto srcLock = tensor::tryLockShared(rt, "ctcGreedyDecode: src", src);
+
+ const auto &s = src->shape_;
+ if (s.size() < 2) {
+ throw error::InvalidArgument("ctcGreedyDecode: src must be at least 2-D [..,T,V]");
+ }
+ const int32_t vocab = s.back();
+ if (vocab < 1) {
+ throw error::InvalidArgument("ctcGreedyDecode: vocab dimension must be >= 1");
+ }
+ if (src->numel_ % static_cast(vocab) != 0) {
+ throw error::InvalidArgument("ctcGreedyDecode: numel must be a multiple of the vocab dim");
+ }
+ const int32_t timesteps = static_cast(src->numel_) / vocab;
+ const auto *data = reinterpret_cast(src->data_.get());
+
+ // Interleaved (index, value) pairs. float32 holds any index a CTC vocab
+ // can reach exactly, so one array carries both without a second buffer.
+ std::vector out;
+ out.reserve(static_cast(timesteps) * 2);
+ for (int32_t t = 0; t < timesteps; ++t) {
+ const float *row = data + static_cast(t) * static_cast(vocab);
+ const float *maxIt = std::max_element(row, row + vocab);
+ out.push_back(static_cast(maxIt - row));
+ out.push_back(*maxIt);
+ }
+ return conversions::toJsiTypedArray(rt, out);
+ };
+ module.setProperty(rt, name,
+ jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name),
+ 1, error::guarded(fnBody)));
+}
+
+} // namespace rnexecutorch::extensions::cv::ocr_ops
diff --git a/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.h b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.h
new file mode 100644
index 0000000000..abcaf6b7d6
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/cv/ocr_ops.h
@@ -0,0 +1,8 @@
+#pragma once
+
+#include
+
+namespace rnexecutorch::extensions::cv::ocr_ops {
+void install_extractDbnetTextBoxes(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+void install_ctcGreedyDecode(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+} // namespace rnexecutorch::extensions::cv::ocr_ops
diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js
index 591061fcae..459cdcfba9 100644
--- a/packages/react-native-executorch/scripts/download-libs.js
+++ b/packages/react-native-executorch/scripts/download-libs.js
@@ -145,8 +145,8 @@ const FEATURE_MAP = {
semanticSegmentation: { backends: ['xnnpack'], libs: ['opencv'] },
// YOLO-seg xnnpack-only, rf_detr-seg/fastsam add coreml → union.
instanceSegmentation: { backends: ['xnnpack', 'coreml'], libs: ['opencv'] },
- // CRAFT + CRNN — xnnpack only.
- ocr: { backends: ['xnnpack'], libs: ['opencv'] },
+ // PP-OCRv6 (DBNet + SVTR) ships xnnpack, coreml and vulkan → union.
+ ocr: { backends: ['xnnpack', 'coreml', 'vulkan'], libs: ['opencv'] },
verticalOCR: { backends: ['xnnpack'], libs: ['opencv'] },
// All style-transfer presets ship xnnpack + coreml.
styleTransfer: { backends: ['xnnpack', 'coreml'], libs: ['opencv'] },
diff --git a/packages/react-native-executorch/src/extensions/cv/index.ts b/packages/react-native-executorch/src/extensions/cv/index.ts
index 2b28536552..9ede9915f0 100644
--- a/packages/react-native-executorch/src/extensions/cv/index.ts
+++ b/packages/react-native-executorch/src/extensions/cv/index.ts
@@ -1,2 +1,3 @@
export * from './image';
export * from './ops';
+export * from './utils/ocrUtils';
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/image.ts b/packages/react-native-executorch/src/extensions/cv/ops/image.ts
index b1e5a4ee34..72c8676583 100644
--- a/packages/react-native-executorch/src/extensions/cv/ops/image.ts
+++ b/packages/react-native-executorch/src/extensions/cv/ops/image.ts
@@ -1,6 +1,7 @@
import { rnexecutorchJsi } from '../../../native/bridge';
import type { Tensor } from '../../../core/tensor';
import type { ImageFormat } from '../image';
+import type { Quad } from './quad';
/**
* Supported color conversion code presets (similar to OpenCV).
@@ -224,3 +225,42 @@ export function applyColormap(
'worklet';
return rnexecutorchJsi.cv.applyColormap(src, dst, colormap);
}
+
+/**
+ * Options for {@link rectifyQuad}. `contentWidth` is the rectified content's width
+ * (px) in the canvas; `align` (default `'left'`) places it and `padValue`
+ * (default `0`) fills the rest.
+ * @category Types
+ */
+export type RectifyQuadOptions = {
+ readonly contentWidth: number;
+ readonly align?: 'left' | 'center';
+ readonly padValue?: number;
+};
+
+/**
+ * Rectifies an oriented quad region of `src` into the flat pre-allocated canvas
+ * `dst` — perspective crop + resize-to-height + pad in one native pass. An
+ * axis-aligned bbox is a 4-corner quad; pass its corners to rectify a box.
+ * @category Typescript API
+ * @param src The source image tensor (HWC uint8).
+ * @param dst The pre-allocated destination canvas (HWC uint8).
+ * @param quad The region corners (TL, TR, BR, BL) in `src` pixels.
+ * @param options Content width, alignment, and padding.
+ * @returns The destination tensor `dst`.
+ */
+export function rectifyQuad(
+ src: Tensor,
+ dst: Tensor,
+ quad: Quad,
+ options: RectifyQuadOptions
+): Tensor {
+ 'worklet';
+ // The native op takes the corners as a flat [x0,y0,..,x3,y3] array.
+ const flat = quad.flatMap((p) => [p.x, p.y]);
+ return rnexecutorchJsi.cv.rectifyQuad(src, dst, flat, {
+ contentWidth: options.contentWidth,
+ align: options.align ?? 'left',
+ padValue: options.padValue ?? 0,
+ });
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/index.ts b/packages/react-native-executorch/src/extensions/cv/ops/index.ts
index 84a274101d..4128d25527 100644
--- a/packages/react-native-executorch/src/extensions/cv/ops/index.ts
+++ b/packages/react-native-executorch/src/extensions/cv/ops/index.ts
@@ -1,3 +1,4 @@
export * as image from './image';
export * as boxes from './boxes';
export * as points from './points';
+export * as quad from './quad';
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/points.ts b/packages/react-native-executorch/src/extensions/cv/ops/points.ts
index 6e26dd6b4d..4f6218e46c 100644
--- a/packages/react-native-executorch/src/extensions/cv/ops/points.ts
+++ b/packages/react-native-executorch/src/extensions/cv/ops/points.ts
@@ -9,6 +9,32 @@ export type Point = {
readonly y: number;
};
+/**
+ * Euclidean distance between two points.
+ * @category Utils
+ * @param a The first point.
+ * @param b The second point.
+ * @returns The distance between `a` and `b`.
+ */
+export function distance(a: Point, b: Point): number {
+ 'worklet';
+ return Math.hypot(b.x - a.x, b.y - a.y);
+}
+
+/**
+ * Linearly interpolates between two points: `t = 0` returns `a`, `t = 1`
+ * returns `b`, values in between interpolate along the segment.
+ * @category Utils
+ * @param a The start point.
+ * @param b The end point.
+ * @param t The interpolation factor.
+ * @returns The interpolated point.
+ */
+export function interpolatePoint(a: Point, b: Point, t: number): Point {
+ 'worklet';
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+}
+
/**
* Helper function to scale a 2D point based on resize mode and resolution
* changes.
diff --git a/packages/react-native-executorch/src/extensions/cv/ops/quad.ts b/packages/react-native-executorch/src/extensions/cv/ops/quad.ts
new file mode 100644
index 0000000000..c99eb1d8c7
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/ops/quad.ts
@@ -0,0 +1,190 @@
+import { RnExecuTorchError } from '../../../core/error';
+import { distance, interpolatePoint, scalePoint, type Point } from './points';
+import type { BoundingBox, BoxFormat } from './boxes';
+
+/**
+ * An oriented quadrilateral in pixel space: the four corners ordered top-left,
+ * top-right, bottom-right, bottom-left. Orientation lives in the corners
+ * themselves.
+ * @category Types
+ */
+export type Quad = readonly Point[];
+
+/**
+ * Computes the axis-aligned bounding box enclosing a set of points, in the
+ * requested box format. Returns a zero box for empty input.
+ * @category Typescript API
+ * @typeParam F Bounding box coordinate format.
+ * @param points The points to enclose.
+ * @param format The coordinate format of the returned box.
+ * @returns The enclosing {@link BoundingBox} in `format`.
+ */
+export function boundsOfPoints(
+ points: readonly Point[],
+ format: F
+): BoundingBox {
+ 'worklet';
+ let xmin = Infinity;
+ let ymin = Infinity;
+ let xmax = -Infinity;
+ let ymax = -Infinity;
+ for (const p of points) {
+ if (p.x < xmin) xmin = p.x;
+ if (p.y < ymin) ymin = p.y;
+ if (p.x > xmax) xmax = p.x;
+ if (p.y > ymax) ymax = p.y;
+ }
+ if (points.length === 0) {
+ xmin = ymin = xmax = ymax = 0;
+ }
+ switch (format) {
+ case 'xyxy':
+ return { format: 'xyxy', xmin, ymin, xmax, ymax } as BoundingBox;
+ case 'xywh':
+ return { format: 'xywh', xmin, ymin, w: xmax - xmin, h: ymax - ymin } as BoundingBox;
+ case 'cxcywh':
+ return {
+ format: 'cxcywh',
+ cx: (xmin + xmax) / 2,
+ cy: (ymin + ymax) / 2,
+ w: xmax - xmin,
+ h: ymax - ymin,
+ } as BoundingBox;
+ default:
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `boundsOfPoints: unsupported box format '${format}'.`
+ );
+ }
+}
+
+/**
+ * Orders four corner points as top-left, top-right, bottom-right, bottom-left
+ * using their coordinate-sum and coordinate-difference extremes. Inputs that do
+ * not have exactly four points are returned unchanged.
+ * @category Typescript API
+ * @param points The four unordered corners.
+ * @returns The corners ordered TL, TR, BR, BL.
+ */
+export function orderQuad(points: readonly Point[]): Quad {
+ 'worklet';
+ if (points.length !== 4) {
+ return [...points];
+ }
+ // TL/BR are the corners with the min/max coordinate sum; TR/BL the min/max
+ // difference (y − x). indexOfMin/Max break ties on the lowest index.
+ const sum = points.map((p) => p.x + p.y);
+ const diff = points.map((p) => p.y - p.x);
+ const indexOfMin = (a: number[]) => a.indexOf(Math.min(...a));
+ const indexOfMax = (a: number[]) => a.indexOf(Math.max(...a));
+ const corners = [indexOfMin(sum), indexOfMin(diff), indexOfMax(sum), indexOfMax(diff)]; // TL, TR, BR, BL
+ // Degenerate quads (duplicate or collinear corners) can map two roles to the
+ // same point; the heuristic is meaningless there, so return the points
+ // unchanged and let the resulting near-zero-size box be dropped downstream.
+ if (new Set(corners).size !== 4) {
+ return [...points];
+ }
+ return corners.map((i) => points[i]!);
+}
+
+/**
+ * Computes the width and height (in pixels) of an ordered TL,TR,BR,BL quad, taking
+ * the longer of each pair of opposite sides.
+ * @category Typescript API
+ * @param ordered The quad corners ordered TL, TR, BR, BL.
+ * @returns The quad's width and height in pixels.
+ */
+export function quadSize(ordered: Quad): { width: number; height: number } {
+ 'worklet';
+ const [tl, tr, br, bl] = ordered as [Point, Point, Point, Point];
+ const width = Math.max(distance(tl, tr), distance(bl, br));
+ const height = Math.max(distance(tl, bl), distance(tr, br));
+ return { width, height };
+}
+
+/**
+ * Maps a quad expressed in a resized (letterboxed) frame back to the original
+ * image frame, clamping the result to the image bounds.
+ * @category Typescript API
+ * @param quad The quad in the resized frame.
+ * @param fromWidth The width of the resized frame the quad is expressed in.
+ * @param fromHeight The height of the resized frame the quad is expressed in.
+ * @param toWidth The original image width.
+ * @param toHeight The original image height.
+ * @returns The four corners in original image pixels.
+ */
+export function mapQuadToImage(
+ quad: Quad,
+ fromWidth: number,
+ fromHeight: number,
+ toWidth: number,
+ toHeight: number
+): Quad {
+ 'worklet';
+ return quad.map((p) => {
+ const m = scalePoint(p, {
+ from: { width: fromWidth, height: fromHeight },
+ to: { width: toWidth, height: toHeight },
+ resizeMode: 'letterbox',
+ });
+ return { x: Math.max(0, Math.min(m.x, toWidth)), y: Math.max(0, Math.min(m.y, toHeight)) };
+ });
+}
+
+/**
+ * Splits an ordered TL,TR,BR,BL quad into `parts` equal horizontal segments
+ * (each an ordered quad), left to right. `parts <= 1` returns the quad
+ * unchanged.
+ * @category Typescript API
+ * @param ordered The quad corners ordered TL, TR, BR, BL.
+ * @param parts The number of equal horizontal segments to split into.
+ * @returns The segments as ordered TL,TR,BR,BL quads, left to right.
+ */
+export function splitWideQuad(ordered: Quad, parts: number): Quad[] {
+ 'worklet';
+ if (parts <= 1) {
+ return [ordered];
+ }
+ const [tl, tr, br, bl] = ordered as [Point, Point, Point, Point];
+ const out: Quad[] = [];
+ for (let i = 0; i < parts; i++) {
+ const t0 = i / parts;
+ const t1 = (i + 1) / parts;
+ out.push([
+ interpolatePoint(tl, tr, t0),
+ interpolatePoint(tl, tr, t1),
+ interpolatePoint(bl, br, t1),
+ interpolatePoint(bl, br, t0),
+ ]);
+ }
+ return out;
+}
+
+/**
+ * Builds oriented quads from a detector's flat output array — 8 numbers per box:
+ * `x0,y0,..,x3,y3`.
+ * @category Typescript API
+ * @param flat The flat number array from a native detector decode.
+ * @returns The parsed quads.
+ * @throws {RnExecuTorchError} With code `INVALID_ARGUMENT` if `flat` is not a
+ * multiple of 8 values.
+ */
+export function quadsFromFlat(flat: ArrayLike): Quad[] {
+ 'worklet';
+ if (flat.length % 8 !== 0) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `quadsFromFlat: expected a multiple of 8 values, got ${flat.length}.`
+ );
+ }
+ const quads: Quad[] = [];
+ for (let i = 0; i < flat.length; i += 8) {
+ quads.push([
+ { x: flat[i]!, y: flat[i + 1]! },
+ { x: flat[i + 2]!, y: flat[i + 3]! },
+ { x: flat[i + 4]!, y: flat[i + 5]! },
+ { x: flat[i + 6]!, y: flat[i + 7]! },
+ ]);
+ }
+ return quads;
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/detectors.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/detectors.ts
new file mode 100644
index 0000000000..58728d61c9
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/detectors.ts
@@ -0,0 +1,115 @@
+// Built-in detector box-extraction strategies: each turns one architecture's
+// native detect output into quads. The pipeline is model-agnostic — it just
+// calls OcrModelOptions.extractBoxes.
+
+import type { Tensor } from '../../../../core/tensor';
+import { f32, type ParamSpec, type SymbolicDim } from '../../../../core/schema';
+import { quadsFromFlat, type Quad } from '../../ops/quad';
+import { extractDbnetTextBoxes } from '../../utils/ocrUtils';
+
+/**
+ * A detector's box-extraction strategy. Plug a new detector architecture into the
+ * OCR pipeline by supplying an object of this type (a built-in below, or your
+ * own).
+ * @category Types
+ */
+export type TextBoxExtractor = {
+ /**
+ * The detector input dims (W and H) must be a multiple of this — the pipeline
+ * snaps the letterbox size up to it before allocating, so `outputShapes` never
+ * receives a size it must reject. Default 1 (no alignment); a detector whose
+ * output is a half-resolution map needs 2.
+ */
+ readonly inputAlignment?: number;
+ /**
+ * The allowed spec of the `detect` outputs this strategy decodes, used to
+ * validate the model at load. Dimensions that track the detector input size
+ * must be built with the supplied `dim` factory — the pipeline passes
+ * `DynamicDim` when validating a size-varying detector and `StaticDim` when
+ * validating a fixed-size one, so one declaration covers both.
+ * @param dim Symbol factory for every input-size-dependent dimension.
+ * @returns One param spec per `detect` output, in order.
+ */
+ readonly detectOutputSpec: (dim: (symbol: string) => SymbolicDim) => ParamSpec[];
+ /**
+ * The `float32` output tensor shapes the `detect` method produces for a given
+ * detector input size, so the caller can pre-allocate them. One shape per
+ * `detect` output, in order. Runs per pass, so it MUST be a worklet.
+ * @param inputSize The detector input size the image was letterboxed to.
+ * @returns One shape per `detect` output tensor.
+ */
+ readonly outputShapes: (inputSize: {
+ readonly width: number;
+ readonly height: number;
+ }) => number[][];
+ /**
+ * Turns the model's `detect` output tensors into oriented {@link Quad}s in
+ * detector-input pixel space. Runs per pass, so it MUST be a worklet.
+ * @param outputs The model's `detect` output tensors, in order.
+ * @param inputSize The detector input size the image was letterboxed to.
+ * @returns Oriented quads (TL, TR, BR, BL) in detector-input pixel space.
+ */
+ readonly extract: (
+ outputs: readonly Tensor[],
+ inputSize: { readonly width: number; readonly height: number }
+ ) => Quad[];
+};
+
+/**
+ * DBNet decode thresholds — tuning knobs for {@link makeDbnetExtractBoxes}. All
+ * optional; the defaults suit the built-in DBNet models.
+ * @category Types
+ */
+export type DbnetExtractOptions = {
+ /** Binarization threshold on the probability map. Default 0.3. */
+ readonly binThreshold?: number;
+ /** Minimum mean box score to keep a candidate. Default 0.6. */
+ readonly boxThreshold?: number;
+ /** How far to expand (unclip) each shrunk box. Default 1.5. */
+ readonly unclipRatio?: number;
+ /** Discard boxes with a side smaller than this (px). Default 3. */
+ readonly minBoxSide?: number;
+ /** Cap on contour candidates scored per map. Default 1000. */
+ readonly maxCandidates?: number;
+};
+
+/**
+ * Builds a DBNet box-extraction strategy: thresholds and unclips the full-res
+ * post-sigmoid probability map (`outputs[0]` = `[1, 1, H, W]`) into oriented
+ * quads. No char-level mode. {@link dbnetExtractBoxes} is this with defaults.
+ * @param options Threshold overrides; omit for the built-in defaults.
+ * @returns A {@link TextBoxExtractor} bound to those thresholds.
+ * @category Typescript API
+ */
+export function makeDbnetExtractBoxes(options: DbnetExtractOptions = {}): TextBoxExtractor {
+ const binThreshold = options.binThreshold ?? 0.3;
+ const boxThreshold = options.boxThreshold ?? 0.6;
+ const unclipRatio = options.unclipRatio ?? 1.5;
+ const minBoxSide = options.minBoxSide ?? 3;
+ const maxCandidates = options.maxCandidates ?? 1000;
+ return {
+ // Full-resolution NCHW probability map: [1, 1, H, W].
+ detectOutputSpec: (dim) => [f32(1, 1, dim('detOutH'), dim('detOutW'))],
+ outputShapes: ({ width, height }) => {
+ 'worklet';
+ return [[1, 1, height, width]];
+ },
+ extract: (outputs) => {
+ 'worklet';
+ const flat = extractDbnetTextBoxes(outputs[0]!, {
+ binThreshold,
+ boxThreshold,
+ unclipRatio,
+ minBoxSide,
+ maxCandidates,
+ });
+ return quadsFromFlat(flat);
+ },
+ };
+}
+
+/**
+ * DBNet box-extraction strategy with default thresholds.
+ * @category Typescript API
+ */
+export const dbnetExtractBoxes: TextBoxExtractor = makeDbnetExtractBoxes();
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/engine.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/engine.ts
new file mode 100644
index 0000000000..dd62d6b408
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/engine.ts
@@ -0,0 +1,623 @@
+// OCR detect → recognize engine, internal to the OCR task. Worklet source order
+// matters: a referenced worklet must be defined above its callers.
+
+import { tensor, type Tensor } from '../../../../core/tensor';
+import { RnExecuTorchError } from '../../../../core/error';
+import {
+ validateSpec,
+ method,
+ constr,
+ f32,
+ StaticDim,
+ DynamicDim,
+ type ConcreteDim,
+ type DimRef,
+ type LinearConstraint,
+ type ModelSpec,
+ type Range,
+ type SymbolicDim,
+ type TensorSpec,
+} from '../../../../core/schema';
+import type { Model } from '../../../../core/model';
+
+import type { ImageBuffer, ImageFormat } from '../../image';
+import {
+ resize,
+ cvtColor,
+ rectifyQuad,
+ toChannelsFirst,
+ normalize,
+ FORMAT_CHANNELS,
+ FORMAT_CONVERSION,
+ type NormalizeOptions,
+} from '../../ops/image';
+import { mapQuadToImage, orderQuad, quadSize, splitWideQuad, type Quad } from '../../ops/quad';
+import { ctcGreedyDecode } from '../../utils/ocrUtils';
+import type { TextBoxExtractor } from './detectors';
+import { orderByReadingOrder } from './geometry';
+
+/**
+ * A single recognized text region. `quad` is the oriented (TL,TR,BR,BL) box in
+ * original image pixels (axis-aligned bounds via `boundsOfPoints(quad,'xyxy')`).
+ * @category Types
+ */
+export type OcrDetection = {
+ readonly text: string;
+ readonly confidence: number;
+ readonly quad: Quad;
+};
+
+// ─── Input size resolution ───────────────────────────────────────────────────
+
+// A dimension whose legal values are sparse (an `enum`) can jump a long way from
+// one value to the next, so an image at most this fraction larger than a legal
+// value still snaps DOWN to it rather than forcing the next, much bigger one.
+// A `range` steps densely, so it always snaps up.
+const SNAP_DOWN_TOLERANCE = 0.1;
+
+// The inclusive [min, max] extent of a dimension's domain.
+function dimExtent(dim: ConcreteDim): readonly [number, number] {
+ 'worklet';
+ if (dim.kind === 'constant') {
+ return [dim.value, dim.value];
+ }
+ if (dim.kind === 'range') {
+ return [dim.range.min, dim.range.max];
+ }
+ let min = Infinity;
+ let max = -Infinity;
+ for (const choice of dim.choices) {
+ min = Math.min(min, choice);
+ max = Math.max(max, choice);
+ }
+ return [min, max];
+}
+
+// Smallest value on `range`'s lattice that is >= `size` and satisfies `isLegal`,
+// else the largest legal lattice point. Never shrinks content; only the range max
+// can.
+function snapUpRange(size: number, range: Range, isLegal: (value: number) => boolean): number {
+ 'worklet';
+ const step = Math.max(1, range.step);
+ const maxSteps = Math.floor((range.max - range.min) / step);
+ for (let k = Math.max(0, Math.ceil((size - range.min) / step)); k <= maxSteps; k++) {
+ const target = range.min + k * step;
+ if (isLegal(target)) {
+ return target;
+ }
+ }
+ // Overflow (or nothing legal above `size`): the largest legal lattice point.
+ // `range.max` itself may sit off the lattice, hence the walk down from maxSteps.
+ for (let k = maxSteps; k >= 0; k--) {
+ const target = range.min + k * step;
+ if (isLegal(target)) {
+ return target;
+ }
+ }
+ // No legal size anywhere in the range — the load-time contract check rejects
+ // this, so returning the largest lattice point here is only a safety net.
+ return range.min + maxSteps * step;
+}
+
+// Smallest legal choice >= `size` (modulo SNAP_DOWN_TOLERANCE when
+// `tolerateShrink`), else the largest legal choice.
+function snapUpEnum(
+ size: number,
+ choices: readonly number[],
+ isLegal: (value: number) => boolean,
+ tolerateShrink: boolean
+): number {
+ 'worklet';
+ const legal = choices.filter(isLegal).sort((a, b) => a - b);
+ const usable = legal.length > 0 ? legal : [...choices].sort((a, b) => a - b);
+ const floor = tolerateShrink ? size / (1 + SNAP_DOWN_TOLERANCE) : size;
+ return usable.find((choice) => choice >= floor) ?? usable[usable.length - 1]!;
+}
+
+// The legal size closest above `size` in `dim`'s domain, restricted to the values
+// `isLegal` accepts (the detector's input alignment, the recognizer's CTC timestep
+// lattice). A `constant` domain has a single value, so `size` is ignored.
+function snapDim(
+ size: number,
+ dim: ConcreteDim,
+ isLegal: (value: number) => boolean,
+ tolerateShrink = false
+): number {
+ 'worklet';
+ if (dim.kind === 'constant') {
+ return dim.value;
+ }
+ if (dim.kind === 'enum') {
+ return snapUpEnum(size, dim.choices, isLegal, tolerateShrink);
+ }
+ return snapUpRange(size, dim.range, isLegal);
+}
+
+// Whether `dim`'s domain contains any value `isLegal` accepts. Answered by running
+// the very search the run-time path uses, so the two can never disagree: `snapDim`
+// walks the whole domain when nothing above the requested size qualifies, and only
+// returns an illegal value when the domain holds none at all.
+function domainAdmits(dim: ConcreteDim, isLegal: (value: number) => boolean): boolean {
+ const [min] = dimExtent(dim);
+ return isLegal(snapDim(min, dim, isLegal));
+}
+
+// Largest legal detector input size that avoids upscaling where the domains allow
+// it (a domain whose values all exceed the image must upscale). H and W snap
+// independently — a per-dimension domain is by construction independent of the
+// other, so the legal set is their full grid.
+function resolveDetectorSize(
+ det: OcrEngine['det'],
+ imgWidth: number,
+ imgHeight: number,
+ align: number
+): { detW: number; detH: number } {
+ 'worklet';
+ const isAligned = (value: number) => {
+ 'worklet';
+ return value % align === 0;
+ };
+ const scale = Math.min(1, dimExtent(det.h)[1] / imgHeight, dimExtent(det.w)[1] / imgWidth);
+ return {
+ detW: snapDim(Math.round(imgWidth * scale), det.w, isAligned, true),
+ detH: snapDim(Math.round(imgHeight * scale), det.h, isAligned, true),
+ };
+}
+
+// The recognizer's input width and its CTC timestep count are related by the
+// `linear` runtime constraint the model declares over them: `width = pixelsPerStep
+// * timesteps + offset`. SVTR reduces the width by a plain factor (8, 0); the CRNN
+// crops one trailing timestep, so it is affine (4, 4) — inferring a width/timestep
+// ratio would be wrong for it.
+
+type CtcStride = {
+ readonly pixelsPerStep: number;
+ readonly offset: number;
+};
+
+// Where that constraint's two dimensions live in `recognize`: the input width is
+// NCHW dim 3 of the only input tensor, the timestep count is dim 1 of the only
+// output tensor.
+const REC_WIDTH_REF: DimRef = { paramSide: 'input', tensorIdx: 0, dimIdx: 3 };
+const REC_TIMESTEPS_REF: DimRef = { paramSide: 'output', tensorIdx: 0, dimIdx: 1 };
+
+// Whether a width sits on the timestep lattice — i.e. yields a whole number of
+// CTC timesteps, so `recognizeCanvas` can pre-size the probs tensor for it.
+function onCtcLattice(width: number, stride: CtcStride): boolean {
+ 'worklet';
+ return (width - stride.offset) % stride.pixelsPerStep === 0;
+}
+
+// The CTC timestep count a legal width produces.
+function ctcTimesteps(width: number, stride: CtcStride): number {
+ 'worklet';
+ return (width - stride.offset) / stride.pixelsPerStep;
+}
+
+// Recognizer input width: the smallest legal width >= the desired content, so
+// snapping never squishes (only the domain max can). Restricted to widths sitting
+// on the CTC timestep lattice, so the probs output can always be pre-sized.
+function resolveRecWidth(width: ConcreteDim, desiredWidth: number, stride: CtcStride): number {
+ 'worklet';
+ return snapDim(desiredWidth, width, (value) => {
+ 'worklet';
+ return onCtcLattice(value, stride);
+ });
+}
+
+// ─── CTC decode ──────────────────────────────────────────────────────────────
+
+// Greedy-CTC decode of `[..,T,V]` probs: a native op returns per-timestep
+// [idx, value, ...]; drop blank (idx 0) + consecutive repeats, mean-conf the rest.
+function greedyCtcDecode(
+ probs: Tensor,
+ charset: readonly string[]
+): { text: string; conf: number } {
+ 'worklet';
+ const flat = ctcGreedyDecode(probs);
+ let text = '';
+ let last = -1;
+ let probabilitySum = 0;
+ let charCounter = 0;
+ for (let i = 0; i < flat.length; i += 2) {
+ const idx = flat[i]!;
+ if (idx >= 1) {
+ probabilitySum += flat[i + 1]!;
+ charCounter++;
+ if (idx !== last && idx < charset.length) {
+ text += charset[idx]!;
+ }
+ }
+ last = idx;
+ }
+ return { text, conf: charCounter === 0 ? 0 : probabilitySum / charCounter };
+}
+
+// ─── Execution ───────────────────────────────────────────────────────────────
+
+// A line at most this factor wider than the recognizer max is squished into it;
+// anything wider is split and read piecewise (a hard clamp is unreadable).
+const WIDE_SQUISH_TOLERANCE = 1.15;
+
+// Degenerate-quad guard, in ORIGINAL IMAGE pixels and applied to every
+// detector's output after it is mapped back from detector space. Distinct from
+// DBNet's `minBoxSide`, which drops contour candidates in detector-input pixels
+// before the unclip step — that one is a decode threshold, this one is the
+// last check before a quad is warped onto the recognizer canvas.
+const MIN_RECOGNIZABLE_SIDE = 3;
+
+// The recognizer input is contractually RGB (enforced by resolveOcrContract).
+const REC_CHANNELS = 3;
+
+// Detects text boxes in `src`, returning quads in `src` pixel space.
+function detectQuads(
+ engine: OcrEngine,
+ src: Tensor,
+ width: number,
+ height: number,
+ format: ImageFormat
+): Quad[] {
+ 'worklet';
+ const numChannels = FORMAT_CHANNELS[format];
+ const toRgbCode = FORMAT_CONVERSION[format].rgb;
+ const align = Math.max(1, engine.extractBoxes.inputAlignment ?? 1);
+ const { detW, detH } = resolveDetectorSize(engine.det, width, height, align);
+ // Resolve output shapes before allocating — the extractor may throw on a
+ // size it cannot decode, and nothing must leak on that path.
+ const outputShapes = engine.extractBoxes.outputShapes({ width: detW, height: detH });
+ const tResize = tensor('uint8', [detH, detW, numChannels]);
+ const tColor = toRgbCode !== null ? tensor('uint8', [detH, detW, 3]) : null;
+ const tCF = tensor('uint8', [3, detH, detW]);
+ const tNorm = tensor('float32', [3, detH, detW]);
+ const tInput = tensor('float32', [1, 3, detH, detW]);
+ const tOutputs = outputShapes.map((shape) => tensor('float32', shape));
+ try {
+ src
+ .through(resize, tResize, { mode: 'letterbox', interpolation: 'area', padValue: 0 })
+ .throughIf(tColor !== null, cvtColor, tColor!, toRgbCode!)
+ .through(toChannelsFirst, tCF)
+ .through(normalize, tNorm, engine.det.norm)
+ .copyTo(tInput);
+
+ engine.model.execute('detect', [tInput], tOutputs);
+ const quads = engine.extractBoxes.extract(tOutputs, { width: detW, height: detH });
+ return quads.map((q) => mapQuadToImage(q, detW, detH, width, height));
+ } finally {
+ tResize.dispose();
+ tColor?.dispose();
+ tCF.dispose();
+ tNorm.dispose();
+ tInput.dispose();
+ for (const tOut of tOutputs) {
+ tOut.dispose();
+ }
+ }
+}
+
+// Recognizes a canvas already sized to a legal recognizer width — custom
+// `decode` if provided, else greedy CTC.
+function recognizeCanvas(
+ engine: OcrEngine,
+ tCanvas: Tensor,
+ snappedW: number
+): { text: string; conf: number } {
+ 'worklet';
+ const { recH, stride, vocab, norm } = engine.rec;
+ const tCF = tensor('uint8', [REC_CHANNELS, recH, snappedW]);
+ const tNorm = tensor('float32', [REC_CHANNELS, recH, snappedW]);
+ const tInput = tensor('float32', [1, REC_CHANNELS, recH, snappedW]);
+ // The width the caller snapped to sits on the timestep lattice by construction,
+ // so the dynamically-sized probs output can be pre-allocated exactly.
+ const timesteps = ctcTimesteps(snappedW, stride);
+ const tProbs = tensor('float32', [1, timesteps, vocab]);
+ try {
+ tCanvas.through(toChannelsFirst, tCF).through(normalize, tNorm, norm).copyTo(tInput);
+ engine.model.execute('recognize', [tInput], [tProbs]);
+ if (engine.decode) {
+ const r = engine.decode(tProbs, engine.charset);
+ return { text: r.text, conf: r.confidence };
+ }
+ return greedyCtcDecode(tProbs, engine.charset);
+ } finally {
+ tCF.dispose();
+ tNorm.dispose();
+ tInput.dispose();
+ tProbs.dispose();
+ }
+}
+
+// Recognizes one quad that fits the recognizer width (a mild squish is fine);
+// wider lines go through recognizeQuad instead.
+function recognizeNarrowQuad(
+ engine: OcrEngine,
+ src: Tensor,
+ quad: Quad
+): { text: string; conf: number } {
+ 'worklet';
+ const { recH, maxW, width, stride, padValue } = engine.rec;
+ const size = quadSize(quad);
+ // Aspect-preserving width of the content at recognizer height (>= 1 px).
+ const aspectWidth = Math.max(1, Math.round((recH * size.width) / Math.max(1, size.height)));
+ const contentW = Math.min(aspectWidth, maxW);
+ const snappedW = resolveRecWidth(width, contentW, stride);
+ const tCanvas = tensor('uint8', [recH, snappedW, REC_CHANNELS]);
+ try {
+ rectifyQuad(src, tCanvas, quad, {
+ contentWidth: contentW,
+ align: 'left',
+ padValue,
+ });
+ return recognizeCanvas(engine, tCanvas, snappedW);
+ } finally {
+ tCanvas.dispose();
+ }
+}
+
+// Recognizes one ordered (TL,TR,BR,BL) quad. A line wider than the tolerance is
+// split into segments read piecewise; confidence is the length-weighted mean.
+function recognizeQuad(engine: OcrEngine, src: Tensor, quad: Quad): { text: string; conf: number } {
+ 'worklet';
+ const { recH, maxW } = engine.rec;
+ const size = quadSize(quad);
+
+ const desiredW = Math.max(1, Math.round((recH * size.width) / Math.max(1, size.height)));
+ if (desiredW <= maxW * WIDE_SQUISH_TOLERANCE) {
+ return recognizeNarrowQuad(engine, src, quad);
+ }
+ // Known limitation: segments abut with no overlap, so a glyph straddling a cut
+ // can be mangled/dropped at the seam. An overlap margin + de-dup would fix it;
+ // acceptable for now since a line this wide is the rare case.
+ const segments = splitWideQuad(quad, Math.ceil(desiredW / maxW));
+ let text = '';
+ let weightedConf = 0;
+ let weight = 0;
+ for (const segment of segments) {
+ const r = recognizeNarrowQuad(engine, src, segment);
+ if (r.text.length > 0) {
+ text += r.text;
+ weightedConf += r.conf * r.text.length;
+ weight += r.text.length;
+ }
+ }
+ return { text, conf: weight === 0 ? 0 : weightedConf / weight };
+}
+
+// ─── OCR pass ────────────────────────────────────────────────────────────────
+
+/**
+ * The resolved, model-level state one detect → recognize pass needs — the model
+ * itself, the validated detect/recognize contract, the CTC charset, and the run
+ * options. Built once by `createOcr`, reused across every page and per-region call.
+ */
+export type OcrEngine = {
+ readonly model: Model;
+ readonly extractBoxes: TextBoxExtractor;
+ readonly det: {
+ // The exported domains of the detector input's H and W (NCHW dims 2 and 3).
+ readonly h: ConcreteDim;
+ readonly w: ConcreteDim;
+ readonly norm: NormalizeOptions;
+ };
+ readonly rec: {
+ readonly recH: number;
+ readonly maxW: number;
+ // The exported domain of the recognizer input's width (NCHW dim 3).
+ readonly width: ConcreteDim;
+ readonly stride: CtcStride;
+ // Recognizer output vocab size V (charset length incl. the blank).
+ readonly vocab: number;
+ readonly norm: NormalizeOptions;
+ readonly padValue: number;
+ };
+ readonly charset: string[];
+ readonly minConfidence: number;
+ readonly decode?: (
+ probs: Tensor,
+ charset: readonly string[]
+ ) => { readonly text: string; readonly confidence: number };
+};
+
+/**
+ * Runs one detect → recognize → reading-order pass over an {@link ImageBuffer}:
+ * detect text quads on the whole page, warp each to the recognizer canvas, read
+ * it, and sort the results into reading order.
+ * @param engine The resolved OCR engine (contract + model + options).
+ * @param input The page to read.
+ * @returns The recognized regions, in reading order.
+ */
+export function runOcrPass(engine: OcrEngine, input: ImageBuffer): OcrDetection[] {
+ 'worklet';
+ const { width, height, format } = input;
+ const numChannels = FORMAT_CHANNELS[format];
+ const toRgbCode = FORMAT_CONVERSION[format].rgb;
+ const tPage = tensor('uint8', [height, width, numChannels]);
+ let tRecImage: Tensor | null = null;
+ try {
+ tPage.setData(input.data);
+ const quads = detectQuads(engine, tPage, width, height, format);
+ if (quads.length === 0) {
+ return [];
+ }
+
+ let recSrc: Tensor = tPage;
+ if (toRgbCode !== null) {
+ tRecImage = tensor('uint8', [height, width, REC_CHANNELS]);
+ recSrc = cvtColor(tPage, tRecImage, toRgbCode);
+ }
+
+ const detections: OcrDetection[] = [];
+ for (const quad of quads) {
+ const orderedQuad = orderQuad(quad);
+ const size = quadSize(orderedQuad);
+ if (size.width < MIN_RECOGNIZABLE_SIDE || size.height < MIN_RECOGNIZABLE_SIDE) {
+ continue;
+ }
+ const { text, conf } = recognizeQuad(engine, recSrc, orderedQuad);
+ if (text.length > 0 && conf >= engine.minConfidence) {
+ detections.push({ text, confidence: conf, quad: orderedQuad });
+ }
+ }
+ return orderByReadingOrder(detections);
+ } finally {
+ tRecImage?.dispose();
+ tPage.dispose();
+ }
+}
+// ─── Contract ────────────────────────────────────────────────────────────────
+
+type DimFactory = (symbol: string) => SymbolicDim;
+
+function refsEqual(a: DimRef, b: DimRef): boolean {
+ return a.paramSide === b.paramSide && a.tensorIdx === b.tensorIdx && a.dimIdx === b.dimIdx;
+}
+
+// Reads the width↔timesteps relation the recognizer declares. It is discovered
+// rather than assumed because it differs per architecture; `resolveOcrContract`
+// feeds the result back into the allowed spec so `validateSpec` still matches the
+// constraint 1-to-1 and rejects any others the model may declare.
+function readCtcStride(schema: ModelSpec): CtcStride {
+ const constraints = schema.recognize?.runtimeConstraints ?? [];
+ const linear = constraints.find(
+ (c): c is LinearConstraint =>
+ c.kind === 'linear' &&
+ refsEqual(c.dimLhs, REC_WIDTH_REF) &&
+ refsEqual(c.dimRhs, REC_TIMESTEPS_REF)
+ );
+ if (linear === undefined) {
+ throw RnExecuTorchError(
+ 'SCHEMA_MISMATCH',
+ 'resolveOcrContract: the recognizer must declare a linear runtime constraint relating ' +
+ 'its input width (input 0, dim 3) to its CTC timesteps (output 0, dim 1); the pipeline ' +
+ 'needs it to pre-size the probs output.'
+ );
+ }
+ const [pixelsPerStep, offset] = linear.coefficients;
+ if (pixelsPerStep <= 0) {
+ throw RnExecuTorchError(
+ 'SCHEMA_MISMATCH',
+ `resolveOcrContract: the recognizer's width-per-timestep coefficient must be positive, ` +
+ `but the model declares ${pixelsPerStep}.`
+ );
+ }
+ return { pixelsPerStep, offset };
+}
+
+// The exported domain of one tensor dimension. `validateSpec` has already proved
+// the method exists with this rank, so the lookup cannot miss.
+function exportedDim(schema: ModelSpec, methodName: string, ref: DimRef): ConcreteDim {
+ const methodSpec = schema[methodName]!;
+ const params = ref.paramSide === 'input' ? methodSpec.inputs : methodSpec.outputs;
+ const tensors = params.filter((p): p is TensorSpec => p.kind === 'Tensor');
+ return tensors[ref.tensorIdx]!.shape[ref.dimIdx]!;
+}
+
+/**
+ * Validates the model's `detect` (RGB `[1,3,H,W]` in, extractor-defined outs) and
+ * `recognize` (RGB `[1,3,H,W]` in, `[1,T,V]` probs out; only the width may vary)
+ * methods against the exported schema, resolves their legal input sizes, and
+ * builds the CTC charset. Runs at construction; throws on any contract mismatch.
+ * @param model The loaded fused detect/recognize model.
+ * @param charsetOption The recognizer charset (string = one codepoint per index;
+ * array = taken verbatim).
+ * @param extractBoxes The detector's box extractor — it declares the `detect`
+ * output layout it decodes, and its alignment is checked against the model's
+ * legal input sizes.
+ * @returns The model-derived detect/recognize contract ({@link OcrEngine} minus
+ * the run options `createOcr` adds).
+ */
+export function resolveOcrContract(
+ model: Model,
+ charsetOption: string | readonly string[],
+ extractBoxes: TextBoxExtractor
+): {
+ det: Omit;
+ rec: Omit;
+ charset: string[];
+} {
+ const schema = model.schema;
+ const stride = readCtcStride(schema);
+
+ // Either method may be exported at a fixed size or with varying sizes, and the
+ // two choices are independent (a size-varying detector can pair with a
+ // fixed-width recognizer), so all four combinations are offered as variants. A
+ // `static` symbol binds only to a constant dimension and a `dynamic` one only to
+ // a range or enum, which is exactly what separates them.
+ const variant = (detDim: DimFactory, recDim: DimFactory) => ({
+ ...method(
+ 'detect',
+ [f32(1, 3, detDim('detH'), detDim('detW'))],
+ extractBoxes.detectOutputSpec(detDim)
+ ),
+ ...method(
+ 'recognize',
+ [f32(1, REC_CHANNELS, 'recH', recDim('recW'))],
+ [f32(1, recDim('recT'), 'vocab')],
+ [constr.linear(REC_WIDTH_REF, REC_TIMESTEPS_REF, stride.pixelsPerStep, stride.offset)]
+ ),
+ });
+
+ const { dims } = validateSpec(schema, {
+ dynamicDetectDynamicRec: variant(DynamicDim, DynamicDim),
+ dynamicDetectFixedRec: variant(DynamicDim, StaticDim),
+ fixedDetectDynamicRec: variant(StaticDim, DynamicDim),
+ fixedDetectFixedRec: variant(StaticDim, StaticDim),
+ });
+ const [recH, vocabSize] = dims.constant('recH', 'vocab');
+
+ const det = {
+ h: exportedDim(schema, 'detect', { paramSide: 'input', tensorIdx: 0, dimIdx: 2 }),
+ w: exportedDim(schema, 'detect', { paramSide: 'input', tensorIdx: 0, dimIdx: 3 }),
+ };
+ const width = exportedDim(schema, 'recognize', REC_WIDTH_REF);
+
+ // Reject at load, not per run, a detector whose legal sizes the extractor can't
+ // decode: `resolveDetectorSize` only ever returns a size the domain admits, so
+ // if no such size is a multiple of the extractor's alignment, every run fails.
+ const detAlign = Math.max(1, extractBoxes.inputAlignment ?? 1);
+ if (detAlign > 1) {
+ for (const [axis, dim] of [
+ ['height', det.h],
+ ['width', det.w],
+ ] as const) {
+ if (!domainAdmits(dim, (value) => value % detAlign === 0)) {
+ throw RnExecuTorchError(
+ 'SCHEMA_MISMATCH',
+ `resolveOcrContract: no legal detector input ${axis} is a multiple of the extractor's ` +
+ `required alignment (${detAlign}), so decoding would fail on every run.`
+ );
+ }
+ }
+ }
+
+ // Likewise, `recognizeCanvas` pre-allocates the probs tensor as
+ // [1, ctcTimesteps(width), V], so the width the runtime picks must land on the
+ // timestep lattice — the domain has to admit at least one such width.
+ if (!domainAdmits(width, (value) => onCtcLattice(value, stride))) {
+ throw RnExecuTorchError(
+ 'SCHEMA_MISMATCH',
+ `resolveOcrContract: no legal recognizer width yields a whole number of CTC timesteps ` +
+ `(width = ${stride.pixelsPerStep} * timesteps + ${stride.offset}), so the probs tensor ` +
+ `can't be pre-sized.`
+ );
+ }
+
+ // CTC lookup: index 0 is the blank, then the model's characters (a string
+ // splits into codepoints; an array is taken verbatim, preserving ligatures).
+ const charset = [
+ '[blank]',
+ ...(typeof charsetOption === 'string' ? Array.from(charsetOption) : charsetOption),
+ ];
+ if (charset.length !== vocabSize) {
+ throw RnExecuTorchError(
+ 'SCHEMA_MISMATCH',
+ `resolveOcrContract: charset size (${charset.length}, incl. blank) must match the ` +
+ `recognizer output vocab (${vocabSize}).`
+ );
+ }
+
+ return {
+ det,
+ rec: { recH, maxW: dimExtent(width)[1], width, stride, vocab: vocabSize },
+ charset,
+ };
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/geometry.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/geometry.ts
new file mode 100644
index 0000000000..84566ed421
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/geometry.ts
@@ -0,0 +1,109 @@
+// OCR box geometry: reading order for detected quads. Pure functions — no
+// tensors, no model. Worklet source order matters (callee above caller).
+
+import { boundsOfPoints, type Quad } from '../../ops/quad';
+
+// A gutter must be at least this fraction of the content width to split columns;
+// two boxes share a line when their vertical extents overlap by at least this
+// fraction of the shorter box's height.
+const COLUMN_GAP_FRACTION = 0.06;
+const LINE_OVERLAP_FRACTION = 0.3;
+
+// Reorders `{quad}` items the way a human reads the page: leftmost column
+// first (top to bottom), then the next column. Detectors emit boxes in
+// arbitrary order, so detections and assembled blocks are ordered through this.
+export function orderByReadingOrder(items: T[]): T[] {
+ 'worklet';
+ const count = items.length;
+ if (count <= 1) {
+ return items;
+ }
+
+ const boxes = items.map((it) => boundsOfPoints(it.quad, 'xyxy'));
+
+ // A vertical gap between boxes only counts as a column gutter when it is
+ // reasonably wide relative to the page content (else word spacing would
+ // split columns everywhere).
+ let minX = Infinity;
+ let maxX = -Infinity;
+ for (const box of boxes) {
+ if (box.xmin < minX) minX = box.xmin;
+ if (box.xmax > maxX) maxX = box.xmax;
+ }
+ const minGap = COLUMN_GAP_FRACTION * Math.max(1, maxX - minX);
+
+ // Find the gutters: walk every box's left/right edge in x order, keeping a
+ // running count of how many boxes overlap the current x. Count 0 means no
+ // box occupies this x — when such an empty stretch is wider than minGap,
+ // cut a column boundary at its midpoint.
+ const edges: { x: number; delta: number }[] = [];
+ for (const box of boxes) {
+ edges.push({ x: box.xmin, delta: 1 });
+ edges.push({ x: box.xmax, delta: -1 });
+ }
+ // At the same x, process a box's left edge before another's right edge —
+ // two boxes that exactly touch must not look like an empty stretch.
+ edges.sort((a, b) => a.x - b.x || b.delta - a.delta);
+ const cuts: number[] = [];
+ let coverage = 0;
+ // Seed to the first (leftmost) edge, not 0, so a page whose leftmost box starts
+ // well inside a left margin doesn't read that margin as an empty column gutter.
+ let gutterStart = edges.length > 0 ? edges[0]!.x : 0;
+ for (const edge of edges) {
+ const before = coverage;
+ coverage += edge.delta;
+ if (before > 0 && coverage === 0) {
+ gutterStart = edge.x;
+ } else if (before === 0 && coverage > 0 && edge.x - gutterStart >= minGap) {
+ cuts.push((gutterStart + edge.x) / 2);
+ }
+ }
+
+ // A box belongs to column k when exactly k cuts lie left of its center.
+ const columns: number[][] = Array.from({ length: cuts.length + 1 }, () => []);
+ for (let i = 0; i < count; i++) {
+ const centerX = (boxes[i]!.xmin + boxes[i]!.xmax) / 2;
+ let column = 0;
+ for (const cut of cuts) {
+ if (centerX > cut) column++;
+ }
+ columns[column]!.push(i);
+ }
+
+ // Inside each column: boxes whose vertical extents overlap enough sit on the
+ // same text line. Read lines top to bottom, and boxes within a line left to
+ // right.
+ const order: number[] = [];
+ for (const column of columns) {
+ column.sort((a, b) => boxes[a]!.ymin - boxes[b]!.ymin);
+ const lines: { items: number[]; ymin: number; ymax: number }[] = [];
+ for (const i of column) {
+ const box = boxes[i]!;
+ let placed = false;
+ for (const line of lines) {
+ // Overlap is measured against the SHORTER of the two heights, so a
+ // small box beside a tall one still joins its line.
+ const overlap = Math.min(line.ymax, box.ymax) - Math.max(line.ymin, box.ymin);
+ const minHeight = Math.min(line.ymax - line.ymin, box.ymax - box.ymin);
+ if (overlap >= LINE_OVERLAP_FRACTION * Math.max(1, minHeight)) {
+ line.items.push(i);
+ line.ymin = Math.min(line.ymin, box.ymin);
+ line.ymax = Math.max(line.ymax, box.ymax);
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) {
+ lines.push({ items: [i], ymin: box.ymin, ymax: box.ymax });
+ }
+ }
+ lines.sort((a, b) => a.ymin - b.ymin);
+ for (const line of lines) {
+ line.items.sort(
+ (a, b) => boxes[a]!.xmin + boxes[a]!.xmax - (boxes[b]!.xmin + boxes[b]!.xmax)
+ );
+ order.push(...line.items);
+ }
+ }
+ return order.map((i) => items[i]!);
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/tasks/ocr/ocr.ts b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/ocr.ts
new file mode 100644
index 0000000000..1a34ba9052
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/tasks/ocr/ocr.ts
@@ -0,0 +1,203 @@
+import type { WorkletRuntime } from 'react-native-worklets';
+
+import { readTextFile } from '../../../../fetcher/fetcher';
+import { loadModel } from '../../../../core/model';
+import { RnExecuTorchError } from '../../../../core/error';
+import { wrapAsync } from '../../../../core/runtime';
+import { IMAGENET_NORM } from '../../../../constants';
+
+import type { ImageBuffer } from '../../image';
+import type { NormalizeOptions } from '../../ops/image';
+import type { Tensor } from '../../../../core/tensor';
+import type { TextBoxExtractor } from './detectors';
+import { runOcrPass, resolveOcrContract, type OcrEngine, type OcrDetection } from './engine';
+
+export type { OcrDetection } from './engine';
+
+/**
+ * Model-specific OCR options. The pipeline validates the detect/recognize
+ * contract and resolves legal input sizes from the model metadata itself; these
+ * cover everything it can't infer.
+ * @category Types
+ */
+export type OcrModelOptions = {
+ /**
+ * Recognizer charset (string = one codepoint per index; array = taken
+ * verbatim), overriding {@link OcrModel.charsetPath}. Either way entry `i`
+ * labels logit `i + 1`, since logit 0 is the CTC blank. Every built-in preset
+ * ships its charset beside the model instead, so this is only needed for a
+ * custom export whose charset is not published as a file.
+ */
+ readonly charset?: string | readonly string[];
+ /** Maps raw `detect` outputs to quads: {@link dbnetExtractBoxes}. */
+ readonly extractBoxes: TextBoxExtractor;
+ /**
+ * Drop detections below this recognition confidence. Default 0. Confidence is
+ * the max probability averaged over the non-blank timesteps only (blanks
+ * dominate a padded strip, so averaging over all of them would read much
+ * lower). This requires the recognizer to export a softmaxed head (values in
+ * `[0,1]`); on raw logits it is meaningless.
+ */
+ readonly minConfidence?: number;
+ /** Detector norm on uint8 RGB. Default ImageNet; must match the model's training norm. */
+ readonly detectorNorm?: NormalizeOptions;
+ /** Recognizer norm applied after the warp. Default `(x/255−0.5)/0.5` → `[−1,1]`. */
+ readonly recognizerNorm?: NormalizeOptions;
+ /** Recognizer canvas padding fill. Default 128 (neutral gray). */
+ readonly recognizerPadValue?: number;
+ /**
+ * Custom decode replacing greedy CTC. Must be a worklet.
+ * @param probs Softmaxed recognizer output `[1, T, V]`, pre-allocated from the
+ * recognizer's width-to-timestep relation, so `T` always matches the run's
+ * input width. Owned by the pipeline — read it, do not dispose it.
+ * @param charset The resolved charset, index-aligned with the `V` axis.
+ * @returns The decoded line and its confidence in `[0, 1]`.
+ */
+ readonly decode?: (
+ probs: Tensor,
+ charset: readonly string[]
+ ) => { readonly text: string; readonly confidence: number };
+};
+
+/**
+ * Model configuration for the OCR pipeline: one fused detect/recognize PTE plus
+ * its options.
+ * @category Types
+ */
+export type OcrModel = {
+ readonly modelPath: string;
+ /**
+ * The recognizer charset published beside the model: a JSON array of strings,
+ * one per class, where `charset[i]` labels logit `i + 1` (logit 0 is the CTC
+ * blank). Resolved to a local path by the resource fetcher like `modelPath`.
+ * Ignored when `modelOpts.charset` is given; one of the two is required.
+ */
+ readonly charsetPath?: string;
+ readonly modelOpts: OcrModelOptions;
+};
+
+const RECOGNIZER_NORM: NormalizeOptions = { alpha: 1 / 127.5, beta: -1 }; // (x/255 - 0.5)/0.5 -> [-1, 1]
+const RECOGNIZER_PAD_VALUE = 128; // neutral gray
+
+// Loads the charset published beside the model. Kept off the JS bundle on
+// purpose: the multilingual sets are tens of kilobytes each, and an app that
+// never runs OCR should not carry them.
+async function readCharsetFile(charsetPath: string | undefined): Promise {
+ if (charsetPath === undefined) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ 'createOcr: the model config must supply either `charsetPath` or `modelOpts.charset`.'
+ );
+ }
+ // A read that fails means the file is missing or unreadable — the same
+ // re-fetch-the-asset case the model file itself reports as LOAD_FAILED. Only
+ // the content being wrong is the caller's argument to fix.
+ let raw: string;
+ try {
+ raw = await readTextFile(charsetPath);
+ } catch (e) {
+ throw RnExecuTorchError(
+ 'LOAD_FAILED',
+ `createOcr: could not read the charset at '${charsetPath}': ${e instanceof Error ? e.message : String(e)}`
+ );
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (e) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `createOcr: the charset at '${charsetPath}' is not valid JSON: ${e instanceof Error ? e.message : String(e)}`
+ );
+ }
+ if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `createOcr: the charset at '${charsetPath}' must be a JSON array of strings.`
+ );
+ }
+ return parsed as readonly string[];
+}
+
+/**
+ * Creates the OCR runner for detect → recognize models (PaddleOCR):
+ * one pass detects text quads on the whole page, warps each to the recognizer
+ * canvas and reads it, returning the lines in reading order.
+ * @category Typescript API
+ * @param config Model path + OCR options.
+ * @param runtime Optional worklet runtime thread.
+ * @returns A promise resolving to an object containing recognition and disposal
+ * controls.
+ * @throws {RnExecuTorchError} With code `SCHEMA_MISMATCH` if the loaded model's
+ * `detect`/`recognize` methods match none of the pipeline's spec variants.
+ * @throws {RnExecuTorchError} With code `INVALID_ARGUMENT` if the model declares
+ * a recognizer width-to-timestep relation the pipeline cannot satisfy, if the
+ * charset is shorter than the recognizer's vocabulary, or if the published
+ * charset file does not hold a JSON array of strings.
+ * @throws {RnExecuTorchError} With code `LOAD_FAILED` if the charset file cannot
+ * be read from {@link OcrModel.charsetPath}.
+ */
+export async function createOcr(
+ config: OcrModel,
+ runtime?: WorkletRuntime
+): Promise<{
+ /**
+ * Releases all allocated native resources.
+ */
+ dispose: () => void;
+
+ /**
+ * Detects and recognizes every text line in the given image.
+ * @param input The input image buffer.
+ * @returns A promise resolving to the recognized lines in reading order
+ * (leftmost column top to bottom, then the next column).
+ */
+ runOcr: (input: ImageBuffer) => Promise;
+
+ /**
+ * Synchronous version of {@link runOcr} to be executed directly on the
+ * caller or worklet thread.
+ */
+ runOcrWorklet: (input: ImageBuffer) => OcrDetection[];
+}> {
+ const { modelPath, charsetPath, modelOpts } = config;
+ // Read the charset before loading the model: it is the cheaper failure, and
+ // nothing needs disposing if the config is wrong.
+ const charset = modelOpts.charset ?? (await readCharsetFile(charsetPath));
+ const model = await wrapAsync(loadModel, runtime)(modelPath);
+
+ // Contract validation can throw; a bad config must not leak the model.
+ let engine!: OcrEngine;
+ try {
+ const contract = resolveOcrContract(model, charset, modelOpts.extractBoxes);
+ engine = {
+ model,
+ extractBoxes: modelOpts.extractBoxes,
+ det: { ...contract.det, norm: modelOpts.detectorNorm ?? IMAGENET_NORM },
+ rec: {
+ ...contract.rec,
+ norm: modelOpts.recognizerNorm ?? RECOGNIZER_NORM,
+ padValue: modelOpts.recognizerPadValue ?? RECOGNIZER_PAD_VALUE,
+ },
+ charset: contract.charset,
+ minConfidence: modelOpts.minConfidence ?? 0,
+ decode: modelOpts.decode,
+ };
+ } catch (e) {
+ model.dispose();
+ throw e;
+ }
+
+ const dispose = () => {
+ model.dispose();
+ };
+
+ const runOcrWorklet = (input: ImageBuffer): OcrDetection[] => {
+ 'worklet';
+ return runOcrPass(engine, input);
+ };
+
+ const runOcr = wrapAsync(runOcrWorklet, runtime);
+
+ return { runOcr, runOcrWorklet, dispose };
+}
diff --git a/packages/react-native-executorch/src/extensions/cv/utils/ocrUtils.ts b/packages/react-native-executorch/src/extensions/cv/utils/ocrUtils.ts
new file mode 100644
index 0000000000..cb711336c2
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/cv/utils/ocrUtils.ts
@@ -0,0 +1,51 @@
+import { rnexecutorchJsi } from '../../../native/bridge';
+import { type Tensor } from '../../../core/tensor';
+
+/**
+ * Thresholds for {@link extractDbnetTextBoxes}.
+ * @category Types
+ */
+export type DbnetDecodeOptions = {
+ /** Binarization threshold on the probability map. */
+ readonly binThreshold: number;
+ /** Minimum mean box score to keep a candidate. */
+ readonly boxThreshold: number;
+ /** How far to expand (unclip) each shrunk box. */
+ readonly unclipRatio: number;
+ /** Discard boxes with a side smaller than this, in pixels. */
+ readonly minBoxSide: number;
+ /** Cap on contour candidates scored per map. */
+ readonly maxCandidates: number;
+};
+
+/**
+ * Decodes a DBNet probability map into oriented text boxes: binarizes the map,
+ * traces contours, scores each candidate by its mean probability and unclips the
+ * survivors back to their unshrunk size.
+ * @category Typescript API
+ * @param probabilityMap The `detect` output, shape `[1, 1, H, W]`, post-sigmoid.
+ * @param options Decode thresholds.
+ * @returns A flat `Float32Array`, 8 numbers per quad: `x0, y0, x1, y1, x2, y2,
+ * x3, y3`.
+ */
+export function extractDbnetTextBoxes(
+ probabilityMap: Tensor,
+ options: DbnetDecodeOptions
+): Float32Array {
+ 'worklet';
+ return rnexecutorchJsi.cv.extractDbnetTextBoxes(probabilityMap, options) as Float32Array;
+}
+
+/**
+ * Takes the per-timestep argmax of a recognizer's `[.., T, V]` probability
+ * tensor. Blank collapsing and repeat removal stay in TypeScript, so a custom
+ * decode can reuse this and apply its own rules.
+ * @category Typescript API
+ * @param probs Softmaxed recognizer output, shape `[.., T, V]`.
+ * @returns A flat `Float32Array`, 2 numbers per timestep: the argmax index and
+ * its probability. Index 0 is the CTC blank.
+ */
+export function ctcGreedyDecode(probs: Tensor): Float32Array {
+ 'worklet';
+ return rnexecutorchJsi.cv.ctcGreedyDecode(probs) as Float32Array;
+}
diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts
index 8bbc9f963a..c2db392307 100644
--- a/packages/react-native-executorch/src/fetcher/fetcher.ts
+++ b/packages/react-native-executorch/src/fetcher/fetcher.ts
@@ -457,3 +457,18 @@ export async function download(source: T, options: DownloadOptions = {}): Pro
options.onProgress?.(1);
return substituteRemoteSources(source, resolved);
}
+
+/**
+ * Reads a UTF-8 text file off the local filesystem.
+ *
+ * The companion to {@link download}: a config that went through `download` holds
+ * local paths, and sidecar assets shipped beside a model (a charset, a vocabulary,
+ * an index table) still have to be read from one. Routing that read through here
+ * keeps the filesystem dependency in this module instead of spreading it across
+ * every task that loads a sidecar.
+ * @param path Local file path, typically one resolved by {@link download}.
+ * @returns The file contents decoded as UTF-8.
+ */
+export async function readTextFile(path: string): Promise {
+ return RNBlobUtil.fs.readFile(path, 'utf8');
+}
diff --git a/packages/react-native-executorch/src/hooks/useOcr.ts b/packages/react-native-executorch/src/hooks/useOcr.ts
new file mode 100644
index 0000000000..e967e499e4
--- /dev/null
+++ b/packages/react-native-executorch/src/hooks/useOcr.ts
@@ -0,0 +1,28 @@
+import { createOcr, type OcrModel } from '../extensions/cv/tasks/ocr/ocr';
+import { useResourceDownload, type ResourceOptions } from './useResourceDownload';
+import { useModel } from './useModel';
+
+/**
+ * React hook for the OCR pipeline (PaddleOCR). It downloads and loads
+ * the model, tracks progress and errors, instantiates the task runner, and cleans
+ * up native memory on unmount or config change. Heavy work runs on a worklet
+ * thread; `runOcr` resolves with the recognized regions in reading order.
+ * @category Hooks
+ * @param config OCR model configuration. Use a preset from `models.ocr.*`.
+ * @param options Load and caching options. See {@link ResourceOptions}.
+ * @returns Readiness flags, download progress, and the `runOcr` /
+ * `runOcrWorklet` runners.
+ */
+export function useOcr(config: OcrModel, options?: ResourceOptions) {
+ const { resource, downloadProgress, downloadError } = useResourceDownload(config, options);
+ const { model, error } = useModel(createOcr, resource ?? null);
+
+ return {
+ isReady: !!model,
+ error: downloadError || error,
+ downloadProgress,
+ resource,
+ runOcr: model?.runOcr,
+ runOcrWorklet: model?.runOcrWorklet,
+ };
+}
diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts
index 0865295145..a309f68cca 100644
--- a/packages/react-native-executorch/src/index.ts
+++ b/packages/react-native-executorch/src/index.ts
@@ -13,6 +13,7 @@ export * from './hooks/useVoiceActivityDetector';
export * from './hooks/useSpeechToText';
export * from './hooks/useTextToSpeech';
export * from './hooks/useTextToImage';
+export * from './hooks/useOcr';
export * from './hooks/useResourceDownload';
export * from './hooks/useModel';
@@ -38,6 +39,10 @@ export * from './extensions/nlp/tasks/privacyFilter';
export * from './extensions/speech/tasks/fsmnVoiceActivityDetection';
export * from './extensions/speech/tasks/whisperSpeechToText';
export * from './extensions/speech/tasks/supertonicTextToSpeech';
+export * from './extensions/cv/tasks/ocr/ocr';
+export * from './extensions/cv/tasks/ocr/detectors';
+export type { Quad } from './extensions/cv/ops/quad';
+export type { NormalizeOptions } from './extensions/cv/ops/image';
// Core primitives — for library builders and power users
export * from './core/error';
diff --git a/packages/react-native-executorch/src/models.ts b/packages/react-native-executorch/src/models.ts
index fe9155015e..759d34ed1f 100644
--- a/packages/react-native-executorch/src/models.ts
+++ b/packages/react-native-executorch/src/models.ts
@@ -14,6 +14,8 @@ import {
type WhisperSttModel,
WHISPER_LANGUAGES,
} from './extensions/speech/tasks/whisperSpeechToText';
+import type { OcrModel, OcrModelOptions } from './extensions/cv/tasks/ocr/ocr';
+import { dbnetExtractBoxes } from './extensions/cv/tasks/ocr/detectors';
import {
IMAGENET_NORM,
IMAGENET1K_LABELS,
@@ -816,6 +818,45 @@ const PRIVACY_FILTER_NEMOTRON_MLX_INT8: PrivacyFilterModel ({
+ modelPath:
+ `${BASE_URL}-pp-ocrv6/${NEXT_VERSION_TAG}/${backend}/` +
+ `pp_ocrv6_${backend}_${PPOCRV6_PRECISION[backend]}.pte`,
+ charsetPath: `${BASE_URL}-pp-ocrv6/${NEXT_VERSION_TAG}/charset.json`,
+ modelOpts: PADDLE_PPOCRV6_OPTS,
+});
+const ppOcrV6 = {
+ XNNPACK: makePpOcrV6('xnnpack'),
+ COREML: makePpOcrV6('coreml'),
+ VULKAN: makePpOcrV6('vulkan'),
+};
+
/**
* Registry of pre-configured ExecuTorch models.
*
@@ -1428,4 +1469,20 @@ export const models = {
MLX_FP32: SUPERTONIC_3_MLX_FP32,
},
},
+
+ /**
+ * Optical Character Recognition models — a text detector paired with a text
+ * recognizer, run as one two-stage pipeline.
+ */
+ ocr: {
+ /**
+ * PP-OCRv6 small — DBNet detector plus an SVTR recognizer, one model for
+ * every language.
+ *
+ * On Android, `VULKAN` is the faster choice.
+ */
+ PADDLE: {
+ PPOCRV6_SMALL: ppOcrV6,
+ },
+ },
};