Skip to content

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.