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-rustcon uniones redondeadas, replicando el comportamiento depyclipperen 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 |
|
|
Sesión |
|
|
Configuración | Bloque |
|
Paquetes npm | Nombres sueltos ( | Ámbito unificado |
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; elCargo.tomlypostprocess.rsusanclipper2-rust. La diferencia importa porquegeo-clipperenlaza con la biblioteca C++ Clipper, mientras queclipper2-rustes un port en Rust. - «100 % Rust seguro». El bloque
unsafeaparece enffi.rsybinding.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 opcionaluse-opencvpara 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_textnecesita&mut self; compartir un motor entre hilos exige unMutexo un motor por hilo. - CLI. En
main.rsla 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-orderedimprime el mismo texto quetext, sin reconstrucción espacial; para obtener ese diseño hay que usar la API conOutputGranularity::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
