15. Android Deployment
The Android pipeline targets two runtimes — TFLite (what the Flutter app uses today via tflite_flutter) and ONNX Runtime Mobile. Mobile INT8 comes from converter-side PTQ, not PyTorch QAT.
Pipeline Overview
data/raw → src.train (2 phases, EMA, optional --teacher distillation)
→ outputs/checkpoints/<backbone>_phase2_best_<runid>.pt (EMA weights)
→ src.export --mode tflite | onnx-int8 (INT8 PTQ)
→ flutter_app/assets/models/{model.tflite, labels_*.txt}
→ src.parity_check (accuracy gate)
Step 1 — Train
Default training is 2 phases (head warmup → multi-task fine-tune with EMA). For maximum accuracy, distill from a lite4 teacher:
# Teacher (once): lite4 + CBAM
python -m src.train --backbone lite4
# Student: same lite2 size/latency, teacher-quality logits
python -m src.train --backbone lite2 \
--teacher outputs/checkpoints/lite4_phase2_best_<runid>.pt
Step 2 — Export
# TFLite INT8 — full-integer PTQ, calibrated on ~500 real train images.
# Copies model.tflite + labels into flutter_app/assets/models/ automatically.
python -m src.export --mode tflite --backbone lite2
# ONNX Runtime Mobile INT8 — QDQ static quantization, per-channel weights.
python -m src.export --mode onnx-int8 --backbone lite2
TFLite toolchain (only needed for --mode tflite):
Step 3 — Parity Gate
python -m src.parity_check --backbone lite2 \
--tflite outputs/export/lite2_int8.tflite \
--onnx-int8 outputs/export/lite2_mobile_int8.onnx \
--split val
Acceptance: INT8 within 1 pt combined_top1 of fp32. If it fails, ship the FP32 TFLite fallback (<backbone>_fp32.tflite, emitted alongside) or revisit calibration.
On-device Input Convention
Mobile artifacts have ImageNet normalization baked into the graph:
| Value | |
|---|---|
| Input | RGB float32 in [0, 1], shape [1, 3, 260, 260] (static batch 1) |
| Preprocessing on device | pixel / 255.0 — exactly what the Flutter engine already does |
| Outputs | binary [1,2], cattle [1,57], buffalo [1,18] logits, read by index 0/1/2 |
| Post-processing | softmax + argmax, species-gated breed head (as in the app) |
Artifacts in the App
flutter_app/assets/models/
├── model.tflite # INT8 compute, float32 [0,1] I/O
├── labels_binary.txt # "cattle" (index 0) / "buffalo" (index 1)
├── labels_cattle.txt # 57 lines, line i = class i
└── labels_buffalo.txt # 18 lines, line i = class i
Model Sizes (measured, lite2)
| Artifact | Size |
|---|---|
| ONNX FP32 (reference) | ~25.7 MB |
| ONNX INT8 (QDQ, mobile) | ~7.1 MB |
| TFLite INT8 | ~6–7 MB (expected) |
QAT — optional recovery path only
python -m src.train --include-qat (OFF by default) fine-tunes with per-tensor fake-quantization observers (the x86 per-channel default broke conversion), starting from the best phase-2 EMA checkpoint, and saves a timestamped <backbone>_quantized_<runid>.pt. Use it only if converter PTQ drops more than ~2 pts — the artifact is not a TFLite/ORT model. (src.export._sanitize_state_dict can strip the QAT/fused keys and re-load it as a float model, but exporting the phase-2 EMA checkpoint is still preferred.)