mctrl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mctrl-0.1.0.dist-info/METADATA +692 -0
- mctrl-0.1.0.dist-info/RECORD +33 -0
- mctrl-0.1.0.dist-info/WHEEL +4 -0
- mctrl-0.1.0.dist-info/entry_points.txt +3 -0
- mindcontrol/__init__.py +10 -0
- mindcontrol/__main__.py +4 -0
- mindcontrol/app.py +432 -0
- mindcontrol/autotune.py +493 -0
- mindcontrol/calibrate.py +199 -0
- mindcontrol/capture.py +159 -0
- mindcontrol/config.py +243 -0
- mindcontrol/control/__init__.py +1 -0
- mindcontrol/control/bridge.py +360 -0
- mindcontrol/control/events.py +34 -0
- mindcontrol/control/keyboard.py +101 -0
- mindcontrol/control/modes.py +194 -0
- mindcontrol/control/mouse.py +256 -0
- mindcontrol/debug_view.py +188 -0
- mindcontrol/devices.py +222 -0
- mindcontrol/filters.py +95 -0
- mindcontrol/fusion.py +240 -0
- mindcontrol/geometry.py +200 -0
- mindcontrol/gestures/__init__.py +1 -0
- mindcontrol/gestures/engine.py +465 -0
- mindcontrol/logs.py +87 -0
- mindcontrol/models.py +59 -0
- mindcontrol/pipeline.py +376 -0
- mindcontrol/record.py +454 -0
- mindcontrol/replay.py +193 -0
- mindcontrol/session.py +328 -0
- mindcontrol/tracking/__init__.py +1 -0
- mindcontrol/tracking/gaze.py +272 -0
- mindcontrol/tracking/hands.py +102 -0
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mctrl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Control your Mac with hand and eye movements picked up by any camera you own.
|
|
5
|
+
Project-URL: Homepage, https://github.com/GriffinCanCode/mctrl
|
|
6
|
+
Project-URL: Repository, https://github.com/GriffinCanCode/mctrl
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: <3.13,>=3.11
|
|
9
|
+
Requires-Dist: mediapipe<1.0,>=0.10.18
|
|
10
|
+
Requires-Dist: numpy>=1.26
|
|
11
|
+
Requires-Dist: opencv-python>=4.10.0
|
|
12
|
+
Requires-Dist: pyobjc-framework-applicationservices>=10.3
|
|
13
|
+
Requires-Dist: pyobjc-framework-avfoundation>=10.3
|
|
14
|
+
Requires-Dist: pyobjc-framework-cocoa>=10.3
|
|
15
|
+
Requires-Dist: pyobjc-framework-quartz>=10.3
|
|
16
|
+
Requires-Dist: rumps>=0.4.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# mindcontrol
|
|
20
|
+
|
|
21
|
+
Control your Mac with your hands and eyes, using the cameras you already own.
|
|
22
|
+
|
|
23
|
+
It runs as a menu-bar background app. Your gaze aims the cursor across the
|
|
24
|
+
screen, your hand refines the last inch and clicks, and the moment you touch the
|
|
25
|
+
real mouse or keyboard the hands get out of the way. Stand up, walk around, sit
|
|
26
|
+
back down — control follows whichever camera can see you.
|
|
27
|
+
|
|
28
|
+
## The gesture vocabulary
|
|
29
|
+
|
|
30
|
+
The whole system is built on one distinction: **a hand that is talking to the
|
|
31
|
+
computer looks different from a hand that is just there.** Only the poses below
|
|
32
|
+
do anything. Your hand can rest on the desk, hold a coffee, or gesture while you
|
|
33
|
+
talk, and nothing happens.
|
|
34
|
+
|
|
35
|
+
### Getting in and out
|
|
36
|
+
|
|
37
|
+
**Open palm, held still for 1 second** — engage or disengage hand control. This
|
|
38
|
+
is the only gesture recognised while control is off, so you can always put your
|
|
39
|
+
hands down safely.
|
|
40
|
+
|
|
41
|
+
**Touch the mouse, trackpad or keyboard** — control suspends instantly,
|
|
42
|
+
mid-motion. It comes back on its own about three seconds after you stop, or
|
|
43
|
+
immediately if you palm-toggle. You never "exit" hand mode; you just reach for
|
|
44
|
+
the trackpad and it yields.
|
|
45
|
+
|
|
46
|
+
### Pointing
|
|
47
|
+
|
|
48
|
+
**Gaze** aims. Look somewhere and the cursor jumps to that region once your eyes
|
|
49
|
+
settle there for ~150ms. Eyes are excellent at crossing a screen and poor at
|
|
50
|
+
holding still, which is why they only ever do the coarse half.
|
|
51
|
+
|
|
52
|
+
**Ready pose** — index finger and thumb both out, hand relaxed in a loose C —
|
|
53
|
+
turns your hand into a trackpad floating in the air. Move it and the cursor
|
|
54
|
+
moves relative to where it already is, so small wrist motions do fine work.
|
|
55
|
+
Pointer acceleration means slow movement is precise and fast movement is
|
|
56
|
+
sweeping.
|
|
57
|
+
|
|
58
|
+
Gaze politely stands down whenever the hand is moving, dragging or scrolling, so
|
|
59
|
+
the two never fight over the cursor.
|
|
60
|
+
|
|
61
|
+
### Acting
|
|
62
|
+
|
|
63
|
+
**Pinch thumb to index, quick tap** — left click.
|
|
64
|
+
|
|
65
|
+
**Two quick pinch taps** — double click. Sent as a real chained click, so Finder
|
|
66
|
+
and everything else treat it exactly like a trackpad double click.
|
|
67
|
+
|
|
68
|
+
**Pinch thumb to middle finger** — right click.
|
|
69
|
+
|
|
70
|
+
**Pinch and hold, then move** — grab and drag. Hold the pinch past a quarter
|
|
71
|
+
second and you are now holding the thing under the cursor; release to drop it.
|
|
72
|
+
This is the main way to manipulate anything directly.
|
|
73
|
+
|
|
74
|
+
**Close your hand into a fist and move** — scroll and pan, as if you had grabbed
|
|
75
|
+
the page and were pulling it. Open your hand to let go.
|
|
76
|
+
|
|
77
|
+
### System control
|
|
78
|
+
|
|
79
|
+
**Open palm, swipe left or right** — switch desktops.
|
|
80
|
+
|
|
81
|
+
**Open palm, push up** — Mission Control.
|
|
82
|
+
|
|
83
|
+
**Thumb and pinky out ("telephone hand"), held** — toggle dictation, for typing
|
|
84
|
+
by voice. macOS has no shortcut for this that can be synthesised reliably, so
|
|
85
|
+
assign a real key in System Settings > Keyboard > Dictation and mirror it under
|
|
86
|
+
`[keys]` in `config.toml` (default `f5`).
|
|
87
|
+
|
|
88
|
+
An open palm that *moves* is a swipe and an open palm that *sits still* is the
|
|
89
|
+
engage toggle, so those two can never be confused. Every held gesture fires once
|
|
90
|
+
and then waits for you to change pose, so holding a palm for three seconds
|
|
91
|
+
toggles control exactly once.
|
|
92
|
+
|
|
93
|
+
## Install
|
|
94
|
+
|
|
95
|
+
Requires macOS on Apple silicon and Python 3.11 or 3.12.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync # installs the exact versions in uv.lock
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
MediaPipe is pinned below 1.0 deliberately: the 1.x macOS arm64 wheels abort
|
|
102
|
+
inside `TensorsToDetectionsCalculator` asking for a Metal service the wheel does
|
|
103
|
+
not ship. `uv.lock` is checked in because that is not the only sharp edge in this
|
|
104
|
+
dependency set — the 0.10 line has also disagreed with itself about NumPy 2 — so
|
|
105
|
+
the combination known to work is recorded rather than re-resolved.
|
|
106
|
+
|
|
107
|
+
The two model bundles (~11 MB) download themselves into
|
|
108
|
+
`~/.cache/mindcontrol/models/` the first time you run.
|
|
109
|
+
|
|
110
|
+
Then build the native helper, which is what makes the cursor smooth and lets it
|
|
111
|
+
snap to what you are aiming at:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
mindcontrol bridge # compiles native/ and reports where it stands
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Needs the Xcode command line tools (`xcode-select --install`). If you skip this
|
|
118
|
+
the app still runs — it posts events straight from Python instead — but you get
|
|
119
|
+
one cursor move per camera frame and nothing snaps or highlights. The menu-bar
|
|
120
|
+
status line ends in `snapping` or `raw pointer` so you can tell which you have.
|
|
121
|
+
|
|
122
|
+
### As a Mac app
|
|
123
|
+
|
|
124
|
+
To get an icon you can keep in the Dock instead of a shell you have to keep open:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
make app # build/MindControl.app, from its own CPython up
|
|
128
|
+
make install # copy it into /Applications
|
|
129
|
+
make dmg # dist/MindControl-<version>.dmg, to install it somewhere else
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The first build takes a few minutes because it fetches a whole interpreter and
|
|
133
|
+
every pinned wheel. Afterwards use `make update`, which reinstalls just this
|
|
134
|
+
project and the helper into the existing bundle, syncs the handful of changed
|
|
135
|
+
files into `/Applications` and relaunches — about fifteen seconds. `make help`
|
|
136
|
+
lists the rest.
|
|
137
|
+
|
|
138
|
+
The bundle carries its own CPython and the whole pinned dependency set, so once
|
|
139
|
+
it is installed it reads nothing out of the checkout it was built from. The
|
|
140
|
+
native helper is built into it and pointed at through `MINDCONTROL_BRIDGE`, and
|
|
141
|
+
`config.toml` is copied into `~/.config/mindcontrol/` the first time it runs,
|
|
142
|
+
after which that copy is yours to edit.
|
|
143
|
+
|
|
144
|
+
It is a status-bar app, so opening it puts the glyph in the menu bar and nothing
|
|
145
|
+
in the Dock. There is no terminal for it to complain to either, so everything it
|
|
146
|
+
would have printed goes to `~/.local/state/mindcontrol/app.log`.
|
|
147
|
+
|
|
148
|
+
The icon is drawn rather than drawn on: `packaging/icon.py` renders it with
|
|
149
|
+
CoreGraphics at build time. It is deliberately full-bleed, because macOS 26
|
|
150
|
+
rounds, masks and shadows an app icon itself and reads artwork that arrives with
|
|
151
|
+
its own corners as a picture to inset into a plate. Pass `--plate` for the older
|
|
152
|
+
look.
|
|
153
|
+
|
|
154
|
+
## Permissions
|
|
155
|
+
|
|
156
|
+
Three grants are needed, all for whichever app launches the process — your
|
|
157
|
+
terminal, if you start it from a shell, and `MindControl.app` itself if you
|
|
158
|
+
installed the bundle, which is the tidier of the two:
|
|
159
|
+
|
|
160
|
+
**Camera** — macOS asks the first time. If you miss the prompt, System Settings
|
|
161
|
+
> Privacy & Security > Camera.
|
|
162
|
+
|
|
163
|
+
**Accessibility** — required to move the cursor and to notice when you touch the
|
|
164
|
+
real mouse. System Settings > Privacy & Security > Accessibility. Without it the
|
|
165
|
+
app runs and tracks your hands perfectly while silently failing to move
|
|
166
|
+
anything, so it says so loudly at startup.
|
|
167
|
+
|
|
168
|
+
**Menu Bar** — macOS 26 hosts every third-party status item through Control
|
|
169
|
+
Center. System Settings > Menu Bar > Allow in the Menu Bar. Without it the
|
|
170
|
+
process is running and there is nothing to click. If MindControl is not in that
|
|
171
|
+
list, the bundle never registered; rebuild with `make app`.
|
|
172
|
+
|
|
173
|
+
The native helper needs the same grant **in its own right**, because macOS
|
|
174
|
+
attaches the permission to a binary rather than to a project. That is a feature
|
|
175
|
+
here: granted to `mindcontrol-bridge` it survives rebuilding your virtualenv,
|
|
176
|
+
which a grant made to `.venv/bin/python` does not. The helper also uses it for a
|
|
177
|
+
second purpose — asking the window server what is on screen, which is how it
|
|
178
|
+
knows what to highlight. Without it, motion and clicks still work and snapping
|
|
179
|
+
silently does not, so it says so on startup too.
|
|
180
|
+
|
|
181
|
+
`make permissions` opens those panes. Installed as a bundle there is only one
|
|
182
|
+
entry to enable, `MindControl`: the helper is spawned by the app and lives
|
|
183
|
+
inside it, so macOS holds the app responsible for what it asks for and the grant
|
|
184
|
+
covers both. Running from a checkout is where the helper needs its own entry,
|
|
185
|
+
because there the responsible process is your terminal.
|
|
186
|
+
|
|
187
|
+
### Keeping the grants across updates
|
|
188
|
+
|
|
189
|
+
An ad-hoc signature — the default, because it needs nothing set up — pins the
|
|
190
|
+
bundle's designated requirement to a hash of its own contents:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
$ codesign -d -r- /Applications/MindControl.app
|
|
194
|
+
designated => cdhash H"e3bbe92e…"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Every update changes that hash, so macOS sees a different application, asks for
|
|
198
|
+
the camera again and quietly stops honouring the Accessibility entry — while
|
|
199
|
+
still showing its switch as on, because the entry belongs to the copy you built
|
|
200
|
+
last time. That failure is worth recognising: the app runs, tracks your hands,
|
|
201
|
+
and moves nothing, which looks like a bug in the tracking and is a signature.
|
|
202
|
+
|
|
203
|
+
Two copies of the bundle do the same thing to each other. `build/MindControl.app`
|
|
204
|
+
and `/Applications/MindControl.app` are two applications with one identifier, so
|
|
205
|
+
grant the one you run, which `make run` and `make update` take to be the
|
|
206
|
+
installed one.
|
|
207
|
+
|
|
208
|
+
Signing with a certificate pins the requirement to the certificate instead, which
|
|
209
|
+
does not change. Any code-signing certificate on your keychain is found and used
|
|
210
|
+
without being asked for:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
$ packaging/identity.sh
|
|
214
|
+
Developer ID Application: …
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
With none, builds are ad-hoc and the grants are given again each time.
|
|
218
|
+
`SIGN_IDENTITY` overrides the choice, and `SIGN_IDENTITY=-` forces ad-hoc. If you
|
|
219
|
+
have no certificate, Keychain Access > Certificate Assistant > Create a
|
|
220
|
+
Certificate… makes one: identity type **Self Signed Root**, certificate type
|
|
221
|
+
**Code Signing**. Grant the three permissions once to a build signed that way and
|
|
222
|
+
later `make update`s keep them.
|
|
223
|
+
|
|
224
|
+
## Running
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
mindcontrol # menu-bar background app
|
|
228
|
+
mindcontrol --debug # foreground, with the tuning overlay
|
|
229
|
+
mindcontrol --debug --no-overlay # foreground, status line only
|
|
230
|
+
mindcontrol calibrate # nine-point gaze calibration
|
|
231
|
+
mindcontrol cameras # list capture devices
|
|
232
|
+
mindcontrol record # capture a labelled gesture session
|
|
233
|
+
mindcontrol autotune # fit thresholds to that session
|
|
234
|
+
mindcontrol replay # run a session back through the engine
|
|
235
|
+
mindcontrol bridge # build the native helper
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
When snapping picks the wrong thing, ask the helper what it can see under the
|
|
239
|
+
cursor:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
native/.build/release/mindcontrol-bridge --inspect
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
It prints every candidate with its role, its distance, and its size, and marks
|
|
246
|
+
the one it would choose with `->`. This is how every selection bug so far was
|
|
247
|
+
found rather than reasoned about: a container directly under the cursor scoring
|
|
248
|
+
zero and so beating the button inside it, word lookup asking for a fixed 96
|
|
249
|
+
characters of context in documents shorter than that, a panel-sized `AXGroup`
|
|
250
|
+
winning over empty space, and the whole Dock going dark because `AXDockItem` was a
|
|
251
|
+
role the ranking had never been shown. It takes no single-instance claim, so it
|
|
252
|
+
works while the real helper is running.
|
|
253
|
+
|
|
254
|
+
When the cursor ends up somewhere you did not ask for, the goal, the destination
|
|
255
|
+
and the posted position are three different numbers, and the only useful question
|
|
256
|
+
is which of them disagreed:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
native/.build/release/mindcontrol-bridge --trace # one line per tick, on stderr
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Every button also prints where it actually landed and what it was aimed at, which
|
|
263
|
+
the per-tick lines cannot show: a click is precisely the moment the cursor stops
|
|
264
|
+
being where the tick said it was. Reading a press resolve to a word 130 px away
|
|
265
|
+
is how the stale-position bug above was found, after two rounds of guessing at it.
|
|
266
|
+
|
|
267
|
+
The menu-bar glyph is your state at a glance: `◉` engaged, `◐` suspended because
|
|
268
|
+
you touched hardware, `○` off. The menu carries a live status line (mode, hands
|
|
269
|
+
seen, cameras, frame rate, whether gaze is calibrated), an engage toggle, the
|
|
270
|
+
overlay toggle, and calibration.
|
|
271
|
+
|
|
272
|
+
MediaPipe announces its GL version, its XNNPACK delegate and two feedback
|
|
273
|
+
managers on every start, which buries the output you actually want to read during
|
|
274
|
+
a guided recording. Those lines are suppressed while the models load:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
MINDCONTROL_VERBOSE=1 mindcontrol record # put them back
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Suppression only covers model construction, and only when it succeeds. If a model
|
|
281
|
+
fails to load, everything it logged on the way down is printed, since that is the
|
|
282
|
+
one time those lines are worth having.
|
|
283
|
+
|
|
284
|
+
### Calibrating gaze
|
|
285
|
+
|
|
286
|
+
Run `mindcontrol calibrate`, or pick "Calibrate gaze..." from the menu. Nine
|
|
287
|
+
dots appear; look at each until its ring closes. Escape cancels and leaves any
|
|
288
|
+
existing calibration untouched.
|
|
289
|
+
|
|
290
|
+
Calibration learns your main display specifically — the one the camera watched
|
|
291
|
+
you look at. Hand movement can roam across every monitor; gaze warps land on
|
|
292
|
+
that one screen. Recalibrate if you move the camera or change seat.
|
|
293
|
+
|
|
294
|
+
Until you calibrate, everything except gaze works, and the pointer is purely
|
|
295
|
+
hand-driven.
|
|
296
|
+
|
|
297
|
+
## Using more than one camera
|
|
298
|
+
|
|
299
|
+
Find out what you have:
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
mindcontrol cameras # names, resolutions, and which indices work
|
|
303
|
+
mindcontrol cameras --preview # a frame from each, to see which is which
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Names come from AVFoundation, whose ordering has been seen to disagree with
|
|
307
|
+
OpenCV's indices, and there is no shared identifier to reconcile them. `--preview`
|
|
308
|
+
is the only way to be certain, and being certain matters: `primary_gaze` pointed
|
|
309
|
+
at the wrong camera means gaze estimated from a view of the wall.
|
|
310
|
+
|
|
311
|
+
Then say which cameras to use and which one watches your eyes:
|
|
312
|
+
|
|
313
|
+
```toml
|
|
314
|
+
[cameras]
|
|
315
|
+
devices = [0, 1]
|
|
316
|
+
primary_gaze = 0
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Nothing else changes. Each camera gets its own thread and its own inference, and
|
|
320
|
+
the results are merged by how confident each camera is. The payoff is occlusion:
|
|
321
|
+
a pinch hidden behind your palm from the laptop is obvious from a camera at your
|
|
322
|
+
side, and either one can carry the gesture.
|
|
323
|
+
|
|
324
|
+
Hand *shape* is combined across cameras by confidence-weighted vote. Hand
|
|
325
|
+
*position* is not averaged — each camera sees you from a different place, so an
|
|
326
|
+
average would describe a hand that exists nowhere. One camera leads for position,
|
|
327
|
+
and when the lead changes the new leader's coordinates are shifted onto where the
|
|
328
|
+
pointer already was, so the handover costs neither a jump nor the motion in that
|
|
329
|
+
frame. Losing the motion mattered: a swipe is judged on accumulated travel, and a
|
|
330
|
+
handover mid-sweep used to erase it.
|
|
331
|
+
|
|
332
|
+
Gaze runs on the primary camera only. It is the expensive model, and only a
|
|
333
|
+
camera near the screen you are looking at can say anything useful.
|
|
334
|
+
|
|
335
|
+
More cameras is not automatically better, and the reason is the vote. A camera
|
|
336
|
+
that sees the hand nearly edge-on still gets a say in the blended shape, and its
|
|
337
|
+
say is wrong in a particular direction: fingers foreshorten, so an open palm can
|
|
338
|
+
read as something closed. On one recorded session a phone added as a third view
|
|
339
|
+
was worth two extra clicks and cost five swipes — 7 down to 2 — because the palm
|
|
340
|
+
that has to stay open through a sweep kept flickering. Judge a camera by replaying
|
|
341
|
+
a session with and without it, not by how good its own picture looks.
|
|
342
|
+
|
|
343
|
+
Three cameras measured at 19ms median per poll on an M4 Max, against a 33ms
|
|
344
|
+
budget at 30fps, so time is not what limits how many you add. Two things to know
|
|
345
|
+
if a camera might come and go — an iPhone over Continuity does:
|
|
346
|
+
|
|
347
|
+
List it **last**. Indices are positional, so a device that disappears from the
|
|
348
|
+
middle renumbers everything after it, and a config naming cameras by number would
|
|
349
|
+
quietly start pointing at different lenses. Lose the trailing index and the rest
|
|
350
|
+
keep their meaning.
|
|
351
|
+
|
|
352
|
+
Expect a wait on start. Cameras are given up to twelve seconds to deliver their
|
|
353
|
+
first frame before recording begins, because a Continuity camera has been
|
|
354
|
+
measured taking most of five, and frames captured while one is still waking are
|
|
355
|
+
indistinguishable afterwards from a camera that saw nothing. Any camera that
|
|
356
|
+
never wakes is named, and the session goes ahead without it.
|
|
357
|
+
|
|
358
|
+
## Tuning
|
|
359
|
+
|
|
360
|
+
The shipped thresholds are reasoned, not measured. They were chosen against hand
|
|
361
|
+
proportions in the literature, and nobody's hands are the literature. Rather than
|
|
362
|
+
nudging numbers until things feel right, you can measure yours once:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
mindcontrol record # ~1 minute: hold each pose when prompted
|
|
366
|
+
mindcontrol autotune # see what your hands imply, change nothing
|
|
367
|
+
mindcontrol autotune --apply
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`record` walks you through the poses and stores the raw landmarks together with
|
|
371
|
+
the pose it asked for. That pairing is what makes it useful: the label says which
|
|
372
|
+
cluster each sample belongs to, so a boundary can be *found* instead of guessed.
|
|
373
|
+
|
|
374
|
+
**Use one hand and keep the other out of frame.** A second hand resting in shot
|
|
375
|
+
is not idle data — it is a different pose wearing the same label, and it corrupts
|
|
376
|
+
every threshold fitted from that prompt. Both `autotune` and `replay` check for
|
|
377
|
+
this and tell you when a recording cannot answer the question you asked of it.
|
|
378
|
+
|
|
379
|
+
`autotune` reads those clusters and puts each threshold in the gap between them —
|
|
380
|
+
your pinched distances on one side, your open ones on the other. Percentiles
|
|
381
|
+
rather than extremes, so a single bad frame cannot move a threshold. **If two
|
|
382
|
+
clusters overlap it declines and tells you**, because a tuner that always emits a
|
|
383
|
+
number is indistinguishable from one that emits noise. Without `--apply` it only
|
|
384
|
+
reports. With it, `config.toml` is edited in place, comments intact.
|
|
385
|
+
|
|
386
|
+
`replay` runs a recording back through the gesture engine offline:
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
mindcontrol replay
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
It prints what each prompt actually classified as, and what the state machine did
|
|
393
|
+
about it — how many clicks your taps produced, whether a held pinch became one
|
|
394
|
+
drag or a burst of clicks, whether anything fired while control was off. Because
|
|
395
|
+
it reads recorded timestamps rather than the clock, every run is identical, so
|
|
396
|
+
this is also how you tell whether a config change helped or hurt.
|
|
397
|
+
|
|
398
|
+
Use it before `--apply`, not after. A fit can be sound and still be a regression,
|
|
399
|
+
because several of these thresholds trade one pose against another: raising
|
|
400
|
+
`thumb_extended` until a fist is recognised also makes it harder for a thumb to
|
|
401
|
+
count as *out*, which is what the telephone pose needs. Replay each candidate,
|
|
402
|
+
then write only the ones that earned it:
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
mindcontrol autotune --apply --only thumb_extended,pinch_close
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
When one gesture needs another attempt and the rest of the script already worked,
|
|
409
|
+
re-record just that part:
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
mindcontrol record --focus pinch # ~27s
|
|
413
|
+
mindcontrol record --focus swipe # ~16s
|
|
414
|
+
mindcontrol record --focus poses
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Each group carries the prompts its fit *depends on*, not only the failing one. A
|
|
418
|
+
threshold is a boundary between two clusters, so recording just the pinch would
|
|
419
|
+
leave nothing to separate it from — `--focus pinch` therefore also captures the
|
|
420
|
+
open hands that form the other side of that boundary.
|
|
421
|
+
|
|
422
|
+
Recordings live in `~/.local/state/mindcontrol/sessions/`. They are worth keeping:
|
|
423
|
+
they turn "it feels wrong" into a number, and they let the test suite check real
|
|
424
|
+
gestures long after the moment you performed them.
|
|
425
|
+
|
|
426
|
+
### Tuning by hand
|
|
427
|
+
|
|
428
|
+
Open the overlay (`mindcontrol --debug`) and watch the real numbers from your own
|
|
429
|
+
hands in your own light. It shows each hand's skeleton, its classified pose, its
|
|
430
|
+
live pinch distance, `x2` when cameras are being merged, and a small screen proxy
|
|
431
|
+
showing where gaze thinks you are looking.
|
|
432
|
+
|
|
433
|
+
Every threshold lives in `config.toml`, and "Reload config" applies edits without
|
|
434
|
+
restarting. Distances are in **palm units** — divided by the span from your wrist
|
|
435
|
+
to your middle knuckle — so they hold whether you are at the keyboard or across
|
|
436
|
+
the room.
|
|
437
|
+
|
|
438
|
+
The knobs worth reaching for first:
|
|
439
|
+
|
|
440
|
+
- **Cursor too twitchy** — lower `pointer.filter_fc_min`. **Too laggy** — raise it.
|
|
441
|
+
- **Cursor too slow to cross the screen** — raise `pointer.sensitivity` or
|
|
442
|
+
`pointer.gain_max`.
|
|
443
|
+
- **Clicks not registering** — raise `gestures.pinch_close` toward your measured
|
|
444
|
+
pinch distance. **Clicks firing on their own** — lower it.
|
|
445
|
+
- **Clicks sticking down** — lower `gestures.pinch_open`. Watch your resting
|
|
446
|
+
pinch number on the overlay; it must sit clearly above this value.
|
|
447
|
+
- **Taps turning into drags** — raise `gestures.tap_max_ms`.
|
|
448
|
+
- **Open palms triggering when your hand is sideways** — raise
|
|
449
|
+
`gestures.palm_facing` toward 0.3. It defaults to 0.0, accepting any
|
|
450
|
+
orientation, because a too-strict setting here would stop you engaging at all.
|
|
451
|
+
- **Accidental swipes** — raise `gestures.swipe_min_speed` or
|
|
452
|
+
`swipe_min_travel`.
|
|
453
|
+
- **Swipes never firing, though the palm is recognised when you hold it still** —
|
|
454
|
+
raise `gestures.swipe_grace_ms`. A sweeping palm is blurred and rotating, so the
|
|
455
|
+
pose drops out partway through; without a grace window the accumulated travel is
|
|
456
|
+
wiped mid-gesture and no swipe ever completes. 600 ms is enough for most hands.
|
|
457
|
+
Lower it if a fist just after a swipe fails to scroll.
|
|
458
|
+
- **Adding a camera made swipes *worse*** — check the pose report from
|
|
459
|
+
`mindcontrol replay` for the prompt that is failing. Handing the lead between
|
|
460
|
+
cameras is free (the new leader's own movement is stitched onto the track, so
|
|
461
|
+
`rebases` should stay near zero), but a camera with a poor view still pulls the
|
|
462
|
+
*shape* average around, and a swipe needs the open palm to survive the sweep.
|
|
463
|
+
A camera that sees your hand edge-on helps a still pinch and hurts a sweep.
|
|
464
|
+
- **Pinches doing nothing, or scrolling instead of clicking** — your pinch is
|
|
465
|
+
probably curling the other three fingers, which makes it a fist, and a fist
|
|
466
|
+
scrolls by design. Keep the middle, ring and little fingers out. No threshold
|
|
467
|
+
can separate the two: on a curled pinch they measure identically.
|
|
468
|
+
- **Gaze fighting your hand** — lower `gaze.hand_quiet_speed`. **Gaze warping on
|
|
469
|
+
small corrections** — raise `gaze.warp_min_distance`.
|
|
470
|
+
|
|
471
|
+
Set `pointer.mode` to `hands` to switch gaze off entirely, or `gaze` to lean on
|
|
472
|
+
it harder.
|
|
473
|
+
|
|
474
|
+
## How it fits together
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
Python -- what a hand meant
|
|
478
|
+
cameras (one thread each)
|
|
479
|
+
-> hand landmarks per camera tracking/hands.py
|
|
480
|
+
-> gaze features on the primary tracking/gaze.py
|
|
481
|
+
-> merge cameras into one view fusion.py
|
|
482
|
+
-> classify shape, run the machine geometry.py, gestures/engine.py
|
|
483
|
+
-> send intents over a socket control/bridge.py
|
|
484
|
+
|
|
485
|
+
Swift -- what the cursor does about it native/Sources/BridgeCore/
|
|
486
|
+
<- 48-byte datagrams Protocol.swift, Transport.swift
|
|
487
|
+
-> integrate motion at display rate Motion.swift
|
|
488
|
+
-> ask what is on screen Probe.swift, Targets.swift
|
|
489
|
+
-> post real input events Cursor.swift
|
|
490
|
+
-> draw the highlight Overlay.swift
|
|
491
|
+
-> be the only helper running Solitary.swift
|
|
492
|
+
-> assemble the above Run.swift
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`BridgeCore` is a library and `native/Sources/Bridge/main.swift` is a two-line
|
|
496
|
+
executable over it, so target selection and the wire format can be tested without
|
|
497
|
+
a screen, an Accessibility grant, or another application to interrogate:
|
|
498
|
+
`swift test --package-path native`.
|
|
499
|
+
|
|
500
|
+
`pipeline.py` runs the camera loop on a worker thread. `app.py` keeps the main
|
|
501
|
+
thread for the menu bar, because a macOS status item needs the Cocoa run loop.
|
|
502
|
+
`control/modes.py` arbitrates between your hands and your hardware, watching for
|
|
503
|
+
physical input on a private run loop of its own.
|
|
504
|
+
|
|
505
|
+
Every event the app injects is tagged, and the watcher ignores anything carrying
|
|
506
|
+
that tag. Without it the app would see its own cursor motion, conclude a human
|
|
507
|
+
had grabbed the mouse, and suspend itself the instant it started working. Both
|
|
508
|
+
sides stamp the same tag, so `control/events.py` and `Cursor.swift` have to agree
|
|
509
|
+
about it.
|
|
510
|
+
|
|
511
|
+
### Why the cursor moved to Swift
|
|
512
|
+
|
|
513
|
+
Not for speed in the abstract. Three things could not be done from Python, and
|
|
514
|
+
each was measured before it was moved.
|
|
515
|
+
|
|
516
|
+
**Interpolation.** The camera has an opinion thirty times a second; the display
|
|
517
|
+
can show a new position a hundred and twenty times. Posting one move per camera
|
|
518
|
+
frame is four refreshes of stillness and then a jump, which is what "not smooth"
|
|
519
|
+
was. The helper accumulates deltas into a goal and walks the cursor there with a
|
|
520
|
+
critically damped spring, on a thread that asks for the user-interactive band and
|
|
521
|
+
is never behind the GIL while MediaPipe is running inference. Counted with an
|
|
522
|
+
event tap: **4.9 events per intent at 114 Hz**, 8.4 ms apart, largest step 7 px,
|
|
523
|
+
against 1.0 event per intent 33 ms apart and 20 px each.
|
|
524
|
+
|
|
525
|
+
**One writer.** Cursor motion used to be posted from wherever a frame arrived, so
|
|
526
|
+
a gaze warp and a hand delta could each be computed from a position the other had
|
|
527
|
+
already invalidated. That is what "overlapping with itself" was. Now one thread
|
|
528
|
+
owns the cursor and *clicks are queued to it too*, which makes the ordering total:
|
|
529
|
+
a press and its release always bracket exactly the motion between them. It costs a
|
|
530
|
+
click one tick — eight milliseconds — and no measured reversal survives it.
|
|
531
|
+
|
|
532
|
+
One thread is only one writer if there is also one process, and that does not come
|
|
533
|
+
for free. Binding the socket unlinks whatever was there, so a second helper takes
|
|
534
|
+
every frame and looks perfectly healthy while the first sits on its last goal with
|
|
535
|
+
a live motion thread, a live probe, and a second highlight window — started two
|
|
536
|
+
deliberately and got exactly that. So the claim is held as an `flock`, which the
|
|
537
|
+
kernel drops however the holder dies, and the newcomer evicts the incumbent rather
|
|
538
|
+
than refusing to start: an orphan nobody can see must not be able to block every
|
|
539
|
+
future launch. A helper also exits on its own once `getppid()` is 1, because a
|
|
540
|
+
parent that crashes never gets to clean up. `--inspect` deliberately takes no
|
|
541
|
+
claim, since diagnosing a *running* cursor is most of what it is for.
|
|
542
|
+
|
|
543
|
+
One writer is still not the *only* writer, because the rest of the system has
|
|
544
|
+
never agreed to that. Another application warping the pointer, a dialog taking
|
|
545
|
+
focus, Mission Control — after any of them the helper's idea of where the cursor
|
|
546
|
+
is describes somewhere it no longer is, and every target and pull computed from it
|
|
547
|
+
is wrong in a way nothing on screen reveals until a click lands in the wrong
|
|
548
|
+
place. Measured: two identical drags in a row, the second pressing on a word 130
|
|
549
|
+
px from where the cursor had been put, because the probe was still answering about
|
|
550
|
+
the first. So the real position is compared against the posted one every refresh,
|
|
551
|
+
and any disagreement is adopted as truth — the goal, the velocity, the held
|
|
552
|
+
target and every probe taken before that moment all discarded together.
|
|
553
|
+
|
|
554
|
+
Only while the cursor is at rest, though, and that restriction is the whole
|
|
555
|
+
reason it is safe: mid-flight the window server is legitimately a frame behind
|
|
556
|
+
what was just posted, and at the top speed allowed here a frame is two hundred
|
|
557
|
+
pixels, indistinguishable from a real jump. At rest there is nothing to mistake.
|
|
558
|
+
Worth knowing what "at rest" has to mean — the cursor stopped moving, *not* the
|
|
559
|
+
cursor reached its goal, because with a target in range the spring settles at the
|
|
560
|
+
goal plus the pull. Comparing against the goal held the check off permanently at
|
|
561
|
+
a 7 px steady-state offset, which is exactly how the bug above survived its first
|
|
562
|
+
fix.
|
|
563
|
+
|
|
564
|
+
**Knowing what is on screen.** Snapping needs to ask, and asking is synchronous
|
|
565
|
+
IPC into another application's main thread:
|
|
566
|
+
|
|
567
|
+
| | pyobjc | native Swift |
|
|
568
|
+
|---|---|---|
|
|
569
|
+
| one attribute read | 1140 µs | 382 µs |
|
|
570
|
+
| whole-window tree walk (2419 nodes) | 2490 ms | 4251 ms |
|
|
571
|
+
| single-point hit test | — | 0.43 ms (p95 3.46 ms) |
|
|
572
|
+
| four attributes, batched vs separate | — | 0.14 vs 0.31 ms |
|
|
573
|
+
|
|
574
|
+
The middle row is the important one: building a target list by walking a window is
|
|
575
|
+
hopeless in *either* language — four seconds, by which time the layout has changed.
|
|
576
|
+
So nothing walks the tree. The primitive that works is the single-point hit test,
|
|
577
|
+
which answers "what is here" rather than "what exists", and nearness is
|
|
578
|
+
reconstructed by asking about the cursor, the point it is heading for, and a ring
|
|
579
|
+
around that. Swift is only 1.2× faster per node, because the cost is the IPC — but
|
|
580
|
+
it is 3× faster per call, and the p95 is why the probe runs on a thread nobody
|
|
581
|
+
waits on, with a messaging timeout so an application wedged on its own main thread
|
|
582
|
+
costs one skipped probe rather than a frozen cursor.
|
|
583
|
+
|
|
584
|
+
### What snapping actually does
|
|
585
|
+
|
|
586
|
+
Choosing a target is not finding the closest rectangle. Four corrections turn
|
|
587
|
+
proximity into intent, and they are tunable in `[native]`:
|
|
588
|
+
|
|
589
|
+
- **Role.** A hit test lands on whatever is deepest at the point, which is usually
|
|
590
|
+
the group containing the button — 18 of 25 sampled screen points returned an
|
|
591
|
+
`AXGroup`. A button outranks its container even when the container is nearer.
|
|
592
|
+
- **Scenery is refused outright.** Outranking a container is not enough when it is
|
|
593
|
+
the only candidate: a Finder window with the cursor in the gap between two icons
|
|
594
|
+
offered a 614×756 `AXGroup` at zero distance, which won, drew a highlight over
|
|
595
|
+
most of the window, and pulled nowhere at all — a large target's anchor clamps to
|
|
596
|
+
where the cursor already was. Nothing is the right answer over empty space.
|
|
597
|
+
Containers are therefore named and excluded rather than merely outranked. A role
|
|
598
|
+
the table has never seen is judged by its shape instead of refused, because
|
|
599
|
+
refusing all of them silently killed the entire Dock, where an icon is an
|
|
600
|
+
`AXDockItem`.
|
|
601
|
+
- **Heading.** Where the hand is travelling is better evidence than where it
|
|
602
|
+
currently is, so a target ahead of the cursor beats an equidistant one behind.
|
|
603
|
+
- **Stickiness.** The target already held keeps a bonus, or a cursor resting on the
|
|
604
|
+
boundary between two buttons alternates every probe and the highlight strobes.
|
|
605
|
+
This is the pinch detector's hysteresis applied to space instead of time.
|
|
606
|
+
|
|
607
|
+
The pull is a force, not a jump: it fades to nothing at the edge of the snap
|
|
608
|
+
radius so crossing that boundary is not felt, and a large target's anchor is the
|
|
609
|
+
nearest point on it, so the pull vanishes once you are inside and the hand has
|
|
610
|
+
full freedom again. Exactness comes at click time instead — a click resolves to
|
|
611
|
+
the highlighted target itself, so the highlight never promises something the click
|
|
612
|
+
does not honour. A drag freezes its target, except when it began on a word, in
|
|
613
|
+
which case it keeps snapping to words.
|
|
614
|
+
|
|
615
|
+
That last exception is what makes selecting a range land on whole ones, and it
|
|
616
|
+
needs the two ends resolved differently. A click on a word wants its middle, which
|
|
617
|
+
is where a caret belongs inside a word — but a press is the start of a selection,
|
|
618
|
+
so it takes the word's *leading* edge, and the release takes the far edge of the
|
|
619
|
+
word it ends on, measured against the word it began on so that dragging leftwards
|
|
620
|
+
takes the first word entirely rather than the last. Without it, dragging from
|
|
621
|
+
"quick" to "lazy" in TextEdit selected `ick brown fox jumps over the la`; with it,
|
|
622
|
+
the same drag selects whole words, and a drag that stays inside one word selects
|
|
623
|
+
that word.
|
|
624
|
+
|
|
625
|
+
The highlight is a single Core Animation layer, retargeted rather than redrawn.
|
|
626
|
+
That is why it cannot overlap itself, and why it glides at display rate even when
|
|
627
|
+
the probe underneath it is stuttering.
|
|
628
|
+
|
|
629
|
+
### Text, and where it stops working
|
|
630
|
+
|
|
631
|
+
Aiming at a word uses three calls: the point maps to a character index, a clamped
|
|
632
|
+
window of text around that index gives the word's extent, and one bounds query
|
|
633
|
+
turns the extent into a rectangle. Verified in TextEdit: a 23×14 box on the word
|
|
634
|
+
directly under the cursor, and neighbouring words picked up from the probe ring
|
|
635
|
+
when the cursor is on a space between them.
|
|
636
|
+
|
|
637
|
+
Two things had to be true for that to be trustworthy. Prose is aimed *inside*, not
|
|
638
|
+
at, so a text element is only a target in its own right if it is short enough to be
|
|
639
|
+
a field — otherwise a 656×384 document view wins by default and outlines the very
|
|
640
|
+
text it is supposed to be pointing at. And there is no error for "no glyph here":
|
|
641
|
+
a position past the end of the text answers with index zero, indistinguishable from
|
|
642
|
+
a real hit on the first character, which offered the document's opening word to a
|
|
643
|
+
point measured 92 px away in the bottom margin. Since the bounds of the answer are
|
|
644
|
+
already being fetched, they are checked against the question — if the box is not on
|
|
645
|
+
the line that was asked about, the answer is discarded. Clicking blank space still
|
|
646
|
+
places the caret there; it simply no longer lights up a word to promise otherwise.
|
|
647
|
+
|
|
648
|
+
Chromium and Electron windows answer the character count with nothing, so no
|
|
649
|
+
amount of asking will find a word inside one — including in this project's own
|
|
650
|
+
editor. Whole-element snapping still works there; word snapping does not.
|
|
651
|
+
`PixelTextLocator` in `Probe.swift` is the seam for the way around it, reading
|
|
652
|
+
glyph boxes from the pixels instead. It is deliberately unimplemented: Vision's
|
|
653
|
+
fast recogniser is affordable at 4 ms on a 240×120 patch, but one-shot capture
|
|
654
|
+
measured 53 ms regardless of region size, so doing it properly means holding a
|
|
655
|
+
persistent `SCStream` on the focused window and asking for Screen Recording
|
|
656
|
+
permission on top of Accessibility. Every caller already copes with a locator that
|
|
657
|
+
declines to answer.
|
|
658
|
+
|
|
659
|
+
## Tests
|
|
660
|
+
|
|
661
|
+
```bash
|
|
662
|
+
uv sync --group dev
|
|
663
|
+
uv run pytest
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
Three layers, deliberately separated:
|
|
667
|
+
|
|
668
|
+
- **Logic** — the state machine and the geometry, driven by stated measurements
|
|
669
|
+
and by synthetic hands built to human proportions. Fast, and independent of
|
|
670
|
+
cameras, lighting, and hands.
|
|
671
|
+
- **Machinery** — a scripted session replayed end to end, covering session I/O,
|
|
672
|
+
re-measurement, and every gesture the engine can emit. Deterministic, so it
|
|
673
|
+
runs anywhere.
|
|
674
|
+
- **Reality** — the same assertions against your own recording. These *skip*
|
|
675
|
+
until you have run `mindcontrol record`, rather than invent input, because a
|
|
676
|
+
test that fabricates its own data would report success while checking nothing
|
|
677
|
+
about the person using this.
|
|
678
|
+
|
|
679
|
+
When a reality test fails but the matching machinery test passes, the pipeline is
|
|
680
|
+
fine and the thresholds do not suit those hands: run `mindcontrol autotune`.
|
|
681
|
+
|
|
682
|
+
## Limits worth knowing
|
|
683
|
+
|
|
684
|
+
Gaze from a webcam is good for regions, not for pixels; expect a few percent of
|
|
685
|
+
screen error, which is exactly why the hand does the last inch.
|
|
686
|
+
|
|
687
|
+
There is no air keyboard. Typing goes through dictation.
|
|
688
|
+
|
|
689
|
+
Gaze is calibrated for one display and one seating position.
|
|
690
|
+
|
|
691
|
+
Hand tracking needs your hand reasonably lit and reasonably unoccluded. A second
|
|
692
|
+
camera helps more than better thresholds.
|