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

93
tests/test_transform.py Normal file
View File

@@ -0,0 +1,93 @@
"""Pure-algorithm tests for human_mouse.transform — no IO, no playwright."""
from __future__ import annotations
import math
import pytest
from human_mouse import Segment, affine_warp, upsample
def _seg(points: list[tuple[float, float, float]]) -> Segment:
return Segment(user="t", session="s", points=points)
# ---------------------------------------------------------------- affine_warp
def test_affine_warp_pins_endpoints():
seg = _seg([(0, 0, 0), (50, 50, 10), (100, 100, 20)])
out = affine_warp(seg, start=(500, 300), end=(700, 400))
assert math.isclose(out[0][0], 500) and math.isclose(out[0][1], 300)
assert math.isclose(out[-1][0], 700) and math.isclose(out[-1][1], 400)
def test_affine_warp_uniform_along_axis():
"""A straight segment along +x maps to a straight segment between targets."""
seg = _seg([(0, 0, 0), (50, 0, 10), (100, 0, 20)])
out = affine_warp(seg, start=(0, 0), end=(200, 0))
assert math.isclose(out[1][0], 100, abs_tol=1e-6)
assert math.isclose(out[1][1], 0, abs_tol=1e-6)
def test_affine_warp_preserves_time():
seg = _seg([(0, 0, 100), (10, 5, 130), (20, 0, 160)])
out = affine_warp(seg, start=(0, 0), end=(40, 0))
assert out[0][2] == 0 # rebased
assert out[1][2] == 30
assert out[2][2] == 60
def test_affine_warp_rotates_perpendicular_offset():
"""A 90-degree rotation: source goes east, target goes north."""
seg = _seg([(0, 0, 0), (50, 10, 10), (100, 0, 20)])
out = affine_warp(seg, start=(0, 0), end=(0, 100))
# The middle point sat 10 px to the left of the source axis (east-pointing
# axis -> left is +y); after rotating the axis to north, "left of axis"
# means -x. So midpoint x should be approximately -10.
assert math.isclose(out[1][0], -10, abs_tol=1e-6)
assert math.isclose(out[1][1], 50, abs_tol=1e-6)
# ------------------------------------------------------------------- upsample
def test_upsample_factor_1_is_identity():
pts = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (2.0, 2.0, 2.0)]
assert upsample(pts, 1) == pts
def test_upsample_factor_4_count_formula():
# For N input points and factor k, expect k*(N-1) + 1 output points.
pts = [(0.0, 0.0, 0.0), (4.0, 0.0, 4.0), (8.0, 0.0, 8.0)]
out = upsample(pts, 4)
assert len(out) == 4 * (3 - 1) + 1
def test_upsample_endpoints_unchanged():
pts = [(0.0, 0.0, 0.0), (10.0, 10.0, 5.0)]
out = upsample(pts, 5)
assert out[0] == pts[0]
assert out[-1] == pts[-1]
def test_upsample_midpoint_correct_for_factor_2():
pts = [(0.0, 0.0, 0.0), (10.0, 20.0, 100.0)]
out = upsample(pts, 2)
# Should be: P0, P0+0.5*delta, P1
assert out[1] == (5.0, 10.0, 50.0)
def test_upsample_time_monotonic_with_monotonic_input():
pts = [(0.0, 0.0, 0.0), (1.0, 0.0, 5.0), (2.0, 0.0, 12.0), (3.0, 0.0, 20.0)]
out = upsample(pts, 4)
times = [p[2] for p in out]
assert all(t1 >= t0 for t0, t1 in zip(times, times[1:]))
@pytest.mark.parametrize("factor", [0, -1])
def test_upsample_non_positive_factor_returns_copy(factor: int):
pts = [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]
out = upsample(pts, factor)
assert out == pts
assert out is not pts # returns a copy, not the same list