Writing

The Detector Was the Easy Half

A focus tracker 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 mid-chase on a null-pointer bug. Focus check: take a breath.

I turned the feature off within a day. I had built it myself, and I was pleased with it right up until the moment it interrupted me.

Noticing that someone has drifted is a signal-processing problem, and signal-processing problems yield to effort. Deciding what to do about it does not, and no amount of work on the detector gets you closer to an answer.

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. But 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 (HRV) in real time, without contact. HRV tends to drop under stress and fatigue.

Figure 01 · the detector

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

  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

from signal quality; a shaky signal counts for less

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, scoring signal quality so that a shaky stretch counts for less instead of being thrown away.

Almost all of my time went to noise. A cloud passes and the ambient light shifts. Your head drifts two centimeters and the region of interest loses the forehead. Webcam auto-exposure kicks in and wipes out a full second of data. Video compression injects artifacts that show up as high-frequency garbage. Each of these corrupts the estimate, and they all happen constantly. So I built a confidence-scoring layer that down-weights a shaky signal instead of discarding it 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 tedious. Give me another month and the detector gets better.

Earning the right to interrupt

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. Getting the nudge right depends on what you're doing, when, and how you've reacted to past ones.

So I set a contextual bandit to 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, and its built-in exploration matters here, because you can't A/B test focus interventions on a population of one. The context vector carried the time of day, the detector's current read on my state, how much HRV had sagged below my baseline since the last break, and how busy the workspace was, from how many tabs were open to how fast I was switching windows. The reward leaned hardest on recovery — was I back in a focused state when the nudge ended? — and next on an explicit thumbs up or down.

I never evaluated the bandit carefully enough to say whether it helped. Either way, it could not have solved the interruption problem. A bandit optimizes which nudge to send, given that you are going to send one, and mine never ran until the trigger had fired. 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 know how to improve and an intervention policy I still argue with. The detector took an afternoon to prototype and a month to clean up. The policy has no version I would call finished, and I have stopped expecting one to arrive from a better model.

Update, September 2026: the HRV reading stays off until it holds up against an ECG, and the policy ships as fixed rules.