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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mctrl = mindcontrol.app:main
3
+ mindcontrol = mindcontrol.app:main
@@ -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"
@@ -0,0 +1,4 @@
1
+ from .app import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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())