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,256 @@
|
|
|
1
|
+
"""Where an intent leaves Python.
|
|
2
|
+
|
|
3
|
+
Two ways out, same interface.
|
|
4
|
+
|
|
5
|
+
*Through the native helper*, when it is running. It owns the cursor, integrates
|
|
6
|
+
motion at display rate, snaps to whatever is nearest, and draws the highlight --
|
|
7
|
+
none of which can be done from here, because it needs a thread that is never
|
|
8
|
+
behind the GIL and a hundred accessibility hit tests a second. This is the path
|
|
9
|
+
that feels smooth, and it is the default.
|
|
10
|
+
|
|
11
|
+
*Straight to Quartz*, when the helper is not available -- not built yet, refused
|
|
12
|
+
permission, or crashed. Not a legacy path: it is the reason a missing Swift
|
|
13
|
+
toolchain degrades the feel rather than breaking the app. It posts one event per
|
|
14
|
+
camera frame, which is exactly as stepped as it sounds, and is why the helper
|
|
15
|
+
exists.
|
|
16
|
+
|
|
17
|
+
Two details are load-bearing on both paths.
|
|
18
|
+
|
|
19
|
+
*Tagging.* Every event carries a user-data marker so the physical-input watcher
|
|
20
|
+
can tell our own synthetic moves from the user's real ones. Without it the app
|
|
21
|
+
would see its own cursor motion, decide a human grabbed the mouse, and suspend
|
|
22
|
+
itself the instant it started working. The helper stamps the same marker, which
|
|
23
|
+
is why ``events.EVENT_MARKER`` and ``eventMarker`` in ``Cursor.swift`` have to
|
|
24
|
+
agree.
|
|
25
|
+
|
|
26
|
+
*Click chaining.* macOS decides what a double click is from the click-state field
|
|
27
|
+
on the event, not from two clicks arriving close together. Real trackpads set it;
|
|
28
|
+
so do we, which is what makes two quick pinches open a folder in Finder.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
import Quartz
|
|
36
|
+
|
|
37
|
+
from .bridge import Bridge
|
|
38
|
+
from .events import create_source, post
|
|
39
|
+
|
|
40
|
+
_BUTTON_EVENTS = {
|
|
41
|
+
"left": (Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp, Quartz.kCGMouseButtonLeft),
|
|
42
|
+
"right": (
|
|
43
|
+
Quartz.kCGEventRightMouseDown,
|
|
44
|
+
Quartz.kCGEventRightMouseUp,
|
|
45
|
+
Quartz.kCGMouseButtonRight,
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
_DRAG_EVENTS = {
|
|
49
|
+
"left": Quartz.kCGEventLeftMouseDragged,
|
|
50
|
+
"right": Quartz.kCGEventRightMouseDragged,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main_display_bounds() -> tuple[float, float, float, float]:
|
|
55
|
+
"""The main display's rect, as (min_x, min_y, max_x, max_y).
|
|
56
|
+
|
|
57
|
+
Gaze is expressed as a fraction of this one screen rather than of the whole
|
|
58
|
+
desktop. Calibration can only teach where you look on the screen the camera
|
|
59
|
+
watched you look at, and a fraction of a three-monitor desktop would put the
|
|
60
|
+
cursor on a display you were never calibrated for.
|
|
61
|
+
"""
|
|
62
|
+
box = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID())
|
|
63
|
+
return (
|
|
64
|
+
box.origin.x,
|
|
65
|
+
box.origin.y,
|
|
66
|
+
box.origin.x + box.size.width,
|
|
67
|
+
box.origin.y + box.size.height,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def desktop_bounds() -> tuple[float, float, float, float]:
|
|
72
|
+
"""Union of every active display, as (min_x, min_y, max_x, max_y).
|
|
73
|
+
|
|
74
|
+
Cursor coordinates are global across displays, so clamping to the main
|
|
75
|
+
screen would trap the pointer on a multi-monitor desk. Hands can reach every
|
|
76
|
+
screen; only gaze is confined to the calibrated one.
|
|
77
|
+
"""
|
|
78
|
+
error, display_ids, _ = Quartz.CGGetActiveDisplayList(16, None, None)
|
|
79
|
+
if error or not display_ids:
|
|
80
|
+
main = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID())
|
|
81
|
+
return (
|
|
82
|
+
main.origin.x,
|
|
83
|
+
main.origin.y,
|
|
84
|
+
main.origin.x + main.size.width,
|
|
85
|
+
main.origin.y + main.size.height,
|
|
86
|
+
)
|
|
87
|
+
boxes = [Quartz.CGDisplayBounds(display_id) for display_id in display_ids]
|
|
88
|
+
return (
|
|
89
|
+
min(b.origin.x for b in boxes),
|
|
90
|
+
min(b.origin.y for b in boxes),
|
|
91
|
+
max(b.origin.x + b.size.width for b in boxes),
|
|
92
|
+
max(b.origin.y + b.size.height for b in boxes),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Mouse:
|
|
97
|
+
"""Posts cursor, button and scroll events, through the helper when it is up.
|
|
98
|
+
|
|
99
|
+
Drag state is tracked here regardless of which path is in use, because the
|
|
100
|
+
press and release that bracket it are still decided in Python -- the gaze arm
|
|
101
|
+
reads :attr:`dragging` to know it must not warp mid-drag.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, double_click_ms: float = 400.0, bridge: Bridge | None = None) -> None:
|
|
105
|
+
self._source = create_source()
|
|
106
|
+
self._double_click_ms = double_click_ms
|
|
107
|
+
self._bridge = bridge
|
|
108
|
+
self._held: str | None = None
|
|
109
|
+
self._last_click_at = 0.0
|
|
110
|
+
self._last_click_point = (0.0, 0.0)
|
|
111
|
+
self._click_run = 0
|
|
112
|
+
self.refresh_bounds()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def _native(self) -> Bridge | None:
|
|
116
|
+
"""The helper, if it is there to take the intent."""
|
|
117
|
+
bridge = self._bridge
|
|
118
|
+
return bridge if bridge is not None and bridge.connected else None
|
|
119
|
+
|
|
120
|
+
def refresh_bounds(self) -> None:
|
|
121
|
+
"""Re-read display geometry, for when a monitor is plugged in or unplugged."""
|
|
122
|
+
self._min_x, self._min_y, self._max_x, self._max_y = desktop_bounds()
|
|
123
|
+
self._gaze_box = main_display_bounds()
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def size(self) -> tuple[float, float]:
|
|
127
|
+
"""Size of the whole desktop, which hand movement may roam across."""
|
|
128
|
+
return self._max_x - self._min_x, self._max_y - self._min_y
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def gaze_size(self) -> tuple[float, float]:
|
|
132
|
+
"""Size of the display gaze is calibrated against."""
|
|
133
|
+
left, top, right, bottom = self._gaze_box
|
|
134
|
+
return right - left, bottom - top
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def dragging(self) -> bool:
|
|
138
|
+
return self._held is not None
|
|
139
|
+
|
|
140
|
+
def location(self) -> tuple[float, float]:
|
|
141
|
+
"""Where the cursor actually is right now.
|
|
142
|
+
|
|
143
|
+
Read from the system rather than remembered, so that if you nudge the
|
|
144
|
+
real mouse, gesture movement carries on from where you left it.
|
|
145
|
+
"""
|
|
146
|
+
point = Quartz.CGEventGetLocation(Quartz.CGEventCreate(None))
|
|
147
|
+
return point.x, point.y
|
|
148
|
+
|
|
149
|
+
def _clamp(self, x: float, y: float) -> tuple[float, float]:
|
|
150
|
+
return (
|
|
151
|
+
min(max(x, self._min_x), self._max_x - 1.0),
|
|
152
|
+
min(max(y, self._min_y), self._max_y - 1.0),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _post(self, event) -> None:
|
|
156
|
+
post(event)
|
|
157
|
+
|
|
158
|
+
def _move_event(self, x: float, y: float) -> None:
|
|
159
|
+
# While a button is down the motion must be posted as a drag, or the app
|
|
160
|
+
# underneath sees the cursor teleport without ever being dragged.
|
|
161
|
+
if self._held is not None:
|
|
162
|
+
event_type = _DRAG_EVENTS[self._held]
|
|
163
|
+
button = _BUTTON_EVENTS[self._held][2]
|
|
164
|
+
else:
|
|
165
|
+
event_type = Quartz.kCGEventMouseMoved
|
|
166
|
+
button = Quartz.kCGMouseButtonLeft
|
|
167
|
+
self._post(Quartz.CGEventCreateMouseEvent(self._source, event_type, (x, y), button))
|
|
168
|
+
|
|
169
|
+
def move_by(self, dx: float, dy: float) -> None:
|
|
170
|
+
native = self._native
|
|
171
|
+
if native is not None:
|
|
172
|
+
# The helper accumulates this into a goal and walks the cursor there at
|
|
173
|
+
# display rate. Sending a delta rather than a destination is what lets
|
|
174
|
+
# it do that without ever reading the cursor back.
|
|
175
|
+
native.move_by(dx, dy)
|
|
176
|
+
return
|
|
177
|
+
x, y = self.location()
|
|
178
|
+
self._move_event(*self._clamp(x + dx, y + dy))
|
|
179
|
+
|
|
180
|
+
def move_to_fraction(self, fx: float, fy: float) -> None:
|
|
181
|
+
"""Jump to a point given as fractions of the calibrated display, for gaze warps."""
|
|
182
|
+
native = self._native
|
|
183
|
+
if native is not None:
|
|
184
|
+
native.warp_to_fraction(fx, fy)
|
|
185
|
+
return
|
|
186
|
+
left, top, _, _ = self._gaze_box
|
|
187
|
+
width, height = self.gaze_size
|
|
188
|
+
self._move_event(*self._clamp(left + fx * width, top + fy * height))
|
|
189
|
+
|
|
190
|
+
def click(self, button: str = "left") -> None:
|
|
191
|
+
"""Click, chaining into a double or triple click when repeated quickly."""
|
|
192
|
+
if button not in _BUTTON_EVENTS:
|
|
193
|
+
return
|
|
194
|
+
native = self._native
|
|
195
|
+
if native is not None:
|
|
196
|
+
# Chaining is done on the far side, where the click's actual landing
|
|
197
|
+
# point is known: it resolves to the snapped target, not to wherever
|
|
198
|
+
# the cursor had drifted to.
|
|
199
|
+
native.click(button)
|
|
200
|
+
return
|
|
201
|
+
down, up, index = _BUTTON_EVENTS[button]
|
|
202
|
+
x, y = self.location()
|
|
203
|
+
now = time.monotonic()
|
|
204
|
+
near = abs(x - self._last_click_point[0]) < 6 and abs(y - self._last_click_point[1]) < 6
|
|
205
|
+
in_time = (now - self._last_click_at) * 1000.0 < self._double_click_ms
|
|
206
|
+
self._click_run = self._click_run + 1 if (near and in_time) else 1
|
|
207
|
+
self._last_click_at = now
|
|
208
|
+
self._last_click_point = (x, y)
|
|
209
|
+
|
|
210
|
+
for event_type in (down, up):
|
|
211
|
+
event = Quartz.CGEventCreateMouseEvent(self._source, event_type, (x, y), index)
|
|
212
|
+
if event is not None:
|
|
213
|
+
Quartz.CGEventSetIntegerValueField(
|
|
214
|
+
event, Quartz.kCGMouseEventClickState, min(self._click_run, 3)
|
|
215
|
+
)
|
|
216
|
+
self._post(event)
|
|
217
|
+
|
|
218
|
+
def press(self, button: str = "left") -> None:
|
|
219
|
+
if button not in _BUTTON_EVENTS or self._held is not None:
|
|
220
|
+
return
|
|
221
|
+
self._held = button
|
|
222
|
+
native = self._native
|
|
223
|
+
if native is not None:
|
|
224
|
+
native.press(button)
|
|
225
|
+
return
|
|
226
|
+
down, _, index = _BUTTON_EVENTS[button]
|
|
227
|
+
x, y = self.location()
|
|
228
|
+
self._post(Quartz.CGEventCreateMouseEvent(self._source, down, (x, y), index))
|
|
229
|
+
|
|
230
|
+
def release(self, button: str | None = None) -> None:
|
|
231
|
+
"""Let go of a held button. Safe to call when nothing is held."""
|
|
232
|
+
held = self._held if button is None else button
|
|
233
|
+
self._held = None
|
|
234
|
+
if held is None or held not in _BUTTON_EVENTS:
|
|
235
|
+
return
|
|
236
|
+
native = self._native
|
|
237
|
+
if native is not None:
|
|
238
|
+
native.release(held)
|
|
239
|
+
return
|
|
240
|
+
_, up, index = _BUTTON_EVENTS[held]
|
|
241
|
+
x, y = self.location()
|
|
242
|
+
self._post(Quartz.CGEventCreateMouseEvent(self._source, up, (x, y), index))
|
|
243
|
+
|
|
244
|
+
def scroll(self, dx: float, dy: float) -> None:
|
|
245
|
+
"""Scroll by a pixel delta, following the hand as if it held the page."""
|
|
246
|
+
native = self._native
|
|
247
|
+
if native is not None:
|
|
248
|
+
native.scroll(dx, dy)
|
|
249
|
+
return
|
|
250
|
+
# Pulling your hand down should drag the content down, which in wheel
|
|
251
|
+
# terms is a positive vertical value; the camera's y axis grows downward,
|
|
252
|
+
# hence the negation.
|
|
253
|
+
event = Quartz.CGEventCreateScrollWheelEvent2(
|
|
254
|
+
self._source, Quartz.kCGScrollEventUnitPixel, 2, int(-dy), int(dx), 0
|
|
255
|
+
)
|
|
256
|
+
self._post(event)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Live overlay for tuning.
|
|
2
|
+
|
|
3
|
+
Most of the thresholds in `config.toml` are only meaningful against real numbers
|
|
4
|
+
from your own hands in your own lighting, so this window shows them: the
|
|
5
|
+
skeleton, the classified pose, the live pinch distance, and where gaze thinks you
|
|
6
|
+
are looking.
|
|
7
|
+
|
|
8
|
+
On macOS an OpenCV window must own the main thread, which the menu bar already
|
|
9
|
+
does, so the viewer runs in a separate process and is fed finished images. When
|
|
10
|
+
running headless (`--debug`) the main thread is free and drawing happens inline.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import multiprocessing as mp
|
|
17
|
+
import queue
|
|
18
|
+
|
|
19
|
+
import cv2
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .capture import Frame
|
|
23
|
+
from .geometry import SKELETON, Pose
|
|
24
|
+
from .pipeline import PipelineStatus
|
|
25
|
+
|
|
26
|
+
VIEW_WIDTH = 720
|
|
27
|
+
POSE_COLOURS = {
|
|
28
|
+
Pose.READY: (120, 220, 255),
|
|
29
|
+
Pose.FIST: (255, 170, 90),
|
|
30
|
+
Pose.OPEN_PALM: (140, 240, 140),
|
|
31
|
+
Pose.TELEPHONE: (220, 160, 255),
|
|
32
|
+
}
|
|
33
|
+
DEFAULT_COLOUR = (170, 170, 170)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render(
|
|
37
|
+
frame: Frame, hands: list, status: PipelineStatus, gaze: tuple[float, float] | None
|
|
38
|
+
) -> np.ndarray:
|
|
39
|
+
"""Draw one annotated frame, scaled down for cheap display."""
|
|
40
|
+
image = frame.image
|
|
41
|
+
scale = VIEW_WIDTH / max(image.shape[1], 1)
|
|
42
|
+
canvas = cv2.resize(image, (VIEW_WIDTH, int(image.shape[0] * scale)))
|
|
43
|
+
height, width = canvas.shape[:2]
|
|
44
|
+
|
|
45
|
+
for fused in hands:
|
|
46
|
+
features = fused.features
|
|
47
|
+
colour = POSE_COLOURS.get(features.pose, DEFAULT_COLOUR)
|
|
48
|
+
points = [(int(p[0] * width), int(p[1] * height)) for p in features.landmarks[:, :2]]
|
|
49
|
+
for start, end in SKELETON:
|
|
50
|
+
cv2.line(canvas, points[start], points[end], colour, 1, cv2.LINE_AA)
|
|
51
|
+
for point in points:
|
|
52
|
+
cv2.circle(canvas, point, 2, colour, -1, cv2.LINE_AA)
|
|
53
|
+
|
|
54
|
+
anchor = (int(features.anchor[0] * width), int(features.anchor[1] * height))
|
|
55
|
+
cv2.circle(canvas, anchor, 7, colour, 2, cv2.LINE_AA)
|
|
56
|
+
label = f"{features.handedness} {features.pose.value} pinch {features.pinch:.2f}"
|
|
57
|
+
if fused.merged:
|
|
58
|
+
label += f" x{len(fused.cameras)}"
|
|
59
|
+
cv2.putText(
|
|
60
|
+
canvas,
|
|
61
|
+
label,
|
|
62
|
+
(anchor[0] - 60, max(anchor[1] - 16, 14)),
|
|
63
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
64
|
+
0.45,
|
|
65
|
+
colour,
|
|
66
|
+
1,
|
|
67
|
+
cv2.LINE_AA,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_draw_status(canvas, status)
|
|
71
|
+
if gaze is not None:
|
|
72
|
+
_draw_gaze_inset(canvas, gaze)
|
|
73
|
+
return canvas
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _draw_status(canvas: np.ndarray, status: PipelineStatus) -> None:
|
|
77
|
+
lines = [
|
|
78
|
+
f"{status.fps:5.1f} fps mode {status.mode}",
|
|
79
|
+
f"gesture {status.gesture}",
|
|
80
|
+
f"cameras {','.join(str(c) for c in status.cameras) or '-'}"
|
|
81
|
+
f"{' merged' if status.merged else ''}"
|
|
82
|
+
f" gaze {'ready' if status.gaze_ready else 'uncalibrated'}",
|
|
83
|
+
]
|
|
84
|
+
cv2.rectangle(canvas, (0, 0), (canvas.shape[1], 14 + 20 * len(lines)), (20, 20, 20), -1)
|
|
85
|
+
for index, text in enumerate(lines):
|
|
86
|
+
cv2.putText(
|
|
87
|
+
canvas,
|
|
88
|
+
text,
|
|
89
|
+
(10, 20 + index * 20),
|
|
90
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
91
|
+
0.48,
|
|
92
|
+
(230, 230, 230),
|
|
93
|
+
1,
|
|
94
|
+
cv2.LINE_AA,
|
|
95
|
+
)
|
|
96
|
+
for index, problem in enumerate(status.problems[:2]):
|
|
97
|
+
cv2.putText(
|
|
98
|
+
canvas,
|
|
99
|
+
problem[:70],
|
|
100
|
+
(10, canvas.shape[0] - 12 - index * 18),
|
|
101
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
102
|
+
0.45,
|
|
103
|
+
(120, 140, 255),
|
|
104
|
+
1,
|
|
105
|
+
cv2.LINE_AA,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _draw_gaze_inset(canvas: np.ndarray, gaze: tuple[float, float]) -> None:
|
|
110
|
+
"""Show gaze on a small proxy of the screen.
|
|
111
|
+
|
|
112
|
+
Gaze is a screen coordinate, not a camera one, so plotting it on the video
|
|
113
|
+
would put it somewhere meaningless. A miniature screen keeps it honest.
|
|
114
|
+
"""
|
|
115
|
+
box_w, box_h = 150, 94
|
|
116
|
+
x0, y0 = canvas.shape[1] - box_w - 12, 12
|
|
117
|
+
cv2.rectangle(canvas, (x0, y0), (x0 + box_w, y0 + box_h), (70, 70, 70), 1)
|
|
118
|
+
cv2.circle(
|
|
119
|
+
canvas,
|
|
120
|
+
(int(x0 + gaze[0] * box_w), int(y0 + gaze[1] * box_h)),
|
|
121
|
+
5,
|
|
122
|
+
(110, 230, 255),
|
|
123
|
+
-1,
|
|
124
|
+
cv2.LINE_AA,
|
|
125
|
+
)
|
|
126
|
+
cv2.putText(
|
|
127
|
+
canvas, "gaze", (x0 + 4, y0 + box_h - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (90, 90, 90), 1
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _viewer(frames: mp.Queue, title: str) -> None:
|
|
132
|
+
"""Child-process loop: show whatever arrives until told to stop."""
|
|
133
|
+
while True:
|
|
134
|
+
try:
|
|
135
|
+
image = frames.get(timeout=0.5)
|
|
136
|
+
except queue.Empty:
|
|
137
|
+
if cv2.waitKey(1) & 0xFF == 27:
|
|
138
|
+
break
|
|
139
|
+
continue
|
|
140
|
+
if image is None:
|
|
141
|
+
break
|
|
142
|
+
cv2.imshow(title, image)
|
|
143
|
+
if cv2.waitKey(1) & 0xFF == 27:
|
|
144
|
+
break
|
|
145
|
+
cv2.destroyAllWindows()
|
|
146
|
+
for _ in range(4):
|
|
147
|
+
cv2.waitKey(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class DebugView:
|
|
151
|
+
"""An overlay window hosted in its own process."""
|
|
152
|
+
|
|
153
|
+
def __init__(self, title: str = "mindcontrol") -> None:
|
|
154
|
+
self._title = title
|
|
155
|
+
self._context = mp.get_context("spawn")
|
|
156
|
+
self._queue: mp.Queue | None = None
|
|
157
|
+
self._process = None
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def running(self) -> bool:
|
|
161
|
+
return self._process is not None and self._process.is_alive()
|
|
162
|
+
|
|
163
|
+
def open(self) -> None:
|
|
164
|
+
if self.running:
|
|
165
|
+
return
|
|
166
|
+
self._queue = self._context.Queue(maxsize=1)
|
|
167
|
+
self._process = self._context.Process(
|
|
168
|
+
target=_viewer, args=(self._queue, self._title), daemon=True
|
|
169
|
+
)
|
|
170
|
+
self._process.start()
|
|
171
|
+
|
|
172
|
+
def push(self, image: np.ndarray) -> None:
|
|
173
|
+
"""Offer a frame, dropping it if the viewer is still busy with the last one."""
|
|
174
|
+
if not self.running or self._queue is None:
|
|
175
|
+
return
|
|
176
|
+
with contextlib.suppress(queue.Full):
|
|
177
|
+
self._queue.put_nowait(image)
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
if self._queue is not None:
|
|
181
|
+
with contextlib.suppress(queue.Full):
|
|
182
|
+
self._queue.put_nowait(None)
|
|
183
|
+
if self._process is not None:
|
|
184
|
+
self._process.join(timeout=1.5)
|
|
185
|
+
if self._process.is_alive():
|
|
186
|
+
self._process.terminate()
|
|
187
|
+
self._process = None
|
|
188
|
+
self._queue = None
|
mindcontrol/devices.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Finding out which cameras exist.
|
|
2
|
+
|
|
3
|
+
OpenCV offers no way to enumerate capture devices, only to try opening one by
|
|
4
|
+
index, so discovery means probing indices and seeing what answers. macOS reports
|
|
5
|
+
proper device names through AVFoundation, which is worth reading: index order is
|
|
6
|
+
not stable across reboots or replugs, and picking a camera by number alone is how
|
|
7
|
+
you end up gaze-tracking from the camera pointed at the wall.
|
|
8
|
+
|
|
9
|
+
Names are a strong hint rather than proof. AVFoundation's discovery order has been
|
|
10
|
+
observed to disagree with OpenCV's indices, and there is no identifier shared
|
|
11
|
+
between the two APIs to reconcile them. So `--preview` exists: it shows a frame
|
|
12
|
+
from each index, which is the only unambiguous way to learn which camera is which.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import cv2
|
|
21
|
+
|
|
22
|
+
MAX_PROBE = 8
|
|
23
|
+
# A camera that has just been opened can take a moment to deliver its first
|
|
24
|
+
# frame -- built-in webcams especially, since the indicator light has to come up.
|
|
25
|
+
# Giving up after a single failed read reports a working camera as absent.
|
|
26
|
+
READ_ATTEMPTS = 5
|
|
27
|
+
READ_DELAY_S = 0.12
|
|
28
|
+
# AVFoundation reports a placeholder frame rate until the stream settles, so a
|
|
29
|
+
# camera that woke slowly can advertise 1fps while really delivering 30. Reporting
|
|
30
|
+
# that is worse than saying nothing: it invites the user to exclude a good camera.
|
|
31
|
+
SETTLE_READS = 5
|
|
32
|
+
MEASURE_FRAMES = 15
|
|
33
|
+
PLAUSIBLE_FPS = 5.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _frame_rate(capture: cv2.VideoCapture) -> float:
|
|
37
|
+
"""The camera's real frame rate, timed directly if it will not say."""
|
|
38
|
+
for _ in range(SETTLE_READS):
|
|
39
|
+
capture.read()
|
|
40
|
+
declared = float(capture.get(cv2.CAP_PROP_FPS))
|
|
41
|
+
if declared >= PLAUSIBLE_FPS:
|
|
42
|
+
return declared
|
|
43
|
+
start = time.perf_counter()
|
|
44
|
+
read = sum(bool(capture.read()[0]) for _ in range(MEASURE_FRAMES))
|
|
45
|
+
elapsed = time.perf_counter() - start
|
|
46
|
+
return read / elapsed if elapsed > 0 and read else declared
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Device:
|
|
51
|
+
index: int
|
|
52
|
+
width: int
|
|
53
|
+
height: int
|
|
54
|
+
fps: float
|
|
55
|
+
name: str = ""
|
|
56
|
+
usable: bool = True
|
|
57
|
+
problem: str = ""
|
|
58
|
+
|
|
59
|
+
def describe(self) -> str:
|
|
60
|
+
label = self.name or "unnamed device"
|
|
61
|
+
if not self.usable:
|
|
62
|
+
return f" [{self.index}] {label} UNAVAILABLE - {self.problem}"
|
|
63
|
+
return f" [{self.index}] {label} {self.width}x{self.height} @ {self.fps:.0f}fps"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def names() -> list[str]:
|
|
67
|
+
"""Camera names in AVFoundation's order, which matches OpenCV's indices."""
|
|
68
|
+
try:
|
|
69
|
+
import AVFoundation
|
|
70
|
+
except ImportError:
|
|
71
|
+
return []
|
|
72
|
+
try:
|
|
73
|
+
# Modern discovery session; the older devicesWithMediaType_ is deprecated
|
|
74
|
+
# and returns nothing on recent macOS.
|
|
75
|
+
discover = (
|
|
76
|
+
AVFoundation.AVCaptureDeviceDiscoverySession
|
|
77
|
+
.discoverySessionWithDeviceTypes_mediaType_position_
|
|
78
|
+
)
|
|
79
|
+
session = discover(
|
|
80
|
+
[
|
|
81
|
+
AVFoundation.AVCaptureDeviceTypeBuiltInWideAngleCamera,
|
|
82
|
+
AVFoundation.AVCaptureDeviceTypeExternal,
|
|
83
|
+
AVFoundation.AVCaptureDeviceTypeContinuityCamera,
|
|
84
|
+
],
|
|
85
|
+
AVFoundation.AVMediaTypeVideo,
|
|
86
|
+
0,
|
|
87
|
+
)
|
|
88
|
+
return [str(device.localizedName()) for device in session.devices()]
|
|
89
|
+
except (AttributeError, TypeError):
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def probe(max_index: int = MAX_PROBE) -> list[Device]:
|
|
94
|
+
"""Open each index in turn and report what it can actually deliver.
|
|
95
|
+
|
|
96
|
+
Bounded by the AVFoundation device count when that is available, because
|
|
97
|
+
probing past the last device makes OpenCV shout about indices being out of
|
|
98
|
+
bounds -- noise that looks like a failure but is just the end of the list.
|
|
99
|
+
"""
|
|
100
|
+
labels = names()
|
|
101
|
+
limit = min(max_index, len(labels)) if labels else max_index
|
|
102
|
+
found: list[Device] = []
|
|
103
|
+
|
|
104
|
+
for index in range(limit):
|
|
105
|
+
name = labels[index] if index < len(labels) else ""
|
|
106
|
+
capture = cv2.VideoCapture(index, cv2.CAP_AVFOUNDATION)
|
|
107
|
+
if not capture.isOpened():
|
|
108
|
+
capture.release()
|
|
109
|
+
found.append(
|
|
110
|
+
Device(index, 0, 0, 0.0, name, usable=False, problem="could not be opened")
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# isOpened() alone is optimistic; a device that cannot produce an image
|
|
115
|
+
# is no use to us, so insist on a real frame before believing it.
|
|
116
|
+
frame = None
|
|
117
|
+
for _ in range(READ_ATTEMPTS):
|
|
118
|
+
ok, frame = capture.read()
|
|
119
|
+
if ok and frame is not None:
|
|
120
|
+
break
|
|
121
|
+
time.sleep(READ_DELAY_S)
|
|
122
|
+
frame = None
|
|
123
|
+
|
|
124
|
+
if frame is None:
|
|
125
|
+
found.append(
|
|
126
|
+
Device(
|
|
127
|
+
index, 0, 0, 0.0, name,
|
|
128
|
+
usable=False,
|
|
129
|
+
problem="opened but sent no frames (in use by another app?)",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
found.append(
|
|
134
|
+
Device(
|
|
135
|
+
index=index,
|
|
136
|
+
width=int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
|
137
|
+
height=int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)),
|
|
138
|
+
fps=_frame_rate(capture),
|
|
139
|
+
name=name,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
capture.release()
|
|
143
|
+
return found
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def preview(devices: list[Device], height: int = 260) -> None:
|
|
147
|
+
"""Show one frame from each usable camera, side by side and labelled.
|
|
148
|
+
|
|
149
|
+
Grabbed one device at a time: opening several cameras at once can exceed the
|
|
150
|
+
bus bandwidth and make an innocent device look broken.
|
|
151
|
+
"""
|
|
152
|
+
import numpy as np
|
|
153
|
+
|
|
154
|
+
tiles = []
|
|
155
|
+
for device in devices:
|
|
156
|
+
if not device.usable:
|
|
157
|
+
continue
|
|
158
|
+
capture = cv2.VideoCapture(device.index, cv2.CAP_AVFOUNDATION)
|
|
159
|
+
frame = None
|
|
160
|
+
for _ in range(READ_ATTEMPTS):
|
|
161
|
+
ok, candidate = capture.read()
|
|
162
|
+
if ok and candidate is not None:
|
|
163
|
+
frame = candidate
|
|
164
|
+
break
|
|
165
|
+
time.sleep(READ_DELAY_S)
|
|
166
|
+
capture.release()
|
|
167
|
+
if frame is None:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
scaled = cv2.resize(frame, (int(frame.shape[1] * height / frame.shape[0]), height))
|
|
171
|
+
cv2.rectangle(scaled, (0, 0), (scaled.shape[1], 30), (20, 20, 20), -1)
|
|
172
|
+
cv2.putText(
|
|
173
|
+
scaled,
|
|
174
|
+
f"[{device.index}] {device.name}",
|
|
175
|
+
(8, 21),
|
|
176
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
177
|
+
0.6,
|
|
178
|
+
(240, 240, 240),
|
|
179
|
+
1,
|
|
180
|
+
cv2.LINE_AA,
|
|
181
|
+
)
|
|
182
|
+
tiles.append(scaled)
|
|
183
|
+
|
|
184
|
+
if not tiles:
|
|
185
|
+
print("[cameras] nothing to preview")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
print("[cameras] preview open; press any key to close")
|
|
189
|
+
cv2.imshow("mindcontrol cameras", np.hstack(tiles))
|
|
190
|
+
cv2.waitKey(0)
|
|
191
|
+
cv2.destroyAllWindows()
|
|
192
|
+
for _ in range(4):
|
|
193
|
+
cv2.waitKey(1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run(max_index: int = MAX_PROBE, show: bool = False) -> int:
|
|
197
|
+
devices = probe(max_index)
|
|
198
|
+
usable = [device for device in devices if device.usable]
|
|
199
|
+
|
|
200
|
+
if not devices:
|
|
201
|
+
print("[cameras] none found; check System Settings > Privacy & Security > Camera")
|
|
202
|
+
return 2
|
|
203
|
+
|
|
204
|
+
print(f"[cameras] {len(usable)} of {len(devices)} device(s) usable:")
|
|
205
|
+
for device in devices:
|
|
206
|
+
print(device.describe())
|
|
207
|
+
|
|
208
|
+
if not usable:
|
|
209
|
+
return 2
|
|
210
|
+
|
|
211
|
+
indices = ", ".join(str(d.index) for d in usable)
|
|
212
|
+
print("\nto use them together, in config.toml:")
|
|
213
|
+
print(f" [cameras]\n devices = [{indices}]\n primary_gaze = {usable[0].index}")
|
|
214
|
+
print("\nprimary_gaze should be whichever camera sits nearest the screen you look at;")
|
|
215
|
+
print("it is the one gaze is estimated from, so put it under your main display.")
|
|
216
|
+
print("\nNames come from AVFoundation and its order can disagree with these indices.")
|
|
217
|
+
print("Run 'mindcontrol cameras --preview' to see which index is really which.")
|
|
218
|
+
|
|
219
|
+
if show:
|
|
220
|
+
print()
|
|
221
|
+
preview(devices)
|
|
222
|
+
return 0
|