""" browser_session.py InsightUX research browser. Flow: 1. Window opens on real google.com — search and click through like a normal browser (no scraping, no fake results — it's just Google). 2. Land on whatever page you want to study. Press S to start tracking. 3. Gaze dot + AOI highlighting (same as run_session.py) kicks in and logs sessions//gaze_log.jsonl + dom_log.jsonl. 4. Press E to stop. The window auto-navigates to a generated analysis_report.html: ranked attention, dwell timeline, heatmap. Prerequisite: calibration.pkl must exist in the working directory (run calibrate.py first). Run: python browser_session.py """ import os import sys import json import time import math import threading import subprocess from dataclasses import replace from datetime import datetime # Windows consoles default to a non-UTF-8 codepage (cp1252) — a stray # unicode character in any print() (ours, or one of calibrate.py/validate.py's # once relaunched as a subprocess sharing this process's stdout handle) would # otherwise crash the whole process with UnicodeEncodeError. Confirmed this # happened for real during a calibration run mid-session. if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8", errors="replace") import cv2 import numpy as np import webview import pyautogui from preprocessing.preprocessing_pipeline import ( create_face_mesh, estimate_camera_matrix, estimate_head_pose, compute_iris_radius, compute_ear, step1_normalize, step2_illumination, LEFT_EYE_INDICES, LEFT_EAR_INDICES, LEFT_IRIS_INDICES, RIGHT_EYE_INDICES, RIGHT_EAR_INDICES, RIGHT_IRIS_INDICES, ) from inference_pipeline import InsightUXPipeline, GazeAngleSmoother from session_logger import GazeLogger from analysis import generate_report # ============================================================================= # CONFIG — kept identical to run_session.py so calibration.pkl stays valid # ============================================================================= # Bundled read-only assets (models/checkpoints) vs. per-user writable data # (calibration.pkl, sessions/, the generated landing page) need different # roots once frozen by PyInstaller — onedir nests bundled data under # _internal/, alongside a persistent folder holding InsightUX.exe itself. if getattr(sys, "frozen", False): RESOURCE_DIR = sys._MEIPASS DATA_DIR = os.path.dirname(sys.executable) else: RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = RESOURCE_DIR BASE_DIR = DATA_DIR # kept as an alias: subprocess cwd for calibrate/validate relaunches # Bump alongside packaging/installer.iss's AppVersion on every release, and # update version.json in the same commit — see packaging/README.md. VERSION = "1.0.0" VERSION_CHECK_URL = "https://huggingface.co/arpitasethiii/insightux/resolve/main/version.json" ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx") CALIBRATION_PATH = os.path.join(DATA_DIR, "calibration.pkl") SCREEN_W, SCREEN_H = pyautogui.size() PATCH_SOURCE = "blended" POSE_SMOOTH = 0.65 MAX_JUMP = 220 NO_FACE_RESET = 15 DOM_EVERY = 6 EURO_MINCUTOFF = 0.35 EURO_BETA = 0.12 # Rolling-median window applied to (pitch, yaw) BEFORE the RBF query. # See GazeAngleSmoother in inference_pipeline.py for why this is essential # and not merely a nicety. Must match validate.py to stay comparable. ANGLE_SMOOTH_WINDOW = 10 POSE_NORM_SCALE = 30.0 HEAD_PITCH_COMPENSATION = 0.0 # reverted — 0.35 made accuracy worse, not better HEAD_YAW_COMPENSATION = 0.0 SESSIONS_ROOT = os.path.join(DATA_DIR, "sessions") # The window opens on this local, fully InsightUX-branded page instead of # raw google.com. What you type here still hits REAL Google — no fake # results, no scraping — this page is just the visible skin around that # one redirect step. Typed URLs go straight there instead. _LANDING_HTML = r""" InsightUX — Eye-Tracking Research Browser
Eye-Tracking Research Browser
Search results and websites open below as normal.
Once you land on the page you want to study, press S to start eye-tracking, E to stop.
""" def _write_landing_page(): path = os.path.join(DATA_DIR, "insightux_landing.html") with open(path, "w", encoding="utf-8") as f: f.write(_LANDING_HTML) return "file://" + path.replace(os.sep, "/") LANDING_URL = _write_landing_page() def normalize_pose(pitch_deg, yaw_deg, roll_deg): return np.array([ pitch_deg / POSE_NORM_SCALE, yaw_deg / POSE_NORM_SCALE, roll_deg / POSE_NORM_SCALE, ], dtype=np.float32) def compensate_pitch(raw_pitch, head_pitch_deg): return raw_pitch - np.radians(head_pitch_deg) * HEAD_PITCH_COMPENSATION def compensate_yaw(raw_yaw, head_yaw_deg): return raw_yaw - np.radians(head_yaw_deg) * HEAD_YAW_COMPENSATION # ============================================================================= # ONE EURO FILTER (display smoothing only) # ============================================================================= class OneEuroFilter: def __init__(self, mincutoff=0.8, beta=0.4, dcutoff=1.0): self.mincutoff = mincutoff self.beta = beta self.dcutoff = dcutoff self.x_prev = None self.dx_prev = 0.0 self.t_prev = None @staticmethod def _alpha(cutoff, dt): tau = 1.0 / (2 * math.pi * cutoff) return 1.0 / (1.0 + tau / dt) def __call__(self, x, t): if self.x_prev is None: self.x_prev, self.t_prev = x, t return x dt = t - self.t_prev if dt <= 0: dt = 1e-3 self.t_prev = t dx = (x - self.x_prev) / dt a_d = self._alpha(self.dcutoff, dt) dx_hat = a_d * dx + (1 - a_d) * self.dx_prev cutoff = self.mincutoff + self.beta * abs(dx_hat) a = self._alpha(cutoff, dt) x_hat = a * x + (1 - a) * self.x_prev self.x_prev, self.dx_prev = x_hat, dx_hat return x_hat def reset(self): self.x_prev = None self.dx_prev = 0.0 self.t_prev = None # ============================================================================= # JS: browser chrome (always present) — top toolbar (back/forward/reload/ # home/address bar) + left sidebar (Calibrate/Validate/Start Session), so the # window reads as a normal browser instead of a bare content pane. Purely an # HTML/CSS/JS overlay drawn on top of whatever page is loaded — pywebview # 4.4.1 has no native toolbar API, so this is the only way to add persistent # chrome without switching GUI frameworks. # # The page itself is pushed down/right by exactly the toolbar height and # sidebar width (via forced padding, not just floating the chrome on # top of it) so real page content is never hidden underneath — the chrome # and the website occupy clearly separate regions instead of overlapping. # overflow-x is force-hidden as a safety net against the small width # reduction ever introducing a horizontal scrollbar. The one edge case this # can't fully solve: a small minority of sites use position:fixed elements # of their own pinned to the true viewport edges (independent of body # padding) — those can still end up visually behind our chrome. # ============================================================================= CHROME_JS = r""" (function(){ if (window.__insightuxControl) { return; } window.__insightuxControl = true; // S/E (start/stop tracking) only make sense on an actual website — not on // the InsightUX landing/search page and not on a generated insights // report page. The toolbar/sidebar, and X (quit), always work everywhere. const isLanding = !!document.querySelector('meta[name="insightux-landing"]'); const isReport = !!document.querySelector('meta[name="insightux-report"]'); const isTrackable = !isLanding && !isReport; const style = document.createElement('style'); style.textContent = ` html { overflow-x: hidden !important; } body { box-sizing: border-box !important; width: 100vw !important; margin: 0 !important; padding-top: 46px !important; padding-left: 84px !important; overflow-x: hidden !important; } #__insightux_toolbar { position:fixed; top:0; left:0; right:0; height:46px; z-index:2147483647; display:flex; align-items:center; gap:10px; padding:0 12px; box-sizing:border-box; background:linear-gradient(90deg, rgba(20,18,26,0.97), rgba(30,20,40,0.97)); border-bottom:2px solid; border-image:linear-gradient(90deg,#7B2FBE,#FF2DF0) 1; box-shadow:0 3px 14px rgba(0,0,0,0.35); font:12px -apple-system,'Segoe UI',Arial; color:#f0ecf7; } #__insightux_toolbar button { flex:0 0 auto; width:30px; height:30px; border-radius:8px; cursor:pointer; background:#241f2e; border:1px solid #3a3348; color:#f0ecf7; font-size:15px; display:flex; align-items:center; justify-content:center; } #__insightux_toolbar button:hover { background:#302a3d; } #__iux_addr_form { flex:1 1 auto; min-width:0; } #__iux_addr { width:100%; box-sizing:border-box; padding:7px 14px; border-radius:16px; border:1.5px solid #3a3348; background:#1c1826; color:#f0ecf7; outline:none; font-size:12.5px; } #__iux_addr:focus { border-color:#9B59FF; } #__insightux_status { flex:0 0 auto; color:#c9a6f5; white-space:nowrap; max-width:30vw; overflow:hidden; text-overflow:ellipsis; } #__insightux_sidebar { position:fixed; top:46px; left:0; bottom:0; width:84px; z-index:2147483647; display:flex; flex-direction:column; align-items:stretch; gap:8px; padding:10px 8px; box-sizing:border-box; font:11px -apple-system,'Segoe UI',Arial; background:linear-gradient(180deg, rgba(24,20,32,0.97), rgba(16,14,22,0.97)); border-right:2px solid; border-image:linear-gradient(180deg,#7B2FBE,#FF2DF0) 1; box-shadow:3px 0 14px rgba(0,0,0,0.35); } #__insightux_sidebar button { background:#241f2e; border:1px solid #3a3348; color:#f0ecf7; border-radius:10px; padding:10px 4px; cursor:pointer; line-height:1.5; font-size:11px; text-align:center; } #__insightux_sidebar button:hover { background:#302a3d; } #__iux_session.active { background:linear-gradient(135deg,#7B2FBE,#FF2DF0); border-color:transparent; } #__insightux_sidebar .__iux_hints { margin-top:auto; color:#7a7288; font-size:9.5px; line-height:1.6; text-align:center; } #__insightux_update { display:none; cursor:pointer; flex:0 0 auto; white-space:nowrap; padding:5px 12px; border-radius:12px; font-weight:600; font-size:11px; background:linear-gradient(135deg,#7B2FBE,#FF2DF0); color:#fff; } #__insightux_update:hover { filter:brightness(1.12); } `; document.head.appendChild(style); const toolbar = document.createElement('div'); toolbar.id = '__insightux_toolbar'; toolbar.innerHTML = `
Press S to start tracking · E to stop · H heatmap · M mouse panel · X quit `; document.documentElement.appendChild(toolbar); const sidebar = document.createElement('div'); sidebar.id = '__insightux_sidebar'; sidebar.innerHTML = `
S/E start/stop
H heatmap
M panel
X quit
`; document.documentElement.appendChild(sidebar); setInterval(function(){ if (!document.documentElement.contains(toolbar)) document.documentElement.appendChild(toolbar); if (!document.documentElement.contains(sidebar)) document.documentElement.appendChild(sidebar); }, 1000); const statusEl = () => document.getElementById('__insightux_status'); window.insightuxSetStatus = function(text){ const el = statusEl(); if (el) el.textContent = text; }; window.insightuxSetSessionUI = function(isTracking){ const btn = document.getElementById('__iux_session'); if (!btn) return; btn.innerHTML = isTracking ? '■
Stop
Session' : '▶
Start
Session'; btn.classList.toggle('active', isTracking); }; window.insightuxShowUpdate = function(version, url){ const el = document.getElementById('__insightux_update'); if (!el) return; el.textContent = 'Update available (v' + version + ')'; el.style.display = 'inline-block'; el.onclick = function(){ if (window.pywebview && window.pywebview.api && window.pywebview.api.open_update_page) { window.pywebview.api.open_update_page(url); } }; }; // -- address bar / navigation (pure DOM history/location, no Python round-trip) -- const addrInput = document.getElementById('__iux_addr'); addrInput.value = isLanding ? '' : location.href; document.getElementById('__iux_addr_form').addEventListener('submit', function(e){ e.preventDefault(); const raw = addrInput.value.trim(); if (!raw) return; const looksLikeUrl = /^https?:\/\//i.test(raw) || (/^[\w-]+(\.[\w-]+)+([/?#].*)?$/i.test(raw) && !raw.includes(' ')); if (looksLikeUrl) { location.href = raw.startsWith('http') ? raw : ('https://' + raw); } else { location.href = 'https://www.google.com/search?q=' + encodeURIComponent(raw); } }); document.getElementById('__iux_back').addEventListener('click', function(){ history.back(); }); document.getElementById('__iux_fwd').addEventListener('click', function(){ history.forward(); }); document.getElementById('__iux_reload').addEventListener('click', function(){ location.reload(); }); document.getElementById('__iux_home').addEventListener('click', function(){ location.href = __INSIGHTUX_LANDING_URL__; }); // -- sidebar actions (Python decides validity — camera/session conflicts, // wrong page — and reports back through the status text) -- function callApi(method){ if (window.pywebview && window.pywebview.api && window.pywebview.api[method]) { window.pywebview.api[method](); } } document.getElementById('__iux_calibrate').addEventListener('click', function(){ callApi('start_calibration'); }); document.getElementById('__iux_validate').addEventListener('click', function(){ callApi('run_validation'); }); document.getElementById('__iux_session').addEventListener('click', function(){ callApi('toggle_session'); }); function isTypingTarget(el){ if (!el) return false; const tag = el.tagName ? el.tagName.toLowerCase() : ''; return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true; } document.addEventListener('keydown', function(e){ const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement); const apiReady = !!(window.pywebview && window.pywebview.api); if (e.key === 'x' || e.key === 'X') { if (typingBlocked || !apiReady) return; window.pywebview.api.quit_app(); return; } if (!isTrackable) return; // S/E do nothing on the landing page or a report page if (e.key === 's' || e.key === 'S' || e.key === 'e' || e.key === 'E') { console.log('[insightux] key=' + e.key, 'typingBlocked=' + typingBlocked, 'apiReady=' + apiReady, 'focusedEl=' + (document.activeElement && document.activeElement.tagName)); } if (!apiReady) return; if (typingBlocked) { window.insightuxSetStatus("Click somewhere on the page (not a text field) before pressing S/E"); return; } if (e.key === 's' || e.key === 'S') { window.pywebview.api.start_tracking(); } else if (e.key === 'e' || e.key === 'E') { window.pywebview.api.stop_tracking(); } }, true); // capture phase — fires before page scripts can intercept/stop the event })(); """ CHROME_JS = CHROME_JS.replace("__INSIGHTUX_LANDING_URL__", json.dumps(LANDING_URL)) # JS: tracking overlay (gaze dot + AOI highlighting) — injected only once # tracking actually starts. Ported directly from run_session.py's JS_SETUP. TRACKING_JS = r""" (function(){ if (window.__insightux) { return; } const OWN_UI_IDS = new Set(['__insightux_toolbar', '__insightux_sidebar', '__insightux_pill', '__insightux_canvas', '__insightux_mouse_panel']); const OWN_UI_SELECTOR = '#__insightux_toolbar, #__insightux_sidebar, #__insightux_pill, #__insightux_canvas, #__insightux_mouse_panel'; const state = { aois: [] }; const dwell = { pendLabel: null, pendSince: 0, activeLabel: null, emptySince: 0 }; const DWELL_MS = 420; const RELEASE_MS = 1200; let activeBox = null; const target = { fx: 0.5, fy: 0.5 }; const dot = { x: null, y: null }; const DOT_LERP = 0.07; const cv = document.createElement('canvas'); cv.id = '__insightux_canvas'; cv.style.cssText = 'position:fixed;left:0;top:0;width:100vw;height:100vh;pointer-events:none;z-index:2147483646;'; (document.body || document.documentElement).appendChild(cv); const ctx = cv.getContext('2d'); function resize(){ cv.width = window.innerWidth; cv.height = window.innerHeight; } resize(); window.addEventListener('resize', resize, {passive:true}); setInterval(function(){ if (!document.body.contains(cv)) document.body.appendChild(cv); }, 1000); const MIN_W=40, MIN_H=24, MAX_AOIS=90, MAX_SCAN=2500; const PAD_PX = 90; const TALL_WRAPPER_H = 220; const ALWAYS_SELECTOR = "nav, header, footer, img, video, iframe, h1, h2, h3, button, figure, [class*='hero'], [class*='banner'], [class*='card']"; const TEXT_CONTAINER_SELECTOR = "div, span, section, article, main, aside, p, li"; function hasOwnText(el){ for (const node of el.childNodes){ if (node.nodeType === 3 && node.textContent.trim().length > 2) return true; } return false; } function labelFor(el){ if (el.dataset && el.dataset.aoi) return el.dataset.aoi.slice(0,40); const tag = el.tagName.toLowerCase(); if (tag==='nav') return 'navbar'; if (tag==='header') return 'header'; if (tag==='footer') return 'footer'; if (tag==='img'){ const alt=(el.getAttribute('alt')||'').trim(); if (alt) return 'img: '+alt.slice(0,30); const src=el.getAttribute('src')||''; const name=src.split('/').pop().split('?')[0]; return 'img: '+(name||'image').slice(0,30); } if (tag==='video') return 'video'; if (tag==='iframe') return 'embed'; if (tag==='h1'||tag==='h2'){ const t=(el.innerText||'').trim().replace(/\s+/g,' '); if (t) return tag+': '+t.slice(0,30); } const id=el.id?('#'+el.id):''; let cls=''; if (el.className && typeof el.className==='string'){ const f=el.className.trim().split(/\s+/)[0]; if (f) cls='.'+f; } const txt=(el.innerText||'').trim().replace(/\s+/g,' ').slice(0,24); const base=id||cls||tag; return txt?(base+' ('+txt+')'):base; } function refresh(){ const vh = window.innerHeight; const raw = []; const seen = new Set(); let full = false; function consider(el){ if (full) return; // Never track InsightUX's own injected UI (toolbar, sidebar, the // gaze-dot canvas, the mouse tracker panel) as if it were page content. if (OWN_UI_IDS.has(el.id)) return; if (el.closest && el.closest(OWN_UI_SELECTOR)) return; const tag = el.tagName.toLowerCase(); const explicit = !!(el.dataset && el.dataset.aoi); if (!explicit){ const isAlways = (tag==='nav'||tag==='header'||tag==='footer'||tag==='img'|| tag==='video'||tag==='iframe'|| tag==='h1'||tag==='h2'||tag==='h3'||tag==='button'||tag==='figure') || (el.className && typeof el.className==='string' && /hero|banner|card/i.test(el.className)); if (!isAlways && !hasOwnText(el)) return; } const r = el.getBoundingClientRect(); if (r.widthvh) return; const label = labelFor(el); if (!label) return; const key = label+'@'+Math.round(r.left)+','+Math.round(r.top); if (seen.has(key)) return; seen.add(key); const pos = getComputedStyle(el).position; raw.push({el:el, label:label, x:Math.round(r.left), y:Math.round(r.top), w:Math.round(r.width), h:Math.round(r.height), sticky:(pos==='sticky'||pos==='fixed')}); if (raw.length >= MAX_AOIS*2) full = true; } document.querySelectorAll('[data-aoi]').forEach(consider); document.querySelectorAll(ALWAYS_SELECTOR).forEach(consider); const candidates = document.querySelectorAll(TEXT_CONTAINER_SELECTOR); for (let i=0; i { const isTallWrapper = o.h > TALL_WRAPPER_H && raw.some(o2 => o2 !== o && o.el.contains(o2.el)); return !isTallWrapper; }); state.aois = filtered.slice(0, MAX_AOIS).map(o => ({ label:o.label, x:o.x, y:o.y, w:o.w, h:o.h, sticky:o.sticky })); } refresh(); window.addEventListener('scroll', refresh, {passive:true}); setInterval(refresh, 400); window.insightuxAOIs = function(){ return JSON.stringify({ url: location.href, scrollX: Math.round(window.scrollX), scrollY: Math.round(window.scrollY), viewport:{w:window.innerWidth,h:window.innerHeight}, page:{w:document.documentElement.scrollWidth,h:document.documentElement.scrollHeight}, aois: state.aois }); }; window.insightuxUpdate = function(fx, fy){ target.fx = fx; target.fy = fy; }; function renderLoop(){ const w = window.innerWidth, h = window.innerHeight; const tx = target.fx * w, ty = target.fy * h; if (dot.x === null){ dot.x = tx; dot.y = ty; } dot.x += (tx - dot.x) * DOT_LERP; dot.y += (ty - dot.y) * DOT_LERP; const now = performance.now(); const px = dot.x, py = dot.y; let cand = null; for (const a of state.aois){ if (px>=a.x-PAD_PX && px<=a.x+a.w+PAD_PX && py>=a.y-PAD_PX && py<=a.y+a.h+PAD_PX){ if (!cand || (a.w*a.h)<(cand.w*cand.h)) cand = a; } } const candLabel = cand ? cand.label : null; if (candLabel !== dwell.pendLabel){ dwell.pendLabel = candLabel; dwell.pendSince = now; } if (cand){ dwell.emptySince = 0; if (dwell.activeLabel !== cand.label && (now - dwell.pendSince) >= DWELL_MS){ dwell.activeLabel = cand.label; } } else { if (dwell.emptySince === 0) dwell.emptySince = now; if (dwell.activeLabel && (now - dwell.emptySince) >= RELEASE_MS){ dwell.activeLabel = null; } } let active = null; if (dwell.activeLabel){ for (const a of state.aois){ if (a.label === dwell.activeLabel){ active = a; break; } } if (!active) dwell.activeLabel = null; } activeBox = active; ctx.clearRect(0,0,cv.width,cv.height); if (activeBox){ ctx.fillStyle = 'rgba(123,47,190,0.16)'; ctx.fillRect(activeBox.x, activeBox.y, activeBox.w, activeBox.h); ctx.strokeStyle = '#7B2FBE'; ctx.lineWidth = 3; ctx.strokeRect(activeBox.x, activeBox.y, activeBox.w, activeBox.h); const label = activeBox.label; ctx.font = 'bold 13px Arial'; const tw = ctx.measureText(label).width + 16; const ly = Math.max(0, activeBox.y - 22); ctx.fillStyle = '#7B2FBE'; ctx.fillRect(activeBox.x, ly, tw, 20); ctx.fillStyle = '#FFFFFF'; ctx.fillText(label, activeBox.x + 8, ly + 14); } ctx.beginPath(); ctx.arc(dot.x, dot.y, 13, 0, 2*Math.PI); ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fill(); ctx.beginPath(); ctx.arc(dot.x, dot.y, 9, 0, 2*Math.PI); ctx.fillStyle = '#FF2DF0'; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(0,0,0,0.55)'; ctx.stroke(); requestAnimationFrame(renderLoop); } requestAnimationFrame(renderLoop); window.__insightux = true; })(); """ # JS: mouse tracking overlay — ported feature-for-feature from the # "Mouse Tracker & Heatmap" Chrome extension (Mouse/content.js + background.js # + popup.js), adapted to run as a single injected script instead of a # content-script/background/popup trio (pywebview has no extension host). # Same trail/heatmap/dwell sampling loop, same click interest labeling, same # heatmap render, plus an in-page panel that replaces the extension's popup # (Start/Stop/Clear/Toggle Heatmap/View Logs, timer, "Most Viewed Element"). # ============================================================================= MOUSE_JS = r""" (function(){ if (window.__insightuxMouse) { return; } window.__insightuxMouse = true; let isTracking = true; let canvas = null; let panelOpen = false; let logsOpen = false; // Per-sync buffers (flushed to Python every 2s for on-disk persistence) let trailBuffer = []; let heatmapBuffer = []; let clickBuffer = []; let dwellBuffer = []; // Cumulative in-page state (mirrors the extension's background.js state) let allTrail = []; let allHeatmap = []; let allClicks = []; let dwellTotals = {}; let sessionStart = Date.now(); let sessionStop = null; let lastClientX = 0, lastClientY = 0; let lastPageX = 0, lastPageY = 0; let lastSampleTime = 0; const sampleRate = 50; const DWELL_THRESHOLD = 5000; let stationaryStart = 0; let isStationary = false; window.insightuxMouseSetTracking = function(on){ isTracking = !!on; if (isTracking) { sessionStart = Date.now(); sessionStop = null; } else { sessionStop = Date.now(); flushDwell(); } updatePanel(); }; // ---- sampling loop (trail while moving, heatmap dwell points while still) ---- setInterval(function(){ if (!isTracking) return; const currentScrollX = window.scrollX, currentScrollY = window.scrollY; if (lastPageX === 0 && lastPageY === 0 && lastClientX !== 0) { lastPageX = lastClientX + currentScrollX; lastPageY = lastClientY + currentScrollY; } const currentPageX = (lastClientX !== 0) ? (lastClientX + currentScrollX) : lastPageX; const currentPageY = (lastClientY !== 0) ? (lastClientY + currentScrollY) : lastPageY; if (currentPageX === 0 && currentPageY === 0) return; const now = Date.now(); const dt = now - lastSampleTime; if (dt > 1000) { lastSampleTime = now; return; } const dx = currentPageX - lastPageX, dy = currentPageY - lastPageY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > 2) { const pt = { x: currentPageX, y: currentPageY }; trailBuffer.push(pt); allTrail.push(pt); lastPageX = currentPageX; lastPageY = currentPageY; isStationary = false; stationaryStart = 0; } else { if (!isStationary) { isStationary = true; stationaryStart = now; } else { const dwellDuration = now - stationaryStart; if (dwellDuration > DWELL_THRESHOLD) { const pt = { x: currentPageX, y: currentPageY }; heatmapBuffer.push(pt); allHeatmap.push(pt); } } } lastSampleTime = now; }, sampleRate); // ---- dwell / element-interest tracking ---- let currentHoverLabel = null, currentHoverStartTime = 0; function handleHoverChange(newLabel){ if (!isTracking) return; if (newLabel !== currentHoverLabel) { const now = Date.now(); if (currentHoverLabel && currentHoverStartTime > 0) { const duration = now - currentHoverStartTime; if (duration > 10) recordDwell(currentHoverLabel, duration); } currentHoverLabel = newLabel; currentHoverStartTime = newLabel ? now : 0; } } function recordDwell(element, duration){ dwellBuffer.push({ element: element, duration: duration }); dwellTotals[element] = (dwellTotals[element] || 0) + duration; } function flushDwell(){ if (currentHoverLabel && currentHoverStartTime > 0) { const now = Date.now(); const duration = now - currentHoverStartTime; if (duration > 50) recordDwell(currentHoverLabel, duration); currentHoverStartTime = now; } } // ---- sync to Python every 2s -> mouse_log.jsonl in the session folder ---- setInterval(function(){ flushDwell(); if (trailBuffer.length || heatmapBuffer.length || clickBuffer.length || dwellBuffer.length) { const payload = { trail: trailBuffer.length ? trailBuffer : null, heatmap: heatmapBuffer.length ? heatmapBuffer : null, click: clickBuffer.length ? clickBuffer : null, dwell: dwellBuffer.length ? dwellBuffer : null, }; trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = []; if (window.pywebview && window.pywebview.api && window.pywebview.api.log_mouse_data) { try { window.pywebview.api.log_mouse_data(payload).catch(function(){}); } catch (e) {} } } updatePanel(); }, 2000); // ---- exclude InsightUX's own injected UI from every measurement ---- // (the toolbar, sidebar, mouse-tracker panel, heatmap canvas) — only // real website elements should ever show up in trail/heatmap/dwell/clicks. const OWN_UI_IDS = new Set(['__insightux_toolbar', '__insightux_sidebar', '__insightux_pill', '__insightux_canvas', '__insightux_mouse_panel']); const OWN_UI_SELECTOR = '#__insightux_toolbar, #__insightux_sidebar, #__insightux_pill, #__insightux_canvas, #__insightux_mouse_panel'; function isOwnUI(el){ if (!el) return false; if (OWN_UI_IDS.has(el.id)) return true; return !!(el.closest && el.closest(OWN_UI_SELECTOR)); } // ---- mouse position + hover tracking ---- function updateMousePos(e){ if (isOwnUI(e.target)) return; lastClientX = e.clientX; lastClientY = e.clientY; if (lastPageX === 0) lastPageX = e.pageX; if (lastPageY === 0) lastPageY = e.pageY; if (isTracking) handleHoverChange(getSmartLabel(e.target)); } document.addEventListener('mousemove', updateMousePos, true); document.addEventListener('mouseenter', updateMousePos, true); document.addEventListener('mouseover', updateMousePos, true); document.addEventListener('click', updateMousePos, true); document.addEventListener('mouseleave', function(){ lastClientX = 0; lastClientY = 0; handleHoverChange(null); }, true); document.addEventListener('click', function(e){ if (!isTracking) return; if (e.target === canvas) return; if (isOwnUI(e.target)) return; if (!isInteractable(e.target)) return; const x = e.pageX, y = e.pageY; const label = getSmartLabel(e.target) || e.target.tagName; if (['BODY', 'HTML', 'DIV', 'SPAN'].includes(label) && !e.target.innerText.trim()) return; const logEntry = { timestamp: new Date().toLocaleTimeString(), x: x, y: y, element: label, text: e.target.innerText ? e.target.innerText.substring(0, 30).replace(/(\r\n|\n|\r)/gm, ' ').trim() : '', url: window.location.href }; clickBuffer.push(logEntry); allClicks.push(logEntry); if (canvas) drawClick(x, y); updatePanel(); }, true); function isInteractable(el){ if (!el) return false; const tag = el.tagName.toLowerCase(); if (['a', 'button', 'input', 'select', 'textarea', 'details', 'summary', 'label'].includes(tag)) return true; const role = el.getAttribute('role'); if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') return true; try { if (window.getComputedStyle(el).cursor === 'pointer') return true; } catch (e) {} let parent = el.parentElement, depth = 0; while (parent && depth < 3) { const pTag = parent.tagName.toLowerCase(); if (['a', 'button'].includes(pTag)) return true; if (parent.getAttribute('role') === 'button') return true; parent = parent.parentElement; depth++; } if (tag === 'code' || tag === 'pre' || el.classList.contains('code') || el.closest('pre')) return true; return false; } function getSmartLabel(el){ if (!el) return null; const tag = el.tagName.toLowerCase(); if (['body', 'html', 'main', 'div', 'span', 'section', 'article'].includes(tag)) { if (el.id && (el.id.includes('logo') || el.id.includes('wrapper') || el.id.includes('container'))) return null; if (el.className && typeof el.className === 'string' && (el.className.toLowerCase().includes('logo') || el.className.toLowerCase().includes('brand'))) return null; const aria = el.getAttribute('aria-label'); if (aria) return 'Element: ' + aria; if (el.children.length === 0) { const txt = el.innerText.trim(); if (txt.length > 2 && txt.length < 50 && /[a-zA-Z0-9]/.test(txt)) return 'Element: ' + txt; } return null; } if (tag === 'a') return 'Link: ' + (el.innerText.trim().substring(0, 30) || 'Link'); if (tag === 'button') return 'Button: ' + (el.innerText.trim().substring(0, 30) || 'Button'); if (tag === 'input') return 'Input: ' + (el.placeholder || el.name || el.id || 'Input'); if (tag === 'textarea') return 'Input: Text Area'; if (tag === 'img') return 'Image: ' + (el.alt || 'Image'); if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) return tag.toUpperCase() + ': ' + el.innerText.trim().substring(0, 40); if (tag === 'code' || tag === 'pre') return 'Code: ' + el.innerText.trim().substring(0, 30); const text = el.innerText.trim(); if (text && text.length > 2) { if (el.classList.contains('code') || el.closest('pre')) return 'Code: ' + text.substring(0, 30); if (text.toLowerCase().includes('no message found')) return null; const isHex = /^#[0-9A-F]{6}$/i.test(text) || /^#[0-9A-F]{3}$/i.test(text); const isColorName = ['red', 'blue', 'green', 'yellow', 'black', 'white', 'orange', 'purple', 'gray', 'grey', 'pink', 'brown', 'cyan', 'magenta'].includes(text.toLowerCase()); if (isHex || isColorName) return null; if (text.length > 50) return 'Text: ' + text.substring(0, 47) + '...'; return 'Text: ' + text; } return null; } // ---- heatmap overlay canvas (ported 1:1 from the extension) ---- window.insightuxMouseToggleHeatmap = function(){ if (canvas) { document.body.removeChild(canvas); canvas = null; return; } canvas = document.createElement('canvas'); canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0'; canvas.style.zIndex = '2147483645'; canvas.style.pointerEvents = 'none'; canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth); canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight); document.body.appendChild(canvas); drawHeatmapHighQuality(allHeatmap, allTrail, allClicks); }; function drawClick(x, y){ if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.save(); ctx.beginPath(); ctx.strokeStyle = '#00FF00'; ctx.lineWidth = 3; ctx.shadowColor = 'black'; ctx.shadowBlur = 2; const size = 10; ctx.moveTo(x - size, y - size); ctx.lineTo(x + size, y + size); ctx.moveTo(x + size, y - size); ctx.lineTo(x - size, y + size); ctx.stroke(); ctx.beginPath(); ctx.arc(x, y, size + 5, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } function drawHeatmapHighQuality(heatmapData, trailData, clickData){ if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); let allHeatPoints = []; if (heatmapData && heatmapData.length) allHeatPoints = allHeatPoints.concat(heatmapData); if (trailData && trailData.length) allHeatPoints = allHeatPoints.concat(trailData); if (clickData && clickData.length) { clickData.forEach(function(c){ for (let i = 0; i < 5; i++) allHeatPoints.push({ x: c.x, y: c.y }); }); } if (!allHeatPoints.length) return; const radius = 60; const brushCanvas = document.createElement('canvas'); brushCanvas.width = radius * 2; brushCanvas.height = radius * 2; const brushCtx = brushCanvas.getContext('2d'); const g = brushCtx.createRadialGradient(radius, radius, 0, radius, radius, radius); g.addColorStop(0, 'rgba(0, 0, 0, 0.05)'); g.addColorStop(1, 'rgba(0, 0, 0, 0)'); brushCtx.fillStyle = g; brushCtx.fillRect(0, 0, radius * 2, radius * 2); allHeatPoints.forEach(function(point){ ctx.drawImage(brushCanvas, point.x - radius, point.y - radius); }); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imageData.data; const gradientMap = createGradientMap(); for (let i = 0; i < data.length; i += 4) { const alpha = data[i + 3]; if (alpha > 0) { let mapIndex = Math.floor(alpha * 1.5); if (mapIndex > 255) mapIndex = 255; const cIndex = mapIndex * 4; data[i] = gradientMap[cIndex]; data[i + 1] = gradientMap[cIndex + 1]; data[i + 2] = gradientMap[cIndex + 2]; data[i + 3] = Math.min(255, 150 + alpha); } } ctx.putImageData(imageData, 0, 0); } function createGradientMap(){ const c = document.createElement('canvas'); c.width = 256; c.height = 1; const ctx = c.getContext('2d'); const g = ctx.createLinearGradient(0, 0, 256, 0); g.addColorStop(0.0, 'rgba(0, 0, 255, 0)'); g.addColorStop(0.1, 'rgba(0, 0, 255, 1)'); g.addColorStop(0.4, 'rgba(0, 255, 255, 1)'); g.addColorStop(0.6, 'rgba(0, 255, 0, 1)'); g.addColorStop(0.8, 'rgba(255, 255, 0, 1)'); g.addColorStop(1.0, 'rgba(255, 0, 0, 1)'); ctx.fillStyle = g; ctx.fillRect(0, 0, 256, 1); return ctx.getImageData(0, 0, 256, 1).data; } window.addEventListener('resize', function(){ if (canvas) { canvas.width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, document.documentElement.offsetWidth); canvas.height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, document.documentElement.offsetHeight); } }); // ---- insights panel: in-page port of the extension's popup.html/popup.js ---- const style = document.createElement('style'); style.textContent = ` #__insightux_mouse_panel { position: fixed; top: 44px; right: 16px; width: 260px; z-index: 2147483647; background: linear-gradient(180deg, rgba(28,24,38,0.97), rgba(20,18,26,0.97)); border: 1px solid #3a3348; border-radius: 12px; box-shadow: 0 8px 28px rgba(0,0,0,0.45); font: 12px -apple-system, 'Segoe UI', Arial; color: #f0ecf7; padding: 14px; pointer-events: auto; display: none; } #__insightux_mouse_panel.open { display: block; } #__insightux_mouse_panel h3 { margin: 0 0 8px 0; font-size: 13px; color: #f0ecf7; display:flex; align-items:center; justify-content:space-between; } #__insightux_mouse_panel .muted { color: #9a92ad; font-size: 11px; } #__insightux_mouse_panel .row { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; } #__insightux_mouse_panel button { flex: 1 1 auto; padding: 6px 8px; font-size: 11px; border-radius: 6px; border: 1px solid #3a3348; background: #241f2e; color: #f0ecf7; cursor: pointer; } #__insightux_mouse_panel button:hover { background: #302a3d; } #__insightux_mouse_panel .timer { font-weight: 600; margin-bottom: 6px; } #__insightux_mouse_panel .hero { background: rgba(123,47,190,0.15); border: 1px solid #7B2FBE; border-radius: 8px; padding: 8px; text-align: center; margin-bottom: 8px; } #__insightux_mouse_panel .hero .lbl { font-size: 9px; text-transform: uppercase; color: #c9a6f5; letter-spacing: 0.05em; } #__insightux_mouse_panel .hero .el { font-weight: 600; margin: 3px 0; color: #FF2DF0; } #__insightux_mouse_panel .interest-item, #__insightux_mouse_panel .log-item { background: #241f2e; border-radius: 6px; padding: 5px 7px; margin-bottom: 4px; font-size: 11px; } #__insightux_mouse_panel .list { max-height: 160px; overflow-y: auto; } `; document.head.appendChild(style); const panel = document.createElement('div'); panel.id = '__insightux_mouse_panel'; panel.innerHTML = `

Mouse Tracker idle

Session Time: 00:00
`; document.documentElement.appendChild(panel); document.getElementById('__mt_clear').addEventListener('click', function(){ trailBuffer = []; heatmapBuffer = []; clickBuffer = []; dwellBuffer = []; allTrail = []; allHeatmap = []; allClicks = []; dwellTotals = {}; sessionStart = Date.now(); sessionStop = null; if (canvas) { document.body.removeChild(canvas); canvas = null; } updatePanel(); }); document.getElementById('__mt_heatmap').addEventListener('click', function(){ window.insightuxMouseToggleHeatmap(); }); document.getElementById('__mt_logs').addEventListener('click', function(){ logsOpen = !logsOpen; document.getElementById('__mt_logs_list').style.display = logsOpen ? 'block' : 'none'; document.getElementById('__mt_interests').style.display = logsOpen ? 'none' : 'block'; updatePanel(); }); window.insightuxMouseTogglePanel = function(){ panelOpen = !panelOpen; panel.classList.toggle('open', panelOpen); if (panelOpen) updatePanel(); }; function formatTime(ms){ return Math.round(ms / 1000) + 's'; } function updatePanel(){ if (!panelOpen) return; document.getElementById('__mt_status').textContent = isTracking ? 'tracking' : 'stopped'; const startTs = sessionStart; const diff = isTracking ? Math.floor((Date.now() - startTs) / 1000) : (sessionStop ? Math.floor((sessionStop - startTs) / 1000) : 0); const mins = Math.floor(diff / 60).toString().padStart(2, '0'); const secs = (diff % 60).toString().padStart(2, '0'); document.getElementById('__mt_timer').textContent = 'Session Time: ' + mins + ':' + secs; const interests = Object.entries(dwellTotals).map(function(e){ return { element: e[0], duration: e[1] }; }) .sort(function(a, b){ return b.duration - a.duration; }).slice(0, 6); const hero = document.getElementById('__mt_hero'); if (interests.length) { hero.style.display = 'block'; document.getElementById('__mt_hero_el').textContent = interests[0].element; document.getElementById('__mt_hero_time').textContent = formatTime(interests[0].duration); } else { hero.style.display = 'none'; } const interestsEl = document.getElementById('__mt_interests'); if (!logsOpen) { if (interests.length > 1) { interestsEl.innerHTML = interests.slice(1).map(function(it, i){ return '
' + (i + 2) + '. ' + it.element + ' — ' + formatTime(it.duration) + '
'; }).join(''); } else { interestsEl.innerHTML = '
No other interests yet.
'; } } const logsEl = document.getElementById('__mt_logs_list'); if (logsOpen) { if (allClicks.length) { logsEl.innerHTML = allClicks.slice().reverse().slice(0, 20).map(function(l){ return '
' + l.timestamp + '
' + l.element + '
"' + l.text + '"
'; }).join(''); } else { logsEl.innerHTML = '
No clicks recorded.
'; } } } setInterval(updatePanel, 1000); // ---- hotkeys: H = toggle heatmap, M = toggle insights panel ---- function isTypingTarget(el){ if (!el) return false; const tag = el.tagName ? el.tagName.toLowerCase() : ''; return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true; } document.addEventListener('keydown', function(e){ const typingBlocked = isTypingTarget(e.target) || isTypingTarget(document.activeElement); if (typingBlocked) return; if (e.key === 'h' || e.key === 'H') window.insightuxMouseToggleHeatmap(); else if (e.key === 'm' || e.key === 'M') window.insightuxMouseTogglePanel(); }, true); updatePanel(); })(); """ # ============================================================================= # API exposed to JS — start/stop/quit are the entry points # ============================================================================= # Pages tracking must never start on, checked against window.get_current_url() # as a Python-side backstop to CHROME_JS's isTrackable check (belt and # suspenders — the JS guard can't run before the JS bridge is up). _NON_TRACKABLE_URL_MARKERS = ("insightux_landing.html", "analysis_report.html") def _relaunch_args(mode): """Command to spawn calibrate.py/validate.py's logic in a fresh process. Frozen (PyInstaller) builds have no bundled interpreter to hand a .py file to, and sys.executable IS the frozen exe itself — so a frozen build relaunches itself with --mode instead. Unpackaged/dev mode still runs from the venv exactly as before.""" if getattr(sys, "frozen", False): return [sys.executable, "--mode", mode] return [sys.executable, os.path.abspath(__file__), "--mode", mode] class Api: def __init__(self): self.window = None self.tracking = False self.stop_event = threading.Event() self.thread = None self.mouse_log_f = None self.mouse_close_timer = None self.calib_proc = None self.validate_proc = None self.update_info = None def _notify_update(self, version, url): try: self.window.evaluate_js( f"window.insightuxShowUpdate && window.insightuxShowUpdate(" f"{json.dumps(version)}, {json.dumps(url)})" ) except Exception: pass def open_update_page(self, url): print(f"[browser_session] open_update_page() called from JS: {url}") import webbrowser try: webbrowser.open(url) except Exception as e: print(f"[browser_session] failed to open update url: {e}") return True def toggle_session(self): """Single entry point for the sidebar's Start/Stop Session button — lets the JS button stay dumb (always call the same thing) while Python decides which action makes sense from current state.""" return self.stop_tracking() if self.tracking else self.start_tracking() def start_tracking(self): print("[browser_session] start_tracking() called from JS") if self.tracking: print("[browser_session] already tracking, ignoring") return False try: current_url = self.window.get_current_url() or "" except Exception: current_url = "" if any(marker in current_url for marker in _NON_TRACKABLE_URL_MARKERS): print(f"[browser_session] refusing to start on non-website page: {current_url}") self._set_status("Start tracking only works on a website — search or open a page first.") return False if self._external_proc_running(): print("[browser_session] refusing to start, calibration/validation still running") self._set_status("Wait for calibration/validation to finish before starting a session.") return False if not os.path.exists(CALIBRATION_PATH): print(f"[browser_session] no {CALIBRATION_PATH} found — run calibrate.py first") self._set_status("No calibration.pkl found — run calibrate.py first.") return False self.tracking = True self.stop_event.clear() session_dir = os.path.join(SESSIONS_ROOT, datetime.now().strftime("%Y%m%d_%H%M%S")) os.makedirs(session_dir, exist_ok=True) print(f"[browser_session] starting tracking thread -> {session_dir}") self._inject_tracking_overlay() self._inject_mouse_overlay() self._open_mouse_log(session_dir) self._set_mouse_tracking(True) self._set_session_ui(True) self._set_status("Recording gaze + mouse... Press E to stop, H for heatmap, M for mouse panel") self.thread = threading.Thread( target=gaze_worker, args=(self.window, self.stop_event, session_dir, self), daemon=True ) self.thread.start() return True def stop_tracking(self): print("[browser_session] stop_tracking() called from JS") if not self.tracking: print("[browser_session] not currently tracking, ignoring") return False self._set_status("Wrapping up your session...") self._set_mouse_tracking(False) self._set_session_ui(False) self.stop_event.set() # Give the page a moment to flush its last batch of mouse data over # log_mouse_data() before the file handle is closed. if self.mouse_close_timer: self.mouse_close_timer.cancel() self.mouse_close_timer = threading.Timer(2.5, self._close_mouse_log) self.mouse_close_timer.daemon = True self.mouse_close_timer.start() return True def quit_app(self): # X is an emergency kill switch, not a graceful shutdown — the user # wants the terminal process gone immediately, camera/threads and all. print("[browser_session] quit_app() called from JS -- terminating process") os._exit(0) # -- calibration / validation ------------------------------------------ # calibrate.py and validate.py are unmodified, standalone OpenCV/pyautogui # scripts (their own fullscreen window, their own event loop) — they were # never meant to share a process with pywebview's GUI loop. Launching them # as a subprocess reuses them exactly as-is instead of rewriting their # display logic into the browser. def _external_proc_running(self): running = lambda p: p is not None and p.poll() is None return running(self.calib_proc) or running(self.validate_proc) def start_calibration(self): print("[browser_session] start_calibration() called from JS") if self.tracking: self._set_status("Stop the current session (E) before calibrating.") return False if self._external_proc_running(): self._set_status("Calibration/validation is already running in another window.") return False try: self.calib_proc = subprocess.Popen(_relaunch_args("calibrate"), cwd=BASE_DIR) except Exception as e: print(f"[browser_session] failed to launch calibrate.py: {e}") self._set_status("Could not launch calibration — see terminal for details.") return False self._set_status("Calibration launched in a separate window — follow the dots there.") threading.Thread( target=self._watch_external_proc, args=(self.calib_proc, "Calibration"), daemon=True ).start() return True def run_validation(self): print("[browser_session] run_validation() called from JS") if self.tracking: self._set_status("Stop the current session (E) before validating.") return False if self._external_proc_running(): self._set_status("Calibration/validation is already running in another window.") return False if not os.path.exists(CALIBRATION_PATH): self._set_status("No calibration.pkl found — run Calibrate first.") return False try: self.validate_proc = subprocess.Popen(_relaunch_args("validate"), cwd=BASE_DIR) except Exception as e: print(f"[browser_session] failed to launch validate.py: {e}") self._set_status("Could not launch validation — see terminal for details.") return False self._set_status("Validation launched in a separate window — look at each dot.") threading.Thread( target=self._watch_external_proc, args=(self.validate_proc, "Validation"), daemon=True ).start() return True def _watch_external_proc(self, proc, label): proc.wait() print(f"[browser_session] {label} process exited (code {proc.returncode})") if label == "Calibration": self._set_status("Calibration finished — check the terminal for the quality readout, then press S or Start Session.") else: self._set_status("Validation finished — check the terminal for the accuracy numbers.") def _set_session_ui(self, is_tracking): try: flag = "true" if is_tracking else "false" self.window.evaluate_js( f"window.insightuxSetSessionUI && window.insightuxSetSessionUI({flag})" ) except Exception: pass def _inject_tracking_overlay(self): try: self.window.evaluate_js(TRACKING_JS) except Exception as e: print(f"[browser_session] overlay inject failed: {e}") def _inject_mouse_overlay(self): try: self.window.evaluate_js(MOUSE_JS) except Exception as e: print(f"[browser_session] mouse overlay inject failed: {e}") def _set_mouse_tracking(self, on): try: flag = "true" if on else "false" self.window.evaluate_js( f"window.insightuxMouseSetTracking && window.insightuxMouseSetTracking({flag})" ) except Exception: pass def _open_mouse_log(self, session_dir): self._close_mouse_log() path = os.path.join(session_dir, "mouse_log.jsonl") try: self.mouse_log_f = open(path, "w", buffering=1) self.mouse_log_f.write(json.dumps({"type": "meta", "t": round(time.time(), 4)}) + "\n") print(f"[browser_session] writing mouse stream to {path}") except Exception as e: print(f"[browser_session] could not open mouse log: {e}") self.mouse_log_f = None def _close_mouse_log(self): if self.mouse_log_f: try: self.mouse_log_f.close() print("[browser_session] mouse log closed") except Exception: pass self.mouse_log_f = None def log_mouse_data(self, payload): """Called from the injected Mouse Tracker overlay (MOUSE_JS) roughly every 2s while tracking, with a batch of trail / heatmap / click / dwell records — same cadence as the extension's background.js sync loop, just persisted to mouse_log.jsonl instead of chrome.storage.""" if not self.mouse_log_f: return False try: rec = dict(payload) if isinstance(payload, dict) else {} rec["type"] = "mouse_batch" rec["t"] = round(time.time(), 4) self.mouse_log_f.write(json.dumps(rec) + "\n") except Exception as e: print(f"[browser_session] mouse log write failed: {e}") return True def _set_status(self, text): try: self.window.evaluate_js(f"window.insightuxSetStatus && window.insightuxSetStatus({json.dumps(text)})") except Exception: pass api = Api() def _version_tuple(v): return tuple(int(p) for p in v.split(".") if p.isdigit()) def check_for_update(): """Runs once in a background thread at startup. Publishing a new release is still fully manual (bump VERSION here + AppVersion in installer.iss + version.json, rebuild, push) — this only automates the *checking* side, per the confirmed scope. No internet / any failure here is silent: the user just doesn't see an update banner, never an error.""" import urllib.request try: with urllib.request.urlopen(VERSION_CHECK_URL, timeout=4) as resp: data = json.loads(resp.read().decode("utf-8")) latest = data.get("latest", "") url = data.get("url", "") if latest and url and _version_tuple(latest) > _version_tuple(VERSION): api.update_info = {"version": latest, "url": url} api._notify_update(latest, url) print(f"[browser_session] update available: {VERSION} -> {latest}") except Exception as e: print(f"[browser_session] update check skipped: {e}") def on_page_loaded(window): """Fires on every page load (navigation, back/forward, reload — not just the first page). The chrome must be re-injected every time since each navigation is a fresh document. If a session is already running, the gaze/mouse overlays need the same treatment or navigating mid-session would silently kill the dot and the mouse tracker on the new page.""" try: window.evaluate_js(CHROME_JS) except Exception as e: print(f"[browser_session] chrome inject failed (will retry): {e}") if api.tracking: api._inject_tracking_overlay() api._inject_mouse_overlay() api._set_mouse_tracking(True) api._set_session_ui(True) # CHROME_JS was just re-injected fresh on this page and has no memory of # an update found on a previous page — re-tell it if one was found. if api.update_info: api._notify_update(api.update_info["version"], api.update_info["url"]) # ============================================================================= # GAZE WORKER — runs in a background thread, started/stopped via Api # ============================================================================= def gaze_worker(window, stop_event, session_dir, api_ref): pipeline = InsightUXPipeline(ONNX_PATH, CALIBRATION_PATH) face_mesh = create_face_mesh(static_image_mode=False) cap = cv2.VideoCapture(0) logger = GazeLogger(SCREEN_W, SCREEN_H, session_dir=session_dir) dom_path = os.path.join(session_dir, "dom_log.jsonl") dom_f = open(dom_path, "w", buffering=1) screens_dir = os.path.join(session_dir, "screenshots") os.makedirs(screens_dir, exist_ok=True) shot_count = 0 last_shot_scroll = None last_shot_time = 0.0 SCROLL_SHOT_THRESHOLD = 60 # px scrolled before a new snapshot is worth taking MAX_SHOT_INTERVAL = 2.5 # seconds — take one anyway if you just sit still def maybe_capture_screenshot(scroll_y): """Grab a full-screen shot when the page has scrolled enough, or periodically even if it hasn't — this is what lets the report show the heatmap ON TOP of the actual page instead of floating in a void.""" nonlocal shot_count, last_shot_scroll, last_shot_time now = time.time() scrolled_enough = last_shot_scroll is None or abs(scroll_y - last_shot_scroll) >= SCROLL_SHOT_THRESHOLD time_elapsed = (now - last_shot_time) >= MAX_SHOT_INTERVAL if not (scrolled_enough or time_elapsed): return None try: img = pyautogui.screenshot() # Force to the same pixel space as gaze coordinates (SCREEN_W x # SCREEN_H) — Windows DPI scaling can otherwise return a # screenshot at a different resolution than pyautogui.size(), # which would silently misalign every heatmap point. if img.size != (SCREEN_W, SCREEN_H): img = img.resize((SCREEN_W, SCREEN_H)) shot_name = f"shot_{shot_count:04d}.png" img.save(os.path.join(screens_dir, shot_name)) shot_count += 1 last_shot_scroll = scroll_y last_shot_time = now return f"screenshots/{shot_name}" except Exception as e: print(f"[browser_session] screenshot capture failed: {e}") return None print(f"[browser_session] tracking started -> {session_dir}") cam_matrix = None last_x = SCREEN_W / 2 last_y = SCREEN_H / 2 fil_x = OneEuroFilter(mincutoff=EURO_MINCUTOFF, beta=EURO_BETA) fil_y = OneEuroFilter(mincutoff=EURO_MINCUTOFF, beta=EURO_BETA) angle_smoother = GazeAngleSmoother(window=ANGLE_SMOOTH_WINDOW) sm_pitch = sm_yaw = sm_roll = None no_face_count = 0 frame_i = 0 while not stop_event.is_set(): ret, frame = cap.read() if not ret: break if cam_matrix is None: cam_matrix = estimate_camera_matrix(frame.shape) rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = face_mesh.process(rgb) if not results.multi_face_landmarks: no_face_count += 1 if no_face_count >= NO_FACE_RESET: last_x, last_y = SCREEN_W / 2, SCREEN_H / 2 fil_x.reset(); fil_y.reset() angle_smoother.reset() sm_pitch = sm_yaw = sm_roll = None no_face_count = 0 continue no_face_count = 0 lms = results.multi_face_landmarks[0].landmark head_pose = estimate_head_pose(lms, frame.shape, cam_matrix) if head_pose is None: continue if sm_pitch is None: sm_pitch, sm_yaw, sm_roll = head_pose.pitch, head_pose.yaw, head_pose.roll else: b = POSE_SMOOTH sm_pitch = b*sm_pitch + (1-b)*head_pose.pitch sm_yaw = b*sm_yaw + (1-b)*head_pose.yaw sm_roll = b*sm_roll + (1-b)*head_pose.roll head_pose = replace(head_pose, pitch=sm_pitch, yaw=sm_yaw, roll=sm_roll) pose_vec = normalize_pose(sm_pitch, sm_yaw, sm_roll) # Eye aperture — the vertical cue. Calibration decides whether this or # the CNN's pitch actually tracks screen-Y, and uses the better one. ear_now = 0.5 * (compute_ear(lms, LEFT_EAR_INDICES, frame.shape) + compute_ear(lms, RIGHT_EAR_INDICES, frame.shape)) def get_patch(eye_idx, ear_idx, iris_idx): s1 = step1_normalize(frame, lms, head_pose, eye_idx, ear_idx, iris_idx) if not s1.is_open: return None if PATCH_SOURCE == "norm": return s1.norm_crop ir = compute_iris_radius(lms, iris_idx, frame.shape) s2 = step2_illumination(s1, ir) return s2.blended if s2.is_usable else None left_patch = get_patch(LEFT_EYE_INDICES, LEFT_EAR_INDICES, LEFT_IRIS_INDICES) right_patch = get_patch(RIGHT_EYE_INDICES, RIGHT_EAR_INDICES, RIGHT_IRIS_INDICES) if left_patch is None and right_patch is None: continue if left_patch is None: left_patch = right_patch if right_patch is None: right_patch = left_patch _, _, raw_pitch, raw_yaw = pipeline.predict_gaze_vector(left_patch, pose_vec, right_patch) pitch = compensate_pitch(raw_pitch, sm_pitch) yaw = compensate_yaw(raw_yaw, sm_yaw) # Smooth the ANGLES before the RBF sees them. The RBF amplifies input # noise (steep gradient along the low-variance pitch axis), so noise # must be removed here — smoothing the screen coords afterwards cannot # undo amplification that already happened through a nonlinear map. pitch, yaw, ear_s = angle_smoother(pitch, yaw, ear_now) sx, sy = pipeline.calibration.predict(pitch, yaw, ear_s) sx = max(0.0, min(sx, SCREEN_W)) sy = max(0.0, min(sy, SCREEN_H)) jump = np.hypot(sx - last_x, sy - last_y) if jump > MAX_JUMP: sx = last_x + (sx - last_x) * 0.3 sy = last_y + (sy - last_y) * 0.3 last_x, last_y = sx, sy logger.log(sx, sy) now = time.time() fx = fil_x(sx / SCREEN_W, now) fy = fil_y(sy / SCREEN_H, now) try: window.evaluate_js(f"window.insightuxUpdate({fx:.5f},{fy:.5f})") except Exception: pass frame_i += 1 if frame_i % DOM_EVERY == 0: try: raw = window.evaluate_js("window.insightuxAOIs()") if raw: rec = json.loads(raw) rec["type"] = "dom" rec["t"] = round(time.time(), 4) shot_ref = maybe_capture_screenshot(rec.get("scrollY", 0)) if shot_ref: rec["screenshot"] = shot_ref dom_f.write(json.dumps(rec) + "\n") except Exception: pass time.sleep(0.005) cap.release() logger.close() dom_f.close() print(f"[browser_session] session saved in {session_dir}") # generate report and hand control back try: report_path = generate_report(session_dir) api_ref.tracking = False window.load_url("file://" + report_path) print(f"[browser_session] report ready -> {report_path}") except Exception as e: print(f"[browser_session] report generation failed: {e}") api_ref.tracking = False # ============================================================================= # MAIN # ============================================================================= def _go_fullscreen(window): # Small delay so WebView2 fully initializes and grabs keyboard focus # BEFORE going fullscreen. Passing fullscreen=True straight to # create_window is what causes the "can't type" issue and the # AccessibilityObject.Bounds recursion crash on Windows — both trace # back to the WinForms host not finishing its focus/accessibility # setup before the fullscreen transition happens. time.sleep(0.4) try: window.toggle_fullscreen() except Exception as e: print(f"[browser_session] fullscreen toggle failed: {e}") if __name__ == "__main__": # A packaged build is one exe with no separate calibrate.py/validate.py # files to shell out to — --mode makes it a single self-dispatching # entry point instead. See _relaunch_args() above for the launch side. import argparse parser = argparse.ArgumentParser() parser.add_argument("--mode", choices=["browser", "calibrate", "validate"], default="browser") args = parser.parse_args() if args.mode == "calibrate": import calibrate calibrate.main() elif args.mode == "validate": import validate validate.main() else: os.makedirs(SESSIONS_ROOT, exist_ok=True) window = webview.create_window( "InsightUX — Eye-Tracking Research Browser", LANDING_URL, width=SCREEN_W, height=SCREEN_H, js_api=api ) api.window = window window.events.loaded += lambda: on_page_loaded(window) window.events.shown += lambda: threading.Thread( target=_go_fullscreen, args=(window,), daemon=True ).start() threading.Thread(target=check_for_update, daemon=True).start() # debug=True enables right-click > Inspect (DevTools) so you can see # the [insightux] console.log diagnostics from CHROME_JS directly — # keyboard handling runs entirely in the browser, so JS-side issues # never show up in this terminal, only in DevTools' Console tab. webview.start(debug=True)