Skip to main content

Module 2 — Converting to TensorFlow Lite

Module 1 gave us the target: a plant-disease classifier under 8 MB, under 60 ms per image, running with no network. Module 2 starts the trip by turning our fine-tuned MobileNetV2 into a .tflite file the mobile runtime can load. The API is small; the traps are all in the operators.

From what, exactly?

The converter accepts three input formats, and knowing which one you have saves an hour of confusion.

InputCommandWhen
SavedModel directoryTFLiteConverter.from_saved_model("model/")trained anywhere, exported with model.export() — the canonical path
Keras model in memoryTFLiteConverter.from_keras_model(model)you just finished fit and want a quick check
Concrete functionTFLiteConverter.from_concrete_functions([fn])custom preprocessing or a model built without Keras

Course 08, module 10 already made the case for SavedModel over .keras: no Python dependency, everything the runtime needs is inside the directory. That case gets stronger here, because the mobile runtime has no Python at all. Always convert from a SavedModel in real projects. The Keras-in-memory path is fine for a Colab notebook, but hides the export step where most preprocessing bugs actually live.

The minimal conversion

import tensorflow as tf

# Load the fine-tuned MobileNetV2 saved in module 10 of course 08.
converter = tf.lite.TFLiteConverter.from_saved_model("models/leaf_classifier/1")

tflite_model = converter.convert()

with open("leaf_classifier.tflite", "wb") as f:
f.write(tflite_model)

print(f"model size: {len(tflite_model) / 1024 / 1024:.2f} MB")

For our example, this prints roughly 9.20 MB. Just above budget, and we have applied no optimisation yet — that is the point of modules 3 to 5, each of which will slice a chunk off that number.

Not every operator survives the trip

TensorFlow has thousands of operators. TensorFlow Lite implements about 130 of them, called the built-in ops. If your model uses an op that is not in that list, convert() fails with an explicit message:

error: 'tf.SomeOp' op is neither a custom op nor a flex op.

Three responses, in order of preference.

Rewrite the model to avoid the op. This is often possible: a custom Lambda layer that computes something exotic can usually be replaced by a chain of built-ins. This is the cheapest fix at runtime because everything stays inside the standard interpreter.

Enable Select TF Ops to keep the offending op:

converter.target_spec.supported_ops = [
tf.lite.OpsSet.TFLITE_BUILTINS,
tf.lite.OpsSet.SELECT_TF_OPS,
]

Select TF Ops links a subset of the full TensorFlow runtime into the app. The .tflite file gains a few hundred kilobytes, the shipped app gains 3 to 6 MB of native library, and one operator that was blocking conversion now runs. It is a real trade — use it when the alternative is rewriting a critical layer, avoid it when a rewrite is cheap.

Write a custom op in C++. Reserved for research projects and specialised hardware; a normal application should never need it.

"Unsupported operator" is a symptom, not a diagnosis

The message names the op that failed, but the cause is usually one line of Python written weeks earlier: a tf.py_function, an exotic slicing expression, a probability distribution the mobile runtime does not implement. Fix the root cause in the training code and re-export the SavedModel; do not accumulate SELECT_TF_OPS calls to paper over each new symptom.

Signatures make the model self-describing

By default, the converter picks one signature named serving_default. The interpreter uses it to know the input names and shapes. When you have several signatures — one for classification, one for embedding extraction — pass them explicitly:

converter = tf.lite.TFLiteConverter.from_saved_model(
"models/leaf_classifier/1",
signature_keys=["serving_default", "embedding"],
)
tflite_model = converter.convert()

On the mobile side, this lets a single .tflite file expose two entry points, which avoids shipping two files.

Metadata: what the app needs to know about the model

A raw .tflite file tells the runtime the shapes and dtypes, but not what the classes mean or how to preprocess the input. Without metadata, the Android team has to hard-code label names, mean and standard deviation values, and image size — three places where a mismatch between training and mobile silently ruins predictions.

The tflite-support library attaches this information as a proper metadata block inside the file itself:

from tflite_support.metadata_writers import image_classifier
from tflite_support.metadata_writers import writer_utils

writer = image_classifier.MetadataWriter.create_for_inference(
writer_utils.load_file("leaf_classifier.tflite"),
input_norm_mean=[127.5],
input_norm_std=[127.5],
label_file_paths=["labels.txt"],
)

writer_utils.save_file(writer.populate(), "leaf_classifier.tflite")

The mobile app's Task Library (module 7) then reads this metadata automatically: it resizes the image to the right dimensions, normalises with the right constants, decodes the output using the label file. The training team ships one artifact, the mobile team consumes one artifact, and there is no third document that can drift.

Verify before you trust

The same reasoning as saved_model_cli show applies here: check the artifact before you hand it to the mobile team.

interpreter = tf.lite.Interpreter(model_path="leaf_classifier.tflite")
interpreter.allocate_tensors()

print("inputs :", interpreter.get_input_details())
print("outputs:", interpreter.get_output_details())

The output should show input shape [1, 224, 224, 3] in float32 and output shape [1, 38] for our 38 leaf-disease classes. Anything else — a shape of [1, 10] when you expected 38, a dtype of int8 when you have not quantized yet — flags a wiring bug that is much cheaper to fix now than after the app is built.

The trade-off matrix, first row

VariantSizeLatency (Pixel 4a)Top-1 accuracy
MobileNetV2 fine-tuned, .tflite float329.20 MB88 ms0.941

Nine megabytes and 88 milliseconds is over budget on both axes. This is the baseline that modules 3, 4 and 5 will each attack from a different angle.

Key takeaways

  • Convert from a SavedModel, not from a Keras model in memory: the SavedModel is the only artifact that survives without Python and that the training-serving-mobile chain shares.
  • Unsupported operators throw an explicit error at conversion time: rewrite the model first, enable Select TF Ops only when the alternative is worse, write custom ops almost never.
  • Signatures name the entry points, metadata attaches preprocessing constants and labels inside the .tflite file, so the mobile team never has to guess.
  • Inspect the artifact with Interpreter.get_input_details before deployment: catching a wrong shape or dtype here is minutes; catching it in the app is days.

Next module: post-training quantization, the cheapest way to cut this baseline by three-quarters without retraining.