Description
Cet article de blog :
- Couverture : Générée via Google Nano Banana 2, libre de droits.
- Code source du projet : Open source sur GitHub, veuillez visiter PPOCRv5-Android pour y accéder.
Déclaration :
L’auteur (Fleey) n’est pas un professionnel du domaine de l’IA, mais un passionné. En cas d’omissions ou d’erreurs dans le texte, j’espère que les lecteurs feront preuve de compréhension et apporteront des corrections rapides !
Introduction
En 2024, Google a renommé TensorFlow Lite en LiteRT. Plus qu’un simple rebranding, cela marque un changement de paradigme de l’IA embarquée, passant du « mobile-first » vers l’« edge-first » 1. Dans ce contexte, l’OCR (Reconnaissance Optique de Caractères), l’une des applications les plus précieuses de l’IA embarquée, traverse une révolution silencieuse.
L’équipe PaddleOCR de Baidu a publié en 2025 le modèle PP-OCRv5, un modèle OCR unifié prenant en charge plusieurs langues, dont le chinois simplifié, le chinois traditionnel, l’anglais et le japonais 2. Sa version mobile ne pèse qu’environ 70 Mo, mais elle est capable de reconnaître 18 383 caractères dans un seul modèle. Derrière ce chiffre se cache la collaboration de deux réseaux de neurones profonds : la détection et la reconnaissance.
Cependant, un problème se pose : PP-OCRv5 est entraîné sur le framework PaddlePaddle, alors que le moteur d’inférence le plus mature sur Android est LiteRT. Comment franchir ce fossé ?
Commençons par la conversion du modèle pour lever progressivement le voile sur l’ingénierie de l’OCR embarqué.
flowchart TB subgraph E2E["Flux OCR de bout en bout"] direction TB
subgraph Input["Entrée"] IMG[Image originale<br/>Taille arbitraire] end
subgraph Detection["Détection de texte - DBNet"] DET_PRE[Pré-traitement<br/>Resize 640x640<br/>Normalisation ImageNet] DET_INF[Inférence DBNet<br/>~45ms GPU] DET_POST[Post-traitement<br/>Binarisation - Contours - Rectangle orienté] end
subgraph Recognition["Reconnaissance de texte - SVTRv2"] REC_CROP[Recadrage par transformation perspective<br/>Largeur adaptative 48xW] REC_INF[Inférence SVTRv2<br/>~15ms/ligne GPU] REC_CTC[Décodage CTC<br/>Fusion des répétitions + Suppression des blancs] end
subgraph Output["Sortie"] RES[Résultats OCR<br/>Texte + Confiance + Position] end end
IMG --> DET_PRE --> DET_INF --> DET_POST DET_POST -->|N boîtes de texte| REC_CROP REC_CROP --> REC_INF --> REC_CTC --> RESConversion du modèle : Le long voyage de PaddlePaddle vers TFLite
La fragmentation des frameworks de deep learning est un point sensible du secteur. PyTorch, TensorFlow, PaddlePaddle, ONNX : chaque framework possède son propre format de modèle et ses propres implémentations d’opérateurs. ONNX (Open Neural Network Exchange) tente d’être une représentation intermédiaire universelle, mais la réalité est souvent moins idéale que la théorie.
Le chemin de conversion pour PP-OCRv5 est le suivant :
flowchart LR subgraph PaddlePaddle["Framework PaddlePaddle"] PM[inference.json<br/>inference.pdiparams] end
subgraph ONNX["Intermédiaire ONNX"] OM[model.onnx<br/>opset 14] end
subgraph Optimization["Optimisation du graphe"] GS[onnx-graphsurgeon<br/>Décomposition d'opérateurs] end
subgraph TFLite["Format LiteRT"] TM[model.tflite<br/>Quantifié en FP16] end
PM -->|paddle2onnx| OM OM -->|Décomposition HardSigmoid<br/>Modif mode Resize| GS GS -->|onnx2tf| TMCe chemin semble simple, mais il recèle des subtilités techniques.
Premier obstacle : Compatibilité des opérateurs de paddle2onnx
paddle2onnx est l’outil de conversion officiel fourni par PaddlePaddle. En théorie, il peut convertir les modèles PaddlePaddle au format ONNX. Cependant, PP-OCRv5 utilise certains opérateurs spécifiques dont le mapping dans ONNX n’est pas direct.
paddle2onnx --model_dir PP-OCRv5_mobile_det \ --model_filename inference.json \ --params_filename inference.pdiparams \ --save_file ocr_det_v5.onnx \ --opset_version 14Un détail crucial ici : le fichier de modèle de PP-OCRv5 est inference.json au lieu du traditionnel inference.pdmodel. Il s’agit d’un changement de format dans les nouvelles versions de PaddlePaddle, ce qui piège de nombreux développeurs 3.
Deuxième obstacle : HardSigmoid et compatibilité GPU
Le modèle ONNX converti contient l’opérateur HardSigmoid. Cet opérateur est défini mathématiquement comme suit :
Où et .
Le problème est que le GPU Delegate de LiteRT ne supporte pas HardSigmoid. Lorsqu’un modèle contient un opérateur non supporté, le GPU Delegate fait basculer (fallback) tout le sous-graphe vers le CPU, ce qui entraîne une perte de performance importante.
La solution consiste à décomposer HardSigmoid en opérateurs de base. En utilisant la bibliothèque onnx-graphsurgeon, nous pouvons effectuer une “chirurgie” au niveau du graphe de calcul :
import onnx_graphsurgeon as gsimport numpy as np
def decompose_hardsigmoid(graph: gs.Graph) -> gs.Graph: """ Décompose HardSigmoid en opérateurs de base compatibles GPU HardSigmoid(x) = max(0, min(1, alpha*x + beta)) Décomposition en : Mul -> Add -> Clip """ for node in graph.nodes: if node.op == "HardSigmoid": # Récupérer les paramètres 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]
# Créer des tenseurs constants 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) )
# Créer des variables intermédiaires mul_out = gs.Variable(name=f"{node.name}_mul_out") add_out = gs.Variable(name=f"{node.name}_add_out")
# Construire le sous-graphe décomposé : 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} )
# Remplacer le nœud d'origine graph.nodes.remove(node) graph.nodes.extend([mul_node, add_node, clip_node])
graph.cleanup().toposort() return graphL’intérêt de cette décomposition réside dans le fait que Mul, Add et Clip sont des opérateurs entièrement supportés par le GPU Delegate de LiteRT. Après décomposition, l’ensemble du sous-graphe peut être exécuté en continu sur le GPU, évitant ainsi les coûts de transfert de données entre CPU et GPU.
TIP
Pourquoi ne pas modifier directement le code d’entraînement du modèle ? Parce que le calcul du gradient de HardSigmoid lors de l’entraînement diffère de celui de Clip. La décomposition ne doit être effectuée que lors de la phase d’inférence pour maintenir la stabilité numérique de l’entraînement.
Troisième obstacle : Mode de transformation des coordonnées de l’opérateur Resize
L’opérateur Resize d’ONNX possède un attribut coordinate_transformation_mode qui détermine comment mapper les coordonnées de sortie aux coordonnées d’entrée. PP-OCRv5 utilise le mode half_pixel, mais le support de ce mode par le GPU Delegate de LiteRT est limité.
Le changer en mode asymmetric permet d’obtenir une meilleure compatibilité GPU :
for node in graph.nodes: if node.op == "Resize": node.attrs["coordinate_transformation_mode"] = "asymmetric"WARNING
Cette modification peut entraîner de légères différences numériques. Lors des tests réels, l’impact de cette différence sur la précision de l’OCR est négligeable, mais elle peut nécessiter une évaluation minutieuse pour d’autres tâches.
Dernière étape : onnx2tf et quantification FP16
onnx2tf est un outil permettant de convertir les modèles ONNX au format TFLite. La quantification FP16 (virgule flottante demi-précision) est un choix courant pour le déploiement mobile : elle réduit de moitié la taille du modèle tout en conservant une précision acceptable, et permet d’exploiter les unités de calcul FP16 des GPU mobiles.
onnx2tf -i ocr_det_v5_fixed.onnx -o converted_det \ -b 1 -ois x:1,3,640,640 -nL’argument -ois spécifie ici la forme statique (static shape) de l’entrée. Les formes statiques sont cruciales pour l’accélération GPU ; les formes dynamiques obligeraient à recompiler le programme GPU à chaque inférence, ce qui nuirait gravement aux performances.
Détection de texte : La binarisation différentiable de DBNet
Le module de détection de PP-OCRv5 est basé sur DBNet (Differentiable Binarization Network) 4. Contrairement aux méthodes traditionnelles qui utilisent un seuil fixe pour la binarisation, l’innovation de DBNet consiste à laisser le réseau apprendre lui-même le seuil optimal pour chaque pixel.
flowchart TB subgraph DBNet["Architecture DBNet"] direction TB IMG[Image d'entrée<br/>H x W x 3] BB[Backbone<br/>MobileNetV3] FPN[Pyramide de caractéristiques FPN<br/>Fusion multi-échelle]
subgraph Heads["Sorties à deux branches"] PH[Carte de probabilité<br/>P: H x W x 1] TH[Carte de seuil<br/>T: H x W x 1] end
DB["Binarisation différentiable<br/>B = sigmoid k * P-T"] end
IMG --> BB --> FPN FPN --> PH FPN --> TH PH --> DB TH --> DBBinarisation standard vs Binarisation différentiable
La binarisation standard est une fonction en escalier :
Cette fonction n’est pas dérivable, ce qui empêche un entraînement de bout en bout par rétropropagation. DBNet propose une fonction d’approximation :
Où est la carte de probabilité, est la carte de seuil (apprise par le réseau), et est un facteur d’amplification (fixé à 50 lors de l’entraînement).
TIP
Cette formule est essentiellement une fonction Sigmoid, dont l’entrée est devenue . Lorsque est suffisamment grand, son comportement se rapproche d’une fonction en escalier tout en restant dérivable.
Implémentation technique du flux de post-traitement
Dans le projet PPOCRv5-Android, le flux de post-traitement est implémenté dans postprocess.cpp. Les étapes clés comprennent :
flowchart LR subgraph Input["Sortie du modèle"] PM[Carte de probabilité P<br/>640 x 640] end
subgraph Binary["Binarisation"] BT[Filtrage par seuil<br/>seuil=0.1] BM[Image binaire<br/>640 x 640] end
subgraph Contour["Détection de contours"] DS[Sous-échantillonnage 4x<br/>160 x 160] CC[Analyse de composantes connexes<br/>Parcours BFS] BD[Extraction des points de bordure] end
subgraph Geometry["Calculs géométriques"] CH[Calcul de l'enveloppe convexe<br/>Graham Scan] RR[Rotating Calipers<br/>Rectangle englobant min] UC[Extension Unclip<br/>ratio=1.5] end
subgraph Output["Sortie"] TB[RotatedRect<br/>centre, taille, angle] end
PM --> BT --> BM BM --> DS --> CC --> BD BD --> CH --> RR --> UC --> TBDans le code réel, la méthode TextDetector::Impl::Detect illustre le flux complet de détection :
std::vector<RotatedRect> Detect(const uint8_t *image_data, int width, int height, int stride, float *detection_time_ms) { // 1. Calculer le ratio de redimensionnement scale_x_ = static_cast<float>(width) / kDetInputSize; scale_y_ = static_cast<float>(height) / kDetInputSize;
// 2. Redimensionnement par interpolation bilinéaire vers 640x640 image_utils::ResizeBilinear(image_data, width, height, stride, resized_buffer_.data(), kDetInputSize, kDetInputSize);
// 3. Normalisation ImageNet PrepareFloatInput();
// 4. Inférence auto run_result = compiled_model_->Run(input_buffers_, output_buffers_);
// 5. Binarisation BinarizeOutput(prob_map, total_pixels);
// 6. Détection de contours auto contours = postprocess::FindContours(binary_map_.data(), kDetInputSize, kDetInputSize);
// 7. Rectangle englobant minimum + Unclip for (const auto &contour : contours) { RotatedRect rect = postprocess::MinAreaRect(contour); UnclipBox(rect, kUnclipRatio); // Mapper les coordonnées vers l'image originale rect.center_x *= scale_x_; rect.center_y *= scale_y_; // ... }}Le point crucial de ce flux est le « rectangle englobant minimum orienté ». Contrairement aux boîtes englobantes alignées sur les axes (AABB), les rectangles orientés peuvent épouser étroitement du texte sous n’importe quel angle, ce qui est essentiel pour le texte incliné dans les scènes naturelles.
Unclip : Algorithme de dilatation des boîtes de texte
Les zones de texte produites par DBNet sont généralement légèrement plus petites que le texte réel, car le réseau apprend la « zone centrale » du texte. Pour obtenir les bordures complètes du texte, une opération de dilatation (Unclip) est nécessaire sur les polygones détectés.
Le principe mathématique d’Unclip repose sur l’opération inverse de l’algorithme de clipping de polygones de Vatti. Pour un polygone et une distance de dilatation , le polygone dilaté satisfait :
Où est l’aire du polygone, son périmètre, et le ratio de dilatation (généralement fixé à 1.5).
Dans postprocess.cpp, la fonction UnclipBox implémente cette logique :
void UnclipBox(RotatedRect &box, float unclip_ratio) { // Calculer la distance de dilatation float area = box.width * box.height; float perimeter = 2.0f * (box.width + box.height);
if (perimeter < 1e-6f) return; // Éviter la division par zéro
// d = A * r / L float distance = area * unclip_ratio / perimeter;
// Dilatation vers l'extérieur : augmenter largeur et hauteur de 2d box.width += 2.0f * distance; box.height += 2.0f * distance;}Cette version simplifiée suppose que la boîte de texte est un rectangle. Pour des polygones plus complexes, il faudrait utiliser la bibliothèque Clipper complète pour effectuer un décalage (offset) de polygone :
// Unclip complet de polygone (via la bibliothèque Clipper)ClipperLib::Path polygon;for (const auto& pt : contour) { polygon.push_back(ClipperLib::IntPoint( static_cast<int>(pt.x * 1000), // Agrandir pour préserver la précision static_cast<int>(pt.y * 1000) ));}
ClipperLib::ClipperOffset offset;offset.AddPath(polygon, ClipperLib::jtRound, ClipperLib::etClosedPolygon);
ClipperLib::Paths solution;offset.Execute(solution, distance * 1000); // DilatationNOTE
PPOCRv5-Android a opté pour une dilatation rectangulaire simplifiée plutôt qu’un décalage de polygone complet. Les raisons sont les suivantes :
- La plupart des boîtes de texte sont proches de rectangles.
- La bibliothèque Clipper complète augmenterait considérablement la taille du binaire.
- La version simplifiée offre de meilleures performances.
Reconnaissance de texte : SVTRv2 et décodage CTC
Si la détection consiste à « trouver où se trouve le texte », la reconnaissance consiste à « lire ce qu’est le texte ». Le module de reconnaissance de PP-OCRv5 est basé sur SVTRv2 (Scene Text Recognition with Visual Transformer v2) 5.
Innovations architecturales de SVTRv2
SVTRv2 apporte trois améliorations clés par rapport à la génération précédente SVTR :
flowchart TB subgraph SVTRv2["Architecture SVTRv2"] direction TB
subgraph Encoder["Encodeur visuel"] PE[Patch Embedding<br/>Convolution 4x4]
subgraph Mixing["Blocs d'attention hybride x12"] LA[Attention locale<br/>Fenêtre 7x7] GA[Attention globale<br/>Champ récepteur global] FFN[Feed Forward<br/>MLP] end end
subgraph Decoder["Décodeur CTC"] FC[Couche entièrement connectée<br/>D -> 18384] SM[Softmax] CTC[Décodage CTC] end end
PE --> LA --> GA --> FFN FFN --> FC --> SM --> CTC-
Mécanisme d’attention hybride : Utilisation alternée de l’attention locale (pour capturer les détails des traits) et de l’attention globale (pour comprendre la structure des caractères). L’attention locale utilise une fenêtre glissante de 7x7, réduisant la complexité de calcul de à .
-
Fusion de caractéristiques multi-échelles : Contrairement à la résolution unique de ViT, SVTRv2 utilise différentes résolutions de cartes de caractéristiques à différentes profondeurs, similaire à une structure pyramidale de CNN.
-
Module de Guidage Sémantique (Semantic Guidance Module) : Ajout d’une branche sémantique légère à la fin de l’encodeur pour aider le modèle à comprendre les relations sémantiques entre les caractères, au-delà des simples caractéristiques visuelles.
Ces améliorations permettent à SVTRv2 d’atteindre une précision comparable aux méthodes basées sur l’Attention, tout en conservant la simplicité du décodage CTC 6.
Pourquoi le CTC plutôt que l’Attention ?
Il existe deux paradigmes dominants pour la reconnaissance de texte :
- CTC (Connectionist Temporal Classification) : Considère la reconnaissance comme un problème d’étiquetage de séquence, où la sortie est alignée avec l’entrée.
- Décodeur basé sur l’Attention : Utilise un mécanisme d’attention pour générer la sortie caractère par caractère.
Les méthodes basées sur l’Attention sont généralement plus précises, mais les méthodes CTC sont plus simples et plus rapides. La contribution de SVTRv2 est d’améliorer l’encodeur visuel pour permettre aux méthodes CTC d’égaler, voire de dépasser, la précision des méthodes basées sur l’Attention 6.
Le cœur du décodage CTC consiste à « fusionner les répétitions » et à « supprimer les blancs » :
flowchart LR subgraph Input["Sortie du modèle"] 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["Fusion répétitions"] M["blank, H, blank, e, l, o"] end
subgraph Remove["Suppression blancs"] R["H, e, l, o"] end
subgraph Output["Sortie"] O["Helo - Erreur"] end
L --> A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 A1 & A2 & A3 & A4 & A5 & A6 & A7 & A8 & A9 --> Merge --> Remove --> OutputAttendez, il y a un problème ici. Si le texte original est “Hello”, les deux ‘l’ ont été fusionnés par erreur. La solution du CTC est d’insérer un jeton “blank” entre les caractères répétés.
Encodage correct : [blank, H, e, l, blank, l, o]Résultat du décodage : "Hello"Décodage CTC optimisé par NEON
Le décodage CTC de PPOCRv5-Android utilise un Argmax optimisé par NEON. Dans text_recognizer.cpp :
inline void ArgmaxNeon8(const float *__restrict__ data, int size, int &max_idx, float &max_val) { if (size < 16) { // Fallback scalaire 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; }
// Vectorisation NEON : traite 4 float à la fois 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);
// Comparaison vectorisée et sélection conditionnelle uint32x4_t cmp = vcgtq_f32(v_curr, v_max); v_max = vbslq_f32(cmp, v_curr, v_max); // Choisir la plus grande valeur v_max_idx = vbslq_s32(cmp, v_idx, v_max_idx); // Choisir l'index correspondant }
// Réduction horizontale : trouver le max parmi les 4 candidats float max_vals[4]; int32_t max_idxs[4]; vst1q_f32(max_vals, v_max); vst1q_s32(max_idxs, v_max_idx); // ... comparaison finale}Pour un Argmax de 18 384 catégories, l’optimisation NEON peut apporter une accélération d’environ 3 fois.
Principes mathématiques de la fonction de perte CTC et du décodage
L’idée centrale du CTC est la suivante : étant donné une séquence d’entrée et tous les chemins d’alignement possibles , calculer la probabilité de la séquence cible :
Où est une “fonction de mapping plusieurs-vers-un” qui mappe le chemin à la séquence de sortie (en fusionnant les répétitions et en supprimant les blancs).
Lors de l’inférence, nous utilisons le décodage glouton (Greedy Decoding) plutôt qu’un Beam Search complet :
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; // Utilisé pour fusionner les répétitions
for (int t = 0; t < time_steps; ++t) { // Trouver la catégorie de probabilité maximale pour le pas de temps actuel 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; } }
// Règles de décodage CTC : // 1. Ignorer le jeton blank (index 0) // 2. Fusionner les caractères consécutifs répétés if (max_idx != 0 && max_idx != prev_idx) { result += dictionary[max_idx - 1]; // -1 car blank occupe l'index 0 }
prev_idx = max_idx; }
return result;}La complexité temporelle du décodage glouton est , où est le nombre de pas de temps et le nombre de catégories. Pour PP-OCRv5, et , chaque décodage nécessite environ 1,5 million de comparaisons. C’est pourquoi l’optimisation NEON est si importante.
TIP
Le Beam Search peut améliorer la précision du décodage, mais sa charge de calcul est fois supérieure à celle du décodage glouton ( étant la largeur du faisceau). Sur mobile, le décodage glouton est généralement un meilleur choix.
Dictionnaire de caractères : Le défi des 18 383 caractères
PP-OCRv5 prend en charge 18 383 caractères, incluant :
- Caractères chinois simplifiés courants
- Caractères chinois traditionnels courants
- Lettres anglaises et chiffres
- Hiragana et Katakana japonais
- Ponctuation courante et caractères spéciaux
Ce dictionnaire est stocké dans le fichier keys_v5.txt, avec un caractère par ligne. Lors du décodage CTC, la forme des logits de sortie du modèle est [1, T, 18384], où T est le nombre de pas de temps, et 18384 = 18383 caractères + 1 jeton blank.
API C++ LiteRT : L’interface moderne après la refactorisation de 2024
PPOCRv5-Android utilise l’API C++ de LiteRT telle qu’elle a été refactorisée en 2024, offrant une conception d’interface plus moderne. Par rapport à l’API C traditionnelle de TFLite, la nouvelle API offre une meilleure sécurité de typage et de meilleures capacités de gestion des ressources.
Comparaison entre l’ancienne et la nouvelle API
La refactorisation de LiteRT en 2024 a apporté des changements significatifs :
| Caractéristique | Ancienne API (TFLite) | Nouvelle API (LiteRT) |
|---|---|---|
| Espace de noms | tflite:: | litert:: |
| Gestion d’erreurs | Retourne l’énumération TfLiteStatus | Retourne le type Expected<T> |
| Gestion mémoire | Manuelle | Automatique via RAII |
| Configuration Delegate | API dispersées | Classe Options unifiée |
| Accès aux tenseurs | Pointeurs + cast manuel | TensorBuffer sécurisé |
L’avantage majeur de la nouvelle API réside dans la sécurité du typage et la gestion automatique des ressources. Exemple avec la gestion d’erreurs :
// Ancienne API : nécessite de vérifier manuellement chaque valeur de retourTfLiteStatus status = TfLiteInterpreterAllocateTensors(interpreter);if (status != kTfLiteOk) { // Gestion d'erreur}
// Nouvelle API : utilise le type Expected, supporte le chaînage d'appelsauto model_result = litert::CompiledModel::Create(env, model_path, options);if (!model_result) { LOGE(TAG, "Erreur : %s", model_result.Error().Message().c_str()); return false;}auto model = std::move(*model_result); // Gestion automatique du cycle de vieEnvironnement et initialisation du modèle
Dans text_detector.cpp, le flux d’initialisation est le suivant :
bool Initialize(const std::string &model_path, AcceleratorType accelerator_type) { // 1. Créer l'environnement LiteRT auto env_result = litert::Environment::Create({}); if (!env_result) { LOGE(TAG, "Échec de création de l'environnement LiteRT : %s", env_result.Error().Message().c_str()); return false; } env_ = std::move(*env_result);
// 2. Configurer l'accélérateur matériel auto options_result = litert::Options::Create(); auto hw_accelerator = ToLiteRtAccelerator(accelerator_type); options.SetHardwareAccelerators(hw_accelerator);
// 3. Compiler le modèle auto model_result = litert::CompiledModel::Create(*env_, model_path, options); if (!model_result) { LOGW(TAG, "Échec de création du CompiledModel avec l'accélérateur %d : %s", static_cast<int>(accelerator_type), model_result.Error().Message().c_str()); return false; } compiled_model_ = std::move(*model_result);
// 4. Ajuster la forme du tenseur d'entrée std::vector<int> input_dims = {1, kDetInputSize, kDetInputSize, 3}; compiled_model_->ResizeInputTensor(0, absl::MakeConstSpan(input_dims));
// 5. Créer des buffers gérés CreateBuffersWithCApi();
return true;}Managed Tensor Buffer : La clé de l’inférence zéro-copie
Le Managed Tensor Buffer de LiteRT est essentiel pour obtenir une inférence haute performance. Il permet au GPU Delegate d’accéder directement au buffer, sans transfert de données CPU-GPU :
bool CreateBuffersWithCApi() { LiteRtCompiledModel c_model = compiled_model_->Get(); LiteRtEnvironment c_env = env_->Get();
// Obtenir les exigences du buffer d'entrée LiteRtTensorBufferRequirements input_requirements = nullptr; LiteRtGetCompiledModelInputBufferRequirements( c_model, /*signature_index=*/0, /*input_index=*/0, &input_requirements);
// Obtenir les informations de type du tenseur auto input_type = compiled_model_->GetInputTensorType(0, 0); LiteRtRankedTensorType tensor_type = static_cast<LiteRtRankedTensorType>(*input_type);
// Créer un buffer géré LiteRtTensorBuffer input_buffer = nullptr; LiteRtCreateManagedTensorBufferFromRequirements( c_env, &tensor_type, input_requirements, &input_buffer);
// Envelopper dans un objet C++, gestion automatique du cycle de vie input_buffers_.push_back( litert::TensorBuffer::WrapCObject(input_buffer, litert::OwnHandle::kYes)); return true;}Les avantages de cette conception sont :
- Inférence zéro-copie : Le GPU Delegate accède directement au buffer.
- Gestion automatique de la mémoire :
OwnHandle::kYesgarantit que le buffer est libéré lors de la destruction de l’objet C++. - Sécurité de typage : Vérification de la correspondance des types de tenseurs à la compilation.
Accélération GPU : Choix et compromis d’OpenCL
LiteRT propose plusieurs options d’accélération matérielle :
flowchart TB subgraph Delegates["Écosystème 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/>Optimisé CPU] end
subgraph Hardware["Mapping matériel"] direction TB ADRENO[GPU Adreno<br/>Qualcomm] MALI[GPU Mali<br/>ARM] NPU[NPU/DSP<br/>Spécifique constructeur] CPU[CPU ARM<br/>NEON] end
GPU_CL --> ADRENO GPU_CL --> MALI GPU_GL --> ADRENO GPU_GL --> MALI NNAPI --> NPU XNN --> CPU| Accélérateur | Backend | Avantages | Inconvénients |
|---|---|---|---|
| GPU | OpenCL | Large support, bonnes perfs | Pas un composant standard Android |
| GPU | OpenGL ES | Composant standard Android | Moins performant qu’OpenCL |
| NPU | NNAPI | Performance maximale | Mauvaise compatibilité appareils |
| CPU | XNNPACK | Compatibilité universelle | Performance la plus faible |
PPOCRv5-Android a choisi OpenCL comme backend d’accélération principal. Google a publié le backend OpenCL pour TFLite en 2020, lequel offre une accélération environ 2 fois supérieure au backend OpenGL ES sur les GPU Adreno 7.
Les avantages d’OpenCL proviennent de plusieurs aspects :
- Intention de conception : OpenCL a été conçu dès le départ pour le calcul généraliste, tandis qu’OpenGL est une API de rendu graphique à laquelle le support des compute shaders a été ajouté plus tard.
- Mémoire constante : La mémoire constante d’OpenCL est très efficace pour l’accès aux poids des réseaux de neurones.
- Support FP16 : OpenCL supporte nativement la virgule flottante demi-précision, alors que le support d’OpenGL est arrivé plus tard.
Cependant, OpenCL présente un défaut majeur : il n’est pas un composant standard d’Android. La qualité des implémentations OpenCL varie selon les constructeurs, et certains appareils ne le supportent pas du tout.
OpenCL vs OpenGL ES : Comparaison approfondie des performances
Pour comprendre l’avantage d’OpenCL, il faut descendre au niveau de l’architecture GPU. Prenons l’exemple du Qualcomm Adreno 640 :
flowchart TB subgraph Adreno["Architecture Adreno 640"] direction TB
subgraph SP["Shader Processors x2"] ALU1[Tableau ALU<br/>256 FP32 / 512 FP16] ALU2[Tableau ALU<br/>256 FP32 / 512 FP16] end
subgraph Memory["Hiérarchie mémoire"] L1[Cache L1<br/>16KB par SP] L2[Cache L2<br/>1MB partagé] GMEM[Mémoire globale<br/>LPDDR4X] end
subgraph Special["Unités dédiées"] TMU[Unité de texture<br/>Interpolation bilinéaire] CONST[Cache constante<br/>Accélération des poids] end end
ALU1 --> L1 ALU2 --> L1 L1 --> L2 --> GMEM TMU --> L1 CONST --> ALU1 & ALU2L’avantage de performance d’OpenCL provient de :
| Caractéristique | OpenCL | OpenGL ES Compute |
|---|---|---|
| Mémoire constante | Support natif, accélération matérielle | Nécessite une simulation via UBO |
| Taille du groupe de travail | Configuration flexible | Limitée par le modèle de shader |
| Barrière mémoire | Contrôle fin | Grain grossier |
| Calcul FP16 | Extension cl_khr_fp16 | Nécessite la précision mediump |
| Outils de débogage | Snapdragon Profiler | Support limité |
Dans les opérations de convolution, les poids sont généralement constants. OpenCL peut placer les poids en mémoire constante, bénéficiant ainsi d’optimisations de diffusion (broadcast) au niveau matériel. OpenGL ES doit passer les poids comme des Uniform Buffer Objects (UBO), ce qui augmente la charge d’accès mémoire.
NOTE
Google a restreint le chargement direct des bibliothèques OpenCL par les applications depuis Android 7.0. Cependant, le GPU Delegate de LiteRT contourne cette restriction en utilisant dlopen pour charger dynamiquement l’implémentation OpenCL du système. C’est pourquoi le GPU Delegate doit détecter la disponibilité d’OpenCL au moment de l’exécution.
Stratégie de dégradation gracieuse (Fallback)
PPOCRv5-Android implémente une stratégie de repli :
constexpr AcceleratorType kFallbackChain[] = { AcceleratorType::kGpu, // Premier choix : GPU AcceleratorType::kCpu, // Repli : 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;}Cette stratégie garantit que l’application fonctionne sur n’importe quel appareil, seules les performances varient.
Couche native : Optimisations C++ et NEON
Pourquoi utiliser le C++ plutôt que Kotlin ?
La réponse est simple : la performance. Le pré-traitement d’image implique de nombreuses opérations au niveau du pixel, dont le coût sur la JVM est inacceptable. Plus important encore, le C++ permet d’utiliser directement les instructions SIMD ARM NEON pour réaliser des calculs vectorisés.
NEON : Le jeu d’instructions SIMD d’ARM
NEON est une extension SIMD (Single Instruction, Multiple Data) des processeurs ARM. Elle permet à une seule instruction de traiter simultanément plusieurs éléments de données.
flowchart LR subgraph NEON["Registre NEON 128 bits"] direction TB F4["4x float32"] I8["8x int16"] B16["16x int8"] end
subgraph Operations["Opérations vectorisées"] direction TB LD["vld1q_f32<br/>Charger 4 float"] SUB["vsubq_f32<br/>Soustraction parallèle 4 voies"] MUL["vmulq_f32<br/>Multiplication parallèle 4 voies"] ST["vst1q_f32<br/>Stocker 4 float"] end
subgraph Speedup["Gain de performance"] S1["Scalaire : 4 instructions"] S2["NEON : 1 instruction"] S3["Accélération théorique : 4x"] end
F4 --> LD LD --> SUB --> MUL --> ST ST --> S3PPOCRv5-Android utilise l’optimisation NEON dans plusieurs chemins critiques. Exemple avec la binarisation (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) { // Traiter 16 pixels à la fois 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);
// Comparaison vectorisée 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);
// Réduction vers uint8 uint16x4_t n0 = vmovn_u32(cmp0); uint16x4_t n1 = vmovn_u32(cmp1); uint16x8_t n01 = vcombine_u16(n0, n1); // ... fusion et stockage } // Fallback scalaire pour les pixels restants for (; i < total_pixels; ++i) { binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0; }#else // Implémentation purement scalaire for (int i = 0; i < total_pixels; ++i) { binary_map_[i] = (prob_map[i] > kBinaryThreshold) ? 255 : 0; }#endif}Points clés d’optimisation de ce code :
- Chargement par lots :
vld1q_f32charge 4 float à la fois, réduisant le nombre d’accès mémoire. - Comparaison vectorisée :
vcgtq_f32compare 4 valeurs simultanément et génère un masque. - Réduction de type :
vmovn_u32compresse les résultats 32 bits en 16 bits, puis finalement en 8 bits.
Par rapport à une implémentation scalaire, l’optimisation NEON peut apporter une accélération de 3 à 4 fois 8.
Implémentation NEON de la normalisation ImageNet
La normalisation de l’image est une étape clé du pré-traitement. La standardisation ImageNet utilise la formule suivante :
Où et (canaux RGB).
Dans image_utils.cpp, l’implémentation de la normalisation optimisée par NEON est la suivante :
void NormalizeImageNet(const uint8_t* src, int width, int height, int stride, float* dst) { // Paramètres de normalisation 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__) // Pré-calcul : (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);
// Pré-calcul : -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) { // Charger 4 pixels RGBA (16 octets) uint8x16_t rgba = vld1q_u8(row + x * 4);
// Dé-entrelacement : 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)));
// Normalisation : (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);
// Stockage entrelacé : RRRR, GGGG, BBBB -> RGBRGBRGBRGB float32x4x3_t rgb = {r_f, g_f, b_f}; vst3q_f32(dst_row + x * 3, rgb); }
// Traitement scalaire des pixels restants 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 // Implémentation scalaire (omise)#endif}Techniques d’optimisation clés de ce code :
- Pré-calcul des constantes : Transformer
(x - mean) / stdenx * scale + biaspour réduire les divisions à l’exécution. - Fused Multiply-Add :
vmlaq_f32effectue une multiplication et une addition en une seule instruction. - Chargement dé-entrelacé :
vld4q_u8sépare automatiquement le RGBA en quatre canaux. - Stockage entrelacé :
vst3q_f32écrit les trois canaux RGB de manière entrelacée en mémoire.
Zéro dépendance OpenCV
De nombreux projets OCR dépendent d’OpenCV pour le pré-traitement d’image. OpenCV est puissant, mais il alourdit considérablement la taille du paquet (plus de 10 Mo sur Android).
PPOCRv5-Android a choisi la voie du « zéro dépendance OpenCV ». Toutes les opérations de pré-traitement d’image sont implémentées en C++ pur dans image_utils.cpp :
- Redimensionnement par interpolation bilinéaire : Implémenté à la main, supportant l’optimisation NEON.
- Normalisation : Standardisation ImageNet et normalisation de reconnaissance.
- Transformation perspective : Recadrage de zones de texte sous n’importe quel angle à partir de l’image originale.
Implémentation NEON de l’interpolation bilinéaire
L’interpolation bilinéaire est l’algorithme central du redimensionnement d’image. Étant donné les coordonnées de l’image source, l’interpolation bilinéaire calcule la valeur du pixel cible :
Où , , et sont les valeurs des quatre pixels voisins.
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 : traite 4 pixels cibles à la fois 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) { // Calculer 4 coordonnées sources float sx[4]; for (int i = 0; i < 4; ++i) { sx[i] = ((dx + i) + 0.5f) * scale_x - 0.5f; }
// Charger les poids 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]; }
// Effectuer l'interpolation bilinéaire pour chaque canal for (int c = 0; c < 4; ++c) { // RGBA float32x4_t f00, f10, f01, f11;
// Collecter les valeurs voisines de 4 pixels 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 similaires
// Formule d'interpolation bilinéaire 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 );
// Reconvertir en uint8 et stocker uint32x4_t result_u32 = vcvtq_u32_f32(result); // ... stockage } }#endif // Traitement scalaire des pixels restants (omis) }}TIP
L’optimisation NEON de l’interpolation bilinéaire est complexe car les adresses des quatre pixels voisins ne sont pas contiguës. Une méthode plus efficace consiste à utiliser l’interpolation bilinéaire séparable : d’abord horizontalement, puis verticalement. Cela permet de mieux exploiter la localité du cache.
Ce choix a nécessité plus de travail de développement, mais les bénéfices sont notables :
- Réduction de la taille de l’APK d’environ 10 Mo.
- Contrôle total sur la logique de pré-traitement, facilitant l’optimisation.
- Évitement des problèmes de compatibilité de versions d’OpenCV.
Transformation perspective : Du rectangle orienté à la ligne de texte standard
Les modèles de reconnaissance de texte attendent en entrée des images de lignes de texte horizontales. Cependant, les boîtes de texte détectées peuvent être des rectangles orientés sous n’importe quel angle. La transformation perspective se charge de « redresser » ces zones rectangulaires.
Dans text_recognizer.cpp, la méthode CropAndRotate implémente cette fonctionnalité :
void CropAndRotate(const uint8_t *__restrict__ image_data, int width, int height, int stride, const RotatedRect &box, int &target_width) { // Calculer les quatre coins du rectangle orienté 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]; // Coordonnées (x, y) des 4 coins 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); // ... calculer les autres coins
// Largeur cible adaptative : préserver le ratio d'aspect 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]
// Matrice de transformation affine 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;
// Échantillonnage par interpolation bilinéaire + normalisation (optimisé 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); } }}Optimisations clés de cette implémentation :
- Largeur adaptative : Ajuster dynamiquement la largeur de sortie selon le ratio d’aspect de la boîte de texte pour éviter tout étirement ou compression excessifs.
- Approximation par transformation affine : Pour les boîtes de texte proches d’un parallélogramme, utiliser une transformation affine au lieu d’une transformation perspective pour réduire les calculs.
- Interpolation bilinéaire NEON : L’échantillonnage et la normalisation sont effectués en une seule passe, réduisant les accès mémoire.
JNI : Le pont entre Kotlin et C++
Le JNI (Java Native Interface) est le pont de communication entre Kotlin/Java et le C++. Cependant, les appels JNI ont un coût ; des appels fréquents entre les langages peuvent gravement nuire aux performances.
Le principe de conception de PPOCRv5-Android est de minimiser le nombre d’appels JNI. Tout le flux OCR ne nécessite qu’un seul appel JNI :
sequenceDiagram participant K as Couche Kotlin participant J as Pont JNI participant N as Couche Native participant G as GPU
K->>J: process(bitmap) J->>N: Passer le pointeur RGBA
Note over N,G: La couche native effectue tout le travail
N->>N: Pré-traitement d'image NEON N->>G: Inférence détection de texte G-->>N: Carte de probabilité N->>N: Post-traitement détection contours
loop Chaque boîte de texte N->>N: Recadrage transformation perspective N->>G: Inférence reconnaissance de texte G-->>N: logits N->>N: Décodage CTC end
N-->>J: Résultats OCR J-->>K: List OcrResultDans ppocrv5_jni.cpp, la fonction centrale nativeProcess illustre cette conception :
JNIEXPORT jobjectArray JNICALLJava_me_fleey_ppocrv5_ocr_OcrEngine_nativeProcess( JNIEnv *env, jobject thiz, jlong handle, jobject bitmap) {
auto *engine = reinterpret_cast<ppocrv5::OcrEngine *>(handle);
// Verrouiller les pixels du Bitmap void *pixels = nullptr; AndroidBitmap_lockPixels(env, bitmap, &pixels);
// Un seul appel JNI pour tout le travail 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);
// Construire et retourner le tableau d'objets Java // ...}Cette conception évite le coût des allers-retours de données entre la détection et la reconnaissance.
Conception architecturale : Modularité et testabilité
L’architecture de PPOCRv5-Android suit le principe de « séparation des préoccupations » (SoC) :
flowchart TB subgraph UI["Couche UI Jetpack Compose"] direction LR CP[CameraPreview] GP[GalleryPicker] RO[ResultOverlay] end
subgraph VM["Couche ViewModel"] OVM[OCRViewModel<br/>Gestion d'état] end
subgraph Native["Couche Native - C++"] OE[OcrEngine<br/>Orchestration]
subgraph Detection["Détection de texte"] TD[TextDetector] DB[DBNet FP16] end
subgraph Recognition["Reconnaissance de texte"] TR[TextRecognizer] SVTR[SVTRv2 + CTC] end
subgraph Preprocessing["Pré-traitement d'image"] IP[ImagePreprocessor<br/>Optimisé NEON] PP[PostProcessor<br/>Détection de contours] end
subgraph Runtime["Runtime LiteRT"] GPU[GPU Delegate<br/>OpenCL] CPU[Fallback CPU<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| CPULes avantages de cette architecture multicouche sont :
- Couche UI : En Kotlin/Compose pur, dédiée à l’interaction utilisateur.
- Couche ViewModel : Gère l’état et la logique métier.
- Couche Native : Calcul haute performance, totalement découplée de l’UI.
Chaque couche peut être testée indépendamment. La couche native peut être testée unitairement avec Google Test, et la couche ViewModel avec JUnit + MockK.
Encapsulation de la couche Kotlin
Dans OcrEngine.kt, la couche Kotlin offre une API concise :
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("Échec de création du moteur OCR natif") }
OcrEngine(handle) } }
fun process(bitmap: Bitmap): List<OcrResult> { check(nativeHandle != 0L) { "OcrEngine a été fermé" } return nativeProcess(nativeHandle, bitmap)?.toList() ?: emptyList() }
override fun close() { if (nativeHandle != 0L) { nativeDestroy(nativeHandle) nativeHandle = 0 } }}Avantages de cette conception :
- Utilisation du type
Resultpour gérer les erreurs d’initialisation. - Implémentation de l’interface
Closeable, supportant les blocsusepour la libération automatique des ressources. - Copie automatique des fichiers de modèles depuis les assets vers le répertoire de cache.
Optimisation du démarrage à froid
La première inférence (démarrage à froid) est généralement beaucoup plus lente que les suivantes (démarrage à chaud). Cela est dû à :
- Le GPU Delegate doit compiler les programmes OpenCL.
- Les poids du modèle doivent être transférés de la mémoire CPU vers la mémoire GPU.
- Divers caches doivent être préchauffés.
PPOCRv5-Android atténue ce problème via un mécanisme de Warm-up :
void OcrEngine::WarmUp() { LOGD(TAG, "Démarrage du warm-up (%d itérations)...", kWarmupIterations);
// Créer une petite image de test 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; }
// Exécuter quelques inférences pour préchauffer 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 terminé (accélérateur : %s)", AcceleratorName(active_accelerator_));}Optimisation de l’alignement mémoire
Dans TextDetector::Impl, tous les buffers pré-alloués utilisent un alignement de 64 octets :
// Buffers pré-alloués avec alignement sur la ligne de cachealignas(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_;L’alignement sur 64 octets correspond à la taille de la ligne de cache des processeurs ARM modernes. Un accès mémoire aligné évite le fractionnement des lignes de cache et améliore l’efficacité de l’accès mémoire.
Pool de mémoire et réutilisation d’objets
Les allocations et libérations fréquentes de mémoire sont des tueurs de performance. PPOCRv5-Android utilise une stratégie de pré-allocation, allouant toute la mémoire nécessaire en une seule fois lors de l’initialisation :
class TextDetector::Impl { // Buffers pré-alloués, cycle de vie identique à 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(...) { // Allocation unique pour éviter les malloc à l'exécution resized_buffer_.resize(kDetInputSize * kDetInputSize * 4); normalized_buffer_.resize(kDetInputSize * kDetInputSize * 3); binary_map_.resize(kDetInputSize * kDetInputSize); prob_map_.resize(kDetInputSize * kDetInputSize); return true; }};Avantages de cette conception :
- Évitement de la fragmentation mémoire : Tous les grands blocs de mémoire sont alloués au démarrage.
- Réduction des appels système :
mallocpeut déclencher des appels système, la pré-allocation évite ce surcoût. - Respect du cache : La mémoire allouée de manière contiguë a plus de chances d’être physiquement contiguë, améliorant le taux de réussite du cache.
Optimisation de la prédiction de branche
Les processeurs modernes utilisent la prédiction de branche pour améliorer l’efficacité du pipeline. Une mauvaise prédiction entraîne un vidage du pipeline, coûtant 10 à 20 cycles d’horloge.
Sur les chemins critiques (hot paths), nous utilisons __builtin_expect pour donner des indices au compilateur :
// La plupart des pixels ne dépasseront pas le seuilif (__builtin_expect(prob_map[i] > kBinaryThreshold, 0)) { binary_map_[i] = 255;} else { binary_map_[i] = 0;}__builtin_expect(expr, val) indique au compilateur que la valeur de expr est très probablement val. Le compilateur ajuste alors la disposition du code pour placer les branches « peu probables » loin du chemin principal.
Déroulage de boucle et pipeline logiciel
Pour les boucles intensives en calcul, le déroulage manuel peut réduire le surcoût de la boucle et exposer davantage de parallélisme au niveau des instructions :
// Version non dérouléefor (int i = 0; i < n; ++i) { dst[i] = src[i] * scale + bias;}
// Version déroulée 4xint 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;}Après déroulage, le CPU peut exécuter simultanément plusieurs instructions de multiplication-addition indépendantes, exploitant pleinement les multiples unités d’exécution de l’architecture superscalaire.
Optimisation Prefetch
Dans la boucle interne de la transformation perspective, utilisez __builtin_prefetch pour charger à l’avance les données de la ligne suivante :
for (int dy = 0; dy < kRecInputHeight; ++dy) { // Précharger les données de la ligne suivante 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); } } // ... traiter la ligne actuelle}Cette optimisation permet de masquer la latence mémoire : pendant le traitement de la ligne actuelle, les données de la ligne suivante sont déjà chargées dans le cache L1.
Détails d’ingénierie du post-traitement
Analyse de composantes connexes et détection de contours
Dans postprocess.cpp, la fonction FindContours implémente une analyse efficace des composantes connexes :
std::vector<std::vector<Point>> FindContours(const uint8_t *binary_map, int width, int height) { // 1. Sous-échantillonnage 4x pour réduire la charge de calcul 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. Parcours BFS des composantes connexes 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();
// Détecter les pixels de bordure 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) }); }
// Extension 4-voisinage 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;}Points d’optimisation clés :
- Sous-échantillonnage 4x : Réduire l’image binaire de 640x640 à 160x160, divisant par 16 la charge de calcul.
- Détection de bordure : Ne conserver que les pixels de bordure plutôt que toute la composante connexe.
- Limite du nombre maximal de contours :
kMaxContours = 100, pour éviter les problèmes de performance dans les cas extrêmes.
Algorithmes d’enveloppe convexe et de Rotating Calipers
Le calcul du rectangle englobant minimum orienté se fait en deux étapes : d’abord calculer l’enveloppe convexe, puis utiliser l’algorithme des Rotating Calipers pour trouver le rectangle englobant d’aire minimale.
Algorithme d’enveloppe convexe Graham Scan
Le Graham Scan est un algorithme classique pour calculer l’enveloppe convexe, avec une complexité de :
std::vector<Point> ConvexHull(std::vector<Point> points) { if (points.size() < 3) return points;
// 1. Trouver le point le plus bas (y min, puis x min) 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. Trier par angle polaire 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) { // En cas de colinéarité, le plus proche d'abord return DistanceSquared(p0, a) < DistanceSquared(p0, b); } return cross > 0; // Sens anti-horaire });
// 3. Construire l'enveloppe std::vector<Point> hull; for (const auto& p : points) { // Retirer les points provoquant un virage horaire while (hull.size() > 1 && CrossProduct(hull[hull.size()-2], hull[hull.size()-1], p) <= 0) { hull.pop_back(); } hull.push_back(p); }
return hull;}
// Produit en croix : déterminer la direction du viragefloat 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);}Algorithme des Rotating Calipers
L’algorithme des Rotating Calipers parcourt chaque arête de l’enveloppe convexe pour calculer l’aire du rectangle englobant basé sur cette arête :
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; // Positions des trois "calipers"
for (int i = 0; i < n; ++i) { int j = (i + 1) % n;
// Vecteur direction de l'arête actuelle 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);
// Vecteur unitaire float ux = edge_x / edge_len; float uy = edge_y / edge_len;
// Direction perpendiculaire float vx = -uy; float vy = ux;
// Trouver le point le plus à droite (projection max le long de l'arête) while (Dot(hull[(right + 1) % n], ux, uy) > Dot(hull[right], ux, uy)) { right = (right + 1) % n; }
// Trouver le point le plus haut (projection max perpendiculaire) while (Dot(hull[(top + 1) % n], vx, vy) > Dot(hull[top], vx, vy)) { top = (top + 1) % n; }
// Trouver le point le plus à gauche while (Dot(hull[(left + 1) % n], ux, uy) < Dot(hull[left], ux, uy)) { left = (left + 1) % n; }
// Calculer les dimensions du rectangle 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; // Mettre à jour les paramètres du meilleur rectangle best_rect.width = width; best_rect.height = height; best_rect.angle = std::atan2(uy, ux) * 180.0f / M_PI; // Calculer le centre... } }
return best_rect;}L’intuition clé des Rotating Calipers est que lorsque l’arête de base tourne, les trois « calipers » (points les plus à droite, en haut et à gauche) ne font qu’avancer de manière monotone. Ainsi, la complexité totale est et non .
Rectangle englobant minimum orienté
La fonction MinAreaRect utilise l’algorithme des Rotating Calipers pour calculer le rectangle englobant minimum orienté :
RotatedRect MinAreaRect(const std::vector<Point> &contour) { // 1. Sous-échantillonnage pour réduire le nombre de points std::vector<Point> points = subsample_points(contour, kMaxBoundaryPoints);
// 2. Chemin rapide : utiliser l'AABB pour les boîtes de texte à ratio élevé 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) { // Retourner directement la boîte englobante alignée sur les axes 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. Calcul de l'enveloppe convexe std::vector<Point> hull = convex_hull(std::vector<Point>(points));
// 4. Rotating Calipers : parcourir chaque arête de l'enveloppe float min_area = std::numeric_limits<float>::max(); RotatedRect best_rect;
for (size_t i = 0; i < hull.size(); ++i) { // Calculer le rectangle englobant basé sur l'arête actuelle float edge_x = hull[j].x - hull[i].x; float edge_y = hull[j].y - hull[i].y;
// Projeter tous les points sur la direction de l'arête et la direction perpendiculaire 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; // Mettre à jour le meilleur rectangle } }
return best_rect;}La complexité temporelle de cet algorithme est (calcul de l’enveloppe convexe) + (Rotating Calipers), où est le nombre de points de bordure. En limitant à moins de 200 via le sous-échantillonnage, on garantit des performances en temps réel.
OCR caméra en temps réel : CameraX et analyse de trames
Le défi de l’OCR en temps réel est de traiter chaque trame le plus rapidement possible tout en maintenant une prévisualisation fluide.
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["Flux d'analyse de trame"] direction TB IP[ImageProxy<br/>YUV_420_888] BM[Conversion Bitmap<br/>RGBA_8888] JNI[Appel JNI<br/>Appel unique] end
subgraph Native["OCR Natif"] direction TB DET[TextDetector<br/>~45ms GPU] REC[TextRecognizer<br/>~15ms/ligne] RES[Résultats OCR] end
subgraph UI["Mise à jour UI"] direction TB VM[ViewModel<br/>StateFlow] OV[ResultOverlay<br/>Dessin Canvas] end
CP --> PV CP --> IA IA --> IP --> BM --> JNI JNI --> DET --> REC --> RES RES --> VM --> OVImageAnalysis de CameraX
CameraX est la bibliothèque de caméra de Jetpack Android. Elle fournit le cas d’utilisation ImageAnalysis, qui nous permet d’analyser les trames de la caméra en temps réel :
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) // Mettre à jour l'UI imageProxy.close()}La configuration clé est STRATEGY_KEEP_ONLY_LATEST : si la vitesse de traitement de l’analyseur ne suit pas la cadence de la caméra, les anciennes trames sont abandonnées pour ne conserver que la plus récente. Cela garantit la pertinence temporelle des résultats OCR.
Équilibre entre cadence et latence
Sur les appareils avec accélération GPU (bien que mon Snapdragon 870 actuel semble avoir des soucis pour déléguer la majorité des calculs au GPU), PPOCRv5-Android peut théoriquement atteindre des vitesses de traitement élevées. Cependant, cela ne signifie pas que nous devions traiter chaque trame.
Considérons ce scénario : l’utilisateur pointe la caméra vers un texte qui ne change pas à court terme. Effectuer un OCR complet à chaque trame gaspillerait d’énormes ressources de calcul.
Une stratégie d’optimisation est la « détection de changement » : déclencher l’OCR uniquement lorsque l’image change de manière significative. Cela peut être réalisé en comparant les histogrammes ou les points caractéristiques des trames successives.
Perspectives d’avenir : NPU et quantification
L’avenir de l’IA embarquée réside dans les NPU (Neural Processing Unit). Par rapport aux GPU, les NPU sont conçus spécifiquement pour l’inférence de réseaux de neurones et offrent une meilleure efficacité énergétique.
Cependant, le défi des NPU réside dans leur fragmentation. Chaque fabricant de puces possède sa propre architecture NPU et son propre SDK :
- Qualcomm : Hexagon DSP + AI Engine
- MediaTek : APU
- Samsung : Exynos NPU
- Google : Tensor TPU
L’API NNAPI (Neural Networks API) d’Android tente de fournir une couche d’abstraction unifiée, mais les résultats réels sont inégaux. De nombreuses fonctionnalités NPU ne sont pas exposées via NNAPI, obligeant les développeurs à utiliser des SDK spécifiques aux constructeurs.
Quantification INT8 : Une bataille inachevée
La quantification FP16 est un choix conservateur qui ne perd presque aucune précision. Mais pour une performance extrême, la quantification INT8 est l’étape suivante.
La quantification INT8 compresse les poids et les activations de 32 bits flottants vers des entiers de 8 bits, ce qui peut théoriquement apporter :
- Une compression du modèle par 4.
- Une accélération de l’inférence par 2 à 4 (selon le matériel).
- Une accélération de plus de 10 fois sur le DSP Qualcomm Hexagon.
La tentation était trop forte. J’ai donc entamé un long voyage vers la quantification INT8.
Première tentative : Calibration avec données synthétiques
La quantification INT8 nécessite un ensemble de données de calibration pour déterminer les paramètres de quantification (Scale et Zero Point). Au début, par paresse, j’ai utilisé des images « pseudo-texte » générées aléatoirement :
# Mauvaise pratique : utiliser du bruit aléatoire pour la calibrationimg = np.ones((h, w, 3), dtype=np.float32) * 0.9for _ in range(num_lines): gray_val = np.random.uniform(0.05, 0.3) img[y:y+line_h, x:x+line_w] = gray_valLe résultat fut désastreux. Le modèle ne sortait que des zéros :
Raw FLOAT32 output range: min=0.0000, max=0.0000Prob map stats: min=0.0000, max=0.0000, mean=0.000000L’outil de quantification a calculé des paramètres erronés basés sur le bruit aléatoire, provoquant la troncature des valeurs d’activation des images réelles.
Deuxième tentative : Calibration avec images réelles
Je suis passé à de vraies images de jeux de données OCR : ICDAR2015, TextOCR, et des exemples officiels de PaddleOCR. J’ai également implémenté un pré-traitement Letterbox pour m’assurer que la distribution des images lors de la calibration soit identique à celle de l’inférence :
def letterbox_image(image, target_size): """Redimensionner en gardant le ratio, remplir le reste en gris""" ih, iw = image.shape[:2] h, w = target_size scale = min(w / iw, h / ih) # ... coller au centreLe modèle ne sortait plus de zéros, mais les résultats de reconnaissance restaient illisibles.
Troisième tentative : Correction de la gestion des types côté C++
J’ai découvert que le code C++ gérait mal les entrées INT8. Les modèles INT8 attendent des valeurs de pixels brutes (0-255), alors que je faisais encore la normalisation ImageNet (soustraction de la moyenne et division par l’écart-type).
if (input_is_int8_) { // Modèle INT8 : entrée directe des pixels bruts, normalisation fusionnée dans la 1ère couche dst[i * 3 + 0] = static_cast<int8_t>(src[i * 4 + 0] ^ 0x80);} else { // Modèle FP32 : normalisation manuelle requise // (pixel - moyenne) / std}Parallèlement, j’ai implémenté une logique de lecture dynamique des paramètres de quantification au lieu de les coder en dur :
bool GetQuantizationParams(LiteRtTensor tensor, float* scale, int32_t* zero_point) { LiteRtQuantization quant; LiteRtGetTensorQuantization(tensor, &quant); // ...}Résultat final : Le compromis
Après plusieurs jours de débogage, le modèle INT8 ne fonctionnait toujours pas correctement. Les causes possibles sont :
- L’implémentation de la quantification d’onnx2tf : PP-OCRv5 utilise des combinaisons d’opérateurs spécifiques qu’onnx2tf n’a peut-être pas traitées correctement lors de la quantification.
- Les caractéristiques de sortie de DBNet : DBNet produit des cartes de probabilité avec des valeurs entre 0 et 1. La quantification INT8 est particulièrement sensible à ces petites plages de valeurs.
- Accumulation d’erreurs dans un modèle multi-étapes : La détection et la reconnaissance sont deux modèles en série ; les erreurs de quantification s’accumulent et s’amplifient.
Analysons plus en détail le deuxième point. La sortie de DBNet passe par une activation Sigmoid, compressant les valeurs dans [0, 1]. La formule de quantification INT8 est la suivante :
Pour des valeurs dans [0, 1], si le scale est mal défini, les valeurs quantifiées n’occuperont qu’une infime partie de la plage INT8 [-128, 127], entraînant une grave perte de précision.
# Supposons scale = 0.00784 (1/127), zero_point = 0# Entrée 0.5 -> round(0.5 / 0.00784) + 0 = 64# Entrée 0.1 -> round(0.1 / 0.00784) + 0 = 13# Entrée 0.01 -> round(0.01 / 0.00784) + 0 = 1# Entrée 0.001 -> round(0.001 / 0.00784) + 0 = 0 # Perte de précision !Le seuil de DBNet est généralement fixé entre 0,1 et 0,3, ce qui signifie qu’une grande quantité de valeurs de probabilité significatives (0,1-0,3) ne sont représentées que par 25 entiers (de 13 à 38) après quantification, une résolution largement insuffisante.
WARNING
La quantification INT8 de PP-OCRv5 est un défi connu. Si vous essayez également, je vous suggère de vérifier d’abord que le modèle FP32 fonctionne normalement avant de traquer les problèmes de quantification. Sinon, envisagez d’utiliser le framework officiel Paddle Lite de PaddlePaddle, qui offre un meilleur support pour PaddleOCR.
Quantization-Aware Training : La solution correcte
S’il est impératif d’utiliser la quantification INT8, la méthode correcte est le Quantization-Aware Training (QAT), plutôt que la Post-Training Quantization (PTQ).
Le QAT simule les erreurs de quantification pendant l’entraînement, permettant au modèle d’apprendre à s’adapter aux représentations de basse précision :
# Exemple QAT PyTorchimport torch.quantization as quant
model = DBNet()model.qconfig = quant.get_default_qat_qconfig('fbgemm')model_prepared = quant.prepare_qat(model)
# Entraînement normal, mais avec des nœuds de pseudo-quantification insérésfor epoch in range(num_epochs): for images, labels in dataloader: outputs = model_prepared(images) # Contient la simulation de quantification loss = criterion(outputs, labels) loss.backward() optimizer.step()
# Convertir en véritable modèle quantifiémodel_quantized = quant.convert(model_prepared)Malheureusement, l’équipe officielle de PP-OCRv5 n’a pas fourni de modèle entraîné avec QAT. Cela signifie que pour obtenir un modèle INT8 de haute qualité, il faudrait effectuer un entraînement QAT à partir de zéro, ce qui dépasse le cadre de ce projet.
Finalement, j’ai choisi un compromis : utiliser la quantification FP16 + accélération GPU, plutôt que l’INT8 + DSP.
Le prix de cette décision est :
- Une taille de modèle 2 fois supérieure à l’INT8.
- L’impossibilité d’exploiter la très basse consommation du DSP Hexagon.
- Une vitesse d’inférence 2 à 3 fois plus lente que l’optimum théorique.
Mais les bénéfices sont :
- Une précision du modèle presque identique au FP32.
- Un cycle de développement considérablement raccourci.
- Une complexité du code réduite.
L’essence de l’ingénierie est le compromis. Parfois, le « assez bien » est plus important que l’« optimum théorique ».
Conclusion
De PaddlePaddle à LiteRT, de DBNet à SVTRv2, d’OpenCL à NEON, la pratique de l’ingénierie OCR embarquée implique des connaissances dans de nombreux domaines : deep learning, compilateurs, programmation GPU, développement mobile, etc.
La leçon principale de ce projet est que l’IA embarquée ne consiste pas seulement à « mettre un modèle sur un téléphone ». Elle nécessite :
- Une compréhension approfondie de l’architecture du modèle pour une conversion correcte.
- Une connaissance des caractéristiques matérielles pour exploiter pleinement les accélérateurs.
- Une maîtrise de la programmation système pour implémenter du code natif haute performance.
- Une attention portée à l’expérience utilisateur pour trouver l’équilibre entre performance et consommation d’énergie.
PPOCRv5-Android est un projet open source qui montre comment déployer des modèles OCR modernes dans des applications mobiles réelles. J’espère que cet article servira de référence aux développeurs ayant des besoins similaires.
Comme l’a dit Google lors du lancement de LiteRT : « Maximum performance, simplified. » 9 L’objectif de l’IA embarquée n’est pas la complexité, mais de rendre le complexe simple.
Post-scriptum
Pour être honnête, je me suis éloigné d’Android (tant dans mon travail que dans mes loisirs) depuis au moins deux ans. C’est la première fois que je publie une bibliothèque relativement mature sur mon compte GitHub secondaire (j’ai déjà confié mon compte principal à des collègues pour marquer ma détermination à partir).
Ces dernières années, mon travail ne s’est pas concentré sur le domaine Android. Je ne peux pas en dire plus pour le moment, mais j’en parlerai peut-être plus tard si l’occasion se présente. Quoi qu’il en soit, il me sera peut-être difficile de contribuer davantage à l’écosystème Android à l’avenir.
La publication de ce projet est née de ma passion personnelle, alors que je construisais un outil précoce basé sur Android — dont l’OCR n’est qu’une petite partie de la couche inférieure. Le code complet sera ouvert prochainement (très bientôt, normalement), mais je ne peux pas en révéler plus pour l’instant.
Bref, merci d’avoir lu jusqu’ici, et j’espère que vous donnerez une étoile (Star) à mon dépôt. Merci !
Références
Footnotes
-
Google AI Edge. “LiteRT: Maximum performance, simplified.” 2024. https://developers.googleblog.com/litert-maximum-performance-simplified/ ↩
-
Équipe PaddleOCR. “PaddleOCR 3.0 Technical Report.” arXiv:2507.05595, 2025. https://arxiv.org/abs/2507.05595 ↩
-
Discussion GitHub. “Problem while deploying the newest official PP-OCRv5.” PaddleOCR #16100, 2025. https://github.com/PaddlePaddle/PaddleOCR/discussions/16100 ↩
-
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 ↩
-
Du, Y., et al. “SVTR: Scene Text Recognition with a Single Visual Model.” IJCAI, 2022. https://arxiv.org/abs/2205.00159 ↩
-
Du, Y., et al. “SVTRv2: CTC Beats Encoder-Decoder Models in Scene Text Recognition.” ICCV, 2025. https://arxiv.org/abs/2411.15858 ↩ ↩2
-
Blog TensorFlow. “Even Faster Mobile GPU Inference with OpenCL.” 2020. https://blog.tensorflow.org/2020/08/faster-mobile-gpu-inference-with-opencl.html ↩
-
ARM Developer. “Neon Intrinsics on Android.” Documentation ARM, 2024. https://developer.arm.com/documentation/101964/latest/ ↩
-
Google AI Edge. “Documentation LiteRT.” 2024. https://ai.google.dev/edge/litert ↩