Zones, tripwires & events
How detections become the events your VMS acts on: what fires, exactly when, and how often.
Coordinates#
All geometry is normalised to [0,1], origin at top-left. A zone defined once survives a resolution change, a sensor swap and a crop change — which is the whole reason it is not in pixels.
const xcaicx_point bay[] = {
{0.55f, 0.0f}, {1.0f, 0.0f}, {1.0f, 1.0f}, {0.55f, 1.0f}
};The reference point#
A track is tested by the midpoint of its bottom edge — the ground contact point — not the box centre.
┌─────────┐
│ │
│ track │
│ │
└────●────┘ ← this point is what enters and exits zonesNote
Using the box centre makes a tall person "enter" a floor zone while they are still a metre outside it, and makes a person standing at the edge flap in and out as their box height changes with posture. The bottom-edge midpoint is what a floor plan actually means.
Zones#
int32_t zone_id = -1;
xcaicx_stream_add_zone(stream, "packing-bay", bay, 4,
/*dwell_seconds=*/30, &zone_id);s.add_zone("packing-bay", [(0.55, 0), (1, 0), (1, 1), (0.55, 1)], dwell_seconds=30)A zone is a polygon of three or more points. Membership is an even-odd crossing test, in the form that handles a vertex sitting exactly on the ray consistently — which is what stops a track resting on a zone edge from flapping enter/exit forever.
Polygons may be concave. They are not required to be convex, and winding order does not matter.
Zone events#
| Event | Fires when |
|---|---|
XCAICX_EVENT_ZONE_ENTER | The reference point moves from outside to inside |
XCAICX_EVENT_ZONE_EXIT | It moves from inside to outside |
XCAICX_EVENT_DWELL_EXCEEDED | It has been continuously inside for dwell_seconds |
Each carries zone_id, zone_name, track_id, the track's class in detail, its score, and pts_us.
Dwell#
dwell_seconds = 0 disables the dwell timer for that zone.
Dwell is measured from the pts_us of the entering frame, so it is video time, not wall time — a stream running at half speed still produces correct dwell measurements, and a stalled pts_us produces none.
Careful
DWELL_EXCEEDED fires once per visit, not once per frame. Leaving and re-entering arms it again. If you are counting dwell events to bill or to alarm, that once-per-visit semantic is the one to build against.
Tripwires#
int32_t line_id = -1;
xcaicx_stream_add_line(stream, "doorway",
(xcaicx_point){0.5f, 0.0f},
(xcaicx_point){0.5f, 1.0f}, &line_id);A tripwire is directed. Crossing from the a side to the b side is reported with detail = "a_to_b"; the other way is "b_to_a". That is what makes an entry/exit counter possible from one line:
if (e->kind == XCAICX_EVENT_LINE_CROSS) {
if (strcmp(e->detail, "a_to_b") == 0) ++entered;
else ++exited;
}Crossing is detected by a sign change in the signed area of the triangle (a, b, p), and is only counted if the crossing point falls within the segment's span — not on the infinite line through it. A person walking well past the end of a short tripwire does not trip it.
Module events#
Two events come from the modules rather than from geometry:
| Event | detail values | Source |
|---|---|---|
XCAICX_EVENT_PPE_VIOLATION | no_helmet, no_vest | PPE module |
XCAICX_EVENT_DEFECT_FOUND | scratch, blemish | Defect module |
XCAICX_EVENT_PLATE_READ | The plate text, or plate_candidate | ANPR module |
PPE reports the absence, not the presence, because the actionable and billable event on a factory floor is the violation. A compliant worker produces no event, which also keeps the event stream proportional to what needs attention.
PLATE_READ carries plate_candidate when the build has no text model — the reference backend localises plates but cannot read them. With a production ANPR bundle, detail and detection.text carry the plate string and text_score its confidence.
Reading events#
for (size_t i = 0; i < res->event_count; ++i) {
const xcaicx_event *e = &res->events[i];
log_event(e->kind,
e->zone_name ? e->zone_name : "",
e->detail ? e->detail : "",
e->track_id, e->score, e->pts_us);
}for ev in result.events:
print(ev.kind.name, ev.zone_name, ev.detail, ev.track_id)Events are valid until the next call on the stream. Copy the strings if you queue them.
Track lifecycle and event fidelity#
Events are only generated for confirmed tracks — those that have been seen min_hits (default 3) times. A one-frame false positive therefore cannot generate a zone entry.
The cost is that a genuinely fast object crossing the frame in two frames will not produce events either. On a fast conveyor or a road camera, that is the trade-off to be aware of; raising the frame rate is the fix, not lowering min_hits, because lowering it re-admits the false positives.
When a track is lost for longer than max_age (default 15 frames of coasting) it is retired and its zone/tripwire state is reaped. If it reappears it is a new track id, and a zone it is standing in will fire a fresh ZONE_ENTER.
Note
That re-entry behaviour is why occupancy should be computed from live track membership rather than by counting ZONE_ENTER minus ZONE_EXIT over a long period. Occlusion behind a pillar longer than max_age will otherwise inflate your count.
Designing zones that behave#
- Anchor zones to the floor. The reference point is at ground level, so a zone drawn around a person's torso in the image never triggers.
- Keep tripwires away from frame edges. A track is only confirmed after
min_hitsframes, so an object entering at the very edge may cross before it is confirmed. Place the line a little inside the frame. - Prefer one tripwire to two zones for counting through a doorway. Direction comes for free and there is no dwell state to reason about.
- Do not overlap zones you intend to count separately — a single track inside both produces events for both, which is correct but is often not what the operator drew.
Tuning#
| Symptom | Change |
|---|---|
| Enter/exit flapping on a boundary | Move the boundary off the natural walking line; the even-odd test is already hysteresis-free by design |
| Events missing for fast movers | Raise frame rate, or lower detect_every_n |
| Too many spurious detections | Raise detect_threshold |
| One object producing several boxes | Lower nms_threshold (more aggressive merging) |
| Two adjacent objects merged into one | Raise nms_threshold |
Next#
- Performance & tuning —
detect_every_nand the frame budget - Backends & models — what changes when you ship real models
- C ABI reference — event struct definitions