A local, privacy-first activity tracker that watches your room through a webcam and tells you where your day actually went.
It works out what you're doing — at your desk, sleeping, praying, cooking, on your phone, watching TV — and shows you a timeline of your day, split into productive, neutral, and wasted. You decide which is which.
It runs entirely on your machine. It never writes a frame, an image, or a video to disk, and it makes no network calls at runtime.
┌─ Now ──────────────────────────────────────────────────────────┐
│ ● At desk since 09:14 · 2 h 31 m [ Pause ] │
│ Why: seated in the desk zone · 90% sure · posture: sitting │
│ · zone: Desk · sees: laptop, keyboard │
└────────────────────────────────────────────────────────────────┘
Productive 1 h 15 m ▓▓▓▓
Neutral 15 h 59 m ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
Wasted 4 h 15 m ▓▓▓▓▓▓▓▓▓▓
Unknown 30 m (paused, dark, or unsure — never counted either way)
What makes it different: it refuses to lie to you. It tells you why it made every call, admits when it isn't sure, and never fills a gap with a guess.
The camera can see your body. It cannot see your screen.
"Sitting at your desk writing code" and "sitting at your desk watching YouTube" are pixel-identical. No camera and no AI model can tell them apart, because the information simply isn't in the picture.
So DeskWatch does not pretend. Desk time counts as neutral by default, not
productive. If you want it counted as productive, you say so in Settings — that's your
call to make, not the camera's.
Everything else, it genuinely can see. Here's the honest accuracy picture:
| Activity | How it knows | How much to trust it |
|---|---|---|
| Away | nobody in frame | Very high |
| Sleeping | lying in the bed zone, still for 5+ min | Very high |
| At desk | sitting in the desk zone | Very high |
| Praying | kneeling/standing on the prayer-mat zone | High |
| Cooking | standing in the kitchen zone | High — really means "you're in the kitchen" |
| In bed (awake) | lying in bed, still moving | High |
| Exercising | standing + lots of motion | Medium |
| Watching TV | settled on the couch | Medium — it's really "you're on the couch" |
| Eating | bowl/cup/spoon near you | Low–medium — the model often misses food |
| On phone | phone detected near your wrist | Low at first — see below |
Phone detection is the weakest one. The model that spots phones was trained on close-up photos; a phone in your hand three metres from a webcam is small and often missed. Expect it to be wrong sometimes at first. Every time you correct it, that's recorded — the CSV export shows what the camera guessed versus what you said, so you can see for yourself how good it actually is on your camera, in your room.
Nothing is ever guessed. If DeskWatch isn't confident, it says Unknown and puts that
time in its own bucket, where it counts neither for you nor against you. The same goes for
time when it was paused, the room was dark, or the tracker was off.
And every single call it makes explains itself. The dashboard always shows why:
Why: lying still in bed for 7 min · 90% sure · posture: lying · zone: Bed
Needs macOS, Python 3.12, and uv.
brew install uv # if you don't have it
git clone <this repo> && cd deskwatch
uv sync # NETWORK — downloads packages (~1-2 GB, incl. torch). Once.
uv run deskwatch setup # NETWORK — downloads the two AI models (~12 MB).
# This is the LAST step that touches the internet.
uv run deskwatch calibrate # No network. Pick a camera, draw your zones.
uv run deskwatch start # No network. Runs the tracker + dashboard.Then open http://127.0.0.1:8752.
macOS will ask for camera permission the first time. If no prompt appears, grant it yourself: System Settings → Privacy & Security → Camera, and enable it for your terminal.
uv run deskwatch calibrateYou'll see a snapshot of your room in the browser. Click points around an area, name it, say what it is, and add it. Draw as many as you like.
Zones are what make activity detection honest. "Lying down in the bed zone" is a fact. "Asleep" with no bed zone would be a guess — so DeskWatch won't make it.
| Draw this | And DeskWatch can detect |
|---|---|
| Desk | at desk |
| Bed | sleeping, in bed awake, on phone in bed |
| Prayer mat | praying |
| Kitchen | cooking |
| Couch (+ TV) | watching TV |
| Dining table | eating |
| Exercise area | exercising |
Without zones, it can only detect away, moving about, and on phone. That's it. Draw your zones.
Point the camera so it can see as much of the room as possible — ideally from a high corner looking down, so it sees your body and not just your head. (A laptop webcam only sees your head and shoulders; DeskWatch handles that, but it can't see your bed from there.)
uv run deskwatch start # tracker + dashboard
uv run deskwatch pause # release the camera (for a video call)
uv run deskwatch resume
uv run deskwatch spike # print what the camera sees, once a second — great for debugging
uv run deskwatch demo-data # seed a fake day so you can see the dashboard immediatelyCtrl+Alt+P pauses and resumes from anywhere (needs Accessibility permission; if it's not granted, the dashboard button does the same job).
Covering the lens is the other pause. DeskWatch sees the covered lens and records
Paused — never Away. Uncover it and it resumes in about three seconds.
Every segment has a "Not right?" button. One click, pick the real activity, done. Your answer always wins, and it's final.
The Today page opens with a "Check these" list at the top: the segments it was least sure about, and the activities it's known to be shaky at. That's your 30-second daily review. Everything else, leave alone.
Settings → What counts as what. Every activity maps to productive, neutral, or wasted, and it's yours to set. Defaults worth knowing:
- At desk → neutral. The camera can't tell working from browsing. Set it to productive only if you mean it.
- Sleeping → neutral. Sleep is not wasted time.
- On phone / Watching TV → wasted. Change it if you disagree.
The mapping is applied when the page is drawn, so changing one recolours your whole history instantly. Decide gaming is fine now? One click, and every past day updates.
./scripts/install_agent.sh # launchd agent, restarts on failure
./scripts/uninstall_agent.sh # remove it; your data is untouched- No frames, video, or images are ever written to disk. Not in logs, not in error
handlers, not as debug output. There is no
cv2.imwriteand nocv2.VideoWriteranywhere in the codebase, andtests/test_no_image_writes.pyparses the syntax tree of every source file on every test run to keep it that way. - Frames are processed in memory and thrown away. All that's ever saved: timestamped activity segments, your corrections, your settings, and text logs.
- Zero network calls at runtime. Verified: the tracker process holds no TCP sockets at
all. The dashboard listens on
127.0.0.1only. htmx is bundled in the repo, not loaded from a CDN. Ultralytics telemetry is switched off in code, and models load from an absolute local file path (loading by name is what makes Ultralytics phone home). - The only step that uses the network is
deskwatch setup.
The database is plain SQLite. That's fine for what it holds — no images, no content, just
"you were at your desk from 09:00 to 11:30". If you want it encrypted later, swap
SQLCipher in behind db.connect(); nothing else changes.
If other people share this room, tell them. DeskWatch has no face recognition and no per-person tracking — it just sees "a person" — but it is still a camera, and they deserve to know.
Once a second, every second:
- Grab the newest frame (buffer size 1, grab/retrieve — so the sample is fresh, not stale).
- Pose model finds people and their 17 body keypoints.
- Object model looks for a phone, laptop, keyboard, cup, bowl, book, TV.
- Posture is computed from keypoint geometry — standing / sitting / lying / kneeling. Pure maths, no model. (Lying = your torso is more horizontal than vertical. Kneeling = your shins are folded, so your ankles sit at knee height instead of below them — which is exactly what makes prayer posture detectable.)
- Zones: which of your polygons is the person standing in?
- The rules fuse it:
bed zone + lying + still 5 min → sleeping. Every rule is one readable line, which is why the system can always tell you why. - The state machine turns noisy per-second guesses into clean segments.
fsm.py is a pure function — no I/O, no globals, it never even reads the clock. The
same stream of observations always produces exactly the same segments, which is why it can
be tested exhaustively without a camera.
- A new activity commits only after winning ≥70% of the last 10 seconds. That kills flicker.
- Away additionally needs a full minute of an empty room — walking out of shot for 20 seconds isn't leaving.
- Commits are backdated to when you actually moved, not to when the machine made up its mind. Segments are contiguous: no gaps, no overlaps.
- Segments under 30 s are absorbed into their neighbours, so a 20-second trip to the kitchen doesn't chop your morning into three pieces. Pauses and blackouts are exempt — a 5-second pause is deliberate, not noise.
- A dark room is not "asleep". It's
Unobserved. Inferring sleep from darkness would be inventing data out of the absence of it. - A dead camera is not "away". If the camera fails, the state machine is fed nothing and heartbeats stop, so the gap renders as "tracker off". "The camera broke" is not "I left the room", and the report shouldn't claim otherwise.
- A covered lens is not "away". It's
Paused.
- Three processes:
tracker,web, and asupervisorthat restarts either if it dies (backoff 5 s → 60 s). SQLite (WAL mode) is the only channel between them. - A crash never corrupts data. Every write is a short transaction, and the open segment's start is saved every single tick. On restart, the orphaned segment is closed at the last heartbeat and the gap shows as "tracker off".
- Camera unplugged? It reopens with exponential backoff and doesn't crash.
- Times are stored as UTC and converted to local only when drawn. A day is a local calendar day; midnight-crossing segments are split; a DST day really is 23 or 25 hours long in the totals.
- A single-instance lock stops two trackers fighting over the camera.
About 20–25% of one core (~3% of an 8-core Mac). Roughly half of that is just the cost of having a webcam open on macOS — it doesn't drop with lower resolution or a slower read rate, and it's the same whether or not we do any AI at all. The rest is the two models, which take ~120 ms per second.
uv run pytest111 tests, none of which need a camera. They cover the state machine (flicker, backdating, minimum segments, both pause kinds, deterministic replay), posture geometry (including the head-and-shoulders webcam case, which was found by running against a real camera and finding it broke), the activity rules (every one of them, including that a phone on the desk is not a phone in your hand), rollups (DST days, midnight, tracker-off gaps, recolour-on-remap), crash recovery, and the privacy guarantee.
No face recognition or per-person tracking. No screen or app tracking. No audio, ever. No alerts or nudges. No cloud, no accounts, no mobile app. No emotion or attention detection. No "focus score".
If you want to know whether desk time was work or browsing, a camera fundamentally cannot tell you. The honest options are: label it yourself, or add a local screen tracker like ActivityWatch — which sees the app and the tab, and is also free and local. DeskWatch is designed to sit alongside it: the camera knows you're at the desk, ActivityWatch knows what's on the screen.
Tell anyone who shares the room. DeskWatch has no face recognition and no per-person
tracking — it only ever sees "a person" — but it is still a camera in a private space, and
the people around you didn't consent to it. Point it at your own space, and use the pause
(Ctrl+Alt+P, or just cover the lens) when others are around.
Check your local law before pointing a camera at shared areas. DeskWatch captures no audio, ever — which is also the thing most likely to cause you legal trouble.
AGPL-3.0 — see LICENSE.
This isn't a stylistic choice: DeskWatch uses Ultralytics YOLO for pose and object detection, which is AGPL-3.0, and that license reaches work built on top of it. If you want to build something closed-source from this, you'd need to replace the detector — MediaPipe (Apache-2.0) is the usual escape hatch.