added progress from previous repo host
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -1,3 +1,100 @@
|
||||
# adaptive_filtering
|
||||
# Pupil Capture screen surface
|
||||
|
||||
Adaptive filtering of eye_tracking data
|
||||
This small Python program fills the primary screen and displays four unique
|
||||
`tag36h11` AprilTags in its corners. It has no third-party dependencies; the
|
||||
standard Python installation used on Windows normally includes Tkinter.
|
||||
|
||||
## Run
|
||||
|
||||
Close or move anything important from the primary screen, then run:
|
||||
|
||||
```powershell
|
||||
python show_apriltag_surface.py
|
||||
```
|
||||
|
||||
Press `Esc` or `Q` to close it. For a preview in a normal resizable window:
|
||||
|
||||
```powershell
|
||||
python show_apriltag_surface.py --windowed
|
||||
```
|
||||
|
||||
By default, every marker is flush with both adjacent screen edges. Useful
|
||||
options include `--tag-size 240`, `--margin 32`, and
|
||||
`--tag-ids 10 11 12 13`. The requested size is rounded down to a multiple of
|
||||
10 pixels so that the source grid is always scaled without interpolation.
|
||||
|
||||
## Define the surface in Pupil Capture
|
||||
|
||||
1. Start this program and look at the screen through the headset.
|
||||
2. Enable **Surface Tracker** in Pupil Capture.
|
||||
3. Confirm all four markers are outlined/detected in the World window.
|
||||
4. Select **Add surface**, name it (for example, `screen`), then freeze the
|
||||
scene and edit the surface corners to match the usable screen area.
|
||||
5. Keep this program running whenever the surface should remain trackable.
|
||||
|
||||
If detection is unreliable, increase `--tag-size`, reduce glare, and ensure
|
||||
the world camera can see the markers. Do not reuse any of these four marker IDs
|
||||
elsewhere in the camera's view.
|
||||
|
||||
For the next integration step, enable Pupil Capture's **Network API** plugin
|
||||
and keep **Surface Tracker** active. Surface-relative gaze is broadcast on a
|
||||
topic beginning with `surfaces.`; its normalized `(x, y)` coordinates use the
|
||||
bottom-left as `(0, 0)` and the top-right as `(1, 1)`.
|
||||
|
||||
## Live gaze visualization
|
||||
|
||||
Set the `screen` surface Width to `2560` and Height to `1440`, keep Surface
|
||||
Tracker and Network API enabled, and install the two network dependencies once:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Then close the marker-only program and run:
|
||||
|
||||
```powershell
|
||||
python visualize_surface_gaze.py
|
||||
```
|
||||
|
||||
This program displays the same four corner tags, subscribes to
|
||||
`surfaces.screen`, draws a red gaze dot, and prints rows containing Pupil
|
||||
timestamp, screen x, screen y, and confidence. Coordinates are converted to
|
||||
the Windows top-left origin. It connects to `127.0.0.1:50020` by default;
|
||||
use `--host 192.168.0.47` only when this script runs on another computer.
|
||||
|
||||
The displayed point passes through a 5-sample median filter, exponential
|
||||
smoothing, and a 3-pixel dead zone. Terminal rows contain timestamp, raw x,
|
||||
raw y, filtered x, filtered y, and confidence. For a steadier but slower point:
|
||||
|
||||
```powershell
|
||||
python visualize_surface_gaze.py --smoothing 0.10 --median-window 7
|
||||
```
|
||||
|
||||
For a more responsive point, use `--smoothing 0.30 --median-window 3`.
|
||||
|
||||
## Guided gaze exercises
|
||||
|
||||
With Pupil Capture configured the same way, run:
|
||||
|
||||
```powershell
|
||||
python gaze_exercises.py
|
||||
```
|
||||
|
||||
Press Space to begin and between blocks. The sequence contains 10 three-second
|
||||
steady fixations, 10 rapid target acquisitions, 20 two-color selective-attention
|
||||
trials, and three 10-second smooth-pursuit paths. A live yellow dot shows the
|
||||
raw gaze when the program is run with `--cursor`; it is hidden by default. No
|
||||
median or smoothing filter is applied, and every received sample that meets the
|
||||
`--confidence` threshold is written to a timestamped CSV in `results/`, including
|
||||
the target, gaze coordinates, pixel error, and whether gaze was on target. During
|
||||
every color trial, green is the target and red is the distractor.
|
||||
|
||||
Visualize the newest result file with:
|
||||
|
||||
```powershell
|
||||
python visualize_results.py
|
||||
```
|
||||
|
||||
Or select a particular run with `python visualize_results.py results\file.csv`.
|
||||
The script opens a six-panel dashboard, prints summary statistics, and saves a
|
||||
`*_dashboard.png` beside the source CSV. Use `--no-show` to only save the PNG.
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Guided screen-based gaze exercises for Pupil Capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
from datetime import datetime
|
||||
import math
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import random
|
||||
import statistics
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
|
||||
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
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run four guided gaze exercises.")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
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("--viewing-distance", type=float, default=DEFAULT_VIEWING_DISTANCE_CM,
|
||||
help="eye-to-screen distance in cm (default: 60)")
|
||||
parser.add_argument("--rounds", type=int, default=3,
|
||||
help="number of complete 1-2-3-4 exercise rounds (default: 3)")
|
||||
parser.add_argument("--animation-fps", type=int, default=120,
|
||||
help="target animation refresh rate (default: 120)")
|
||||
parser.add_argument("--cursor", action="store_true",
|
||||
help="show the live gaze cursor (hidden by default)")
|
||||
parser.add_argument("--tag-ids", nargs=4, type=int, default=(0, 1, 2, 3))
|
||||
parser.add_argument("--tag-size", type=int, default=200)
|
||||
parser.add_argument("--margin", type=int, default=0)
|
||||
parser.add_argument("--background", default="#10141c")
|
||||
parser.add_argument("--windowed", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
class ExerciseWindow(SurfaceTagWindow):
|
||||
BLOCKS = ("fixation", "saccade", "colors", "pursuit")
|
||||
|
||||
def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
|
||||
super().__init__(root, args)
|
||||
self.args = args
|
||||
self.frame_interval_ms = max(1, round(1000 / args.animation_fps))
|
||||
self.messages: queue.Queue = queue.Queue(maxsize=256)
|
||||
self.stop_event = threading.Event()
|
||||
self.receiver = SurfaceReceiver(
|
||||
self.messages, self.stop_event, args.host, args.port,
|
||||
args.surface, args.confidence
|
||||
)
|
||||
self.receiver.start()
|
||||
self.gaze: tuple[float, float] | None = None
|
||||
self.rng = random.Random()
|
||||
|
||||
self.block_index = -1
|
||||
self.round = 1
|
||||
self.block = "welcome"
|
||||
self.trial = 0
|
||||
self.trial_started = 0.0
|
||||
self.block_started = 0.0
|
||||
self.target = (SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2)
|
||||
self.distractor: tuple[float, float] | None = None
|
||||
self.correct_color = "#39e681"
|
||||
self.hit_started: float | None = None
|
||||
self.errors: list[float] = []
|
||||
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
|
||||
|
||||
results_dir = Path(__file__).resolve().parent / "results"
|
||||
results_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.result_path = results_dir / f"gaze_exercises_{stamp}.csv"
|
||||
self.result_file = self.result_path.open("w", newline="", encoding="utf-8")
|
||||
self.writer = csv.writer(self.result_file)
|
||||
self.writer.writerow([
|
||||
"pupil_timestamp", "round", "block", "trial", "trial_elapsed_s",
|
||||
"target_x", "target_y", "gaze_x", "gaze_y", "error_px",
|
||||
"error_cm", "error_deg", "hit"
|
||||
])
|
||||
|
||||
self.target_item = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
|
||||
self.target_center = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
|
||||
self.distractor_item = self.canvas.create_oval(0, 0, 0, 0, state="hidden", tags="exercise")
|
||||
self.gaze_item = self.canvas.create_oval(
|
||||
0, 0, 0, 0, fill="#ffd43b", outline="white", width=2,
|
||||
state="hidden", tags="exercise"
|
||||
)
|
||||
self.heading = self.canvas.create_text(
|
||||
0, 70, fill="white", font=("Segoe UI", 25, "bold"), tags="exercise"
|
||||
)
|
||||
self.instructions = self.canvas.create_text(
|
||||
0, 125, fill="#d7deea", font=("Segoe UI", 16),
|
||||
justify="center", tags="exercise"
|
||||
)
|
||||
self.network_status = self.canvas.create_text(
|
||||
0, 20, fill="#ffca58", font=("Segoe UI", 11), tags="exercise"
|
||||
)
|
||||
root.bind("<space>", self.on_space)
|
||||
root.protocol("WM_DELETE_WINDOW", self.close)
|
||||
self.show_welcome()
|
||||
root.after(self.frame_interval_ms, self.tick)
|
||||
|
||||
def sx(self, x: float) -> float:
|
||||
return x / SCREEN_WIDTH * self.canvas.winfo_width()
|
||||
|
||||
def sy(self, y: float) -> float:
|
||||
return y / SCREEN_HEIGHT * self.canvas.winfo_height()
|
||||
|
||||
def draw(self, event: tk.Event) -> None:
|
||||
super().draw(event)
|
||||
self.canvas.coords(self.heading, event.width / 2, 65)
|
||||
self.canvas.coords(self.instructions, event.width / 2, 115)
|
||||
self.canvas.coords(self.network_status, event.width / 2, 18)
|
||||
self.position_shapes()
|
||||
self.canvas.tag_raise("exercise")
|
||||
|
||||
def close(self, _event: tk.Event | None = None) -> None:
|
||||
self.stop_event.set()
|
||||
if not self.result_file.closed:
|
||||
self.result_file.close()
|
||||
super().close(_event)
|
||||
|
||||
def show_welcome(self) -> None:
|
||||
self.block = "welcome"
|
||||
self.hide_targets()
|
||||
self.canvas.itemconfigure(self.heading, text="Gaze exercise sequence")
|
||||
self.canvas.itemconfigure(
|
||||
self.instructions,
|
||||
text=(f"The following sequence repeats {self.args.rounds} times in 1-2-3-4 order:\n\n"
|
||||
"1. Hold gaze on 10 targets for 3 seconds each\n"
|
||||
"2. Move quickly to 10 appearing targets\n"
|
||||
"3. Choose the instructed color in 20 trials\n"
|
||||
"4. Follow a moving target for 10 seconds × 3\n\n"
|
||||
"Sit comfortably, keep your head natural, then press SPACE."),
|
||||
)
|
||||
|
||||
def on_space(self, _event: tk.Event | None = None) -> None:
|
||||
if self.block in {"welcome", "between"}:
|
||||
self.start_next_block()
|
||||
|
||||
def start_next_block(self) -> None:
|
||||
self.block_index += 1
|
||||
if self.block_index >= len(self.BLOCKS):
|
||||
if self.round >= self.args.rounds:
|
||||
self.finish()
|
||||
return
|
||||
self.round += 1
|
||||
self.block_index = 0
|
||||
self.block = self.BLOCKS[self.block_index]
|
||||
self.trial = 0
|
||||
self.errors.clear()
|
||||
self.hits = self.samples = 0
|
||||
self.block_started = self.trial_started = time.monotonic()
|
||||
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)
|
||||
|
||||
def new_trial(self) -> None:
|
||||
self.trial_started = time.monotonic()
|
||||
self.hit_started = None
|
||||
self.distractor = None
|
||||
if self.block == "fixation":
|
||||
self.target = self.random_target()
|
||||
self.set_text(f"Round {self.round}/{self.args.rounds} — Steady fixation — {self.trial + 1}/10", "Keep looking at the center dot")
|
||||
elif self.block == "saccade":
|
||||
self.target = self.random_target()
|
||||
self.set_text(f"Round {self.round}/{self.args.rounds} — Quick movement — {self.trial + 1}/10", "Look at the new target as quickly as possible")
|
||||
elif self.block == "colors":
|
||||
self.target = self.random_target()
|
||||
while True:
|
||||
self.distractor = self.random_target()
|
||||
if math.dist(self.target, self.distractor) > 500:
|
||||
break
|
||||
self.correct_color = "#39e681"
|
||||
self.set_text(
|
||||
f"Round {self.round}/{self.args.rounds} — Always look at GREEN",
|
||||
f"Ignore red • Trial {self.trial + 1}/20",
|
||||
)
|
||||
elif self.block == "pursuit":
|
||||
self.set_text(f"Round {self.round}/{self.args.rounds} — Smooth pursuit — {self.trial + 1}/3", "Follow the moving target with your eyes")
|
||||
self.position_shapes()
|
||||
|
||||
def set_text(self, heading: str, instructions: str) -> None:
|
||||
self.canvas.itemconfigure(self.heading, text=heading)
|
||||
self.canvas.itemconfigure(self.instructions, text=instructions)
|
||||
|
||||
def hide_targets(self) -> None:
|
||||
for item in (self.target_item, self.target_center, self.distractor_item):
|
||||
self.canvas.itemconfigure(item, state="hidden")
|
||||
|
||||
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()
|
||||
self.canvas.coords(item, x - rx, y - ry, x + rx, y + ry)
|
||||
|
||||
def position_shapes(self) -> None:
|
||||
if self.block not in self.BLOCKS:
|
||||
return
|
||||
color = self.correct_color if self.block == "colors" else "#55aaff"
|
||||
self.position_circle(self.target_item, self.target, TARGET_RADIUS)
|
||||
self.position_circle(self.target_center, self.target, 7)
|
||||
self.canvas.itemconfigure(self.target_item, fill=color, outline="white", width=3, state="normal")
|
||||
self.canvas.itemconfigure(self.target_center, fill="white", outline="", state="normal")
|
||||
if self.block == "colors" and self.distractor is not None:
|
||||
other = "#ff5263" if self.correct_color == "#39e681" else "#39e681"
|
||||
self.position_circle(self.distractor_item, self.distractor, TARGET_RADIUS)
|
||||
self.canvas.itemconfigure(self.distractor_item, fill=other, outline="white", width=3, state="normal")
|
||||
else:
|
||||
self.canvas.itemconfigure(self.distractor_item, state="hidden")
|
||||
|
||||
def process_network(self) -> list[tuple[GazeSample, tuple[float, float]]]:
|
||||
samples: list[tuple[GazeSample, tuple[float, float]]] = []
|
||||
try:
|
||||
while True:
|
||||
kind, value = self.messages.get_nowait()
|
||||
if kind == "gaze":
|
||||
sample: GazeSample = value # type: ignore[assignment]
|
||||
gaze = (
|
||||
sample.x_norm * SCREEN_WIDTH,
|
||||
(1.0 - sample.y_norm) * SCREEN_HEIGHT,
|
||||
)
|
||||
samples.append((sample, gaze))
|
||||
else:
|
||||
self.canvas.itemconfigure(self.network_status, text=str(value))
|
||||
except queue.Empty:
|
||||
pass
|
||||
if samples:
|
||||
self.gaze = samples[-1][1]
|
||||
self.position_circle(self.gaze_item, self.gaze, 14)
|
||||
if self.args.cursor:
|
||||
self.canvas.itemconfigure(self.gaze_item, state="normal")
|
||||
return samples
|
||||
|
||||
def record_sample(
|
||||
self,
|
||||
sample: GazeSample,
|
||||
gaze: tuple[float, float],
|
||||
now: float,
|
||||
) -> None:
|
||||
error = math.dist(gaze, self.target)
|
||||
error_cm = error * self.cm_per_px
|
||||
error_deg = math.degrees(2.0 * math.atan2(error_cm / 2.0, self.args.viewing_distance))
|
||||
hit = error <= HIT_RADIUS
|
||||
self.errors.append(error)
|
||||
self.samples += 1
|
||||
self.hits += int(hit)
|
||||
self.writer.writerow([
|
||||
f"{sample.timestamp:.6f}", self.round, self.block, self.trial + 1,
|
||||
f"{now - self.trial_started:.3f}", f"{self.target[0]:.1f}",
|
||||
f"{self.target[1]:.1f}", f"{gaze[0]:.1f}", f"{gaze[1]:.1f}",
|
||||
f"{error:.1f}", f"{error_cm:.3f}", f"{error_deg:.3f}", int(hit)
|
||||
])
|
||||
|
||||
def tick(self) -> None:
|
||||
gaze_samples = self.process_network()
|
||||
now = time.monotonic()
|
||||
if self.block in self.BLOCKS:
|
||||
elapsed = now - self.trial_started
|
||||
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.position_shapes()
|
||||
for sample, gaze in gaze_samples:
|
||||
self.record_sample(sample, gaze, now)
|
||||
|
||||
if self.block == "fixation" and elapsed >= 3.0:
|
||||
self.advance_trial(10)
|
||||
elif self.block == "saccade":
|
||||
on_target = self.gaze is not None and math.dist(self.gaze, self.target) <= HIT_RADIUS
|
||||
if on_target:
|
||||
self.hit_started = self.hit_started or now
|
||||
else:
|
||||
self.hit_started = None
|
||||
if (self.hit_started and now - self.hit_started >= 0.15) or elapsed >= 3.0:
|
||||
self.advance_trial(10)
|
||||
elif self.block == "colors" and elapsed >= 1.75:
|
||||
self.advance_trial(20)
|
||||
elif self.block == "pursuit" and elapsed >= 10.0:
|
||||
self.advance_trial(3)
|
||||
|
||||
if self.root.winfo_exists():
|
||||
self.root.after(self.frame_interval_ms, self.tick)
|
||||
|
||||
def advance_trial(self, total: int) -> None:
|
||||
self.trial += 1
|
||||
if self.trial >= total:
|
||||
self.end_block()
|
||||
else:
|
||||
self.new_trial()
|
||||
|
||||
def end_block(self) -> None:
|
||||
mean_error = statistics.fmean(self.errors) if self.errors else float("nan")
|
||||
hit_rate = self.hits / self.samples * 100 if self.samples else 0.0
|
||||
mean_cm = mean_error * self.cm_per_px
|
||||
mean_deg = math.degrees(2.0 * math.atan2(mean_cm / 2.0, self.args.viewing_distance))
|
||||
summary = (f"round {self.round} {self.block}: mean error {mean_error:.0f} px / {mean_cm:.2f} cm / "
|
||||
f"{mean_deg:.2f} deg, on-target {hit_rate:.0f}%")
|
||||
self.summary_lines.append(summary)
|
||||
self.block = "between"
|
||||
self.hide_targets()
|
||||
self.result_file.flush()
|
||||
self.set_text("Block complete", f"{summary}\n\nPress SPACE for the next exercise")
|
||||
|
||||
def finish(self) -> None:
|
||||
self.block = "finished"
|
||||
self.hide_targets()
|
||||
self.result_file.flush()
|
||||
self.set_text(
|
||||
"All exercises complete",
|
||||
"\n".join(self.summary_lines) + f"\n\nResults saved to:\n{self.result_path}\n\nPress Esc to close",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
validate_args(args)
|
||||
if not 0 <= args.confidence <= 1:
|
||||
raise SystemExit("error: --confidence must be between 0 and 1")
|
||||
if 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")
|
||||
if args.rounds < 1:
|
||||
raise SystemExit("error: --rounds must be at least 1")
|
||||
if args.animation_fps < 1:
|
||||
raise SystemExit("error: --animation-fps must be at least 1")
|
||||
enable_windows_dpi_awareness()
|
||||
root = tk.Tk()
|
||||
ExerciseWindow(root, args)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
msgpack>=1.0,<2
|
||||
matplotlib>=3.8,<4
|
||||
pyzmq>=25,<28
|
||||
@@ -0,0 +1 @@
|
||||
pupil_timestamp,block,trial,trial_elapsed_s,target_x,target_y,gaze_x,gaze_y,error_px,error_cm,error_deg,hit
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,146 @@
|
||||
"""Display four AprilTags for defining a monitor as a Pupil Capture surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
|
||||
|
||||
DEFAULT_TAG_IDS = (0, 1, 2, 3)
|
||||
SOURCE_TAG_SIZE = 10
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Show four tag36h11 markers in the corners of a screen."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tag-ids",
|
||||
metavar=("TL", "TR", "BL", "BR"),
|
||||
nargs=4,
|
||||
type=int,
|
||||
default=DEFAULT_TAG_IDS,
|
||||
help="marker IDs for top-left, top-right, bottom-left, bottom-right",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tag-size",
|
||||
type=int,
|
||||
default=200,
|
||||
help="displayed marker size in pixels (default: 200)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--margin",
|
||||
type=int,
|
||||
default=0,
|
||||
help="distance from each screen edge in pixels (default: 0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--background",
|
||||
default="#f2f2f2",
|
||||
help="window background color (default: #f2f2f2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--windowed",
|
||||
action="store_true",
|
||||
help="open a resizable 1280x720 window instead of fullscreen",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def enable_windows_dpi_awareness() -> None:
|
||||
"""Keep Tk coordinates in physical pixels on high-DPI Windows displays."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||||
except (AttributeError, OSError):
|
||||
try:
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def validate_args(args: argparse.Namespace) -> None:
|
||||
if len(set(args.tag_ids)) != 4:
|
||||
raise SystemExit("error: all four tag IDs must be unique")
|
||||
if any(tag_id < 0 or tag_id > 586 for tag_id in args.tag_ids):
|
||||
raise SystemExit("error: tag36h11 IDs must be between 0 and 586")
|
||||
if args.tag_size < SOURCE_TAG_SIZE:
|
||||
raise SystemExit(f"error: --tag-size must be at least {SOURCE_TAG_SIZE}")
|
||||
if args.margin < 0:
|
||||
raise SystemExit("error: --margin cannot be negative")
|
||||
|
||||
|
||||
class SurfaceTagWindow:
|
||||
def __init__(self, root: tk.Tk, args: argparse.Namespace) -> None:
|
||||
self.root = root
|
||||
self.args = args
|
||||
self.images: list[tk.PhotoImage] = []
|
||||
self.canvas = tk.Canvas(
|
||||
root,
|
||||
background=args.background,
|
||||
highlightthickness=0,
|
||||
cursor="none",
|
||||
)
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
|
||||
root.title("Pupil Capture screen surface markers")
|
||||
root.configure(background=args.background)
|
||||
if args.windowed:
|
||||
root.geometry("1280x720")
|
||||
root.minsize(640, 480)
|
||||
else:
|
||||
root.attributes("-fullscreen", True)
|
||||
|
||||
root.bind("<Escape>", self.close)
|
||||
root.bind("q", self.close)
|
||||
root.bind("Q", self.close)
|
||||
self.canvas.bind("<Configure>", self.draw)
|
||||
|
||||
tag_dir = Path(__file__).resolve().parent / "tag36h11"
|
||||
scale = max(1, args.tag_size // SOURCE_TAG_SIZE)
|
||||
self.actual_tag_size = SOURCE_TAG_SIZE * scale
|
||||
for tag_id in args.tag_ids:
|
||||
path = tag_dir / f"tag36_11_{tag_id:05d}.png"
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"error: marker image not found: {path}")
|
||||
# Tk's integer zoom preserves the tag's hard black/white pixel edges.
|
||||
self.images.append(tk.PhotoImage(file=path).zoom(scale, scale))
|
||||
|
||||
def close(self, _event: tk.Event | None = None) -> None:
|
||||
self.root.destroy()
|
||||
|
||||
def draw(self, event: tk.Event) -> None:
|
||||
width, height = event.width, event.height
|
||||
size, margin = self.actual_tag_size, self.args.margin
|
||||
if width < 2 * (size + margin) or height < 2 * (size + margin):
|
||||
self.root.title("Window too small for surface markers")
|
||||
else:
|
||||
self.root.title("Pupil Capture screen surface markers — Esc/Q to close")
|
||||
|
||||
centers = (
|
||||
(margin + size // 2, margin + size // 2),
|
||||
(width - margin - size // 2, margin + size // 2),
|
||||
(margin + size // 2, height - margin - size // 2),
|
||||
(width - margin - size // 2, height - margin - size // 2),
|
||||
)
|
||||
self.canvas.delete("marker")
|
||||
for image, (x, y) in zip(self.images, centers):
|
||||
self.canvas.create_image(x, y, image=image, tags="marker")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
validate_args(args)
|
||||
enable_windows_dpi_awareness()
|
||||
root = tk.Tk()
|
||||
SurfaceTagWindow(root, args)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user