Python modules¶
src/drowsyguard/ — installed by pip install -e .. Each module owns one stage,
and the ownership matters: it is what keeps the desktop and the firmware from
drifting apart.
| Module | Owns |
|---|---|
cli |
argument parsing and dispatch — no logic of its own |
data |
prepare_dataset(): subject-independent splits, video decoding, hardlinking |
ingest |
dataset importers; import_ddd() rebuilds DDD's flat folders into the subject layout |
model |
TinyDrowsyNet, the whole-face classifier |
train |
train_model(), evaluate_checkpoint() |
export |
export_onnx(), quantize_espdl() |
eyestate |
eye-state classification and PERCLOS — the drowsiness mechanism |
facedetect |
YuNet detection, gated and tracked |
facegate |
which detections to believe, and when a sequence of them is a driver |
presence |
is anyone there, and — separately — can the device tell |
behavior |
multi-cue fusion: PERCLOS, long blinks, yawn, nod |
alerts |
AlertReason and its channels, mirrored from voice_alert.h |
risk |
RiskFilter — the Python mirror of the firmware filter |
live |
LiveEngine: capture, inference and state for the dashboard |
server |
the FastAPI app |
The mirrors¶
Four modules exist because the device has the same logic and the dashboard is where it gets tuned. A threshold tuned against different logic is worse than no tuning at all: it produces confident numbers about the wrong system.
| Python | Firmware | How the equality is enforced |
|---|---|---|
risk |
risk_filter.h |
tests/test_firmware_parity.py parses the C++ constructor signature |
behavior |
behavior.h |
the same test compares every constant, and fails if a new one is added to only one side |
facegate |
face_gate.{h,cpp} |
tests/test_facegate_parity.py compiles the C++ and drives both through the same 400-candidate sweep and eleven detection sequences, requiring the same verdict, reject reason and presence decision at every step |
presence |
presence.{h,cpp} |
tests/test_presence.py does the same with eight timelines, comparing state, timers and alert edges frame by frame |
The last two are a stronger guarantee than matching constants, and deliberately so: these decisions are ordered (which check is reported first), stateful (how many detections have agreed, how many missed) and full of boundary cases. Every one of those is a place a transcription can drift without a single number changing.
The three that carry the design¶
risk — the mirror¶
drowsyguard.risk.RiskFilter is kept behaviourally identical to
firmware/esp32s3/main/risk_filter.cpp so that thresholds tuned in the dashboard
transfer to the device unchanged.
tests/test_firmware_parity.py parses the C++ constructor signature and compares
the defaults. If you change one, change both — ./plxy.sh test will tell you
if you did not.
from drowsyguard.risk import RiskFilter, DEFAULT_TRIGGER, DEFAULT_REQUIRED, DEFAULT_COOLDOWN
f = RiskFilter(DEFAULT_TRIGGER, DEFAULT_REQUIRED, DEFAULT_COOLDOWN)
alert = f.update(0.81) # True only after `required` consecutive frames
eyestate — the mechanism¶
Instead of asking a CNN "does this face look drowsy" — which learns who the driver is — this measures eyelid closure directly and integrates it over time. That is the drowsiness mechanism itself, and it is what makes a 32×32 input sufficient.
behavior — the fusion¶
Eye closure alone is not drowsiness. This module adds the behaviours that accompany it — yawning, long/slow blinks, head nodding — and fuses them into one score, with every cue measured against a rolling per-driver baseline so face shape and camera angle cancel out instead of becoming signal.
A closure that began with the mouth flung wide open has to outlast REFLEX_MAX_S
before it counts as a microsleep, rather than the ordinary MICROSLEEP_MIN_S. That is
a false-alarm guard rather than a feature: an involuntary reflex shuts the eyes and
opens the mouth in one movement, which duration alone cannot tell from a microsleep,
and a reflex resolves inside that window. REFLEX_JAW_DELTA sets how wide the mouth
has to be for the longer wait to apply — higher than JAW_OPEN_DELTA, so an ordinary
slack jaw buys no extra grace.
facegate — what to believe¶
facedetect used to take the highest-scoring box YuNet returned and believe it. That
is fine when the only thing in frame is a face and wrong in every other case: a hand,
a headrest, a phone or a patch of low-light noise all produce boxes, and one believed
frame is enough to start feeding a rolling baseline and a PERCLOS window with
measurements of something that is not a person.
Two layers, and keeping them apart is the design:
check()is static — one candidate on its own. Confidence, box size and shape, then the five landmarks. It is cheap, it is order-dependent (the first failure is the one reported, by name), and it cannot see time.FaceTrackis temporal. Consecutive detections must agree before a driver is present; a candidate that has moved further than a head can move between detections is refused; the last box is held briefly when the detector misses; and a new track never inherits the old one's confirmation. That last property is what stops a track sliding off the driver and onto a passenger.
from drowsyguard.facegate import Box, FaceTrack, check
why, geom = check(landmarks, Box(x, y, w, h, True), score, frame_w, frame_h)
if why.value != 'ok':
print('refused:', why.value) # e.g. 'box-not-head-shaped'
presence — is anyone there¶
The module that answers "no driver" without ever confusing it with "no camera". It takes a health argument alongside the presence boolean, debounces in both directions, and returns an edge — true on exactly the update that announces an absence — so the caller needs no de-duplication of its own.
from drowsyguard.presence import PipelineHealth, PresenceMonitor
mon = PresenceMonitor()
r = mon.update(driver_present, PipelineHealth.OK, dt_s)
if r.alert:
announce() # exactly once per absence episode
facedetect — why YuNet¶
OpenCV 5 removed CascadeClassifier and ships no bundled cascades, so Haar is
not an option. The YuNet ONNX file is fetched once by drowsyguard fetch-models.
Tracking is not bare per-frame detection: every candidate goes through facegate,
the accepted box is EMA-smoothed, and it is held for FACE_HOLD_DETECTIONS attempts
when detection drops — because detectors lose the face at exactly the moment of
interest, eyes closing and head nodding.
The raw detector is injectable (FaceTracker(detect_fn=...)), which is what makes
the gate testable: a hand for two seconds, an empty frame, a driver occluded for
exactly the length of the hold, a passenger appearing mid-track, twelve frames of
low-confidence detections. None of those can be produced reliably in front of a
webcam, and a test that depends on producing them is a test that gets deleted.
Scripts¶
scripts/ holds one-off tooling that is not part of the package:
| Script | Does |
|---|---|
export_eye_model.py |
export open-closed-eye-0001 for the device |
quantize_espdl.py |
ESP-DL quantization |
generate_tutorial_diagrams.py |
the eleven tutorial figures |
generate_step_diagrams.py |
the step-by-step figures |
generate_wiring_poster.py |
the one-page wiring poster |
diagram_kit.py, diagram_fonts.py, pinmap.py |
shared drawing helpers and the pin table the diagrams read |
board_reset.py |
drive the board into download mode |
make_voice_clips.py |
render the alert audio (six reasons × two languages) |
make_manifest.py |
turn a firmware build into an ESP Web Tools manifest, merged image and offset record for the browser installer |
Regenerate every figure with ./plxy.sh diagrams;
tests/test_tutorial_diagrams.py checks they stay in step with pinmap.py.