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.
mindcontrol/capture.py ADDED
@@ -0,0 +1,159 @@
1
+ """Multi-camera capture.
2
+
3
+ Each camera runs on its own thread and keeps only its newest frame. Dropping
4
+ stale frames rather than queueing them is deliberate: a pointer built on
5
+ two-second-old video is worse than useless, so latency is protected at the cost
6
+ of throughput.
7
+
8
+ Adding a camera is a config edit -- ``devices = [0, 1]`` -- and nothing
9
+ downstream changes, because everything consumes the same ``Frame`` shape.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass
17
+
18
+ import cv2
19
+ import numpy as np
20
+
21
+ from .config import CameraConfig
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Frame:
26
+ """One image from one camera, stamped on arrival."""
27
+
28
+ camera_id: int
29
+ image: np.ndarray
30
+ timestamp_ms: int
31
+ sequence: int
32
+
33
+
34
+ class CameraWorker:
35
+ """Grabs frames from a single device into a latest-only slot."""
36
+
37
+ def __init__(self, camera_id: int, cfg: CameraConfig) -> None:
38
+ self.camera_id = camera_id
39
+ self._cfg = cfg
40
+ self._capture: cv2.VideoCapture | None = None
41
+ self._thread: threading.Thread | None = None
42
+ self._lock = threading.Lock()
43
+ self._frame: Frame | None = None
44
+ self._stop = threading.Event()
45
+ self._sequence = 0
46
+ self.error: str | None = None
47
+
48
+ def open(self) -> bool:
49
+ capture = cv2.VideoCapture(self.camera_id, cv2.CAP_AVFOUNDATION)
50
+ if not capture.isOpened():
51
+ capture.release()
52
+ self.error = f"camera {self.camera_id} could not be opened"
53
+ return False
54
+ capture.set(cv2.CAP_PROP_FRAME_WIDTH, self._cfg.width)
55
+ capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self._cfg.height)
56
+ capture.set(cv2.CAP_PROP_FPS, self._cfg.fps)
57
+ # A one-frame device buffer keeps the newest image close to real time.
58
+ capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
59
+ self._capture = capture
60
+ self.error = None
61
+ return True
62
+
63
+ def start(self) -> bool:
64
+ if self._capture is None and not self.open():
65
+ return False
66
+ self._stop.clear()
67
+ self._thread = threading.Thread(
68
+ target=self._run, name=f"camera-{self.camera_id}", daemon=True
69
+ )
70
+ self._thread.start()
71
+ return True
72
+
73
+ def _run(self) -> None:
74
+ assert self._capture is not None
75
+ failures = 0
76
+ while not self._stop.is_set():
77
+ ok, image = self._capture.read()
78
+ if not ok or image is None:
79
+ failures += 1
80
+ if failures > 60:
81
+ self.error = f"camera {self.camera_id} stopped delivering frames"
82
+ return
83
+ time.sleep(0.01)
84
+ continue
85
+ failures = 0
86
+ if self._cfg.mirror:
87
+ # Mirror so the debug view reads like a mirror and your hand and
88
+ # the cursor travel the same direction.
89
+ image = cv2.flip(image, 1)
90
+ self._sequence += 1
91
+ frame = Frame(
92
+ camera_id=self.camera_id,
93
+ image=image,
94
+ timestamp_ms=int(time.monotonic() * 1000),
95
+ sequence=self._sequence,
96
+ )
97
+ with self._lock:
98
+ self._frame = frame
99
+
100
+ def latest(self) -> Frame | None:
101
+ with self._lock:
102
+ return self._frame
103
+
104
+ def stop(self) -> None:
105
+ self._stop.set()
106
+ if self._thread is not None:
107
+ self._thread.join(timeout=1.5)
108
+ self._thread = None
109
+ if self._capture is not None:
110
+ self._capture.release()
111
+ self._capture = None
112
+ with self._lock:
113
+ self._frame = None
114
+
115
+
116
+ class CameraBank:
117
+ """A set of cameras addressed as one source."""
118
+
119
+ def __init__(self, cfg: CameraConfig) -> None:
120
+ self._cfg = cfg
121
+ self.workers: dict[int, CameraWorker] = {}
122
+
123
+ @property
124
+ def primary_id(self) -> int:
125
+ """Device trusted for gaze; falls back to any live camera."""
126
+ if self._cfg.primary_gaze in self.workers:
127
+ return self._cfg.primary_gaze
128
+ return next(iter(self.workers), self._cfg.primary_gaze)
129
+
130
+ def start(self) -> list[str]:
131
+ """Start every configured camera, returning messages for the ones that failed."""
132
+ problems: list[str] = []
133
+ for camera_id in self._cfg.devices:
134
+ worker = CameraWorker(camera_id, self._cfg)
135
+ if worker.start():
136
+ self.workers[camera_id] = worker
137
+ else:
138
+ problems.append(worker.error or f"camera {camera_id} unavailable")
139
+ return problems
140
+
141
+ def latest(self) -> dict[int, Frame]:
142
+ """Newest frame per camera, skipping cameras that have not delivered yet."""
143
+ frames: dict[int, Frame] = {}
144
+ for camera_id, worker in self.workers.items():
145
+ frame = worker.latest()
146
+ if frame is not None:
147
+ frames[camera_id] = frame
148
+ return frames
149
+
150
+ def failures(self) -> list[str]:
151
+ return [w.error for w in self.workers.values() if w.error]
152
+
153
+ def stop(self) -> None:
154
+ for worker in self.workers.values():
155
+ worker.stop()
156
+ self.workers.clear()
157
+
158
+ def __len__(self) -> int:
159
+ return len(self.workers)
mindcontrol/config.py ADDED
@@ -0,0 +1,243 @@
1
+ """Configuration loading.
2
+
3
+ The whole app is driven by ``config.toml``; this module resolves where that file
4
+ lives, merges it over the built-in defaults, and exposes it as nested dataclasses
5
+ so the rest of the code gets attribute access and type checking instead of dict
6
+ spelunking.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tomllib
12
+ from dataclasses import dataclass, field, fields, is_dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ APP_DIR = Path.home() / ".config" / "mindcontrol"
17
+ STATE_DIR = Path.home() / ".local" / "state" / "mindcontrol"
18
+ CACHE_DIR = Path.home() / ".cache" / "mindcontrol"
19
+ GAZE_MODEL_PATH = STATE_DIR / "gaze_calibration.json"
20
+
21
+
22
+ @dataclass
23
+ class CameraConfig:
24
+ devices: list[int] = field(default_factory=lambda: [0])
25
+ primary_gaze: int = 0
26
+ width: int = 1280
27
+ height: int = 720
28
+ fps: int = 30
29
+ mirror: bool = True
30
+
31
+
32
+ @dataclass
33
+ class TrackingConfig:
34
+ max_hands: int = 2
35
+ hand_detection_confidence: float = 0.6
36
+ hand_presence_confidence: float = 0.5
37
+ hand_tracking_confidence: float = 0.5
38
+ face_enabled: bool = True
39
+ face_detection_confidence: float = 0.5
40
+ stale_after_ms: float = 250.0
41
+ # How much better a rival camera must score before it takes over position.
42
+ # Handovers are cheap now that motion is stitched across them, so this only
43
+ # stops the lead flitting between views of near-equal confidence.
44
+ leader_margin: float = 0.15
45
+
46
+
47
+ @dataclass
48
+ class PointerConfig:
49
+ mode: str = "hybrid"
50
+ sensitivity: float = 2600.0
51
+ gain_min: float = 0.55
52
+ gain_max: float = 3.1
53
+ gain_speed_reference: float = 1.4
54
+ deadzone: float = 0.0012
55
+ filter_fc_min: float = 1.4
56
+ filter_beta: float = 0.035
57
+
58
+
59
+ @dataclass
60
+ class GazeConfig:
61
+ fixation_ms: float = 150.0
62
+ fixation_radius: float = 0.045
63
+ warp_min_distance: float = 0.09
64
+ hand_quiet_speed: float = 0.25
65
+ filter_fc_min: float = 0.9
66
+ filter_beta: float = 0.008
67
+ blink_ear: float = 0.14
68
+
69
+
70
+ @dataclass
71
+ class GestureConfig:
72
+ pinch_close: float = 0.30
73
+ pinch_open: float = 0.42
74
+ tap_max_ms: float = 260.0
75
+ tap_max_travel: float = 0.05
76
+ double_click_ms: float = 400.0
77
+ finger_extended: float = 1.18
78
+ thumb_extended: float = 0.80
79
+ palm_spread: float = 0.18
80
+ palm_facing: float = 0.0
81
+ engage_hold_ms: float = 1000.0
82
+ dictation_hold_ms: float = 700.0
83
+ hold_max_travel: float = 0.09
84
+ scroll_sensitivity: float = 2200.0
85
+ scroll_deadzone: float = 0.002
86
+ swipe_min_speed: float = 1.1
87
+ swipe_min_travel: float = 0.16
88
+ # How long a sweep keeps its open-palm status after the pose flickers out.
89
+ # Zero demands the pose every frame, which is what a swipe used to require.
90
+ swipe_grace_ms: float = 0.0
91
+ gesture_cooldown_ms: float = 500.0
92
+
93
+
94
+ @dataclass
95
+ class ModesConfig:
96
+ suspend_on_physical_input: bool = True
97
+ resume_after_s: float = 3.0
98
+ start_engaged: bool = False
99
+
100
+
101
+ @dataclass
102
+ class NativeConfig:
103
+ """Settings for the native interaction helper in ``native/``.
104
+
105
+ Field names are the wire contract with the Swift side, which decodes this
106
+ dataclass verbatim from JSON. Renaming one here means renaming it in
107
+ ``native/Sources/Bridge/Tuning.swift`` too.
108
+ """
109
+
110
+ # Fall back to posting events straight from Python. Everything still works,
111
+ # but without smoothing, snapping or a highlight.
112
+ enabled: bool = True
113
+
114
+ # --- motion ---
115
+ # Seconds for the cursor to close most of the gap to where the hand points.
116
+ # The camera offers thirty positions a second and the display wants a hundred
117
+ # and twenty, so the difference has to be interpolated rather than stepped.
118
+ motion_time_constant: float = 0.045
119
+ minimum_step_pixels: float = 0.35
120
+ maximum_speed: float = 26000.0
121
+
122
+ # --- snapping ---
123
+ snap_enabled: bool = True
124
+ # Pixels from a target at which its pull starts to be felt.
125
+ snap_radius: float = 96.0
126
+ snap_strength: float = 0.75
127
+ # Targets no larger than this pull to their centre; bigger ones pull only to
128
+ # their nearest edge, so a large panel can be entered anywhere.
129
+ small_target_pixels: float = 72.0
130
+ # Bonus the current target keeps, so the highlight does not flicker between
131
+ # two adjacent controls. Hysteresis in space, like pinch_close/pinch_open in time.
132
+ snap_stickiness: float = 1.4
133
+ # How much to favour targets in the direction the hand is travelling.
134
+ snap_heading_weight: float = 0.45
135
+ # Snap to words inside text, not only to the text element as a whole.
136
+ text_snap_enabled: bool = True
137
+
138
+ # --- probing ---
139
+ # Milliseconds between accessibility hit tests. Measured at 0.43 ms median but
140
+ # 3.46 ms at p95, which is why it runs on its own thread.
141
+ probe_interval_ms: float = 16.0
142
+ probe_lookahead_s: float = 0.08
143
+ # Give up on an application that will not answer this quickly, in seconds.
144
+ probe_timeout_s: float = 0.05
145
+ target_lifetime_ms: float = 350.0
146
+
147
+ # --- highlight ---
148
+ overlay_enabled: bool = True
149
+ overlay_corner_radius: float = 6.0
150
+ overlay_border_width: float = 2.0
151
+ overlay_glide_s: float = 0.11
152
+ overlay_border_color: list[float] = field(default_factory=lambda: [0.36, 0.72, 1.0, 0.95])
153
+ overlay_fill_color: list[float] = field(default_factory=lambda: [0.36, 0.72, 1.0, 0.14])
154
+
155
+
156
+ @dataclass
157
+ class DebugConfig:
158
+ overlay: bool = False
159
+ stats_interval_s: float = 0.0
160
+
161
+
162
+ @dataclass
163
+ class KeyBinding:
164
+ key: str
165
+ mods: list[str] = field(default_factory=list)
166
+
167
+
168
+ DEFAULT_BINDINGS: dict[str, str] = {
169
+ "swipe_left": "desktop_left",
170
+ "swipe_right": "desktop_right",
171
+ "palm_push_up": "mission_control",
172
+ "telephone": "dictation",
173
+ }
174
+
175
+ DEFAULT_KEYS: dict[str, KeyBinding] = {
176
+ "dictation": KeyBinding("f5", []),
177
+ "mission_control": KeyBinding("up", ["ctrl"]),
178
+ "desktop_left": KeyBinding("left", ["ctrl"]),
179
+ "desktop_right": KeyBinding("right", ["ctrl"]),
180
+ }
181
+
182
+
183
+ @dataclass
184
+ class Config:
185
+ cameras: CameraConfig = field(default_factory=CameraConfig)
186
+ tracking: TrackingConfig = field(default_factory=TrackingConfig)
187
+ pointer: PointerConfig = field(default_factory=PointerConfig)
188
+ gaze: GazeConfig = field(default_factory=GazeConfig)
189
+ gestures: GestureConfig = field(default_factory=GestureConfig)
190
+ modes: ModesConfig = field(default_factory=ModesConfig)
191
+ native: NativeConfig = field(default_factory=NativeConfig)
192
+ debug: DebugConfig = field(default_factory=DebugConfig)
193
+ bindings: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_BINDINGS))
194
+ keys: dict[str, KeyBinding] = field(default_factory=lambda: dict(DEFAULT_KEYS))
195
+ source_path: Path | None = None
196
+
197
+
198
+ def _apply(target: Any, values: dict[str, Any], where: str) -> None:
199
+ """Overlay a TOML table onto a dataclass instance, ignoring unknown keys."""
200
+ known = {f.name: f.type for f in fields(target)}
201
+ for key, value in values.items():
202
+ if key not in known:
203
+ print(f"[config] ignoring unknown key {where}.{key}")
204
+ continue
205
+ setattr(target, key, value)
206
+
207
+
208
+ def config_search_path(explicit: Path | None = None) -> list[Path]:
209
+ if explicit:
210
+ return [explicit]
211
+ return [Path.cwd() / "config.toml", APP_DIR / "config.toml"]
212
+
213
+
214
+ def load(explicit: Path | None = None) -> Config:
215
+ """Load config from the first path that exists, else return defaults."""
216
+ cfg = Config()
217
+ for candidate in config_search_path(explicit):
218
+ if not candidate.is_file():
219
+ continue
220
+ with candidate.open("rb") as handle:
221
+ raw = tomllib.load(handle)
222
+ for name, value in raw.items():
223
+ if name == "bindings":
224
+ cfg.bindings.update(value)
225
+ elif name == "keys":
226
+ cfg.keys.update(
227
+ {
228
+ action: KeyBinding(spec["key"], list(spec.get("mods", [])))
229
+ for action, spec in value.items()
230
+ }
231
+ )
232
+ elif hasattr(cfg, name) and is_dataclass(getattr(cfg, name)):
233
+ _apply(getattr(cfg, name), value, name)
234
+ else:
235
+ print(f"[config] ignoring unknown section [{name}]")
236
+ cfg.source_path = candidate
237
+ break
238
+ return cfg
239
+
240
+
241
+ def ensure_dirs() -> None:
242
+ for path in (APP_DIR, STATE_DIR, CACHE_DIR):
243
+ path.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1 @@
1
+ """Turning intents into real macOS input events."""