Module 10 — Project: on-device image classification
The nine previous modules built pieces. This module puts them together into a shippable app: choose a variant from the trade-off matrix, justify it against the constraints from module 1, wire it into an Android build, ship it, and honestly list what had to be sacrificed. This is the module that separates a working prototype from a product farmers can actually rely on.
The brief, one more time
A leaf-disease classifier for smallholder farmers in areas with unreliable connectivity. Thirty-eight classes (healthy plus a handful of common diseases per crop). Input: a phone photo of a single leaf. Output: a suggested diagnosis, with a confidence level and a link to a treatment page cached in the app.
Constraints from module 1:
- Under 8 MB on disk, ideally under 2 MB after gzip so cellular downloads are painless.
- Under 60 ms per image on a Pixel 4a-class device, so the result appears "instantly".
- Under 40 mJ per inference so a hundred inferences a day is invisible on the battery.
- Works offline: model, labels, treatment guide all bundled or downloadable.
Reading the trade-off matrix
Here it is again, from module 9:
| Variant | Raw | Gzip | P50 CPU | Acc. |
|---|---|---|---|---|
| Float32 baseline | 9.20 MB | 8.10 MB | 88 ms | 0.941 |
| Dynamic range | 2.40 MB | 2.25 MB | 65 ms | 0.940 |
| Full-int PTQ | 2.35 MB | 2.20 MB | 22 ms | 0.928 |
| Full-int QAT | 2.35 MB | 2.20 MB | 22 ms | 0.939 |
| QAT + prune 60% | 2.35 MB | 1.30 MB | 22 ms | 0.933 |
| QAT + prune + cluster | 2.35 MB | 0.95 MB | 22 ms | 0.930 |
Four variants pass all four constraints; two fail on latency or size. The choice is between:
- QAT full-int: highest accuracy (0.939) that fits every constraint, 2.20 MB after gzip.
- QAT + prune 60%: 0.006 accuracy drop for a 40 percent reduction in download size (1.30 MB after gzip).
- QAT + prune + cluster: 0.009 accuracy drop for a 57 percent reduction in download size (0.95 MB after gzip).
For our audience — farmers on 3G, often on prepaid data — the download size matters more than an accuracy point that will not be measurable in the field. We ship QAT + prune 60%: 0.933 accuracy is above our 0.90 floor, 1.30 MB downloads over 3G in under three seconds, and 22 ms latency is well below the 60 ms budget. Weight clustering would give another 0.35 MB back for 0.003 accuracy, but complicates the training pipeline and yields a diminishing return — a decision we might revisit at v3.
The full app skeleton
The Android app has four moving parts, and they map cleanly to the previous modules.
Model asset (app/src/main/assets/leaf_classifier.tflite, plus a labels.txt embedded in the metadata block). Bundled at 1.30 MB gzipped inside the APK.
Classifier class (Task Library, module 7): loads the model, sets up the CPU + XNNPACK delegate (which is default), performs inference on a background executor.
class LeafClassifier(context: Context) {
private val classifier: ImageClassifier
init {
val options = ImageClassifierOptions.builder()
.setBaseOptions(
BaseOptions.builder()
.setNumThreads(4)
.build()
)
.setMaxResults(3)
.setScoreThreshold(0.30f)
.build()
classifier = ImageClassifier.createFromFileAndOptions(
context, "leaf_classifier.tflite", options
)
}
fun classify(bitmap: Bitmap): List<Category> =
classifier.classify(TensorImage.fromBitmap(bitmap))
.firstOrNull()?.categories.orEmpty()
}
Camera + gallery pipeline (module 7): a CameraX preview and an "import from gallery" fallback for when the camera permission is denied.
Treatment page: a local static site cached in the app, keyed by class name. When the classifier returns "Cassava — Bacterial Blight", the app opens the corresponding page with symptoms, treatment steps, and a phone number for local agronomy support.
The whole thing is roughly 800 lines of Kotlin plus the model, and it fits in a 12 MB APK on Play Store when everything is compressed.
The update mechanism
Module 7 sketched it; here is the concrete plan.
- Ship v1 with the model in
assets/— no download at first launch. - Every 30 days, on Wi-Fi, poll a manifest at
https://static.leafapp.example/manifest.json:
{
"model_version": 3,
"model_url": "https://static.leafapp.example/leaf_classifier_v3.tflite",
"model_sha256": "6f5a...c1",
"min_app_version": 12
}
- If
model_version > current, download to a temp file, verify SHA-256, atomically move tocontext.filesDir/model_v3.tflite. - The classifier reads from
filesDirif a file is present, otherwise fromassets/. - Keep the previous version until the new one has served 20 inferences without exception.
The min_app_version field is the escape hatch: if v3 needs a preprocessing change (say, different input size), the manifest hides it from v1 apps that would misuse it.
What we sacrificed, honestly
A production report only earns trust when it is candid about what was given up. Four sacrifices went into shipping this app.
Accuracy on rare classes. Global accuracy is 0.933, but per-class accuracy on the three rarest diseases (each under one percent of the training set) is closer to 0.85. A farmer confronting one of those diseases sees a lower confidence and, one time in seven, a wrong suggestion. The mitigation is showing the top-3 with confidence scores, not just the top-1 — a suggestion the user can double-check is more useful than a false certainty.
Live camera classification. The app runs one inference per shutter tap, not on the video stream. Live classification is technically feasible (22 ms per inference, we could do 30 FPS) but the energy cost — several joules per minute — is unshippable for a battery that has to last a day in the field. This is the trade thermal throttling from module 9 warned about.
Cross-crop generalisation. The classifier was trained on cassava, tomato, potato and maize; a farmer with a coffee plant gets nothing. Adding a new crop means retraining, revalidating on three phones, updating metadata and shipping a new model. It is doable, and the update mechanism is what makes it feasible without a store review.
Server-side monitoring. A cloud-served model would let us catch drift by watching the confidence distribution over time. On-device, we do not have that signal. Instead, the app sends anonymous, opt-in aggregate telemetry: number of inferences per class per day. If a class stops appearing entirely — no more "healthy potato" predictions from Kenya — that is a signal that something changed. It is a much weaker signal than a live confusion matrix, and we accept that.
The launch checklist
The last thing before shipping is a small, non-negotiable checklist:
- Benchmark on three reference devices (module 9) and confirm P95 latency is under 90 ms on the entry-level.
- Confirm APK install size stays under 25 MB.
- Verify the permission dialog string is written in the target languages, not just English.
- Verify offline behaviour: airplane mode, first launch — the classifier works, the treatment pages load.
- Verify the manifest polling handles a bad response gracefully — 404, malformed JSON, wrong checksum — without ever crashing the app.
- One end-to-end field test with three real users on real crops before the store submission.
The last point is the one you cannot automate. A benchmark tells you the model runs; a field test tells you it helps.
Key takeaways
- The trade-off matrix is a decision document, not an information dump; the chosen variant is defended against every constraint from module 1.
- On our brief the winner is QAT + 60% pruning: 1.30 MB gzipped, 22 ms P50, 0.933 accuracy — download size wins over the last accuracy point for a cellular-only audience.
- The update mechanism is small and non-negotiable: manifest polling, SHA-256 verification, atomic replace, keep the previous version until the new one has proven itself.
- Every ship has sacrifices; write them down alongside the successes — rare-class accuracy, no live camera, single-crop coverage, weak drift signal — because that is the version future contributors will need to understand what to fix next.
Next module: the course recap, the decision tree that summarises the whole optimisation flow, and the 40-question final exam.