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
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."""
|