feat(adapter): implement segment quality filters
This commit is contained in:
@@ -124,9 +124,72 @@ def filter_segments(
|
|||||||
min_dist: int,
|
min_dist: int,
|
||||||
max_span_ms: int,
|
max_span_ms: int,
|
||||||
max_gap_ms: int,
|
max_gap_ms: int,
|
||||||
|
coord_max: int = 5000,
|
||||||
) -> list[Segment]:
|
) -> list[Segment]:
|
||||||
"""Stub — implemented in Task 5."""
|
"""Drop segments that fail any quality check.
|
||||||
raise NotImplementedError
|
|
||||||
|
A segment is dropped if any of these are true:
|
||||||
|
- len(events) < min_events
|
||||||
|
- Euclidean dist(events[0], (click_x, click_y)) < min_dist
|
||||||
|
- events[-1].t_ms - events[0].t_ms > max_span_ms
|
||||||
|
- any adjacent Move pair has dt > max_gap_ms (sampling drop-out)
|
||||||
|
- any coord (start/end/click) outside [0, coord_max]
|
||||||
|
- total arc length < min_dist (high-frequency jitter only)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segments: Candidate segments.
|
||||||
|
min_events: Minimum number of Move events.
|
||||||
|
min_dist: Minimum start→click pixel distance AND minimum total arc length.
|
||||||
|
max_span_ms: Maximum time span of the segment (events[-1] - events[0]).
|
||||||
|
max_gap_ms: Maximum allowed gap between adjacent Move events.
|
||||||
|
coord_max: Maximum allowed pixel coordinate value (5000 catches multi-monitor anomalies).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list, original order preserved.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
keep: list[Segment] = []
|
||||||
|
for seg in segments:
|
||||||
|
if len(seg.events) < min_events:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sx, sy = seg.events[0].x, seg.events[0].y
|
||||||
|
ex, ey = seg.click_x, seg.click_y
|
||||||
|
|
||||||
|
# Coord range check
|
||||||
|
if any(c < 0 or c > coord_max for c in (sx, sy, ex, ey)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Endpoint distance
|
||||||
|
dist = math.hypot(ex - sx, ey - sy)
|
||||||
|
if dist < min_dist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Time span
|
||||||
|
span = seg.events[-1].t_ms - seg.events[0].t_ms
|
||||||
|
if span > max_span_ms:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Gap check + total arc length
|
||||||
|
total_arc = 0.0
|
||||||
|
bad_gap = False
|
||||||
|
for i in range(1, len(seg.events)):
|
||||||
|
dt = seg.events[i].t_ms - seg.events[i - 1].t_ms
|
||||||
|
if dt > max_gap_ms:
|
||||||
|
bad_gap = True
|
||||||
|
break
|
||||||
|
dx = seg.events[i].x - seg.events[i - 1].x
|
||||||
|
dy = seg.events[i].y - seg.events[i - 1].y
|
||||||
|
total_arc += math.hypot(dx, dy)
|
||||||
|
if bad_gap:
|
||||||
|
continue
|
||||||
|
if total_arc < min_dist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
keep.append(seg)
|
||||||
|
|
||||||
|
return keep
|
||||||
|
|
||||||
|
|
||||||
def process_session(
|
def process_session(
|
||||||
|
|||||||
@@ -185,3 +185,79 @@ class TestSegmentByClicks:
|
|||||||
# Drag event should not appear in seg.events
|
# Drag event should not appear in seg.events
|
||||||
assert all(e.state == "Move" for e in segments[0].events)
|
assert all(e.state == "Move" for e in segments[0].events)
|
||||||
assert len(segments[0].events) == 2
|
assert len(segments[0].events) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilterSegments:
|
||||||
|
def _seg(self, events_data: list[tuple[int, int, int]], click=(500, 500), session="s"):
|
||||||
|
"""events_data: list of (t_ms, x, y) tuples."""
|
||||||
|
from ai_mouse.data_adapters.balabit import MouseEvent, Segment
|
||||||
|
events = [MouseEvent(t_ms=t, button="NoButton", state="Move", x=x, y=y)
|
||||||
|
for (t, x, y) in events_data]
|
||||||
|
click_t = events[-1].t_ms + 50 if events else 100
|
||||||
|
return Segment(events=events, click_x=click[0], click_y=click[1],
|
||||||
|
click_t_ms=click_t, session_id=session)
|
||||||
|
|
||||||
|
def test_drops_segment_with_too_few_events(self):
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
seg = self._seg([(0, 100, 100), (50, 105, 105)]) # 2 events, min=5
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=10,
|
||||||
|
max_span_ms=5000, max_gap_ms=200)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_drops_segment_with_short_distance(self):
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
# Start (100,100), end click=(105,100) → dist=5, min_dist=50
|
||||||
|
events = [(i*10, 100+i, 100) for i in range(10)]
|
||||||
|
seg = self._seg(events, click=(105, 100))
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||||
|
max_span_ms=5000, max_gap_ms=200)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_drops_segment_with_too_long_span(self):
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
# Span 6000ms, max_span=5000
|
||||||
|
events = [(0, 100, 100), (2000, 200, 200), (4000, 300, 300), (6000, 400, 400),
|
||||||
|
(6010, 410, 400), (6020, 420, 400)]
|
||||||
|
seg = self._seg(events, click=(500, 500))
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||||
|
max_span_ms=5000, max_gap_ms=10000)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_drops_segment_with_gap(self):
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
# Gap of 500ms between events 2 and 3, max_gap=200
|
||||||
|
events = [(0, 100, 100), (50, 110, 110), (100, 120, 120),
|
||||||
|
(600, 200, 200), (650, 210, 210), (700, 220, 220)]
|
||||||
|
seg = self._seg(events, click=(300, 300))
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||||
|
max_span_ms=5000, max_gap_ms=200)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_drops_segment_with_out_of_range_coords(self):
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
# x=10000 out of range
|
||||||
|
events = [(i*10, 10000, 100) for i in range(6)]
|
||||||
|
seg = self._seg(events, click=(10100, 100))
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||||
|
max_span_ms=5000, max_gap_ms=200)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_drops_segment_with_short_arc_length(self):
|
||||||
|
"""Total arc < 50 even though endpoints are far → high-frequency jitter only."""
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
# Tiny back-and-forth with click 100px away — unrealistic, drop it
|
||||||
|
events = [(i*10, 100 + (i % 2), 100) for i in range(10)]
|
||||||
|
seg = self._seg(events, click=(200, 100))
|
||||||
|
# arc length = sum of |Δp| ≈ 9 (alternating ±1) which is < 50
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||||
|
max_span_ms=5000, max_gap_ms=200)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_keeps_valid_segment(self):
|
||||||
|
from ai_mouse.data_adapters.balabit import filter_segments
|
||||||
|
# Smooth 100→500 px straight line, 10 events, span 500ms
|
||||||
|
events = [(i*50, 100 + i*40, 100) for i in range(10)]
|
||||||
|
seg = self._seg(events, click=(500, 100))
|
||||||
|
result = filter_segments([seg], min_events=5, min_dist=50,
|
||||||
|
max_span_ms=5000, max_gap_ms=200)
|
||||||
|
assert len(result) == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user