feat(lib): add ScrollModel (numpy + ONNX Runtime); rename legacy scroll module
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# sites/ai_mouse/ai_mouse/__init__.py
|
# sites/ai_mouse/ai_mouse/__init__.py
|
||||||
from ai_mouse.generator import generate
|
from ai_mouse.generator import generate
|
||||||
from ai_mouse.scroll.generator import generate_scroll
|
from ai_mouse._scroll_legacy import generate_scroll
|
||||||
|
|
||||||
__all__ = ["generate", "generate_scroll"]
|
__all__ = ["generate", "generate_scroll"]
|
||||||
|
|||||||
139
src/ai_mouse/scroll.py
Normal file
139
src/ai_mouse/scroll.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""ScrollModel — ONNX Runtime-backed scroll event generation."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import onnxruntime as ort
|
||||||
|
|
||||||
|
from ai_mouse._assets import resolve
|
||||||
|
from ai_mouse.errors import ModelLoadError
|
||||||
|
|
||||||
|
_DURATION_TABLE = {
|
||||||
|
"fast": lambda d: d * 0.2 + 100.0,
|
||||||
|
"precise": lambda d: d * 1.5 + 300.0,
|
||||||
|
"target": lambda d: d * 0.4 + 200.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_QUANTUM = {"precise": 40, "fast": 120, "target": 120}
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollModel:
|
||||||
|
"""Persistent ONNX Runtime session for scroll event generation."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model_path: str | Path | None = None,
|
||||||
|
providers: Sequence[str] | None = None,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
path_obj: Optional[Path] = Path(model_path) if model_path is not None else None
|
||||||
|
|
||||||
|
onnx_path = resolve(path_obj, "scroll_decoder.onnx")
|
||||||
|
cfg_path = resolve(path_obj, "scroll_config.json")
|
||||||
|
cfg = json.loads(cfg_path.read_text())
|
||||||
|
|
||||||
|
self._seq_len = int(cfg["seq_len"])
|
||||||
|
self._latent_dim = int(cfg["latent_dim"])
|
||||||
|
self._cond_dim = int(cfg["cond_dim"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._session = ort.InferenceSession(
|
||||||
|
str(onnx_path),
|
||||||
|
providers=list(providers) if providers else ["CPUExecutionProvider"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ModelLoadError(f"Failed to load scroll ONNX session: {exc}") from exc
|
||||||
|
|
||||||
|
self._rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
start_scroll_y: int,
|
||||||
|
target_scroll_y: int,
|
||||||
|
mode: Literal["target", "fast", "precise"] = "target",
|
||||||
|
viewport_height: int = 800,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
rng = np.random.default_rng(seed) if seed is not None else self._rng
|
||||||
|
|
||||||
|
distance = abs(target_scroll_y - start_scroll_y)
|
||||||
|
direction = 1 if target_scroll_y > start_scroll_y else -1
|
||||||
|
distance = max(distance, 10)
|
||||||
|
|
||||||
|
cond = self._build_condition(float(distance), direction, mode, viewport_height)
|
||||||
|
z = rng.standard_normal((1, self._latent_dim)).astype(np.float32)
|
||||||
|
decoded = self._session.run(["seq"], {"z": z, "cond": cond[None]})[0][0]
|
||||||
|
|
||||||
|
delta_norm = decoded[:, 0]
|
||||||
|
log_dt = decoded[:, 1]
|
||||||
|
|
||||||
|
delta_weights = np.exp(delta_norm)
|
||||||
|
delta_weights /= delta_weights.sum()
|
||||||
|
delta_px = delta_weights * distance * direction
|
||||||
|
|
||||||
|
quantum = _QUANTUM[mode]
|
||||||
|
delta_q = np.round(delta_px / quantum) * quantum
|
||||||
|
for i in range(len(delta_q)):
|
||||||
|
if delta_q[i] == 0:
|
||||||
|
delta_q[i] = quantum * direction
|
||||||
|
delta_q[-1] += (distance * direction) - delta_q.sum()
|
||||||
|
|
||||||
|
if len(log_dt) > 3:
|
||||||
|
median_log = float(np.median(log_dt))
|
||||||
|
log_dt[:2] = np.clip(log_dt[:2], None, median_log + 0.5)
|
||||||
|
log_dt[-2:] = np.clip(log_dt[-2:], None, median_log + 0.5)
|
||||||
|
dt_ms = np.clip(np.exp(log_dt), 5, 80)
|
||||||
|
expected = _DURATION_TABLE[mode](distance)
|
||||||
|
dt_ms = np.clip(dt_ms * (expected / max(dt_ms.sum(), 1.0)), 5, 80)
|
||||||
|
|
||||||
|
t_abs = np.cumsum(dt_ms).astype(int)
|
||||||
|
t_abs = np.concatenate([[0], t_abs[:-1]])
|
||||||
|
for i in range(1, len(t_abs)):
|
||||||
|
if t_abs[i] <= t_abs[i - 1]:
|
||||||
|
t_abs[i] = t_abs[i - 1] + 5
|
||||||
|
|
||||||
|
events: list[dict] = []
|
||||||
|
for i in range(self._seq_len):
|
||||||
|
dy = int(delta_q[i])
|
||||||
|
if dy != 0 or len(events) < 5:
|
||||||
|
events.append({"deltaY": dy, "deltaMode": 0, "t": int(t_abs[i])})
|
||||||
|
return events
|
||||||
|
|
||||||
|
def _build_condition(
|
||||||
|
self,
|
||||||
|
distance: float,
|
||||||
|
direction: int,
|
||||||
|
mode: str,
|
||||||
|
viewport_height: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
mode_onehot = [0.0, 0.0, 0.0]
|
||||||
|
if mode == "target":
|
||||||
|
mode_onehot[0] = 1.0
|
||||||
|
elif mode == "fast":
|
||||||
|
mode_onehot[1] = 1.0
|
||||||
|
elif mode == "precise":
|
||||||
|
mode_onehot[2] = 1.0
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
distance / 5000.0,
|
||||||
|
math.log(max(distance, 1.0) / 500.0),
|
||||||
|
float(direction),
|
||||||
|
viewport_height / 1000.0,
|
||||||
|
*mode_onehot,
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._session = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def __enter__(self) -> "ScrollModel":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self.close()
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""Scroll wheel event generation (inference only)."""
|
|
||||||
from ai_mouse.scroll.generator import generate_scroll
|
|
||||||
|
|
||||||
__all__ = ["generate_scroll"]
|
|
||||||
37
tests/unit/test_scroll.py
Normal file
37
tests/unit/test_scroll.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Tests for ScrollModel and ai_mouse.generate_scroll()."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_model_init_default() -> None:
|
||||||
|
from ai_mouse.scroll import ScrollModel
|
||||||
|
m = ScrollModel()
|
||||||
|
assert m._seq_len > 0
|
||||||
|
m.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_model_generate_target_mode() -> None:
|
||||||
|
from ai_mouse.scroll import ScrollModel
|
||||||
|
m = ScrollModel()
|
||||||
|
events = m.generate(0, 1500, mode="target")
|
||||||
|
assert len(events) >= 5
|
||||||
|
total = sum(e["deltaY"] for e in events)
|
||||||
|
assert 1000 <= total <= 2000 # broad — quantisation can drift
|
||||||
|
assert events[0]["t"] == 0
|
||||||
|
assert all(e["deltaMode"] == 0 for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_model_direction() -> None:
|
||||||
|
from ai_mouse.scroll import ScrollModel
|
||||||
|
m = ScrollModel()
|
||||||
|
events = m.generate(2000, 0, mode="target")
|
||||||
|
total = sum(e["deltaY"] for e in events)
|
||||||
|
assert total < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_scroll_invalid_path() -> None:
|
||||||
|
from ai_mouse.errors import ModelLoadError
|
||||||
|
from ai_mouse.scroll import ScrollModel
|
||||||
|
with pytest.raises(ModelLoadError):
|
||||||
|
ScrollModel(model_path="/no/such/path")
|
||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
import torch
|
import torch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ai_mouse.scroll.generator import generate_scroll
|
from ai_mouse._scroll_legacy import generate_scroll
|
||||||
from tools.scroll.models import ScrollCVAE
|
from tools.scroll.models import ScrollCVAE
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user