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 +8 -0
- glanced/align.py +55 -0
- glanced/attention.py +360 -0
- glanced/camera.py +122 -0
- glanced/cli.py +676 -0
- glanced/daemon.py +409 -0
- glanced/embed.py +61 -0
- glanced/enroll.py +339 -0
- glanced/gui/__init__.py +40 -0
- glanced/gui/enroll_window.py +547 -0
- glanced/gui/passphrase.py +146 -0
- glanced/ipc.py +103 -0
- glanced/landmarker.py +95 -0
- glanced/liveness/__init__.py +66 -0
- glanced/liveness/analyzer.py +95 -0
- glanced/liveness/bezel.py +211 -0
- glanced/liveness/cues.py +405 -0
- glanced/liveness/features.py +245 -0
- glanced/liveness/frame.py +175 -0
- glanced/liveness/geometry.py +238 -0
- glanced/liveness/glare.py +85 -0
- glanced/liveness/planar.py +366 -0
- glanced/liveness/scoring.py +193 -0
- glanced/livetest.py +161 -0
- glanced/locksetup.py +143 -0
- glanced/models.py +102 -0
- glanced/pamsetup.py +179 -0
- glanced/paths.py +58 -0
- glanced/pipeline.py +137 -0
- glanced/poses.py +187 -0
- glanced/preview.py +147 -0
- glanced/scan.py +95 -0
- glanced/servicesetup.py +121 -0
- glanced/store.py +138 -0
- glanced-0.3.0.dist-info/METADATA +317 -0
- glanced-0.3.0.dist-info/RECORD +41 -0
- glanced-0.3.0.dist-info/WHEEL +5 -0
- glanced-0.3.0.dist-info/entry_points.txt +2 -0
- glanced-0.3.0.dist-info/licenses/LICENSE +21 -0
- glanced-0.3.0.dist-info/licenses/NOTICE +15 -0
- glanced-0.3.0.dist-info/top_level.txt +1 -0
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]
|