An object-detection model on a Hailo-8 accelerator that decides whether a camera's tripwire alarm deserves a human's attention, before an email reaches the monitoring agents.
The camera is not lying. It is answering an easier question than the one we care about.
A Dahua camera with IVS rules reports CrossLineDetection whenever
something crosses a tripwire. It reports it for shadows sweeping across pavement,
for headlights at night, for rain, for branches moving in wind. Every one of those
becomes an email, and every email costs a monitoring agent the seconds it takes to
open it, look, and dismiss it. Enough of them and the alerts stop being read.
The obvious fix is to run object detection on the alarm snapshot: if there is no person in the frame, drop it. That removes the shadows and the rain. It does not remove the most common false positive, and that is the whole reason this project has the shape it does.
Presence and crossing are different questions, and only one of them fits in a photograph.
So the field agent does not send a snapshot. It sends a window: about two seconds before the event and two after, sampled at 4 fps. The office side detects objects in every frame, links them into tracks, and tests whether a track's path actually intersects the rule geometry.
Neither end can accept inbound connections, so both dial out.
cloudflared gives the server an HTTPS URL; the agent
posts to it. If the link drops, packages queue on disk and retry.MQTT is the right tool for telemetry — trailer alive, camera down, temperature — and it will likely carry exactly that later. It is the wrong tool for a megabyte of JPEGs per event: large payloads work against the protocol, and it means operating a broker for something HTTP already does.
Object storage as the primary route (upload to R2, have the office poll) adds a hop and its latency to a path whose whole purpose is getting an operator's attention quickly. The failure it protects against — office unreachable — is covered more cheaply by a disk-backed retry queue on the trailer. Storage remains attractive later as an archive, not as transport.
What the office needs in order to rule on a crossing.
event.json rule geometry, timestamps, what the camera itself claimed
frames/0000.jpg analysis window, ~4 fps, 640 px long side, q80
hd.jpg full-quality frame, for the operator's email
| Contents | What it is for |
|---|---|
| 12–16 frames at 4 fps | detect the object and reconstruct its path |
| Tripwire geometry (8192 space) | something to test the path against |
| The camera's own claim | compare both filters; audit later |
| Per-frame timestamps | order the trajectory |
| One full-resolution frame | what the agent actually looks at |
Analysis frames are downscaled deliberately. The network resizes everything to 640×640 regardless, so shipping 4K over a metered link buys pixels that are thrown away before inference. Only the operator's frame keeps full quality. Rule geometry travels in Dahua's fixed 8192×8192 space, exactly as the camera config reports it, so it stays valid at any stream resolution.
Each answers a different question. Every one of these exists because a real event exposed the need for it.
confirmed
A track of a person or vehicle crosses the rule. The email goes out.
rejected — "object present but its path never crossed the rule"
Someone walked beside the tripwire. The camera fired; we do not. This is the verdict that a snapshot-only filter cannot produce, and the reason for the frame window.
rejected — "object present but stationary (parked or idle)"
Detection boxes wobble a few pixels between frames even on a parked car. When a rule line runs underneath that car, the wobble alone can walk its ground point across the line and a plain intersection test calls that a crossing. An object must travel more than 15 px before we ask whether it crossed — a flat figure, chosen because measured wobble on stationary vehicles ran 0.1–8.9 px regardless of their size. An earlier version scaled the threshold to the object's width and had to be withdrawn: it demanded far too much of large vehicles and could dismiss a slow arrival as parked.
rejected — "no object of interest held across frames"
An object must appear in at least two frames to count: a single-frame confidence spike is exactly what a reflection produces.
rejected — "camera reported a X where we detected nothing"
The most important verdict, and the last one added. Reporting "the object was parked" when the thing we tracked is not the thing the camera flagged hides our own misses behind a statement about something else. This separates "we looked, and it was not doing what the camera said" from "we never saw it at all" — and only the second kind points at a failure on our side.
Crossing is tested at the object's ground point — the bottom-centre of its box, where feet meet the floor. Using the box centre would register a tall person as having crossed while they are still on the near side.
The most expensive lesson of the first morning, and the least intuitive.
The filter was missing real people. A camera reported a person crossing, we detected nothing, and the evidence photo looked empty — so the verdict was recorded as the camera hallucinating. It was not. Someone who knew the site looked at the same picture and saw the person immediately.
The obvious response is to send higher-resolution frames. It does nothing. The detector resizes whatever it receives to 640×640 before inference, so a 1080p frame and a 480p frame arrive at the model identically, and a distant person shrinks back to a handful of pixels either way. Measured on the same scene, the substream at 640, the substream at native size and the main stream at native size all returned exactly the same detections.
What works is cropping instead of scaling. The event tells us where the camera saw something; the agent cuts that region out of the main stream at native scale and ships it alongside the frames. The crop is small in bytes precisely because it is small in area, and the person inside it is no longer 45 pixels tall but around 125.
The result
The same event that had been rejected came back confirmed, person at 0.90, tracked across 19 of 21 frames — with 62 of those detections coming from the crops and none from the full frames. The full-frame pass never saw her at all.
One consequence worth planning for: raw frames at 1080p cost about 6 MB each, and an eight-second buffer per camera became 750 MB. Four agents exhausted the machine's paging file. The buffer now retains 5 fps instead of 15 — every frame is still read so the decoder stays current, but only the ones we would actually ship are kept. Four cameras now run in 1.5 GB and under 6% CPU.
Six real events from the first morning: what the trailer sent, what the office concluded, and whether it was right. Amber is what the camera claimed; green is what the Hailo found; red is the rule.
Rule1 24/7 ·
camera claimed Vehicleconfirmed — car at 0.94, tracked
18/18 frames, 369 msThis entry originally appeared here as a false positive, and that verdict-about-the-verdict was wrong. The author looked at this single frame, saw a car sitting still, and concluded the system had been fooled by box wobble. The site operator knew the vehicles had arrived at that hour: the car was moving through the window and came to rest by the end of it. One frame cannot show motion — which is the same lesson the whole system is built on, applied to the person reading the evidence rather than the machine.
The rule geometry is still wrong here: eight vertices where the last two double back, producing the red segments that cut across the frame. That remains worth fixing on the camera.
Rule2 24/7 ·
camera claimed Vehicleconfirmed — truck at 0.90,
tracked 18/18 framesSeeing the same pattern twice felt like confirmation of a defect. It was
confirmation of a habit: reading motion out of a still image. Acting on that
reading introduced a real bug — a rule that an object must travel more than
30% of its own width before the crossing test runs. That sounds
principled and is not: box wobble does not scale with vehicle size, so the
rule demanded 76 px of a car and 15 px of a person, and a vehicle easing into
a space could be dismissed as parked. One archived rejection with 24 px of
travel and a Vehicle report from the camera looks like exactly
that failure.
Measured across real events, wobble on genuinely stationary vehicles ran between 0.1 and 8.9 px regardless of their size. The threshold is now a flat 15 px, which clears the wobble with margin and stops punishing slow arrivals.
Rule1 24/7 ·
camera claimed Human at
[160, 256 → 4608, 5824]rejected — no object of interest
held across frames; nothing glimpsed at allThe camera's own box gives it away: in the 8192-unit rule space it spans more than half the frame. Whatever it reacted to was the size of a building, not a person — mid-morning tree shadows sweeping across the lawn. This is the everyday false positive the whole project exists to absorb.
Human at [5688, 1488 → 6088, 3488]rejected — camera reported a
Human where we detected nothing; the objects we did see (car, truck)
moved 4.2 pxThe verdict was written off as the camera hallucinating — the author looked at this washed-out image and saw an empty pavement. The site operator looked at the same picture and saw the person immediately. In the substream she is roughly 45×40 pixels, and the detector shrinks every frame to 640×640 before inference, so she all but disappears.
This single case drove the largest change in the system: the agent now crops the region the camera points at, at native scale, and sends it alongside the frames. Note also the 4.2 px figure — that is the jitter from examples 1 and 2, measured.
Rule2 ·
camera claimed Humanrejected — nothing of interest
detected there; the truck in view moved 0.1 pxUnlike example 3, the camera is not imagining things — the amber box sits squarely on a real object. It is simply not a person: against the utility cabinet beside it, the shape is about 30 cm. This is why the verdict wording matters. "We detected nothing there" is a claim that can be checked against a picture; "the camera was wrong" is a conclusion that needs one.
Humanconfirmed — person at 0.90,
tracked 17/17 frames, travelled 106 px, 59 detections from the
cropsThe same camera and the same kind of subject as example 4, now resolved correctly. The decisive number is 59: that many detections came from the cropped regions, and the full-frame pass contributed none. Without the crops this event would have been another silent miss.
The amber box sitting slightly below the green one is the camera's own offset, not ours — visible only once both opinions are drawn on the same picture.
What the examples have in common
Of the six, the filter got five right. The single genuine failure was example 4 — a real person we never detected — and it was found only because someone who knew the site contradicted the recorded verdict.
The more uncomfortable pattern is in examples 1 and 2. Both verdicts were correct; the analysis of them was not. Judging motion from a still frame produced a confident diagnosis, and that diagnosis produced a threshold that could reject genuine slow arrivals. A wrong explanation for a right answer is still expensive, because it gets built on.
Every correction on this page came from someone looking at a picture, not from reasoning about numbers. That is why evidence images carry both opinions, why rejections are archived beside confirmations, and why the raw event packages are now kept: so a disputed verdict can be re-examined instead of argued about.
Every case where the camera reported a person and we detected nothing there. This is the category that hides false negatives, so none of these should be assumed correct until someone who knows the site has looked.
Of 60 events processed, 31 were rejected. Most of those rejections are straightforward — someone walked near a line without crossing it. These nine are different: the camera insisted a person was at a specific place and our detector found nothing in it. That is exactly the shape of the one confirmed miss of the day, so the whole group is suspect.
Each entry shows a zoom of the exact region the camera pointed at, because at full-frame scale these subjects are a few dozen pixels and impossible to judge — the mistake that started all of this. The full frame is beside it for context. Two cases are already settled and are included first, as calibration for the rest.
Camera said Human; we tracked only a car and a truck, moving
4.2 px. Recorded as a camera error. Wrong — the site operator saw the
person straight away. This case drove the switch to native-scale crops.
Camera said Human; the only object we tracked was a truck
that moved 0.1 px. Confirmed by the operator as a bird or wind-blown
litter — against the utility cabinet beside it the shape is about 30 cm.
Rejection correct.
Camera said Human. We tracked a truck,
displacement 1.8 px.
Camera said Human. We tracked a truck,
displacement 8.9 px.
Camera said Human. We tracked a truck,
displacement 2.9 px. One minute after Reference A, same rule — if that one
had a person, this one deserves a hard look.
Camera said Human. We tracked a car and a
truck, displacement 8.9 px.
Camera said Human. We tracked a car,
displacement 2.2 px. First case after the move to yolov8x and
native-scale crops — if there is a person here, the fix is incomplete.
Camera said Human. We tracked a car, a truck
and a person — but none of them where the camera was pointing. Two
people in the scene is the likeliest reading.
Camera said Human. We tracked a car and a
truck, displacement 3.4 px.
How to read this queue
The displacement figures are a hint, not an answer: they describe the objects we did track — parked vehicles, mostly — not the thing the camera flagged. A reading of 2 px means the truck in view sat still, and says nothing about whether a person walked past it.
What matters is the zoom. If a person is visible in it, the rejection is a miss and the detector needs more help at that scale. If it holds a bird, a shadow or a bag, the rejection was right and the camera is the one at fault.
A wider sample is now published for review
The nine cases below were the first pass. A broader sample of 85 rejected events — spread across every category and every day — is now available as a separate page built for fast manual review: Rejection audit →
It includes all 23 of the "camera saw something we did not" cases, plus 34 that never crossed the rule, 16 with nothing detected and 12 stationary. Each carries a zoomed inset of exactly what the camera pointed at, because at full-frame scale these subjects are too small to judge.
Everything below was discovered the hard way and is written down so it is discovered only once.
| Constraint | Consequence |
|---|---|
| HailoRT 4.15.0 ships wheels for cp38/39/310 only | Python 3.10 is mandatory; 3.13 cannot talk to the chip |
| Runtime 4.15 pairs with Model Zoo v2.9.0 / DFC 3.25.0 | HEFs from other releases will not load at all |
hailort pins numpy 1.23.3; netifaces has no cp310 wheel |
install with --no-deps; netifaces only finds Hailos over Ethernet, and this one is PCIe |
| v2.9.0 YOLOv8 HEFs carry no on-chip NMS | raw head exposed; DFL decode, sigmoid and NMS written by hand |
| The chip serves one process at a time | the server must own it exclusively and serialize inferences |
| Dahua firmware speaks legacy TLS | OpenSSL 3 refuses it by default; cameras look dead while answering |
The failure mode worth remembering
Error 74, HAILO_OUT_OF_PHYSICAL_DEVICES, means another process
already holds the accelerator. It appears whenever a previous server is left
running — including one orphaned by a closed SSH session. Kill the old process
before starting a new one.
| Metric | Value | Note |
|---|---|---|
| Inference, yolov8m @ 640×640 | 17.8 ms | ~56 fps |
| Inference, yolov8l @ 640×640 | 33.2 ms | mAP 52.61 |
| Inference, yolov8x @ 640×640 | 51.9 ms | mAP 53.61 — in use |
| Inference, yolov7e6 @ 1280×1280 | 125.3 ms | mAP 55.37, NMS on-chip — candidate |
| End to end over HTTP, one image | 21.4 ms | including decode and NMS |
| Crossing to email | ~8 s | measured on real crossings |
| Package size, substream only | ~550 KB | before native-scale crops |
| Package size, with crops | ~1.9 MB | the cost of actually seeing people |
| Camera event lag | 0.6–2 s | only measurable after correcting clock skew |
| Four agents, 1080p | 1.5 GB / 6% | RAM and CPU on the field machine |
The verdict logic is covered by a synthetic test that pans a still image to fabricate motion. It asserts both outcomes that matter: a moving object is confirmed as crossing, and a stationary one beside the line is rejected with the "never crossed" reason. Both pass.
Most of the delay is a deliberate wait, not slowness. Knowing which part is which is what makes it safe to leave alone.
| Stage | Time | Nature |
|---|---|---|
| Person crosses → camera emits the event | 0.6–2 s | the camera's own processing; outside our control |
| Event → package built | 3 s | deliberate — waiting for the frames after the crossing |
| Package → verdict | 2 s | upload plus ~40 inferences |
| Verdict → email delivered | 2 s | mostly the TLS handshake and login, repeated every time |
| Crossing → agent has the email | ~8 s | with an annotated snapshot and a stated reason |
The three seconds spent building the package are the price of the whole design. After an event arrives, the agent waits two more seconds so the frames following the crossing land in the buffer. Without them there is no trajectory, and without a trajectory we are back to being unable to tell someone crossing a line from someone walking beside it. Cutting that wait would make the system faster and worse.
Two of the remaining stages are recoverable without touching decision quality: the SMTP session is rebuilt on every send rather than held open, and there is a half-second safety margin after the window that could be trimmed. Together they would bring this closer to five or six seconds. That work is deferred on purpose — eight seconds from a crossing to a verified email with a photograph is comfortably inside what monitoring needs, and the camera's own unverified alerts do not arrive faster.
| File | Role | State |
|---|---|---|
yolo_hailo.py |
YOLOv8 on the Hailo-8: preprocess, infer, DFL decode, NMS, rescale | working |
server.py |
HTTP service, stdlib only. Single image, event window, health | working |
event_filter.py |
Tracking and the geometric crossing test | working |
trailer_agent.py |
Field side: ring buffer, IVS trigger, packaging, retry queue, email | running |
make_event.py, smoke_test.py |
Build packages from clips; exercise the verdict logic offline | working |
| Cloudflare Tunnel | Public HTTPS entry point for the office server | pending |
| Windows service | So the server survives a reboot and a closed session | pending |
| Live viewer | Boxes from both filters drawn together | pending |
Dependencies were held to numpy and opencv on
purpose. After the fight with a missing C compiler, a server that needs nothing
installed is one less thing to break at three in the morning.
336 events, decided and archived. What the numbers say, and what they still cannot tell us.
| Day | Events | Notes |
|---|---|---|
| 12 Aug | 1 | first synthetic test |
| 13 Aug | 97 | first real day; three defects found and fixed |
| 14 Aug | 60 | machine rebooted for exhausted memory |
| 15 Aug | 33 | unattended |
| 16 Aug | 28 | unattended |
| 17 Aug | 108 | server stopped externally; one agent lost to a filename bug |
| 18 Aug | 9 | switched to YOLOX |
| Verdict | Count | Share |
|---|---|---|
| Person crossed — confirmed | 126 | 38% |
| Present, but never crossed the rule | 98 | 29% |
| Nothing of interest held across frames | 37 | 11% |
| Vehicle crossed — confirmed | 24 | 7% |
| Present, but stationary | 24 | 7% |
| Camera saw something we did not | 23 | 7% |
182 of 336 events were rejected — 54%. If every rejection is right, that is 54% fewer emails reaching the monitoring agents. That figure is the headline, and it should be read carefully: it is what the filter decided, not what has been verified.
The largest single rejection category is the one that justifies the whole design. 98 events — 29% of everything — were people or vehicles genuinely present that never crossed the rule. A filter that only asked "is there a person?" would have forwarded every one of them as a real crossing. That is the trajectory test earning its cost, measured rather than argued.
The number that should not be ignored
The audit category — camera reported a person where we detected nothing — has grown from 9 cases to 23. That is 7% of all traffic, and it is the category where our one confirmed miss was found. Every one of those 23 was silently suppressed. Nine are published with zoomed crops in the audit queue above; the other fourteen have not been looked at by anyone.
Until someone who knows these sites reviews a sample, the honest statement is: the filter removes 54% of alerts, and we have verified a handful of those decisions.
None came from the detection logic. All came from the machine, the operating system, or the plumbing around it.
14 August — committed memory exhausted. After six days of uptime the trailer machine reached 97% of its commit limit, with about 24 GB unattributable to any visible process. Agents began failing every few minutes, then stopped decoding video entirely. A reboot brought committed memory to 50% and free disk from 8.9 GB to 28.3 GB. Contributing cause: the system drive was nearly full, preventing Windows from growing its page file — the very first error we saw said exactly that, hours before anyone connected it to the crashes.
17 August — the server was terminated from outside. Its log ends in an interrupt, not a crash, after running three days and processing 88 events without a single failed upload. The cause was never established.
17 August — and a bug of ours that made it worse. When the server went
away, one agent tried to queue its package for retry and died outright. The rule
is named Rule1 24/7; the filename sanitiser replaced spaces but not
the slash, Windows read it as a directory, and the unhandled exception took the
process down.
The lesson from that third one
The retry queue exists so the system survives the server being unreachable. It was the thing that killed the agent when the server became unreachable. A safeguard that only runs during a failure will only ever be tested during a failure — and it had sat unexercised for three days because no upload had ever failed. Sanitising the name was the small fix; wrapping the write so a lost package can never take the process with it was the real one.
Since the fix, the full cycle has been observed working end to end: upload failed, package queued to disk, server returned, queue drained on its own, and the delayed event was decided normally.
Driven by licensing, not performance — and the performance turned out better than expected anyway.
From 18 August the server runs yolox_l_leaky instead of yolov8x. The reason is not technical: Ultralytics YOLOv8 is AGPL-3.0, YOLOX is Apache-2.0. For purely internal use the distinction may not bite, but it constrains what the system can later become, and the cost of keeping that door open turned out to be small.
| yolov8x | yolox_l_leaky | |
|---|---|---|
| Latency, bench | 51.9 ms | 33.7 ms |
| Latency, in production | 54.5 ms | 30.6 ms |
| COCO mAP | 53.61 | 46.6 |
| Post-processing | manual DFL + NMS | on-chip |
| License | AGPL-3.0 | Apache-2.0 |
In production YOLOX runs at 30.6 ms per inference against 54.5 ms — a 44% reduction, better than the bench figures suggested, because the NMS the chip now performs was previously costing us CPU on every frame.
The switch required a new decoder. YOLOv8 HEFs expose the raw head; YOLOX
emits a single output the accelerator has already resolved into per-class
detection lists. yolo_hailo.py now detects which it has been given
rather than being told, so changing models is one argument in a startup
script.
A trap worth writing down
YOLOX returns boxes as [y_min, x_min, y_max, x_max] —
y before x, the opposite of every other coordinate convention in this
codebase. Assuming the usual order produces silently transposed boxes rather
than an error, which would look like a broken detector instead of a broken
reader.
Validation before going live: the reference image returns the bus and all four people, with boxes matching yolov8x almost exactly, and without the spurious low-confidence detection yolov8 produced in that same frame. Both verdict tests pass.
What this comparison does not yet prove. Contrasting the yolov8x days against the yolox days is not a fair test — different traffic, weather and people. The 6-point mAP gap should surface somewhere, most likely on distant pedestrians, and the only honest way to find out is to replay the same archived packages through both models. We now store 313 raw event packages, so that comparison is available to run.
The evidence store holds 989 files, 960 MB, of which 313 are raw event packages. That is roughly 190 MB per day at current traffic — which is why the retention policy exists now rather than being a future concern.
The cleanup process has not yet deleted anything, which is correct: packages expire at five days and the oldest is exactly five days old. The first sweep is due imminently, and it will be the first evidence that retention actually works rather than merely being configured. Under the current limits the store should settle around 4 GB against a 20 GB ceiling.
Thresholds are untuned. Confidence sits at 0.30 with yolov8l — more sensitive than it will end up, chosen deliberately after the missed person. Extra detections can be discarded by the trajectory test; something never detected cannot be recovered. The server archives every rejection and now the raw event package too, so thresholds can be raised against recorded evidence instead of intuition.
Verdicts are only spot-checked, and the checks themselves have erred. Of the first events, six were audited against their images. Five verdicts were correct. But two of those five were initially recorded here as failures, and one genuine failure was initially recorded as a success — each corrected only when the site operator, who knew what had actually happened, contradicted the write-up. Reviewing a proper sample is the next real work, and it should be done by someone who knows the location.
Deduplication is not built yet. When it is, it must key on the track or the event — never on position. Position-based dedup was already tried on a related system and it silently discarded legitimate new arrivals.
Night performance is unknown. Every measurement so far is daylight or synthetic. IR illumination, motion blur and rain all change detection confidence, and the thresholds that work at noon may not hold at 3am.