glanced 0.3.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.
glanced/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """glanced — face unlock for Linux, with liveness detection.
2
+
3
+ A Linux reimplementation of the liveness model from Glance
4
+ (https://github.com/jonnyoo/glance, MIT), around a PAM-based unlock path rather
5
+ than macOS's keystroke injection.
6
+ """
7
+
8
+ __version__ = "0.1.1"
glanced/align.py ADDED
@@ -0,0 +1,55 @@
1
+ """Five-point similarity alignment to the embedder's 112x112 input.
2
+
3
+ The five points and the destination template are ArcFace's standard: the same
4
+ canonical constellation InsightFace trains against, so an embedding produced
5
+ here is comparable with one produced by any other ArcFace pipeline.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional, Sequence
11
+
12
+ import numpy as np
13
+
14
+ from .liveness.geometry import solve_similarity_transform
15
+
16
+ try:
17
+ import cv2
18
+ except ImportError: # pragma: no cover
19
+ cv2 = None # type: ignore[assignment]
20
+
21
+ #: ArcFace's canonical five-point template for a 112x112 crop:
22
+ #: left eye, right eye, nose tip, left mouth corner, right mouth corner.
23
+ ARCFACE_TEMPLATE = np.array(
24
+ [
25
+ [38.2946, 51.6963],
26
+ [73.5318, 51.5014],
27
+ [56.0252, 71.7366],
28
+ [41.5493, 92.3655],
29
+ [70.7299, 92.2041],
30
+ ],
31
+ dtype=float,
32
+ )
33
+
34
+ OUTPUT_SIZE = 112
35
+
36
+
37
+ def align(image: np.ndarray, five_points: Sequence[Sequence[float]]) -> Optional[np.ndarray]:
38
+ """Warp `image` so the five landmarks land on the ArcFace template.
39
+
40
+ A similarity transform (rotation, uniform scale, translation) rather than a
41
+ full affine or homography, deliberately: anything more general would let the
42
+ warp squash a face toward the template and erase exactly the shape
43
+ differences the embedding is supposed to encode.
44
+ """
45
+ if cv2 is None:
46
+ raise RuntimeError("opencv-python is required for alignment")
47
+ points = np.asarray(five_points, dtype=float).reshape(-1, 2)
48
+ if len(points) != 5:
49
+ return None
50
+ matrix = solve_similarity_transform(points, ARCFACE_TEMPLATE)
51
+ if matrix is None:
52
+ return None
53
+ return cv2.warpAffine(
54
+ image, matrix, (OUTPUT_SIZE, OUTPUT_SIZE), flags=cv2.INTER_LINEAR, borderValue=0
55
+ )
glanced/attention.py ADDED
@@ -0,0 +1,360 @@
1
+ """Attention mode: head pose for the desktop, never for auth.
2
+
3
+ A subscriber connects to `attention.sock` and, for as long as it stays
4
+ connected, receives one JSON line per processed camera frame:
5
+
6
+ {"schemaVersion": 1, "t": 1234.5, "state": "tracking",
7
+ "present": true, "yaw": -12.4, "pitch": 3.1, "conf": 1.0}
8
+
9
+ `yaw` and `pitch` are degrees; yaw is positive when the head turns to the
10
+ subject's left, pitch positive when the chin comes down (the same convention as
11
+ `poses.py`, in different units). `conf` is how far the face is above the size
12
+ at which the landmarker is trusted, 0..1. `state` is one of:
13
+
14
+ * `starting` — sent once on connect, before the camera is open;
15
+ * `tracking` — the camera is open and this event carries a pose (or
16
+ `present: false` with the pose fields null);
17
+ * `paused` — an unlock scan has the camera. The pose fields are null;
18
+ * `error` — the camera could not be opened. `reason` says why; the tracker
19
+ retries while anyone is still listening.
20
+
21
+ Only `tracking` means anything about where the user is looking. A client
22
+ covering the screen should treat every other state, and silence, as "come
23
+ down": the point of a shield is that a crash never leaves it up.
24
+
25
+ What is deliberately *not* here:
26
+
27
+ * **No frames, no landmarks, no embeddings.** Events are three numbers and a
28
+ bool, derived in-process. A reader looking for "does this stream my face
29
+ anywhere" should be able to answer no from this file alone.
30
+ * **No verbs.** The socket is publish-only. Bytes a client sends are never
31
+ read, so nothing on it can start, stop or influence a scan.
32
+ * **No ArcFace, no template, no arming.** Attention needs the landmarker and
33
+ the pose it already computes, nothing else, so it works on a daemon that
34
+ has never been armed for a user who has never enrolled.
35
+
36
+ The camera has one owner. The tracker holds it only while at least one
37
+ subscriber is connected — nothing listening, camera closed, LED off — and
38
+ hands it over the moment a scan asks (`paused()`), taking it back when the
39
+ scan ends. An auth request never waits on attention for more than
40
+ `HANDOVER_TIMEOUT`.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import contextlib
46
+ import json
47
+ import logging
48
+ import math
49
+ import socket
50
+ import threading
51
+ import time
52
+ from dataclasses import dataclass
53
+ from typing import Any, Callable, Iterator, Optional
54
+
55
+ from .liveness.features import MIN_RELIABLE_INTEROCULAR_PX, interocular_distance
56
+
57
+ log = logging.getLogger("glanced.attention")
58
+
59
+ SCHEMA_VERSION = 1
60
+
61
+ #: Landmarker passes per second. A comfort feature that costs a core all day
62
+ #: is one people uninstall; at eight the light landmarker is a few percent.
63
+ DEFAULT_FPS = 8.0
64
+
65
+ #: How long a scan will wait for the tracker to release the camera before
66
+ #: trying to open it anyway. The tracker checks between frames, so at any
67
+ #: sane fps this is generous; the cap is what keeps a wedged tracker from
68
+ #: stalling a PAM conversation.
69
+ HANDOVER_TIMEOUT = 3.0
70
+
71
+ #: Back-off between attempts to reopen a camera that failed, capped so an
72
+ #: unplugged-then-replugged webcam is picked up again within a few seconds.
73
+ RETRY_MIN = 0.5
74
+ RETRY_MAX = 5.0
75
+
76
+ #: A subscriber that cannot take an event this fast is dropped, so a stuck
77
+ #: client cannot stall delivery to the others.
78
+ SEND_TIMEOUT = 0.5
79
+
80
+ #: The capture the tracker asks for. The landmarker works at 640 wide anyway
81
+ #: (`camera.WORKING_WIDTH`), so capturing at that size skips a resize per
82
+ #: frame and moves fewer bytes off the sensor.
83
+ CAPTURE_WIDTH = 640
84
+ CAPTURE_HEIGHT = 360
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Event:
89
+ state: str
90
+ present: Optional[bool] = None
91
+ yaw: Optional[float] = None
92
+ pitch: Optional[float] = None
93
+ conf: Optional[float] = None
94
+ reason: Optional[str] = None
95
+
96
+ def encode(self, now: Optional[float] = None) -> bytes:
97
+ payload: dict[str, Any] = {
98
+ "schemaVersion": SCHEMA_VERSION,
99
+ "t": round(time.monotonic() if now is None else now, 3),
100
+ "state": self.state,
101
+ "present": self.present,
102
+ "yaw": None if self.yaw is None else round(self.yaw, 1),
103
+ "pitch": None if self.pitch is None else round(self.pitch, 1),
104
+ "conf": None if self.conf is None else round(self.conf, 2),
105
+ }
106
+ if self.reason:
107
+ payload["reason"] = self.reason
108
+ return json.dumps(payload).encode() + b"\n"
109
+
110
+
111
+ ABSENT = Event("tracking", present=False)
112
+
113
+
114
+ def pose_event(face: Any) -> Event:
115
+ """A `tracking` event from a `landmarker.DetectedFace`."""
116
+ iod = interocular_distance(face.mesh)
117
+ conf = 0.0 if iod is None else min(1.0, iod / MIN_RELIABLE_INTEROCULAR_PX)
118
+ return Event(
119
+ "tracking",
120
+ present=True,
121
+ yaw=None if face.yaw is None else math.degrees(face.yaw),
122
+ pitch=None if face.pitch is None else math.degrees(face.pitch),
123
+ conf=conf,
124
+ )
125
+
126
+
127
+ class AttentionTracker:
128
+ """Owns the attention loop, its subscribers, and the camera handover."""
129
+
130
+ def __init__(
131
+ self,
132
+ *,
133
+ device: str = "/dev/video0",
134
+ fps: float = DEFAULT_FPS,
135
+ landmarker_factory: Optional[Callable[[], Any]] = None,
136
+ camera_factory: Optional[Callable[[str], Any]] = None,
137
+ ) -> None:
138
+ self.device = device
139
+ self.fps = fps
140
+ self._landmarker_factory = landmarker_factory or self._default_landmarker
141
+ self._camera_factory = camera_factory or self._default_camera
142
+ self._landmarker = None
143
+ self._subscribers: list[socket.socket] = []
144
+ self._lock = threading.Lock()
145
+ self._changed = threading.Condition(self._lock)
146
+ self._pauses = 0
147
+ self._closing = False
148
+ self._tracking = False
149
+ # Set whenever the loop is *not* holding the camera.
150
+ self._camera_free = threading.Event()
151
+ self._camera_free.set()
152
+ self._thread: Optional[threading.Thread] = None
153
+
154
+ # --- subscribers --------------------------------------------------------
155
+
156
+ def subscribe(self, connection: socket.socket) -> None:
157
+ """Adopt a connection as a subscriber. Never reads from it."""
158
+ connection.settimeout(SEND_TIMEOUT)
159
+ with contextlib.suppress(OSError):
160
+ # Nothing a client sends is ever read; refuse it at the socket so
161
+ # the kernel does not buffer it either.
162
+ connection.shutdown(socket.SHUT_RD)
163
+ with self._changed:
164
+ if self._closing:
165
+ connection.close()
166
+ return
167
+ first = Event("paused", reason="unlock scan in progress") if self._pauses else Event("starting")
168
+ try:
169
+ connection.sendall(first.encode())
170
+ except OSError as error:
171
+ log.info("attention subscriber dropped before its first event: %s", error)
172
+ connection.close()
173
+ return
174
+ self._subscribers.append(connection)
175
+ log.info("attention subscriber connected (%d)", len(self._subscribers))
176
+ if self._thread is None or not self._thread.is_alive():
177
+ self._thread = threading.Thread(target=self._run, name="attention", daemon=True)
178
+ self._thread.start()
179
+ self._changed.notify_all()
180
+
181
+ @property
182
+ def subscribers(self) -> int:
183
+ with self._lock:
184
+ return len(self._subscribers)
185
+
186
+ def _publish(self, event: Event) -> None:
187
+ line = event.encode()
188
+ with self._changed:
189
+ kept = []
190
+ for connection in self._subscribers:
191
+ try:
192
+ connection.sendall(line)
193
+ kept.append(connection)
194
+ except OSError:
195
+ connection.close()
196
+ if len(kept) != len(self._subscribers):
197
+ log.info("attention subscriber left (%d)", len(kept))
198
+ self._subscribers = kept
199
+ self._changed.notify_all()
200
+
201
+ # --- camera handover ----------------------------------------------------
202
+
203
+ @contextlib.contextmanager
204
+ def paused(self) -> Iterator[None]:
205
+ """Take the camera away from the tracker for the duration.
206
+
207
+ Returns once the tracker has closed the device, or after
208
+ `HANDOVER_TIMEOUT` with a warning — a scan must never hang on this.
209
+ """
210
+ with self._changed:
211
+ self._pauses += 1
212
+ self._changed.notify_all()
213
+ try:
214
+ if not self._camera_free.wait(HANDOVER_TIMEOUT):
215
+ log.warning("attention tracker did not release the camera in %.1fs", HANDOVER_TIMEOUT)
216
+ yield
217
+ finally:
218
+ with self._changed:
219
+ self._pauses -= 1
220
+ self._changed.notify_all()
221
+
222
+ # --- the loop -----------------------------------------------------------
223
+
224
+ def _wanted(self) -> bool:
225
+ """Should the loop hold the camera right now? Caller holds the lock."""
226
+ return bool(self._subscribers) and not self._pauses and not self._closing
227
+
228
+ def _run(self) -> None:
229
+ retry = RETRY_MIN
230
+ while True:
231
+ with self._changed:
232
+ while not self._wanted():
233
+ if self._closing or not self._subscribers:
234
+ return
235
+ self._changed.wait()
236
+ # Claimed under the lock, so a `paused()` that lands now either
237
+ # sees the claim and waits, or was seen by `_wanted()` first.
238
+ self._camera_free.clear()
239
+ failed = False
240
+ try:
241
+ self._track()
242
+ retry = RETRY_MIN
243
+ except Exception as error:
244
+ failed = True
245
+ log.warning("attention camera unavailable: %s", error)
246
+ self._publish(Event("error", reason=f"{type(error).__name__}: {error}"))
247
+ finally:
248
+ self._tracking = False
249
+ self._camera_free.set()
250
+ with self._lock:
251
+ paused = self._pauses > 0
252
+ wanted = bool(self._subscribers) and not self._closing
253
+ if not wanted:
254
+ return
255
+ if paused:
256
+ self._publish(Event("paused", reason="unlock scan in progress"))
257
+ continue
258
+ if failed:
259
+ # Wait before reopening, but wake early for a pause or close.
260
+ with self._changed:
261
+ self._changed.wait(retry)
262
+ retry = min(retry * 2, RETRY_MAX)
263
+
264
+ def _track(self) -> None:
265
+ """Hold the camera and publish until no longer wanted. Raises when the
266
+ device cannot be opened or stops delivering."""
267
+ from .camera import to_working_resolution
268
+
269
+ if self._landmarker is None:
270
+ self._landmarker = self._landmarker_factory()
271
+ interval = 1.0 / self.fps if self.fps > 0 else 0.0
272
+ with self._camera_factory(self.device) as camera:
273
+ log.info("attention tracking on %s at %.0f fps", self.device, self.fps)
274
+ self._tracking = True
275
+ for native in camera.frames(interval):
276
+ with self._lock:
277
+ if not self._wanted():
278
+ return
279
+ working, _ = to_working_resolution(native)
280
+ face = self._landmarker.detect(working, int(time.monotonic() * 1000))
281
+ self._publish(ABSENT if face is None else pose_event(face))
282
+ raise RuntimeError("camera stopped delivering frames")
283
+
284
+ # --- lifecycle ----------------------------------------------------------
285
+
286
+ def status(self) -> dict[str, Any]:
287
+ with self._lock:
288
+ return {
289
+ "enabled": True,
290
+ "subscribers": len(self._subscribers),
291
+ "tracking": self._tracking,
292
+ "fps": self.fps,
293
+ }
294
+
295
+ def close(self) -> None:
296
+ with self._changed:
297
+ self._closing = True
298
+ for connection in self._subscribers:
299
+ connection.close()
300
+ self._subscribers = []
301
+ self._changed.notify_all()
302
+ if self._thread is not None:
303
+ self._thread.join(timeout=HANDOVER_TIMEOUT)
304
+ if self._landmarker is not None:
305
+ self._landmarker.close()
306
+ self._landmarker = None
307
+
308
+ # --- defaults -----------------------------------------------------------
309
+
310
+ @staticmethod
311
+ def _default_landmarker():
312
+ from .landmarker import Landmarker
313
+
314
+ return Landmarker()
315
+
316
+ @staticmethod
317
+ def _default_camera(device: str):
318
+ from .camera import Camera, CameraConfig
319
+
320
+ return Camera(CameraConfig(device=device, width=CAPTURE_WIDTH, height=CAPTURE_HEIGHT))
321
+
322
+
323
+ class DisabledTracker:
324
+ """What the daemon holds with `--no-attention`: no socket, no thread, and
325
+ a `paused()` that costs nothing."""
326
+
327
+ subscribers = 0
328
+
329
+ def subscribe(self, connection: socket.socket) -> None:
330
+ connection.close()
331
+
332
+ @contextlib.contextmanager
333
+ def paused(self) -> Iterator[None]:
334
+ yield
335
+
336
+ def status(self) -> dict[str, Any]:
337
+ return {"enabled": False, "subscribers": 0, "tracking": False, "fps": None}
338
+
339
+ def close(self) -> None:
340
+ pass
341
+
342
+
343
+ def subscribe(path, on_event: Callable[[dict[str, Any]], bool]) -> None:
344
+ """Client side: connect and call `on_event` per event until it returns
345
+ False or the daemon goes away. What `glancectl attention` uses."""
346
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
347
+ try:
348
+ client.connect(str(path))
349
+ buffer = b""
350
+ while True:
351
+ chunk = client.recv(4096)
352
+ if not chunk:
353
+ return
354
+ buffer += chunk
355
+ while b"\n" in buffer:
356
+ line, buffer = buffer.split(b"\n", 1)
357
+ if line and not on_event(json.loads(line)):
358
+ return
359
+ finally:
360
+ client.close()
glanced/camera.py ADDED
@@ -0,0 +1,122 @@
1
+ """V4L2 capture.
2
+
3
+ Two frames come out of every grab, and the distinction matters to liveness:
4
+
5
+ * the **full frame**, which the bezel detector needs because it has to look
6
+ *around* the face for a device edge, not just at it;
7
+ * a **native-resolution crop** around the face for the gloss/glare cue, which
8
+ only ever downsamples — never upsamples — so `GlareSample.crop_pixel_width`
9
+ stays an honest measure of how much real detail was available.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from dataclasses import dataclass
16
+ from typing import Iterator, Optional, Sequence
17
+
18
+ import numpy as np
19
+
20
+ try:
21
+ import cv2
22
+ except ImportError: # pragma: no cover
23
+ cv2 = None # type: ignore[assignment]
24
+
25
+ #: The resolution the liveness tuning constants assume. Several gates
26
+ #: (`min_yaw_range_degrees`, `MIN_RELIABLE_INTEROCULAR_PX`) are expressed in
27
+ #: pixels at this width; changing it silently invalidates them.
28
+ WORKING_WIDTH = 640
29
+
30
+
31
+ @dataclass
32
+ class CameraConfig:
33
+ device: str = "/dev/video0"
34
+ width: int = 1280
35
+ height: int = 720
36
+ fps: int = 30
37
+
38
+
39
+ class Camera:
40
+ def __init__(self, config: CameraConfig = CameraConfig()) -> None:
41
+ if cv2 is None:
42
+ raise RuntimeError("opencv-python is required for capture")
43
+ self.config = config
44
+ self._capture: Optional["cv2.VideoCapture"] = None
45
+
46
+ def __enter__(self) -> "Camera":
47
+ self.open()
48
+ return self
49
+
50
+ def __exit__(self, *exc) -> None:
51
+ self.close()
52
+
53
+ def open(self) -> None:
54
+ capture = cv2.VideoCapture(self.config.device, cv2.CAP_V4L2)
55
+ if not capture.isOpened():
56
+ raise RuntimeError(f"could not open {self.config.device}")
57
+ capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.width)
58
+ capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.height)
59
+ capture.set(cv2.CAP_PROP_FPS, self.config.fps)
60
+ self._capture = capture
61
+
62
+ def close(self) -> None:
63
+ if self._capture is not None:
64
+ self._capture.release()
65
+ self._capture = None
66
+
67
+ def frames(self, min_interval: float = 0.0) -> Iterator[np.ndarray]:
68
+ """Yield RGB frames until the device stops delivering.
69
+
70
+ `min_interval` throttles by wall clock: frames arriving sooner than
71
+ that after the last one yielded are grabbed and dropped without being
72
+ decoded or converted. That is how attention mode runs the landmarker
73
+ at 8 fps on a camera that only offers 30 — asking the driver for a
74
+ lower rate is a request most UVC webcams quietly ignore.
75
+ """
76
+ if self._capture is None:
77
+ raise RuntimeError("camera is not open")
78
+ next_at = 0.0
79
+ while True:
80
+ if not self._capture.grab():
81
+ return
82
+ now = time.monotonic()
83
+ if now < next_at:
84
+ continue
85
+ next_at = now + min_interval
86
+ ok, bgr = self._capture.retrieve()
87
+ if not ok:
88
+ return
89
+ yield cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
90
+
91
+
92
+ def to_working_resolution(frame: np.ndarray, width: int = WORKING_WIDTH) -> tuple[np.ndarray, float]:
93
+ """Downscale to the working width. Returns the frame and the scale factor
94
+ that maps working-resolution coordinates back to native pixels."""
95
+ height, native_width = frame.shape[:2]
96
+ if native_width <= width:
97
+ return frame, 1.0
98
+ scale = width / native_width
99
+ resized = cv2.resize(frame, (width, int(round(height * scale))), interpolation=cv2.INTER_AREA)
100
+ return resized, scale
101
+
102
+
103
+ def render_crop(
104
+ frame: np.ndarray, bounding_box: Sequence[float], padding: float = 0.15
105
+ ) -> Optional[np.ndarray]:
106
+ """Native-resolution crop around a face box, with a little padding.
107
+
108
+ Never upsamples: if the requested region is smaller than the box asks for,
109
+ what comes back is what the sensor actually resolved. The gloss/glare cue
110
+ confidence-weights on the returned width precisely so that an
111
+ under-resolved crop abstains instead of guessing.
112
+ """
113
+ height, width = frame.shape[:2]
114
+ x, y, w, h = (float(v) for v in bounding_box)
115
+ pad_x, pad_y = w * padding, h * padding
116
+ x0 = int(max(0, round(x - pad_x)))
117
+ y0 = int(max(0, round(y - pad_y)))
118
+ x1 = int(min(width, round(x + w + pad_x)))
119
+ y1 = int(min(height, round(y + h + pad_y)))
120
+ if x1 <= x0 or y1 <= y0:
121
+ return None
122
+ return frame[y0:y1, x0:x1]