BACK TO BLOG
2026-06-22Meghmalhar Bhowmick

One Webcam, Three AI Threads, Zero Crashes

PythonComputer VisionThreadingOpenCVWindowsSecurityArchitecture

Guardian is a background security suite that lives in the Windows system tray. It does three things simultaneously:

  • Watches for hand gestures to switch virtual desktops (MediaPipe)
  • Monitors for an "OK" pinch gesture to emergency-close the active window (MediaPipe)
  • Scans faces against a reference database and triggers a fullscreen blackout if an unrecognized face stays in view for 20 seconds (DeepFace FaceNet)

When I first wrote this, each module opened the webcam independently with its own cv2.VideoCapture(0) handle. On Windows, that lasts approximately 0.3 seconds before the OS throws device lockout errors (0x800705AA), drops frames, or crashes the Python runtime entirely. The Windows DirectShow and Media Foundation drivers only allow a single process handle on VideoCapture(0). Three handles fighting over the same device is not going to work.

The SharedCamera singleton

The fix is obvious in hindsight: one thread owns the camera, everyone else borrows frames from it.

In guardian/modules/camera.py, there's a SharedCamera class that is initialized once in main.py and injected into every downstream module:

class SharedCamera:
    def __init__(self, index: int = 0):
        self.index = index
        self._cap = None
        self._frame = None
        self._lock = threading.Lock()
        self._running = False
        self._thread = None

    def open(self) -> bool:
        self._cap = cv2.VideoCapture(self.index)
        if not self._cap.isOpened():
            return False
        self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
        self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
        self._running = True
        self._thread = threading.Thread(target=self._reader, name='CamReader', daemon=True)
        self._thread.start()
        return True

    def _reader(self):
        while self._running:
            ret, frame = self._cap.read()
            if ret:
                with self._lock:
                    self._frame = frame
            else:
                time.sleep(0.01)

    def get_frame(self):
        with self._lock:
            return self._frame.copy() if self._frame is not None else None

One background daemon thread (CamReader) continuously grabs frames and caches the latest one behind a threading.Lock(). Any module calls cam.get_frame() at whatever rate it wants without ever touching the hardware layer. The gesture module might poll at 30 FPS, the face recognition module at 5 FPS — they both just call the same method and get whatever the latest cached frame is.

Downscaling to 640×480 at the hardware layer (before any processing happens) drops CPU utilization from ~95% to barely noticeable background load. MediaPipe landmarks and FaceNet embeddings don't need full 1080p frames to work accurately.

The face lock with fail-open design

modules/face_lock.py loads reference facial embeddings from a local face_data.pkl serialized file on startup. When a face is detected in frame via OpenCV Haar-cascade detection, it's passed to DeepFace with the FaceNet model backend, which generates a 128-dimensional embedding. The cosine distance against the reference is:

dist = 1 - (dot(a, b) / (norm(a) * norm(b)))

If an unrecognized face (dist > tolerance = 0.55) stays in frame continuously for blackout_seconds = 20, Guardian launches the blackout overlay: a Tkinter window configured with -fullscreen, -topmost, overrideredirect(True), and a pure black canvas that completely obscures the OS desktop.

The most important design decision here wasn't the FaceNet pipeline — it was the fail-open architecture. If the camera disconnects, DeepFace throws an exception, or the recognition pipeline crashes, the system return True (recognized) instead of triggering a lockout. You should never be locked out of your own computer because of a software exception. Pressing F12 at any time destroys the blackout overlay and starts a 15-second re-verification window.

The security risk of false-authorized access is acceptable. The failure mode of permanently locking yourself out is not.

The gesture engine debounce logic

modules/gesture.py uses MediaPipe Hands with a strict multi-frame confirmation filter. Gestures only trigger after holding a stable landmark configuration for 8 consecutive frames. This eliminates false positives from transitional hand positions (your hand moving between gestures passes through configurations that could look like an open hand or fist for a single frame).

The "OK" decoy trigger in modules/window_closer.py measures Euclidean distance between thumb tip (landmark 4) and index tip (landmark 8). Below 0.07 while middle/ring/pinky are extended → sends Alt+F4 to kill the active window, waits 300ms, then opens a preconfigured decoy URL in the default browser. Plausible deniability as a feature.

Takeaway

Never let consumer application logic communicate directly with hardware handles. Isolate hardware I/O inside an asynchronous reader daemon protected by mutex locks. Downstream consumers poll cached data, not the hardware itself. This pattern scales cleanly — add a fourth or fifth AI module to Guardian and the camera handling code doesn't change at all. And always design security systems to fail open on exceptions: a lock that traps you is worse than a lock that occasionally fails to engage.

[ GALLERY ]