Writing

The Detector Was the Easy Half

A Chrome extension that reads your pulse through the webcam and decides when to interrupt you. The signal took a month. The interruption is a different kind of problem, and I turned it off within a day.

4 min read
rPPGChrome ExtensionReactSignal Processing

The banner slid down the page while I was three hours into a null-pointer bug and finally closing in on it. Focus check: take a breath.

I turned the feature off the same day. I had written it myself two weeks earlier, and I was pleased with it right up until the moment it interrupted me.

That is the compressed version of everything I learned building a focus tracker. Noticing that someone has drifted is a signal-processing problem, and signal-processing problems yield to effort. Deciding what to do about it is a different kind of problem, and no amount of work on the detector gets you closer to solving it.

Reading a pulse through a webcam

What I wanted was something that would notice before I did. I kept catching myself forty minutes into a YouTube rabbit hole with no memory of how I got there: open a tab to check a function signature, end up reading about the Suez Canal. Most focus tools either ask you to report your own state, which fails exactly when you need it most, or require a wearable, and I wasn't strapping on a chest monitor to do a problem set. Every laptop already has a webcam, and the webcam can see something you can't.

Remote photoplethysmography is what makes that possible. Each heartbeat pushes blood through the capillaries near the surface of your skin, causing color fluctuations too small for the naked eye but recoverable from video if you process the frames carefully. From those fluctuations you can pull heart rate and heart-rate variability in real time, without contact.

Figure 01 · the detector

Most of the work sat between the frame and the number.

inside a Chrome
extension

  1. auto-exposure

    Frame

    webcam, 30 fps

  2. Face

    detection

  3. head drift

    ROI

    forehead, cheeks

  4. light shift

    Channels

    per colour

  5. compression

    Bandpass

    0.7 to 4 Hz

  6. Peaks

    detection

  7. HR, HRV

    per beat

Confidence weight, per frame

from face stability and lighting; shaky frames weigh less at the peaks

Every stage was tractable. The four labels above the lane are where the time went.

The stages are the standard rPPG pipeline. The layer underneath is where the month went: each frame gets a weight from how stable the face and the lighting were, and peak detection takes the weight rather than dropping the frame.

Nothing in the pipeline is unusual. What is unusual is the layer under it, and almost all of my time went there, to noise. A cloud passes and the ambient light shifts. Your head drifts two centimeters and the ROI loses the forehead. Webcam auto-exposure kicks in and wipes out a full second of data. The browser's media pipeline injects compression artifacts as high-frequency garbage. Each of these corrupts the estimate, and they all happen constantly. So I built a confidence-scoring layer that weights every frame by how stable the face detection and lighting are, then down-weights shaky frames during peak detection instead of discarding them outright. It smoothed things out. In dim light the signal still falls apart, and the ceiling there is hardware: the built-in MacBook camera I developed on gave a clean enough signal, and the external webcams I tried did not.

All of that was tractable, if tedious. Give me another month and the detector gets better.

Earning the right to interrupt

Interrupting is where it stops being an engineering problem.

My first version was blunt: HRV drops below a threshold, banner slides down. That was the one that fired mid-bug, and the failure wasn't in the threshold. A breathing prompt is a fine thing to receive during a reading assignment and an insult during a debugging sprint. "Take a break" is wise at the ninety-minute mark and patronizing at fifteen. Which nudge is right depends on what you're doing, when, and how you reacted the last four times.

So I had a contextual bandit learn that mapping per user: a linear model of which nudge pays off in which context, one that keeps trying the nudges it is least sure about. Mine was LinUCB. The context vector carries current HRV, time in session, time of day, and a rolling history of recent outcomes; the reward blends physiological recovery — does HRV bounce back after a nudge? — with an explicit thumbs up or down. That built-in exploration matters here, because you cannot A/B test focus interventions on a population of one.

It helped. It did not solve the interruption problem, because that is not really a modeling problem. A bandit optimizes which nudge to send, given that you are going to send one. The question underneath, whether a piece of software has standing to break your concentration at all and what it owes you when it gets that wrong, has no gradient to descend.

What I have now is a detector I trust and an intervention policy I still argue with. The detector took an afternoon to prototype and a month to make reliable. The policy has no version I would call finished, and I have stopped expecting one to arrive from a better model.