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 .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.openFdavecchannel.mapcharge 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
analyzeou 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.