Keras 轉 TFLite:Android 應用的正規化錯誤與反向 top-5
Keras to TFLite on Android: wrong normalization and a backwards top-5
作者檢查其 Android 圖像分類應用的 Keras/TFLite 流程,發現像素正規化數值用錯,而 top-5 優先佇列會移除最高分項目,令畫面列出最低分的五個類別。
For a photo of a quail, my Android app's top-5 code picks American alligator, sea cucumber, barracouta, reel and sea
urchin. The model is right; the app is not. It keeps the five worst labels, and it scales the pixels with the wrong
numbers.
I added LiteRT export to Keras (keras#21674) and its PyTorch path
(keras#22758), so the export was the part I trusted. The scores and
labels below come from a run on 22 October 2026 on a computer's CPU, with the app's preprocessing and top-5 code copied into
Python. I did not run the app on a phone for this post.
The export: one call
LiteRT is the new name of TensorFlow Lite; the files still end in .tflite. The
keras.io guide installs Keras, KerasHub and litert-torch from their main
branches. On 22 October 2026 that broke this export, with
'tfl.batch_matmul' op found invalid output rank, expected 3 but got 4. The released versions worked:
pip install torch \
--index-url https://download.pytorch.org/whl/cpu
pip install keras==3.15.1 keras-hub==0.32.0 \
litert-torch==0.10.0 ai-edge-litert==2.3.0
Then the whole export, from my app's README:
# Run with KERAS_BACKEND=torch set in the shell.
import keras_hub, torch
m = keras_hub.models.MobileNetImageClassifier.from_preset(
"mobilenet_v3_small_100_imagenet")
m.export("/tmp/mobilenetv3.tflite", format="litert",
input_signature=[torch.randn(1, 224, 224, 3)])
from_preset loads a KerasHub preset: a pretrained model, fetched by name. input_signature fixes the input to one
224 by 224 colour image, because the PyTorch export path needs one fixed shape
(keras#22997). The keras.io guide picks the PyTorch backend because
its files need no flex ops, the TensorFlow ops that the new LiteRT Android runtime cannot run. With the pinned
versions it wrote a 10,255,480-byte file.
Bug 1: the pixel numbers
The exported file starts after preprocessing: calling a KerasHub model directly "does not apply preprocessing"
(pipeline_model.py).
So the app has to resize and normalize every photo itself, with the numbers the model was trained with. My app reads
them from a sidecar, a small JSON file that sits next to the model file.
The sample sidecar in my app's tests uses 127.5 for every mean and standard deviation. The preset's own
preprocessor.json
uses ImageNet's numbers, a different pair for each colour:
Nothing crashes, and on my quail photo the answer did not even change: quail stayed first, but its raw score fell
from 10.11 to 9.72. A wrong number that leaves the top answer alone is easy to ship. The fix is to copy the preset's
numbers instead of guessing them: mean is -offset / scale and std is 1 / scale, on pixels from 0 to 255. As a
sidecar:
{
"task": "image_classification",
"image_width": 224,
"image_height": 224,
"normalization": {
"mean": [123.675, 116.28, 103.53],
"std": [58.395, 57.12, 57.375]
},
"labels": "imagenet_labels.txt",
"num_classes": 1000
}
Bug 2: the top-5 list
These lines pick the five labels the screen shows
(ImageClassificationTask.kt, lines 64 to 72,
wrapped to fit):
val top5 = PriorityQueue(
compareByDescending<Classification> { it.score })
probs.forEachIndexed { idx, score ->
if (score.isFinite()) {
val label = labelList.getOrElse(idx) { "class_$idx" }
top5.add(Classification(label, score))
if (top5.size > 5) top5.poll()
}
}
TaskResult.Success(
top5.sortedByDescending { it.score }, latency)
Java's PriorityQueue keeps the smallest item, by its comparator, at the head, and compareByDescending makes that
the highest score. poll() removes the head. So each time the queue holds six, it throws away the best score, and the
screen ends up with the five least likely of 1,000 classes. For the quail photo that list is classes 50, 329, 389,
758 and 328, while the real top five for the same input are quail, prairie chicken, partridge, black grouse and
ruffed grouse. The
fix is one word: compareBy instead of
compareByDescending, so the head is the lowest score and poll() drops that one. The test for this screen only
checks that one to five labels come back, so it could not catch this.
Both bugs are still in the repository at commit b8d3e2f (3 July 2026). The fixes are small, and I have left them in
for now: if you want a first pull request, they are yours. To run the app yourself, the
README has the three adb push lines and the install
command.
The rule I took from it
Never retype preprocessing numbers. Either the export script writes the sidecar from the preset's own
preprocessor.json, or the normalization goes inside the exported model, so there is nothing left to keep in sync.
This app keeps the sidecar, because it loads any model that has one next to it.
Which do you use, a sidecar written by code or normalization inside the model? If inside, how do you export it?
Next: the check that would have caught both →
This article was written with the help of AI, from my own repository and the public docs it links.
來源:DEV Community · dev.to
