Module 10 — Projet : classification de maladies foliaires sur téléphone
Neuf modules ont préparé chaque brique. Ce dernier module d'ingénierie assemble le fil rouge en une application concrète : un agriculteur photo- graphie une feuille, l'application diagnostique la maladie en moins d'une seconde, sans réseau, et propose une action. Le tableau des variantes se ferme, le choix est justifié, et les compromis sont assumés.
Rappel du problème et des contraintes
- Utilisateurs : agriculteurs de zones rurales avec accès réseau intermittent (Kenya, Inde du Sud, Nord-Est brésilien).
- Terrain : trois téléphones cibles (Nokia G21, Galaxy A15, iPhone 12), photo prise en lumière naturelle, souvent sur fond de terre.
- Modèle : 38 classes de maladies foliaires du jeu PlantVillage,
MobileNetV2 affiné à 96,3 % d'exactitude en
float32. - Budgets (du module 1) : APK < 5 Mo, p95 latence < 150 ms sur milieu de gamme, aucun appel réseau à l'inférence, aucune donnée sortante.
Le tableau des variantes, ligne complète
| Variante | Taille APK | p95 Nokia G21 | p95 Galaxy A15 | p95 iPhone 12 | Exactitude |
|---|---|---|---|---|---|
float32 (module 2) | 12 Mo | 285 ms | 195 ms | 60 ms | 96,3 % |
| Plage dynamique (module 3) | 3,3 Mo | 210 ms | 145 ms | 55 ms | 96,1 % |
int8 post-training (module 3) | 3,3 Mo | 175 ms | 108 ms | 42 ms | 95,7 % |
int8 QAT (module 4) | 3,3 Mo | 175 ms | 108 ms | 42 ms | 96,2 % |
int8 QAT + élagage 60 % (module 5) | 2,1 Mo | 175 ms | 108 ms | 42 ms | 95,7 % |
| Même + NNAPI/Core ML (module 6) | 2,1 Mo | 82 ms | 41 ms | 18 ms | 95,7 % |
La variante retenue apparaît en gras : 2,1 Mo, int8 QAT + élagage 60 %,
avec délégué NNAPI (Android) ou Core ML (iOS). Elle réduit le fichier
d'origine par six, passe le p95 très largement sous le budget sur les trois
téléphones, et ne perd que 0,6 point d'exactitude par rapport au modèle
d'origine.
Ce qui a été sacrifié, et pourquoi c'était le bon choix
Le processus a laissé de côté trois options qui semblaient tentantes :
float16pour préserver 96,3 % : la variantefloat16conserve l'exactitude parfaitement mais pèse 7,1 Mo, deux fois plus lourd que l'int8, et ne débloque pas l'accélération NNAPI. Sur un utilisateur qui télécharge son APK par 3G intermittente, ces 5 Mo supplémentaires font la différence entre une installation qui aboutit et une qui échoue.- Élagage à 80 % pour descendre à 1,4 Mo : le gain de 700 Ko ne justifie pas la perte de 1,3 point d'exactitude, qui commence à dégrader visiblement les diagnostics de classes rares (mosaïque du manioc, virus du haricot).
- Modèle Core ML natif sur iOS pour gagner 5 ms : la maintenance d'un second pipeline d'entraînement pour économiser 5 ms sur un usage ponctuel (pas de traitement continu) est un mauvais compromis. L'harmonisation multi-plateforme prime.
Chacun de ces trois choix est traçable dans le tableau : c'est lui qui transforme des préférences en décisions défendables.
L'architecture de l'application
L'application s'organise en quatre écrans :
- Accueil avec un gros bouton « Photographier une feuille » et l'historique des diagnostics récents (stocké dans SQLite local).
- Prise de vue utilisant CameraX (Android) ou AVFoundation (iOS), avec un cadre visuel pour aider à cadrer la feuille et un rappel textuel « bonne lumière, feuille au centre ».
- Résultat affichant la classe prédite, la probabilité, et les deux classes suivantes ; un bouton « Voir les traitements » et un bouton « Prise de vue à nouveau ».
- Paramètres proposant la mise à jour du modèle (voir plus bas) et quelques réglages d'affichage.
L'inférence est déclenchée à l'écran 2 dès la validation de la photo, sur un fil de fond ; le passage à l'écran 3 attend le résultat, avec un indicateur de progression si l'inférence dépasse 200 ms. Sur les mesures du module 9, cet indicateur ne s'affiche qu'exceptionnellement sur Nokia G21.
Le composant central en Kotlin
Le cœur du fil Android tient dans un ViewModel qui expose un LiveData de
résultats, alimenté par la Task Library de TFLite :
class DiagnosticViewModel(context: Context) : ViewModel() {
private val classifieur = ImageClassifier.createFromFileAndOptions(
context,
"plantvillage_qat_elague_v3.tflite",
ImageClassifier.ImageClassifierOptions.builder()
.setBaseOptions(
BaseOptions.builder().useNnapi().setNumThreads(4).build()
)
.setMaxResults(3)
.setScoreThreshold(0.15f)
.build(),
)
private val _resultat = MutableLiveData<Diagnostic>()
val resultat: LiveData<Diagnostic> = _resultat
fun diagnostiquer(bitmap: Bitmap) = viewModelScope.launch(Dispatchers.Default) {
val image = TensorImage.fromBitmap(bitmap)
val debut = SystemClock.elapsedRealtime()
val bruts = classifieur.classify(image)
val duree = SystemClock.elapsedRealtime() - debut
val classes = bruts[0].categories.map {
Prediction(it.label, it.score)
}
_resultat.postValue(Diagnostic(classes, duree))
}
override fun onCleared() {
classifieur.close()
}
}
Cent lignes suffisent à couvrir l'essentiel : le chargement une fois pour toutes, l'inférence en arrière-plan, le retour marshalé, la fermeture propre.
La mise à jour du modèle après publication
L'agronomie évolue : de nouvelles maladies apparaissent, des variétés résistantes changent l'apparence des symptômes. Un modèle publié en janvier peut être obsolète en juin. La mise à jour par nouvelle version de l'application coûte trop cher pour un modèle mis à jour plusieurs fois par an.
La stratégie retenue : le modèle vit dans un cache local, mis à jour par Firebase Custom Model Deployment. Au lancement, l'application interroge Firebase, télécharge la version courante si elle est plus récente, la stocke, puis charge l'interprète depuis ce fichier plutôt que depuis les assets. Un modèle par défaut reste embarqué pour couvrir le cas du premier lancement hors ligne.
private suspend fun modeleCourant(context: Context): File {
val conditions = CustomModelDownloadConditions.Builder()
.requireWifi() // ne pas consommer la data mobile pour cela
.build()
return suspendCoroutine { continuation ->
FirebaseModelDownloader.getInstance()
.getModel("plantvillage-v3", DownloadType.LOCAL_MODEL, conditions)
.addOnSuccessListener { modele ->
continuation.resume(modele.file ?: fichierParDefaut(context))
}
.addOnFailureListener { continuation.resume(fichierParDefaut(context)) }
}
}
Deux garde-fous : requireWifi() évite de consommer les données mobiles
sans consentement, et le repli sur le modèle par défaut assure une
expérience fonctionnelle même si Firebase est injoignable.
Les tests avant publication
L'application n'est pas publiée avant d'avoir passé quatre séries de tests, qui se combinent :
- Correction de bout en bout : sur 500 images tenues à l'écart de l'entraînement, l'application produit-elle les mêmes diagnostics que le modèle en Python ? Un écart de plus de 1 point signale une divergence de prétraitement (module 2).
- Latence sur le parc réel : mesure p95 sur au moins trois appareils par gamme cible ; règle du module 9.
- Consommation mémoire :
dumpsys meminfosur Android, Instruments sur iOS ; l'application entière doit rester sous 100 Mo de pic. - Test à froid, sans réseau : une installation neuve sur un téléphone en mode avion doit permettre au moins un diagnostic complet, avec le modèle par défaut.
Ce dernier test est celui qu'on oublie ; il valide que la promesse « ça marche sans connexion » est réellement tenue à froid, pas seulement après un premier lancement en Wi-Fi.
Un modèle embarqué ne produit aucun journal centralisé. Les 50 premiers utilisateurs pilotes reçoivent une version instrumentée qui envoie anonymement, avec leur accord, la latence et la classe prédite (jamais l'image). Le retour de terrain remonte les cas d'usage réels, souvent très différents de ce que la validation en laboratoire prédisait.
En résumé
- La variante finale du fil rouge —
int8QAT + élagage 60 %, 2,1 Mo, 95,7 %, p95 ≤ 82 ms — respecte tous les budgets du module 1 avec une marge confortable. - Chaque choix (garder
int8plutôt quefloat16, s'arrêter à 60 % d'élagage, garder TFLite plutôt que Core ML natif) est traçable dans le tableau et défendable en revue. - La mise à jour du modèle passe par Firebase Custom Model Deployment en Wi-Fi, avec repli sur un modèle embarqué par défaut pour le premier lancement hors ligne.
- La publication se fait après quatre tests : correction de bout en bout, latence sur parc, mémoire au pic, fonctionnement à froid sans réseau ; c'est ce dernier qui garantit la promesse.
Module suivant : la récapitulation complète et l'examen de 40 questions.