Skip to content

Commit cb6036e

Browse files
CodeKitty-maxclaude
andcommitted
Port camera_stream to the ExecuTorch runtime
The camera demo now loads an ExecuTorch program (.pte) and runs it with Program/Method, with planned memory in PSRAM and the ESP-NN delegate for the int8 graph. It shares esp_pal.cpp with the executor runner. - et_model.{h,cpp}: program loading, float NCHW input, logits output and delegate timing. - export_model.py: calibrates on an image folder, optional QAT, and reports top-1 accuracy of the lowered program. Replaces the standalone compiler. - viewer.py shows the runtime, delegate count and time spent in ESP-NN. - Default camera XCLK is 10 MHz; at 20 MHz the OV3660 drops and corrupts frames after a few minutes. On an ESP32-S3 N16R8, MobileNetV3-Small 224 lowers to one delegate with no CPU ops and runs in 646 ms (626 ms in the delegate), 1.3 results/s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9568d68 commit cb6036e

9 files changed

Lines changed: 631 additions & 95 deletions

File tree

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,55 @@
11
# Camera inference stream (ESP32-S3)
22

3-
Live ImageNet classification on an ESP32-S3 camera board with the standalone
4-
`espnn_runtime`, streamed over USB to a terminal or a browser.
3+
Live ImageNet classification on an ESP32-S3 camera board. The model is an
4+
ExecuTorch program (`.pte`) run by the ExecuTorch runtime, with its int8
5+
graph in the ESP-NN delegate (`backends/espressif`). Results and a preview
6+
stream over USB to a terminal or a browser.
57

68
Tested with an OV3660 on the ESP32-S3-EYE / Freenove ESP32-S3-WROOM CAM
79
pinout (selectable in menuconfig, as is the XIAO ESP32S3 Sense).
810

911
```bash
10-
# 1. Compile a model (QAT recovers MobileNetV3-Small accuracy; plain PTQ loses most of it)
11-
python ../aot_espnn_compiler.py --model mobilenet_v3_small --mode standalone \
12-
--calib-dir imagenette2-160/train --eval-dir imagenette2-160/val \
13-
--calib-samples 1024 --qat-epochs 3 --qat-lr 1e-4 --output models/mobilenet_v3_small_224.xtb
12+
# 1. Quantize and lower a model. MobileNetV3-Small needs QAT; plain PTQ loses
13+
# most of its accuracy.
14+
python export_model.py --calib-dir imagenette2-160/train \
15+
--eval-dir imagenette2-160/val --qat-epochs 5 --qat-lr 1e-3 \
16+
--output models/mobilenet_v3_small_224.pte
1417

15-
# 2. Build and flash (activated ESP-IDF shell)
16-
./flash.sh /dev/ttyACM0
18+
# 2. Cross-compile ExecuTorch (see backends/espressif/README.md) into
19+
# cmake-out-esp, then build and flash from an activated ESP-IDF shell
20+
ET_BUILD_DIR=$PWD/../../../cmake-out-esp ./flash.sh /dev/ttyACM0
1721

1822
# 3. Watch: terminal dashboard, or Chrome at http://127.0.0.1:8765/
19-
python viewer.py
20-
python viewer.py --web
23+
python viewer.py --port /dev/ttyACM0
24+
python viewer.py --port /dev/ttyACM0 --web
2125
```
2226

23-
The board captures JPEG frames from the sensor, decodes and center-crops them,
24-
runs the model on core 1 and streams results, the model's input image and a
25-
15 fps preview on core 0. On the ESP32-S3 with MobileNetV3-Small this gives
26-
about 1.4 results per second with roughly 0.75 s from capture to result.
27+
`export_model.py` prints the CPU operators the program needs and writes them
28+
to `models/mobilenet_v3_small_224.ops.txt` for `EXECUTORCH_SELECT_OPS_LIST`.
29+
MobileNetV3-Small lowers to one delegate with no CPU operators, so any
30+
selection works.
31+
32+
The board captures JPEG frames from the sensor, decodes and center-crops
33+
them, runs the model on core 1, and streams results, the model's input image
34+
and a preview of about 12 fps on core 0.
35+
36+
Measured on an ESP32-S3 N16R8 at 240 MHz with MobileNetV3-Small at 224×224:
37+
38+
| | |
39+
|---|---|
40+
| Inference | 646 ms, of which 626 ms in the ESP-NN delegate (593 ms in kernels) |
41+
| Results | 1.3 per second, about 0.8 s from capture to result |
42+
| Accuracy (200 Imagenette val images, 1000-way top-1) | float 134, int8 program 152 |
43+
44+
The int8 program beats the float model there because QAT fine-tunes on
45+
Imagenette's 10 classes. It is not a general ImageNet result.
46+
47+
The delegate copies the weights into PSRAM and can split kernels across both
48+
cores (`CONFIG_ESPNN_DELEGATE_DUAL_CORE`, which slows the preview).
2749

2850
Notes from bring-up: capture JPEG rather than RGB565 (continuous RGB565
2951
capture competes with inference for PSRAM bandwidth), keep the JPEG frame
3052
buffers in internal RAM, and do not use the camera driver's PSRAM DMA mode.
53+
Keep XCLK at 10 MHz (the default): at 20 MHz the OV3660 started dropping
54+
and corrupting frames after a few minutes, which showed up as a black or
55+
frozen preview and `dec_err` counts in the results.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
# Copyright 2026 Mwaniki Njagi.
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+
# SPDX-License-Identifier: BSD-3-Clause
8+
9+
"""
10+
Quantize a torchvision ImageNet classifier for the camera demo and lower it
11+
to an ExecuTorch program (.pte) with ESP-NN delegates.
12+
13+
Calibrates on an image folder, optionally fine-tunes with quantization-aware
14+
training (distillation from the float model, no labels needed), and reports
15+
top-1 accuracy of the lowered program as the board computes it. Also writes
16+
<output>.ops.txt, the CPU operators to select when building the runtime.
17+
18+
python export_model.py --calib-dir imagenette2-160/train \\
19+
--eval-dir imagenette2-160/val --qat-epochs 5 --qat-lr 1e-3 \\
20+
--output models/mobilenet_v3_small_224.pte
21+
22+
MobileNetV3-Small needs QAT; plain PTQ loses most of its accuracy.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import copy
29+
import random
30+
from pathlib import Path
31+
32+
import torch
33+
import torch.nn.functional as F
34+
from executorch.backends.espressif import EspNNQuantizer, to_espnn_edge
35+
from executorch.backends.espressif.reference import ProgramEmulator
36+
from torchao.quantization.pt2e import (
37+
disable_observer,
38+
move_exported_model_to_eval,
39+
move_exported_model_to_train,
40+
)
41+
from torchao.quantization.pt2e.quantize_pt2e import (
42+
convert_pt2e,
43+
prepare_pt2e,
44+
prepare_qat_pt2e,
45+
)
46+
47+
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
48+
# Imagenette folder names and their ImageNet-1k class indices.
49+
IMAGENETTE_TO_IMAGENET = {
50+
"n01440764": 0,
51+
"n02102040": 217,
52+
"n02979186": 482,
53+
"n03000684": 491,
54+
"n03028079": 497,
55+
"n03394916": 566,
56+
"n03417042": 569,
57+
"n03425413": 571,
58+
"n03445777": 574,
59+
"n03888257": 701,
60+
}
61+
MEAN = (0.485, 0.456, 0.406)
62+
STD = (0.229, 0.224, 0.225)
63+
64+
65+
def load_images(directory: Path, count: int, size: int, seed: int):
66+
from PIL import Image
67+
from torchvision import transforms
68+
69+
transform = transforms.Compose(
70+
[
71+
transforms.Resize(size * 256 // 224),
72+
transforms.CenterCrop(size),
73+
transforms.ToTensor(),
74+
transforms.Normalize(MEAN, STD),
75+
]
76+
)
77+
files = sorted(
78+
p for p in directory.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES
79+
)
80+
if not files:
81+
raise SystemExit(f"no images found under {directory}")
82+
random.Random(seed).shuffle(files)
83+
images, labels = [], []
84+
for path in files[:count]:
85+
images.append(transform(Image.open(path).convert("RGB")))
86+
labels.append(IMAGENETTE_TO_IMAGENET.get(path.parent.name, -1))
87+
return torch.stack(images), labels
88+
89+
90+
def qat_prepare(model, data, args):
91+
batch = min(args.qat_batch, len(data))
92+
exported = torch.export.export(
93+
copy.deepcopy(model),
94+
(data[:2],),
95+
dynamic_shapes=({0: torch.export.Dim("batch", min=2, max=1024)},),
96+
strict=True,
97+
).module()
98+
prepared = prepare_qat_pt2e(exported, EspNNQuantizer(is_qat=True))
99+
parameters = [p for p in prepared.parameters() if p.is_floating_point()]
100+
for parameter in parameters:
101+
parameter.requires_grad_(True)
102+
steps = max(1, round(args.qat_epochs * max(1, len(data) // batch)))
103+
optimizer = torch.optim.SGD(parameters, lr=args.qat_lr, momentum=0.9)
104+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, steps)
105+
generator = torch.Generator().manual_seed(args.seed)
106+
move_exported_model_to_train(prepared)
107+
with torch.no_grad():
108+
for start in range(0, min(len(data), 16 * batch), batch):
109+
prepared(data[start : start + batch])
110+
with torch.enable_grad():
111+
for step in range(steps):
112+
if step == int(steps * 0.6):
113+
prepared.apply(disable_observer)
114+
inputs = data[torch.randint(0, len(data), (batch,), generator=generator)]
115+
if bool(torch.rand(1, generator=generator) < 0.5):
116+
inputs = inputs.flip(3)
117+
with torch.no_grad():
118+
teacher = F.softmax(model(inputs), dim=1)
119+
loss = F.kl_div(
120+
F.log_softmax(prepared(inputs), dim=1), teacher, reduction="batchmean"
121+
)
122+
optimizer.zero_grad()
123+
loss.backward()
124+
optimizer.step()
125+
scheduler.step()
126+
if step % max(1, steps // 10) == 0 or step + 1 == steps:
127+
print(f"QAT step {step + 1}/{steps} loss {loss.item():.4f}")
128+
move_exported_model_to_eval(prepared)
129+
for parameter in parameters:
130+
parameter.requires_grad_(False)
131+
return prepared
132+
133+
134+
def main() -> int:
135+
parser = argparse.ArgumentParser(description=__doc__)
136+
parser.add_argument("--model", default="mobilenet_v3_small")
137+
parser.add_argument("--size", type=int, default=224)
138+
parser.add_argument("--calib-dir", type=Path, required=True)
139+
parser.add_argument("--calib-samples", type=int, default=1024)
140+
parser.add_argument("--eval-dir", type=Path)
141+
parser.add_argument("--eval-samples", type=int, default=100)
142+
parser.add_argument("--qat-epochs", type=float, default=0.0)
143+
parser.add_argument("--qat-lr", type=float, default=1e-3)
144+
parser.add_argument("--qat-batch", type=int, default=16)
145+
parser.add_argument("--seed", type=int, default=0)
146+
parser.add_argument(
147+
"--output", type=Path, default=Path("models/mobilenet_v3_small_224.pte")
148+
)
149+
args = parser.parse_args()
150+
151+
from torchvision import models
152+
153+
model = getattr(models, args.model)(weights="DEFAULT").eval()
154+
calibration, _ = load_images(
155+
args.calib_dir, args.calib_samples, args.size, args.seed
156+
)
157+
example = (calibration[:1],)
158+
159+
if args.qat_epochs > 0:
160+
prepared = qat_prepare(model, calibration, args)
161+
else:
162+
prepared = prepare_pt2e(
163+
torch.export.export(model, example, strict=True).module(),
164+
EspNNQuantizer(),
165+
)
166+
with torch.no_grad():
167+
for image in calibration:
168+
prepared(image.unsqueeze(0))
169+
quantized = convert_pt2e(prepared)
170+
171+
edge, report = to_espnn_edge(torch.export.export(quantized, example, strict=True))
172+
print(
173+
f"{report['partitions']} ESP-NN partition(s), "
174+
f"{report['delegated_ops']} delegated operators, "
175+
f"{len(report['cpu_ops'])} on the CPU"
176+
)
177+
178+
if args.eval_dir is not None:
179+
images, labels = load_images(
180+
args.eval_dir, args.eval_samples, args.size, args.seed + 1
181+
)
182+
emulator = ProgramEmulator(edge.exported_program())
183+
float_correct = device_correct = agree = 0
184+
with torch.no_grad():
185+
for image, label in zip(images, labels):
186+
image = image.unsqueeze(0)
187+
float_top1 = int(model(image).argmax())
188+
device_top1 = int(emulator(image).to(torch.float32).argmax())
189+
float_correct += int(float_top1 == label)
190+
device_correct += int(device_top1 == label)
191+
agree += int(float_top1 == device_top1)
192+
print(
193+
f"top-1 on {len(labels)} images: float {float_correct}, "
194+
f"int8 program {device_correct}, agreement {agree}"
195+
)
196+
197+
program = edge.to_executorch()
198+
plan = program.executorch_program.execution_plan[0]
199+
operators = sorted(
200+
f"{op.name}.{op.overload}"
201+
for op in plan.operators
202+
if not op.name.startswith("quantized_decomposed::")
203+
)
204+
args.output.parent.mkdir(parents=True, exist_ok=True)
205+
args.output.write_bytes(program.buffer)
206+
ops_path = args.output.with_suffix(".ops.txt")
207+
ops_path.write_text(",".join(operators) + "\n")
208+
print(f"wrote {args.output} ({len(program.buffer)} bytes) and {ops_path}")
209+
return 0
210+
211+
212+
if __name__ == "__main__":
213+
raise SystemExit(main())

‎examples/espressif/camera_stream/flash.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ set -euo pipefail
1313
PORT=${1:-${ESPPORT:?usage: flash.sh PORT (or set ESPPORT)}}
1414
cd "$(dirname "$0")"
1515
mkdir -p build
16-
idf.py build
16+
idf.py ${ET_BUILD_DIR:+-DET_BUILD_DIR_PATH="$ET_BUILD_DIR"} build
1717
touch build/.viewer_pause
1818
trap 'rm -f build/.viewer_pause' EXIT
1919
sleep 1

‎examples/espressif/camera_stream/main/CMakeLists.txt‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66
#
77
# SPDX-License-Identifier: BSD-3-Clause
88

9+
get_filename_component(
10+
camera_et_root "${CMAKE_CURRENT_LIST_DIR}/../../../.." ABSOLUTE
11+
)
12+
13+
# The ExecuTorch platform layer is shared with the executor runner example.
14+
# WHOLE_ARCHIVE keeps it over the runtime's weak defaults.
915
idf_component_register(
1016
SRCS
1117
"main.cpp"
1218
"camera_stream.cpp"
19+
"et_model.cpp"
1320
"model_data.S"
21+
"${camera_et_root}/examples/espressif/executor_runner/esp_pal.cpp"
1422
INCLUDE_DIRS
1523
"."
1624
REQUIRES
@@ -20,7 +28,31 @@ idf_component_register(
2028
esp_timer
2129
esp_rom
2230
esp_driver_usb_serial_jtag
31+
WHOLE_ARCHIVE
32+
)
33+
34+
set(ET_BUILD_DIR_PATH
35+
"${camera_et_root}/cmake-out-esp"
36+
CACHE PATH "ExecuTorch cross-compiled for this target and installed"
2337
)
38+
# GLOBAL so that the project-level link step and espnn_runtime see the imported
39+
# targets.
40+
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE)
41+
find_package(
42+
executorch REQUIRED HINTS "${ET_BUILD_DIR_PATH}/lib/cmake/ExecuTorch"
43+
)
44+
target_include_directories(
45+
${COMPONENT_LIB} PRIVATE "${camera_et_root}/.."
46+
"${camera_et_root}/runtime/core/portable_type/c10"
47+
)
48+
target_compile_definitions(
49+
${COMPONENT_LIB} PRIVATE C10_USING_CUSTOM_GENERATED_MACROS
50+
)
51+
set(camera_et_libs executorch executorch_selected_kernels)
52+
if(TARGET quantized_ops_lib)
53+
list(APPEND camera_et_libs quantized_ops_lib)
54+
endif()
55+
target_link_libraries(${COMPONENT_LIB} PRIVATE ${camera_et_libs})
2456

2557
idf_build_get_property(project_dir PROJECT_DIR)
2658
if(IS_ABSOLUTE "${CONFIG_CAMERA_STREAM_MODEL}")
@@ -31,8 +63,8 @@ endif()
3163
if(NOT EXISTS "${model}")
3264
message(
3365
FATAL_ERROR
34-
"Camera model ${model} not found. Compile one with "
35-
"examples/espressif/aot_espnn_compiler.py --mode standalone and set "
66+
"Camera model ${model} not found. Export one with "
67+
"examples/espressif/camera_stream/export_model.py and set "
3668
"CONFIG_CAMERA_STREAM_MODEL."
3769
)
3870
endif()

‎examples/espressif/camera_stream/main/Kconfig.projbuild‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
menu "Camera inference stream"
1010

1111
config CAMERA_STREAM_MODEL
12-
string "Model to embed (XTS3 graph or XTB1 bundle; absolute or relative to this project)"
13-
default "models/mobilenet_v3_small_224.xtb"
12+
string "ExecuTorch program (.pte) to embed; absolute or relative to this project"
13+
default "models/mobilenet_v3_small_224.pte"
1414

1515

1616
choice CAMERA_STREAM_PINOUT
@@ -38,17 +38,14 @@ menu "Camera inference stream"
3838
default 12
3939
depends on CAMERA_STREAM_SENSOR_JPEG
4040

41-
config CAMERA_STREAM_DUAL_CORE
42-
bool "Split ESP-NN kernels across both cores"
43-
default n
44-
help
45-
Faster inference, but the ESP-NN worker on core 0 competes with
46-
the JPEG preview task, so the preview frame rate drops.
47-
4841
config CAMERA_STREAM_XCLK_MHZ
4942
int "Camera XCLK frequency in MHz"
5043
range 6 24
51-
default 20
44+
default 10
45+
help
46+
At 20 MHz an OV3660 on the ESP32-S3 CAM boards drops and corrupts
47+
frames after a while (NO-SOI, frame timeouts, dark frames).
48+
10 MHz is stable and still gives a 12 fps preview.
5249

5350
config CAMERA_STREAM_PREVIEW
5451
bool "Stream JPEG preview frames alongside the results"

0 commit comments

Comments
 (0)