human-movement-bench 0.1.2__tar.gz
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.
- human_movement_bench-0.1.2/CITATION.cff +11 -0
- human_movement_bench-0.1.2/CONTRIBUTORS.md +5 -0
- human_movement_bench-0.1.2/LICENSE +21 -0
- human_movement_bench-0.1.2/PKG-INFO +39 -0
- human_movement_bench-0.1.2/README.md +12 -0
- human_movement_bench-0.1.2/pyproject.toml +50 -0
- human_movement_bench-0.1.2/pyproject.toml.orig +32 -0
- human_movement_bench-0.1.2/src/human_movement_bench/__init__.py +4 -0
- human_movement_bench-0.1.2/src/human_movement_bench/assets/fixture.html +27 -0
- human_movement_bench-0.1.2/src/human_movement_bench/baselines.py +48 -0
- human_movement_bench-0.1.2/src/human_movement_bench/cli.py +178 -0
- human_movement_bench-0.1.2/src/human_movement_bench/datasets.py +209 -0
- human_movement_bench-0.1.2/src/human_movement_bench/delivery.py +51 -0
- human_movement_bench-0.1.2/src/human_movement_bench/detectors.py +186 -0
- human_movement_bench-0.1.2/src/human_movement_bench/evaluation.py +230 -0
- human_movement_bench-0.1.2/src/human_movement_bench/external.py +128 -0
- human_movement_bench-0.1.2/src/human_movement_bench/features.py +126 -0
- human_movement_bench-0.1.2/src/human_movement_bench/personal_diagnostic.py +256 -0
- human_movement_bench-0.1.2/src/human_movement_bench/personal_diagnostic_spec.json +22 -0
- human_movement_bench-0.1.2/src/human_movement_bench/report.py +39 -0
- human_movement_bench-0.1.2/src/human_movement_bench/spec.json +21 -0
- human_movement_bench-0.1.2/src/human_movement_bench/traces.py +123 -0
- human_movement_bench-0.1.2/src/human_movement_bench/website.py +167 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use Human Movement, please cite this software."
|
|
3
|
+
type: software
|
|
4
|
+
title: Human Movement Benchmark
|
|
5
|
+
version: 0.1.2
|
|
6
|
+
date-released: 2026-11-06
|
|
7
|
+
authors:
|
|
8
|
+
- family-names: Galal
|
|
9
|
+
given-names: Karim
|
|
10
|
+
license: MIT
|
|
11
|
+
abstract: "Windows-first personalized cursor trajectories and input timing for computer-use applications."
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Contributor
|
|
2
|
+
|
|
3
|
+
Karim Galal is the sole contributor and maintainer of Human Movement.
|
|
4
|
+
|
|
5
|
+
External libraries, datasets, research papers and engineering baselines retain their original ownership and attribution. They are references or dependencies, rather than contributors to this package.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Karim Galal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: human-movement-bench
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Independent reproducible evaluation of timed human input traces
|
|
5
|
+
Author: Karim Galal
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Dist: numpy>=2.1
|
|
13
|
+
Requires-Dist: scipy>=1.14
|
|
14
|
+
Requires-Dist: scikit-learn>=1.5
|
|
15
|
+
Requires-Dist: joblib>=1.4
|
|
16
|
+
Requires-Dist: pytweening==1.2.0 ; extra == 'baselines'
|
|
17
|
+
Requires-Dist: selenium>=4.25 ; extra == 'baselines'
|
|
18
|
+
Requires-Dist: pillow>=11 ; extra == 'datasets'
|
|
19
|
+
Requires-Dist: pyarrow>=19 ; extra == 'datasets'
|
|
20
|
+
Requires-Dist: torch>=2.6 ; extra == 'detectors'
|
|
21
|
+
Maintainer: Karim Galal
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Provides-Extra: baselines
|
|
24
|
+
Provides-Extra: datasets
|
|
25
|
+
Provides-Extra: detectors
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Human Movement Benchmark
|
|
29
|
+
|
|
30
|
+
Independent evaluation of timed mouse, click, keyboard and scroll traces. Developed and maintained solely by Karim Galal.
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install human-movement-bench
|
|
34
|
+
human-movement-bench --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Install `human-movement-bench[detectors]` to use the optional PyTorch detector. This evaluator does not import the movement generator. Headline scores require matched independent data, detector controls and successful functional checks; synthetic fixtures only validate the implementation.
|
|
38
|
+
|
|
39
|
+
Licensed under MIT. See the included LICENSE file.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Human Movement Benchmark
|
|
2
|
+
|
|
3
|
+
Independent evaluation of timed mouse, click, keyboard and scroll traces. Developed and maintained solely by Karim Galal.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install human-movement-bench
|
|
7
|
+
human-movement-bench --help
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Install `human-movement-bench[detectors]` to use the optional PyTorch detector. This evaluator does not import the movement generator. Headline scores require matched independent data, detector controls and successful functional checks; synthetic fixtures only validate the implementation.
|
|
11
|
+
|
|
12
|
+
Licensed under MIT. See the included LICENSE file.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "human-movement-bench"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
description = "Independent reproducible evaluation of timed human input traces"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"numpy>=2.1",
|
|
9
|
+
"scipy>=1.14",
|
|
10
|
+
"scikit-learn>=1.5",
|
|
11
|
+
"joblib>=1.4",
|
|
12
|
+
]
|
|
13
|
+
license = "MIT"
|
|
14
|
+
license-files = ["LICENSE"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[[project.authors]]
|
|
23
|
+
name = "Karim Galal"
|
|
24
|
+
|
|
25
|
+
[[project.maintainers]]
|
|
26
|
+
name = "Karim Galal"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
human-movement-bench = "human_movement_bench.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
detectors = ["torch>=2.6"]
|
|
33
|
+
datasets = [
|
|
34
|
+
"pillow>=11",
|
|
35
|
+
"pyarrow>=19",
|
|
36
|
+
]
|
|
37
|
+
baselines = [
|
|
38
|
+
"pytweening==1.2.0",
|
|
39
|
+
"selenium>=4.25",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
44
|
+
build-backend = "uv_build"
|
|
45
|
+
|
|
46
|
+
[tool.uv.build-backend]
|
|
47
|
+
source-include = [
|
|
48
|
+
"CITATION.cff",
|
|
49
|
+
"CONTRIBUTORS.md",
|
|
50
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "human-movement-bench"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
description = "Independent reproducible evaluation of timed human input traces"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = ["numpy>=2.1", "scipy>=1.14", "scikit-learn>=1.5", "joblib>=1.4"]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
authors = [{name = "Karim Galal"}]
|
|
11
|
+
maintainers = [{name = "Karim Galal"}]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
human-movement-bench = "human_movement_bench.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
detectors = ["torch>=2.6"]
|
|
24
|
+
datasets = ["pillow>=11", "pyarrow>=19"]
|
|
25
|
+
baselines = ["pytweening==1.2.0", "selenium>=4.25"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.13.7,<0.14.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
source-include = ["CITATION.cff", "CONTRIBUTORS.md"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Human Movement · Independent fixture</title>
|
|
2
|
+
<style>body{font:16px/1.5 system-ui;background:#f4f3ed;color:#183b2b;max-width:1000px;margin:30px auto;padding:20px}h1{font-size:36px;font-weight:550}button{font:inherit;border:0;background:#285740;color:white;padding:12px 20px;border-radius:8px;cursor:pointer}header{display:flex;justify-content:space-between;align-items:center}#arena{height:420px;background:white;border:1px solid #d6dfd3;border-radius:12px;position:relative;overflow:hidden;margin:24px 0}.target{position:absolute;padding:0;border-radius:50%}.drop{position:absolute;right:50px;top:180px;width:120px;height:100px;border:2px dashed #285740;display:grid;place-items:center}.drag{position:absolute;left:50px;top:180px;width:50px;height:50px;background:#285740;border-radius:8px;touch-action:none}.corridor{position:absolute;left:50px;right:50px;top:165px;height:130px;background:#edf3e9}.pane{overflow:auto;height:100%;padding:20px}.card{height:150px;background:#f1f4ec;margin:12px;padding:20px}.goal{border:2px solid #285740}textarea{font:20px system-ui;width:90%;height:160px;padding:15px;margin:10px}#status{color:#60766a}#error{color:#a33}small{letter-spacing:1px}nav{display:flex;gap:12px;flex-wrap:wrap}.summary{padding:25px}#finish{background:#e1e7db;color:#183b2b}</style>
|
|
3
|
+
<header><small>HMBENCH-1 · LOCAL FIXTURE</small><span id="label"></span></header><h1>Measure what actually arrives.</h1><p id="instructions">Capture pointing, clicks, a corridor drag, scrolling, and a known typing sample. These events belong to a separate benchmark session.</p>
|
|
4
|
+
<nav><button id="start">Start session</button><button id="next" disabled>Next task</button><button id="finish" disabled>Finish & save</button></nav><section id="arena"><div class="summary"><h2>Ready when you are.</h2><p>Use identical display settings for every method. Browser and injection diagnostics are reported separately from behavioral similarity.</p></div></section><p id="status">Nothing is recording.</p><p id="error" role="alert"></p>
|
|
5
|
+
<script>
|
|
6
|
+
const config=__CONFIG__,arena=document.querySelector('#arena'),status=document.querySelector('#status');document.querySelector('#label').textContent=config.label+' · '+config.version;
|
|
7
|
+
let active=false,mode=0,trial=0,events=[],start=0,chain=Promise.resolve(),presses=new Map(),id=0,untrusted=0,dragging=false,corridor=true;
|
|
8
|
+
const names=['mouse','click','mouse','scroll','keyboard'],tasks=['pointing','click_hold','corridor_drag','scroll_card','fixed_text'];
|
|
9
|
+
const prompt='Hello world — café, 中文, 🙂';
|
|
10
|
+
function post(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json','X-HM-Token':config.token},body:JSON.stringify(body)}).then(async r=>{let d=await r.json();if(!r.ok)throw Error(d.error);return d})}
|
|
11
|
+
function emit(kind,extra={},event){if(!active)return;if(event&&!event.isTrusted)untrusted++;events.push({kind,t:(performance.now()-start)/1000,...extra})}
|
|
12
|
+
function env(){return {platform:navigator.platform,dpr:devicePixelRatio,width:innerWidth,height:innerHeight,screenWidth:screen.width,screenHeight:screen.height}}
|
|
13
|
+
function save(functional={}){if(!events.length)return;const payload={events,task_id:tasks[mode],modality:names[mode],functional,environment:env(),diagnostics:{webdriver:navigator.webdriver,untrusted_events:untrusted,userAgent:navigator.userAgent}};events=[];start=performance.now();untrusted=0;chain=chain.then(()=>post('/trace',payload)).then(r=>{status.textContent=r.saved+' traces saved locally.'}).catch(e=>document.querySelector('#error').textContent=e.message)}
|
|
14
|
+
function layout(){const out={task:tasks[mode],prompt,viewport:env(),screenX,screenY};for(const name of ['.target','.drag','.drop','textarea','.goal']){const e=arena.querySelector(name);if(e){const r=e.getBoundingClientRect();out[name]={x:r.x,y:r.y,width:r.width,height:r.height}}}post('/layout',out).catch(()=>{})}
|
|
15
|
+
function target(){const widths=[24,48,80],locations=[[.2,.3],[.8,.7],[.4,.7],[.75,.2]];const size=widths[trial%3],loc=locations[trial%4];arena.innerHTML='<button class="target" aria-label="Benchmark target">+</button>';const b=arena.firstChild;b.style.cssText=`width:${size}px;height:${size}px;left:${loc[0]*(arena.clientWidth-size)}px;top:${loc[1]*(arena.clientHeight-size)}px`;b.onclick=()=>{save(mode===0?{target_hit:true}:{});trial++;target()};layout()}
|
|
16
|
+
function stage(){events=[];start=performance.now();trial=0;presses.clear();document.querySelector('#instructions').textContent=['Click varied targets several times, then choose Next task.','Click the target repeatedly at your normal pace.','Drag the green block through the pale corridor into the outlined box.','Scroll down to the outlined card and click it.','Type the displayed sample exactly, then click Finish & save.'][mode];if(mode<2)target();else if(mode===2){arena.innerHTML='<div class="corridor"></div><div class="drop">Release here</div><div class="drag" role="button" aria-label="Drag block"></div>';const d=arena.querySelector('.drag');corridor=true;d.onpointerdown=e=>{dragging=true;d.setPointerCapture(e.pointerId)};d.onpointermove=e=>{if(!dragging)return;const r=arena.getBoundingClientRect();const y=e.clientY-r.y;corridor&&=y>=165&&y<=295;d.style.left=(e.clientX-r.x-25)+'px';d.style.top=(y-25)+'px'};d.onpointerup=e=>{dragging=false;const r=arena.querySelector('.drop').getBoundingClientRect();save({drag_success:corridor&&e.clientX>=r.left&&e.clientX<r.right&&e.clientY>=r.top&&e.clientY<r.bottom});stage()};layout()}else if(mode===3){arena.innerHTML='<div class="pane"></div>';for(let i=0;i<12;i++){const c=document.createElement('div');c.className='card'+(i===7?' goal':'');c.textContent=i===7?'Click this card':'Card '+i;arena.firstChild.append(c);if(i===7)c.onclick=()=>{save({});stage()}}layout()}else{arena.innerHTML='<div class="summary"><p id="prompt"></p><textarea aria-label="Type the sample" spellcheck="false"></textarea></div>';arena.querySelector('#prompt').textContent=prompt;arena.querySelector('textarea').focus();layout()}}
|
|
17
|
+
document.querySelector('#start').onclick=()=>{active=true;document.querySelector('#start').disabled=true;document.querySelector('#next').disabled=false;document.querySelector('#finish').disabled=false;status.textContent='Recording browser-delivered input.';stage()};
|
|
18
|
+
document.querySelector('#next').onclick=()=>{if(mode===4){save({exact_text:arena.querySelector('textarea').value===prompt})}else events=[];mode=(mode+1)%5;stage()};
|
|
19
|
+
document.querySelector('#finish').onclick=async()=>{save(mode===4?{exact_text:arena.querySelector('textarea').value===prompt}:{});active=false;await chain;document.querySelector('#next').disabled=true;document.querySelector('#finish').disabled=true;arena.innerHTML='<div class="summary"><h2>Session saved.</h2><p>The evaluator will check data sufficiency, independent controls, and task correctness before showing a score.</p></div>'};
|
|
20
|
+
arena.addEventListener('pointermove',e=>emit('move',{x:e.clientX,y:e.clientY},e),true);
|
|
21
|
+
for(const [name,kind] of [['pointerdown','button_down'],['pointerup','button_up']])arena.addEventListener(name,e=>emit(kind,{x:e.clientX,y:e.clientY,button:['left','middle','right'][e.button]||'other'},e),true);
|
|
22
|
+
arena.addEventListener('wheel',e=>emit('scroll',{dx:e.deltaX,dy:e.deltaY},e),{capture:true,passive:true});
|
|
23
|
+
arena.addEventListener('keydown',e=>{if(!presses.has(e.code))presses.set(e.code,++id);emit('key_down',{press_id:presses.get(e.code),repeat:e.repeat},e)},true);
|
|
24
|
+
arena.addEventListener('keyup',e=>{emit('key_up',{press_id:presses.get(e.code)||0},e);presses.delete(e.code)},true);
|
|
25
|
+
arena.addEventListener('pointerup',e=>{if(active&&mode===0&&e.button===0&&!e.target.closest('.target')){save({target_hit:false});trial++;target()}});window.addEventListener('resize',layout);
|
|
26
|
+
</script></html>
|
|
27
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Frozen engineering controls; no SDK internal implementation is imported."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
BASELINE_VERSION = "engineering-1"
|
|
9
|
+
EXTERNAL_REVISIONS = {
|
|
10
|
+
"humancursor": "39d33f387b953950ffc1035183c9609f7f87c2c8",
|
|
11
|
+
"human_mouse": "c5cb6db923d4ff2a410abb466691a9659d924f9a",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def trajectory(
|
|
16
|
+
method: str,
|
|
17
|
+
start: tuple,
|
|
18
|
+
goal: tuple,
|
|
19
|
+
*,
|
|
20
|
+
duration: float = 0.6,
|
|
21
|
+
seed: int = 17842,
|
|
22
|
+
samples: int = 75,
|
|
23
|
+
) -> list[dict]:
|
|
24
|
+
if method not in {"linear", "bezier", "minimum_jerk"}:
|
|
25
|
+
raise ValueError("Choose a frozen built-in baseline")
|
|
26
|
+
if samples < 2 or not math.isfinite(duration) or duration <= 0:
|
|
27
|
+
raise ValueError("Positive duration and at least two samples required")
|
|
28
|
+
rng = random.Random(seed)
|
|
29
|
+
dx, dy = goal[0] - start[0], goal[1] - start[1]
|
|
30
|
+
bend = rng.uniform(-0.15, 0.15)
|
|
31
|
+
c1 = (start[0] + dx / 3 - dy * bend, start[1] + dy / 3 + dx * bend)
|
|
32
|
+
c2 = (start[0] + dx * 2 / 3 - dy * bend, start[1] + dy * 2 / 3 + dx * bend)
|
|
33
|
+
output = []
|
|
34
|
+
for i in range(samples):
|
|
35
|
+
s = i / (samples - 1)
|
|
36
|
+
u = 10 * s**3 - 15 * s**4 + 6 * s**5 if method == "minimum_jerk" else s
|
|
37
|
+
if method == "bezier":
|
|
38
|
+
x, y = [
|
|
39
|
+
(1 - u) ** 3 * start[j]
|
|
40
|
+
+ 3 * (1 - u) ** 2 * u * c1[j]
|
|
41
|
+
+ 3 * (1 - u) * u * u * c2[j]
|
|
42
|
+
+ u**3 * goal[j]
|
|
43
|
+
for j in (0, 1)
|
|
44
|
+
]
|
|
45
|
+
else:
|
|
46
|
+
x, y = start[0] + dx * u, start[1] + dy * u
|
|
47
|
+
output.append({"kind": "move", "t": duration * s, "x": x, "y": y})
|
|
48
|
+
return output
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .traces import assert_disjoint, digest, make_manifest, read_jsonl, write_jsonl
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None):
|
|
11
|
+
parser = argparse.ArgumentParser(prog="human-movement-bench")
|
|
12
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
13
|
+
for name in ("validate", "split", "fit", "evaluate"):
|
|
14
|
+
p = sub.add_parser(name)
|
|
15
|
+
p.add_argument("input", type=Path)
|
|
16
|
+
p.add_argument("--personal", action="store_true")
|
|
17
|
+
if name in {"split", "fit", "evaluate"}:
|
|
18
|
+
p.add_argument("--output", type=Path, required=True)
|
|
19
|
+
if name == "evaluate":
|
|
20
|
+
p.add_argument("--detectors", type=Path, required=True)
|
|
21
|
+
p.add_argument("--candidate-version", required=True)
|
|
22
|
+
p.add_argument("--profile-hash", default="none")
|
|
23
|
+
p.add_argument("--model-hash", default="none")
|
|
24
|
+
serve = sub.add_parser("serve")
|
|
25
|
+
serve.add_argument("--output", type=Path, required=True)
|
|
26
|
+
serve.add_argument("--participant", required=True)
|
|
27
|
+
serve.add_argument("--session")
|
|
28
|
+
serve.add_argument(
|
|
29
|
+
"--label", choices=("human", "candidate", "control", "human_control"), default="human"
|
|
30
|
+
)
|
|
31
|
+
serve.add_argument("--split", default="final_test")
|
|
32
|
+
serve.add_argument("--candidate-version", default="manual")
|
|
33
|
+
serve.add_argument("--no-browser", action="store_true")
|
|
34
|
+
report = sub.add_parser("report")
|
|
35
|
+
report.add_argument("reports", nargs="+", type=Path)
|
|
36
|
+
report.add_argument("--output", required=True, type=Path)
|
|
37
|
+
delivery = sub.add_parser("delivery")
|
|
38
|
+
delivery.add_argument("input", type=Path)
|
|
39
|
+
delivery.add_argument("--output", type=Path, required=True)
|
|
40
|
+
baseline = sub.add_parser("baseline")
|
|
41
|
+
baseline.add_argument(
|
|
42
|
+
"method", choices=("linear", "bezier", "minimum_jerk", "humancursor", "human_mouse")
|
|
43
|
+
)
|
|
44
|
+
baseline.add_argument("--start", type=float, nargs=2, default=(100, 100))
|
|
45
|
+
baseline.add_argument("--goal", type=float, nargs=2, default=(900, 500))
|
|
46
|
+
baseline.add_argument("--duration", type=float, default=0.6)
|
|
47
|
+
baseline.add_argument("--checkout", type=Path)
|
|
48
|
+
baseline.add_argument("--seed", type=int, default=17842)
|
|
49
|
+
baseline.add_argument("--output", type=Path, required=True)
|
|
50
|
+
imp = sub.add_parser("import")
|
|
51
|
+
imp.add_argument("dataset", choices=("cmu", "sapimouse", "balabit"))
|
|
52
|
+
imp.add_argument("input", type=Path)
|
|
53
|
+
imp.add_argument("--output", type=Path, required=True)
|
|
54
|
+
imp.add_argument("--split", required=True)
|
|
55
|
+
imp.add_argument("--terms-note", required=True)
|
|
56
|
+
imp.add_argument("--participant")
|
|
57
|
+
imp.add_argument("--session")
|
|
58
|
+
imp.add_argument("--timestamp-scale", type=float)
|
|
59
|
+
imp.add_argument("--timestamp-column")
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
try:
|
|
62
|
+
if args.command in {"validate", "split", "fit", "evaluate"}:
|
|
63
|
+
traces = read_jsonl(args.input)
|
|
64
|
+
assert_disjoint(traces, personal=args.personal)
|
|
65
|
+
if args.command == "validate":
|
|
66
|
+
print(
|
|
67
|
+
json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"traces": len(traces),
|
|
70
|
+
"sha256": digest(args.input),
|
|
71
|
+
"leakage_check": "passed",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
elif args.command == "split":
|
|
76
|
+
print(json.dumps(make_manifest(traces, args.output), indent=2))
|
|
77
|
+
elif args.command == "fit":
|
|
78
|
+
from .detectors import fit
|
|
79
|
+
|
|
80
|
+
print(json.dumps(fit(traces, args.output, personal=args.personal), indent=2))
|
|
81
|
+
else:
|
|
82
|
+
from .evaluation import evaluate
|
|
83
|
+
|
|
84
|
+
result = evaluate(
|
|
85
|
+
traces,
|
|
86
|
+
args.detectors,
|
|
87
|
+
personal=args.personal,
|
|
88
|
+
metadata={
|
|
89
|
+
"dataset_sha256": digest(args.input),
|
|
90
|
+
"candidate_version": args.candidate_version,
|
|
91
|
+
"profile_sha256": args.profile_hash,
|
|
92
|
+
"model_sha256": args.model_hash,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
args.output.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
96
|
+
print(
|
|
97
|
+
json.dumps(
|
|
98
|
+
{"status": result["status"], "overall_score": result["overall_score"]}
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
elif args.command == "serve":
|
|
102
|
+
from .website import Fixture
|
|
103
|
+
|
|
104
|
+
Fixture(
|
|
105
|
+
args.output,
|
|
106
|
+
participant=args.participant,
|
|
107
|
+
session=args.session,
|
|
108
|
+
label=args.label,
|
|
109
|
+
split=args.split,
|
|
110
|
+
candidate_version=args.candidate_version,
|
|
111
|
+
).serve(open_browser=not args.no_browser)
|
|
112
|
+
elif args.command == "report":
|
|
113
|
+
from .report import render
|
|
114
|
+
|
|
115
|
+
render([json.loads(p.read_text()) for p in args.reports], args.output)
|
|
116
|
+
elif args.command == "delivery":
|
|
117
|
+
from .delivery import analyze_delivery
|
|
118
|
+
|
|
119
|
+
args.output.write_text(
|
|
120
|
+
json.dumps(analyze_delivery(json.loads(args.input.read_text())), indent=2),
|
|
121
|
+
encoding="utf-8",
|
|
122
|
+
)
|
|
123
|
+
elif args.command == "baseline":
|
|
124
|
+
from .baselines import BASELINE_VERSION, trajectory
|
|
125
|
+
|
|
126
|
+
if args.method in {"humancursor", "human_mouse"}:
|
|
127
|
+
if not args.checkout:
|
|
128
|
+
parser.error("External baseline requires --checkout at its frozen revision")
|
|
129
|
+
request = {
|
|
130
|
+
"method": args.method,
|
|
131
|
+
"checkout": str(args.checkout),
|
|
132
|
+
"start": args.start,
|
|
133
|
+
"goal": args.goal,
|
|
134
|
+
"seed": args.seed,
|
|
135
|
+
}
|
|
136
|
+
captured = subprocess.run(
|
|
137
|
+
[sys.executable, "-m", "human_movement_bench.external"],
|
|
138
|
+
input=json.dumps(request),
|
|
139
|
+
text=True,
|
|
140
|
+
capture_output=True,
|
|
141
|
+
check=True,
|
|
142
|
+
timeout=60,
|
|
143
|
+
)
|
|
144
|
+
result = json.loads(captured.stdout)
|
|
145
|
+
else:
|
|
146
|
+
result = {
|
|
147
|
+
"method": args.method,
|
|
148
|
+
"version": BASELINE_VERSION,
|
|
149
|
+
"events": trajectory(
|
|
150
|
+
args.method, args.start, args.goal, duration=args.duration, seed=args.seed
|
|
151
|
+
),
|
|
152
|
+
}
|
|
153
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
args.output.write_text(json.dumps(result, indent=2), encoding="utf-8")
|
|
155
|
+
elif args.command == "import":
|
|
156
|
+
from .datasets import cmu_csv, mouse_csv
|
|
157
|
+
|
|
158
|
+
if args.dataset == "cmu":
|
|
159
|
+
result = cmu_csv(args.input, split=args.split, terms_note=args.terms_note)
|
|
160
|
+
else:
|
|
161
|
+
if not args.participant or not args.session or not args.timestamp_scale:
|
|
162
|
+
parser.error(
|
|
163
|
+
"Mouse import requires participant, session, and explicit timestamp-scale"
|
|
164
|
+
)
|
|
165
|
+
result = mouse_csv(
|
|
166
|
+
args.input,
|
|
167
|
+
dataset=args.dataset,
|
|
168
|
+
participant=args.participant,
|
|
169
|
+
session=args.session,
|
|
170
|
+
split=args.split,
|
|
171
|
+
timestamp_scale=args.timestamp_scale,
|
|
172
|
+
timestamp_column=args.timestamp_column,
|
|
173
|
+
terms_note=args.terms_note,
|
|
174
|
+
)
|
|
175
|
+
write_jsonl(args.output, result)
|
|
176
|
+
print(f"Imported {len(result)} traces")
|
|
177
|
+
except (ValueError, OSError) as exc:
|
|
178
|
+
parser.exit(1, f"{exc}\n")
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Local, explicit dataset adapters. Downloads and terms acceptance are not implicit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .traces import digest, validate
|
|
11
|
+
|
|
12
|
+
CATALOG = {
|
|
13
|
+
"webclick": {
|
|
14
|
+
"url": "https://huggingface.co/datasets/Hcompany/WebClick",
|
|
15
|
+
"cohort": "webclick",
|
|
16
|
+
"role": "layout fixtures only; no timed motion",
|
|
17
|
+
"terms": "Apache-2.0 dataset card; retain source attribution",
|
|
18
|
+
},
|
|
19
|
+
"sapimouse": {
|
|
20
|
+
"url": "https://github.com/margitantal68/sapimouse",
|
|
21
|
+
"cohort": "sapimouse",
|
|
22
|
+
"role": "mouse sessions",
|
|
23
|
+
"terms": "Repository Apache-2.0; verify separate raw-download terms before redistribution",
|
|
24
|
+
},
|
|
25
|
+
"balabit": {
|
|
26
|
+
"url": "https://github.com/balabit/Mouse-Dynamics-Challenge",
|
|
27
|
+
"cohort": "balabit",
|
|
28
|
+
"role": "remote desktop mouse sessions",
|
|
29
|
+
"terms": "Record source README terms; no blanket license inferred",
|
|
30
|
+
},
|
|
31
|
+
"derived_mouse_dynamics": {
|
|
32
|
+
"url": "https://data.mendeley.com/datasets/9rv9s3v2br/1",
|
|
33
|
+
"cohort": "balabit",
|
|
34
|
+
"role": "derived from Balabit; not an independent cohort",
|
|
35
|
+
"terms": "Verify version-specific data terms",
|
|
36
|
+
},
|
|
37
|
+
"cmu": {
|
|
38
|
+
"url": "https://www.cs.cmu.edu/~keystroke/",
|
|
39
|
+
"cohort": "cmu_fixed_text",
|
|
40
|
+
"role": "fixed-text timing only",
|
|
41
|
+
"terms": "Research dataset; cite Killourhy and Maxion DSN 2009; verify source terms",
|
|
42
|
+
},
|
|
43
|
+
"becaptcha": {
|
|
44
|
+
"url": "https://github.com/BiDAlab/BeCAPTCHA-Mouse",
|
|
45
|
+
"cohort": "becaptcha",
|
|
46
|
+
"role": "optional mouse trajectories",
|
|
47
|
+
"terms": "Institutional agreement required; no automatic access or redistribution",
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def provenance(path, dataset, terms_note):
|
|
53
|
+
if not terms_note.strip():
|
|
54
|
+
raise ValueError("Record the permitted use/terms you verified for this dataset")
|
|
55
|
+
return {**CATALOG[dataset], "file_sha256": digest(path), "terms_note": terms_note}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def webclick_box(box: list[float], width: int, height: int) -> dict:
|
|
59
|
+
"""WebClick bbox is normalized [left, top, right, bottom], never a path."""
|
|
60
|
+
if len(box) != 4 or not all(isinstance(x, (float, int)) and 0 <= x <= 1 for x in box):
|
|
61
|
+
raise ValueError("WebClick bounding box must be finite normalized coordinates")
|
|
62
|
+
left, top, right, bottom = box
|
|
63
|
+
if right <= left or bottom <= top or width <= 0 or height <= 0:
|
|
64
|
+
raise ValueError("Empty, reversed, or invalid WebClick box")
|
|
65
|
+
return {
|
|
66
|
+
"x": left * width,
|
|
67
|
+
"y": top * height,
|
|
68
|
+
"width": (right - left) * width,
|
|
69
|
+
"height": (bottom - top) * height,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mouse_csv(
|
|
74
|
+
path: str | Path,
|
|
75
|
+
*,
|
|
76
|
+
dataset: str,
|
|
77
|
+
participant: str,
|
|
78
|
+
session: str,
|
|
79
|
+
split: str,
|
|
80
|
+
timestamp_scale: float,
|
|
81
|
+
terms_note: str,
|
|
82
|
+
timestamp_column: str | None = None,
|
|
83
|
+
) -> list[dict]:
|
|
84
|
+
if dataset not in {"sapimouse", "balabit"} or timestamp_scale <= 0:
|
|
85
|
+
raise ValueError("Choose sapimouse/balabit and explicit seconds-per-timestamp-unit")
|
|
86
|
+
path = Path(path)
|
|
87
|
+
with path.open(encoding="utf-8-sig", newline="") as stream:
|
|
88
|
+
sample = stream.read(4096)
|
|
89
|
+
stream.seek(0)
|
|
90
|
+
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
|
|
91
|
+
rows = [
|
|
92
|
+
{k.strip().lower(): v.strip() for k, v in r.items()}
|
|
93
|
+
for r in csv.DictReader(stream, dialect=dialect)
|
|
94
|
+
]
|
|
95
|
+
events = []
|
|
96
|
+
column = timestamp_column or "client timestamp"
|
|
97
|
+
for row in rows:
|
|
98
|
+
state = row["state"].lower()
|
|
99
|
+
if state in {"move", "drag"}:
|
|
100
|
+
kind = "move"
|
|
101
|
+
elif state in {"pressed", "press", "down"}:
|
|
102
|
+
kind = "button_down"
|
|
103
|
+
elif state in {"released", "release", "up"}:
|
|
104
|
+
kind = "button_up"
|
|
105
|
+
else:
|
|
106
|
+
continue
|
|
107
|
+
event = {
|
|
108
|
+
"kind": kind,
|
|
109
|
+
"t": float(row[column]) * timestamp_scale,
|
|
110
|
+
"x": float(row["x"]),
|
|
111
|
+
"y": float(row["y"]),
|
|
112
|
+
}
|
|
113
|
+
if kind != "move":
|
|
114
|
+
event["button"] = row["button"].lower()
|
|
115
|
+
events.append(event)
|
|
116
|
+
if not events:
|
|
117
|
+
raise ValueError("No supported input events found")
|
|
118
|
+
origin = events[0]["t"]
|
|
119
|
+
for e in events:
|
|
120
|
+
e["t"] -= origin
|
|
121
|
+
source = digest(path)
|
|
122
|
+
chunks, chunk = [], []
|
|
123
|
+
for e in events:
|
|
124
|
+
chunk.append(e)
|
|
125
|
+
if e["kind"] == "button_up" and len(chunk) >= 5:
|
|
126
|
+
chunks.append(chunk)
|
|
127
|
+
chunk = []
|
|
128
|
+
if len(chunk) >= 5:
|
|
129
|
+
chunks.append(chunk)
|
|
130
|
+
output = []
|
|
131
|
+
for i, chunk in enumerate(chunks):
|
|
132
|
+
start = chunk[0]["t"]
|
|
133
|
+
chunk = [{**e, "t": e["t"] - start} for e in chunk]
|
|
134
|
+
output.append(
|
|
135
|
+
validate(
|
|
136
|
+
{
|
|
137
|
+
"participant_id": participant,
|
|
138
|
+
"session_id": session,
|
|
139
|
+
"recording_id": f"{source}:{i}",
|
|
140
|
+
"source_recording_id": source,
|
|
141
|
+
"cohort": CATALOG[dataset]["cohort"],
|
|
142
|
+
"condition": "remote_desktop" if dataset == "balabit" else "sapimouse_capture",
|
|
143
|
+
"task_id": "unlabelled_pointing",
|
|
144
|
+
"split": split,
|
|
145
|
+
"label": "human",
|
|
146
|
+
"modality": "mouse",
|
|
147
|
+
"events": chunk,
|
|
148
|
+
"provenance": provenance(path, dataset, terms_note),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return output
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmu_csv(path: str | Path, *, split: str, terms_note: str) -> list[dict]:
|
|
156
|
+
source = digest(path)
|
|
157
|
+
output = []
|
|
158
|
+
with Path(path).open(encoding="utf-8-sig", newline="") as stream:
|
|
159
|
+
for i, row in enumerate(csv.DictReader(stream)):
|
|
160
|
+
holds = [(k, float(v)) for k, v in row.items() if k.startswith("H.")]
|
|
161
|
+
down_to_down = [float(v) for k, v in row.items() if k.startswith("DD.")]
|
|
162
|
+
events, t = [], 0.0
|
|
163
|
+
for j, (_, hold) in enumerate(holds):
|
|
164
|
+
events.extend(
|
|
165
|
+
[
|
|
166
|
+
{"kind": "key_down", "press_id": j + 1, "t": t},
|
|
167
|
+
{"kind": "key_up", "press_id": j + 1, "t": t + hold},
|
|
168
|
+
]
|
|
169
|
+
)
|
|
170
|
+
if j < len(down_to_down):
|
|
171
|
+
t += down_to_down[j]
|
|
172
|
+
participant, session = row["subject"], row["sessionIndex"]
|
|
173
|
+
# Source grouping is per actual subject/session, not the consolidated CSV container.
|
|
174
|
+
recording = hashlib.sha256(f"{source}:{participant}:{session}".encode()).hexdigest()
|
|
175
|
+
output.append(
|
|
176
|
+
validate(
|
|
177
|
+
{
|
|
178
|
+
"participant_id": participant,
|
|
179
|
+
"session_id": f"{participant}:{session}",
|
|
180
|
+
"session_time": int(session),
|
|
181
|
+
"recording_id": f"{recording}:{i}",
|
|
182
|
+
"source_recording_id": recording,
|
|
183
|
+
"cohort": "cmu_fixed_text",
|
|
184
|
+
"condition": "cmu_fixed_password",
|
|
185
|
+
"task_id": "cmu_fixed_text",
|
|
186
|
+
"split": split,
|
|
187
|
+
"label": "human",
|
|
188
|
+
"modality": "keyboard",
|
|
189
|
+
"events": sorted(events, key=lambda e: e["t"]),
|
|
190
|
+
"provenance": provenance(path, "cmu", terms_note),
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return output
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def becaptcha_json(path, *, agreement_reference: str) -> list[dict]:
|
|
198
|
+
if not agreement_reference.strip():
|
|
199
|
+
raise ValueError("A verified institutional agreement reference is required")
|
|
200
|
+
# Deliberately accepts an institution's canonical export, not undocumented restricted formats.
|
|
201
|
+
rows = json.loads(Path(path).read_text())
|
|
202
|
+
for row in rows:
|
|
203
|
+
validate(row)
|
|
204
|
+
row["provenance"] = {
|
|
205
|
+
**CATALOG["becaptcha"],
|
|
206
|
+
"agreement_reference": agreement_reference,
|
|
207
|
+
"file_sha256": digest(path),
|
|
208
|
+
}
|
|
209
|
+
return rows
|