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
| 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.
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
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.
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-nnhas hand-written S3 assembly for exactly these operations. Rewriting the int8 branch ofconv.cppto 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.cpptook it to 3.5 s. esp_nn.his a dispatch header, and every optimised kernel in it is gated onCONFIG_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.