10.1 Model Preparation
This phase covers building the example 1D-CNN model, exporting it to int8 TFLite format, generating quantized input data, and producing the C byte arrays embedded in the MCU firmware. The source files for this phase (model_keras.py, export_tflite.py, gen_input_data.py) are provided alongside this document. Install dependencies on Host Machine once before running any script:
python3 -m venv .venv && source .venv/bin/activate
pip install numpy tensorflow
10.1.1 Reference Model Architecture
The example model is a minimal 1D-CNN with random weights.| Parameter | Value |
|---|---|
| Architecture | Conv2D + Mean (GlobalAvgPool) + FullyConnected |
| TFLM ops | 3: AddConv2D, AddMean, AddFullyConnected |
| Framework | Keras (TensorFlow 2.x), exported via TFLiteConverter |
| Input shape | [1, 1, 200, 1] (batch, height, length, channels) |
| Input dtype | int8 — 200 bytes per inference window |
| Output shape | [1, 2] — 2-class logits |
| Model file size | 2,096 bytes (cnn1d_minimal_int8.tflite) |
| Tensor arena | TFLI_ARENA_KB = 4 (measure g_tfli_arena_used on-target to tune) |
10.1.1.1 Model Graph
BatchNormalization is folded into the Conv2D weights during export — it does not appear as a separate op in the quantized graph. The int8 model has exactly 3 operators. input
|
[1 x 1 x 200 x 1] int8
|
v
+--------------------------------+
| Conv2D |
| filter [4 x 1 x 3 x 1] |
| bias [4] |
| padding SAME |
| BatchNorm folded into weights |
+--------------------------------+
| ReLU |
+--------------------------------+
|
[1 x 1 x 200 x 4]
|
v
+--------------------------------+
| Mean |
| axis [2] (global avg pool) |
+--------------------------------+
|
[1 x 4]
|
v
+--------------------------------+
| FullyConnected |
| weights [2 x 4] |
+--------------------------------+
|
[1 x 2]
|
v
output_0
10.1.2 Source Files
10.1.2.1 model_keras.py — Model Definition
Defines the Keras model architecture.
"""Minimal Keras CNN1D for TFLM MCU demo.
Architecture: 1 x Conv2D(4 filters, k=1x3, same) + BN + ReLU
+ GlobalAveragePool2D + Dense(num_classes)
TFLM ops needed: Conv2D, Mean, FullyConnected (3 ops, no MaxPool/Reshape)
"""
import tensorflow as tf
INPUT_HEIGHT = 1
INPUT_LENGTH = 200
INPUT_CHANNELS = 1
def build_model(num_classes: int = 2, channels: int = 4) -> tf.keras.Model:
inputs = tf.keras.Input(
batch_shape=(1, INPUT_HEIGHT, INPUT_LENGTH, INPUT_CHANNELS), name="input")
x = tf.keras.layers.Conv2D(
channels, kernel_size=(1, 3), padding="same")(inputs)
x = tf.keras.layers.BatchNormalization()(x)
x = tf.keras.layers.ReLU()(x)
x = tf.keras.layers.GlobalAveragePooling2D()(x)
outputs = tf.keras.layers.Dense(num_classes, name="output")(x)
return tf.keras.Model(inputs, outputs, name="CNN1D_minimal")
if __name__ == "__main__":
model = build_model()
model.summary()
n = sum(tf.size(w).numpy() for w in model.trainable_weights)
print(f"trainable parameters: {n}")
10.1.2.2 export_tflite.py — Export Float32 and Int8 TFLite
Builds the model from model_keras.py, runs full-integer quantization with a random representative dataset, and validates both outputs.
"""Export minimal CNN1D to TFLite float32 and full-integer int8.
Outputs
-------
model_files/cnn1d_minimal_float.tflite -- float32 (reference)
model_files/cnn1d_minimal_int8.tflite -- int8, deploy on MCU
"""
import argparse
import os
import numpy as np
import tensorflow as tf
from model_keras import build_model, INPUT_HEIGHT, INPUT_LENGTH, INPUT_CHANNELS
REPR_SAMPLES = 100
def representative_dataset():
rng = np.random.default_rng(42)
for _ in range(REPR_SAMPLES):
sample = rng.standard_normal(
(1, INPUT_HEIGHT, INPUT_LENGTH, INPUT_CHANNELS)).astype(np.float32)
yield [sample]
def export_float(model, path):
converter = tf.lite.TFLiteConverter.from_keras_model(model)
data = converter.convert()
with open(path, "wb") as f:
f.write(data)
print(f" wrote {path} ({len(data):,} bytes)")
return data
def export_int8(model, path):
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
data = converter.convert()
with open(path, "wb") as f:
f.write(data)
print(f" wrote {path} ({len(data):,} bytes)")
return data
def validate_and_report(tflite_bytes, dtype, label):
interp = tf.lite.Interpreter(model_content=tflite_bytes)
interp.allocate_tensors()
inp = interp.get_input_details()[0]
out = interp.get_output_details()[0]
print(f" [{label}] input shape={inp['shape']} dtype={inp['dtype'].__name__}"
f" scale={inp['quantization'][0]:.6f} zero_pt={inp['quantization'][1]}")
print(f" [{label}] output shape={out['shape']} dtype={out['dtype'].__name__}"
f" scale={out['quantization'][0]:.6f} zero_pt={out['quantization'][1]}")
x = np.zeros((1, INPUT_HEIGHT, INPUT_LENGTH, INPUT_CHANNELS), dtype=dtype)
interp.set_tensor(inp["index"], x)
interp.invoke()
result = interp.get_tensor(out["index"])
print(f" [{label}] zero-input output: {result}")
try:
ops = interp._get_ops_details()
for op in ops:
print(f" op_index={op['index']} name={op['op_name']}")
except AttributeError:
print(" (op detail API not available in this TF version)")
return inp['quantization']
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--num-classes", type=int, default=2)
ap.add_argument("--channels", type=int, default=4)
ap.add_argument("--out-dir", default="model_files")
args = ap.parse_args()
os.makedirs(args.out_dir, exist_ok=True)
model = build_model(num_classes=args.num_classes, channels=args.channels)
float_path = os.path.join(args.out_dir, "cnn1d_minimal_float.tflite")
int8_path = os.path.join(args.out_dir, "cnn1d_minimal_int8.tflite")
print("--- float32 ---")
fb = export_float(model, float_path)
validate_and_report(fb, np.float32, "float32")
print("\n--- int8 ---")
ib = export_int8(model, int8_path)
scale, zero_pt = validate_and_report(ib, np.int8, "int8")
print(f"\nInput quantization: scale={scale:.6f} zero_point={zero_pt}")
print(f" float->int8: q = clip(round(x / {scale:.6f}) + {zero_pt}, -128, 127)")
print(f"\nDeploy: {int8_path}")
if __name__ == "__main__":
main()
python export_tflite.py
| File | Purpose |
|---|---|
model_files/cnn1d_minimal_float.tflite | Float32 model — reference only |
model_files/cnn1d_minimal_int8.tflite | Int8 model — embed in MCU firmware |
scale, zero_point) used internally by gen_input_data.py.
10.1.2.3 gen_input_data.py — Generate Quantized Input Files
Reads quantization parameters directly from the exported int8 model, then generates 200-sample int8 windows from a real dataset or synthetic data.
"""Generate int8 and float32 input files for the minimal CNN1D model.
Outputs
-------
inputs/input_0.bin -- 200 int8 bytes, ready for MCU g_cnn_input[]
inputs/input_0_f32.raw -- 200 float32 = 800 bytes, for reference
inputs/input_list.txt -- list of .bin paths for batch testing
"""
import argparse
import os
import numpy as np
import tensorflow as tf
WINDOW_LEN = 200
DATASET_LEN = 1696
DTYPE_F32 = np.dtype("<f4")
def quantize(data_f32, scale, zero_pt):
q = np.round(data_f32 / scale).astype(np.int32) + zero_pt
return np.clip(q, -128, 127).astype(np.int8)
def get_quant_params(model_path):
interp = tf.lite.Interpreter(model_path=model_path)
interp.allocate_tensors()
inp = interp.get_input_details()[0]
scale, zero_pt = inp["quantization"]
if scale == 0.0:
raise ValueError(f"{model_path}: input tensor has scale=0 -- not an int8 model")
return float(scale), int(zero_pt)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="model_files/cnn1d_minimal_int8.tflite")
ap.add_argument("--dataset", default=None,
help="Raw float32 LE dataset (1,696 values). Omit for synthetic data.")
ap.add_argument("--num-windows", type=int, default=8)
ap.add_argument("--out-dir", default="inputs")
args = ap.parse_args()
os.makedirs(args.out_dir, exist_ok=True)
if os.path.exists(args.model):
scale, zero_pt = get_quant_params(args.model)
print(f"quant params from {args.model}: scale={scale:.6f} zero_point={zero_pt}")
else:
scale, zero_pt = 1.0 / 128.0, 0
print("model not found -- using default quant params: scale=1/128 zero_point=0")
if args.dataset:
data = np.fromfile(args.dataset, dtype=DTYPE_F32)
print(f"loaded dataset: {args.dataset} ({data.size} values)")
else:
rng = np.random.default_rng(0)
data = rng.standard_normal(DATASET_LEN).astype(DTYPE_F32)
print(f"synthesized dataset: {data.size} values")
bin_files = []
for i in range(args.num_windows):
start = (i * WINDOW_LEN) % (len(data) - WINDOW_LEN + 1)
window_f32 = data[start : start + WINDOW_LEN]
window_i8 = quantize(window_f32, scale, zero_pt)
bin_path = os.path.join(args.out_dir, f"input_{i}.bin")
window_i8.tofile(bin_path)
bin_files.append(bin_path)
f32_path = os.path.join(args.out_dir, f"input_{i}_f32.raw")
window_f32.tofile(f32_path)
print(f" window {i}: start={start} "
f"f32=[{window_f32.min():.3f}, {window_f32.max():.3f}] "
f"i8=[{window_i8.min()}, {window_i8.max()}] -> {bin_path}")
list_path = os.path.join(args.out_dir, "input_list.txt")
with open(list_path, "w") as f:
for p in bin_files:
f.write(os.path.abspath(p) + "\n")
print(f"\nwrote {list_path} ({len(bin_files)} entries)")
print(f"Primary MCU input file: {os.path.abspath(bin_files[0])}")
print(f" use as g_cnn_input[] in input_data.cpp (200 int8 bytes)")
if __name__ == "__main__":
main()
python gen_input_data.py --num-windows 8
# Pass --dataset /path/to/data.raw to use a real 1,696-value float32 LE dataset
inputs/ folder):
| File | Size | Purpose |
|---|---|---|
input_0.bin … input_7.bin | 200 bytes each | Pre-quantized int8 windows — embed input_0.bin in firmware |
input_0_f32.raw … input_7_f32.raw | 800 bytes each | Float32 originals — host verification only |
input_list.txt | — | Absolute paths for batch testing tools |
10.1.3 Verify on Host
Before integrating model with MCU Firmware, confirm the model runs correctly with the generated input:import tensorflow as tf
import numpy as np
interp = tf.lite.Interpreter("model_files/cnn1d_minimal_int8.tflite")
interp.allocate_tensors()
inp = interp.get_input_details()[0]
out = interp.get_output_details()[0]
print(f"Input: shape={inp['shape']} dtype={inp['dtype']} "
f"scale={inp['quantization'][0]:.6f} zp={inp['quantization'][1]}")
print(f"Output: shape={out['shape']} dtype={out['dtype']} "
f"scale={out['quantization'][0]:.6f} zp={out['quantization'][1]}")
x = np.fromfile("inputs/input_0.bin", dtype=np.int8).reshape(inp['shape'])
interp.set_tensor(inp['index'], x)
interp.invoke()
y = interp.get_tensor(out['index'])
print(f"Output logits (int8): {y}")
print(f"Predicted class: {np.argmax(y)}")
Input: shape=[1 1 200 1] dtype=int8 scale=0.007812 zp=0
Output: shape=[1 2] dtype=int8 scale=... zp=...
Output logits (int8): [[...]]
Predicted class: 0 or 1

