Spatial Scanning

Spatial Scanning / Tracking Task (high-level overview)
This task measures how accurately a participant’s gaze follows a moving 3-D target sphere in VR across three short trials with varying motion patterns.
| Metric | How it’s calculated (each frame → trial average) | Stored in summary file as |
|---|---|---|
| Angular error (°) | Angle between the eye-to-target vector and the eye’s forward vector | AngularError_Deg |
| RMS error (m) | Root-mean-square of the 2-D distance between the gaze intersection point and the target’s screen position | GazeRMS_Error |
Visual feedback (yellow glow)
- The target sphere gains a semi-transparent yellow halo whenever angular error ≤ 5 °—letting participants know they are “locked on.”
- To adjust the tolerance, change the comparison in
track_gaze_error():
(glow.enable if angle <= 5 else glow.disable)(viz.RENDERING)
Data output
After each trial, the script writes the averaged AngularError_Deg and GazeRMS_Error (plus any extra summary fields you add) to SightLab’s standard experiment summary file, ready for further analysis.
Key parameters you can tweak
| Variable | Purpose | Default |
|---|---|---|
TARGET_SPHERE |
Which colour starts as the target ('greenBall', 'redBall', 'blueBall'). |
'greenBall' |
TRIAL_COUNT |
Number of trials. | 3 |
TRIAL_LENGTH |
Seconds each trial lasts. | 3 |
start_positions |
Candidate (x, y, z) starting points for the hover motion. | Three presets |
speed, scale (figure-eight) |
Path tempo and size. | 2.0, 2.5 |
pause_time (hover) |
Dwell time at each hover waypoint. | 1 s |
import sightlab_utils.sightlab as sl
from sightlab_utils.settings import *
import math, random
import viz, viztask, vizact, vizshape, vizmat
# ——— Initialize SightLab —————————————————————————————————————————
sightlab = sl.SightLab(gui=False, headlight=True)
env = viz.addChild('dojo.osgb')
sightlab.setEnvironment(env)
sightlab.setStartText(' ')
# ——— Create spheres & register for gaze —————————————————————————
sphere1 = vizshape.addSphere(radius=0.2, color=viz.RED) # figure-eight by default
sphere2 = vizshape.addSphere(radius=0.2, color=viz.BLUE) # crossing by default
sphere3 = vizshape.addSphere(radius=0.2, color=viz.GREEN) # hover by default
sightlab.addSceneObject('redBall', sphere1, gaze=True)
sightlab.addSceneObject('blueBall', sphere2, gaze=True)
sightlab.addSceneObject('greenBall', sphere3, gaze=True)
# ——— Experiment parameters ——————————————————————————————————————
TARGET_SPHERE = 'greenBall' # ← choose 'redBall' or 'blueBall' if desired
TRIAL_COUNT = 3
TRIAL_LENGTH = 3
sightlab.setTrialCount(TRIAL_COUNT)
# ——— Mapping helpers for colour swapping ————————————————
color_to_sphere = {'red': sphere1, 'blue': sphere2, 'green': sphere3}
color_rgb = {'red': viz.RED, 'blue': viz.BLUE, 'green': viz.GREEN}
selected_color = TARGET_SPHERE.replace('Ball', '').lower()
# Build the required swap plan for the three trials
if selected_color == 'green': # hover → figure-8 → cross
swap_plan = [None, ('green', 'red'), ('green', 'blue')]
elif selected_color == 'red': # hover → figure-8 → cross
swap_plan = [('red', 'green'), ('red', 'green'), ('red', 'blue')]
else: # blue selected # hover → figure-8 → cross
swap_plan = [('blue', 'green'), ('blue', 'red'), ('blue', 'green')]
# This handle is updated so gaze error always tracks the chosen colour
target_sphere_ref = color_to_sphere[selected_color]
# ——— Glow indicator for gaze lock —————————————————————————————
glow = vizshape.addSphere(radius=0.25, color=viz.YELLOW)
glow.alpha(0.4)
glow.disable(viz.RENDERING)
# ——— Possible start‐positions for the hover ball ——————————————
start_positions = [
[ 2, 1, 2],
[ 0.5, 1, 2],
[-1.5, 1, 2]
]
# ——— Motion generators —————————————————————————————————————————
def figure_eight_motion(sphere, speed=1.0, scale=2.0):
t, dt = 0.0, 0.01
while True:
sphere.setPosition([math.sin(t)*scale, 1.5, math.sin(2*t)*scale/2])
t += dt * speed
yield viztask.waitTime(dt)
def crossing_motion(sphere, positions, speed):
while True:
for pos in positions:
yield sphere.runAction(vizact.moveTo(pos, speed=speed))
yield viztask.waitTime(0.5)
def hovering_motion(sphere, hover_pos, pause_time):
while True:
for pos in hover_pos:
yield sphere.runAction(vizact.moveTo(pos, time=1))
yield viztask.waitTime(pause_time)
# ——— Gaze‐error tracking ————————————————————————————————————————
rms_errors, angular_errors = [], []
def get_angle_between(v1, v2):
return vizmat.AngleBetweenVector(v1, v2)
def track_gaze_error():
global target_sphere_ref
while True:
gp = sightlab.getGazePointObject().getPosition()
tp = target_sphere_ref.getPosition()
hp = viz.MainView.getPosition()
# RMS & angular errors
dx, dy = gp[0] - tp[0], gp[1] - tp[1]
rms_errors.append(math.hypot(dx, dy))
gv = vizmat.NormalizeRGB([gp[i] - hp[i] for i in range(3)])
tv = vizmat.NormalizeRGB([tp[i] - hp[i] for i in range(3)])
angle = get_angle_between(gv, tv)
angular_errors.append(angle)
# Glow indicator
glow.setPosition(tp)
(glow.enable if angle <= 5 else glow.disable)(viz.RENDERING)
yield viztask.waitTime(0.01)
def compute_and_store_rms_error():
if rms_errors:
rms = math.sqrt(sum(e*e for e in rms_errors) / len(rms_errors))
else:
rms = -1
sightlab.setExperimentSummaryData('GazeRMS_Error', round(rms, 4))
# ——— Swap helper ————————————————————————————————————————————
def apply_swap(trial_index):
pair = swap_plan[trial_index]
if pair:
a, b = pair
# physically swap which sphere carries each colour
color_to_sphere[a], color_to_sphere[b] = color_to_sphere[b], color_to_sphere[a]
# repaint so colours match their new owners
for col, sph in color_to_sphere.items():
sph.color(color_rgb[col])
# update global reference for gaze tracking
global target_sphere_ref
target_sphere_ref = color_to_sphere[selected_color]
# ——— Keep a handle to the hover task ————————————————————————
hover_task = None
# ——— Main experiment coroutine ——————————————————————————————————
def sightLabExperiment():
yield viztask.waitEvent(EXPERIMENT_START)
global hover_task
# start continuous figure-eight & crossing motions (bound to spheres 1 & 2)
viztask.schedule(figure_eight_motion(sphere1, speed=2.0, scale=2.5))
viztask.schedule(crossing_motion(
sphere2,
positions=[[-5,0.5,-3],[5,1,3],[-5,1.5,6],[5,0.8,-2]],
speed=2
))
for t in range(TRIAL_COUNT):
# 0) Perform any required colour swap
apply_swap(t)
sightlab.setStartPoint([0, 0, -4])
# 1) Pick & place new start for sphere3 (always hover object)
new_pos = random.choice(start_positions)
sphere3.clearActions()
sphere3.setPosition(new_pos)
# 2) Restart hover motion
if hover_task: hover_task.kill()
hover_positions = [[new_pos[0], y, new_pos[2]] for y in (0.5, 1.0, 1.5)]
hover_task = viztask.schedule(hovering_motion(sphere3, hover_positions, pause_time=1))
# 3) Trial instructions
instr_text = (
f"Follow the {TARGET_SPHERE}\n\n"
"Press Trigger or Left Mouse Button to Continue"
)
difficulty_labels = ['Easy', 'Hard', 'Medium']
difficulty = difficulty_labels[t]
yield sightlab.startTrial(
startTrialText=instr_text,
textContinueEvent="triggerPress",
trialLabel=f"{TARGET_SPHERE} {difficulty}" )
yield viztask.waitTime(2)
# 4) Start gaze-error logging
rms_errors.clear(); angular_errors.clear()
viztask.schedule(track_gaze_error())
# 5) Let the trial run
yield viztask.waitTime(TRIAL_LENGTH)
# 6) Record summary data
avg_ang = sum(angular_errors)/len(angular_errors) if angular_errors else -1
sightlab.setExperimentSummaryData('AngularError_Deg', round(avg_ang, 2))
compute_and_store_rms_error()
yield sightlab.endTrial()
# ——— Kick everything off —————————————————————————————————————
viztask.schedule(sightlab.runExperiment)
viztask.schedule(sightLabExperiment)
viz.callback(viz.getEventID('ResetPosition'), sightlab.resetViewPoint)
Optional Audio Feedback Version
This version plays a beep that will get faster as you're closer on target. These values can be manipulated. The audio sound can also be swapped out.
BEEP_START_DEG
** Raise it if you want the feedback to kick in even when participants are fairly far off target (e.g. 45 °).
Lower it if you only want beeps once they’re already quite close.
MIN_BEEP_INTERVAL / MAX_BEEP_INTERVAL
** These set the tempo range.
Smaller numbers = faster beeps.
Example: changing MIN_BEEP_INTERVAL from 0.15 s to 0.05 s makes the locked-on beep stutter much more rapidly.
ANGLE_LOCK_DEG (unchanged from earlier) still governs the yellow-glow indicator and dwell-time logic. It can be tighter or looser than BEEP_START_DEG; they’re independent.