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,33 @@
|
|
|
1
|
+
mindcontrol/__init__.py,sha256=4cFvHjub_dQw2xEbw-svvWxDYGhJKXVfcMSqUCmkqtA,307
|
|
2
|
+
mindcontrol/__main__.py,sha256=pyuShe1147W4ZVJVoj3KP7jGDBq2uxK11pchF60RpHU,79
|
|
3
|
+
mindcontrol/app.py,sha256=X9IKxkNoagSeY5LLRuV6AiqRGRh9GibKioqFff1EPKQ,16335
|
|
4
|
+
mindcontrol/autotune.py,sha256=RqkgbkI5zKRaf2wLkN_wq8sTv-DVskJVGChk7psVzjc,20649
|
|
5
|
+
mindcontrol/calibrate.py,sha256=60mEucKJh59a8T2iz6oJwrR9Pt1pKm3rc0r90VR0vL8,7072
|
|
6
|
+
mindcontrol/capture.py,sha256=K2mkMONmqN-fS_OC08trOPCc2_jkNDmCNLyp1lfJ1YE,5245
|
|
7
|
+
mindcontrol/config.py,sha256=S7G8uRJC6EqQCRfzFKaPhM0I8wRNXUfuPidccjTuKWQ,8317
|
|
8
|
+
mindcontrol/debug_view.py,sha256=ZXfLgfgEV6qL2a3nFgXzLOZM__B2PRc0Ya1Wmt5uTK0,6100
|
|
9
|
+
mindcontrol/devices.py,sha256=4ruqL1ejUSQCZMy_lDvpT-sExkzy5VaSae1B3BsauE4,7814
|
|
10
|
+
mindcontrol/filters.py,sha256=R_Y3I7-8gn4hs_CmM0128sKxbmFL4K9WzrhZrGz6KVQ,3048
|
|
11
|
+
mindcontrol/fusion.py,sha256=_zQIr2lMe-aiPV4Zpu_eoXnRJUmkwSbixNbZGNQ52YM,10283
|
|
12
|
+
mindcontrol/geometry.py,sha256=OcilDF72yO_bJLwtE5Q_MYP5gygcbqsNHG_sdY17kyg,7042
|
|
13
|
+
mindcontrol/logs.py,sha256=b-sXF752aPAUBN2WYuk9hrWKol0YEiOdW_l0wFlavI4,3061
|
|
14
|
+
mindcontrol/models.py,sha256=mTP6e7AGA03M6ZdXtQOTNMbUoeuW9rEmfegZR-wamps,1868
|
|
15
|
+
mindcontrol/pipeline.py,sha256=qCT_O0oMamoU7hNanzH70n8VFs_BgRE5mu1xF7iov64,15238
|
|
16
|
+
mindcontrol/record.py,sha256=r7BOK5KxlII6tg1IVQfPKBT-UY0xbWQOqZKLGVNObgw,15530
|
|
17
|
+
mindcontrol/replay.py,sha256=DtPnwBWqcc9uOFcNk784am-j3cKW_p2aD_52T0SiU5A,7565
|
|
18
|
+
mindcontrol/session.py,sha256=4eeDo4bHW5wixzRu56L_jkELCmw4JsenZsIi3bmXI_Q,11359
|
|
19
|
+
mindcontrol/control/__init__.py,sha256=3lNsKlLB42jqdUeUc8mRPprECo4PIMKMM2_8k-H3TRw,52
|
|
20
|
+
mindcontrol/control/bridge.py,sha256=SQNiX6JK-xrQb5TCl2GKCyfT60MNowgQtILcPfVleok,13366
|
|
21
|
+
mindcontrol/control/events.py,sha256=kiB4QCpjnFVcDO59vjAxtb-HWz01swYr_u_31tehmyk,1146
|
|
22
|
+
mindcontrol/control/keyboard.py,sha256=DkqHdzVCBd5cseTThbIPZethTWKXKnnn-p5G_Ip6Sgg,2972
|
|
23
|
+
mindcontrol/control/modes.py,sha256=06DLzJvA0obQ8o2F512sCgccm3-EooS8XCYmHwv7Bsk,6759
|
|
24
|
+
mindcontrol/control/mouse.py,sha256=teRUW29BdS1f4nuOCy_CTCJi8EvfTa48jPEoc1E3Ik4,10269
|
|
25
|
+
mindcontrol/gestures/__init__.py,sha256=qDkY-k8oyuFLaf4EyMd8CaKXQCJ2-8gZ_q2zHBG50Hw,71
|
|
26
|
+
mindcontrol/gestures/engine.py,sha256=ZgX31SDgRwZ2UGYrmgMfT_Apuar6t6a903VSnJgnWSE,19308
|
|
27
|
+
mindcontrol/tracking/__init__.py,sha256=JwZSOQrJvmrDNMCT4QQ1N-c68kwFXuZfuAqFMtITrwM,60
|
|
28
|
+
mindcontrol/tracking/gaze.py,sha256=tRP9Ien8xw2dakNxW6MeVgcUCgbgJyzZDa51ENbzqvI,10325
|
|
29
|
+
mindcontrol/tracking/hands.py,sha256=TZi3gcr4Kqa9nlD1Apwq2b7OTmpUTjNhdEk7TxVnveo,4007
|
|
30
|
+
mctrl-0.1.0.dist-info/METADATA,sha256=JBM71qD13Kht6oxk_sMEFzwCylJ2bxI373XVx5_fd6M,34678
|
|
31
|
+
mctrl-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
32
|
+
mctrl-0.1.0.dist-info/entry_points.txt,sha256=NyIak-Z5MWoLH--xnjEIOX9AvrFb_S_-xdmxTKsXsto,82
|
|
33
|
+
mctrl-0.1.0.dist-info/RECORD,,
|
mindcontrol/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""mindcontrol: camera-driven hand and gaze control for macOS."""
|
|
2
|
+
|
|
3
|
+
from .logs import quiet
|
|
4
|
+
|
|
5
|
+
# Before anything pulls in MediaPipe: its loggers read these levels from the
|
|
6
|
+
# environment as their C++ extension modules load, so a later call would be too
|
|
7
|
+
# late to have any effect.
|
|
8
|
+
quiet()
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
mindcontrol/__main__.py
ADDED
mindcontrol/app.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""The background app.
|
|
2
|
+
|
|
3
|
+
rumps owns the main thread because a macOS status-bar item needs the Cocoa run
|
|
4
|
+
loop, so the pipeline runs on a worker thread and the two meet only through the
|
|
5
|
+
status snapshot and a handful of menu callbacks.
|
|
6
|
+
|
|
7
|
+
``--debug`` skips the menu bar entirely and runs the pipeline on the main thread
|
|
8
|
+
with the overlay window inline, which is the mode to use while tuning thresholds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import config as config_module
|
|
21
|
+
from .config import Config, load
|
|
22
|
+
from .control.modes import Mode
|
|
23
|
+
from .debug_view import DebugView, render
|
|
24
|
+
from .pipeline import Pipeline, PipelineStatus
|
|
25
|
+
|
|
26
|
+
# Status-bar glyphs. Text rather than an icon file keeps the app one directory.
|
|
27
|
+
GLYPHS = {Mode.ACTIVE: "\u25c9", Mode.SUSPENDED: "\u25d0", Mode.OFF: "\u25cb"}
|
|
28
|
+
|
|
29
|
+
# Names the menu bar slot so macOS remembers it across launches. Unnamed items
|
|
30
|
+
# get a fresh leftmost slot every time, and on a notched display with a full
|
|
31
|
+
# menu bar that slot is *under the notch*: hosted, on level 25, and invisible.
|
|
32
|
+
STATUS_ITEM_NAME = "MindControl"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def camera_state(prompt: bool = True) -> str:
|
|
36
|
+
"""Camera authorisation, as one of ``ok``, ``waiting`` or ``refused``.
|
|
37
|
+
|
|
38
|
+
OpenCV's AVFoundation backend asks for access itself and then fails the same
|
|
39
|
+
frame if the answer is not already yes, so nothing may open a camera until
|
|
40
|
+
this says ``ok``. The dialog is answered by a human, which takes many frames,
|
|
41
|
+
hence ``waiting`` rather than a second attempt.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
from AVFoundation import (
|
|
45
|
+
AVAuthorizationStatusAuthorized,
|
|
46
|
+
AVAuthorizationStatusNotDetermined,
|
|
47
|
+
AVCaptureDevice,
|
|
48
|
+
AVMediaTypeVideo,
|
|
49
|
+
)
|
|
50
|
+
except ImportError:
|
|
51
|
+
return "ok"
|
|
52
|
+
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeVideo)
|
|
53
|
+
if status == AVAuthorizationStatusAuthorized:
|
|
54
|
+
return "ok"
|
|
55
|
+
if status != AVAuthorizationStatusNotDetermined:
|
|
56
|
+
return "refused"
|
|
57
|
+
if prompt:
|
|
58
|
+
AVCaptureDevice.requestAccessForMediaType_completionHandler_(
|
|
59
|
+
AVMediaTypeVideo, lambda _granted: None
|
|
60
|
+
)
|
|
61
|
+
return "waiting"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_accessibility(prompt: bool = True) -> bool:
|
|
65
|
+
"""Report whether we may post input events, prompting once if not.
|
|
66
|
+
|
|
67
|
+
Without this permission the app runs, sees your hands, and silently fails to
|
|
68
|
+
move anything, so it is worth saying so loudly at startup.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
from ApplicationServices import (
|
|
72
|
+
AXIsProcessTrusted,
|
|
73
|
+
AXIsProcessTrustedWithOptions,
|
|
74
|
+
kAXTrustedCheckOptionPrompt,
|
|
75
|
+
)
|
|
76
|
+
except ImportError:
|
|
77
|
+
return True
|
|
78
|
+
if AXIsProcessTrusted():
|
|
79
|
+
return True
|
|
80
|
+
if prompt:
|
|
81
|
+
AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: True})
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MindControlApp:
|
|
86
|
+
"""Status-bar front end for the pipeline."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, cfg: Config) -> None:
|
|
89
|
+
import rumps
|
|
90
|
+
|
|
91
|
+
self._rumps = rumps
|
|
92
|
+
self.cfg = cfg
|
|
93
|
+
self.pipeline = Pipeline(cfg)
|
|
94
|
+
self.view = DebugView()
|
|
95
|
+
self._calibrating = False
|
|
96
|
+
|
|
97
|
+
self.app = rumps.App("MindControl", title=GLYPHS[Mode.OFF], quit_button=None)
|
|
98
|
+
self._status_item = rumps.MenuItem("Starting...")
|
|
99
|
+
self._engage_item = rumps.MenuItem("Engage hands", callback=self._on_engage)
|
|
100
|
+
self._overlay_item = rumps.MenuItem("Show overlay", callback=self._on_overlay)
|
|
101
|
+
self._gaze_item = rumps.MenuItem("Calibrate gaze...", callback=self._on_calibrate)
|
|
102
|
+
self.app.menu = [
|
|
103
|
+
self._status_item,
|
|
104
|
+
None,
|
|
105
|
+
self._engage_item,
|
|
106
|
+
self._overlay_item,
|
|
107
|
+
None,
|
|
108
|
+
self._gaze_item,
|
|
109
|
+
rumps.MenuItem("Reload config", callback=self._on_reload),
|
|
110
|
+
rumps.MenuItem("Open config folder", callback=self._on_open_config),
|
|
111
|
+
None,
|
|
112
|
+
rumps.MenuItem("Quit", callback=self._on_quit),
|
|
113
|
+
]
|
|
114
|
+
self._timer = rumps.Timer(self._on_tick, 0.5)
|
|
115
|
+
self._booted = False
|
|
116
|
+
self._camera_prompted = False
|
|
117
|
+
|
|
118
|
+
# ----------------------------------------------------------------- lifecycle
|
|
119
|
+
|
|
120
|
+
def run(self) -> None:
|
|
121
|
+
# rumps creates the status item, then the run loop. Cameras, models and
|
|
122
|
+
# the Accessibility prompt wait until the first tick so Control Center
|
|
123
|
+
# has a hosted glyph before any modal appears in front of nothing.
|
|
124
|
+
self._rumps.events.before_start.register(self._pin_status_item)
|
|
125
|
+
self._timer.start()
|
|
126
|
+
self.app.run()
|
|
127
|
+
|
|
128
|
+
def _pin_status_item(self) -> None:
|
|
129
|
+
"""Name the menu bar slot, and seed it on the right the first time.
|
|
130
|
+
|
|
131
|
+
rumps creates the item unnamed, so this runs from ``before_start``, which
|
|
132
|
+
is the first moment it exists. Naming it there still moves it. The
|
|
133
|
+
position is only seeded when nothing is stored, so a later Cmd-drag is
|
|
134
|
+
remembered instead of being overwritten on every launch.
|
|
135
|
+
"""
|
|
136
|
+
from Foundation import NSUserDefaults
|
|
137
|
+
|
|
138
|
+
defaults = NSUserDefaults.standardUserDefaults()
|
|
139
|
+
key = f"NSStatusItem Preferred Position {STATUS_ITEM_NAME}"
|
|
140
|
+
if defaults.objectForKey_(key) is None:
|
|
141
|
+
# Measured from the right edge, so zero is beside Control Center --
|
|
142
|
+
# the one place a new item cannot land behind the notch.
|
|
143
|
+
defaults.setObject_forKey_(0, key)
|
|
144
|
+
item = getattr(self.app._nsapp, "nsstatusitem", None)
|
|
145
|
+
if item is not None:
|
|
146
|
+
item.setAutosaveName_(STATUS_ITEM_NAME)
|
|
147
|
+
|
|
148
|
+
def _boot(self) -> bool:
|
|
149
|
+
camera = camera_state(prompt=not self._camera_prompted)
|
|
150
|
+
self._camera_prompted = True
|
|
151
|
+
if camera == "waiting":
|
|
152
|
+
self._status_item.title = "Waiting for camera permission..."
|
|
153
|
+
return False
|
|
154
|
+
if camera == "refused":
|
|
155
|
+
print(
|
|
156
|
+
"[app] Camera access was refused, so there is nothing to track.\n"
|
|
157
|
+
" System Settings > Privacy & Security > Camera, then start it again."
|
|
158
|
+
)
|
|
159
|
+
if not check_accessibility():
|
|
160
|
+
print(
|
|
161
|
+
"[app] Accessibility permission is required to move the cursor.\n"
|
|
162
|
+
" System Settings > Privacy & Security > Accessibility, then "
|
|
163
|
+
"enable the app running this process and start it again."
|
|
164
|
+
)
|
|
165
|
+
self.pipeline.frame_hook = self._on_frame
|
|
166
|
+
self.pipeline.start()
|
|
167
|
+
if self.cfg.debug.overlay:
|
|
168
|
+
self.view.open()
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
def _shutdown(self) -> None:
|
|
172
|
+
self._timer.stop()
|
|
173
|
+
self.pipeline.stop()
|
|
174
|
+
self.view.close()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ callbacks
|
|
177
|
+
|
|
178
|
+
def _on_frame(self, frame, hands, status: PipelineStatus) -> None:
|
|
179
|
+
"""Called on the pipeline thread for every processed frame."""
|
|
180
|
+
if self.view.running:
|
|
181
|
+
self.view.push(render(frame, hands, status, status.gaze_point))
|
|
182
|
+
|
|
183
|
+
def _on_tick(self, _timer) -> None:
|
|
184
|
+
if not self._booted:
|
|
185
|
+
if not self._boot():
|
|
186
|
+
return
|
|
187
|
+
self._booted = True
|
|
188
|
+
status = self.pipeline.status
|
|
189
|
+
mode = self.pipeline.modes.mode
|
|
190
|
+
self.app.title = GLYPHS[mode]
|
|
191
|
+
self._engage_item.title = "Disengage hands" if mode is Mode.ACTIVE else "Engage hands"
|
|
192
|
+
self._overlay_item.title = "Hide overlay" if self.view.running else "Show overlay"
|
|
193
|
+
self._status_item.title = self._summary(status)
|
|
194
|
+
|
|
195
|
+
def _summary(self, status: PipelineStatus) -> str:
|
|
196
|
+
if self._calibrating:
|
|
197
|
+
return "Calibrating gaze..."
|
|
198
|
+
if status.problems:
|
|
199
|
+
return status.problems[0][:60]
|
|
200
|
+
cameras = ",".join(str(c) for c in status.cameras) or "none"
|
|
201
|
+
gaze = "gaze on" if status.gaze_ready else "gaze uncalibrated"
|
|
202
|
+
# Worth showing: without the helper the cursor still works but is neither
|
|
203
|
+
# smoothed nor snapped, and that is exactly the complaint it exists to fix.
|
|
204
|
+
pointer = "snapping" if status.native else "raw pointer"
|
|
205
|
+
return (
|
|
206
|
+
f"{self.pipeline.modes.describe()} - {status.hands} hand(s) - "
|
|
207
|
+
f"cam {cameras} - {status.fps:.0f} fps - {gaze} - {pointer}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _on_engage(self, _sender) -> None:
|
|
211
|
+
self.pipeline.modes.toggle()
|
|
212
|
+
if self.pipeline.modes.mode is Mode.ACTIVE:
|
|
213
|
+
self.pipeline.engine.rebase()
|
|
214
|
+
|
|
215
|
+
def _on_overlay(self, _sender) -> None:
|
|
216
|
+
if self.view.running:
|
|
217
|
+
self.view.close()
|
|
218
|
+
else:
|
|
219
|
+
self.view.open()
|
|
220
|
+
|
|
221
|
+
def _on_calibrate(self, _sender) -> None:
|
|
222
|
+
if self._calibrating:
|
|
223
|
+
return
|
|
224
|
+
self._calibrating = True
|
|
225
|
+
# Calibration needs the camera and a fullscreen window of its own, so the
|
|
226
|
+
# pipeline lets go for the duration and reloads the result afterwards.
|
|
227
|
+
threading.Thread(target=self._calibrate, name="calibrate", daemon=True).start()
|
|
228
|
+
|
|
229
|
+
def _calibrate(self) -> None:
|
|
230
|
+
previous = self.pipeline.modes.mode
|
|
231
|
+
self.pipeline.modes.set_mode(Mode.OFF)
|
|
232
|
+
self.pipeline.pause()
|
|
233
|
+
try:
|
|
234
|
+
result = subprocess.run(
|
|
235
|
+
[sys.executable, "-m", "mindcontrol.calibrate"],
|
|
236
|
+
cwd=Path.cwd(),
|
|
237
|
+
check=False,
|
|
238
|
+
)
|
|
239
|
+
if result.returncode != 0:
|
|
240
|
+
print(f"[app] calibration exited with {result.returncode}")
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
print(f"[app] could not start calibration: {exc}")
|
|
243
|
+
finally:
|
|
244
|
+
self.pipeline.resume()
|
|
245
|
+
self.pipeline.modes.set_mode(previous)
|
|
246
|
+
self._calibrating = False
|
|
247
|
+
|
|
248
|
+
def _on_reload(self, _sender) -> None:
|
|
249
|
+
self.cfg = load()
|
|
250
|
+
self.pipeline.apply_config(self.cfg)
|
|
251
|
+
print(f"[app] reloaded config from {self.cfg.source_path}")
|
|
252
|
+
|
|
253
|
+
def _on_open_config(self, _sender) -> None:
|
|
254
|
+
target = self.cfg.source_path or config_module.APP_DIR
|
|
255
|
+
subprocess.run(["open", str(Path(target).parent if target.is_file() else target)])
|
|
256
|
+
|
|
257
|
+
def _on_quit(self, _sender) -> None:
|
|
258
|
+
self._shutdown()
|
|
259
|
+
self._rumps.quit_application()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def run_headless(cfg: Config, overlay: bool) -> int:
|
|
263
|
+
"""Run without the menu bar, drawing the overlay on the main thread."""
|
|
264
|
+
import cv2
|
|
265
|
+
|
|
266
|
+
pipeline = Pipeline(cfg)
|
|
267
|
+
latest: dict[str, object] = {}
|
|
268
|
+
|
|
269
|
+
def hook(frame, hands, status) -> None:
|
|
270
|
+
if overlay:
|
|
271
|
+
latest["image"] = render(frame, hands, status, status.gaze_point)
|
|
272
|
+
|
|
273
|
+
pipeline.frame_hook = hook
|
|
274
|
+
pipeline.start()
|
|
275
|
+
keys = "Ctrl-C stops" + (", Esc closes the window" if overlay else "")
|
|
276
|
+
print(f"[app] running headless; {keys}", flush=True)
|
|
277
|
+
try:
|
|
278
|
+
while True:
|
|
279
|
+
image = latest.pop("image", None)
|
|
280
|
+
if overlay and image is not None:
|
|
281
|
+
cv2.imshow("mindcontrol", image)
|
|
282
|
+
if overlay and cv2.waitKey(10) & 0xFF == 27:
|
|
283
|
+
break
|
|
284
|
+
if not overlay:
|
|
285
|
+
time.sleep(0.2)
|
|
286
|
+
status = pipeline.status
|
|
287
|
+
print(
|
|
288
|
+
f"\r{status.mode:<22} {status.gesture:<34} "
|
|
289
|
+
f"{status.hands} hand(s) {status.fps:5.1f} fps",
|
|
290
|
+
end="",
|
|
291
|
+
flush=True,
|
|
292
|
+
)
|
|
293
|
+
except KeyboardInterrupt:
|
|
294
|
+
pass
|
|
295
|
+
finally:
|
|
296
|
+
pipeline.stop()
|
|
297
|
+
if overlay:
|
|
298
|
+
cv2.destroyAllWindows()
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
303
|
+
parser = argparse.ArgumentParser(
|
|
304
|
+
prog="mindcontrol",
|
|
305
|
+
description="Control your Mac with your hands and eyes.",
|
|
306
|
+
)
|
|
307
|
+
parser.add_argument("--config", type=Path, help="path to config.toml")
|
|
308
|
+
parser.add_argument(
|
|
309
|
+
"--debug",
|
|
310
|
+
action="store_true",
|
|
311
|
+
help="run in the foreground with the tuning overlay instead of the menu bar",
|
|
312
|
+
)
|
|
313
|
+
parser.add_argument("--no-overlay", action="store_true", help="with --debug, skip the window")
|
|
314
|
+
parser.add_argument("--engaged", action="store_true", help="start with hands already engaged")
|
|
315
|
+
|
|
316
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
317
|
+
subcommands.add_parser("run", help="run the menu-bar app (the default)")
|
|
318
|
+
subcommands.add_parser("calibrate", help="nine-point gaze calibration")
|
|
319
|
+
|
|
320
|
+
cameras = subcommands.add_parser("cameras", help="list capture devices")
|
|
321
|
+
cameras.add_argument("--max-index", type=int, default=8, help="highest index to probe")
|
|
322
|
+
cameras.add_argument(
|
|
323
|
+
"--preview", action="store_true", help="show a frame from each, to confirm which is which"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
recorder = subcommands.add_parser("record", help="capture a labelled gesture session")
|
|
327
|
+
recorder.add_argument("--out", type=Path, help="where to write the session")
|
|
328
|
+
recorder.add_argument("--note", default="", help="note stored in the session header")
|
|
329
|
+
recorder.add_argument(
|
|
330
|
+
"--focus",
|
|
331
|
+
help="record only one part of the script, e.g. pinch, swipe, poses (comma-separated)",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
tuner = subcommands.add_parser("autotune", help="fit thresholds to a recorded session")
|
|
335
|
+
tuner.add_argument("session", nargs="?", type=Path, help="session file (default: newest)")
|
|
336
|
+
tuner.add_argument(
|
|
337
|
+
"--apply", action="store_true", help="write the proposals into config.toml"
|
|
338
|
+
)
|
|
339
|
+
tuner.add_argument(
|
|
340
|
+
"--only",
|
|
341
|
+
help="comma-separated thresholds to write, e.g. thumb_extended,pinch_close",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
player = subcommands.add_parser("replay", help="run a recorded session through the engine")
|
|
345
|
+
player.add_argument("session", nargs="?", type=Path, help="session file (default: newest)")
|
|
346
|
+
|
|
347
|
+
helper = subcommands.add_parser(
|
|
348
|
+
"bridge", help="build the native helper that smooths and snaps the cursor"
|
|
349
|
+
)
|
|
350
|
+
helper.add_argument("--rebuild", action="store_true", help="rebuild even if a binary exists")
|
|
351
|
+
# Not --debug: that is already a top-level flag, and argparse would let the
|
|
352
|
+
# subcommand silently shadow it.
|
|
353
|
+
helper.add_argument("--dev", action="store_true", help="build unoptimised, for helper work")
|
|
354
|
+
return parser
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main(argv: list[str] | None = None) -> int:
|
|
358
|
+
args = _build_parser().parse_args(argv)
|
|
359
|
+
|
|
360
|
+
config_module.ensure_dirs()
|
|
361
|
+
cfg = load(args.config)
|
|
362
|
+
if args.engaged:
|
|
363
|
+
cfg.modes.start_engaged = True
|
|
364
|
+
|
|
365
|
+
command = args.command or "run"
|
|
366
|
+
|
|
367
|
+
if command == "cameras":
|
|
368
|
+
from . import devices
|
|
369
|
+
|
|
370
|
+
return devices.run(args.max_index, show=args.preview)
|
|
371
|
+
|
|
372
|
+
if command == "calibrate":
|
|
373
|
+
from . import calibrate
|
|
374
|
+
|
|
375
|
+
return calibrate.run(cfg)
|
|
376
|
+
|
|
377
|
+
if command == "bridge":
|
|
378
|
+
from .control import bridge
|
|
379
|
+
|
|
380
|
+
return bridge.run(rebuild=args.rebuild, debug=args.dev)
|
|
381
|
+
|
|
382
|
+
if command == "record":
|
|
383
|
+
from . import record
|
|
384
|
+
|
|
385
|
+
focus = tuple(f.strip() for f in args.focus.split(",") if f.strip()) if args.focus else None
|
|
386
|
+
return record.run(cfg, out=args.out, note=args.note, focus=focus)
|
|
387
|
+
|
|
388
|
+
if command in {"autotune", "replay"}:
|
|
389
|
+
from . import session
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
chosen = session.resolve(args.session)
|
|
393
|
+
except FileNotFoundError as missing:
|
|
394
|
+
# An expected situation, not a crash: nobody has recorded yet.
|
|
395
|
+
print(f"[{command}] {missing}")
|
|
396
|
+
return 2
|
|
397
|
+
|
|
398
|
+
if command == "autotune":
|
|
399
|
+
from . import autotune
|
|
400
|
+
|
|
401
|
+
only = {k.strip() for k in args.only.split(",") if k.strip()} if args.only else None
|
|
402
|
+
return autotune.run(
|
|
403
|
+
chosen, cfg.gestures, cfg.source_path, args.apply, only, cfg.tracking
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
from . import replay
|
|
407
|
+
|
|
408
|
+
return replay.run(session.Session.load(chosen), cfg)
|
|
409
|
+
|
|
410
|
+
if args.debug:
|
|
411
|
+
if not check_accessibility():
|
|
412
|
+
print(
|
|
413
|
+
"[app] Accessibility permission is required to move the cursor.\n"
|
|
414
|
+
" System Settings > Privacy & Security > Accessibility, then "
|
|
415
|
+
"enable the app running this process and start it again."
|
|
416
|
+
)
|
|
417
|
+
return run_headless(cfg, overlay=not args.no_overlay)
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
from Foundation import NSBundle
|
|
421
|
+
|
|
422
|
+
ident = NSBundle.mainBundle().bundleIdentifier()
|
|
423
|
+
except Exception:
|
|
424
|
+
ident = None
|
|
425
|
+
print(f"[app] bundle {ident or 'none'}", flush=True)
|
|
426
|
+
|
|
427
|
+
MindControlApp(cfg).run()
|
|
428
|
+
return 0
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
if __name__ == "__main__":
|
|
432
|
+
raise SystemExit(main())
|