Getting Howdy face authentication working reliably in complete darkness on a Dell laptop with Windows Hello infrared hardware, on Ubuntu 26.04.
This is a record of an actual debugging session, including the dead ends. The
scripts in scripts/ are reusable on any machine with an IR camera.
Two symptoms, which looked unrelated:
- Face recognition failed in low light.
- The small red light next to the camera — which blinked under Windows Hello — never came on.
Both had the same cause, and the second one explains the first.
A laptop with Windows Hello has two cameras behind one bezel:
| Device | Sensor | Formats |
|---|---|---|
/dev/video0 |
RGB webcam | MJPG, YUYV |
/dev/video2 |
Infrared | GREY |
Howdy's default config pointed at /dev/video0, the ordinary colour webcam.
That sensor needs ambient visible light, so a dark room meant no recognition.
The "red indicator light" is not a status LED. It is the IR illuminator —
an array of infrared LEDs that floods your face so the IR sensor can see. It
sits just at the edge of human visible range, which is why it reads as a dim
red flicker. It only energises when something opens the IR camera stream.
Windows Hello used the IR camera, so it blinked. Howdy on /dev/video0 never
touched it.
So there was nothing to implement for the blinking light. Pointing Howdy at
/dev/video2 fixed the darkness problem, and the blink returned as a side
effect of the same change.
Brightening the screen to light your face. The IR sensor sits behind an IR-pass filter and images ~850nm light from the illuminator. Visible light from a display is filtered out before it reaches the sensor. Once you are on the IR camera, ambient visible light stops being a variable entirely — which is the whole point.
Raising certainty when recognition is unreliable. certainty is the
match threshold. Raising it does not improve recognition, it widens what
counts as your face. Every problem in this session was fixed at the capture
layer instead, and the threshold never moved off its default of 3.5.
Everything below was confirmed on the machine this was built for:
- Ubuntu 26.04.1 LTS, kernel 7.0.0, Python 3.14
- Dell
Integrated_Webcam_HD, IR sensor at/dev/video2,GREY340x340 - Howdy built from source,
boltgolt/howdy@d3ab993(v2.6.1-273, master) dlib20.0.1 via pip,python3-opencv4.10.0 andpython3-numpy2.3.5 via apt
Howdy is not in the Ubuntu archives, so it is built from source with meson.
sudo apt update
sudo apt install -y \
build-essential cmake meson ninja-build pkg-config \
python3-dev python3-opencv python3-numpy \
libinih-dev libevdev-dev libpam0g-dev \
v4l-utilsv4l-utils is not a Howdy dependency, but v4l2-ctl is needed to find the
IR camera.
Ubuntu 26.04 enforces PEP 668, so installing into the system interpreter needs an explicit override:
sudo pip3 install dlib --break-system-packagesThis compiles from source and takes several minutes. Confirm it worked:
python3 -c "import dlib; print(dlib.__version__)"git clone https://github.com/boltgolt/howdy.git
cd howdy
meson setup build
ninja -C build
sudo ninja -C build installThis installs to the /usr/local prefix:
| Path | Contents |
|---|---|
/usr/local/bin/howdy |
CLI entry point |
/usr/local/lib/x86_64-linux-gnu/howdy/ |
Python implementation |
/usr/local/lib/x86_64-linux-gnu/security/pam_howdy.so |
PAM module |
/usr/local/etc/howdy/config.ini |
Configuration |
/usr/local/etc/howdy/models/ |
Enrolled face models |
/usr/local/share/dlib-data/ |
dlib recognition models |
The build defaults to install_pam_config=false, so the PAM line is added by
hand. Edit /etc/pam.d/common-auth and add this above the pam_unix.so
line:
auth sufficient /usr/local/lib/x86_64-linux-gnu/security/pam_howdy.so
Order matters. sufficient means a successful face match ends authentication
immediately, and a failure falls through to the next module. Placing it above
pam_unix.so makes face the first attempt with password as fallback.
Resulting stack:
auth sufficient /usr/local/lib/x86_64-linux-gnu/security/pam_howdy.so
auth [success=2 default=ignore] pam_unix.so nullok try_first_pass
auth [success=1 default=ignore] pam_sss.so use_first_pass
auth requisite pam_deny.so
Keep a root shell open while editing PAM. A malformed
common-authcan lock you out of authentication entirely. Because Howdy issufficientrather thanrequired, a Howdy failure falls through to your password — but a syntax error in the file is a different matter.
Find the IR camera:
./scripts/detect-ir-camera.shIt reports every capture node with its formats and flags the GREY-only one.
Then apply the settings that matter, in /usr/local/etc/howdy/config.ini:
[video]
device_path = /dev/video2 # the IR camera, NOT the RGB one
device_fps = 30 # sync capture to the IR emitter pulse
certainty = 3.5 # default; leave it alone
timeout = 6 # IR emitter needs a moment to energise
dark_threshold = 60 # default; see troubleshootingThis is the setting that caused the most trouble, and it is easy to miss.
The IR illuminator pulses. At the default -1, OpenCV picks a capture rate
that is not synchronised to it, so some frames are caught between pulses and
come out dark or half-lit. Encode one of those and you get a model that does
not resemble your face.
That is exactly what happened here. The very first enrolled model was captured before the emitter stabilised. Its nearest neighbour among 13 later models was 5.42 — far outside the 3.5 threshold — so it never matched anything. Not realising that, the fix attempted at the time was enrolling more and more models until enough good ones accumulated.
Pinning device_fps = 30 made captures consistent. The improvement in the
glasses models was immediate:
| before | after | |
|---|---|---|
| spread between glasses models | 4.15 – 4.81 | 2.66 |
Set this before enrolling, not after.
sudo howdy addRoughly 5–6 models per appearance is enough. Vary your head angle slightly between scans — near-identical captures cost matching time without adding coverage.
Glasses are not a minor variation under IR. The illuminator reflects off the lenses, often showing them as bright opaque patches, so the encodings land in a genuinely separate cluster several units away from bare-face ones.
Label them distinctly (...Glasses1, ...Glasses2, ...). analyze-models.py
uses that substring to group models, and grouping is not cosmetic: scoring
every model against every other would flag the entire glasses cluster as
outliers and recommend deleting exactly the models you need.
Tip that reduced glare noticeably: angle your chin down slightly, so the emitter reflects off the lens away from the sensor rather than straight back.
sudo ./scripts/analyze-models.pyIt groups models by label, then reports, within each group, how far each model sits from its neighbours on Howdy's scale (dlib distance x 10, lower is closer).
=== WITH 'Glasses' (5 models) ===
id label avg nearest to
--------------------------------------------------------------
15 Prince FaceGlasses6 3.01 2.06 16
16 Prince FaceGlasses7 3.11 2.06 15
14 Prince FaceGlasses5 3.33 2.66 15
17 Prince FaceGlasses8 3.71 3.33 16
18 Prince FaceGlasses9 3.88 3.59 14 ISOLATED
Two things to read from this:
ISOLATED — nearest neighbour is beyond the threshold. That model only
covers the exact pose it was captured at, so tilting your head slightly drops
you into a gap. Usually a dark or glare-affected frame. Re-capture it.
redundant — nearest neighbour is under 1.7, a near-duplicate. Harmless,
but removable if you want fewer encodings to compare against.
The report also checks whether the two groups overlap. If the closest bare-to-glasses pair is under the threshold, coverage is continuous between them rather than two disconnected islands.
Remove a model with sudo howdy remove <id>.
howdy remove never reuses ids, so pruning leaves gaps (1, 2, 4, 5, 7...)
and any numbering in your labels drifts out of sync.
sudo ./scripts/relabel-models.py --prefix "Face" --dry-run # preview
sudo ./scripts/relabel-models.py --prefix "Face" # applyIt renumbers ids to a gapless 0..N, rebuilds labels to match, and keeps the
grouped models last. Only id and label change — encodings and timestamps
are copied through, verified unchanged before writing, and a timestamped
backup is made first. Nothing outside the model file references these ids.
There are two distinct things to test, and passing one says nothing about the other.
Camera and recognition:
sudo howdy testOpens a live preview with a running certainty score. Below 3.5 is a match.
This exercises the camera and the models, but does not touch PAM. Run it once
in normal light and again in a dark room — under IR the two should look nearly
identical.
Actual authentication:
sudo -k; sudo echo "face auth worked"sudo -k clears the cached credential first. Without it you are waved through
from your last password entry and learn nothing.
Use ; rather than && — sudo -k can return non-zero even on success.
Also lock the session (Super+L) and confirm unlock by face, since a screen
locker can handle PAM differently than sudo does.
Average darkness: [62.73], Threshold: 60.0
dark_threshold rejects frames caught between IR emitter pulses. A marginal
reading like the above usually succeeds on a retry. If it recurs, loosen it
slightly:
dark_threshold = 68Raise it cautiously. A higher value admits genuinely underlit frames, and an underlit frame is precisely how a useless model gets enrolled.
/dev/videoN numbering can shuffle on USB-attached camera modules. Re-run
./scripts/detect-ir-camera.sh and check the GREY device is still where
device_path points.
Run analyze-models.py. Isolated models mean patchy coverage — re-capture
them. Do not raise certainty to compensate.
[sudo error] Failure, timeout reached
Confirm device_path is the IR device, device_fps is pinned, and consider
raising timeout to 6. The emitter needs a moment to energise after the
stream opens.
Model files are biometric data. /usr/local/etc/howdy/models/*.dat
contains 128-dimension encodings of your face. The .gitignore here excludes
*.dat — do not commit them.
Howdy is not equivalent to a password. It authenticates against a 2D IR
image with no depth sensing or liveness detection, so it is weaker than
Windows Hello, which uses structured-light depth. Reasonable for sudo and
unlocking a screen; think carefully before more than that.
Junk models are a real risk. A model built from a dark or garbled frame is
not inert — it is a live match target corresponding to noise rather than your
face. Anything landing near it authenticates. This is the main reason to run
analyze-models.py after enrolling.
Never raise certainty to fix recognition problems. It trades security
for convenience, and in every case here the real fault was capture quality.
howdy-ir-setup/
├── README.md
├── .gitignore # excludes *.dat biometric models
└── scripts/
├── detect-ir-camera.sh # find the IR capture node
├── analyze-models.py # cluster-aware model audit
└── relabel-models.py # renumber ids and sync labels
MIT. Howdy itself is MIT licensed and maintained at boltgolt/howdy.