Gaze Gesture Library

Webcam gaze tracking reduced to one coarse screen-region dwell gesture

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/589409/1890547/Gaze%20Gesture%20Library.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Аўтар
SassyRhombus
Версія
1.3.0
Створаны
31.07.2026
Абноўлены
01.08.2026
Памер
39.9 КБ
Ліцэнзія
MIT

Gaze Gesture Library

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.

What this can and cannot do

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.

Usage

@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.

Options

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.

Placing the target

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.

Calibration

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.

Why start() shows a button

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 NotAllowedErrorbefore 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.

Suspension

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.

Assets and CSP

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.

Architecture

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: idledwellspent. 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.

Tests

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.

Manual verification checklist

The thresholds above cannot be tuned without a real webcam, real lighting and your actual seating distance. After installing:

  1. Run calibration (required after any change to the target band).
  2. Turn on the tracking overlay and read normally for a minute. If the gaze dot sits inside the band while you are reading, the band is in the wrong place — fix that before touching any timing.
  3. Confirm one glance advances exactly one post, not several.
  4. Confirm a second glance fires without a deliberate detour elsewhere first. Needing one means the band still overlaps your reading position.
  5. Any advance you did not intend means dwellMs is too low.
  6. Switch tabs and confirm the camera indicator goes out.

Consumers

  • 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.