Doomscroll Tracker

A webcam sits open while you work. Look down at your phone for four seconds and it opens the McDonald's job application in your browser. It is a joke that runs every day.

▶ Run it in your browser — no install

PythonOpenCV Haar cascadescomputer vision single file · 257 linesstdlib + one dep
01 · What it does

It punishes you for looking at your phone

Run it and a small preview window opens showing your webcam. While your face is pointed at the screen, a green box tracks it and the status reads FOCUSED. Tilt your head down to look at your phone and the box vanishes. Four seconds later the window turns red — NOT FOCUSED… APPLYING TO McDONALD'S — and jobs.mchire.com opens in your browser. Look back up and it forgives you instantly, with the console noting that the application remains open.

The interesting part is how little it takes. There is no pose estimation, no neural network and no machine learning of any kind. The whole thing is one 1990s face detector and a stopwatch, and it works because of a limitation rather than in spite of one.

The trick: a frontal-face Haar cascade only fires on faces pointed roughly at the camera. Tilt your head down and detection simply stops. So "no frontal face for N seconds" is "you are looking at your phone" — the classifier's most famous weakness, used as the signal.

02 · Architecture

One loop, six steps

Every stage below is in doomscroll_tracker.py. There are no other files, no config, and no state that outlives the process.

01
Capture Grab one frame from the webcam. A failed read ends the loop cleanly rather than raising.
cv2.VideoCapture(CAM_INDEX) → cap.read()
02
Grayscale Haar cascades work on a single channel, so colour is thrown away immediately.
cv2.cvtColor(frame, COLOR_BGR2GRAY)
03
Detect frontal faces The only perception in the program. minSize throws away tiny false positives that would otherwise reset the stopwatch.
detectMultiScale(scaleFactor=1.2, minNeighbors=5, minSize=(80,80))
04
Stopwatch Every frame containing a face refreshes last_seen. Nothing else touches it.
last_seen = now · (now − last_seen) > GRACE_SECONDS
05
Two-state machine FOCUSED and DOOMSCROLLING. Entering the second needs the grace period to elapse; leaving it is instant.
06
Act Draw the overlay every frame; open the browser at most once per episode and never more often than the cooldown.
FOCUSEDgreen box per face, green status, stopwatch reset
DOOMSCROLLINGred banner every frame, webbrowser.open once
03 · Real output

What actually happened when it ran

captured 2026-09-05 · macOS 24.1.0 · Python 3.11.15 · opencv-python 4.14.0

A real bug, found by running it

The script was written against pip install opencv-python. That command now resolves to OpenCV 5.0, and OpenCV 5.0 removed Haar cascades entirely — there is no cv2.CascadeClassifier and not one cascade XML in the wheel. Counted on this machine:

17
cascade XMLs bundled in opencv-python 4.14.0
0
cascade XMLs bundled in opencv-python 5.0.0
AttributeError
what 5.0.0 raised before the fix

The install command is now pinned to "opencv-python<5" and the program checks for the class before it opens the camera, so the failure is one readable line instead of a traceback:

[tracker] OpenCV 5.0.0 has no Haar cascades — 5.0 removed them.
[tracker] This script needs the 4.x line:  pip install "opencv-python<5"

The state machine, end to end

A webcam cannot run at documentation-build time, so this run injects two things and nothing else: the detector returns a scripted timeline of face / no-face, and the clock is a deterministic 30fps counter. Every branch below is the shipped code's own logicwebbrowser.open is stubbed so no tab really opened. The grace period, the one-shot trigger, the cooldown suppressing a second episode, and the recovery are all real:

opencv 4.14.0 · simulated 30fps · GRACE=1.5s COOLDOWN=6.0s (shipped defaults 4.0 / 20.0)
timeline: 1s face → 3s NO face → 1s face → 2.5s NO face → 1s face
------------------------------------------------------------------
[tracker] watching. look at your screen like you mean it.
[tracker] grace=1.5s  cooldown=6.0s  cam=0
[tracker] press Q in the preview window to quit.
[tracker] doomscrolling detected (1.5s without a frontal face).
[tracker] opening https://jobs.mchire.com/jobs — good luck out there.
    >> browser would open: https://jobs.mchire.com/jobs
[tracker] welcome back. application remains open.
[tracker] doomscrolling detected (1.5s without a frontal face).
[tracker] doomscrolling, but on cooldown (2.0s left). this one's free.
[tracker] welcome back. application remains open.
[tracker] camera stopped returning frames. shutting down.
------------------------------------------------------------------
exit code: 0

Read the cooldown line. The second look-away is a genuine doomscroll and the program says so — but it opened no tab, because the previous one was 2.0 seconds inside the cooldown. That is the guard that stops a flaky camera from opening fifty tabs.

What was not measured

Detection accuracy on a live human face was not benchmarked here, and no number on this page claims otherwise. Doing it honestly needs a webcam and a person sitting in front of it, neither of which exists at build time. A photograph was tried and correctly produced zero detections at every angle — but it showed a person looking up and away in low light, which is the negative case, not a control. Synthetic drawn faces do not trigger Haar cascades at all: they need photographic texture. The detector's real-world hit rate is therefore an open question, and the grace period and cooldown exist precisely because it is.

04 · Key decisions

Why it is built this way

"No frontal face for N seconds" is the entire doomscroll signal

the cascade only fires on faces pointed at the camera, so a head tilted at a phone stops being detected — the classifier's best-known weakness used as the feature rather than worked around.

Pinned to opencv-python<5

OpenCV 5.0 removed Haar cascades outright; a plain pip install opencv-python now installs a version this cannot run on.

The version check runs before the camera is opened

failing fast with a readable message beats an AttributeError thrown mid-loop after the webcam light has already come on.

A grace period and a cooldown, not just one

the grace period stops a glance away from being punished; the cooldown stops a flaky camera from opening fifty tabs. They guard different failure modes.

The cooldown marks the episode spent even when it suppresses the opening

otherwise that branch re-evaluates every frame for the same episode and floods the console.

Recovery is immediate, with no grace period on the way back

symmetric hysteresis would mean staring at the screen for four seconds to clear a punishment you had already stopped earning.

minSize=(80,80) on detectMultiScale

rejects the tiny spurious detections Haar throws on background texture, which would otherwise reset the stopwatch and mask a real doomscroll.

Walking away from the desk counts as doomscrolling

the program cannot tell an empty chair from a phone tilt, and defending the distinction would need the pose model that was deliberately left out — documented rather than hidden.

webbrowser.open is wrapped in try/except

a browser that fails to launch should not kill a loop that is holding the camera.

05 · How to run it

In the browser — nothing to install

The web version runs the identical cascade in JavaScript: the same haarcascade_frontalface_default.xml converted to a 204KB table, the same 25 stages and 2913 features, the same integral-image evaluation. Click start, allow the camera, and it behaves exactly like the Python original. The video never leaves the page — there is no server, no upload and no storage.

One browser difference: window.open is blocked outside a user gesture, and a doomscroll is by definition not a click — a pop-up is the one delivery method guaranteed to fail. So the default punishment redirects the current tab, after three seconds of visible countdown you can cancel. New-tab and overlay-only modes are there too.

And there is sound. Getting caught starts the shift: fryer timer, drive-thru chime, order bell, griddle sizzle, room hum. All of it is synthesised at runtime from oscillators and generated noise — no audio files ship, nothing is downloaded, and no recorded or branded music is reproduced. It stops the moment you look back at the screen.

Locally, in Python

python3 -m venv .venv
source .venv/bin/activate
pip install "opencv-python<5"

The version pin matters — see above. Then:

python doomscroll_tracker.py

A window titled mcdonalds doomscroll tracker opens. Press Q in that window to quit; closing it with the mouse may not register. macOS will ask for camera permission the first time, and the terminal running it needs to be granted access under System Settings → Privacy & Security → Camera.

Retuning it

Everything adjustable is a constant at the top of the file. Nothing else needs reading:

doomscroll_tracker.py · lines 64–86the whole configuration surface
# TUNABLES — retune the entire app from right here.
# --------------------------------------------------------------------------

# Where you end up when you can't stop scrolling.
MCDONALDS_JOBS_URL = "https://jobs.mchire.com/jobs"

# How long your face may be missing before it counts as doomscrolling.
# Too low: blinking away for a sip of coffee gets punished. Too high: you get
# away with a full TikTok. 4 seconds is the sweet spot of cruelty.
GRACE_SECONDS = 4.0

# Minimum gap between two browser openings, even across separate episodes.
# This is the anti-tab-bomb guard for flaky cameras / bad lighting.
COOLDOWN_SECONDS = 20.0

# Which webcam. 0 is the built-in on most machines; try 1, 2, ... for USB cams.
CAM_INDEX = 0

# Haar cascade knobs. Raise MIN_FACE_SIZE if a poster on your wall keeps getting
# detected as a face; lower it if you sit far from the camera.
SCALE_FACTOR = 1.2      # pyramid step; smaller = slower but more thorough
MIN_NEIGHBORS = 5       # higher = fewer false positives, more missed faces
MIN_FACE_SIZE = (80, 80)  # px; rejects tiny junk detections
06 · Code tour

The parts that matter

It is one file of 257 lines. These five blocks are the program; the rest is drawing rectangles.

① the dependency guard · lines 105–112why it fails legibly instead of crashing
    # ---- dependency check ------------------------------------------------
    # OpenCV 5.0 dropped Haar cascades. Fail fast and legibly, before we take
    # over the camera, rather than throwing AttributeError mid-loop.
    if not hasattr(cv2, "CascadeClassifier"):
        log(f"OpenCV {cv2.__version__} has no Haar cascades — 5.0 removed them.")
        log('This script needs the 4.x line:  pip install "opencv-python<5"')
        return 1
② loading the detector · lines 121–133the one model, shipped inside the wheel
    # ---- detector --------------------------------------------------------
    # Loaded from the wheel's bundled data dir, so there's nothing to download.
    cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
    face_cascade = cv2.CascadeClassifier(cascade_path)
    if face_cascade.empty():
        log(f"Could not load the face cascade from: {cascade_path}")
        log("Reinstall with: pip install --force-reinstall opencv-python")
        cap.release()
        return 1

    log("watching. look at your screen like you mean it.")
    log(f"grace={GRACE_SECONDS}s  cooldown={COOLDOWN_SECONDS}s  cam={CAM_INDEX}")
    log("press Q in the preview window to quit.")
③ the state machine · lines 160–185the whole idea, in twelve lines of logic
            face_present = len(faces) > 0

            # -------------------------------------------------------------
            # STATE MACHINE
            # -------------------------------------------------------------
            if face_present:
                # A frontal face means you're looking at the screen. Refresh the
                # stopwatch every single frame it's visible.
                last_seen = now

                if state == STATE_DOOMSCROLLING:
                    # Recovery is immediate — no grace period on the way back.
                    state = STATE_FOCUSED
                    browser_triggered = False
                    log("welcome back. application remains open.")
            else:
                # No frontal face. Could be a phone tilt, could be you left, could
                # be the lighting. Either way the clock is running.
                if state == STATE_FOCUSED and (now - last_seen) > GRACE_SECONDS:
                    state = STATE_DOOMSCROLLING
                    log(
                        f"doomscrolling detected "
                        f"({now - last_seen:.1f}s without a frontal face)."
                    )

            # -------------------------------------------------------------
④ the punishment · lines 186–209one tab per episode, cooldown across episodes
            # PUNISHMENT
            # Fires at most once per episode (browser_triggered), and never more
            # often than COOLDOWN_SECONDS apart no matter how many episodes.
            # -------------------------------------------------------------
            if state == STATE_DOOMSCROLLING and not browser_triggered:
                since_last_open = now - last_browser_open
                if since_last_open >= COOLDOWN_SECONDS:
                    log(f"opening {MCDONALDS_JOBS_URL} — good luck out there.")
                    try:
                        webbrowser.open(MCDONALDS_JOBS_URL)
                    except Exception as exc:  # never let a browser hiccup kill the loop
                        log(f"couldn't open the browser: {exc}")
                    last_browser_open = now
                    browser_triggered = True
                else:
                    # Still on cooldown. Mark the episode as spent anyway so we
                    # don't re-check this branch every frame for the same episode.
                    log(
                        f"doomscrolling, but on cooldown "
                        f"({COOLDOWN_SECONDS - since_last_open:.1f}s left). this one's free."
                    )
                    browser_triggered = True

            # -------------------------------------------------------------