Tech Notes · Edge AI / NPU

Quantizing a Model for the Rockchip NPU — MNIST RKNN Walkthrough

A complete, end-to-end MNIST example: build the network, train it, freeze to a PB graph, convert PB to RKNN, quantize, and verify the NPU inference result against the original. Translated and annotated from peng, Rockchip / Toybrick community.

Attribution & source
Original author: peng · Published on the Rockchip / Toybrick community forum (t.rock-chips.com/forum.php?mod=viewthread&tid=858), originally titled "RK3399Pro Getting-Started Tutorial (9): MNIST RKNN Quantization", posted 2019-09-20.
Translated into English and annotated by Bestom. Code is reproduced from the original with inline comments translated to English; logic and identifiers are unchanged. Bestom presents this as a reference walkthrough for engineers bringing models to Rockchip NPUs — it is not a substitute for the current RKNN Toolkit documentation for your specific SoC.

Why this walkthrough still matters

Quantization is the single biggest lever for running a neural network on a Rockchip NPU: it shrinks the model and lets the accelerator use its INT8 datapath. The original MNIST example is small, but the shape of the pipeline — train → freeze → convert → calibrate/quantize → verify — is exactly what you repeat for every real model on RK3399Pro, RK3566/68, RK3588, and RK3576.

Bestom note: This tutorial targets the original RKNN Toolkit (TensorFlow 1.x era, RK3399Pro). On our current NPU platforms — RK3588 and RK3576 — the conversion API is RKNN Toolkit2, but the concepts are identical: you still export a frozen graph (or ONNX), point the toolkit at a calibration dataset, choose a quantized dtype, and verify accuracy on-device. See the annotation at the end for the modern mapping. Bestom builds RK3588/RK3576 SoM and reference designs and can help you port a trained model to the NPU — Solutions → Edge AI.
Training PC Target board (NPU) ┌──────────────────────┐ ┌──────────────────────────┐ │ model.py train.py │ │ RKNN runtime │ │ freese.py (→ .pb) │ .pb file │ rknn.init_runtime() │ │ rknn_transfer.py │ ───────────▶ │ rknn.inference() │ │ (→ .rknn, INT8) │ │ │ │ get_image.py │ dataset.txt│ (calibration samples) │ │ (→ dataset.txt) │ ───────────▶ │ │ └──────────────────────┘ └──────────────────────────┘

1. Build the network (model.py)

A LeNet-5-style network. The input placeholder is shaped [None, 28, 28] — a single-channel 28×28 image flattened in the batch — which keeps the later RKNN quantization step straightforward.

import tensorflow as tf



# input uses 28x28 layout to make later rknn quantization easier

x = tf.placeholder("float", [None, 28, 28], name='x')

y_ = tf.placeholder("float", [None, 10], name='y_')

keep_prob = tf.placeholder("float", name='keep_prob')



def weight_variable(shape, name):

    initial = tf.truncated_normal(shape, stddev=0.1)

    return tf.Variable(initial, name=name)



def bias_variable(shape, name):

    initial = tf.constant(0.1, shape=shape)

    return tf.Variable(initial, name=name)



def conv2d(x, W):

    return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')



def max_pool_2x2(x):

    return tf.nn.max_pool(x, ksize=[1, 2, 2, 1],

                          strides=[1, 2, 2, 1], padding='SAME')



# convolution layer

def lenet5_layer(input, weight, bias, weight_name, bias_name):

    W_conv = weight_variable(weight, weight_name)

    b_conv = bias_variable(bias, bias_name)

    h_conv = tf.nn.relu(conv2d(input, W_conv) + b_conv)

    return max_pool_2x2(h_conv)



# fully-connected layer

def dense_layer(layer, weight, bias, weight_name, bias_name):

    W_fc = weight_variable(weight, weight_name)

    b_fc = bias_variable(bias, bias_name)

    return tf.nn.relu(tf.matmul(layer, W_fc) + b_fc)



def build_model(is_training):

    # first conv

    x_image = tf.reshape(x, [-1, 28, 28, 1])

    W_conv1 = [5, 5, 1, 32]

    b_conv1 = [32]

    layer1 = lenet5_layer(x_image, W_conv1, b_conv1, 'W_conv1', 'b_conv1')

    # second conv

    W_conv2 = [5, 5, 32, 64]

    b_conv2 = [64]

    layer2 = lenet5_layer(layer1, W_conv2, b_conv2, 'W_conv2', 'b_conv2')

    # fully-connected

    W_fc1 = [7 * 7 * 64, 1024]

    b_fc1 = [1024]

    layer2_flat = tf.reshape(layer2, [-1, 7*7*64])

    layer3 = dense_layer(layer2_flat, W_fc1, b_fc1, 'W_fc1', 'b_fc1')

    # softmax

    W_fc2 = weight_variable([1024, 10], 'W_fc2')

    b_fc2 = bias_variable([10], 'b_fc2')

    if is_training:

        # dropout only during training

        h_fc1_drop = tf.nn.dropout(layer3, keep_prob)

        finaloutput = tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2, name="y_conv")

    else:

        finaloutput = tf.nn.softmax(tf.matmul(layer3, W_fc2) + b_fc2, name="y_conv")

    print('finaloutput:', finaloutput)

    return finaloutput

2. Train the network (train.py)

A create_training_graph(is_quantify) switch lets you optionally insert TensorFlow's fake-quantization training graph. The base run uses is_quantify=False. Note the reshape_batch helper that turns MNIST's flat 784-vector into a 28×28 image.

# -*- coding=utf-8 -*-

import tensorflow as tf

from tensorflow.examples.tutorials.mnist import input_data

from model import build_model, x, y_, keep_prob



mnist = input_data.read_data_sets("../MNIST_data/", one_hot=True)



def create_training_graph(is_quantify):

    # build the training graph; create_training_graph is inserted here

    g = tf.get_default_graph()  # arg for create_training_graph (default graph)

    # call the network definition to get the output

    y_conv = build_model(is_training=True)  # is_training=True (dropout used)

    # loss function

    cross_entropy = -tf.reduce_sum(y_ * tf.log(y_conv))

    print('cost:', cross_entropy)

    if is_quantify:

        # insert create_training_graph AFTER loss, BEFORE optimize

        tf.contrib.quantize.create_training_graph(input_graph=g, quant_delay=0)

    # optimize

    optimize = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)

    # accuracy

    correct_prediction = tf.equal(tf.argmax(y_conv, 1), tf.argmax(y_, 1))

    accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))

    return dict(

        x=x,

        y=y_,

        keep_prob=keep_prob,

        optimize=optimize,

        cost=cross_entropy,

        correct_prediction=correct_prediction,

        accuracy=accuracy,

    )



def reshape_batch(batch):

    rebatch = []

    for item in batch:

        b = item.reshape(28, 28)

        rebatch.append(b)

    return rebatch



def train_network(graph, ckpt, point_dir, pbtxt):

    init = tf.global_variables_initializer()

    saver = tf.train.Saver()

    with tf.Session() as sess:

        sess.run(init)

        # train 20000 steps; accuracy reaches 99%+

        for i in range(20000):

            batch = mnist.train.next_batch(50)

            if i % 100 == 0:

                train_accuracy = sess.run([graph['accuracy']], feed_dict={

                    graph['x']: reshape_batch(batch[0]),  # image data

                    graph['y']: batch[1],                  # labels

                    graph['keep_prob']: 1.0})

                print("step %d, training accuracy %g" % (i, train_accuracy[0]))

            sess.run([graph['optimize']], feed_dict={

                graph['x']: reshape_batch(batch[0]),

                graph['y']: batch[1],

                graph['keep_prob']: 0.5})

        test_accuracy = sess.run([graph['accuracy']], feed_dict={

            graph['x']: reshape_batch(mnist.test.images),

            graph['y']: mnist.test.labels,

            graph['keep_prob']: 1.0})

        print("Test accuracy %g" % test_accuracy[0])

        # save ckpt and pbtxt (change paths to your own)

        saver.save(sess, ckpt)

        tf.train.write_graph(sess.graph_def, point_dir, pbtxt, True)

        print(tf.trainable_variables())

        print(tf.get_variable('W_fc2', [1024, 10]).value)



if __name__ == "__main__":

    ckpt = './checkpoint/mnist.ckpt'

    point_dir = './checkpoint'

    pbtxt = 'mnist.pbtxt'

    g1 = create_training_graph(False)

    train_network(g1, ckpt, point_dir, pbtxt)

3. Freeze to a PB graph (freese.py)

Read the checkpoint and write a frozen PB — all variables become inline constants, and only the y_conv output node is kept. With is_quantify=False, the eval (fake-quant) graph is not inserted.

import tensorflow as tf

import os.path

from model import build_model

from tensorflow.python.framework import graph_util



def create_inference_graph():

    # build the model for evaluation

    logits = build_model(is_training=False)

    return logits



def load_variables_from_checkpoint(sess, start_checkpoint):

    # restore weights from the checkpoint

    saver = tf.train.Saver(tf.global_variables())

    saver.restore(sess, start_checkpoint)



def frozen(is_quantify, ckpt, pbtxt):

    init = tf.global_variables_initializer()

    with tf.Session() as sess:

        sess.run(init)

        logits = create_inference_graph()

        if is_quantify:

            tf.contrib.quantize.create_eval_graph()

        load_variables_from_checkpoint(sess, ckpt)

        # turn all variables into inline constants and save

        frozen_graph_def = graph_util.convert_variables_to_constants(

            sess, sess.graph_def, ['y_conv'])

        tf.train.write_graph(

            frozen_graph_def,

            os.path.dirname(pbtxt),

            os.path.basename(pbtxt),

            as_text=False)

        tf.logging.info('Saved frozen graph to %s', pbtxt)



if __name__ == "__main__":

    ckpt = './checkpoint/mnist.ckpt'

    pbtxt = 'mnist_frozen_graph.pb'

    frozen(False, ckpt, pbtxt)

4. Convert PB → RKNN (with optional quantization)

First extract the test-set images and a dataset.txt (needed for quantization calibration). Then run the conversion: common_transfer builds an unquantized RKNN, and quantify_transfer builds a quantized one using quantized_dtype='dynamic_fixed_point-8'.

4.1 Generate images + dataset.txt (get_image.py)

import struct

import numpy as np

import PIL.Image

from PIL import Image

import os



os.system("mkdir ../MNIST_data/mnist_test")

filename = '../MNIST_data/t10k-images.idx3-ubyte'

dataset = './dataset.txt'

binfile = open(filename, 'rb')

buf = binfile.read()

index = 0

data_list = []

magic, numImages, numRows, numColumns = struct.unpack_from('>IIII', buf, index)

index += struct.calcsize('>IIII')

for image in range(0, numImages):

    im = struct.unpack_from('>784B', buf, index)

    index += struct.calcsize('>784B')

    im = np.array(im, dtype='uint8')

    im = im.reshape(28, 28)

    im = Image.fromarray(im)

    im.save('../MNIST_data/mnist_test/test_%s.jpg' % image, 'jpeg')

    data_list.append('../MNIST_data/mnist_test/test_%s.jpg\n' % image)

with open(dataset, 'w+') as ff:

    ff.writelines(data_list)

4.2 PB → RKNN conversion (rknn_transfer.py)

from rknn.api import RKNN



def common_transfer(pb_name, export_name):

    ret = 0

    rknn = RKNN()

    print('--> Loading model')

    ret = rknn.load_tensorflow(

        tf_pb='./mnist_frozen_graph.pb',

        inputs=['x'],

        outputs=['y_conv'],

        input_size_list=[[28, 28, 1]])

    if ret != 0:

        print('load_tensorflow error')

        rknn.release()

        return ret

    print('done')

    print('--> Building model')

    rknn.build(do_quantization=False)

    print('done')

    rknn.export_rknn('./mnist.rknn')

    rknn.release()

    return ret



def quantify_transfer(pb_name, dataset_name, export_name):

    ret = 0

    print(pb_name, dataset_name, export_name)

    rknn = RKNN()

    rknn.config(channel_mean_value='', reorder_channel='',

                quantized_dtype='dynamic_fixed_point-8')

    print('--> Loading model')

    ret = rknn.load_tensorflow(

        tf_pb=pb_name,

        inputs=['x'],

        outputs=['y_conv'],

        input_size_list=[[28, 28, 1]])

    if ret != 0:

        print('load_tensorflow error')

        rknn.release()

        return ret

    print('done')

    print('--> Building model')

    rknn.build(do_quantization=True, dataset=dataset_name)

    print('done')

    rknn.export_rknn(export_name)

    rknn.release()

    return ret



if __name__ == '__main__':

    pb_name = './mnist_frozen_graph.pb'

    export_name = './mnist.rknn'

    ret = common_transfer(pb_name, export_name)

    if ret != 0:

        print('======common transfer error !!===========')

    else:

        print('======common transfer ok !!===========')

    dataset_name = './dataset.txt'

    export_name = './mnist_quantization.rknn'

    quantify_transfer(pb_name, dataset_name, export_name)

    if ret != 0:

        print('======quantization transfer 10000 error !!===========')

    else:

        print('======quantization transfer 10000 ok !!===========')
Bestom note: dynamic_fixed_point-8 is Rockchip's INT8 asymmetric quantization. The dataset.txt you feed to build(do_quantization=True, dataset=...) is the calibration set — a representative sample of real inputs. On RK3588/RK3576 with RKNN Toolkit2 the same idea applies: you supply a dataset.txt (or a folder of images) and the toolkit picks activation ranges from it. A poor calibration set is the most common cause of accuracy drop after quantization.

5. Compare PB vs RKNN inference

Run the original frozen-graph inference and the RKNN inference over the same images and compare accuracy. The RKNN side loads the model, calls init_runtime() (this talks to the NPU), then inference().

# rknn_predict.py (key parts)

import numpy as np

from PIL import Image

from rknn.api import RKNN



def get_predict(probability):

    data = probability[0][0]

    data = data.tolist()

    max_prob = max(data)

    return data.index(max_prob), max_prob



def load_model(model_name):

    rknn = RKNN()

    print('-->loading model')

    rknn.load_rknn(model_name)

    print('loading model done')

    print('--> Init runtime environment')

    ret = rknn.init_runtime()

    if ret != 0:

        print('Init runtime environment failed')

        exit(ret)

    print('done')

    return rknn



def predict(rknn, length):

    acc_count = 0

    for i in range(length):

        im = Image.open("../MNIST_data/mnist_test/test_%d.jpg" % i)

        im = im.resize((28, 28), Image.ANTIALIAS)

        im = np.asarray(im)

        outputs = rknn.inference(inputs=[im])

        pred, prob = get_predict(outputs)

        if list(mnist.test.labels[i]).index(1) == pred:

            acc_count += 1

    result = float(acc_count) / length

    print('result:', result)



if __name__ == "__main__":

    # switch to the quantized or unquantized rknn model as needed

    model_name = './mnist.rknn'

    length = 10000

    rknn = load_model(model_name)

    predict(rknn, length)

    rknn.release()

The original thread includes an accuracy-comparison table (visible to logged-in forum users) and reports that the quantized RKNN model's file size is roughly half of the unquantized RKNN, which in turn is roughly half of the float32 PB — consistent with float32 → int16 → int8.


6. Going further: fake quantization (optional)

The thread's appendix shows a full fake-quantization path: re-train with tf.contrib.quantize.create_training_graph / create_eval_graph, freeze, convert the fake-quantized PB to a fully-quantized TFLite via toco, then load that TFLite into RKNN. This generally gives better INT8 accuracy than post-training quantization alone because the network learns to tolerate the reduced precision during training.

# toco: pb -> fully-quantized tflite (int8 in/out/weights)

toco \

 --graph_def_file=mnist_frozen_graph_fake.pb \

 --output_file=mnist_fakequantize.tflite \

 --output_format=TFLITE \

 --inference_type=QUANTIZED_UINT8 \

 --input_shapes=1,28,28 \

 --input_arrays=x \

 --output_arrays=y_conv \

 --mean_values=0 \

 --std_dev_values=256 \

 --change_concat_input_ranges=false --allow_custom_ops

Then rknn.load_tflite(model='./mnist_fakequantize.tflite') and rknn.build(do_quantization=False) to export the RKNN. The thread closes with a community Q&A covering multi-input handling (later RKNN Toolkit versions support it natively), out-of-memory during training, board-vs-PC conversion size differences, and a shape-mismatch pitfall when the placeholder x is defined as [None,28,28] but RKNN adds the channel dimension per input_size_list.

Bestom note (modern mapping): The tf.contrib.quantize + toco flow above is TensorFlow 1.x and is largely historical. For current RK3588 / RK3576 work we recommend RKNN Toolkit2: export your trained model to ONNX (or a frozen TF/PyTorch graph), then use RKNNBuilder / the rknnlite / rknn.toolkit2 API to config → load → build(do_quantization=True, dataset=...) → export_rknn, and run inference with rknn.init_runtime() / rknnlite.infer on the board. The pipeline shape is unchanged; only the API calls differ. Bestom can supply a current RK3588/RK3576 quantization reference for your model — see Contact.

Recap