Skip to content

Commit defd9ca

Browse files
authored
Zephyr Sample: fix MobileNetV2 sample input normalization (#23283)
The mv2-ethosu sample scaled uint8 input with (x - 128) / 128, which maps to [-1, 1]. torchvision's MobileNetV2 is trained with ImageNet per-channel mean and standard deviation, so a real photograph fed through the sample was normalized with the wrong transform. This went unnoticed because the bundled mv2_input.h holds a synthetic tensor, and noise in produces noise out either way. Apply ImageNet normalization per channel instead, and add gen_input.py to regenerate mv2_input.h from a real image. The script resizes to 256, centre-crops to 224x224, stores uint8 RGB in NCHW order, and records the float32 torchvision prediction in a comment so the device output has a reference to compare against. Verified on an Alif Ensemble E8 DevKit (Cortex-M55 HP + Ethos-U55 256 MAC) with ExecuTorch v1.5.1, Zephyr 4.4.0 and Zephyr SDK 1.0.1. With INT8 quantization calibrated on 250 ImageNet photographs, the device top-1 matched the float32 torchvision reference on three of three test images that the reference classified confidently. Authored with assistance from Claude Code.
1 parent bdb7964 commit defd9ca

3 files changed

Lines changed: 149 additions & 2 deletions

File tree

‎zephyr/samples/mv2-ethosu/README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,36 @@ model on the Arm Ethos-U NPU using ExecuTorch within a Zephyr RTOS application.
66
The model classifies a static RGB test input tensor with shape `[1, 3, 224, 224]`
77
(NCHW) into one of 1000 ImageNet classes and prints the top-5 predictions.
88

9+
## Using a real image
10+
11+
The bundled `src/mv2_input.h` holds a synthetic tensor, not a photograph. It
12+
exercises the full runtime path (load, delegate, execute) but the predictions it
13+
produces are meaningless. To classify something real, regenerate the header:
14+
15+
```bash
16+
python gen_input.py your_photo.jpg
17+
```
18+
19+
The script resizes to 256, centre-crops to 224x224, stores the result as uint8
20+
RGB in NCHW order, and records what float32 torchvision MobileNetV2 predicts.
21+
Rebuild and reflash after regenerating.
22+
23+
Two things are required for that recorded prediction to be a valid reference.
24+
Export with `--model_name=mv2` rather than the `mv2_untrained` used in the
25+
commands below, since untrained weights produce arbitrary class IDs. And supply
26+
representative calibration data with `--calibration_data`, because the default
27+
calibrates on the model's example input, which is random:
28+
29+
```bash
30+
python -m executorch.backends.arm.scripts.aot_arm_compiler \
31+
--model_name=mv2 --quantize --delegate \
32+
--target=ethos-u55-256 --calibration_data=<dir-of-pt-tensors> \
33+
--output=mv2_ethosu.pte
34+
```
35+
36+
ImageNet has no "person" class, so portraits return an unrelated label with low
37+
confidence. Photographs of animals, objects, food and vehicles work well.
38+
939
## Prerequisites
1040

1141
- Zephyr SDK with ExecuTorch module enabled
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Generate src/mv2_input.h from a real image.
8+
9+
The sample ships with a synthetic input so it can run without bundling an
10+
image. That exercises the runtime but produces meaningless predictions. Point
11+
this script at a photograph to get a header the device can actually classify:
12+
13+
python gen_input.py cat.jpg
14+
15+
The image is resized to 256, centre-cropped to 224x224 and stored as uint8 RGB
16+
in NCHW order, matching the normalisation main.cpp applies on device. The
17+
float32 torchvision prediction is recorded in a comment so the device output
18+
has a reference to be compared against.
19+
20+
Requires: pillow, torch, torchvision.
21+
"""
22+
23+
import argparse
24+
import os
25+
26+
import numpy as np
27+
import torch
28+
import torchvision
29+
from PIL import Image
30+
from torchvision import transforms
31+
32+
HEADER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src", "mv2_input.h")
33+
34+
35+
def main() -> None:
36+
parser = argparse.ArgumentParser(description=__doc__)
37+
parser.add_argument("image", help="path to a source image")
38+
parser.add_argument("-o", "--output", default=HEADER, help="header to write")
39+
args = parser.parse_args()
40+
41+
weights = torchvision.models.MobileNet_V2_Weights.IMAGENET1K_V1
42+
model = torchvision.models.mobilenet_v2(weights=weights).eval()
43+
categories = weights.meta["categories"]
44+
45+
crop = transforms.Compose([transforms.Resize(256), transforms.CenterCrop(224)])
46+
normalize = transforms.Compose(
47+
[
48+
transforms.ToTensor(),
49+
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
50+
]
51+
)
52+
53+
image = crop(Image.open(args.image).convert("RGB"))
54+
with torch.no_grad():
55+
logits = model(normalize(image).unsqueeze(0))[0]
56+
probs = torch.softmax(logits, dim=0)
57+
top1 = int(torch.argmax(logits))
58+
print(
59+
f"{os.path.basename(args.image)} -> class {top1} "
60+
f"({categories[top1]}) {probs[top1].item() * 100:.1f}%"
61+
)
62+
63+
chw = np.transpose(np.asarray(image, dtype=np.uint8), (2, 0, 1)).copy()
64+
raw = chw.tobytes()
65+
66+
lines = [
67+
"/* Copyright (c) Meta Platforms, Inc. and affiliates.",
68+
" * All rights reserved.",
69+
" *",
70+
" * This source code is licensed under the BSD-style license found in the",
71+
" * LICENSE file in the root directory of this source tree.",
72+
" *",
73+
f" * Generated by gen_input.py from {os.path.basename(args.image)}.",
74+
" * Resized 256, centre-cropped 224, uint8 RGB in NCHW order.",
75+
" *",
76+
f" * float32 torchvision MobileNetV2 predicts class {top1} "
77+
f"({categories[top1]}) at {probs[top1].item() * 100:.1f}%.",
78+
" */",
79+
"",
80+
"#ifndef MV2_INPUT_H",
81+
"#define MV2_INPUT_H",
82+
"",
83+
"#include <stdint.h>",
84+
"",
85+
f"#define MV2_INPUT_SIZE {len(raw)}",
86+
f"#define MV2_HOST_TOP1 {top1}",
87+
f'#define MV2_HOST_LABEL "{categories[top1]}"',
88+
"",
89+
"static const uint8_t mv2_input_data[MV2_INPUT_SIZE] __attribute__((aligned(4)))",
90+
'__attribute__((section(".rodata.mv2_input"))) = {',
91+
]
92+
for i in range(0, len(raw), 16):
93+
lines.append(" " + ",".join(f"0x{b:02x}" for b in raw[i : i + 16]) + ",")
94+
lines += ["};", "", "#endif /* MV2_INPUT_H */"]
95+
96+
with open(args.output, "w") as handle:
97+
handle.write("\n".join(lines) + "\n")
98+
print(f"wrote {args.output}")
99+
100+
101+
if __name__ == "__main__":
102+
main()

‎zephyr/samples/mv2-ethosu/src/main.cpp‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ unsigned char* ethosu_fast_scratch = dedicated_sram;
9090

9191
namespace {
9292

93+
// Preprocessing constants for torchvision's ImageNet-trained MobileNetV2.
94+
constexpr size_t kNumChannels = 3;
95+
constexpr float kImageNetMean[kNumChannels] = {0.485f, 0.456f, 0.406f};
96+
constexpr float kImageNetStd[kNumChannels] = {0.229f, 0.224f, 0.225f};
97+
9398
Error prepare_input_tensors(
9499
Method& method,
95100
MemoryAllocator& allocator,
@@ -147,9 +152,19 @@ Error prepare_input_tensors(
147152
Info,
148153
"Converting uint8 input (%lu elements) to float32",
149154
static_cast<unsigned long>(input_size));
155+
// torchvision MobileNetV2 is trained with ImageNet normalisation, so the
156+
// uint8 image has to be scaled to [0,1] and then standardised per
157+
// channel. The tensor is NCHW, so each channel is a contiguous plane.
150158
float* float_data = static_cast<float*>(data_ptr);
151-
for (size_t j = 0; j < input_size; j++) {
152-
float_data[j] = (static_cast<float>(input_data[j]) - 128.0f) / 128.0f;
159+
const size_t plane = input_size / kNumChannels;
160+
for (size_t c = 0; c < kNumChannels; c++) {
161+
const float mean = kImageNetMean[c];
162+
const float stddev = kImageNetStd[c];
163+
for (size_t j = 0; j < plane; j++) {
164+
const size_t idx = c * plane + j;
165+
float_data[idx] =
166+
((static_cast<float>(input_data[idx]) / 255.0f) - mean) / stddev;
167+
}
153168
}
154169
} else if (input_size == tensor_meta->nbytes()) {
155170
ET_LOG(

0 commit comments

Comments
 (0)