RustO!: un motor OCR en Rust puro, sin OpenCV ni C++

Quien haya puesto un OCR en producción conoce el problema: el modelo de reconocimiento es la parte pequeña y lo que da guerra es todo lo que lo rodea. Casi cualquier pipeline (Tesseract, EasyOCR, RapidOCR, PaddleOCR) delega en OpenCV la carga de imágenes, el redimensionado, los contornos, la dilatación y las transformaciones de perspectiva. Eso significa enlazar con bibliotecas C++ enormes, pelearse con el NDK en Android y cargar con imágenes de contenedor de cientos de megas.

RustO! (rusto-rs), un proyecto de código abierto con licencia MIT de Muhamad Rizki, propone quitar OpenCV de la ecuación y reescribir todo en Rust: visión por computador e inferencia. El repositorio es relativamente joven (se creó en noviembre de 2025, va por la versión 0.3.2 del 25 de octubre de 2026 y suma unas 106 estrellas), pero ya publica paquetes para Rust, WebAssembly, Node, Python, Go, Java, Dart, Flutter, React, React Native, iOS, Android y .NET. Hemos leído el código y la documentación del repositorio para contarte qué hace de verdad, con ejemplos, y dónde conviene matizar lo que se ha dicho de él.

Qué es y cómo está montado

RustO! es un motor OCR basado en los modelos de PaddleOCR (PP-OCRv6, v5, v4 y v3) que ejecuta la inferencia con RTen, un motor de redes neuronales escrito en Rust puro que lee modelos ONNX y su propio formato .rten (FlatBuffers). Sobre él se apoya un pipeline clásico de dos etapas:

  • Detección con DBNet: produce un mapa de probabilidad de texto que se binariza, se dilata (opcionalmente) y se convierte en polígonos con un módulo propio de trazado de contornos (contours.rs).
  • Expansión de polígonos: como DBNet entrena con regiones encogidas, los polígonos se expanden con recorte de Vatti. El repositorio usa la crate clipper2-rust con uniones redondeadas, replicando el comportamiento de pyclipper en PaddleOCR.
  • Recorte y rectificación de cada línea de texto en paralelo con Rayon.
  • Reconocimiento CTC por lotes con el diccionario del modelo.
  • Etapas opcionales: clasificador de orientación de página (0°, 90°, 180°, 270°), clasificador de dirección de línea (180°) y rectificación de documento.

Los módulos del núcleo son pocos y legibles: det.rs, rec.rs, orient.rs, preprocess.rs, postprocess.rs, contours.rs, geometry.rs y layout.rs, además de table.rs y doc_pipeline.rs para estructura de tablas y documentos. Todo el crate suma unas 9.400 líneas de Rust.

De MNN a RTen: lo que cambió en la 0.3

La versión 0.2 usaba Alibaba MNN como motor de inferencia, y eso traía de vuelta CMake, un compilador de C++ y modelos en un formato propio. La 0.3 lo sustituye por RTen y cambia varias piezas de la API:

Aspecto

v0.2.x

v0.3.x

Motor de inferencia

Alibaba MNN (C++ y CMake)

RTen (Rust puro)

Formato de modelos

.mnn

.rten u .onnx

Sesión

MnnSession

EngineSession sobre el trait InferenceSession

Configuración

Bloque preprocessing anidado

InitializeConfig (estática) y OcrRunOptions (por petición)

Paquetes npm

Nombres sueltos (react-rusto…)

Ámbito unificado @rustors/*

Con ese cambio, el proyecto se compila con un simple cargo build, sin CMake ni toolchain nativo para el núcleo.

Primeros pasos en Rust

Añade la crate y descarga los modelos. El repositorio incluye scripts/download_models.sh para bajarlos desde ModelScope, y hay paquetes de modelos ya empaquetados en npm y NuGet.

cargo add rusto-rs

El ejemplo mínimo carga el motor una vez y procesa una imagen:

use rusto::{DetectTextResult, ImageSource, InitializeConfig, OcrRunOptions, RustO};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut ocr = RustO::initialize(InitializeConfig::ppv6(
        "models/det.rten",
        "models/rec.rten",
        "models/dict.txt",
    ))?;

    let result = ocr.detect_text(
        &ImageSource::Path("factura.jpg".into()),
        &OcrRunOptions::default(),
    )?;

    if let DetectTextResult::Structured(lineas) = result {
        for l in lineas {
            println!("{:.2}  {}", l.score, l.text);
        }
    }
    Ok(())
}

Cada resultado (TextResult) trae el texto, la puntuación, los cuatro vértices del polígono (box_points) y un rectángulo alineado a los ejes (frame), lo que permite dibujar cajas encima de la imagen o recortar campos concretos.

También hay una variante para imágenes que ya tienes decodificadas en memoria (detect_text_rgb), útil en servicios que reciben bytes de una cámara o de otro pipeline y no quieren pasar por el disco.

Tres formas de salida: líneas, palabras y diseño espacial

El campo output de OcrRunOptions admite Lines (por defecto), Words y Spatial. Las dos primeras devuelven resultados estructurados con coordenadas. Spatial devuelve una cadena en la que se ha reconstruido la posición bidimensional del texto, algo muy práctico en facturas, tickets y tablas, donde una lista plana de cajas pierde la relación entre columnas.

use rusto::{DetectTextResult, ImageSource, OcrRunOptions, OutputGranularity};

let opciones = OcrRunOptions {
    output: OutputGranularity::Spatial,
    line_y_threshold: Some(0.5), // tolerancia vertical para agrupar en filas
    word_x_threshold: Some(0.4), // tolerancia horizontal entre palabras
    ..Default::default()
};

if let DetectTextResult::Spatial(texto) =
    ocr.detect_text(&ImageSource::Path("ticket.jpg".into()), &opciones)?
{
    println!("{texto}");
}

El algoritmo agrupa las cajas en filas comparando el centro vertical con la altura mediana de las cajas, y después inserta espacios proporcionales al hueco horizontal medido en anchuras de carácter. La documentación del proyecto muestra este resultado para un ticket de dos líneas:

Item Description                       Amount
Coffee Latte                            $4.50
Croissant                               $3.50
Total                                   $8.00

Como pauta, sube line_y_threshold (0,7 a 0,9) si tickets torcidos o hechos con el móvil se parten en varias líneas, y bájalo (0,2 a 0,35) en tablas densas. Con word_x_threshold pasa lo mismo: valores más altos fusionan palabras separadas, más bajos conservan los límites de columna.

Ajuste fino por petición

OcrRunOptions no modifica el motor, así que puedes variar parámetros de una imagen a otra. Estos son los más útiles, con los valores que la documentación recomienda para casos típicos.

Papel térmico y matricial

Los tickets térmicos tienen trazos rotos y gris claro. Conviene bajar el umbral de binarización, expandir más los polígonos y activar la dilatación:

use rusto::{OcrRunOptions, PostprocessRunOptions};

let ticket_termico = OcrRunOptions {
    text_score: Some(0.45),
    postprocess: Some(PostprocessRunOptions {
        threshold: Some(0.25),
        unclip_ratio: Some(2.2),
        ..Default::default()
    }),
    ..Default::default()
};

Fotos del móvil de muchos megapíxeles

Una foto de 48 o 108 MP puede disparar el consumo de memoria si no se acota antes de llegar a la red. Limita el lado mayor:

use rusto::{DetectionRunOptions, OcrRunOptions};

let foto_movil = OcrRunOptions {
    max_side_len: Some(1600.0),
    detection: Some(DetectionRunOptions {
        limit_side_len: Some(960),
        limit_type: Some("max".into()),
        ..Default::default()
    }),
    ..Default::default()
};

Documentos densos con letra pequeña

Para contratos o tablas con cuerpo de 6 u 8 puntos, la guía propone subir el lado corto con limit_type: "min", desactivar la dilatación para que no se peguen caracteres contiguos y bajar el unclip_ratio a 1,6.

Los valores por defecto salen de los presets por versión de modelo (PPV6_MODEL_CONFIG y compañía): para v6, lado mínimo de 736, umbral de binarización 0,3, umbral de caja 0,6, unclip_ratio 2,0 y dilatación activada; v4 y v3 usan 960 como lado máximo, 1,5 de expansión y sin dilatación.

Orientación y líneas invertidas

Para documentos fotografiados de lado o boca abajo se adjunta un modelo de orientación de página al configurar el motor. Para líneas individuales invertidas (solo en PP-OCRv4 y v5; los modelos v6 llevan una red de reconocimiento robusta a la orientación) se añade el clasificador de línea y se activa por petición:

let config = InitializeConfig::ppv5("models/det.rten", "models/rec.rten", "models/dict.txt")
    .with_orientation_threshold("models/orient.rten", 0.85)
    .with_cls_threshold("models/cls.rten", 0.9);

let mut ocr = RustO::initialize(config)?;

let opciones = OcrRunOptions {
    classification: Some(true),
    ..Default::default()
};

Motor de inferencia intercambiable

Si necesitas TensorRT en un servidor con GPU o CoreML en Apple, el proyecto permite inyectar tus propias sesiones mediante RustO::with_custom_engines. El contrato es el trait InferenceSession, que en el código actual tiene esta forma (extracto):

pub trait InferenceSession: Send {
    fn run(&mut self, input: ArrayD<f32>) -> Result<ArrayD<f32>, EngineError>;
    fn run_all(&mut self, input: ArrayD<f32>) -> Result<HashMap<String, ArrayD<f32>>, EngineError>;
    // ... y run_with_inputs para entradas con nombre
}

let ocr = RustO::with_custom_engines(config, mi_sesion_det, mi_sesion_rec)?;

Fíjate en que el trait pide Send y &mut self, no Send + Sync ni &self. Esto importa para el siguiente apartado.

Concurrencia: cómo usarlo en un servidor

En el artículo de Medium que acompañó al lanzamiento se presenta el motor como «sin bloqueos», compartido con Arc<RustO> entre hilos. En el código del repositorio, en cambio, detect_text recibe &mut self, y los bindings de UniFFI (binding.rs) guardan el motor dentro de un Mutex. Es decir: las opciones por petición no mutan el motor, pero el motor en sí no se puede usar desde varios hilos a la vez sin sincronización.

Hay dos patrones razonables. El primero, un motor protegido por Mutex, es el más simple, aunque serializa las peticiones:

use std::sync::{Arc, Mutex};

let ocr = Arc::new(Mutex::new(RustO::initialize(config)?));

let ocr2 = Arc::clone(&ocr);
std::thread::spawn(move || {
    let mut guard = ocr2.lock().unwrap();
    let _ = guard.detect_text(&ImageSource::Path("a.jpg".into()), &OcrRunOptions::default());
});

El segundo, mejor si buscas rendimiento, es un motor por hilo de trabajo (cada uno con sus pesos en memoria, aunque RTen puede mapear los modelos .rten con mmap) o un pequeño pool. Los dos fragmentos son ilustrativos y no los hemos compilado contra la versión 0.3.2; compruébalos con tu propio proyecto.

Otros lenguajes: WebAssembly, Node, Python y .NET

Una de las ventajas de tener un núcleo sin dependencias nativas es que se empaqueta con facilidad. Estos son los ejemplos que publica el README.

Navegador (WASM)

El OCR se ejecuta íntegramente en el cliente, sin enviar documentos a ningún servidor:

npm install @rustors/web @rustors/model-ppocrv6-tiny

import { initialize, detectText } from '@rustors/web';
import { getModelUrls } from '@rustors/model-ppocrv6-tiny';

const urls = getModelUrls('/models/ppocrv6-tiny');
await initialize({
  preset: 'ppv6',
  models: { detection: urls.detection, recognition: urls.recognition, dictionary: urls.dictionary },
});

const results = await detectText(document.querySelector('img'));
results.forEach((r) => console.log(`${r.text} (${r.score})`));

Si usas una política de seguridad de contenido (CSP), la guía recuerda permitir 'wasm-unsafe-eval' en script-src y la descarga de los ficheros .wasm y de los modelos en connect-src.

Node.js

const { RustO } = require('@rustors/node');

const engine = new RustO(JSON.stringify({
  detection: { model_path: 'models/PPOCR_v6/det.rten' },
  recognition: { model_path: 'models/PPOCR_v6/rec.rten', dict_path: 'models/PPOCR_v6/dict.txt' }
}));

const results = engine.detectTextFile('factura.png', JSON.stringify({}));
results.forEach(r => console.log(`${r.text} (${(r.score * 100).toFixed(1)}%)`));

Python

pip install rusto

from rusto import RustO, InitializeConfig, OcrRunOptions
from rusto.generated.rusto import ImageSource

engine = RustO.initialize(InitializeConfig.ppv6("det.rten", "rec.rten", "dict.txt"))
result = engine.detect_text(ImageSource.PATH("factura.png"), OcrRunOptions())
if result.is_STRUCTURED():
    for item in result.results:
        print(f"{item.text} ({item.score:.2f})")

.NET

using RustODotnet;

using var ocr = RustO.Initialize(InitializeConfig.Ppv6());
var result = ocr.DetectText(new UriImageSource("factura.jpg"), new OcrRunOptions());

if (result is StructuredDetectTextResult structured) {
    foreach (var item in structured.Items)
        Console.WriteLine($"{item.Text} ({item.Score:F2})");
}

Además hay paquetes para Go (Cgo), Java y Kotlin (JNA vía JitPack), Dart y Flutter, React (con el hook useOcr), React Native, iOS (Swift Package Manager y CocoaPods) y Android (AAR), más una biblioteca compartida con interfaz C para cualquier otro lenguaje.

Línea de comandos

# Salida JSON
cargo run --release -- --det-model det.rten --rec-model rec.rten --dict dict.txt factura.jpg

# Solo texto
cargo run --release -- --det-model det.rten --rec-model rec.rten --dict dict.txt --format text factura.jpg

Modelos y tamaños

Nivel

FP32

INT8

Uso recomendado

PP-OCRv6 Tiny (por defecto)

~6,0 MB

~3,4 MB

WASM, móvil, IoT

PP-OCRv6 Small

~30 MB

~16,2 MB

Móvil y escritorio

PP-OCRv6 Medium

~134 MB

~75,7 MB

Servidor

PP-OCRv5 / v4 (Mobile)

~23-28 MB

-

Pipelines antiguos y paquetes de un solo idioma

PP-OCRv6 reconoce más de 50 idiomas con un único diccionario, entre ellos escrituras latina, cirílica, árabe, devanagari, china, japonesa y coreana. Para tu aplicación, el nivel Tiny en INT8 es un buen punto de partida, y el salto a Small o Medium se justifica cuando falla el reconocimiento con tipografías difíciles.

Cifras que publica el proyecto

El README y la guía de rendimiento dan estos tamaños para el motor sin OpenCV: unos 5,4 MB para el binario de línea de comandos, alrededor de 304 KB para la biblioteca compartida C, 1,8 MB por ABI en Android y 848 KB para el paquete WebAssembly. En memoria, entre 85 y 120 MB de pico frente a los 250 MB o más que atribuyen a un pipeline con OpenCV y ONNX Runtime.

En latencia, la guía mide un escaneo A4 a 150 ppp con unas 25 cajas de texto y el modelo PP-OCRv6 Tiny:

Hardware

Total aproximado

Memoria pico

AMD Ryzen 9 7950X

~80 ms

~85 MB

Apple M3 Max

~70 ms

~92 MB

Intel Core i7-13700K

~87 ms

~88 MB

iPhone 15 Pro

~110 ms

~65 MB

Snapdragon 8 Gen 3

~130 ms

~72 MB

Raspberry Pi 5

~350 ms

~110 MB

Son mediciones del propio autor y no las hemos reproducido. Es razonable creerse el orden de magnitud (el binario es pequeño y no arrastra bibliotecas), pero tómalas como orientación y mide con tus documentos. Lo mismo vale para la paridad del 99,3 % con las cajas de PaddleOCR: es el dato que declaran, sin una suite pública de comparación a la vista en el repositorio.

Matices tras leer el código

Al contrastar el artículo de lanzamiento con el repositorio hemos encontrado varias diferencias que conviene tener claras antes de adoptarlo:

  • Crate de recorte de polígonos. El artículo cita geo-clipper; el Cargo.toml y postprocess.rs usan clipper2-rust. La diferencia importa porque geo-clipper enlaza con la biblioteca C++ Clipper, mientras que clipper2-rust es un port en Rust.
  • «100 % Rust seguro». El bloque unsafe aparece en ffi.rs y binding.rs (13 ocurrencias), que es lo esperable en una capa de interfaz con C y otros lenguajes. El núcleo no lo necesita. Además existe una característica opcional use-opencv para verificar resultados contra OpenCV; sin ella, OpenCV no entra en la compilación.
  • Expansión de polígonos. El artículo habla de un ratio de 1,6, el valor típico de PaddleOCR. En RustO! los presets de v6 y v5 usan 2,0 y los de v4 y v3 usan 1,5.
  • Sin bloqueos. Como hemos visto, detect_text necesita &mut self; compartir un motor entre hilos exige un Mutex o un motor por hilo.
  • CLI. En main.rs la herramienta carga siempre los presets de PP-OCRv5 y los indicadores de orientación y rectificación solo muestran un aviso de que no están configurados. La opción --format text-ordered imprime el mismo texto que text, sin reconstrucción espacial; para obtener ese diseño hay que usar la API con OutputGranularity::Spatial.
  • Cifras de despliegue. Los 1,5 a 2 GB de imagen de contenedor, los arranques en frío de 8 a 12 s o los 30 MB de un OpenCV compilado a WASM que menciona el artículo son afirmaciones del autor sobre pipelines ajenos, sin metodología publicada. El README se queda en comparaciones de tamaño de binario y de memoria.

Nada de esto invalida el proyecto: un OCR sin toolchain de C++, con un binario de pocos megas y SDK para tantas plataformas, resuelve un problema real. Pero la imagen de «sin locks» y «sin nada de C» es más ajustada en el marketing que en el código.

Cuándo tiene sentido probarlo

  • Aplicaciones móviles o de escritorio donde meter OpenCV complica el empaquetado.
  • OCR en el navegador cuando los documentos no deben salir del dispositivo (identidades, justificantes, facturas).
  • Funciones serverless o contenedores pequeños donde importa el tamaño de la imagen.
  • Herramientas de línea de comandos y utilidades internas que quieres distribuir como un único ejecutable.
  • Proyectos que ya usan los modelos de PaddleOCR y quieren quitar Python del camino.

Si necesitas aceleración por GPU, el repositorio todavía define esas características (cuda, coreml, vulkan y similares) como vacías por compatibilidad. Para GPU tendrás que implementar tu propio InferenceSession.

Todo el código y la documentación están en github.com/byrizki/rusto-rs, y la guía completa en byrizki.com/rusto-rs.

Imagen: Pexels / SHVETS production

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP