$ Ir al contenido principal
Resumen
0% · ... restante
0%
Práctica de OCR en el dispositivo: Despliegue nativo de PP-OCRv5 en Android
$ cat mobile/ppocrv5-android.md

# Práctica de OCR en el dispositivo: Despliegue nativo de PP-OCRv5 en Android

Autor:
Fecha: 29 de diciembre de 2025, 20:17
Tiempo de lectura: Aprox. 23 min de lectura
mobile/ppocrv5-android.md

Esta página ha sido traducida por IA. En caso de discrepancias, consulte el artículo original.

Notas

Esta entrada de blog:

  • Portada: Generada con Google Nano Banana 2, sin derechos de autor.
  • Código fuente del proyecto: Código abierto en GitHub, visite PPOCRv5-Android para obtenerlo.

Descargo de responsabilidad:

El autor (Fleey) no es un profesional del campo de la IA, esto es puramente por interés personal. Si hay omisiones o errores en el texto, espero que los lectores lo comprendan y me corrijan a tiempo.

Introducción

En 2024, Google cambió el nombre de TensorFlow Lite a LiteRT. Esto no es solo un cambio de marca, sino que marca una transición de paradigma en la IA en el dispositivo (on-device AI) de “móvil primero” a “borde primero” (edge-first) 1. En este contexto, el OCR (Reconocimiento Óptico de Caracteres), como una de las aplicaciones de IA en el dispositivo más valiosas, está experimentando una revolución silenciosa.

El equipo de PaddleOCR de Baidu lanzó en 2025 el PP-OCRv5, un modelo de OCR unificado que admite múltiples idiomas, incluidos chino simplificado, chino tradicional, inglés y japonés 2. Su versión móvil pesa solo unos 70 MB, pero puede realizar el reconocimiento de 18,383 caracteres en un solo modelo. Detrás de esta cifra se encuentra el trabajo coordinado de dos redes neuronales profundas: detección y reconocimiento.

Pero el problema es: PP-OCRv5 se entrena basándose en el framework PaddlePaddle, mientras que el motor de inferencia más maduro en dispositivos Android es LiteRT. ¿Cómo cruzar esta brecha?

Comencemos con la conversión del modelo para desvelar paso a paso la ingeniería detrás del OCR en el dispositivo.

flowchart TB
subgraph E2E["Flujo OCR de extremo a extremo"]
direction TB
subgraph Input["Entrada"]
IMG[Imagen original<br/>Cualquier tamaño]
end
subgraph Detection["Detección de texto - DBNet"]
DET_PRE[Preprocesamiento<br/>Resize 640x640<br/>Normalización ImageNet]
DET_INF[Inferencia DBNet<br/>~45ms GPU]
DET_POST[Post-procesamiento<br/>Binarización - Contornos - Rectángulo rotado]
end
subgraph Recognition["Reconocimiento de texto - SVTRv2"]
REC_CROP[Recorte por transformación de perspectiva<br/>48xW ancho adaptativo]
REC_INF[Inferencia SVTRv2<br/>~15ms/línea GPU]
REC_CTC[Decodificación CTC<br/>Combinar repetidos + Eliminar espacios]
end
subgraph Output["Salida"]
RES[Resultados OCR<br/>Texto + Confianza + Posición]
end
end
IMG --> DET_PRE --> DET_INF --> DET_POST
DET_POST -->|N cuadros de texto| REC_CROP
REC_CROP --> REC_INF --> REC_CTC --> RES

Conversión de modelos: El largo viaje de PaddlePaddle a TFLite

La fragmentación de los frameworks de aprendizaje profundo es un punto de dolor en la industria. PyTorch, TensorFlow, PaddlePaddle, ONNX; cada framework tiene su propio formato de modelo e implementación de operadores. ONNX (Open Neural Network Exchange) intenta ser una representación intermedia universal, pero la realidad suele ser más cruda que el ideal.

La ruta de conversión del modelo PP-OCRv5 es la siguiente:

flowchart LR
subgraph PaddlePaddle["Framework PaddlePaddle"]
PM[inference.json<br/>inference.pdiparams]
end
subgraph ONNX["Intermedio ONNX"]
OM[model.onnx<br/>opset 14]
end
subgraph Optimization["Optimización de grafo"]
GS[onnx-graphsurgeon<br/>Descomposición de operadores]
end
subgraph TFLite["Formato LiteRT"]
TM[model.tflite<br/>Cuantización FP16]
end
PM -->|paddle2onnx| OM
OM -->|Descomposición HardSigmoid<br/>Modificación modo Resize| GS
GS -->|onnx2tf| TM

Este camino parece sencillo, pero esconde varios desafíos.

El primer obstáculo: Compatibilidad de operadores de paddle2onnx

paddle2onnx es la herramienta oficial de conversión de modelos proporcionada por PaddlePaddle. En teoría, puede convertir modelos de PaddlePaddle al formato ONNX. Sin embargo, PP-OCRv5 utiliza algunos operadores especiales cuyo mapeo en ONNX no es uno a uno.

Terminal window
paddle2onnx --model_dir PP-OCRv5_mobile_det \
--model_filename inference.json \
--params_filename inference.pdiparams \
--save_file ocr_det_v5.onnx \
--opset_version 14

Aquí hay un detalle clave: el nombre del archivo del modelo PP-OCRv5 es inference.json en lugar del tradicional inference.pdmodel. Este es un cambio en el formato de modelo de las nuevas versiones de PaddlePaddle que ha causado confusión a muchos desarrolladores 3.

El segundo obstáculo: HardSigmoid y compatibilidad con GPU

El modelo ONNX convertido contiene el operador HardSigmoid. Este operador se define matemáticamente como:

HardSigmoid(x)=max(0,min(1,αx+β))\text{HardSigmoid}(x) = \max(0, \min(1, \alpha x + \beta))

Donde α=0.2\alpha = 0.2 y β=0.5\beta = 0.5.

El problema es que el GPU Delegate de LiteRT no admite HardSigmoid. Cuando un modelo contiene operadores no compatibles, el GPU Delegate realiza un “fallback” de todo el subgrafo a la CPU, lo que provoca una pérdida grave de rendimiento.

La solución es descomponer HardSigmoid en operadores básicos. Usando la librería onnx-graphsurgeon, podemos realizar una “cirugía” a nivel de grafo de computación:

import onnx_graphsurgeon as gs
import numpy as np
def decompose_hardsigmoid(graph: gs.Graph) -> gs.Graph:
"""
Descompone HardSigmoid en operadores básicos amigables para GPU
HardSigmoid(x) = max(0, min(1, alpha*x + beta))
Descompuesto en: Mul -> Add -> Clip
"""
for node in graph.nodes:
if node.op == "HardSigmoid":
# Obtener parámetros de HardSigmoid
alpha = node.attrs.get("alpha", 0.2)
beta = node.attrs.get("beta", 0.5)
input_tensor = node.inputs[0]
output_tensor = node.outputs[0]
# Crear tensores constantes
alpha_const = gs.Constant(
name=f"{node.name}_alpha",
values=np.array([alpha], dtype=np.float32)
)
beta_const = gs.Constant(
name=f"{node.name}_beta",
values=np.array([beta], dtype=np.float32)
)
# Crear variables intermedias
mul_out = gs.Variable(name=f"{node.name}_mul_out")
add_out = gs.Variable(name=f"{node.name}_add_out")
# Construir subgrafo descompuesto: x -> Mul(alpha) -> Add(beta) -> Clip(0,1)
mul_node = gs.Node(
op="Mul",
inputs=[input_tensor, alpha_const],
outputs=[mul_out]
)
add_node = gs.Node(
op="Add",
inputs=[mul_out, beta_const],
outputs=[add_out]
)
clip_node = gs.Node(
op="Clip",
inputs=[add_out],
outputs=[output_tensor],
attrs={"min": 0.0, "max": 1.0}
)
# Reemplazar nodo original
graph.nodes.remove(node)
graph.nodes.extend([mul_node, add_node, clip_node])
graph.cleanup().toposort()
return graph

La clave de esta descomposición es que Mul, Add y Clip son operadores totalmente compatibles con el GPU Delegate de LiteRT. Tras la descomposición, todo el subgrafo puede ejecutarse de forma continua en la GPU, evitando el coste de transferencia de datos entre CPU y GPU.

TIP

¿Por qué no modificar directamente el código de entrenamiento del modelo? Porque el cálculo del gradiente de HardSigmoid durante el entrenamiento es diferente al de Clip. La descomposición solo debe realizarse en la etapa de inferencia para mantener la estabilidad numérica del entrenamiento.

El tercer obstáculo: Modo de transformación de coordenadas del operador Resize

El operador Resize de ONNX tiene un atributo coordinate_transformation_mode, que determina cómo se mapean las coordenadas de salida a las de entrada. PP-OCRv5 utiliza el modo half_pixel, pero el soporte del GPU Delegate de LiteRT para este modo es limitado.

Cambiarlo al modo asymmetric puede mejorar la compatibilidad con la GPU:

for node in graph.nodes:
if node.op == "Resize":
node.attrs["coordinate_transformation_mode"] = "asymmetric"

WARNING

Esta modificación puede causar pequeñas diferencias numéricas. En pruebas reales, el impacto de esta diferencia en la precisión del OCR es insignificante, pero en otras tareas podría requerir una evaluación cuidadosa.

Paso final: onnx2tf y cuantización FP16

onnx2tf es una herramienta para convertir modelos ONNX al formato TFLite. La cuantización FP16 (punto flotante de media precisión) es una opción común para el despliegue en dispositivos móviles; reduce el tamaño del modelo a la mitad con una pérdida de precisión aceptable y aprovecha las unidades de cálculo FP16 de las GPU móviles.

Terminal window
onnx2tf -i ocr_det_v5_fixed.onnx -o converted_det \
-b 1 -ois x:1,3,640,640 -n

El parámetro -ois especifica la forma estática de la entrada. Las formas estáticas son cruciales para la aceleración por GPU, ya que las formas dinámicas obligarían a recompilar el programa de la GPU en cada inferencia, afectando gravemente al rendimiento.

Detección de texto: Binarización Diferenciable de DBNet

El módulo de detección de PP-OCRv5 se basa en DBNet (Differentiable Binarization Network) 4. Los métodos tradicionales de detección de texto utilizan un umbral fijo para la binarización, mientras que la innovación de DBNet consiste en permitir que la red aprenda por sí misma el umbral óptimo para cada píxel.

flowchart TB
subgraph DBNet["Arquitectura DBNet"]
direction TB
IMG[Imagen de entrada<br/>H x W x 3]
BB[Backbone<br/>MobileNetV3]
FPN[Pirámide de características FPN<br/>Fusión multiescala]
subgraph Heads["Salida de doble rama"]
PH[Rama de mapa de probabilidad<br/>P: H x W x 1]
TH[Rama de mapa de umbral<br/>T: H x W x 1]
end
DB["Binarización Diferenciable<br/>B = sigmoid k * P-T"]
end
IMG --> BB --> FPN
FPN --> PH
FPN --> TH
PH --> DB
TH --> DB

Binarización estándar vs. Binarización Diferenciable

La binarización estándar es una función escalón:

Bi,j={1if Pi,jt0otherwiseB_{i,j} = \begin{cases} 1 & \text{if } P_{i,j} \geq t \\ 0 & \text{otherwise} \end{cases}

Esta función no es diferenciable, por lo que no se puede entrenar de extremo a extremo mediante retropropagación. DBNet propone una función aproximada:

B^i,j=11+ek(Pi,jTi,j)\hat{B}_{i,j} = \frac{1}{1 + e^{-k(P_{i,j} - T_{i,j})}}

Donde PP es el mapa de probabilidad, TT es el mapa de umbral (aprendido por la red) y kk es un factor de amplificación (establecido en 50 durante el entrenamiento).

TIP

Esta fórmula es esencialmente una función Sigmoid, solo que la entrada es PTP - T. Cuando kk es lo suficientemente grande, su comportamiento se aproxima a la función escalón, pero mantiene la diferenciabilidad.

Implementación de ingeniería del flujo de post-procesamiento

En el proyecto PPOCRv5-Android, el flujo de post-procesamiento se implementa en postprocess.cpp. El flujo principal incluye:

flowchart LR
subgraph Input["Salida del modelo"]
PM[Mapa de probabilidad P<br/>640 x 640]
end
subgraph Binary["Binarización"]
BT[Filtrado por umbral<br/>threshold=0.1]
BM[Mapa binario<br/>640 x 640]
end
subgraph Contour["Detección de contornos"]
DS[Submuestreo 4x<br/>160 x 160]
CC[Análisis de componentes conectados<br/>Recorrido BFS]
BD[Extracción de puntos de borde]
end
subgraph Geometry["Cálculo geométrico"]
CH[Cálculo de envolvente convexa<br/>Graham Scan]
RR[Rotating Calipers<br/>Rectángulo delimitador mínimo]
UC[Expansión Unclip<br/>ratio=1.5]
end
subgraph Output["Salida"]
TB[RotatedRect<br/>centro, tamaño, ángulo]
end
PM --> BT --> BM
BM --> DS --> CC --> BD
BD --> CH --> RR --> UC --> TB

En el código real, el método TextDetector::Impl::Detect muestra el proceso de detección completo:

std::vector<RotatedRect> Detect(const uint8_t *image_data,
int width, int height, int stride,
float *detection_time_ms) {
// 1. Calcular ratio de escala
scale_x_ = static_cast<float>(width) / kDetInputSize;
scale_y_ = static_cast<float>(height) / kDetInputSize;
// 2. Redimensionar a 640x640 mediante interpolación bilineal
image_utils::ResizeBilinear(image_data, width, height, stride,
resized_buffer_.data(), kDetInputSize, kDetInputSize);
// 3. Normalización ImageNet
PrepareFloatInput();
// 4. Inferencia
auto run_result = compiled_model_->Run(input_buffers_, output_buffers_);
// 5. Binarización
BinarizeOutput(prob_map, total_pixels);
// 6. Detección de contornos
auto contours = postprocess::FindContours(binary_map_.data(),
kDetInputSize, kDetInputSize);
// 7. Rectángulo delimitador mínimo + Unclip
for (const auto &contour : contours) {
RotatedRect rect = postprocess::MinAreaRect(contour);
UnclipBox(rect, kUnclipRatio);
// Mapear coordenadas de vuelta a la imagen original
rect.center_x *= scale_x_;
rect.center_y *= scale_y_;
// ...
}
}

La clave de este proceso es el “rectángulo rotado delimitador mínimo”. A diferencia de los cuadros delimitadores alineados con los ejes, los rectángulos rotados pueden ajustarse estrechamente a texto en cualquier ángulo, lo cual es vital para el texto inclinado en escenas naturales.

Unclip: Algoritmo de expansión de cuadros de texto

Las áreas de texto detectadas por DBNet suelen ser ligeramente más pequeñas que el texto real, ya que la red aprende la “región central” del texto. Para obtener los límites completos del texto, es necesario realizar una operación de expansión (Unclip) sobre el polígono detectado.

El principio matemático de Unclip se basa en la operación inversa del algoritmo de recorte de polígonos de Vatti. Dado un polígono PP y una distancia de expansión dd, el polígono expandido PP' cumple:

d=A×rLd = \frac{A \times r}{L}

Donde AA es el área del polígono, LL es el perímetro y rr es el ratio de expansión (normalmente establecido en 1.5).

En postprocess.cpp, la función UnclipBox implementa esta lógica:

void UnclipBox(RotatedRect &box, float unclip_ratio) {
// Calcular distancia de expansión
float area = box.width * box.height;
float perimeter = 2.0f * (box.width + box.height);
if (perimeter < 1e-6f) return; // Prevenir división por cero
// d = A * r / L
float distance = area * unclip_ratio / perimeter;
// Expandir hacia afuera: aumentar ancho y alto en 2d
box.width += 2.0f * distance;
box.height += 2.0f * distance;
}

Esta versión simplificada asume que el cuadro de texto es un rectángulo. Para polígonos más complejos, se requeriría usar la librería Clipper completa para realizar el desplazamiento del polígono:

// Unclip de polígono completo (usando la librería Clipper)
ClipperLib::Path polygon;
for (const auto& pt : contour) {
polygon.push_back(ClipperLib::IntPoint(
static_cast<int>(pt.x * 1000), // Escalar para mantener precisión
static_cast<int>(pt.y * 1000)
));
}
ClipperLib::ClipperOffset offset;
offset.AddPath(polygon, ClipperLib::jtRound, ClipperLib::etClosedPolygon);
ClipperLib::Paths solution;
offset.Execute(solution, distance * 1000); // Expansión

NOTE

PPOCRv5-Android optó por la expansión rectangular simplificada en lugar del desplazamiento de polígono completo. Esto se debe a que:

  • La mayoría de los cuadros de texto son casi rectangulares.
  • La librería Clipper completa aumentaría considerablemente el tamaño del binario.
  • El rendimiento de la versión simplificada es mejor.

Reconocimiento de texto: SVTRv2 y decodificación CTC

Si la detección es “encontrar dónde está el texto”, el reconocimiento es “leer qué dice el texto”. El módulo de reconocimiento de PP-OCRv5 se basa en SVTRv2 (Scene Text Recognition with Visual Transformer v2) 5.

Innovaciones en la arquitectura de SVTRv2

SVTRv2 presenta tres mejoras clave respecto a su predecesor SVTR:

flowchart TB
subgraph SVTRv2["Arquitectura SVTRv2"]
direction TB
subgraph Encoder["Codificador visual"]
PE[Patch Embedding<br/>Convolución 4x4]
subgraph Mixing["Bloques de atención híbrida x12"]
LA[Atención local<br/>Ventana 7x7]
GA[Atención global<br/>Campo receptivo global]
FFN[Feed Forward<br/>MLP]
end
end
subgraph Decoder["Decodificador CTC"]
FC[Capa totalmente conectada<br/>D -> 18384]
SM[Softmax]
CTC[Decodificación CTC]
end
end
PE --> LA --> GA --> FFN
FFN --> FC --> SM --> CTC
  1. Mecanismo de atención híbrida: Alterna entre atención local (para capturar detalles de los trazos) y atención global (para entender la estructura de los caracteres). La atención local utiliza una ventana deslizante de 7x7, reduciendo la complejidad computacional de O(n2)O(n^2) a O(n×49)O(n \times 49).

  2. Fusión de características multiescala: A diferencia de la resolución única de ViT, SVTRv2 utiliza diferentes resoluciones de mapas de características a distintas profundidades, similar a la estructura piramidal de las CNN.

  3. Módulo de guía semántica (Semantic Guidance Module): Añade una rama semántica ligera al final del codificador para ayudar al modelo a entender las relaciones semánticas entre caracteres, más allá de las características visuales.

Estas mejoras permiten que SVTRv2 alcance una precisión comparable a los métodos basados en Atención, manteniendo la simplicidad de la decodificación CTC 6.

¿Por qué CTC en lugar de Atención?

Existen dos paradigmas principales para el reconocimiento de texto:

  1. CTC (Connectionist Temporal Classification): Trata el reconocimiento como un problema de etiquetado de secuencias, alineando la salida con la entrada.
  2. Decodificador basado en Atención: Utiliza un mecanismo de atención para generar la salida carácter por carácter.

Los métodos de Atención suelen ser más precisos, pero los de CTC son más simples y rápidos. La contribución de SVTRv2 es que, al mejorar el codificador visual, permite que el método CTC alcance o incluso supere la precisión de los métodos de Atención 6.

El núcleo de la decodificación CTC es “combinar repetidos” y “eliminar espacios”:

flowchart LR
subgraph Input["Salida del modelo"]
L["Logits<br/>[T, 18384]"]
end
subgraph Argmax["Argmax NEON"]
A1["t=0: blank"]
A2["t=1: H"]
A3["t=2: H"]
A4["t=3: blank"]
A5["t=4: e"]
A6["t=5: l"]
A7["t=6: l"]
A8["t=7: l"]
A9["t=8: o"]
end
subgraph Merge["Combinar repetidos"]
M["blank, H, blank, e, l, o"]
end
subgraph Remove["Eliminar espacios"]
R["H, e, l, o"]
end
subgraph Output["Salida"]
O["Helo - Error"]
end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9
A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> Output

Un momento, aquí hay un problema. Si el texto original es “Hello”, las dos ‘l’ se han combinado erróneamente. La solución de CTC es insertar un token “blank” entre caracteres repetidos.

Codificación correcta: [blank, H, e, l, blank, l, o]
Resultado decodificado: "Hello"

Decodificación CTC optimizada con NEON

La decodificación CTC de PPOCRv5-Android utiliza Argmax optimizado con NEON. En text_recognizer.cpp:

inline void ArgmaxNeon8(const float *__restrict__ data, int size,
int &max_idx, float &max_val) {
if (size < 16) {
// Fallback escalar
max_idx = 0;
max_val = data[0];
for (int i = 1; i < size; ++i) {
if (data[i] > max_val) {
max_val = data[i];
max_idx = i;
}
}
return;
}
// Vectorización NEON: procesa 4 floats a la vez
float32x4_t v_max = vld1q_f32(data);
int32x4_t v_idx = {0, 1, 2, 3};
int32x4_t v_max_idx = v_idx;
const int32x4_t v_four = vdupq_n_s32(4);
int i = 4;
for (; i + 4 <= size; i += 4) {
float32x4_t v_curr = vld1q_f32(data + i);
v_idx = vaddq_s32(v_idx, v_four);
// Comparación vectorizada y selección condicional
uint32x4_t cmp = vcgtq_f32(v_curr, v_max);
v_max = vbslq_f32(cmp, v_curr, v_max); // Seleccionar valor mayor
v_max_idx = vbslq_s32(cmp, v_idx, v_max_idx); // Seleccionar índice correspondiente
}
// Reducción horizontal: encontrar el máximo entre los 4 candidatos
float max_vals[4];
int32_t max_idxs[4];
vst1q_f32(max_vals, v_max);
vst1q_s32(max_idxs, v_max_idx);
// ... comparación final
}

Para un Argmax de 18,384 categorías, la optimización NEON puede aportar una aceleración de aproximadamente 3 veces.

Principios matemáticos de la pérdida CTC y la decodificación

La idea central de CTC es: dada una secuencia de entrada XX y todas las posibles rutas de alineación π\pi, calcular la probabilidad de la secuencia objetivo YY:

P(YX)=πB1(Y)P(πX)P(Y|X) = \sum_{\pi \in \mathcal{B}^{-1}(Y)} P(\pi|X)

Donde B\mathcal{B} es la “función de mapeo de muchos a uno”, que mapea la ruta π\pi a la secuencia de salida YY (mediante la combinación de repetidos y la eliminación de espacios).

En la inferencia, utilizamos decodificación greedy (codiciosa) en lugar de un Beam Search completo:

std::string CTCGreedyDecode(const float* logits, int time_steps, int num_classes,
const std::vector<std::string>& dictionary) {
std::string result;
int prev_idx = -1; // Para combinar repetidos
for (int t = 0; t < time_steps; ++t) {
// Encontrar la categoría con mayor probabilidad en el paso de tiempo actual
int max_idx = 0;
float max_val = logits[t * num_classes];
for (int c = 1; c < num_classes; ++c) {
if (logits[t * num_classes + c] > max_val) {
max_val = logits[t * num_classes + c];
max_idx = c;
}
}
// Reglas de decodificación CTC:
// 1. Omitir token blank (índice 0)
// 2. Combinar caracteres repetidos consecutivos
if (max_idx != 0 && max_idx != prev_idx) {
result += dictionary[max_idx - 1]; // -1 porque blank ocupa el índice 0
}
prev_idx = max_idx;
}
return result;
}

La complejidad temporal de la decodificación greedy es O(T×C)O(T \times C), donde TT es el número de pasos de tiempo y CC es el número de categorías. Para PP-OCRv5, T80T \approx 80 y C=18384C = 18384, lo que requiere unos 1.5 millones de comparaciones por decodificación. Por eso la optimización NEON es tan importante.

TIP

Beam Search puede mejorar la precisión de la decodificación, pero su carga computacional es kk veces mayor que la decodificación greedy (kk es el ancho del beam). En dispositivos móviles, la decodificación greedy suele ser la mejor opción.

Diccionario de caracteres: El reto de los 18,383 caracteres

PP-OCRv5 admite 18,383 caracteres, incluyendo:

  • Caracteres comunes del chino simplificado.
  • Caracteres comunes del chino tradicional.
  • Letras inglesas y números.
  • Hiragana y Katakana japoneses.
  • Signos de puntuación comunes y caracteres especiales.

Este diccionario se almacena en el archivo keys_v5.txt, con un carácter por línea. Durante la decodificación CTC, la forma de los logits de salida del modelo es [1, T, 18384], donde T es el número de pasos de tiempo y 18384 = 18383 caracteres + 1 token blank.

LiteRT C++ API: La interfaz moderna tras la refactorización de 2024

PPOCRv5-Android utiliza la API de C++ de LiteRT tras su refactorización en 2024, la cual ofrece un diseño de interfaz más moderno. En comparación con la API tradicional de C de TFLite, la nueva API ofrece una mejor seguridad de tipos y capacidades de gestión de recursos.

Comparativa entre la API antigua y la nueva

La refactorización de LiteRT en 2024 trajo cambios significativos en la API:

CaracterísticaAPI antigua (TFLite)API nueva (LiteRT)
Espacio de nombrestflite::litert::
Manejo de erroresDevuelve enumeración TfLiteStatusDevuelve tipo Expected<T>
Gestión de memoriaManualAutomática mediante RAII
Configuración de DelegateAPIs dispersasClase Options unificada
Acceso a tensoresPunteros + conversión manualTensorBuffer con seguridad de tipos

La principal ventaja de la nueva API es la seguridad de tipos y la gestión automática de recursos. Tomando como ejemplo el manejo de errores:

// API antigua: requiere comprobación manual de cada valor de retorno
TfLiteStatus status = TfLiteInterpreterAllocateTensors(interpreter);
if (status != kTfLiteOk) {
// Manejo de errores
}
// API nueva: usa el tipo Expected, permite llamadas encadenadas
auto model_result = litert::CompiledModel::Create(env, model_path, options);
if (!model_result) {
LOGE(TAG, "Error: %s", model_result.Error().Message().c_str());
return false;
}
auto model = std::move(*model_result); // Gestión automática del ciclo de vida

Inicialización del entorno y del modelo

En text_detector.cpp, el flujo de inicialización es el siguiente:

bool Initialize(const std::string &model_path, AcceleratorType accelerator_type) {
// 1. Crear entorno LiteRT
auto env_result = litert::Environment::Create({});
if (!env_result) {
LOGE(TAG, "Failed to create LiteRT environment: %s",
env_result.Error().Message().c_str());
return false;
}
env_ = std::move(*env_result);
// 2. Configurar acelerador de hardware
auto options_result = litert::Options::Create();
auto hw_accelerator = ToLiteRtAccelerator(accelerator_type);
options.SetHardwareAccelerators(hw_accelerator);
// 3. Compilar modelo
auto model_result = litert::CompiledModel::Create(*env_, model_path, options);
if (!model_result) {
LOGW(TAG, "Failed to create CompiledModel with accelerator %d: %s",
static_cast<int>(accelerator_type),
model_result.Error().Message().c_str());
return false;
}
compiled_model_ = std::move(*model_result);
// 4. Ajustar forma del tensor de entrada
std::vector<int> input_dims = {1, kDetInputSize, kDetInputSize, 3};
compiled_model_->ResizeInputTensor(0, absl::MakeConstSpan(input_dims));
// 5. Crear Buffer gestionado
CreateBuffersWithCApi();
return true;
}

Managed Tensor Buffer: La clave para la inferencia cero copia

El Managed Tensor Buffer de LiteRT es fundamental para lograr una inferencia de alto rendimiento. Permite que el GPU Delegate acceda directamente al Buffer, eliminando la necesidad de transferencia de datos entre CPU y GPU:

bool CreateBuffersWithCApi() {
LiteRtCompiledModel c_model = compiled_model_->Get();
LiteRtEnvironment c_env = env_->Get();
// Obtener requisitos del Buffer de entrada
LiteRtTensorBufferRequirements input_requirements = nullptr;
LiteRtGetCompiledModelInputBufferRequirements(
c_model, /*signature_index=*/0, /*input_index=*/0,
&input_requirements);
// Obtener información del tipo de tensor
auto input_type = compiled_model_->GetInputTensorType(0, 0);
LiteRtRankedTensorType tensor_type =
static_cast<LiteRtRankedTensorType>(*input_type);
// Crear Buffer gestionado
LiteRtTensorBuffer input_buffer = nullptr;
LiteRtCreateManagedTensorBufferFromRequirements(
c_env, &tensor_type, input_requirements, &input_buffer);
// Envolver como objeto C++, gestión automática del ciclo de vida
input_buffers_.push_back(
litert::TensorBuffer::WrapCObject(input_buffer,
litert::OwnHandle::kYes));
return true;
}

Las ventajas de este diseño son:

  1. Inferencia cero copia: El GPU Delegate puede acceder directamente al Buffer.
  2. Gestión automática de memoria: OwnHandle::kYes asegura que el Buffer se libere automáticamente al destruir el objeto C++.
  3. Seguridad de tipos: Comprobación en tiempo de compilación de la coincidencia de tipos de tensores.

Aceleración por GPU: Elección y equilibrio de OpenCL

LiteRT ofrece varias opciones de aceleración de hardware:

flowchart TB
subgraph Delegates["Ecosistema LiteRT Delegate"]
direction TB
GPU_CL[GPU Delegate<br/>Backend OpenCL]
GPU_GL[GPU Delegate<br/>Backend OpenGL ES]
NNAPI[NNAPI Delegate<br/>Android HAL]
XNN[XNNPACK Delegate<br/>Optimizado para CPU]
end
subgraph Hardware["Mapeo de hardware"]
direction TB
ADRENO[GPU Adreno<br/>Qualcomm]
MALI[GPU Mali<br/>ARM]
NPU[NPU/DSP<br/>Específico del fabricante]
CPU[CPU ARM<br/>NEON]
end
GPU_CL --> ADRENO
GPU_CL --> MALI
GPU_GL --> ADRENO
GPU_GL --> MALI
NNAPI --> NPU
XNN --> CPU
AceleradorBackendVentajasDesventajas
GPUOpenCLAmplio soporte, buen rendimientoNo es un componente estándar de Android
GPUOpenGL ESComponente estándar de AndroidRendimiento inferior a OpenCL
NPUNNAPIMáximo rendimientoMala compatibilidad entre dispositivos
CPUXNNPACKCompatibilidad más ampliaRendimiento más bajo

PPOCRv5-Android eligió OpenCL como backend de aceleración principal. Google lanzó el backend OpenCL para TFLite en 2020, logrando una aceleración de aproximadamente 2 veces en GPUs Adreno en comparación con el backend OpenGL ES 7.

La ventaja de OpenCL proviene de varios aspectos:

  1. Propósito de diseño: OpenCL fue diseñado desde el principio para computación de propósito general, mientras que OpenGL es una API de renderizado gráfico que añadió soporte para shaders de computación más tarde.
  2. Memoria constante: La memoria constante de OpenCL es muy eficiente para el acceso a los pesos de las redes neuronales.
  3. Soporte FP16: OpenCL admite nativamente punto flotante de media precisión, mientras que el soporte en OpenGL llegó más tarde.

Sin embargo, OpenCL tiene un defecto fatal: no es un componente estándar de Android. La calidad de las implementaciones de OpenCL varía entre fabricantes, y algunos dispositivos ni siquiera lo admiten.

OpenCL vs. OpenGL ES: Comparativa profunda de rendimiento

Para entender la ventaja de OpenCL, debemos profundizar en la arquitectura de la GPU. Tomando como ejemplo la Adreno 640 de Qualcomm:

flowchart TB
subgraph Adreno["Arquitectura Adreno 640"]
direction TB
subgraph SP["Procesadores de Shaders x2"]
ALU1[Array ALU<br/>256 FP32 / 512 FP16]
ALU2[Array ALU<br/>256 FP32 / 512 FP16]
end
subgraph Memory["Jerarquía de memoria"]
L1[Caché L1<br/>16KB por SP]
L2[Caché L2<br/>1MB Compartida]
GMEM[Memoria Global<br/>LPDDR4X]
end
subgraph Special["Unidades especiales"]
TMU[Unidad de Textura<br/>Interpolación bilineal]
CONST[Caché de Constantes<br/>Aceleración de pesos]
end
end
ALU1 --> L1
ALU2 --> L1
L1 --> L2 --> GMEM
TMU --> L1
CONST --> ALU1 & ALU2

La ventaja de rendimiento de OpenCL proviene de:

CaracterísticaOpenCLOpenGL ES Compute
Memoria constanteSoporte nativo, aceleración por hardwareRequiere simulación mediante UBO
Tamaño del grupo de trabajoConfiguración flexibleLimitado por el modelo de shader
Barreras de memoriaControl de grano finoGrano grueso
Cálculo FP16Extensión cl_khr_fp16Requiere precisión mediump
Herramientas de depuraciónSnapdragon ProfilerSoporte limitado

En las operaciones de convolución, los pesos suelen ser constantes. OpenCL puede colocar los pesos en la memoria constante, disfrutando de optimizaciones de difusión a nivel de hardware. OpenGL ES, en cambio, necesita pasar los pesos como Uniform Buffer Objects (UBO), lo que aumenta el coste de acceso a memoria.

NOTE

Google restringió la carga directa de librerías OpenCL por parte de las aplicaciones a partir de Android 7.0. Sin embargo, el GPU Delegate de LiteRT sortea esta restricción cargando dinámicamente la implementación de OpenCL del sistema mediante dlopen. Por esta razón, el GPU Delegate necesita detectar la disponibilidad de OpenCL en tiempo de ejecución.

Estrategia de degradación elegante

PPOCRv5-Android implementa una estrategia de degradación elegante (fallback):

ocr_engine.cpp
constexpr AcceleratorType kFallbackChain[] = {
AcceleratorType::kGpu, // Preferencia: GPU
AcceleratorType::kCpu, // Fallback: CPU
};
std::unique_ptr<OcrEngine> OcrEngine::Create(
const std::string &det_model_path,
const std::string &rec_model_path,
const std::string &keys_path,
AcceleratorType accelerator_type) {
auto engine = std::unique_ptr<OcrEngine>(new OcrEngine());
int start_index = GetFallbackStartIndex(accelerator_type);
for (int i = start_index; i < kFallbackChainSize; ++i) {
AcceleratorType current = kFallbackChain[i];
auto detector = TextDetector::Create(det_model_path, current);
if (!detector) continue;
auto recognizer = TextRecognizer::Create(rec_model_path, keys_path, current);
if (!recognizer) continue;
engine->detector_ = std::move(detector);
engine->recognizer_ = std::move(recognizer);
engine->active_accelerator_ = current;
engine->WarmUp();
return engine;
}
return nullptr;
}

Esta estrategia asegura que la aplicación funcione en cualquier dispositivo, variando únicamente el rendimiento.

Capa nativa: C++ y optimización NEON

¿Por qué usar C++ en lugar de Kotlin?

La respuesta es simple: rendimiento. El preprocesamiento de imágenes implica una gran cantidad de operaciones a nivel de píxel, cuyo coste en la JVM es inaceptable. Más importante aún, C++ permite el uso directo de las instrucciones ARM NEON SIMD para realizar cálculos vectorizados.

NEON: El conjunto de instrucciones SIMD de ARM

NEON es una extensión SIMD (Single Instruction, Multiple Data) para procesadores ARM. Permite que una sola instrucción procese múltiples elementos de datos simultáneamente.

flowchart LR
subgraph NEON["Registro NEON de 128 bits"]
direction TB
F4["4x float32"]
I8["8x int16"]
B16["16x int8"]
end
subgraph Operations["Operaciones vectorizadas"]
direction TB
LD["vld1q_f32<br/>Cargar 4 floats"]
SUB["vsubq_f32<br/>Resta paralela de 4 vías"]
MUL["vmulq_f32<br/>Multiplicación paralela de 4 vías"]
ST["vst1q_f32<br/>Almacenar 4 floats"]
end
subgraph Speedup["Mejora de rendimiento"]
S1["Escalar: 4 instrucciones"]
S2["NEON: 1 instrucción"]
S3["Aceleración teórica: 4x"]
end
F4 --> LD
LD --> SUB --> MUL --> ST
ST --> S3

PPOCRv5-Android utiliza optimizaciones NEON en varias rutas críticas. Tomando como ejemplo la binarización (text_detector.cpp):

void BinarizeOutput(const float *prob_map, int total_pixels) {
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
const float32x4_t v_threshold = vdupq_n_f32(kBinaryThreshold);
const uint8x16_t v_255 = vdupq_n_u8(255);
const uint8x16_t v_0 = vdupq_n_u8(0);
int i = 0;
for (; i + 16 <= total_pixels; i += 16) {
// Procesa 16 píxeles a la vez
float32x4_t f0 = vld1q_f32(prob_map + i);
float32x4_t f1 = vld1q_f32(prob_map + i + 4);
float32x4_t f2 = vld1q_f32(prob_map + i + 8);
float32x4_t f3 = vld1q_f32(prob_map + i + 12);
// Comparación vectorizada
uint32x4_t cmp0 = vcgtq_f32(f0, v_threshold);
uint32x4_t cmp1 = vcgtq_f32(f1, v_threshold);
uint32x4_t cmp2 = vcgtq_f32(f2, v_threshold);
uint32x4_t cmp3 = vcgtq_f32(f3, v_threshold);
// Estrechar a uint8
uint16x4_t n0 = vmovn_u32(cmp0);
uint16x4_t n1 = vmovn_u32(cmp1);
uint16x8_t n01 = vcombine_u16(n0, n1);
// ... combinar y almacenar
}
// Fallback escalar para los píxeles restantes
for (; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#else
// Implementación puramente escalar
for (int i = 0; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#endif
}

Puntos clave de optimización en este código:

  • Carga por lotes: vld1q_f32 carga 4 floats a la vez, reduciendo el número de accesos a memoria.
  • Comparación vectorizada: vcgtq_f32 compara 4 valores simultáneamente, generando una máscara.
  • Estrechamiento de tipos: vmovn_u32 comprime los resultados de 32 bits a 16 bits, y finalmente a 8 bits.

En comparación con la implementación escalar, la optimización NEON puede aportar una aceleración de 3 a 4 veces 8.

Implementación NEON de la normalización ImageNet

La normalización de imágenes es un paso crucial en el preprocesamiento. El estándar ImageNet utiliza la siguiente fórmula:

xnormalized=xμσx_{normalized} = \frac{x - \mu}{\sigma}

Donde μ=[0.485,0.456,0.406]\mu = [0.485, 0.456, 0.406] y σ=[0.229,0.224,0.225]\sigma = [0.229, 0.224, 0.225] (canales RGB).

En image_utils.cpp, la implementación de la normalización optimizada con NEON es la siguiente:

void NormalizeImageNet(const uint8_t* src, int width, int height, int stride,
float* dst) {
// Parámetros de normalización ImageNet
constexpr float kMeanR = 0.485f, kMeanG = 0.456f, kMeanB = 0.406f;
constexpr float kStdR = 0.229f, kStdG = 0.224f, kStdB = 0.225f;
constexpr float kInvStdR = 1.0f / kStdR;
constexpr float kInvStdG = 1.0f / kStdG;
constexpr float kInvStdB = 1.0f / kStdB;
constexpr float kScale = 1.0f / 255.0f;
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
// Precálculo: (1/255) / std = 1 / (255 * std)
const float32x4_t v_scale_r = vdupq_n_f32(kScale * kInvStdR);
const float32x4_t v_scale_g = vdupq_n_f32(kScale * kInvStdG);
const float32x4_t v_scale_b = vdupq_n_f32(kScale * kInvStdB);
// Precálculo: -mean / std
const float32x4_t v_bias_r = vdupq_n_f32(-kMeanR * kInvStdR);
const float32x4_t v_bias_g = vdupq_n_f32(-kMeanG * kInvStdG);
const float32x4_t v_bias_b = vdupq_n_f32(-kMeanB * kInvStdB);
for (int y = 0; y < height; ++y) {
const uint8_t* row = src + y * stride;
float* dst_row = dst + y * width * 3;
int x = 0;
for (; x + 4 <= width; x += 4) {
// Cargar 4 píxeles RGBA (16 bytes)
uint8x16_t rgba = vld1q_u8(row + x * 4);
// Desentrelazado: RGBARGBARGBARGBA -> RRRR, GGGG, BBBB, AAAA
uint8x16x4_t channels = vld4q_u8(row + x * 4);
// uint8 -> uint16 -> uint32 -> float32
uint16x8_t r16 = vmovl_u8(vget_low_u8(channels.val[0]));
uint16x8_t g16 = vmovl_u8(vget_low_u8(channels.val[1]));
uint16x8_t b16 = vmovl_u8(vget_low_u8(channels.val[2]));
float32x4_t r_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(r16)));
float32x4_t g_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(g16)));
float32x4_t b_f = vcvtq_f32_u32(vmovl_u16(vget_low_u16(b16)));
// Normalización: (x / 255 - mean) / std = x * (1/255/std) + (-mean/std)
r_f = vmlaq_f32(v_bias_r, r_f, v_scale_r); // fused multiply-add
g_f = vmlaq_f32(v_bias_g, g_f, v_scale_g);
b_f = vmlaq_f32(v_bias_b, b_f, v_scale_b);
// Almacenamiento entrelazado: RRRR, GGGG, BBBB -> RGBRGBRGBRGB
float32x4x3_t rgb = {r_f, g_f, b_f};
vst3q_f32(dst_row + x * 3, rgb);
}
// Procesamiento escalar para los píxeles restantes
for (; x < width; ++x) {
const uint8_t* px = row + x * 4;
float* dst_px = dst_row + x * 3;
dst_px[0] = (px[0] * kScale - kMeanR) * kInvStdR;
dst_px[1] = (px[1] * kScale - kMeanG) * kInvStdG;
dst_px[2] = (px[2] * kScale - kMeanB) * kInvStdB;
}
}
#else
// Implementación escalar (omitida)
#endif
}

Técnicas clave de optimización en este código:

  1. Precálculo de constantes: Se transforma (x - mean) / std en x * scale + bias, eliminando divisiones en tiempo de ejecución.
  2. Fused Multiply-Add: vmlaq_f32 realiza la multiplicación y la suma en una sola instrucción.
  3. Carga desentrelazada: vld4q_u8 separa automáticamente RGBA en cuatro canales.
  4. Almacenamiento entrelazado: vst3q_f32 escribe los tres canales RGB entrelazados en memoria.

Cero dependencia de OpenCV

Muchos proyectos de OCR dependen de OpenCV para el preprocesamiento de imágenes. OpenCV es potente, pero conlleva un tamaño de binario enorme; la librería OpenCV para Android suele superar los 10 MB.

PPOCRv5-Android optó por la ruta de “cero dependencia de OpenCV”. Todas las operaciones de preprocesamiento de imágenes se implementan en C++ puro en image_utils.cpp:

  • Redimensionamiento por interpolación bilineal: Implementación manual con soporte para optimización NEON.
  • Normalización: Normalización ImageNet y normalización para reconocimiento.
  • Transformación de perspectiva: Recorte de áreas de texto en cualquier ángulo desde la imagen original.

Implementación NEON de la interpolación bilineal

La interpolación bilineal es el algoritmo central para el redimensionamiento de imágenes. Dadas las coordenadas (x,y)(x, y) de la imagen de origen, la interpolación bilineal calcula el valor del píxel objetivo:

f(x,y)=(1α)(1β)f00+α(1β)f10+(1α)βf01+αβf11f(x, y) = (1-\alpha)(1-\beta)f_{00} + \alpha(1-\beta)f_{10} + (1-\alpha)\beta f_{01} + \alpha\beta f_{11}

Donde α=xx\alpha = x - \lfloor x \rfloor, β=yy\beta = y - \lfloor y \rfloor y fijf_{ij} son los valores de los cuatro píxeles vecinos.

void ResizeBilinear(const uint8_t* src, int src_w, int src_h, int src_stride,
uint8_t* dst, int dst_w, int dst_h) {
const float scale_x = static_cast<float>(src_w) / dst_w;
const float scale_y = static_cast<float>(src_h) / dst_h;
for (int dy = 0; dy < dst_h; ++dy) {
const float sy = (dy + 0.5f) * scale_y - 0.5f;
const int y0 = std::max(0, static_cast<int>(std::floor(sy)));
const int y1 = std::min(src_h - 1, y0 + 1);
const float beta = sy - y0;
const float inv_beta = 1.0f - beta;
const uint8_t* row0 = src + y0 * src_stride;
const uint8_t* row1 = src + y1 * src_stride;
uint8_t* dst_row = dst + dy * dst_w * 4;
#if defined(__ARM_NEON) || defined(__ARM_NEON__)
// NEON: procesa 4 píxeles objetivo a la vez
const float32x4_t v_beta = vdupq_n_f32(beta);
const float32x4_t v_inv_beta = vdupq_n_f32(inv_beta);
int dx = 0;
for (; dx + 4 <= dst_w; dx += 4) {
// Calcular 4 coordenadas de origen
float sx[4];
for (int i = 0; i < 4; ++i) {
sx[i] = ((dx + i) + 0.5f) * scale_x - 0.5f;
}
// Cargar pesos alpha
float alpha[4], inv_alpha[4];
int x0[4], x1[4];
for (int i = 0; i < 4; ++i) {
x0[i] = std::max(0, static_cast<int>(std::floor(sx[i])));
x1[i] = std::min(src_w - 1, x0[i] + 1);
alpha[i] = sx[i] - x0[i];
inv_alpha[i] = 1.0f - alpha[i];
}
// Realizar interpolación bilineal para cada canal
for (int c = 0; c < 4; ++c) { // RGBA
float32x4_t f00, f10, f01, f11;
// Recolectar valores vecinos de 4 píxeles
f00 = vsetq_lane_f32(row0[x0[0] * 4 + c], f00, 0);
f00 = vsetq_lane_f32(row0[x0[1] * 4 + c], f00, 1);
f00 = vsetq_lane_f32(row0[x0[2] * 4 + c], f00, 2);
f00 = vsetq_lane_f32(row0[x0[3] * 4 + c], f00, 3);
// ... f10, f01, f11 similar
// Fórmula de interpolación bilineal
float32x4_t v_alpha = vld1q_f32(alpha);
float32x4_t v_inv_alpha = vld1q_f32(inv_alpha);
float32x4_t top = vmlaq_f32(
vmulq_f32(f00, v_inv_alpha),
f10, v_alpha
);
float32x4_t bottom = vmlaq_f32(
vmulq_f32(f01, v_inv_alpha),
f11, v_alpha
);
float32x4_t result = vmlaq_f32(
vmulq_f32(top, v_inv_beta),
bottom, v_beta
);
// Convertir de vuelta a uint8 y almacenar
uint32x4_t result_u32 = vcvtq_u32_f32(result);
// ... almacenar
}
}
#endif
// Procesamiento escalar para los píxeles restantes (omitido)
}
}

TIP

La optimización NEON de la interpolación bilineal es compleja porque las direcciones de los cuatro píxeles vecinos no son contiguas. Un método más eficiente es usar la interpolación bilineal separable: primero interpolar en dirección horizontal y luego en vertical. Esto aprovecha mejor la localidad de la caché.

Esta elección conlleva más trabajo de desarrollo, pero los beneficios son notables:

  1. Reducción del tamaño del APK en unos 10 MB.
  2. Control total sobre la lógica de preprocesamiento, facilitando la optimización.
  3. Evita problemas de compatibilidad de versiones de OpenCV.

Transformación de perspectiva: Del rectángulo rotado a la línea de texto estándar

El modelo de reconocimiento de texto espera como entrada imágenes de líneas de texto horizontales. Sin embargo, los cuadros de texto detectados pueden ser rectángulos rotados en cualquier ángulo. La transformación de perspectiva se encarga de “enderezar” estas regiones.

En text_recognizer.cpp, el método CropAndRotate implementa esta función:

void CropAndRotate(const uint8_t *__restrict__ image_data,
int width, int height, int stride,
const RotatedRect &box, int &target_width) {
// Calcular las cuatro esquinas del rectángulo rotado
const float cos_angle = std::cos(box.angle * M_PI / 180.0f);
const float sin_angle = std::sin(box.angle * M_PI / 180.0f);
const float half_w = box.width / 2.0f;
const float half_h = box.height / 2.0f;
float corners[8]; // Coordenadas (x, y) de las 4 esquinas
corners[0] = box.center_x + (-half_w * cos_angle - (-half_h) * sin_angle);
corners[1] = box.center_y + (-half_w * sin_angle + (-half_h) * cos_angle);
// ... calcular otras esquinas
// Ancho adaptativo del objetivo: mantener relación de aspecto
const float aspect_ratio = src_width / std::max(src_height, 1.0f);
target_width = static_cast<int>(kRecInputHeight * aspect_ratio);
target_width = std::clamp(target_width, 1, kRecInputWidth); // 48x[1, 320]
// Matriz de transformación afín
const float a00 = (x1 - x0) * inv_dst_w;
const float a01 = (x3 - x0) * inv_dst_h;
const float a10 = (y1 - y0) * inv_dst_w;
const float a11 = (y3 - y0) * inv_dst_h;
// Muestreo por interpolación bilineal + normalización (optimización NEON)
for (int dy = 0; dy < kRecInputHeight; ++dy) {
for (int dx = 0; dx < target_width; ++dx) {
float sx = base_sx + a00 * dx;
float sy = base_sy + a10 * dx;
BilinearSampleNeon(image_data, stride, sx, sy, dst_row + dx * 3);
}
}
}

Optimizaciones clave de esta implementación:

  1. Ancho adaptativo: Ajusta dinámicamente el ancho de salida según la relación de aspecto del cuadro de texto, evitando estiramientos o compresiones excesivas.
  2. Aproximación por transformación afín: Para cuadros de texto que son casi paralelogramos, se usa la transformación afín en lugar de la de perspectiva para reducir el cálculo.
  3. Interpolación bilineal NEON: El muestreo y la normalización se realizan en una sola pasada, reduciendo los accesos a memoria.

JNI: El puente entre Kotlin y C++

JNI (Java Native Interface) es el puente de comunicación entre Kotlin/Java y C++. Sin embargo, las llamadas JNI tienen un coste; las llamadas frecuentes entre lenguajes pueden afectar seriamente al rendimiento.

El principio de diseño de PPOCRv5-Android es: minimizar el número de llamadas JNI. Todo el flujo de OCR solo requiere una llamada JNI:

sequenceDiagram
participant K as Capa Kotlin
participant J as Puente JNI
participant N as Capa Nativa
participant G as GPU
K->>J: process(bitmap)
J->>N: Pasar puntero RGBA
Note over N,G: La capa nativa realiza todo el trabajo
N->>N: Preprocesamiento de imagen NEON
N->>G: Inferencia de detección de texto
G-->>N: Mapa de probabilidad
N->>N: Post-procesamiento Detección de contornos
loop Cada cuadro de texto
N->>N: Recorte por transformación de perspectiva
N->>G: Inferencia de reconocimiento de texto
G-->>N: logits
N->>N: Decodificación CTC
end
N-->>J: Resultados OCR
J-->>K: List OcrResult

En ppocrv5_jni.cpp, la función central nativeProcess muestra este diseño:

JNIEXPORT jobjectArray JNICALL
Java_me_fleey_ppocrv5_ocr_OcrEngine_nativeProcess(
JNIEnv *env, jobject thiz, jlong handle, jobject bitmap) {
auto *engine = reinterpret_cast<ppocrv5::OcrEngine *>(handle);
// Bloquear píxeles del Bitmap
void *pixels = nullptr;
AndroidBitmap_lockPixels(env, bitmap, &pixels);
// Una sola llamada JNI completa todo el trabajo de OCR
auto results = engine->Process(
static_cast<const uint8_t *>(pixels),
static_cast<int>(bitmap_info.width),
static_cast<int>(bitmap_info.height),
static_cast<int>(bitmap_info.stride));
AndroidBitmap_unlockPixels(env, bitmap);
// Construir y devolver array de objetos Java
// ...
}

Este diseño evita el coste de pasar datos de ida y vuelta entre la detección y el reconocimiento.

Diseño de arquitectura: Modularidad y testabilidad

La arquitectura de PPOCRv5-Android sigue el principio de “separación de preocupaciones”:

flowchart TB
subgraph UI["Capa UI Jetpack Compose"]
direction LR
CP[CameraPreview]
GP[GalleryPicker]
RO[ResultOverlay]
end
subgraph VM["Capa ViewModel"]
OVM[OCRViewModel<br/>Gestión de estado]
end
subgraph Native["Capa Nativa - C++"]
OE[OcrEngine<br/>Orquestación]
subgraph Detection["Detección de texto"]
TD[TextDetector]
DB[DBNet FP16]
end
subgraph Recognition["Reconocimiento de texto"]
TR[TextRecognizer]
SVTR[SVTRv2 + CTC]
end
subgraph Preprocessing["Procesamiento de imagen"]
IP[ImagePreprocessor<br/>Optimizado con NEON]
PP[PostProcessor<br/>Detección de contornos]
end
subgraph Runtime["Runtime LiteRT"]
GPU[GPU Delegate<br/>OpenCL]
CPU[CPU Fallback<br/>XNNPACK]
end
end
CP --> OVM
GP --> OVM
OVM --> RO
OVM <-->|JNI| OE
OE --> TD
OE --> TR
TD --> DB
TR --> SVTR
TD --> IP
TR --> IP
DB --> PP
DB --> GPU
SVTR --> GPU
GPU -.->|Fallback| CPU

Los beneficios de esta arquitectura por capas son:

  1. Capa UI: Kotlin/Compose puro, enfocada en la interacción del usuario.
  2. Capa ViewModel: Gestiona el estado y la lógica de negocio.
  3. Capa Nativa: Computación de alto rendimiento, totalmente desacoplada de la UI.

Cada capa puede probarse de forma independiente. La capa nativa puede usar Google Test para pruebas unitarias, y la capa ViewModel puede usar JUnit + MockK.

Encapsulamiento en la capa Kotlin

En OcrEngine.kt, la capa Kotlin ofrece una API concisa:

class OcrEngine private constructor(
private var nativeHandle: Long,
) : Closeable {
companion object {
init {
System.loadLibrary("ppocrv5_jni")
}
fun create(
context: Context,
acceleratorType: AcceleratorType = AcceleratorType.GPU,
): Result<OcrEngine> = runCatching {
initializeCache(context)
val detModelPath = copyAssetToCache(context, "$MODELS_DIR/$DET_MODEL_FILE")
val recModelPath = copyAssetToCache(context, "$MODELS_DIR/$REC_MODEL_FILE")
val keysPath = copyAssetToCache(context, "$MODELS_DIR/$KEYS_FILE")
val handle = OcrEngine(0).nativeCreate(
detModelPath, recModelPath, keysPath,
acceleratorType.value,
)
if (handle == 0L) {
throw OcrException("Failed to create native OCR engine")
}
OcrEngine(handle)
}
}
fun process(bitmap: Bitmap): List<OcrResult> {
check(nativeHandle != 0L) { "OcrEngine has been closed" }
return nativeProcess(nativeHandle, bitmap)?.toList() ?: emptyList()
}
override fun close() {
if (nativeHandle != 0L) {
nativeDestroy(nativeHandle)
nativeHandle = 0
}
}
}

Ventajas de este diseño:

  1. Uso del tipo Result para manejar errores de inicialización.
  2. Implementación de la interfaz Closeable, permitiendo el uso de bloques use para liberar recursos automáticamente.
  3. Los archivos de modelo se copian automáticamente desde assets al directorio de caché.

Optimización del arranque en frío

La primera inferencia (arranque en frío) suele ser mucho más lenta que las siguientes (arranque en caliente). Esto se debe a que:

  1. El GPU Delegate necesita compilar el programa OpenCL.
  2. Los pesos del modelo deben transferirse de la memoria CPU a la memoria GPU.
  3. Es necesario precalentar varias cachés.

PPOCRv5-Android mitiga el problema del arranque en frío mediante un mecanismo de Warm-up:

void OcrEngine::WarmUp() {
LOGD(TAG, "Starting warm-up (%d iterations)...", kWarmupIterations);
// Crear una pequeña imagen de prueba
std::vector<uint8_t> dummy_image(kWarmupImageSize * kWarmupImageSize * 4, 128);
for (int i = 0; i < kWarmupImageSize * kWarmupImageSize; ++i) {
dummy_image[i * 4 + 0] = static_cast<uint8_t>((i * 7) % 256);
dummy_image[i * 4 + 1] = static_cast<uint8_t>((i * 11) % 256);
dummy_image[i * 4 + 2] = static_cast<uint8_t>((i * 13) % 256);
dummy_image[i * 4 + 3] = 255;
}
// Ejecutar varias inferencias para precalentar
for (int iter = 0; iter < kWarmupIterations; ++iter) {
float detection_time_ms = 0.0f;
detector_->Detect(dummy_image.data(), kWarmupImageSize, kWarmupImageSize,
kWarmupImageSize * 4, &detection_time_ms);
}
LOGD(TAG, "Warm-up completed (accelerator: %s)", AcceleratorName(active_accelerator_));
}

Optimización de la alineación de memoria

En TextDetector::Impl, todos los buffers preasignados utilizan una alineación de 64 bytes:

// Buffers preasignados con alineación de línea de caché
alignas(64) std::vector<uint8_t> resized_buffer_;
alignas(64) std::vector<float> normalized_buffer_;
alignas(64) std::vector<uint8_t> binary_map_;
alignas(64) std::vector<float> prob_map_;

La alineación de 64 bytes corresponde al tamaño de la línea de caché de los procesadores ARM modernos. El acceso a memoria alineada evita la división de líneas de caché, mejorando la eficiencia del acceso a memoria.

Pool de memoria y reutilización de objetos

La asignación y liberación frecuente de memoria es un asesino del rendimiento. PPOCRv5-Android utiliza una estrategia de preasignación, reservando toda la memoria necesaria de una vez durante la inicialización:

class TextDetector::Impl {
// Buffers preasignados, ciclo de vida igual a Impl
alignas(64) std::vector<uint8_t> resized_buffer_; // 640 * 640 * 4 = 1.6MB
alignas(64) std::vector<float> normalized_buffer_; // 640 * 640 * 3 * 4 = 4.9MB
alignas(64) std::vector<uint8_t> binary_map_; // 640 * 640 = 0.4MB
alignas(64) std::vector<float> prob_map_; // 640 * 640 * 4 = 1.6MB
bool Initialize(...) {
// Asignación única, evita malloc en tiempo de ejecución
resized_buffer_.resize(kDetInputSize * kDetInputSize * 4);
normalized_buffer_.resize(kDetInputSize * kDetInputSize * 3);
binary_map_.resize(kDetInputSize * kDetInputSize);
prob_map_.resize(kDetInputSize * kDetInputSize);
return true;
}
};

Beneficios de este diseño:

  1. Evita la fragmentación de memoria: Todos los bloques grandes se asignan al inicio, sin generar fragmentación durante la ejecución.
  2. Reduce las llamadas al sistema: malloc puede disparar llamadas al sistema; la preasignación evita este coste.
  3. Amigable con la caché: La memoria asignada de forma contigua tiene más probabilidades de ser físicamente contigua, mejorando la tasa de aciertos de la caché.

Optimización de la predicción de saltos

Los CPUs modernos utilizan la predicción de saltos (branch prediction) para mejorar la eficiencia del pipeline. Una predicción errónea puede causar un vaciado del pipeline, perdiendo entre 10 y 20 ciclos de reloj.

En las rutas críticas (hot paths), utilizamos __builtin_expect para dar pistas al compilador:

// La mayoría de los píxeles no superarán el umbral
if (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) {
binary_map_[i] = 255;
} else {
binary_map_[i] = 0;
}

__builtin_expect(expr, val) indica al compilador que es muy probable que el valor de expr sea val. El compilador ajusta el diseño del código en consecuencia, colocando las ramas “poco probables” lejos de la ruta principal.

Desenrollado de bucles y software pipelining

Para bucles con carga computacional intensiva, el desenrollado manual puede reducir el coste del bucle y exponer más paralelismo a nivel de instrucción:

// Versión sin desenrollar
for (int i = 0; i < n; ++i) {
dst[i] = src[i] * scale + bias;
}
// Versión desenrollada 4x
int i = 0;
for (; i + 4 <= n; i += 4) {
dst[i + 0] = src[i + 0] * scale + bias;
dst[i + 1] = src[i + 1] * scale + bias;
dst[i + 2] = src[i + 2] * scale + bias;
dst[i + 3] = src[i + 3] * scale + bias;
}
for (; i < n; ++i) {
dst[i] = src[i] * scale + bias;
}

Tras el desenrollado, el CPU puede ejecutar múltiples instrucciones de multiplicación y suma independientes simultáneamente, aprovechando al máximo las múltiples unidades de ejecución de las arquitecturas superescalares.

Optimización de Prefetch

En el bucle interno de la transformación de perspectiva, utilizamos __builtin_prefetch para cargar anticipadamente los datos de la siguiente línea:

for (int dy = 0; dy < kRecInputHeight; ++dy) {
// Prefetch de los datos de la siguiente línea
if (dy + 1 < kRecInputHeight) {
const float next_sy = y0 + a11 * (dy + 1);
const int next_y = static_cast<int>(next_sy);
if (next_y >= 0 && next_y < height) {
__builtin_prefetch(image_data + next_y * stride, 0, 1);
}
}
// ... procesar línea actual
}

Esta optimización puede ocultar la latencia de memoria; mientras se procesa la línea actual, los datos de la siguiente ya están en la caché L1.

Detalles de ingeniería del post-procesamiento

Análisis de componentes conectados y detección de contornos

En postprocess.cpp, la función FindContours implementa un análisis eficiente de componentes conectados:

std::vector<std::vector<Point>> FindContours(const uint8_t *binary_map,
int width, int height) {
// 1. Submuestreo 4x para reducir la carga computacional
int ds_width = (width + kDownsampleFactor - 1) / kDownsampleFactor;
int ds_height = (height + kDownsampleFactor - 1) / kDownsampleFactor;
std::vector<uint8_t> ds_map(ds_width * ds_height);
downsample_binary_map(binary_map, width, height,
ds_map.data(), ds_width, ds_height, kDownsampleFactor);
// 2. Recorrido BFS de componentes conectados
std::vector<int> labels(ds_width * ds_height, 0);
int current_label = 0;
for (int y = 0; y < ds_height; ++y) {
for (int x = 0; x < ds_width; ++x) {
if (pixel_at(ds_map.data(), x, y, ds_width) > 0 &&
labels[y * ds_width + x] == 0) {
current_label++;
std::vector<Point> boundary;
std::queue<std::pair<int, int>> queue;
queue.push({x, y});
while (!queue.empty()) {
auto [cx, cy] = queue.front();
queue.pop();
// Detectar píxeles de borde
if (is_boundary_pixel(ds_map.data(), cx, cy, ds_width, ds_height)) {
boundary.push_back({
static_cast<float>(cx * kDownsampleFactor + kDownsampleFactor / 2),
static_cast<float>(cy * kDownsampleFactor + kDownsampleFactor / 2)
});
}
// Expansión de 4 vecindades
for (int d = 0; d < 4; ++d) {
int nx = cx + kNeighborDx4[d];
int ny = cy + kNeighborDy4[d];
// ...
}
}
if (boundary.size() >= 4) {
contours.push_back(std::move(boundary));
}
}
}
}
return contours;
}

Puntos clave de optimización:

  1. Submuestreo 4x: Reduce el mapa binario de 640x640 a 160x160, disminuyendo la carga computacional en 16 veces.
  2. Detección de bordes: Solo se conservan los píxeles de borde, no todo el componente conectado.
  3. Límite máximo de contornos: kMaxContours = 100, para evitar problemas de rendimiento en casos extremos.

Algoritmos de envolvente convexa y Rotating Calipers

El cálculo del rectángulo rotado delimitador mínimo se divide en dos pasos: primero se calcula la envolvente convexa y luego se utiliza el algoritmo de Rotating Calipers para encontrar el rectángulo delimitador de área mínima.

Algoritmo de envolvente convexa Graham Scan

Graham Scan es un algoritmo clásico para calcular la envolvente convexa con una complejidad temporal de O(nlogn)O(n \log n):

std::vector<Point> ConvexHull(std::vector<Point> points) {
if (points.size() < 3) return points;
// 1. Encontrar el punto más bajo (y mínimo, x mínimo)
auto pivot = std::min_element(points.begin(), points.end(),
[](const Point& a, const Point& b) {
return a.y < b.y || (a.y == b.y && a.x < b.x);
});
std::swap(points[0], *pivot);
Point p0 = points[0];
// 2. Ordenar por ángulo polar
std::sort(points.begin() + 1, points.end(),
[&p0](const Point& a, const Point& b) {
float cross = CrossProduct(p0, a, b);
if (std::abs(cross) < 1e-6f) {
// Si son colineales, el más cercano va primero
return DistanceSquared(p0, a) < DistanceSquared(p0, b);
}
return cross > 0; // Sentido antihorario
});
// 3. Construir la envolvente
std::vector<Point> hull;
for (const auto& p : points) {
// Eliminar puntos que causen un giro en sentido horario
while (hull.size() > 1 &&
CrossProduct(hull[hull.size()-2], hull[hull.size()-1], p) <= 0) {
hull.pop_back();
}
hull.push_back(p);
}
return hull;
}
// Producto cruzado: para determinar la dirección del giro
float CrossProduct(const Point& o, const Point& a, const Point& b) {
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
}

Algoritmo Rotating Calipers

El algoritmo de Rotating Calipers recorre cada arista de la envolvente convexa y calcula el área del rectángulo delimitador que tiene esa arista como base:

RotatedRect MinAreaRect(const std::vector<Point>& hull) {
if (hull.size() < 3) return {};
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
int n = hull.size();
int right = 1, top = 1, left = 1; // Posiciones de los tres "calipers"
for (int i = 0; i < n; ++i) {
int j = (i + 1) % n;
// Vector de dirección de la arista actual
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
float edge_len = std::sqrt(edge_x * edge_x + edge_y * edge_y);
// Vector unitario
float ux = edge_x / edge_len;
float uy = edge_y / edge_len;
// Dirección perpendicular
float vx = -uy;
float vy = ux;
// Encontrar el punto más a la derecha (proyección máxima en dirección de la arista)
while (Dot(hull[(right + 1) % n], ux, uy) > Dot(hull[right], ux, uy)) {
right = (right + 1) % n;
}
// Encontrar el punto más arriba (proyección máxima en dirección perpendicular)
while (Dot(hull[(top + 1) % n], vx, vy) > Dot(hull[top], vx, vy)) {
top = (top + 1) % n;
}
// Encontrar el punto más a la izquierda
while (Dot(hull[(left + 1) % n], ux, uy) < Dot(hull[left], ux, uy)) {
left = (left + 1) % n;
}
// Calcular dimensiones del rectángulo
float width = Dot(hull[right], ux, uy) - Dot(hull[left], ux, uy);
float height = Dot(hull[top], vx, vy) - Dot(hull[i], vx, vy);
float area = width * height;
if (area < min_area) {
min_area = area;
// Actualizar parámetros del mejor rectángulo
best_rect.width = width;
best_rect.height = height;
best_rect.angle = std::atan2(uy, ux) * 180.0f / M_PI;
// Calcular punto central...
}
}
return best_rect;
}

La idea clave de Rotating Calipers es que, al rotar la base, los tres “calipers” (puntos más a la derecha, arriba e izquierda) solo avanzan de forma monótona, nunca retroceden. Por lo tanto, la complejidad total es O(n)O(n), no O(n2)O(n^2).

Rectángulo rotado delimitador mínimo

La función MinAreaRect utiliza el algoritmo de Rotating Calipers para calcular el rectángulo rotado delimitador mínimo:

RotatedRect MinAreaRect(const std::vector<Point> &contour) {
// 1. Submuestreo para reducir el número de puntos
std::vector<Point> points = subsample_points(contour, kMaxBoundaryPoints);
// 2. Ruta rápida: para cuadros de texto con alta relación de aspecto, usar AABB directamente
float aspect = std::max(aabb_width, aabb_height) /
std::max(1.0f, std::min(aabb_width, aabb_height));
if (aspect > 2.0f && points.size() > 50) {
// Devolver cuadro delimitador alineado con los ejes
RotatedRect rect;
rect.center_x = (min_x + max_x) / 2.0f;
rect.center_y = (min_y + max_y) / 2.0f;
rect.width = aabb_width;
rect.height = aabb_height;
rect.angle = 0.0f;
return rect;
}
// 3. Cálculo de envolvente convexa
std::vector<Point> hull = convex_hull(std::vector<Point>(points));
// 4. Rotating Calipers: recorrer cada arista de la envolvente
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
for (size_t i = 0; i < hull.size(); ++i) {
// Calcular rectángulo delimitador basándose en la arista actual
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
// Proyectar todos los puntos en la dirección de la arista y en la perpendicular
project_points_onto_axis(hull, axis1_x, axis1_y, min1, max1);
project_points_onto_axis(hull, axis2_x, axis2_y, min2, max2);
float area = (max1 - min1) * (max2 - min2);
if (area < min_area) {
min_area = area;
// Actualizar el mejor rectángulo
}
}
return best_rect;
}

La complejidad temporal de este algoritmo es O(nlogn)O(n \log n) (cálculo de envolvente) + O(n)O(n) (Rotating Calipers), donde nn es el número de puntos del borde. Al limitar nn a 200 mediante submuestreo, se asegura el rendimiento en tiempo real.

OCR de cámara en tiempo real: CameraX y análisis de frames

El reto del OCR en tiempo real es: ¿cómo procesar cada frame lo más rápido posible manteniendo una vista previa fluida?

flowchart TB
subgraph Camera["Pipeline CameraX"]
direction TB
CP[CameraProvider]
PV[UseCase Preview<br/>30 FPS]
IA[UseCase ImageAnalysis<br/>STRATEGY_KEEP_ONLY_LATEST]
end
subgraph Analysis["Flujo de análisis de frames"]
direction TB
IP[ImageProxy<br/>YUV_420_888]
BM[Conversión a Bitmap<br/>RGBA_8888]
JNI[Llamada JNI<br/>Única entre lenguajes]
end
subgraph Native["OCR Nativo"]
direction TB
DET[TextDetector<br/>~45ms GPU]
REC[TextRecognizer<br/>~15ms/línea]
RES[Resultados OCR]
end
subgraph UI["Actualización UI"]
direction TB
VM[ViewModel<br/>StateFlow]
OV[ResultOverlay<br/>Dibujo en Canvas]
end
CP --> PV
CP --> IA
IA --> IP --> BM --> JNI
JNI --> DET --> REC --> RES
RES --> VM --> OV

ImageAnalysis de CameraX

CameraX es la librería de cámara de Android Jetpack, que proporciona el caso de uso ImageAnalysis, permitiéndonos analizar los frames de la cámara en tiempo real:

val imageAnalysis = ImageAnalysis.Builder()
.setTargetResolution(Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
imageAnalysis.setAnalyzer(executor) { imageProxy ->
val bitmap = imageProxy.toBitmap()
val result = ocrEngine.process(bitmap)
// Actualizar UI
imageProxy.close()
}

La configuración clave es STRATEGY_KEEP_ONLY_LATEST: cuando la velocidad de procesamiento del analizador no puede seguir el ritmo de los frames de la cámara, se descartan los frames antiguos y solo se conserva el más reciente. Esto garantiza la actualidad de los resultados del OCR.

Equilibrio entre FPS y latencia

En dispositivos con aceleración por GPU (parece que mi Snapdragon 870 actual tiene problemas y no logra delegar la mayor parte del cálculo a la GPU), PPOCRv5-Android teóricamente puede alcanzar velocidades de procesamiento elevadas. Pero esto no significa que debamos procesar cada frame.

Consideremos este escenario: el usuario apunta la cámara a un texto; el contenido del texto no cambiará en un corto periodo de tiempo. Si realizamos un OCR completo en cada frame, desperdiciaremos una gran cantidad de recursos computacionales.

Una estrategia de optimización es la “detección de cambios”: solo se dispara el OCR cuando la imagen cambia significativamente. Esto puede lograrse comparando histogramas o puntos característicos de frames consecutivos.

Perspectivas futuras: NPU y cuantización

El futuro de la IA en el dispositivo reside en las NPU (Neural Processing Unit). En comparación con las GPU, las NPU están diseñadas específicamente para la inferencia de redes neuronales y ofrecen una mejor eficiencia energética.

Sin embargo, el reto de las NPU es la fragmentación. Cada fabricante de chips tiene su propia arquitectura de NPU y SDK:

  • Qualcomm: Hexagon DSP + AI Engine.
  • MediaTek: APU.
  • Samsung: Exynos NPU.
  • Google: Tensor TPU.

La NNAPI (Neural Networks API) de Android intenta proporcionar una capa de abstracción unificada, pero los resultados reales son irregulares. Muchas funciones de las NPU no se exponen a través de NNAPI, obligando a los desarrolladores a usar SDKs específicos de los fabricantes.

Cuantización INT8: Una batalla inacabada

La cuantización FP16 es una opción conservadora que apenas pierde precisión. Pero si se busca el rendimiento extremo, la cuantización INT8 es el siguiente paso.

La cuantización INT8 comprime los pesos y activaciones de punto flotante de 32 bits a enteros de 8 bits, lo que teóricamente puede aportar:

  • Reducción del tamaño del modelo en 4 veces.
  • Aceleración de la inferencia de 2 a 4 veces (dependiendo del hardware).
  • En el DSP Hexagon de Qualcomm, se puede lograr una aceleración de más de 10 veces.

La tentación era demasiado grande, así que comencé un largo viaje por la cuantización INT8.

Primer intento: Calibración con datos sintéticos

La cuantización INT8 requiere un conjunto de datos de calibración para determinar los parámetros de cuantización (Scale y Zero Point). Inicialmente, por pereza, utilicé imágenes generadas aleatoriamente que “parecían” texto:

# Error: usar ruido aleatorio para la calibración
img = np.ones((h, w, 3), dtype=np.float32) * 0.9
for _ in range(num_lines):
gray_val = np.random.uniform(0.05, 0.3)
img[y:y+line_h, x:x+line_w] = gray_val

El resultado fue desastroso. El modelo solo devolvía ceros:

Raw FLOAT32 output range: min=0.0000, max=0.0000
Prob map stats: min=0.0000, max=0.0000, mean=0.000000

La herramienta de cuantización calculó parámetros erróneos basados en el ruido aleatorio, provocando que los valores de activación de las imágenes reales fueran truncados.

Segundo intento: Calibración con imágenes reales

Cambié a imágenes reales de conjuntos de datos de OCR: ICDAR2015, TextOCR y ejemplos oficiales de PaddleOCR. Al mismo tiempo, implementé el preprocesamiento Letterbox para asegurar que la distribución de las imágenes durante la calibración fuera consistente con la de la inferencia:

def letterbox_image(image, target_size):
"""Escalar manteniendo relación de aspecto, rellenar con gris el resto"""
ih, iw = image.shape[:2]
h, w = target_size
scale = min(w / iw, h / ih)
# ... pegar centrado

El modelo dejó de devolver solo ceros, pero los resultados del reconocimiento seguían siendo basura.

Tercer intento: Corregir el manejo de tipos en C++

Descubrí que el código C++ tenía problemas al manejar entradas INT8. El modelo INT8 espera valores de píxel originales (0-255), mientras que yo seguía realizando la normalización ImageNet (restar media y dividir por desviación).

if (input_is_int8_) {
// Modelo INT8: entrada directa de píxeles originales, normalización integrada en la primera capa
dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);
} else {
// Modelo FP32: requiere normalización manual
// (pixel - mean) / std
}

Además, implementé la lógica para leer dinámicamente los parámetros de cuantización en lugar de codificarlos a fuego:

bool GetQuantizationParams(LiteRtTensor tensor, float* scale, int32_t* zero_point) {
LiteRtQuantization quant;
LiteRtGetTensorQuantization(tensor, &quant);
// ...
}

Resultado final: Compromiso

Tras varios días de depuración, el modelo INT8 seguía sin funcionar correctamente. El problema podría deberse a:

  1. Implementación de cuantización de onnx2tf: PP-OCRv5 usa combinaciones de operadores especiales que onnx2tf podría no haber manejado correctamente durante la cuantización.
  2. Características de salida de DBNet: DBNet devuelve un mapa de probabilidad con valores entre 0 y 1; la cuantización INT8 es especialmente sensible a este rango pequeño de valores.
  3. Acumulación de errores en modelos multietapa: Al encadenar los modelos de detección y reconocimiento, los errores de cuantización se acumulan y amplifican.

Analicemos el segundo punto. La salida de DBNet pasa por una activación Sigmoid, comprimiendo el rango a [0, 1]. La cuantización INT8 usa la fórmula:

xquantized=round(xfloatscale)+zero_pointx_{quantized} = \text{round}\left(\frac{x_{float}}{scale}\right) + zero\_point

Para valores en el rango [0, 1], si el scale no se ajusta adecuadamente, los valores cuantizados podrían ocupar solo una pequeña parte del rango INT8 [-128, 127], causando una pérdida grave de precisión.

# Asumiendo scale = 0.00784 (1/127), zero_point = 0
# Entrada 0.5 -> round(0.5 / 0.00784) + 0 = 64
# Entrada 0.1 -> round(0.1 / 0.00784) + 0 = 13
# Entrada 0.01 -> round(0.01 / 0.00784) + 0 = 1
# Entrada 0.001 -> round(0.001 / 0.00784) + 0 = 0 # ¡Pérdida de precisión!

El umbral de DBNet suele fijarse entre 0.1 y 0.3, lo que significa que una gran cantidad de valores de probabilidad significativos (0.1-0.3) solo pueden representarse con 25 enteros (del 13 al 38) tras la cuantización, lo que resulta en una resolución insuficiente.

WARNING

La cuantización INT8 de PP-OCRv5 es un reto conocido. Si lo estás intentando, te sugiero confirmar primero que el modelo FP32 funciona correctamente antes de investigar problemas de cuantización. Alternativamente, considera usar el framework oficial Paddle Lite de PaddlePaddle, que ofrece mejor soporte para PaddleOCR.

Entrenamiento consciente de la cuantización: La solución correcta

Si es imprescindible usar cuantización INT8, el método correcto es el entrenamiento consciente de la cuantización (Quantization-Aware Training, QAT), en lugar de la cuantización post-entrenamiento (Post-Training Quantization, PTQ).

QAT simula los errores de cuantización durante el entrenamiento, permitiendo que el modelo aprenda a adaptarse a representaciones de baja precisión:

# Ejemplo de QAT en PyTorch
import torch.quantization as quant
model = DBNet()
model.qconfig = quant.get_default_qat_qconfig('fbgemm')
model_prepared = quant.prepare_qat(model)
# Entrenamiento normal, pero con nodos de pseudo-cuantización insertados en el forward pass
for epoch in range(num_epochs):
for images, labels in dataloader:
outputs = model_prepared(images) # Incluye simulación de cuantización
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# Convertir al modelo cuantizado real
model_quantized = quant.convert(model_prepared)

Lamentablemente, el equipo oficial de PP-OCRv5 no ha proporcionado modelos entrenados con QAT. Esto significa que para obtener un modelo INT8 de alta calidad, habría que realizar el entrenamiento QAT desde cero, lo cual queda fuera del alcance de este proyecto.

Finalmente, opté por un compromiso: usar cuantización FP16 + aceleración por GPU, en lugar de INT8 + DSP.

El coste de esta decisión es:

  • El tamaño del modelo es el doble que en INT8.
  • No se puede aprovechar el consumo ultrabajo del DSP Hexagon.
  • La velocidad de inferencia es 2-3 veces más lenta que el óptimo teórico.

Pero el beneficio es:

  • La precisión del modelo es casi idéntica a la de FP32.
  • El ciclo de desarrollo se acorta drásticamente.
  • La complejidad del código disminuye.

La esencia de la ingeniería es el equilibrio. A veces, “suficientemente bueno” es más importante que “teóricamente óptimo”.

Conclusión

De PaddlePaddle a TFLite, de DBNet a SVTRv2, de OpenCL a NEON, la práctica de ingeniería del OCR en el dispositivo involucra conocimientos de múltiples campos como el aprendizaje profundo, compiladores, programación de GPU y desarrollo móvil.

La lección central de este proyecto es que la IA en el dispositivo no es simplemente “poner el modelo en el móvil”. Requiere:

  1. Entender profundamente la arquitectura del modelo para realizar una conversión correcta.
  2. Conocer las características del hardware para aprovechar plenamente los aceleradores.
  3. Dominar la programación de sistemas para implementar código nativo de alto rendimiento.
  4. Centrarse en la experiencia del usuario para encontrar el equilibrio entre rendimiento y consumo de energía.

PPOCRv5-Android es un proyecto de código abierto que muestra cómo desplegar modelos modernos de OCR en aplicaciones móviles reales. Espero que este artículo sirva de referencia para desarrolladores con necesidades similares.

Como dijo Google en el lanzamiento de LiteRT: “Maximum performance, simplified.” 9 El objetivo de la IA en el dispositivo no es la complejidad, sino simplificar lo complejo.

Para ser sincero, me he alejado de Android (tanto en el ámbito laboral como personal) durante al menos dos años, y esta es la primera vez que publico una librería relativamente madura en mi cuenta secundaria de GitHub (le entregué mi cuenta principal a un colega para demostrar mi determinación de marcharme).

En estos años, mi enfoque laboral no ha estado en el campo de Android; no es conveniente revelar los detalles, pero tendré oportunidad de hablar de ello en el futuro. En resumen, quizás me sea difícil volver a hacer grandes contribuciones en Android.

El lanzamiento de este proyecto nace de mi interés personal, mientras construyo una herramienta temprana basada en Android en el dispositivo, de la cual el OCR es solo una pequeña parte de la capa inferior. Más adelante (debería ser pronto) también abriré el código fuente completo, aunque por ahora no es conveniente dar detalles.

En fin, gracias por llegar hasta aquí, y espero que puedas darle una estrella (Star) a mi repositorio. ¡Gracias!


Referencias

Footnotes

  1. Google AI Edge. “LiteRT: Maximum performance, simplified.” 2024. https://developers.googleblog.com/litert-maximum-performance-simplified/

  2. PaddleOCR Team. “PaddleOCR 3.0 Technical Report.” arXiv:2507.05595, 2025. https://arxiv.org/abs/2507.05595

  3. GitHub Discussion. “Problem while deploying the newest official PP-OCRv5.” PaddleOCR #16100, 2025. https://github.com/PaddlePaddle/PaddleOCR/discussions/16100

  4. Liao, M., et al. “Real-time Scene Text Detection with Differentiable Binarization.” Proceedings of the AAAI Conference on Artificial Intelligence, 2020. https://arxiv.org/abs/1911.08947

  5. Du, Y., et al. “SVTR: Scene Text Recognition with a Single Visual Model.” IJCAI, 2022. https://arxiv.org/abs/2205.00159

  6. Du, Y., et al. “SVTRv2: CTC Beats Encoder-Decoder Models in Scene Text Recognition.” ICCV, 2025. https://arxiv.org/abs/2411.15858 2

  7. TensorFlow Blog. “Even Faster Mobile GPU Inference with OpenCL.” 2020. https://blog.tensorflow.org/2020/08/faster-mobile-gpu-inference-with-opencl.html

  8. ARM Developer. “Neon Intrinsics on Android.” ARM Documentation, 2024. https://developer.arm.com/documentation/101964/latest/

  9. Google AI Edge. “LiteRT Documentation.” 2024. https://ai.google.dev/edge/litert

~
~
mobile/ppocrv5-android.md
$ license --info

Licencia

Salvo que se indique lo contrario, todos los artículos y materiales de este blog están bajo la licencia Creative Commons Atribución-NoComercial-CompartirIgual 4.0 Internacional (CC BY-NC-SA 4.0)

✓ ¡Copiado!