Kernwerk

A wildfire detector on a board smaller than a one-euro coin

A camera watching a hillside for wildfire sees no fire almost every second of its life. That is the job working exactly as intended, and it is also the problem: the machine that would find the smoke is powered for all of it, drawing its watts on an empty frame in the dark at four in the morning.

So I built the same job twice. Once the way it is normally built, YOLOv5s on a Jetson Orin Nano, and once on a €27 microcontroller smaller than a one-euro coin.

The question was not whether a smaller chip could run a smaller model. It was how much of the job survives the trip down, and what it costs in engineering to get there at all.

The two boards

Two boards drawn to the same scale: a Jetson Orin Nano dev kit at 100 by 79 mm beside a XIAO ESP32-S3 Sense at 21 by 17.5 mm and a one-euro coin at 23.25 mm across. Twenty-one of the small boards fit in the footprint of one dev kit.
Both boards, to scale. The coin is there because the numbers stop meaning anything at this size.
Jetson Orin Nano XIAO ESP32-S3 Sense
board 100 × 79 mm, $249 21 × 17.5 mm, €27
compute 1024-core Ampere GPU, 6-core Arm CPU 240 MHz dual-core Xtensa LX7
software Linux no operating system, no GPU
memory 8 GB 8 MB PSRAM, 320 KB internal
storage microSD or NVMe 8 MB flash
power 10.2 W measured not measured, see below

Twenty-one of the small boards fit inside the outline of the dev kit. The footprint is the least of it. The memory gap is a factor of a thousand, and there is no operating system underneath to manage what little there is.

This decides the project before any model is chosen. The YOLOv5s checkpoint is 14.4 MB. The ESP32 has 8 MB of flash in total, so the file does not fit on the board at all, with nothing else on it. Running it needed 447 MB of working memory on the Jetson, which is 56 times every byte of RAM the small board has.

There is no porting your way across that. You do not shrink a model by a factor of fifty-six, you write a different one.

The two models

The two models do different jobs. That is deliberate, and it is where most of the saving comes from.

YOLOv5s is a detector. It answers where the smoke is, drawing boxes with class labels and confidences, which means anchors, a detection head at three scales, and non-maximum suppression to clean up afterwards. The ESP32 model is a classifier. It answers whether there is smoke, as a single number between 0 and 1, and that is the entire output. No boxes, no anchors, no NMS.

YOLOv5s the ESP32 model
answers where the smoke is whether there is any
output boxes, classes, confidences one number
parameters 7.03 M 133,889
on disk 14.4 MB 185 KB
input 640 × 640 × 3 96 × 96 × 1
working set 447 MB 322 KB tensor arena
latency 22.6 ms on the Orin 340.3 ms on the ESP32
scored on mAP50, 0.771 recall and false alarm rate

The YOLOv5s numbers are not mine. They are the D-Fire authors’ own published weights, evaluated at 640 px without retraining, which is the right baseline for a compression question and not a clean architecture comparison: their training recipe is unknown.

A new model, not a trimmed one

Taking MobileNet off the shelf and cutting it down was the wrong move: the stock model carries a 1000-way classifier and a stem tuned for 224 px, and trimming that to one output at 96 px leaves something less predictable than writing the blocks directly. So it is six blocks, hand-written, one 3×3 convolution and five depthwise-separable ones, average pooling, a single linear output. 133,889 parameters, 30 epochs of AdamW on D-Fire with a binary label.

The real constraint was not elegance. Every operation had to have an int8 kernel in TensorFlow Lite Micro, because an architecture that lowers to an unsupported op is worth nothing on this target, however good it looks in a paper.

96 pixels, and no colour

  • 640 px down to 96 px. An earlier sweep with a full YOLOv5s scored 0.165 mAP50 at 160 px on the faintest smoke, the frames where it covers under 0.1% of the picture. That is close enough to nothing, and it made 96 px look hopeless.
  • It was not, because the task changed at the same time. Detection fails at low resolution: the box cannot be localised once the smoke is a handful of pixels. Classification survives, because a smoky frame is still globally smoky.
  • Colour down to one channel. The camera is mono-capable and smoke is grey by definition, so grayscale costs nothing and the first convolution takes one input instead of three.
  • Together: 1,228,800 values in, down to 9,216. That is what makes a 322 KB tensor arena possible at all.
Horizontal bar chart of how many times smaller the ESP32 build is than the Jetson build: board footprint 21 times, parameters 52 times, model on disk 78 times, input tensor 133 times.
Four reductions, on one scale. The board is the smallest of them.

The microcontroller is fifteen times slower per frame, and it does not matter. A fire watch camera does not need 44 frames per second. At one frame every five seconds, 340 ms of work is a 6.8% duty cycle: the board does nothing at all for the other 93%.

What it does

Eight D-Fire test frames from one watch camera, each shown at 96 by 96 grayscale beside the score the int8 model returns for it and the resulting decision. Three empty frames score zero. A frame with smoke scores 0.99. The next scores 0.54 and is missed. The last three score 1.00.
Real test frames, real int8 scores, read off the board. Checked against the off-device model, none of the eight decisions changed.

There is exactly one column on which the two builds can be compared directly, because it is the only question both answer the same way: given a frame with nothing in it, does the system raise an alarm? On the same 2,005 empty frames of the D-Fire test split, YOLOv5s stays quiet on 96.9% of them and the ESP32 on 95.0%.

Two points apart, on a model 78 times smaller.

No other column belongs side by side. mAP50 demands a localised box and recall only demands that the model notice, so quoting one against the other would flatter the small board with a category error.

On its own terms, at the threshold it ships at, the small model catches 78.6% of frames containing fire or smoke and 82.8% of frames where the smoke covers under 1% of the picture, at a 5.0% false alarm rate. Loosen the threshold to 0.30 and it catches 94.4% of positives and 96.1% of that same faint-smoke slice, at a 14% false alarm rate. Everything about how this behaves in the field is set by where you put that threshold.

Making it run

The first working build ran at 20.5 seconds per frame. That is not a detector, that is a slideshow.

Four builds of the same model on the same board, on a linear millisecond scale: stock TFLite Micro at 20,533 ms, plus ESP-NN for convolution at 4,504 ms, plus ESP-NN for depthwise convolution at 3,487 ms, plus CONFIG_NN_OPTIMIZED at 341 ms.
Four builds of one model on one board. The last step is a single define in a header, and it is worth a factor of ten.

Sixty times faster, and not one weight changed.

  • TensorFlow Lite Micro ships portable C kernels. They run on any chip and use none of the S3’s vector unit, which is the entire reason this processor can do the job. The community Arduino port ships them unchanged.
  • Espressif’s esp-nn has hand-written S3 assembly for exactly these operations. Rewriting the int8 branch of conv.cpp to translate TFLite’s shapes and quantization parameters into ESP-NN’s structs took it to 4.5 s.
  • This model is eleven convolutions, five of them depthwise, sitting at the widest activations. The same treatment for depthwise_conv.cpp took it to 3.5 s.
  • esp_nn.h is a dispatch header, and every optimised kernel in it is gated on CONFIG_NN_OPTIMIZED. Without that define, all the work above routes back into plain C. 3.5 s to 341 ms.

The fourth step is the one worth keeping, because nothing about it was visible from the outside. Both configurations compiled, linked, and produced identical scores. The clue was a rebuild that changed the binary and left the runtime at 3486.77 ms, identical to five decimal places. Nothing had changed about which code ran. It was found with nm, not with a stopwatch: nm -u conv.cpp.o showed esp_nn_conv_s8_ansi where it should have shown esp_nn_conv_s8_esp32s3. That is the shape of most compilation work at this tier. The fast path exists, you are not on it, and the build tells you nothing.

And then fixing what the speed-up broke

The board started boot-looping with CORRUPT HEAP. The S3 kernels load 128 bits at a time and need their scratch buffer 16-byte aligned; heap_caps_malloc promises 8. The kernel rounded the base address down by eight bytes, landing precisely on the allocator’s block header. The bug had been latent through every earlier run, because the reference kernels ignore alignment entirely.

One more would have shipped. ESP-NN grows its scratch buffer lazily, per layer, while the first inference is running, so frame 0 is computed under conditions no later frame sees. It returned 0.9997 for an empty scene: a maximally confident false alarm, in the least forgivable direction for a fire watch, appearing in the field as exactly one inexplicable alarm per power cycle. A discarded warm-up inference at boot fixes it. Both bugs say the same thing: when something gets sixty times faster, go back and check it still works.

Quantization, for once, cost nothing: float32 reaches the same operating point at threshold 0.99 and int8 arrives there at 0.97. The score distribution shifted instead of scrambling, and a threshold can absorb a shift.

Power and energy consumption

The Jetson was measured at 10.2 W mean while running YOLOv5s at 640 px, and 0.23 joules per frame. Those are real numbers off a meter. An Orin that is merely powered on, waiting for something to happen, is still drawing watts.

The ESP32 side has not been measured, and I am not going to quote a datasheet figure as though it were a result. The board has no shunt and cannot read its own supply, so a second firmware build holds the chip in each state for thirty seconds, idle, asleep and inferring, long enough for a meter on the supply to settle. I ran it without a meter and it still produced one result, because the same sweep times the model at three clock rates:

clock ms per inference against pure clock scaling
240 MHz 340.9
160 MHz 463.7 1.36×, not 1.5×
80 MHz 840.0 2.46×, not 3×

Latency does not scale with clock. Fit those three points and 90 ms of every inference sits outside the clock entirely, which is 26% of the total, almost certainly spent waiting on the PSRAM the tensor arena lives in. Clocking down costs less time than it should, which is the condition under which a slower clock can be cheaper per inference. Whether it actually is needs the current.

Until a meter says otherwise, “a €27 part can watch continuously for a fraction of the energy” is an argument, not a measurement, and it is the single biggest gap in this work.

The boundary

  • Power is not measured. Until it is, everything above compares size and accuracy, and says nothing about energy.
  • D-Fire’s negatives are rooms, streets and landscapes. A fire watch stares at a fixed frame of sky, where cloud, fog, haze and sunset all look like smoke to a 96 px grayscale model. The 5% false alarm rate is measured on a distribution this sensor will never see, and that is the largest open risk to the idea.
  • On-device agreement rests on eight frames. That proves the port and nothing more; the accuracy came from 4,306 frames off-device.
  • This is a confirmation sensor for obvious fire and near smoke. The Orin remains the instrument that sees a plume early, and the small board only changes what has to be powered while it waits.

Provenance

  • The data is D-Fire, 17,221 training and 4,306 test frames, from Venâncio, Lisboa and Barbosa, An automatic fire detection system based on deep convolutional neural networks for low-power, resource-constrained devices, Neural Computing and Applications, 2022. The YOLOv5s baseline is that paper’s published checkpoint, run at 640 px without retraining, so every Jetson number here measures their model rather than one I trained.
  • YOLOv5 is AGPL-3.0. Fine for research and for a blog post, and a real constraint on a product: shipping it commercially needs a licence from Ultralytics or a different architecture.
  • The 133,889-parameter model on the ESP32 is written from scratch for this, six blocks of plain convolutions, and carries none of that. A side effect of the exercise, but worth knowing if you are costing a deployment.
  • Board measurements come from the XP15 firmware; the Jetson ones from XP2 on an Orin Nano Super 8 GB, JetPack R36.4.3, CUDA 12.6, TensorRT 10.3.

What I would measure next

The cheapest experiment left answers the biggest question. Point the board at a window for a day, score those frames with the checkpoint that already exists, and find out whether 5% survives contact with clouds. No training, no ML firmware, no meter. The board is used as a camera.

That is the work we do: taking a model down to the tier you actually ship on, and being honest about what it can decide once it gets there. Building something that has to watch continuously on a budget? Talk to us.