Webcam-aware auto-lock for Windows. Vigil watches your laptop camera for your enrolled face and locks the workstation the moment you leave the frame — or as soon as a stranger sits down. Uses face recognition (not just detection) so an unrelated face doesn't keep the PC unlocked, and handles side-angle webcam views (laptop webcam + external monitor setup) where frontal-only detectors fail.
Built for Windows 10/11, Python 3.12, and a laptop webcam. Idle CPU target: under 5% of one core on a modern laptop, ~3% of all cores on a 20-core desktop CPU.
- Watches the webcam looking for your face specifically.
- If you leave the frame for ~6 seconds → locks the screen.
- If a stranger sits down (face present but not yours) → locks in ~2 seconds.
- If another app holds the camera (Zoom, Teams, OBS) → auto-pauses instead of locking.
- If the screen is already locked → releases the camera and stops processing.
- Runs silently in the background (
pythonw.exe) with a tray icon. - Global pause/unpause hotkey, defaults to
Ctrl+Alt+P. - Auto-installs three shortcuts + a Run-key entry on first run:
Startup folder (login), Start Menu (searchable), Desktop
(double-click + pin-to-taskbar), and
HKCU\...\Run\Vigil(redundant login channel — seeAutostartsection).
A security boundary. A printed photo or a phone screen held to the
camera will defeat face recognition — this project has no liveness
detection. Treat this as a convenience auto-lock, not an authentication
system. See Threat model below.
Requires Python 3.12 on Windows x64.
# From the repo folder:
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txtThe install pulls in InsightFace
(RetinaFace detector + ArcFace recognition) plus ONNX Runtime. No C++
compiler required — everything ships as prebuilt wheels. On the first
run, InsightFace downloads the buffalo_l model set (~280 MB) to
~/.insightface/models/.
Note on
opencv-pythonvsopencv-python-headless: InsightFace transitively pulls inopencv-python-headless, which is the samecv2package but built withouthighgui(socv2.imshow/namedWindoware missing — enrollment needs these).requirements.txtpins the fullopencv-pythonso installing in the right order should work, but if you ever seecv2.error: The function is not implemented. Rebuild the library with Windows..., run:pip uninstall -y opencv-python-headless pip install --force-reinstall --no-deps opencv-python==4.13.0.92
Run this once, from a normal terminal (not pythonw). It opens a live
preview window.
python enroll.pyControls:
- SPACE — capture a snapshot (must show a detected face)
- ESC / Q — cancel without saving
It captures 5 snapshots, averages the 512-d face embeddings, and writes
the result to %LOCALAPPDATA%\Vigil\known_face.npy. Shift your head
slightly between captures — including your normal working angle — so
the averaged embedding is robust across poses.
Extras:
python enroll.py --test— also runs a 10-second live match test showing cosine similarity per frame, and prints min/max/mean at the end.python enroll.py --test-only— skip capture; just run the match test against the existing saved encoding. Useful for re-checking the threshold after changing your setup (lighting, monitor, hair, etc.).python enroll.py --list-cameras— enumerate DirectShow cameras by name with their indices. Helpful on laptops with an IR/Hello camera and a visible-light webcam; you want the visible-light one.python enroll.py --camera N— overrideCAMERA_INDEXfor this run.
Foreground mode (development, console output + Ctrl+C to stop):
python main.py --foregroundAdd --verbose for DEBUG-level logging.
Background mode (no console window — the normal way to run it):
pythonw main.pyYou'll see a small round icon appear in the system tray:
- green — watching
- yellow — paused or camera unavailable
- gray — starting or screen already locked
- red — stopped/error
Right-click the icon for Pause/Resume, Re-enroll face, and Quit.
(Left-click does nothing on purpose — an earlier version had left-click
toggling pause, which made it too easy to silently pause by
fat-fingering the tray icon.)
Default: Ctrl+Alt+P — press from any window to toggle monitoring.
When paused, the camera is released (so other apps can use it) and no
locking happens until you press the combo again.
Change the combo in config.py:
PAUSE_HOTKEY = "<ctrl>+<shift>+l" # pynput formatYou don't normally need to run anything — on every daemon startup,
main.py idempotently ensures four autostart channels exist:
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\Vigil.lnk— launched by Windows at login.%APPDATA%\Microsoft\Windows\Start Menu\Programs\Vigil.lnk— shows up in Start search and the Start Menu list so you can launch the daemon manually (after a tray Quit, say).%USERPROFILE%\Desktop\Vigil.lnk(or the OneDrive Desktop equivalent if you have OneDrive desktop redirection) — visible on your Desktop so you can double-click to launch, AND right-click to Pin to taskbar / Pin to Start.HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Vigil— registry-based autostart, the same mechanism Steam, Notion, Teams, Spotify etc. use. This is redundant with #1 on purpose: the Startup folder is supposed to fire every login, but in field testing on Windows 11 it sometimes silently fails (the.lnkis present, but never gets launched — usually aStartupApprovedcache hiccup or shell-init quirk). Having BOTH means we still launch even if one path is sabotaged. The single-instance mutex prevents the duplicate run from causing trouble — the second invocation pops a toast saying "already running" and exits.
All three shortcuts point at the venv's pythonw.exe with main.py as
the argument and use a custom icon at %LOCALAPPDATA%\Vigil\icon.ico
(auto-generated on first run — a green eye so it's distinguishable
from the generic Python icon in Start search). The Run-key value is
the same command line, just stored in the registry.
If you deleted any shortcut/entry by hand, it comes back on the next launch.
If the daemon was already running, the second click triggers the
running daemon to pop a tray balloon saying "Vigil is already running"
within ~1 second (the second process drops a marker file in
%LOCALAPPDATA%\Vigil\.notify_already_running and exits via the
single-instance mutex; the watcher tick consumes the marker and asks
its tray icon to show the balloon). If the daemon WASN'T running, give
it ~10 seconds — InsightFace takes that long to load, then a tray
balloon will appear announcing "Vigil is active" and the green eye icon
will show up in your system tray (you may need to click the up-arrow in
the notification area to see hidden icons).
Earlier versions tried to pop the balloon directly from the second process via either
MessageBoxW(silently swallowed by some installed security tools) or a PowerShell-spawned UWP toast (XML quote-escaping was broken — every duplicate-launch call failed inside its own except handler). The current marker-file approach reuses the samepystraynotification path that fires the "Vigil is active" first-launch balloon, so it works on every machine that the first-launch balloon works on.
Microsoft disabled programmatic Pin-to-Taskbar in Windows 10 ~1607 (to
stop bloatware auto-pinning itself), so this project doesn't try to.
The Desktop shortcut exists for exactly this purpose: right-click the
Vigil shortcut on your Desktop and choose:
- Pin to taskbar — icon sits next to the Start button, always visible
- Pin to Start — icon becomes a tile on your Start menu
Both use the same .lnk, so the custom eye icon carries through.
Clicking the pinned icon launches the daemon — the tray icon then
indicates its state.
python main.py --install-autostart # re-install all 3 shortcuts + Run key
python main.py --uninstall-autostart # remove all 3 + Run key
python main.py --autostart-status # show the state of each
python main.py --no-autoinstall # run the daemon without auto-healingBecause the daemon self-installs on every launch, --uninstall-autostart
alone is not "off" — the next time main.py runs it'll come back. The
real off switches are:
- Task Manager → Startup tab → Vigil → Disable — Windows keeps the
shortcut/Run-key entry but doesn't run them at login. Matches every
other startup entry in the OS. Note: Task Manager shows BOTH the
.lnkand the Run-key entry as separate rows; disable both to fully suppress autostart. - Run the daemon only with
--no-autoinstall, which skips the idempotent check.
All tunables live in config.py. The ones you're most likely to change:
| Setting | Default | What it does |
|---|---|---|
CAMERA_INDEX |
0 | Webcam device. Run enroll.py --list-cameras to see names. |
MATCH_THRESHOLD |
0.4 | Cosine similarity cutoff. Higher = stricter. 0.4 is balanced; 0.5 is strict; 0.3 is loose. |
STRANGER_HARD_THRESHOLD |
0.2 | Faces between this and MATCH_THRESHOLD count as "uncertain" (treated as no-face, not as a stranger). Prevents glance-at-phone / hand-on-face from triggering the fast 2-second stranger lock. |
DETECTION_SIZE |
320 | RetinaFace input resolution. 320 is fast (~400ms/frame at single-thread), 640 is the default InsightFace size (~1.4s/frame). |
FPS_TARGET |
1 | Upper cap on loop rate. Lowering means the loop sleeps more between detections; raising past detection-bound (>2.5) is wasted CPU. |
PAUSE_HOTKEY |
<ctrl>+<alt>+p |
pynput combo string. |
WINDOW_SIZE |
8 | Rolling window of observations, ~8s at FPS=1. |
NO_FACE_LOCK_SECONDS |
6.0 | Seconds since the owner was last seen before locking. The clock resets on every owner match, so glancing down at the keyboard for a few seconds does NOT accumulate. |
STRANGER_LOCK_FRAMES |
2 of 8 | Consecutive stranger frames needed to lock fast. At FPS=1, 2 = ~2s. |
STARTUP_GRACE_SECONDS |
5 | Initial warm-up time with no locking. |
Cosine similarity direction note: InsightFace's similarity is the opposite of dlib's old
face_distance. With InsightFace, higher similarity = better match (+1.0 is identical, ~0.5 is a solid match, below ~0.2 is almost certainly a different person). SoMATCH_THRESHOLDis a floor, not a ceiling. Don't be surprised if your live-test numbers are 0.6-0.8 for yourself — that's normal.
If you get false locks (locks while you're sitting right there):
- Check
%LOCALAPPDATA%\Vigil\vigil.logfor the lock trigger log line. - Lower
MATCH_THRESHOLDtoward 0.35. - Re-run
python enroll.py --test-onlyto see your actual similarity range. If min is consistently under your threshold, retune. - Re-enroll (
python enroll.py) including more angles / the lighting you actually work under.
If you get false accepts (someone else's face treated as yours):
- Raise
MATCH_THRESHOLDtoward 0.5.
If detection is too slow and reactions feel laggy:
- Keep
DETECTION_SIZE = 320. Bumping to 640 triples the CPU cost for no gain at laptop-webcam distances. NO_FACE_LOCK_SECONDSis time-based, so slow FPS doesn't change when it fires — onlySTRANGER_LOCK_FRAMESis sensitive to FPS. Lower it if you want faster stranger-lock response at low FPS.
If you get false locks while just looking down at the keyboard:
- Raise
NO_FACE_LOCK_SECONDStoward 8-10 so longer look-aways are fine. - Re-enroll with your head tilted down slightly — if the detector can still see enough of your face when you glance down, the clock keeps resetting and you never approach the threshold.
If you get false locks while glancing at your phone / hand on face:
- The fix already in place is
STRANGER_HARD_THRESHOLD = 0.2. If you're STILL seeing fast locks during occlusion, your degraded-face similarity is dropping below 0.2. LowerSTRANGER_HARD_THRESHOLDto 0.15 or 0.10 (NOT raise it). Then runpython enroll.py --test-onlywhile putting your hand on your face / holding up your phone — the printed similarity values tell you what range to set.
Everything is logged to %LOCALAPPDATA%\Vigil\vigil.log with rotation
(5 MB × 3 files). Under pythonw.exe this is your only debugging signal.
--foreground --verbose also echoes to the console at DEBUG level.
Expect some noise from PIL (Importing AvifImagePlugin, etc.) at
DEBUG level — those are pystray's icon generator loading PIL plugins,
they're harmless.
opencv-python-headless won the install race over opencv-python. See
the install note above for the fix.
The first run downloads the buffalo_l model set (~280 MB) from GitHub
to ~/.insightface/models/. On a slow connection this can take a
minute or two. Subsequent runs load from disk in ~2 seconds.
Another app is holding it. Close Zoom/Teams/OBS and retry. Or — this is
expected! — start the daemon with the other app already running and it
will sit in CAMERA_UNAVAILABLE (yellow icon) instead of locking. When
the other app releases the camera, the daemon resumes automatically.
Run:
python enroll.py --list-camerasIt prints something like:
0: Sony Camera (Imaging Edge)
1: USB2.0 HD UVC WebCam <-- this one
2: OBS Virtual Camera
Pick the right index and set CAMERA_INDEX = N in config.py.
Supported — that's exactly why this project uses InsightFace (RetinaFace
- ArcFace) instead of dlib's frontal-only HOG detector. Make sure your
enrollment snapshots include your normal working angle, not just a
head-on shot. Then
python enroll.py --test-only— your min similarity at the working angle should be well aboveMATCH_THRESHOLD(0.5+ is typical).
Read %LOCALAPPDATA%\Vigil\vigil.log. The log file is the only output
channel under pythonw, and every state transition + error is captured
there. If the log is empty, pythonw itself failed to launch — try
python main.py --foreground --verbose and see what the console says.
It shouldn't — CAMERA_UNAVAILABLE is a distinct state that skips
locking. If you're still seeing this, check the log for the lock
trigger message; it's probably no owner in last N of M frames because
the camera technically opened but returned blank frames. File an issue
with the log excerpt.
Honest disclosure: this is a convenience auto-lock, not a security control. Specifically:
- Spoofing: a printed photo of you, or a phone screen showing your face, will pass as the owner. InsightFace does not include liveness detection. This project is not the right tool if you need to defend against an attacker with physical access and a photo of you.
- Coverage: if the webcam is physically covered (tape, laptop privacy shutter), every frame returns EMPTY and the PC will lock as designed — good. But if the webcam view is sufficiently dark that detection fails and another app hasn't taken the camera, you'll get locks even though you're sitting there. Bright enough lighting for detection is a requirement.
- Side channels: this doesn't prevent keyboard/network attacks while the PC is unlocked. It only removes the specific risk of leaving an unlocked session when you step away.
What it does give you:
- Removes the "I forgot to Win+L" problem.
- Locks faster than Windows' inactivity timeout.
- Locks on "someone else sat down" in ~1-2 seconds.
| File | Purpose |
|---|---|
main.py |
Entry point: logging, mutex, CLI, lifecycle. |
config.py |
All tunables. |
watcher.py |
State machine + capture + detection loop. |
enroll.py |
First-run: capture + average face embedding. |
face_engine.py |
InsightFace wrapper (detection + recognition). |
lock.py |
LockWorkStation + OpenInputDesktop lock probe. |
hotkey.py |
pynput GlobalHotKeys listener. |
tray.py |
pystray icon + menu. |
autostart.py |
Startup-folder + Start-Menu + Desktop .lnk install/uninstall, HKCU\Run registry key install/uninstall, icon generation. |
requirements.txt |
Pinned deps. |
README.md |
This file. |
Runtime data is kept in %LOCALAPPDATA%\Vigil\:
vigil.log— rotating log (5 MB × 3)known_face.npy— averaged 512-d face embedding (ArcFace)known_face.npy.bak— previous encoding (auto-backup on re-enroll)icon.ico— multi-resolution app icon used for the Startup + Start-Menu shortcuts (generated on first run)
Upgrading from the pre-rename "Webcam Auto-Lock" build? The first launch of Vigil silently migrates
known_face.npy(andicon.ico, and the oldlock.log) from%LOCALAPPDATA%\lock\to%LOCALAPPDATA%\Vigil\, and removes any staleWebcamAutoLock.lnk/HKCU\Run\WebcamAutoLockentries so Task Manager's Startup tab shows just one row. The old%LOCALAPPDATA%\lock\folder is left in place in case you want to nuke it manually.
And model assets in ~/.insightface/models/buffalo_l/:
det_10g.onnx— RetinaFace detectorw600k_r50.onnx— ArcFace recognition (512-d embedding)- (other
.onnxfiles come with the pack but are skipped at runtime)