Webcam gaze tracking reduced to one coarse screen-region dwell gesture
Ce script ne doit pas être installé directement. C'est une librairie destinée à être incluse dans d'autres scripts avec la méta-directive // @require https://update.greasyfork.org/scripts/589409/1890547/Gaze%20Gesture%20Library.js
Webcam gaze tracking reduced to a single gesture: an uninterrupted glance at a coarse screen region fires a callback.
gaze-gesture.lib.user.js — exposes window.GazeGesture.
A webcam resolves gaze to a region, not a point. With calibration and a cooperative reader you get roughly quadrant-level accuracy, and it degrades as soon as the head moves or the viewing distance changes.
| Goal | Viable |
|---|---|
| Presence / looking-away detection | yes |
| One large region at a screen extreme | yes — this library |
| Which of 4–6 screen regions | marginal, needs full calibration |
| Which thumbnail in a grid | no |
| Which line or word of text | no — needs IR hardware |
The library is scoped to the second row deliberately. It does not implement screen-coordinate eye tracking, and it should not be extended into it without replacing the two-point calibration with a proper regression fit.
@require the published build; see the script's Greasy Fork page for the
current version URL.
const gaze = GazeGesture.create({
logger, // optional; LoggingUI.create() instance
prefsKey: 'gaze_calibration', // GM_setValue key for the calibration
target: { x: 0.5, xRadius: 0.3, yMin: 0.82, yMax: 1.6 },
dwellMs: 350,
cooldownMs: 1000,
onTrigger: advanceOnePost,
});
// Nothing opens the camera until one of these is called.
await gaze.calibrate();
await gaze.start();
create() returns { start, stop, toggle, calibrate, isRunning, isCalibrated,
toggleDebug, clearCalibration }. Constructing the tracker has no side effects —
it does not touch getUserMedia, load the model, or add DOM. Only start() and
calibrate() do.
Defaults live in DEFAULTS at the top of the library and are commented there.
The ones worth tuning per host:
target — { x, xRadius, yMin, yMax } in normalized viewport coordinates,
top-left origin: a rectangle spanning x ± xRadius by yMin..yMax. Either
axis may be the discriminating one — a band across the bottom, or a column
down one side — and calibration solves whichever it is. An edge that runs
past the viewport is treated as open-ended, not merely wide: a region
anchored to an edge is a direction, and an estimate that lands beyond it has
committed to that direction harder, so a flick that throws the estimate off
the page still counts. Keep the inner edge — the one facing the reading
material — inside the viewport, or the region swallows the whole screen. Place it where the reader
never looks while reading — see below.dwellMs — hold time before firing. Lowering it trades accidental fires from
glances that merely cross the band on the way somewhere else.smoothing — one-euro filter terms. Raise beta if the gesture feels laggy,
raise minCutoff if it rattles. A fixed-alpha filter was tried first and is
the wrong tool: it forces a choice between jitter and lag, and gaze needs
neither.debug — draw the band, the estimated gaze point and dwell progress over the
page. Also flippable at runtime via toggleDebug().cooldownMs / rearmMs — repeat suppression. Both must be satisfied: gaze
has to leave the target and the cooldown has to elapse. A held stare never
repeats.missGraceMs — how long a lost face is treated as a blink rather than a loss.headWeight — how much head pose counts toward the gaze estimate relative to
eye rotation.The single most important decision, and the easiest to get wrong: the trigger band must not overlap what the reader looks at while reading.
Overlap fails in two compounding ways. The obvious one is that the gate cannot tell reading from gesturing. The subtle one is worse: after firing, re-arming requires gaze to leave the band, and a reader whose material sits inside it never leaves — so the gesture appears to stop working until they deliberately look somewhere else. That reads as "touchy and unresponsive", not as a geometry error.
This is why the default band is at the bottom. A consumer that seats content at the top of the viewport has its reading material in the top fraction, leaving the bottom edge free. It also earns a useful property: on content long enough to reach the bottom, arriving there means the reader has finished, so advancing is what they wanted anyway.
Separating on the horizontal axis instead is the alternative when the layout offers a column the eye does not read: a media or thumbnail gutter down one side, with the text beside it. That buys height-independence — the region is hit wherever on the page the content sits — at the cost of a narrower miss margin, since horizontal gaze estimation is no more precise than vertical and the column is thinner than a full-width band.
Before choosing, turn on the debug overlay and read for a minute. If the gaze dot spends time inside the band while you are reading, the band is wrong.
Two captures — a marker at the centre of the target region, then the viewport centre — solve a per-axis gain and origin. That is sufficient for one target region; a 9-point regression would buy nothing here. The same solve serves a band above or below centre, or a column left or right of it, since the wanted and the measured displacement change sign together.
Only an axis the target sits off-centre on is solved; on the other the two
captures coincide by construction, so it keeps the timid fallback gain. A
bottom band solves y, a side column solves x.
A capture where the reader did not actually move their eyes is refused rather than persisted, so a bad calibration fails loudly. The result is stamped with the target it was solved against and discarded if the target later moves — a band change invalidates the gain, and a silently wrong calibration is worse than none.
The result is stored as JSON under prefsKey via GM_setValue when the
requiring script granted it, and held in memory otherwise.
Firefox refuses getUserMedia when the requesting document is not focused, and
a userscript-manager menu command is exactly that case: the click lands in the
extension popup, so when the callback runs the page holds neither focus nor user
activation. The request then fails as NotAllowedError — before any permission
prompt is shown, which makes it look like a denial rather than a precondition
failure.
start() and calibrate() therefore check for focus and activation first, and
when either is missing they put a consent button in the page and wait for a real
click on it. Calling them from a genuine in-page gesture skips the button
entirely.
Do not "simplify" this by calling getUserMedia directly from a menu callback.
With suspendOnHidden (default), leaving the tab releases the camera device
outright rather than merely dropping frames — that is what extinguishes the
hardware indicator. The permission grant survives, so returning to the tab
resumes without a re-prompt. Set releaseCameraOnHidden: false to keep the
device open and only pause inference.
The vision module, WASM and model load from CDNs at start():
| Asset | Size |
|---|---|
@mediapipe/[email protected]/vision_bundle.mjs (jsDelivr) |
155 KB |
vision_wasm_internal.wasm (jsDelivr) |
11.8 MB |
face_landmarker.task, float16 (Google storage) |
3.8 MB |
Roughly 15.6 MB on first start(), browser-cached thereafter; a cold start
measures ~2.2 s to a ready landmarker.
The module URL and wasmBase are pinned to the same version on purpose — they
are halves of one build, and a floating tag would eventually pair mismatched
ones. Verify both resolve before bumping: the package jumped from the 0.10.x
line to 1.x, so plausible-looking versions may not exist at all.
This works only on origins without a restrictive CSP. rule34.xxx sends no
content-security-policy and no permissions-policy on its comment feed, which
is what makes the direct import() viable there. Note that Cloudflare's
challenge page does send permissions-policy: camera=(); that applies to the
challenge interstitial, not the site.
On a CSP-bearing host you would need to @require a bundled build and pull the
WASM through GM_xmlhttpRequest as an ArrayBuffer. Not implemented.
createGate is a pure state machine over (timestamp, sample). It owns no
camera, no DOM and no timers, which is what makes the debounce and re-arm
behaviour — where the real bugs are — testable headlessly. The MediaPipe layer
above it only turns video frames into normalized gaze points.
States: idle → dwell → spent. A spent gate returns to idle only when
the cooldown has elapsed and gaze has been clear of the target for rearmMs.
A sample substituted during the blink grace window sustains a dwell but cannot complete one — the reader's eyes are shut, so there is no evidence they are still on target.
node tests/gaze-gesture.lib.test.js
Covers the gate, the blendshape collapse and the calibration solve. The camera and MediaPipe layers need real hardware and are verified by hand.
The thresholds above cannot be tuned without a real webcam, real lighting and your actual seating distance. After installing:
dwellMs is too low.rule34.xxx-global_comments_revamp.user.js — glance at the left-quarter column,
the feed's thumbnail gutter, at any height, seats the next post block — the
paging step PageDown used to serve before the
feed's scroll anchoring forced it to be swallowed.