Finding the Queen Bee on a Budget Tablet: A Small YOLO, a Distractor Class and a Voting Trick
How we built an offline queen detector for a beehive app: why a tiny model was the right call, why the “drone” class exists only to be ignored, and how a 4-out-of-7 vote turns a mediocre detector into a stable signal.
Finding the queen in a hive is one of the slowest parts of an inspection. She is one bee among 20,000 to 60,000, she moves, and she hides under other bees. For a beginner it can take many minutes. And every extra minute with the hive open stresses the colony.
For BeeSafe, a connected-hive project at ESIEA, I built a module that flags the queen from the camera feed, directly on the device, with no network. This article walks through the design in the order I made the decisions. If you’re deploying a detector on cheap hardware, most of the lessons apply beyond bees.
Chapter 1: Design the Task Before the Model
1.1 Two classes, but only one is a target
The model has two classes:
- queen: the only thing ever shown to the user.
- drone (male bee): a distractor class. It is never reported.
Workers are simply background.
Why / How. Drones are big, with large eyes, and look a lot like a queen to a small model. If drones are just “background”, the model learns that “big bee-shaped thing” can be background or queen, and confuses the two. Giving drones their own class forces the model to learn the difference. We keep the class in training purely so the network can say “this is a drone, not a queen”. The app throws the drone answer away.
Tip. A distractor class is cheap insurance when your target has a close look-alike. You pay with some annotation effort, and you get fewer false alarms.
Chapter 2: Data Preparation
The main data came from Roboflow Universe (the Bee Queen Scaner dataset, about 7,300 images), complemented by a second bee-detection dataset. Two things had to be fixed before any training:
- Unify the formats. Some labels were segmentation masks. We converted each mask to a tight bounding box.
- Remove loose annotations. Some “queen boxes” covered a large part of the image. They were actually a cluster of bees mislabeled as a queen, and they capped the precision of the model.
The second one is worth a sketch. This is the idea (not our exact script):
def tight_box(mask): # mask: HxW boolean array
ys, xs = mask.nonzero()
return xs.min(), ys.min(), xs.max(), ys.max()def is_loose(box, img_w, img_h, max_frac=0.5):
x0, y0, x1, y1 = box
return (x1 - x0) / img_w > max_frac or (y1 - y0) / img_h > max_fracA queen is a few centimeters long. A “queen” that spans half the photo is not a queen.
Note. The same idea shows up again at inference time as a full-frame filter (Chapter 6), because some of those bad boxes can survive the cleaning.
Chapter 3: Training
We used YOLO11s from Ultralytics:
from ultralytics import YOLOmodel = YOLO("yolo11s.pt")
model.train(
data="queen_drone.yaml", # two classes: queen, drone
imgsz=768,
epochs=150,
patience=60, # early stopping
# moderate augmentation: mosaic, mixup, HSV jitter, small rotations
)Trained at 768 px, then exported and run at 640 px (more on that in Chapter 8).
Why a small model. The target is a low-end Android tablet (Unisoc T606, no neural accelerator). A heavier architecture means thermal throttling and a battery that drains in no time. The model is the one that fits the hardware, not the one with the best leaderboard number.
Chapter 4: Reading the Results
Here is the confusion matrix on the test set, for the deployed model.
Rows are predictions, columns are ground truth. The “background” row holds missed instances, the “background” column holds false detections.
From the counts:
Per-class mAP50 at 640 px is 0.839 for the queen and 0.469 for the drone.
How we got there. Queen precision is 239 / (239 + 21) = 0.92, queen recall is 239 / 301 = 0.79. For drones, 17 / (17 + 14) = 0.55 and 17 / 52 = 0.33. The 62 missed queens and 35 missed drones sit in the background row.
The number that matters is not on the diagonal. Look at the cells linking queen and drone: they are empty. Among 301 queens and 52 drones in the test set, not one was labeled as the other. That is the whole point of keeping the distractor class, and the matrix proves it did its job.
The remaining error is missed detections, not confusions. That fact drives the next two decisions.
The drone recall is low, and for the product it doesn’t matter, since drones are never reported. It limits how well the model discriminates, nothing more.
Chapter 5: The Threshold Is a Product Decision
The confidence threshold for the queen is not an implementation detail. It decides between:
- missing a queen that is present, and
- reporting a queen that is absent.
We swept the threshold during development. The F1 curve is very flat between 0.20 and 0.45. That shape is what justified the choice. On such a plateau, you can move to the recall side at no measurable F1 cost. We set the queen threshold at 0.25, and the drone threshold at 0.40.
Why recall. A false alarm is filtered out later (Chapter 7). A queen that is never detected is lost for the user for good.
Note. Be careful with where the sweep came from. We ran it on a development model (a 3-class YOLO11x), not on the deployed 2-class YOLO11s. So we used it for the shape of the curve and not for its values. The deployed model’s real numbers are the ones in Chapter 4.
Chapter 6: Post-Processing That Encodes What We Know
Raw YOLO outputs go through four rules built around how hives actually are:
- Per-class confidence thresholds: queen 0.25, drone 0.40.
- Non-maximum suppression at IoU 0.45.
- Full-frame filter: reject any box whose width or height exceeds 90% of the image. This is the leftover of the loose-annotation problem from Chapter 2.
- Single target: a hive normally has one queen, so we keep only the highest-confidence queen candidate.
def postprocess(dets):
dets = [d for d in dets if d.conf >= (0.25 if d.cls == "queen" else 0.40)]
dets = nms(dets, iou=0.45)
dets = [d for d in dets if d.w < 0.9 * W and d.h < 0.9 * H]
queens = [d for d in dets if d.cls == "queen"]
return max(queens, key=lambda d: d.conf) if queens else NoneNote. The model’s ONNX graph has the raw output
[1, 6, 8400](4 box coordinates + 2 class scores, over 8,400 candidate boxes) with NMS not baked in. You implement steps 1 to 4 yourself, in the app.
Chapter 7: The Voting Trick
This is the part that turned the module into something usable.
Get Mohamed Amine Barhoumi’s stories in your inbox
Join Medium for free to get updates from this writer.
A single-frame detector with 0.79 recall and a few false alarms makes a flickering display. The queen “appears” and “disappears”. So we added a k-of-N temporal vote: the queen is confirmed only if she’s detected in at least 4 of the last 7 processed frames.
from collections import dequehistory = deque(maxlen=7)def confirmed(frame_has_queen: bool) -> bool:
history.append(frame_has_queen)
return sum(history) >= 4
Why it works. A queen who is really there stays in view for several frames in a row. A false alarm is almost always a one-frame event.
A toy calculation shows the effect. Assume frames are independent (a strong assumption, so treat the result as optimistic):
In real life frames are correlated. If the queen is hidden behind a worker, she is hidden for all seven frames, and a hard-to-see queen stays hard. That’s still where the module is limited, and we say so in Chapter 9. But the vote converts an average detector into a stable signal, and that is what the beekeeper sees.
Tip. The voting trick is the reason a small model was acceptable. It exploits temporal redundancy, which you get for free from a video feed, to buy back accuracy you can’t afford in the network.
Chapter 8: Running on the Device
8.1 Export and runtime
We export to ONNX and run it with ONNX Runtime inside a React Native (Expo) app. The camera preview comes from expo-camera.
model.export(format="onnx", imgsz=640)
# input : 1 x 3 x 640 x 640
# output : 1 x 6 x 8400The key design choice is the decoupling of display rate and inference rate. The preview stays smooth for the user, while inference runs in a loop on independently captured images, at a much lower rate. That is what lets a low-end CPU handle it, and the vote absorbs the low analysis frequency.
The detection in the app: the queen box (green) and drone boxes (orange). The queen is shown to the user only after the vote confirms her. The drone boxes show what the model outputs for the distractor class. The product only reports the queen.
8.2 Train at 768, run at 640: the honest cost
A direct revalidation of the deployed checkpoint on the test set gives:
So 768 is measurably better, but the gap is modest. 640 cuts compute by (768/640)² ≈ 1.44×, which is what we needed to hold the frame rate on that SoC. The loss is partly absorbed by the vote. A compromise we chose, not a free lunch.
8.3 Shrinking the model: fp16
We converted the ONNX models to fp16 (with keep_io_types=True, so the inputs and outputs stay float32 and the JavaScript code doesn't change). On a separate labeled validation set of 757 frames (not the test set of Chapter 4, so the absolute values aren't comparable), we measured:
Essentially the same. For the whole app, fp16 models were one of three size levers that took the APK from 265 MB to 95 MB.
Gotcha. A plain fp16 conversion of the YOLO graph produced an invalid model (a type mismatch on the
Resizeop). The fix was to excludeResizeandUpsamplefrom conversion and re-run shape inference before saving:
import onnx
from onnxconverter_common import float16model_fp16 = float16.convert_float_to_float16(
model, keep_io_types=True,
op_block_list=float16.DEFAULT_OP_BLOCK_LIST + ["Resize", "Upsample"],
)
del model_fp16.graph.value_info[:] # drop stale shape info
model_fp16 = onnx.shape_inference.infer_shapes(model_fp16)8.4 A silent packaging trap
This one cost us a wrong build. After swapping a .onnx file in assets/, Gradle's JS-and-assets bundling task did not notice the content change. It reported BUILD SUCCESSFUL and quietly shipped the old model, at the old size.
What we do now:
- Delete the generated bundle directory after any asset change.
- Verify the model inside the APK by its SHA-256, not by the size of the file.
A green build only proves that the build ran. It doesn’t prove it shipped what you think it shipped.
Chapter 9: Limits and How to Read Our Numbers
- Hardware. No high-resolution real-time inference on this tablet. The vote and the rate decoupling compensate.
- Small queens in the image remain hard. The best lead is to collect dedicated sequences, not to change the architecture.
- Drone data is scarce, hence the weak recall for that class. It doesn’t affect the product but it limits discrimination.
- About the test numbers. They come from a held-out split of the merged datasets. I did not verify that this split is free of near-duplicate frames from the same video or scene, which would make the numbers optimistic. Treat them as an upper bound on field performance. This is why the temporal vote and a recall-oriented threshold matter so much.
What I Would Keep If I Started Over
- Give look-alikes their own class, even if you never show it.
- Clean your annotations and add an inference-time filter for the failure modes you remember from the data.
- Treat the threshold as a product decision, and choose it from the shape of the curve.
- Use time. If your input is a video stream, a vote over a few frames is the cheapest accuracy there is.
- Verify what you ship, not what you built.
References
- Ultralytics YOLO11: https://docs.ultralytics.com/
- Roboflow Universe, Bee Queen Scaner dataset
- ONNX Runtime: https://onnxruntime.ai/
BeeSafe is a connected-hive project developed at ESIEA. If you have questions about any of the steps, leave a comment.
YOLO, Object Detection, Edge AI, ONNX, React Native





