feat: initial release of human_mouse v0.1.0

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>
This commit is contained in:
2026-05-12 00:30:18 +08:00
commit 65ef838bd7
17 changed files with 1680 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
"""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()