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,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