A small Python library for replaying real human mouse trajectories from the SapiMouse dataset onto a Playwright page. Designed for ML-based bot-detection research, behavioral biometrics prototyping, and replay-based test fixtures. Public API: load_all_segments, pick_segments, affine_warp, upsample, replay, replay_random, download_sapimouse. - src/ layout with hatchling build backend - 23 pytest tests (10 transform unit + 13 integration) - MIT license, PEP 561 py.typed marker - python -m human_mouse download for one-shot dataset fetch - examples/cloakbrowser_demo.py demonstrates end-to-end use with CloakBrowser Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
173 lines
5.8 KiB
Python
173 lines
5.8 KiB
Python
"""End-to-end example of human_mouse driving CloakBrowser.
|
|
|
|
Subcommands:
|
|
sapi one real human trajectory, replayed
|
|
multi N trajectories overlaid on a single canvas in different hues
|
|
|
|
Usage::
|
|
|
|
uv run python examples/cloakbrowser_demo.py sapi
|
|
uv run python examples/cloakbrowser_demo.py multi --n 10
|
|
|
|
The dataset path defaults to ./sapimouse_data/sapimouse. Override with
|
|
--data-root or call human_mouse.download_sapimouse() first.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import statistics
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from cloakbrowser import launch
|
|
|
|
import human_mouse as hm
|
|
|
|
HERE = Path(__file__).parent
|
|
DEMO_HTML = HERE / "demo.html"
|
|
DEFAULT_DATA_ROOT = HERE.parent / "sapimouse_data" / "sapimouse"
|
|
DEFAULT_OUT = HERE.parent / "outputs"
|
|
|
|
VIEWPORT = {"width": 1280, "height": 720}
|
|
START = (150, 150)
|
|
TARGET = (1060, 550)
|
|
TARGET_DIST = math.hypot(TARGET[0] - START[0], TARGET[1] - START[1])
|
|
|
|
|
|
def open_page(browser, mode: str):
|
|
context = browser.new_context(viewport=VIEWPORT)
|
|
page = context.new_page()
|
|
page.goto(DEMO_HTML.as_uri() + f"?mode={mode}")
|
|
page.wait_for_selector("#target")
|
|
return page
|
|
|
|
|
|
def save_artifacts(page, name: str, out: Path) -> tuple[Path, Path]:
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
png, js = out / f"{name}.png", out / f"{name}.json"
|
|
js.write_text(json.dumps(page.evaluate("window.__points"), indent=2))
|
|
page.screenshot(path=str(png), full_page=False)
|
|
return png, js
|
|
|
|
|
|
def cmd_sapi(args: argparse.Namespace) -> None:
|
|
print("loading SapiMouse segments...")
|
|
segs = hm.load_all_segments(args.data_root)
|
|
print(f" -> {len(segs)} segments available")
|
|
|
|
seg = hm.pick_segments(segs, n=1,
|
|
target_distance=TARGET_DIST,
|
|
max_path_ratio=args.max_ratio,
|
|
seed=args.seed)[0]
|
|
print(f" -> {seg.user}/{seg.session} "
|
|
f"src={len(seg.points)} dist={seg.straight_distance:.0f}px "
|
|
f"dur={seg.duration_ms:.0f}ms ratio={seg.path_ratio:.2f}")
|
|
|
|
dense = hm.upsample(hm.affine_warp(seg, START, TARGET), args.density)
|
|
|
|
print("launching CloakBrowser (humanize=False)...")
|
|
browser = launch(humanize=False, headless=False)
|
|
page = open_page(browser, "sapi")
|
|
|
|
time.sleep(0.3)
|
|
t0 = time.perf_counter()
|
|
hm.replay(page, dense)
|
|
elapsed = (time.perf_counter() - t0) * 1000
|
|
page.locator("#target").click()
|
|
|
|
png, js = save_artifacts(page, "sapi", args.out)
|
|
captured = json.loads(js.read_text())
|
|
browser.close()
|
|
|
|
print(f"\n sent={len(dense)} captured={len(captured)} "
|
|
f"replay={elapsed:.0f}ms")
|
|
print(f" -> {png}")
|
|
|
|
|
|
def cmd_multi(args: argparse.Namespace) -> None:
|
|
multi_dir = args.out / "multi"
|
|
|
|
print("loading SapiMouse segments...")
|
|
segs = hm.load_all_segments(args.data_root)
|
|
print(f" -> {len(segs)} segments total")
|
|
|
|
picks = hm.pick_segments(segs, n=args.n,
|
|
target_distance=TARGET_DIST,
|
|
max_path_ratio=args.max_ratio,
|
|
seed=args.seed)
|
|
|
|
print("launching CloakBrowser (humanize=False)...")
|
|
browser = launch(humanize=False, headless=False)
|
|
page = open_page(browser, "multi")
|
|
|
|
runs = []
|
|
for i, seg in enumerate(picks, 1):
|
|
hue = round(360 * (i - 1) / len(picks))
|
|
page.evaluate(f"window.__setHue({hue}); window.__resetTrace();")
|
|
dense = hm.upsample(hm.affine_warp(seg, START, TARGET), args.density)
|
|
|
|
t0 = time.perf_counter()
|
|
hm.replay(page, dense)
|
|
elapsed = (time.perf_counter() - t0) * 1000
|
|
|
|
points = page.evaluate("window.__points")
|
|
multi_dir.mkdir(parents=True, exist_ok=True)
|
|
(multi_dir / f"run_{i:02d}.json").write_text(json.dumps(points, indent=2))
|
|
|
|
dts = [b["t"] - a["t"] for a, b in zip(points, points[1:])]
|
|
runs.append({
|
|
"i": i, "hue": hue, "user": seg.user, "session": seg.session,
|
|
"src_points": len(seg.points), "sent_points": len(dense),
|
|
"captured_points": len(points),
|
|
"captured_median_dt_ms": round(statistics.median(dts), 2) if dts else 0.0,
|
|
"replay_ms": round(elapsed, 1),
|
|
})
|
|
print(f" run {i:>2}/{len(picks)} hue={hue:>3} "
|
|
f"{seg.user}/{seg.session} captured={len(points)} pts")
|
|
|
|
overlay = multi_dir / "overlay.png"
|
|
page.screenshot(path=str(overlay), full_page=False)
|
|
(multi_dir / "summary.json").write_text(json.dumps({
|
|
"n": len(picks), "runs": runs,
|
|
}, indent=2))
|
|
browser.close()
|
|
print(f"\n overlay -> {overlay}")
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="human_mouse demo driven by CloakBrowser",
|
|
)
|
|
parser.add_argument("--data-root", type=Path, default=DEFAULT_DATA_ROOT,
|
|
help="path to unzipped SapiMouse folder")
|
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUT,
|
|
help="output directory for screenshots and JSON")
|
|
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
p_sapi = sub.add_parser("sapi", help="one real SapiMouse trajectory replayed")
|
|
p_sapi.add_argument("--density", type=int, default=4)
|
|
p_sapi.add_argument("--max-ratio", type=float, default=2.0)
|
|
p_sapi.add_argument("--seed", type=int, default=42)
|
|
p_sapi.set_defaults(func=cmd_sapi)
|
|
|
|
p_multi = sub.add_parser("multi", help="N trajectories overlaid in different hues")
|
|
p_multi.add_argument("--n", type=int, default=10)
|
|
p_multi.add_argument("--density", type=int, default=4)
|
|
p_multi.add_argument("--max-ratio", type=float, default=2.0)
|
|
p_multi.add_argument("--seed", type=int, default=None)
|
|
p_multi.set_defaults(func=cmd_multi)
|
|
|
|
return parser
|
|
|
|
|
|
def main() -> None:
|
|
args = build_parser().parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|