$ Перейти к основному содержимому
Обзор
0% · Осталось ...
0%
Практика OCR на устройствах: нативное развертывание PP-OCRv5 на Android
$ cat mobile/ppocrv5-android.md

# Практика OCR на устройствах: нативное развертывание PP-OCRv5 на Android

Автор:
Дата: 29 декабря 2025 г. в 20:17
Время чтения: Примерно 23 мин. чтения
mobile/ppocrv5-android.md

Эта страница переведена ИИ. В случае расхождений обращайтесь к оригинальной статье.

Примечания

Этот пост в блоге:

  • Обложка: Сгенерирована на основе Google Nano Banana 2, без авторских прав.
  • Исходный код проекта: Опубликован на GitHub, пожалуйста, посетите PPOCRv5-Android для получения доступа.

Дисклеймер:

Автор (Fleey) не является профессионалом в области ИИ, проект создан исключительно из интереса. Прошу читателей отнестись с пониманием к возможным упущениям и ошибкам и своевременно указывать на них!

Введение

В 2024 году Google переименовала TensorFlow Lite в LiteRT. Это не просто ребрендинг, а символ смены парадигмы в области мобильного ИИ: от «mobile-first» к «edge-first» 1. В этом контексте OCR (оптическое распознавание символов), как одно из самых практичных применений ИИ на устройствах, переживает тихую революцию.

Команда PaddleOCR из Baidu в 2025 году выпустила PP-OCRv5 — унифицированную модель OCR с поддержкой упрощенного и традиционного китайского, английского, японского и других языков 2. Ее мобильная версия весит всего около 70 МБ, но способна распознавать 18 383 символа в рамках одной модели. За этой цифрой стоит совместная работа двух глубоких нейронных сетей: детекции и распознавания.

Но есть проблема: PP-OCRv5 обучена на фреймворке PaddlePaddle, в то время как самым зрелым движком инференса на Android является LiteRT. Как преодолеть этот разрыв?

Давайте начнем с конвертации модели и постепенно раскроем инженерные аспекты реализации OCR на мобильных устройствах.

flowchart TB
subgraph E2E["End-to-End OCR процесс"]
direction TB
subgraph Input["Вход"]
IMG[Исходное изображение<br/>Любой размер]
end
subgraph Detection["Детекция текста - DBNet"]
DET_PRE[Предобработка<br/>Resize 640x640<br/>ImageNet Normalize]
DET_INF[Инференс DBNet<br/>~45ms GPU]
DET_POST[Постпроцессинг<br/>Бинаризация - Контуры - Повернутый прямоугольник]
end
subgraph Recognition["Распознавание текста - SVTRv2"]
REC_CROP[Обрезка с персп. преобр.<br/>48xW адаптивная ширина]
REC_INF[Инференс SVTRv2<br/>~15ms/строка GPU]
REC_CTC[CTC декодирование<br/>Слияние дублей + Удаление пробелов]
end
subgraph Output["Выход"]
RES[Результаты OCR<br/>Текст + Уверенность + Позиция]
end
end
IMG --> DET_PRE --> DET_INF --> DET_POST
DET_POST -->|N текстовых блоков| REC_CROP
REC_CROP --> REC_INF --> REC_CTC --> RES

Конвертация модели: долгий путь от PaddlePaddle до TFLite

Фрагментация фреймворков глубокого обучения — это «боль» всей индустрии. PyTorch, TensorFlow, PaddlePaddle, ONNX — у каждого свои форматы моделей и реализации операторов. ONNX (Open Neural Network Exchange) пытается стать универсальным промежуточным представлением, но реальность часто оказывается суровее ожиданий.

Путь конвертации PP-OCRv5 выглядит следующим образом:

flowchart LR
subgraph PaddlePaddle["PaddlePaddle Framework"]
PM[inference.json<br/>inference.pdiparams]
end
subgraph ONNX["Промежуточный ONNX"]
OM[model.onnx<br/>opset 14]
end
subgraph Optimization["Оптимизация графа"]
GS[onnx-graphsurgeon<br/>Декомпозиция операторов]
end
subgraph TFLite["Формат LiteRT"]
TM[model.tflite<br/>FP16 Quantized]
end
PM -->|paddle2onnx| OM
OM -->|Декомпозиция HardSigmoid<br/>Изменение режима Resize| GS
GS -->|onnx2tf| TM

Этот путь кажется простым, но он полон скрытых нюансов.

Первое препятствие: совместимость операторов в paddle2onnx

paddle2onnx — это официальный инструмент конвертации от PaddlePaddle. Теоретически он может перевести модель PaddlePaddle в формат ONNX. Однако PP-OCRv5 использует специфические операторы, маппинг которых в ONNX не всегда однозначен.

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

Важная деталь: файлы модели PP-OCRv5 называются inference.json, а не традиционно inference.pdmodel. Это изменение формата в новых версиях PaddlePaddle, на котором многие разработчики «спотыкаются» 3.

Второе препятствие: HardSigmoid и совместимость с GPU

Конвертированная ONNX-модель содержит оператор HardSigmoid. Математически он определяется как:

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

где α=0.2\alpha = 0.2, β=0.5\beta = 0.5.

Проблема в том, что GPU Delegate в LiteRT не поддерживает HardSigmoid. Когда модель содержит неподдерживаемый оператор, GPU Delegate выполняет «fallback» всего подграфа на CPU, что приводит к серьезной потере производительности.

Решение — разложить HardSigmoid на базовые операторы. Используя библиотеку onnx-graphsurgeon, мы можем провести «хирургическую операцию» на уровне графа вычислений:

import onnx_graphsurgeon as gs
import numpy as np
def decompose_hardsigmoid(graph: gs.Graph) -> gs.Graph:
"""
Разложение HardSigmoid на базовые операторы, дружелюбные к GPU
HardSigmoid(x) = max(0, min(1, alpha*x + beta))
Разлагается на: Mul -> Add -> Clip
"""
for node in graph.nodes:
if node.op == "HardSigmoid":
# Получение параметров 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]
# Создание константных тензоров
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)
)
# Создание промежуточных переменных
mul_out = gs.Variable(name=f"{node.name}_mul_out")
add_out = gs.Variable(name=f"{node.name}_add_out")
# Построение разложенного подграфа: 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}
)
# Замена исходного узла
graph.nodes.remove(node)
graph.nodes.extend([mul_node, add_node, clip_node])
graph.cleanup().toposort()
return graph

Ключевой момент здесь в том, что Mul, Add и Clip — это операторы, полностью поддерживаемые LiteRT GPU Delegate. После разложения весь подграф может выполняться на GPU непрерывно, избегая накладных расходов на передачу данных между CPU и GPU.

TIP

Почему бы не изменить код обучения модели напрямую? Потому что расчет градиентов для HardSigmoid при обучении отличается от Clip. Разложение следует проводить только на этапе инференса для сохранения численной стабильности обучения.

Третье препятствие: режим трансформации координат оператора Resize

Оператор Resize в ONNX имеет атрибут coordinate_transformation_mode, который определяет, как выходные координаты отображаются на входные. PP-OCRv5 использует режим half_pixel, но поддержка этого режима в LiteRT GPU Delegate ограничена.

Изменение его на режим asymmetric позволяет добиться лучшей совместимости с GPU:

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

WARNING

Такая модификация может привести к незначительным численным различиям. В реальных тестах влияние этих различий на точность OCR пренебрежимо мало, но в других задачах может потребоваться тщательная оценка.

Последний шаг: onnx2tf и квантование FP16

onnx2tf — это инструмент для конвертации ONNX-моделей в формат TFLite. Квантование FP16 (половинная точность) — частый выбор для мобильного развертывания. Оно уменьшает размер модели вдвое при приемлемой потере точности и позволяет использовать вычислительные блоки FP16 мобильных GPU.

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

Параметр -ois здесь задает статическую форму входных данных. Статическая форма критически важна для ускорения на GPU; динамические формы заставляют перекомпилировать программу GPU при каждом инференсе, что сильно бьет по производительности.

Детекция текста: дифференцируемая бинаризация DBNet

Модуль детекции в PP-OCRv5 основан на DBNet (Differentiable Binarization Network) 4. Традиционные методы детекции текста используют фиксированный порог для бинаризации, в то время как инновация DBNet заключается в том, что сеть сама обучается оптимальному порогу для каждого пикселя.

flowchart TB
subgraph DBNet["Архитектура DBNet"]
direction TB
IMG[Входное изображение<br/>H x W x 3]
BB[Backbone<br/>MobileNetV3]
FPN[FPN Пирамида признаков<br/>Многомасштабное слияние]
subgraph Heads["Двухветвевой вывод"]
PH[Ветка карты вероятностей<br/>P: H x W x 1]
TH[Ветка карты порогов<br/>T: H x W x 1]
end
DB["Дифференцируемая бинаризация<br/>B = sigmoid k * P-T"]
end
IMG --> BB --> FPN
FPN --> PH
FPN --> TH
PH --> DB
TH --> DB

Стандартная бинаризация vs Дифференцируемая бинаризация

Стандартная бинаризация — это ступенчатая функция:

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

Эта функция не дифференцируема, что делает невозможным сквозное обучение (end-to-end) через обратное распространение ошибки. DBNet предлагает аппроксимирующую функцию:

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

Где PP — карта вероятностей, TT — карта порогов (обучаемая сетью), а kk — коэффициент усиления (при обучении устанавливается равным 50).

TIP

Эта формула по сути является функцией Sigmoid, где входом служит PTP - T. Когда kk достаточно велико, ее поведение приближается к ступенчатой функции, сохраняя при этом дифференцируемость.

Инженерная реализация процесса постпроцессинга

В проекте PPOCRv5-Android процесс постпроцессинга реализован в файле postprocess.cpp. Основные этапы включают:

flowchart LR
subgraph Input["Вывод модели"]
PM[Карта вероятностей P<br/>640 x 640]
end
subgraph Binary["Бинаризация"]
BT[Фильтрация по порогу<br/>threshold=0.1]
BM[Бинарная карта<br/>640 x 640]
end
subgraph Contour["Детекция контуров"]
DS[4x Даунсэмплинг<br/>160 x 160]
CC[Анализ связных областей<br/>Обход BFS]
BD[Извлечение граничных точек]
end
subgraph Geometry["Геометрические вычисления"]
CH[Выпуклая оболочка<br/>Graham Scan]
RR[Вращающиеся калибры<br/>MinAreaRect]
UC[Расширение Unclip<br/>ratio=1.5]
end
subgraph Output["Выход"]
TB[RotatedRect<br/>center, size, angle]
end
PM --> BT --> BM
BM --> DS --> CC --> BD
BD --> CH --> RR --> UC --> TB

В реальном коде метод TextDetector::Impl::Detect демонстрирует полный цикл детекции:

std::vector<RotatedRect> Detect(const uint8_t *image_data,
int width, int height, int stride,
float *detection_time_ms) {
// 1. Расчет коэффициентов масштабирования
scale_x_ = static_cast<float>(width) / kDetInputSize;
scale_y_ = static_cast<float>(height) / kDetInputSize;
// 2. Билинейная интерполяция до 640x640
image_utils::ResizeBilinear(image_data, width, height, stride,
resized_buffer_.data(), kDetInputSize, kDetInputSize);
// 3. Нормализация ImageNet
PrepareFloatInput();
// 4. Инференс
auto run_result = compiled_model_->Run(input_buffers_, output_buffers_);
// 5. Бинаризация
BinarizeOutput(prob_map, total_pixels);
// 6. Детекция контуров
auto contours = postprocess::FindContours(binary_map_.data(),
kDetInputSize, kDetInputSize);
// 7. Минимальный ограничивающий прямоугольник + Unclip
for (const auto &contour : contours) {
RotatedRect rect = postprocess::MinAreaRect(contour);
UnclipBox(rect, kUnclipRatio);
// Масштабирование координат обратно к оригиналу
rect.center_x *= scale_x_;
rect.center_y *= scale_y_;
// ...
}
}

Ключевым моментом здесь является «минимальный ограничивающий повернутый прямоугольник». В отличие от рамок, выровненных по осям (AABB), повернутые прямоугольники могут плотно прилегать к тексту под любым углом, что критично для наклонного текста в естественных сценах.

Unclip: алгоритм расширения текстовых рамок

Области текста, выдаваемые DBNet, обычно немного меньше реального текста, так как сеть обучается на «ядре» текстовой области. Чтобы получить полные границы текста, необходимо выполнить операцию расширения (Unclip) обнаруженного многоугольника.

Математический принцип Unclip основан на обратной операции алгоритма отсечения многоугольников Ватти. Для многоугольника PP и расстояния расширения dd, расширенный многоугольник PP' удовлетворяет условию:

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

Где AA — площадь многоугольника, LL — периметр, а rr — коэффициент расширения (обычно устанавливается равным 1.5).

В postprocess.cpp функция UnclipBox реализует эту логику:

void UnclipBox(RotatedRect &box, float unclip_ratio) {
// Расчет расстояния расширения
float area = box.width * box.height;
float perimeter = 2.0f * (box.width + box.height);
if (perimeter < 1e-6f) return; // Защита от деления на ноль
// d = A * r / L
float distance = area * unclip_ratio / perimeter;
// Расширение наружу: ширина и высота увеличиваются на 2d
box.width += 2.0f * distance;
box.height += 2.0f * distance;
}

Эта упрощенная версия предполагает, что текстовая рамка является прямоугольником. Для более сложных многоугольников потребуется полноценная реализация смещения многоугольника с использованием библиотеки Clipper:

// Полный Unclip многоугольника (с использованием библиотеки Clipper)
ClipperLib::Path polygon;
for (const auto& pt : contour) {
polygon.push_back(ClipperLib::IntPoint(
static_cast<int>(pt.x * 1000), // Масштабирование для сохранения точности
static_cast<int>(pt.y * 1000)
));
}
ClipperLib::ClipperOffset offset;
offset.AddPath(polygon, ClipperLib::jtRound, ClipperLib::etClosedPolygon);
ClipperLib::Paths solution;
offset.Execute(solution, distance * 1000); // Расширение

NOTE

В PPOCRv5-Android выбрано упрощенное прямоугольное расширение вместо полного смещения многоугольника. Это обусловлено тем, что:

  • Большинство текстовых рамок близки к прямоугольным.
  • Полноценная библиотека Clipper значительно увеличивает размер бинарного файла.
  • Упрощенная версия работает быстрее.

Распознавание текста: SVTRv2 и CTC-декодирование

Если детекция — это «найти, где текст», то распознавание — это «прочитать, что там написано». Модуль распознавания в PP-OCRv5 основан на SVTRv2 (Scene Text Recognition with Visual Transformer v2) 5.

Инновации в архитектуре SVTRv2

SVTRv2 имеет три ключевых улучшения по сравнению с предыдущим поколением SVTR:

flowchart TB
subgraph SVTRv2["Архитектура SVTRv2"]
direction TB
subgraph Encoder["Визуальный энкодер"]
PE[Patch Embedding<br/>4x4 свертка]
subgraph Mixing["Блок смешанного внимания x12"]
LA[Локальное внимание<br/>окно 7x7]
GA[Глобальное внимание<br/>глобальное поле восприятия]
FFN[Feed Forward<br/>MLP]
end
end
subgraph Decoder["CTC декодер"]
FC[Полносвязный слой<br/>D -> 18384]
SM[Softmax]
CTC[CTC Decode]
end
end
PE --> LA --> GA --> FFN
FFN --> FC --> SM --> CTC
  1. Механизм смешанного внимания: поочередное использование локального внимания (для захвата деталей штрихов) и глобального внимания (для понимания структуры символов). Локальное внимание использует скользящее окно 7x7, что снижает вычислительную сложность с O(n2)O(n^2) до O(n×49)O(n \times 49).

  2. Многомасштабное слияние признаков: в отличие от фиксированного разрешения в ViT, SVTRv2 использует разные разрешения карт признаков на разных глубинах, подобно пирамидальной структуре CNN.

  3. Модуль семантического руководства (Semantic Guidance Module): в конце энкодера добавлена легкая семантическая ветка, помогающая модели понимать смысловые связи между символами, а не только визуальные признаки.

Эти улучшения позволяют SVTRv2 достигать точности, сопоставимой с методами на основе Attention, сохраняя при этом простоту CTC-декодирования 6.

Почему CTC, а не Attention?

Существует две основные парадигмы распознавания текста:

  1. CTC (Connectionist Temporal Classification): рассматривает распознавание как задачу разметки последовательности, где вывод выровнен по входу.
  2. Attention-based Decoder: использует механизм внимания для генерации вывода по одному символу за раз.

Методы на основе Attention обычно точнее, но CTC — проще и быстрее. Вклад SVTRv2 заключается в том, что за счет улучшения визуального энкодера метод CTC достигает или даже превосходит по точности методы на основе Attention 6.

Суть CTC-декодирования заключается в «слиянии дублей» и «удалении пробелов»:

flowchart LR
subgraph Input["Вывод модели"]
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["Слияние дублей"]
M["blank, H, blank, e, l, o"]
end
subgraph Remove["Удаление пробелов"]
R["H, e, l, o"]
end
subgraph Output["Выход"]
O["Helo - Ошибка"]
end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9
A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> Output

Подождите, здесь проблема. Если исходный текст — “Hello”, две буквы ‘l’ были ошибочно объединены. Решение CTC: вставлять токен blank между повторяющимися символами.

Правильное кодирование: [blank, H, e, l, blank, l, o]
Результат декодирования: "Hello"

CTC-декодирование с оптимизацией NEON

В PPOCRv5-Android для CTC-декодирования используется Argmax с оптимизацией NEON. В файле text_recognizer.cpp:

inline void ArgmaxNeon8(const float *__restrict__ data, int size,
int &max_idx, float &max_val) {
if (size < 16) {
// Скалярный fallback
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;
}
// Векторизация NEON: обработка 4 float за раз
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);
// Векторное сравнение и условный выбор
uint32x4_t cmp = vcgtq_f32(v_curr, v_max);
v_max = vbslq_f32(cmp, v_curr, v_max); // Выбор большего значения
v_max_idx = vbslq_s32(cmp, v_idx, v_max_idx); // Выбор соответствующего индекса
}
// Горизонтальная редукция: поиск максимума среди 4 кандидатов
float max_vals[4];
int32_t max_idxs[4];
vst1q_f32(max_vals, v_max);
vst1q_s32(max_idxs, v_max_idx);
// ... финальное сравнение
}

Для Argmax по 18 384 категориям оптимизация NEON дает примерно 3-кратное ускорение.

Математический принцип функции потерь CTC и декодирования

Основная идея CTC: для входной последовательности XX и всех возможных путей выравнивания π\pi рассчитать вероятность целевой последовательности YY:

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

Где B\mathcal{B} — это «функция отображения многие-к-одному», которая переводит путь π\pi в выходную последовательность YY (путем слияния дублей и удаления пробелов).

При инференсе мы используем жадное декодирование (Greedy Decoding) вместо полного Beam Search:

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; // Для слияния дублей
for (int t = 0; t < time_steps; ++t) {
// Поиск категории с максимальной вероятностью на текущем шаге
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;
}
}
// Правила CTC-декодирования:
// 1. Пропускать токен blank (индекс 0)
// 2. Сливать последовательно повторяющиеся символы
if (max_idx != 0 && max_idx != prev_idx) {
result += dictionary[max_idx - 1]; // -1, так как blank занимает индекс 0
}
prev_idx = max_idx;
}
return result;
}

Временная сложность жадного декодирования составляет O(T×C)O(T \times C), где TT — количество временных шагов, а CC — количество категорий. Для PP-OCRv5 T80T \approx 80, C=18384C = 18384, что требует около 1,5 миллиона сравнений при каждом декодировании. Вот почему оптимизация NEON так важна.

TIP

Beam Search может повысить точность декодирования, но объем вычислений в kk раз больше, чем при жадном декодировании (kk — ширина луча). На мобильных устройствах жадное декодирование обычно является лучшим выбором.

Словарь символов: вызов в 18 383 знака

PP-OCRv5 поддерживает 18 383 символа, включая:

  • Часто используемые иероглифы упрощенного китайского
  • Часто используемые иероглифы традиционного китайского
  • Английские буквы и цифры
  • Японские хирагану и катакану
  • Распространенные знаки препинания и специальные символы

Этот словарь хранится в файле keys_v5.txt, по одному символу на строку. При CTC-декодировании логиты на выходе модели имеют форму [1, T, 18384], где T — количество временных шагов, а 18384 = 18383 символа + 1 токен blank.

LiteRT C++ API: современный интерфейс после рефакторинга 2024 года

PPOCRv5-Android использует C++ API LiteRT, обновленный в 2024 году. Этот API предлагает более современный дизайн интерфейса. По сравнению с традиционным TFLite C API, новый API обеспечивает лучшую типобезопасность и возможности управления ресурсами.

Сравнение старого и нового API

Рефакторинг LiteRT 2024 принес значительные изменения в API:

ХарактеристикаСтарый API (TFLite)Новый API (LiteRT)
Пространство именtflite::litert::
Обработка ошибокВозврат перечисления TfLiteStatusВозврат типа Expected<T>
Управление памятьюРучное управлениеАвтоматическое через RAII
Конфигурация DelegateРазрозненные APIУнифицированный класс Options
Доступ к тензорамУказатели + ручное приведение типовТипобезопасный TensorBuffer

Основное преимущество нового API — типобезопасность и автоматическое управление ресурсами. Пример обработки ошибок:

// Старый API: ручная проверка каждого возвращаемого значения
TfLiteStatus status = TfLiteInterpreterAllocateTensors(interpreter);
if (status != kTfLiteOk) {
// Обработка ошибки
}
// Новый API: использование типа Expected, поддержка цепочек вызовов
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); // Автоматическое управление жизненным циклом

Инициализация окружения и модели

В text_detector.cpp процесс инициализации выглядит так:

bool Initialize(const std::string &model_path, AcceleratorType accelerator_type) {
// 1. Создание окружения 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. Настройка аппаратного ускорителя
auto options_result = litert::Options::Create();
auto hw_accelerator = ToLiteRtAccelerator(accelerator_type);
options.SetHardwareAccelerators(hw_accelerator);
// 3. Компиляция модели
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. Изменение формы входного тензора
std::vector<int> input_dims = {1, kDetInputSize, kDetInputSize, 3};
compiled_model_->ResizeInputTensor(0, absl::MakeConstSpan(input_dims));
// 5. Создание управляемого Buffer
CreateBuffersWithCApi();
return true;
}

Managed Tensor Buffer: ключ к zero-copy инференсу

Managed Tensor Buffer в LiteRT — это ключ к высокопроизводительному инференсу. Он позволяет GPU Delegate напрямую обращаться к буферу, исключая передачу данных между CPU и GPU:

bool CreateBuffersWithCApi() {
LiteRtCompiledModel c_model = compiled_model_->Get();
LiteRtEnvironment c_env = env_->Get();
// Получение требований к входному Buffer
LiteRtTensorBufferRequirements input_requirements = nullptr;
LiteRtGetCompiledModelInputBufferRequirements(
c_model, /*signature_index=*/0, /*input_index=*/0,
&input_requirements);
// Получение информации о типе тензора
auto input_type = compiled_model_->GetInputTensorType(0, 0);
LiteRtRankedTensorType tensor_type =
static_cast<LiteRtRankedTensorType>(*input_type);
// Создание управляемого Buffer
LiteRtTensorBuffer input_buffer = nullptr;
LiteRtCreateManagedTensorBufferFromRequirements(
c_env, &tensor_type, input_requirements, &input_buffer);
// Обертка в объект C++, автоматическое управление жизненным циклом
input_buffers_.push_back(
litert::TensorBuffer::WrapCObject(input_buffer,
litert::OwnHandle::kYes));
return true;
}

Преимущества такого дизайна:

  1. Zero-copy инференс: GPU Delegate имеет прямой доступ к буферу без копирования данных.
  2. Автоматическое управление памятью: OwnHandle::kYes гарантирует освобождение буфера при деструкции объекта C++.
  3. Типобезопасность: проверка соответствия типов тензоров на этапе компиляции.

Ускорение на GPU: выбор OpenCL и компромиссы

LiteRT предоставляет несколько вариантов аппаратного ускорения:

flowchart TB
subgraph Delegates["Экосистема LiteRT Delegate"]
direction TB
GPU_CL[GPU Delegate<br/>OpenCL Backend]
GPU_GL[GPU Delegate<br/>OpenGL ES Backend]
NNAPI[NNAPI Delegate<br/>Android HAL]
XNN[XNNPACK Delegate<br/>CPU Optimized]
end
subgraph Hardware["Маппинг на железо"]
direction TB
ADRENO[Adreno GPU<br/>Qualcomm]
MALI[Mali GPU<br/>ARM]
NPU[NPU/DSP<br/>Специфично для вендора]
CPU[ARM CPU<br/>NEON]
end
GPU_CL --> ADRENO
GPU_CL --> MALI
GPU_GL --> ADRENO
GPU_GL --> MALI
NNAPI --> NPU
XNN --> CPU
УскорительБэкендПреимуществаНедостатки
GPUOpenCLШирокая поддержка, хорошая производительностьНе является стандартным компонентом Android
GPUOpenGL ESСтандартный компонент AndroidПроизводительность ниже, чем у OpenCL
NPUNNAPIМаксимальная производительностьПлохая совместимость между устройствами
CPUXNNPACKМаксимальная совместимостьСамая низкая производительность

В PPOCRv5-Android в качестве основного бэкенда ускорения выбран OpenCL. Google выпустила бэкенд OpenCL для TFLite в 2020 году, и по сравнению с OpenGL ES он обеспечивает примерно 2-кратное ускорение на GPU Adreno 7.

Преимущества OpenCL обусловлены несколькими факторами:

  1. Изначальное предназначение: OpenCL с самого начала проектировался для вычислений общего назначения, в то время как OpenGL — это API для графического рендеринга, в который поддержка вычислительных шейдеров была добавлена позже.
  2. Константная память: константная память OpenCL очень эффективна для доступа к весам нейронных сетей.
  3. Поддержка FP16: OpenCL нативно поддерживает вычисления с половинной точностью, тогда как в OpenGL поддержка появилась позже.

Однако у OpenCL есть существенный недостаток: он не является стандартным компонентом Android. Качество реализации OpenCL у разных вендоров разнится, а некоторые устройства его вовсе не поддерживают.

OpenCL vs OpenGL ES: глубокое сравнение производительности

Чтобы понять преимущества OpenCL, нужно углубиться в архитектуру GPU. Возьмем для примера Qualcomm Adreno 640:

flowchart TB
subgraph Adreno["Архитектура Adreno 640"]
direction TB
subgraph SP["Shader Processors x2"]
ALU1[ALU Array<br/>256 FP32 / 512 FP16]
ALU2[ALU Array<br/>256 FP32 / 512 FP16]
end
subgraph Memory["Иерархия памяти"]
L1[L1 Cache<br/>16KB на SP]
L2[L2 Cache<br/>1MB Shared]
GMEM[Global Memory<br/>LPDDR4X]
end
subgraph Special["Специализированные блоки"]
TMU[Texture Unit<br/>Билинейная интерполяция]
CONST[Constant Cache<br/>Ускорение весов]
end
end
ALU1 --> L1
ALU2 --> L1
L1 --> L2 --> GMEM
TMU --> L1
CONST --> ALU1 & ALU2

Преимущества производительности OpenCL складываются из:

ХарактеристикаOpenCLOpenGL ES Compute
Константная памятьНативная поддержка, аппаратное ускорениеТребует эмуляции через UBO
Размер рабочих группГибкая настройкаОграничен моделью шейдеров
Барьеры памятиТонкий контрольГрубый контроль
Вычисления FP16Расширение cl_khr_fp16Требует точности mediump
Инструменты отладкиSnapdragon ProfilerОграниченная поддержка

В сверточных операциях веса обычно являются константами. OpenCL может поместить веса в константную память, используя аппаратную оптимизацию широковещательной передачи (broadcast). OpenGL ES вынужден передавать веса как Uniform Buffer Object (UBO), что увеличивает накладные расходы на доступ к памяти.

NOTE

Начиная с Android 7.0, Google ограничила приложениям прямую загрузку библиотек OpenCL. Однако GPU Delegate в LiteRT обходит это ограничение, динамически загружая системную реализацию OpenCL через dlopen. Именно поэтому GPU Delegate должен проверять доступность OpenCL во время выполнения.

Стратегия грациозной деградации (Fallback)

В PPOCRv5-Android реализована стратегия грациозной деградации:

ocr_engine.cpp
constexpr AcceleratorType kFallbackChain[] = {
AcceleratorType::kGpu, // Приоритет GPU
AcceleratorType::kCpu, // Откат на 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;
}

Эта стратегия гарантирует работоспособность приложения на любом устройстве, меняется только производительность.

Нативный слой: C++ и оптимизация NEON

Почему C++, а не Kotlin?

Ответ прост: производительность. Предобработка изображений включает в себя огромное количество попиксельных операций, накладные расходы на которые в JVM недопустимы. Что еще важнее, C++ позволяет напрямую использовать инструкции ARM NEON SIMD для векторизации вычислений.

NEON: набор инструкций SIMD для ARM

NEON — это расширение SIMD (Single Instruction, Multiple Data) для процессоров ARM. Оно позволяет одной инструкции обрабатывать сразу несколько элементов данных.

flowchart LR
subgraph NEON["128-битный регистр NEON"]
direction TB
F4["4x float32"]
I8["8x int16"]
B16["16x int8"]
end
subgraph Operations["Векторные операции"]
direction TB
LD["vld1q_f32<br/>Загрузка 4 float"]
SUB["vsubq_f32<br/>Параллельное вычитание (4 пути)"]
MUL["vmulq_f32<br/>Параллельное умножение (4 пути)"]
ST["vst1q_f32<br/>Сохранение 4 float"]
end
subgraph Speedup["Прирост производительности"]
S1["Скаляр: 4 инструкции"]
S2["NEON: 1 инструкция"]
S3["Теоретическое ускорение: 4x"]
end
F4 --> LD
LD --> SUB --> MUL --> ST
ST --> S3

PPOCRv5-Android использует оптимизацию NEON на нескольких критических путях. Пример бинаризации (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) {
// Обработка 16 пикселей за раз
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);
// Векторное сравнение
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);
// Сужение до uint8
uint16x4_t n0 = vmovn_u32(cmp0);
uint16x4_t n1 = vmovn_u32(cmp1);
uint16x8_t n01 = vcombine_u16(n0, n1);
// ... объединение и сохранение
}
// Скалярный fallback для оставшихся пикселей
for (; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#else
// Чисто скалярная реализация
for (int i = 0; i < total_pixels; ++i) {
binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0;
}
#endif
}

Ключевые точки оптимизации в этом коде:

  • Пакетная загрузка: vld1q_f32 загружает 4 float за раз, сокращая количество обращений к памяти.
  • Векторное сравнение: vcgtq_f32 сравнивает 4 значения одновременно, создавая маску.
  • Сужение типов: vmovn_u32 сжимает 32-битные результаты до 16-битных, а затем до 8-битных.

По сравнению со скалярной реализацией, оптимизация NEON дает ускорение в 3-4 раза 8.

Реализация нормализации ImageNet на NEON

Нормализация изображения — важный этап предобработки. Стандартная нормализация ImageNet использует формулу:

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

Где μ=[0.485,0.456,0.406]\mu = [0.485, 0.456, 0.406], σ=[0.229,0.224,0.225]\sigma = [0.229, 0.224, 0.225] (каналы RGB).

В image_utils.cpp реализация нормализации с оптимизацией NEON выглядит так:

void NormalizeImageNet(const uint8_t* src, int width, int height, int stride,
float* dst) {
// Параметры нормализации 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__)
// Предварительный расчет: (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);
// Предварительный расчет: -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) {
// Загрузка 4 пикселей RGBA (16 байт)
uint8x16_t rgba = vld1q_u8(row + x * 4);
// Деинтерливинг: 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)));
// Нормализация: (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);
// Интерливинг при сохранении: RRRR, GGGG, BBBB -> RGBRGBRGBRGB
float32x4x3_t rgb = {r_f, g_f, b_f};
vst3q_f32(dst_row + x * 3, rgb);
}
// Скалярная обработка оставшихся пикселей
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
// Скалярная реализация (опущена)
#endif
}

Ключевые приемы оптимизации в этом коде:

  1. Предварительный расчет констант: преобразование (x - mean) / std в x * scale + bias для исключения деления во время выполнения.
  2. Fused Multiply-Add: vmlaq_f32 выполняет умножение и сложение за одну инструкцию.
  3. Загрузка с деинтерливингом: vld4q_u8 автоматически разделяет RGBA на четыре канала.
  4. Сохранение с интерливингом: vst3q_f32 записывает три канала RGB в память с чередованием.

Нулевая зависимость от OpenCV

Многие OCR-проекты полагаются на OpenCV для предобработки изображений. OpenCV мощная, но она приносит с собой огромный размер пакета; библиотека OpenCV на Android обычно превышает 10 МБ.

PPOCRv5-Android выбрал путь «нулевой зависимости от OpenCV». Все операции предобработки реализованы на чистом C++ в image_utils.cpp:

  • Масштабирование билинейной интерполяцией: ручная реализация с поддержкой NEON.
  • Нормализация: ImageNet и нормализация для распознавания.
  • Перспективное преобразование: обрезка текстовых областей под любым углом из исходного изображения.

Реализация билинейной интерполяции на NEON

Билинейная интерполяция — основной алгоритм масштабирования изображений. Для координат (x,y)(x, y) в исходном изображении значение целевого пикселя рассчитывается как:

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}

Где α=xx\alpha = x - \lfloor x \rfloor, β=yy\beta = y - \lfloor y \rfloor, а fijf_{ij} — значения четырех соседних пикселей.

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: обработка 4 целевых пикселей за раз
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) {
// Расчет 4 исходных координат
float sx[4];
for (int i = 0; i < 4; ++i) {
sx[i] = ((dx + i) + 0.5f) * scale_x - 0.5f;
}
// Загрузка весов 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];
}
// Билинейная интерполяция для каждого канала
for (int c = 0; c < 4; ++c) { // RGBA
float32x4_t f00, f10, f01, f11;
// Сбор соседних значений для 4 пикселей
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
// Формула билинейной интерполяции
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
);
// Преобразование обратно в uint8 и сохранение
uint32x4_t result_u32 = vcvtq_u32_f32(result);
// ... сохранение
}
}
#endif
// Скалярная обработка оставшихся пикселей (опущена)
}
}

TIP

Оптимизация билинейной интерполяции на NEON сложна, так как адреса четырех соседних пикселей не являются непрерывными. Более эффективный метод — раздельная билинейная интерполяция: сначала по горизонтали, затем по вертикали. Это позволяет лучше использовать локальность кэша.

Цена такого выбора — больший объем разработки, но выгода очевидна:

  1. Размер APK уменьшен примерно на 10 МБ.
  2. Полный контроль над логикой предобработки для оптимизации.
  3. Отсутствие проблем с совместимостью версий OpenCV.

Перспективное преобразование: от повернутого прямоугольника к стандартной строке текста

Модель распознавания текста ожидает на входе горизонтальные изображения строк текста. Однако обнаруженные текстовые рамки могут быть повернутыми прямоугольниками под любым углом. Перспективное преобразование отвечает за «выпрямление» области повернутого прямоугольника.

В text_recognizer.cpp метод CropAndRotate реализует эту функцию:

void CropAndRotate(const uint8_t *__restrict__ image_data,
int width, int height, int stride,
const RotatedRect &box, int &target_width) {
// Расчет четырех угловых точек повернутого прямоугольника
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]; // Координаты (x, y) 4 угловых точек
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);
// ... расчет остальных углов
// Адаптивная целевая ширина: сохранение соотношения сторон
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]
// Матрица аффинного преобразования
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;
// Сэмплирование билинейной интерполяцией + нормализация (оптимизация 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);
}
}
}

Ключевые оптимизации этой реализации:

  1. Адаптивная ширина: динамическая настройка выходной ширины в зависимости от соотношения сторон текстовой рамки во избежание чрезмерного растяжения или сжатия.
  2. Аппроксимация аффинным преобразованием: для текстовых рамок, близких к параллелограммам, используется аффинное преобразование вместо перспективного для снижения объема вычислений.
  3. Билинейная интерполяция на NEON: сэмплирование и нормализация выполняются за один проход, сокращая количество обращений к памяти.

JNI: мост между Kotlin и C++

JNI (Java Native Interface) — это мост для связи между Kotlin/Java и C++. Однако вызовы JNI накладны, и частые межъязыковые вызовы могут серьезно ударить по производительности.

Принцип проектирования PPOCRv5-Android: минимизация количества вызовов JNI. Весь процесс OCR требует только одного вызова JNI:

sequenceDiagram
participant K as Kotlin Layer
participant J as JNI Bridge
participant N as Native Layer
participant G as GPU
K->>J: process(bitmap)
J->>N: Передача указателя RGBA
Note over N,G: Native-слой выполняет всю работу
N->>N: Предобработка изображения NEON
N->>G: Инференс детекции текста
G-->>N: Карта вероятностей
N->>N: Постпроцессинг, детекция контуров
loop Каждая текстовая рамка
N->>N: Обрезка с персп. преобр.
N->>G: Инференс распознавания текста
G-->>N: Logits
N->>N: CTC декодирование
end
N-->>J: Результаты OCR
J-->>K: List OcrResult

В ppocrv5_jni.cpp основная функция nativeProcess демонстрирует этот подход:

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);
// Блокировка пикселей Bitmap
void *pixels = nullptr;
AndroidBitmap_lockPixels(env, bitmap, &pixels);
// Один вызов JNI выполняет всю работу 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);
// Создание массива объектов Java для возврата
// ...
}

Такой дизайн исключает накладные расходы на передачу данных туда-сюда между этапами детекции и распознавания.

Архитектура: модульность и тестируемость

Архитектура PPOCRv5-Android следует принципу «разделения ответственности» (Separation of Concerns):

flowchart TB
subgraph UI["Jetpack Compose UI Layer"]
direction LR
CP[CameraPreview]
GP[GalleryPicker]
RO[ResultOverlay]
end
subgraph VM["ViewModel Layer"]
OVM[OCRViewModel<br/>Управление состоянием]
end
subgraph Native["Native Layer - C++"]
OE[OcrEngine<br/>Оркестрация]
subgraph Detection["Детекция текста"]
TD[TextDetector]
DB[DBNet FP16]
end
subgraph Recognition["Распознавание текста"]
TR[TextRecognizer]
SVTR[SVTRv2 + CTC]
end
subgraph Preprocessing["Обработка изображений"]
IP[ImagePreprocessor<br/>Оптимизация NEON]
PP[PostProcessor<br/>Детекция контуров]
end
subgraph Runtime["LiteRT Runtime"]
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

Преимущества такой многослойной архитектуры:

  1. Слой UI: чистый Kotlin/Compose, сфокусированный на взаимодействии с пользователем.
  2. Слой ViewModel: управление состоянием и бизнес-логикой.
  3. Нативный слой: высокопроизводительные вычисления, полностью отвязанные от UI.

Каждый слой можно тестировать независимо. Нативный слой — с помощью Google Test, слой ViewModel — с помощью JUnit + MockK.

Инкапсуляция на уровне Kotlin

В OcrEngine.kt слой Kotlin предоставляет лаконичный API:

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
}
}
}

Преимущества такого дизайна:

  1. Использование типа Result для обработки ошибок инициализации.
  2. Реализация интерфейса Closeable, поддержка блоков use для автоматического освобождения ресурсов.
  3. Автоматическое копирование файлов моделей из assets в кэш-директорию.

Оптимизация холодного старта

Первый инференс (холодный старт) обычно проходит значительно медленнее последующих. Это связано с тем, что:

  1. GPU Delegate должен скомпилировать программы OpenCL.
  2. Веса модели должны быть переданы из оперативной памяти в память GPU.
  3. Различные кэши должны быть прогреты.

PPOCRv5-Android смягчает проблему холодного старта через механизм Warm-up:

void OcrEngine::WarmUp() {
LOGD(TAG, "Starting warm-up (%d iterations)...", kWarmupIterations);
// Создание небольшого тестового изображения
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;
}
// Выполнение нескольких инференсов для прогрева
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_));
}

Оптимизация выравнивания памяти

В TextDetector::Impl все предварительно выделенные буферы используют выравнивание по 64 байта:

// Предварительно выделенные буферы с выравниванием по кэш-линии
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_;

Выравнивание по 64 байта соответствует размеру кэш-линии современных процессоров ARM. Выровненный доступ к памяти позволяет избежать разделения кэш-линий и повышает эффективность работы с памятью.

Пул памяти и повторное использование объектов

Частое выделение и освобождение памяти — убийца производительности. PPOCRv5-Android использует стратегию предварительного выделения, выделяя всю необходимую память один раз при инициализации:

class TextDetector::Impl {
// Предварительно выделенные буферы, жизненный цикл совпадает с 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(...) {
// Однократное выделение во избежание malloc во время выполнения
resized_buffer_.resize(kDetInputSize * kDetInputSize * 4);
normalized_buffer_.resize(kDetInputSize * kDetInputSize * 3);
binary_map_.resize(kDetInputSize * kDetInputSize);
prob_map_.resize(kDetInputSize * kDetInputSize);
return true;
}
};

Преимущества такого дизайна:

  1. Избежание фрагментации памяти: все большие блоки памяти выделяются при запуске, фрагментация во время работы исключена.
  2. Сокращение системных вызовов: malloc может вызывать системные вызовы, предварительное выделение избавляет от этих расходов.
  3. Дружелюбность к кэшу: последовательно выделенная память с большей вероятностью будет физически непрерывной, что повышает частоту попаданий в кэш.

Оптимизация предсказания переходов

Современные CPU используют предсказание переходов для повышения эффективности конвейера. Ошибочное предсказание приводит к сбросу конвейера и потере 10-20 тактов.

На «горячих путях» мы используем подсказки компилятору __builtin_expect:

// Большинство пикселей не превысят порог
if (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) {
binary_map_[i] = 255;
} else {
binary_map_[i] = 0;
}

__builtin_expect(expr, val) сообщает компилятору, что значение expr, скорее всего, будет равно val. Компилятор на основе этого корректирует компоновку кода, вынося «маловероятные» ветки подальше от основного пути выполнения.

Развертывание циклов и программный конвейер

Для циклов с интенсивными вычислениями ручное развертывание может снизить накладные расходы цикла и открыть больше возможностей для параллелизма на уровне инструкций:

// Неразвернутая версия
for (int i = 0; i < n; ++i) {
dst[i] = src[i] * scale + bias;
}
// Версия с развертыванием 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;
}

После развертывания CPU может одновременно выполнять несколько независимых инструкций умножения-сложения, максимально используя несколько исполнительных блоков суперскалярной архитектуры.

Оптимизация Prefetch

Во внутреннем цикле перспективного преобразования используйте __builtin_prefetch для предварительной загрузки данных следующей строки:

for (int dy = 0; dy < kRecInputHeight; ++dy) {
// Предварительная выборка данных следующей строки
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);
}
}
// ... обработка текущей строки
}

Эта оптимизация позволяет скрыть задержки памяти: пока обрабатывается текущая строка, данные следующей уже подгружаются в кэш L1.

Инженерные детали постпроцессинга

Анализ связных областей и детекция контуров

В postprocess.cpp функция FindContours реализует эффективный анализ связных областей:

std::vector<std::vector<Point>> FindContours(const uint8_t *binary_map,
int width, int height) {
// 1. 4x даунсэмплинг для снижения объема вычислений
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. Обход BFS для поиска связных областей
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();
// Детекция граничных пикселей
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)
});
}
// Расширение по 4-соседству
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;
}

Ключевые точки оптимизации:

  1. 4x даунсэмплинг: уменьшение бинарной карты 640x640 до 160x160 сокращает объем вычислений в 16 раз.
  2. Детекция границ: сохранение только граничных пикселей вместо всей связной области.
  3. Ограничение максимального количества контуров: kMaxContours = 100 для предотвращения проблем с производительностью в экстремальных случаях.

Выпуклая оболочка и алгоритм вращающихся калибров

Расчет минимального ограничивающего повернутого прямоугольника состоит из двух этапов: сначала вычисляется выпуклая оболочка, затем с помощью алгоритма вращающихся калибров находится прямоугольник с минимальной площадью.

Алгоритм Graham Scan для выпуклой оболочки

Graham Scan — классический алгоритм вычисления выпуклой оболочки со сложностью O(nlogn)O(n \log n):

std::vector<Point> ConvexHull(std::vector<Point> points) {
if (points.size() < 3) return points;
// 1. Поиск самой нижней точки (min y, затем min x)
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. Сортировка по полярному углу
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) {
// При коллинеарности более близкая точка идет первой
return DistanceSquared(p0, a) < DistanceSquared(p0, b);
}
return cross > 0; // Против часовой стрелки
});
// 3. Построение оболочки
std::vector<Point> hull;
for (const auto& p : points) {
// Удаление точек, вызывающих поворот по часовой стрелке
while (hull.size() > 1 &&
CrossProduct(hull[hull.size()-2], hull[hull.size()-1], p) <= 0) {
hull.pop_back();
}
hull.push_back(p);
}
return hull;
}
// Векторное произведение: определение направления поворота
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);
}

Алгоритм вращающихся калибров

Алгоритм вращающихся калибров (Rotating Calipers) обходит каждое ребро выпуклой оболочки, вычисляя площадь ограничивающего прямоугольника с основанием на этом ребре:

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; // Три позиции «калибров»
for (int i = 0; i < n; ++i) {
int j = (i + 1) % n;
// Вектор направления текущего ребра
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);
// Единичный вектор
float ux = edge_x / edge_len;
float uy = edge_y / edge_len;
// Перпендикулярное направление
float vx = -uy;
float vy = ux;
// Поиск самой правой точки (макс. проекция вдоль ребра)
while (Dot(hull[(right + 1) % n], ux, uy) > Dot(hull[right], ux, uy)) {
right = (right + 1) % n;
}
// Поиск самой верхней точки (макс. проекция вдоль перпендикуляра)
while (Dot(hull[(top + 1) % n], vx, vy) > Dot(hull[top], vx, vy)) {
top = (top + 1) % n;
}
// Поиск самой левой точки
while (Dot(hull[(left + 1) % n], ux, uy) < Dot(hull[left], ux, uy)) {
left = (left + 1) % n;
}
// Расчет размеров прямоугольника
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;
// Обновление параметров оптимального прямоугольника
best_rect.width = width;
best_rect.height = height;
best_rect.angle = std::atan2(uy, ux) * 180.0f / M_PI;
// Расчет центральной точки...
}
}
return best_rect;
}

Ключевое озарение алгоритма вращающихся калибров в том, что при вращении основания три «калибра» (самая правая, верхняя и левая точки) будут двигаться только вперед, не возвращаясь назад. Таким образом, общая временная сложность составляет O(n)O(n), а не O(n2)O(n^2).

Минимальный ограничивающий повернутый прямоугольник

Функция MinAreaRect использует алгоритм вращающихся калибров для вычисления минимального ограничивающего повернутого прямоугольника:

RotatedRect MinAreaRect(const std::vector<Point> &contour) {
// 1. Субсэмплинг для уменьшения количества точек
std::vector<Point> points = subsample_points(contour, kMaxBoundaryPoints);
// 2. Быстрый путь: для текстовых блоков с высоким соотношением сторон используем AABB
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) {
// Прямой возврат рамки, выровненной по осям
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. Вычисление выпуклой оболочки
std::vector<Point> hull = convex_hull(std::vector<Point>(points));
// 4. Вращающиеся калибры: обход каждого ребра оболочки
float min_area = std::numeric_limits<float>::max();
RotatedRect best_rect;
for (size_t i = 0; i < hull.size(); ++i) {
// Расчет ограничивающего прямоугольника на основе текущего ребра
float edge_x = hull[j].x - hull[i].x;
float edge_y = hull[j].y - hull[i].y;
// Проекция всех точек на направление ребра и перпендикуляр
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;
// Обновление оптимального прямоугольника
}
}
return best_rect;
}

Временная сложность этого алгоритма составляет O(nlogn)O(n \log n) (выпуклая оболочка) + O(n)O(n) (вращающиеся калибры), где nn — количество граничных точек. Ограничение nn до 200 с помощью субсэмплинга гарантирует производительность в реальном времени.

Камера OCR в реальном времени: CameraX и анализ кадров

Вызов OCR в реальном времени заключается в следующем: как обеспечить плавное превью и при этом максимально быстро обрабатывать каждый кадр?

flowchart TB
subgraph Camera["CameraX Pipeline"]
direction TB
CP[CameraProvider]
PV[Preview UseCase<br/>30 FPS]
IA[ImageAnalysis UseCase<br/>STRATEGY_KEEP_ONLY_LATEST]
end
subgraph Analysis["Процесс анализа кадров"]
direction TB
IP[ImageProxy<br/>YUV_420_888]
BM[Конвертация Bitmap<br/>RGBA_8888]
JNI[Вызов JNI<br/>Один межъязыковой вызов]
end
subgraph Native["Нативный OCR"]
direction TB
DET[TextDetector<br/>~45ms GPU]
REC[TextRecognizer<br/>~15ms/строка]
RES[Результаты OCR]
end
subgraph UI["Обновление UI"]
direction TB
VM[ViewModel<br/>StateFlow]
OV[ResultOverlay<br/>Отрисовка на Canvas]
end
CP --> PV
CP --> IA
IA --> IP --> BM --> JNI
JNI --> DET --> REC --> RES
RES --> VM --> OV

ImageAnalysis в CameraX

CameraX — это библиотека камер из Jetpack, предоставляющая кейс ImageAnalysis, который позволяет нам анализировать кадры камеры в реальном времени:

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)
// Обновление UI
imageProxy.close()
}

Ключевая настройка — STRATEGY_KEEP_ONLY_LATEST: если анализатор не успевает за частотой кадров камеры, старые кадры отбрасываются, сохраняется только последний. Это гарантирует актуальность результатов OCR.

Баланс между частотой кадров и задержкой

На устройствах с GPU-ускорением (у моего Snapdragon 870, похоже, есть проблемы, он не всегда может переложить большую часть вычислений на GPU) PPOCRv5-Android теоретически может достигать высокой скорости обработки. Но это не значит, что мы должны обрабатывать каждый кадр.

Рассмотрим сценарий: пользователь наводит камеру на текст, содержимое которого не меняется в течение короткого времени. Если мы будем выполнять полный цикл OCR на каждом кадре, мы впустую потратим вычислительные ресурсы.

Одной из стратегий оптимизации является «детектирование изменений»: OCR запускается только тогда, когда изображение в кадре значительно изменилось. Это можно реализовать путем сравнения гистограмм или характерных точек последовательных кадров.

Перспективы: NPU и квантование

Будущее мобильного ИИ за NPU (Neural Processing Unit). По сравнению с GPU, NPU специально спроектирован для инференса нейронных сетей и обладает более высокой энергоэффективностью.

Однако проблемой NPU является фрагментация. У каждого производителя чипов своя архитектура NPU и SDK:

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

Android NNAPI (Neural Networks API) пытается предоставить унифицированный уровень абстракции, но реальные результаты разнятся. Многие функции NPU не раскрываются через NNAPI, и разработчикам приходится использовать специфические SDK вендоров.

Квантование INT8: незавершенная битва

Квантование FP16 — консервативный выбор, почти не теряющий в точности. Но если стремиться к экстремальной производительности, следующим шагом будет квантование INT8.

Квантование INT8 сжимает веса и активации с 32-битных чисел с плавающей запятой до 8-битных целых чисел, что теоретически дает:

  • 4-кратное сжатие модели.
  • 2-4-кратное ускорение инференса (в зависимости от железа).
  • На Qualcomm Hexagon DSP возможно ускорение более чем в 10 раз.

Это искушение слишком велико. Так я начал долгое путешествие в мир квантования INT8.

Первая попытка: калибровка на синтетических данных

Квантование INT8 требует набора данных для калибровки, чтобы определить параметры квантования (Scale и Zero Point). Сначала я поленился и использовал случайно сгенерированные изображения, имитирующие текст:

# Ошибочный пример: использование случайного шума для калибровки
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

Результат был катастрофическим. Вывод модели состоял из одних нулей:

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

Инструмент квантования рассчитал неверные параметры на основе случайного шума, что привело к отсечению значений активации реальных изображений.

Вторая попытка: калибровка на реальных изображениях

Я перешел на реальные изображения из датасетов OCR: ICDAR2015, TextOCR, официальные примеры PaddleOCR. Также я реализовал предобработку Letterbox, чтобы распределение изображений при калибровке соответствовало инференсу:

def letterbox_image(image, target_size):
"""Масштабирование с сохранением пропорций, заполнение серым цветом недостающих частей"""
ih, iw = image.shape[:2]
h, w = target_size
scale = min(w / iw, h / ih)
# ... вставка по центру

Модель перестала выдавать одни нули, но результаты распознавания все равно оставались «абракадаброй».

Третья попытка: исправление обработки типов на стороне C++

Я обнаружил проблему в коде C++ при обработке входных данных INT8. Модель INT8 ожидает исходные значения пикселей (0-255), а я все еще выполнял нормализацию ImageNet (вычитание среднего и деление на отклонение).

if (input_is_int8_) {
// Модель INT8: прямой ввод исходных пикселей, нормализация встроена в первый слой
dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);
} else {
// Модель FP32: требуется ручная нормализация
// (pixel - mean) / std
}

Параллельно я реализовал логику динамического чтения параметров квантования вместо их жесткого кодирования:

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

Итоговый результат: компромисс

После нескольких дней отладки модель INT8 так и не заработала должным образом. Проблема могла заключаться в:

  1. Реализации квантования в onnx2tf: PP-OCRv5 использует специфические комбинации операторов, которые onnx2tf мог некорректно обработать при квантовании.
  2. Характеристиках вывода DBNet: DBNet выдает карту вероятностей со значениями от 0 до 1, а квантование INT8 крайне чувствительно к таким малым диапазонам.
  3. Накоплении ошибок многостадийной модели: детекция и распознавание соединены последовательно, и ошибки квантования накапливаются и усиливаются.

Давайте подробнее разберем второй пункт. Вывод DBNet проходит через активацию Sigmoid, сжимая диапазон до [0, 1]. Квантование INT8 использует формулу:

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

Для значений в диапазоне [0, 1], если scale установлен неверно, квантованные значения могут занять лишь малую часть диапазона INT8 [-128, 127], что приведет к серьезной потере точности.

# Допустим, scale = 0.00784 (1/127), zero_point = 0
# Вход 0.5 -> round(0.5 / 0.00784) + 0 = 64
# Вход 0.1 -> round(0.1 / 0.00784) + 0 = 13
# Вход 0.01 -> round(0.01 / 0.00784) + 0 = 1
# Вход 0.001 -> round(0.001 / 0.00784) + 0 = 0 # Потеря точности!

Порог DBNet обычно устанавливается на уровне 0.1-0.3. Это означает, что огромное количество значимых вероятностей (0.1-0.3) после квантования будет представлено всего 25 целыми числами (от 13 до 38), чего явно недостаточно для высокого разрешения.

WARNING

Квантование INT8 для PP-OCRv5 — известная сложная задача. Если вы тоже пробуете это сделать, рекомендую сначала убедиться, что модель FP32 работает корректно, а затем постепенно исключать проблемы квантования. Или рассмотрите возможность использования официального фреймворка Paddle Lite, который лучше поддерживает PaddleOCR.

Квантование с учетом обучения: правильное решение

Если использование INT8 необходимо, правильным методом будет квантование с учетом обучения (Quantization-Aware Training, QAT), а не пост-тренировочное квантование (Post-Training Quantization, PTQ).

QAT имитирует ошибки квантования в процессе обучения, заставляя модель адаптироваться к представлению данных с низкой точностью:

# Пример PyTorch QAT
import torch.quantization as quant
model = DBNet()
model.qconfig = quant.get_default_qat_qconfig('fbgemm')
model_prepared = quant.prepare_qat(model)
# Обычное обучение, но с узлами имитации квантования в forward pass
for epoch in range(num_epochs):
for images, labels in dataloader:
outputs = model_prepared(images) # Включает имитацию квантования
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
# Конвертация в настоящую квантованную модель
model_quantized = quant.convert(model_prepared)

К сожалению, официальная команда PP-OCRv5 не предоставила моделей, обученных с QAT. Это означает, что для получения качественной модели INT8 потребуется проводить QAT-обучение с нуля, что выходит за рамки данного проекта.

В итоге я выбрал компромисс: использование квантования FP16 + ускорение на GPU вместо INT8 + DSP.

Цена этого решения:

  • Размер модели в 2 раза больше, чем у INT8.
  • Невозможность использовать сверхнизкое энергопотребление Hexagon DSP.
  • Скорость инференса в 2-3 раза ниже теоретического оптимума.

Но выгода в том, что:

  • Точность модели практически идентична FP32.
  • Срок разработки значительно сокращен.
  • Сложность кода снижена.

Суть инженерии — в балансе. Иногда «достаточно хорошее» важнее «теоретически оптимального».

Заключение

От PaddlePaddle до LiteRT, от DBNet до SVTRv2, от OpenCL до NEON — инженерная практика OCR на мобильных устройствах охватывает знания в области глубокого обучения, компиляторов, программирования GPU, мобильной разработки и многого другого.

Главный урок этого проекта: мобильный ИИ — это не просто «засунуть модель в телефон». Это требует:

  1. Глубокого понимания архитектуры модели для корректной конвертации.
  2. Знания особенностей «железа» для полноценного использования ускорителей.
  3. Владения системным программированием для реализации высокопроизводительного нативного кода.
  4. Внимания к пользовательскому опыту для поиска баланса между производительностью и энергопотреблением.

PPOCRv5-Android — это проект с открытым исходным кодом, который демонстрирует, как развернуть современные модели OCR в реальных мобильных приложениях. Надеюсь, эта статья станет полезным справочником для разработчиков с похожими задачами.

Как сказали в Google при запуске LiteRT: «Maximum performance, simplified.» 9 Цель мобильного ИИ — не усложнять, а делать сложное простым.

Послесловие

Честно говоря, я (как в работе, так и в хобби) отошел от Android как минимум на два года. И это мой первый случай публикации зрелой библиотеки на моем «твинке» в GitHub (основной аккаунт я передал коллегам в знак решимости уйти).

В последние годы фокус моей работы был смещен с Android. Не могу раскрывать детали, но когда-нибудь, возможно, расскажу подробнее. В общем, мне, вероятно, будет трудно достичь новых высот в Android-разработке.

Выпуск этого проекта обусловлен моим личным интересом — я создаю ранний инструмент для Android, работающий на устройстве, и OCR — лишь малая часть его нижнего слоя. Позже (надеюсь, скоро) я полностью открою его исходный код, но пока не могу раскрывать подробности.

В любом случае, спасибо, что дочитали до конца. Буду рад, если вы поставите Star моему репозиторию. Спасибо!


Список литературы

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

Лицензия

Если не указано иное, все статьи и материалы этого блога распространяются под лицензией Creative Commons «Attribution-NonCommercial-ShareAlike» 4.0 Международная лицензия (CC BY-NC-SA 4.0)

✓ Скопировано!