Aller au contenu principal

Module 7 — Intégration dans une application Android

Les six premiers modules ont produit un .tflite optimisé et un plan d'exécution. Il reste à l'intégrer dans une vraie application. Ce module couvre la chaîne complète du côté Android : dépendances, chargement du modèle, capture caméra, prétraitement, appel en arrière-plan, retour vers l'interface. Le langage retenu est Kotlin, qui est aujourd'hui le standard Android.

Les dépendances : deux options selon le contrôle voulu

Deux paquets couvrent la quasi-totalité des besoins :

// build.gradle (module app)
dependencies {
// API bas niveau : interprete direct, controle total
implementation "org.tensorflow:tensorflow-lite:2.16.1"
implementation "org.tensorflow:tensorflow-lite-gpu:2.16.1"
implementation "org.tensorflow:tensorflow-lite-support:0.4.4"

// API haut niveau : Task Library, prete pour classification, detection, NLP
implementation "org.tensorflow:tensorflow-lite-task-vision:0.4.4"
}

android {
aaptOptions {
noCompress "tflite" // le fichier ne doit pas etre compresse a l'apk
}
}

La ligne noCompress "tflite" est fondamentale : sans elle, Android compresse le fichier dans l'APK, et la décompression au démarrage rajoute plusieurs dizaines de millisecondes ainsi qu'un pic mémoire évitable. C'est un piège discret qui n'a aucun symptôme visible tant qu'on ne mesure pas.

Le placement du modèle et son chargement

Le fichier .tflite se place dans src/main/assets/. Le chargement se fait en mémoire tampon mappée, sans copie :

private fun chargerModele(context: Context): MappedByteBuffer {
val afd = context.assets.openFd("plantvillage_qat_elague.tflite")
val stream = FileInputStream(afd.fileDescriptor)
return stream.channel.map(
FileChannel.MapMode.READ_ONLY,
afd.startOffset,
afd.declaredLength,
)
}

Ce chargement mappé partage la mémoire du fichier avec l'application, sans la charger intégralement en RAM. Sur un modèle de 3 Mo, la différence est faible ; sur un modèle plus gros ou sur un téléphone à mémoire tendue, elle évite le pic mémoire au démarrage.

Construction de l'interprète, une fois pour toutes

L'interprète est coûteux à créer (plusieurs dizaines de millisecondes) et léger à réutiliser. Il doit vivre le temps de l'écran ou de l'application, pas être recréé à chaque photo.

class Classifieur(context: Context) {

private val interprete: Interpreter

init {
val options = Interpreter.Options().apply {
setNumThreads(4)
try {
addDelegate(NnApiDelegate())
} catch (e: Throwable) {
Log.w(TAG, "NNAPI indisponible, repli sur XNNPACK", e)
}
}
interprete = Interpreter(chargerModele(context), options)
}

fun classifier(imageUint8: ByteBuffer): FloatArray {
val sortie = Array(1) { FloatArray(NB_CLASSES) }
interprete.run(imageUint8, sortie)
return sortie[0]
}

fun fermer() = interprete.close()
}

Trois pratiques importantes s'y lisent. NnApiDelegate est tenté en premier ; s'il échoue (téléphone trop ancien, opérateur non supporté), le code repart avec XNNPACK, activé par défaut, sans faire planter l'application. La méthode fermer libère explicitement les ressources natives — un interprète non fermé fuit, et le fuit reste invisible tant qu'on ne mesure pas la mémoire allouée. Enfin, la variable sortie est réutilisable d'un appel à l'autre pour éviter les allocations à chaque inférence.

Le prétraitement de l'image caméra

Sur Android, CameraX est aujourd'hui l'API recommandée pour la capture. Une image de la caméra arrive dans un ImageProxy au format YUV, qu'il faut convertir en RGB, redimensionner à 224x224, puis emballer dans un ByteBuffer d'octets non signés dans l'ordre exact attendu par le modèle.

La bibliothèque tensorflow-lite-support fait tout ce travail :

private val processeur = ImageProcessor.Builder()
.add(ResizeOp(224, 224, ResizeOp.ResizeMethod.BILINEAR))
.add(CastOp(DataType.UINT8))
.build()

fun preparer(imageProxy: ImageProxy): ByteBuffer {
val bitmap = imageProxy.toBitmap() // conversion YUV -> RGB
var image = TensorImage(DataType.UINT8)
image.load(bitmap)
image = processeur.process(image)
return image.buffer // pret pour interprete.run
}

Le prétraitement de normalisation (division par 127,5 et soustraction de 1) n'apparaît pas ici : il vit dans le modèle, comme imposé au module 2. Le code Android se limite à redimensionner et à convertir en octets, ce qui laisse peu de place à un décalage silencieux.

Ne jamais bloquer le fil d'interface

L'inférence dure au moins 30 ms sur du bon matériel, jusqu'à 250 ms sur entrée de gamme. Lancée sur le fil principal, elle fait « sauter » l'interface d'autant. La règle : toute inférence s'exécute sur un fil de fond, avec un retour marshalé sur le fil principal pour la mise à jour de l'écran.

class ImageAnalyseur(
private val classifieur: Classifieur,
private val callback: (FloatArray) -> Unit,
) : ImageAnalysis.Analyzer {

override fun analyze(imageProxy: ImageProxy) {
// analyze() est deja appele hors du fil principal par CameraX
try {
val entree = preparer(imageProxy)
val probabilites = classifieur.classifier(entree)
callback(probabilites) // le composant qui recoit renvoie vers le fil UI
} finally {
imageProxy.close() // liberer la place pour la prochaine image
}
}
}

CameraX appelle analyze sur son propre fil, ce qui règle la moitié du problème. Reste à s'assurer que callback renvoie l'affichage vers le fil principal (Handler(Looper.getMainLooper()) ou coroutine Main).

La bibliothèque de tâches pour aller plus vite

Le paquet tensorflow-lite-task-vision offre une API haut niveau pour les cas les plus courants : classification d'image, détection d'objet, segmentation. Elle lit les métadonnées du .tflite (module 2, étiquettes incluses), gère le prétraitement et retourne des résultats structurés.

val classifieur = ImageClassifier.createFromFileAndOptions(
context,
"plantvillage_qat_elague.tflite",
ImageClassifier.ImageClassifierOptions.builder()
.setBaseOptions(BaseOptions.builder().useNnapi().setNumThreads(4).build())
.setMaxResults(5)
.setScoreThreshold(0.1f)
.build(),
)

val image = TensorImage.fromBitmap(bitmap)
val resultats = classifieur.classify(image)
// resultats[0].categories[0].label -> "Cassava Mosaic Disease"
// resultats[0].categories[0].score -> 0.87

Deux appels remplacent une classe entière. La Task Library est aujourd'hui recommandée pour tout modèle standard ; l'API Interpreter directe reste utile pour les cas non couverts (modèles à sorties multiples, prétraitement inhabituel, contrôle fin des délégués).

La mise à jour du modèle après le premier déploiement

Un .tflite embarqué dans l'APK ne se met à jour qu'avec une nouvelle version de l'application. Ce délai — plusieurs jours pour un déploiement progressif — peut coûter cher si un défaut du modèle est découvert après publication.

Deux stratégies existent. La première consiste à télécharger le modèle au premier lancement dans le cache local, à partir d'une URL fournie par l'application. La seconde utilise Firebase Custom Model Deployment, qui gère les versions, la mise en cache et les A/B tests. Dans les deux cas, le code de chargement change à peine — le fichier est simplement lu depuis un File plutôt que depuis les assets — et le déploiement d'une nouvelle version se fait sans passer par le magasin d'applications.

Un modèle téléchargé doit être signé ou vérifié

Un .tflite téléchargé depuis un serveur non maîtrisé est un vecteur d'attaque : un modèle malveillant peut consommer massivement CPU et batterie, ou renvoyer des résultats contrôlés par un tiers. Signer le fichier ou vérifier son empreinte SHA-256 avant chargement est le minimum pour toute distribution à froid.

En résumé

  • noCompress "tflite" dans Gradle évite la décompression au démarrage ; assets.openFd avec channel.map charge le modèle sans copie.
  • L'interprète est coûteux à créer et doit vivre le temps de l'écran, jamais être recréé à chaque image caméra.
  • Le prétraitement Android se limite à redimensionner + cast UINT8 ; la normalisation reste dans le modèle depuis le module 2.
  • L'inférence tourne hors du fil principal (CameraX analyze ou coroutine dédiée) et renvoie vers l'UI par callback.
  • La Task Library remplace des dizaines de lignes de code pour les cas standard et lit automatiquement les étiquettes des métadonnées.

Module suivant : la même intégration côté iOS, avec le délégué Core ML et les permissions caméra.