monitor selection, model prototype and 2 more recordings

This commit is contained in:
2026-08-12 16:48:44 +02:00
parent 8d124a26b1
commit 6a13b1ef04
17 changed files with 214117 additions and 25 deletions
+46 -23
View File
@@ -16,18 +16,16 @@ import time
import tkinter as tk
from collections import deque
from monitor_utils import format_monitor_list
from show_apriltag_surface import SurfaceTagWindow, enable_windows_dpi_awareness, validate_args
from visualize_surface_gaze import GazeSample, SurfaceReceiver
SCREEN_WIDTH = 2560
SCREEN_HEIGHT = 1440
SCREEN_DIAGONAL_IN = 31.5
DEFAULT_VIEWING_DISTANCE_CM = 60.0
TARGET_RADIUS = 80
HIT_RADIUS = 140
SAFE_LEFT, SAFE_RIGHT = 260, SCREEN_WIDTH - 260
SAFE_TOP, SAFE_BOTTOM = 180, SCREEN_HEIGHT - 180
SAFE_MARGIN_X = 260
SAFE_MARGIN_Y = 180
DISPERSION_WINDOW = 5
MAX_CONTINUOUS_GAP_S = 0.5
@@ -38,8 +36,9 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--port", type=int, default=50020)
parser.add_argument("--surface", default="screen")
parser.add_argument("--confidence", type=float, default=0.9)
parser.add_argument("--screen-diagonal", type=float, default=SCREEN_DIAGONAL_IN,
help="physical screen diagonal in inches (default: 31.5)")
parser.add_argument("--screen-diagonal", type=float, default=None,
help="physical screen diagonal in inches "
"(default: auto-detected from the selected monitor)")
parser.add_argument("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM,
help="eye-to-screen distance in cm (default: 60)")
parser.add_argument("--subject-id", default="anon",
@@ -57,6 +56,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--margin", type=int, default=0)
parser.add_argument("--background", default="#10141c")
parser.add_argument("--windowed", action="store_true")
parser.add_argument("--monitor", type=int, default=None,
help="Windows display number to use, e.g. 2 for \\\\.\\DISPLAY2 "
"(default: primary)")
parser.add_argument("--list-monitors", action="store_true",
help="print detected monitors (id, resolution, position, diagonal) and exit")
return parser.parse_args()
@@ -66,6 +70,21 @@ class ExerciseWindow(SurfaceTagWindow):
def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
super().__init__(root, args)
self.args = args
self.width = self.monitor.width
self.height = self.monitor.height
self.safe_left, self.safe_right = SAFE_MARGIN_X, self.width - SAFE_MARGIN_X
self.safe_top, self.safe_bottom = SAFE_MARGIN_Y, self.height - SAFE_MARGIN_Y
self.pursuit_amp_x = min(760, self.width / 2 - SAFE_MARGIN_X)
self.pursuit_amp_y = min(390, self.height / 2 - SAFE_MARGIN_Y)
if args.screen_diagonal is not None:
self.screen_diagonal = args.screen_diagonal
elif self.monitor.diagonal_in is not None:
self.screen_diagonal = self.monitor.diagonal_in
else:
raise SystemExit(
"error: could not auto-detect the diagonal for this monitor; "
"pass --screen-diagonal explicitly"
)
self.frame_interval_ms = max(1, round(1000 / args.animation_fps))
self.messages: queue.Queue = queue.Queue(maxsize=256)
self.stop_event = threading.Event()
@@ -83,7 +102,7 @@ class ExerciseWindow(SurfaceTagWindow):
self.trial = 0
self.trial_started = 0.0
self.block_started = 0.0
self.target = (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)
self.target = (self.width / 2, self.height / 2)
self.distractor: tuple[float, float] | None = None
self.correct_color = "#39e681"
self.hit_started: float | None = None
@@ -92,8 +111,8 @@ class ExerciseWindow(SurfaceTagWindow):
self.hits = 0
self.samples = 0
self.summary_lines: list[str] = []
diagonal_px = math.hypot(SCREEN_WIDTH, SCREEN_HEIGHT)
self.cm_per_px = args.screen_diagonal * 2.54 / diagonal_px
diagonal_px = math.hypot(self.width, self.height)
self.cm_per_px = self.screen_diagonal * 2.54 / diagonal_px
self.history: deque[tuple[float, float, float]] = deque(maxlen=DISPERSION_WINDOW)
self.prev_sample: tuple[float, float, float] | None = None
@@ -119,13 +138,14 @@ class ExerciseWindow(SurfaceTagWindow):
"session_id": self.session_id,
"session_tag": args.session_tag,
"recorded_at": datetime.now().isoformat(timespec="seconds"),
"screen_diagonal_in": args.screen_diagonal,
"monitor_id": self.monitor.display_id,
"screen_diagonal_in": self.screen_diagonal,
"viewing_distance_cm": args.viewing_distance,
"confidence_threshold": args.confidence,
"rounds": args.rounds,
"animation_fps": args.animation_fps,
"screen_width_px": SCREEN_WIDTH,
"screen_height_px": SCREEN_HEIGHT,
"screen_width_px": self.width,
"screen_height_px": self.height,
"result_csv": self.result_path.name,
}, indent=2), encoding="utf-8")
@@ -152,10 +172,10 @@ class ExerciseWindow(SurfaceTagWindow):
root.after(self.frame_interval_ms, self.tick)
def sx(self, x: float) -> float:
return x / SCREEN_WIDTH * self.canvas.winfo_width()
return x / self.width * self.canvas.winfo_width()
def sy(self, y: float) -> float:
return y / SCREEN_HEIGHT * self.canvas.winfo_height()
return y / self.height * self.canvas.winfo_height()
def draw(self, event: tk.Event) -> None:
super().draw(event)
@@ -208,7 +228,7 @@ class ExerciseWindow(SurfaceTagWindow):
self.new_trial()
def random_target(self) -> tuple[float, float]:
return self.rng.uniform(SAFE_LEFT, SAFE_RIGHT), self.rng.uniform(SAFE_TOP, SAFE_BOTTOM)
return self.rng.uniform(self.safe_left, self.safe_right), self.rng.uniform(self.safe_top, self.safe_bottom)
def new_trial(self) -> None:
self.trial_started = time.monotonic()
@@ -246,8 +266,8 @@ class ExerciseWindow(SurfaceTagWindow):
def position_circle(self, item: int, point: tuple[float, float], radius: int) -> None:
x, y = self.sx(point[0]), self.sy(point[1])
rx = radius / SCREEN_WIDTH * self.canvas.winfo_width()
ry = radius / SCREEN_HEIGHT * self.canvas.winfo_height()
rx = radius / self.width * self.canvas.winfo_width()
ry = radius / self.height * self.canvas.winfo_height()
self.canvas.coords(item, x - rx, y - ry, x + rx, y + ry)
def position_shapes(self) -> None:
@@ -273,8 +293,8 @@ class ExerciseWindow(SurfaceTagWindow):
if kind == "gaze":
sample: GazeSample = value # type: ignore[assignment]
gaze = (
sample.x_norm * SCREEN_WIDTH,
(1.0 - sample.y_norm) * SCREEN_HEIGHT,
sample.x_norm * self.width,
(1.0 - sample.y_norm) * self.height,
)
samples.append((sample, gaze))
else:
@@ -357,8 +377,8 @@ class ExerciseWindow(SurfaceTagWindow):
if self.block == "pursuit":
phase = elapsed / 10.0 * math.tau
self.target = (
SCREEN_WIDTH / 2 + 760 * math.sin(phase),
SCREEN_HEIGHT / 2 + 390 * math.sin(phase * 2),
self.width / 2 + self.pursuit_amp_x * math.sin(phase),
self.height / 2 + self.pursuit_amp_y * math.sin(phase * 2),
)
self.position_shapes()
for sample, gaze in gaze_samples:
@@ -414,10 +434,13 @@ class ExerciseWindow(SurfaceTagWindow):
def main() -> None:
args = parse_args()
if args.list_monitors:
print(format_monitor_list())
return
validate_args(args)
if not 0 <= args.confidence <= 1:
raise SystemExit("error: --confidence must be between 0 and 1")
if args.screen_diagonal <= 0:
if args.screen_diagonal is not None and args.screen_diagonal <= 0:
raise SystemExit("error: --screen-diagonal must be greater than 0")
if args.viewing_distance <= 0:
raise SystemExit("error: --viewing-distance must be greater than 0")