Compare commits

...

5 Commits

Author SHA1 Message Date
07dff9194a added tracking structure to readme 2025-10-06 17:24:38 +02:00
daf06564ba added recording for both players 2025-10-06 16:53:55 +02:00
296a8f5d51 auto ip configuration 2025-10-06 11:33:38 +02:00
6306cfc374 small refactoring 2025-10-06 11:27:55 +02:00
0019644e61 major refactoring 2025-10-06 11:21:03 +02:00
9 changed files with 1417 additions and 776 deletions

230
README.md
View File

@ -68,124 +68,174 @@ pip install -r requirements.txt
### Experiment Scripts Overview
- **app.py**: Web interface for controlling the experiment and word list
- **server.py**: UDP relay server for communication between VR clients
- **control.py**: Command-line tool for setting VR client modes and server IP
- **index.html**: Web UI served by app.py
- **word-list.txt**: Default list of charade words
- **app.py**: Main server - web interface, experiment control, and automatic tracking recorder
- **server.py**: UDP relay for communication between VR clients (auto-started by app.py)
- **index.html**: Web UI for configuring and running experiments
- **static/**: Frontend assets (CSS, JavaScript, player display)
- **data/**: Word lists (English and German)
### Setup Instructions
#### 1. Network Setup
- Connect all VR headsets to the same network as the server
- Note the IP addresses of the VR headsets (you'll need these for configuration)
- **Important**: Update `experiment-scripts/server.py` lines 19-20 with your actual VR headset IPs
```python
# Replace these example IPs with your actual headset IPs
DEVICE1_ADDR = ("YOUR_PLAYER1_IP", 5001) # e.g., ("192.168.1.100", 5001)
DEVICE2_ADDR = ("YOUR_PLAYER2_IP", 5001) # e.g., ("192.168.1.101", 5001)
```
- Connect all VR headsets to the same network as your computer
- Note the IP addresses of both VR headsets
- Note your computer's IP address (the server IP)
#### 2. Start the Relay Server
To find your server IP:
```bash
cd experiment-scripts
python server.py
# Linux/Mac
hostname -I
# Windows
ipconfig
```
#### 3. Start the Web Interface
**Note**: The UDP relay automatically detects and forwards data between connected VR headsets - no manual IP configuration needed in server.py!
#### 2. Start the Server
```bash
cd experiment-scripts
python -m fastapi dev app.py
```
Then navigate to `http://localhost:8000`
#### 4. Configure VR Clients
Tell clients which server to connect to:
```bash
# Windows PowerShell (replace with your actual VR headset IPs)
cd experiment-scripts
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "IP:127.0.0.1"
# Linux/Mac (replace with your actual VR headset IPs)
cd experiment-scripts
TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" python3 control.py "IP:127.0.0.1"
fastapi dev app.py
```
#### 5. Set Experiment Condition
This single command automatically starts:
- Web interface on http://localhost:8000
- UDP relay server (server.py)
- Tracking data recorder
Choose one of these modes:
```bash
# Dynamic Face only
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;1;1;0"
# Dynamic Hands only
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:0;0;0;1"
# Dynamic Hands + Face
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;1;1;1"
# Static Face only
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;0;0;0"
# Static Hands only (requires controllers)
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:0;0;0;1"
# Static Hands + Face (requires controllers for hands)
$env:TARGET_IP="YOUR_PLAYER1_IP,YOUR_PLAYER2_IP" ; python control.py "MODE:1;0;0;1"
```
#### 6. Prepare Word List
You can shuffle the word list directly in the web interface (see Web Interface Usage section below).
Navigate to `http://localhost:8000` to access the control interface.
### Web Interface Usage
Once you have the web interface running at `http://localhost:8000`:
All experiment configuration and control is done through the web interface at `http://localhost:8000`.
#### Setting Up a Charades Session
#### 1. Configure VR Headsets
1. **Configure Player IPs**
- Enter the IP addresses of Player 1 and Player 2 VR headsets
- These should match the IPs you used in the `control.py` commands
In the **VR Headset Configuration** section:
- Enter **Server IP Address**: Your computer's IP address
- Enter **Player 1 IP Address**: First VR headset IP
- Enter **Player 2 IP Address**: Second VR headset IP
- Select **Experiment Mode** (see Experiment Conditions below)
- Click **"Send Configuration to Headsets"**
2. **Prepare Word List**
- Copy your word list from `word-list.txt` and paste it into the large text area on the right side
- Click the **"Shuffle"** button to randomize the word order
- Click the **"Modify"** button to generate interactive word items
Wait for confirmation that the configuration was sent successfully.
3. **Set Game Parameters**
- **Current Player For Acting**: Select which player will be acting out the words
- **Time (s)**: Set the time limit for each word (e.g., 30 seconds)
- **Last Word Status**: Set to "None" for the first word
#### 2. Configure Experiment Session
#### Running the Experiment
In the **Session Configuration** section:
- **Group ID**: Identifier for this session (used in CSV filenames)
- **Time per Word**: Duration in seconds for each word (e.g., 30)
- **Total Duration**: Total experiment time in minutes (0 = unlimited)
**Manual Mode (Individual Words)**
1. Enter a word in the "Word" field
2. Select the acting player (Player 1 or Player 2)
3. Set the time limit
4. Click **"Send"** to transmit the word to the VR headsets
In the **Network Configuration** section:
- **Active Player**: Select which player will be performing (Player 1 or Player 2)
**Automatic Mode (Word List)**
1. After clicking "Modify" with your word list, the interface shows all words with timers
2. The system automatically starts with the first word and counts down
3. **During the session**:
- **Mark words correct**: Hover over a word and check the checkbox if guessed correctly
- **Visual indicators**: ▶ = Current word, ✅ = Correct, ❌ = Time expired
4. **Stop the session**: Click **"Stop"** to end early
#### 3. Prepare Word List
#### Exporting Results
In the **Word List** section:
- Copy words from `data/word-list.txt` or enter your own (one word per line)
- Click **"Shuffle Words"** to randomize order
- Click **"Start Experiment"** when ready
1. After completing the word list, click **"Save as CSV"**
2. This downloads a CSV file with word name, correctness, and time remaining
#### 4. Run the Experiment
When you click **"Start Experiment"**:
- The system automatically sends words to the active player
- Tracking data recording starts automatically
- Words advance based on the timer
- Check the checkbox next to each word if guessed correctly
- Click **"Stop Experiment"** to end early
#### 5. Export Data
After the experiment:
- **"Save Results (CSV)"**: Downloads word results
- Format: `{group_id}_results_{timestamp}.csv`
- Contains: word, correct/incorrect, time remaining
- **"Download Tracking Data (CSV)"**: Downloads tracking data
- Format: `{group_id}_tracking_{timestamp}.csv`
- Contains: camera and controller positions/rotations at 60Hz
- Includes: timestamps, current word, condition, elapsed time
### Experiment Conditions
The experiment supports six different conditions that control which body parts are tracked and how:
| Condition | Description | Settings |
|-----------|-------------|----------|
| **Dynamic Face** | Real-time face tracking with expressions and eye rotation | 1;1;1;0 |
| **Dynamic Hands** | Real-time hand tracking with finger gestures | 0;0;0;1 |
| **Dynamic Hands+Face** | Full tracking: face, expressions, eyes, and hands | 1;1;1;1 |
| **Static Face** | Head position tracking only (no expressions) | 1;0;0;0 |
| **Static Hands** | Controller tracking (no finger tracking) | 0;0;0;1 |
| **Static Hands+Face** | Head position + controller tracking | 1;0;0;1 |
**Mode format**: `<show_head>;<show_facial_expression>;<show_eye_rotation>;<show_hands>`
**Notes**:
- **Dynamic modes**: Use natural face/hand tracking via Quest Pro sensors
- **Static modes**: Participants must use controllers for hand input
- Select the condition in the web interface before starting the experiment
### Tracking Data
The system automatically records tracking data from the **active player** (the one performing charades) at approximately 60Hz:
**Recorded data**:
- Center eye camera position (x, y, z) and rotation (w, x, y, z)
- Left hand controller position and rotation
- Right hand controller position and rotation
- Current word being performed
- Timestamps and elapsed time
**Data recording**:
- Starts automatically when experiment starts
- Stops automatically when experiment stops
- Exports as CSV with group ID and timestamp in filename
### Tracking Data CSV Structure
The exported tracking data CSV contains the following columns:
| Column Name | Description | Example Value |
|------------------------- |--------------------------------------------------|-----------------------|
| timestamp | Unix timestamp (seconds since epoch) | 1718123456.1234 |
| elapsed_time | Seconds since experiment start | 12.3456 |
| player_id | "player1" or "player2" | player1 |
| role | "mimicker" or "guesser" | mimicker |
| group_id | Experiment group identifier | g1 |
| condition | Experiment mode string | 1;1;1;1 |
| current_word | Word being performed | Applaudieren |
| word_time_remaining | Seconds left for current word | 18.1234 |
| center_eye_pos_x | Center eye camera position X | 0.1234 |
| center_eye_pos_y | Center eye camera position Y | 1.2345 |
| center_eye_pos_z | Center eye camera position Z | -0.5678 |
| center_eye_rot_w | Center eye camera rotation W (quaternion) | 0.9876 |
| center_eye_rot_x | Center eye camera rotation X (quaternion) | 0.0123 |
| center_eye_rot_y | Center eye camera rotation Y (quaternion) | 0.0456 |
| center_eye_rot_z | Center eye camera rotation Z (quaternion) | -0.0789 |
| left_hand_pos_x | Left hand position X | 0.2345 |
| left_hand_pos_y | Left hand position Y | 1.3456 |
| left_hand_pos_z | Left hand position Z | -0.6789 |
| left_hand_rot_w | Left hand rotation W (quaternion) | 0.8765 |
| left_hand_rot_x | Left hand rotation X (quaternion) | 0.0234 |
| left_hand_rot_y | Left hand rotation Y (quaternion) | 0.0567 |
| left_hand_rot_z | Left hand rotation Z (quaternion) | -0.0890 |
| right_hand_pos_x | Right hand position X | 0.3456 |
| right_hand_pos_y | Right hand position Y | 1.4567 |
| right_hand_pos_z | Right hand position Z | -0.7890 |
| right_hand_rot_w | Right hand rotation W (quaternion) | 0.7654 |
| right_hand_rot_x | Right hand rotation X (quaternion) | 0.0345 |
| right_hand_rot_y | Right hand rotation Y (quaternion) | 0.0678 |
| right_hand_rot_z | Right hand rotation Z (quaternion) | -0.0901 |
**All values are separated by semicolons (`;`).**
### Mode Details
Mode format: `<show_head>;<show_facial_expression>;<show_eye_rotation>;<show_hands>`
- **Dynamic modes**: Real-time face/hand tracking
- **Static modes**: Participants use controllers instead of natural movement
## Unity Project Details

View File

@ -1,22 +1,28 @@
# Run in dev mode using:
# fastapi dev app.py
#
# After starting the server, you can navigate to http://localhost:8000 to see the web interface.
# This will automatically start both the web server AND the UDP relay (server.py).
# After starting, navigate to http://localhost:8000 to see the web interface.
#
# Note: This requires the user to have the fastapi CLI tool installed.
# The user should be in the same directory as `app.py` as well as `index.html`.
# The user should be in the same directory as `app.py`, `server.py`, and `index.html`.
import asyncio
from contextlib import asynccontextmanager
import socket
import subprocess
import os
import signal
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
clients = set()
server_process = None # Will hold the server.py subprocess
# Broadcast function to notify all SSE clients
async def notify_clients(message: str):
@ -35,7 +41,7 @@ async def sock_recvfrom(nonblocking_sock, *pos, loop, **kw):
finally:
loop.remove_reader(nonblocking_sock.fileno())
# Background task: UDP listener
# Background task: UDP listener for SSE clients
async def udp_listener():
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@ -48,13 +54,114 @@ async def udp_listener():
message = data.decode()
await notify_clients(message)
# Background task: Tracking data listener
async def tracking_listener():
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", 5002)) # Different port for tracking data
sock.setblocking(False)
while True:
data, addr = await sock_recvfrom(sock, 1024 * 16, loop=loop) # Larger buffer for tracking data
# Only record if tracking is active
if tracking_state["is_recording"]:
try:
message = data.decode()
# Extract source IP from tagged data (added by server.py)
if message.startswith("SOURCE_IP:"):
parts = message.split("|", 1)
source_ip = parts[0].replace("SOURCE_IP:", "")
actual_data = parts[1] if len(parts) > 1 else ""
else:
# Fallback if data isn't tagged (shouldn't happen normally)
source_ip = addr[0]
actual_data = message
# Record data from both players
if source_ip in [tracking_state["player1_ip"], tracking_state["player2_ip"]]:
parsed = parse_tracking_data(actual_data)
if parsed:
import time
# Calculate time elapsed since experiment start
elapsed_time = time.time() - tracking_state["experiment_start_time"]
# Determine player_ID and role
if source_ip == tracking_state["player1_ip"]:
player_id = "player1"
else:
player_id = "player2"
# Determine role: active_player_ip is the mimicker
if source_ip == tracking_state["active_player_ip"]:
role = "mimicker"
else:
role = "guesser"
# Add sample with metadata
sample = {
"timestamp": time.time(),
"elapsed_time": elapsed_time,
"player_id": player_id,
"role": role,
"current_word": current_word_state["word"],
"word_time_remaining": 0.0,
"data": parsed
}
# Calculate word time remaining if word is active
if current_word_state["startTime"]:
word_elapsed = time.time() - current_word_state["startTime"]
sample["word_time_remaining"] = max(0, current_word_state["timeSeconds"] - word_elapsed)
tracking_state["samples"].append(sample)
except Exception as e:
print(f"Error processing tracking data: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
global server_process
# Start server.py subprocess
print("Starting UDP relay server (server.py)...")
try:
server_process = subprocess.Popen(
["python", "server.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=os.path.dirname(os.path.abspath(__file__))
)
print(f"UDP relay server started with PID {server_process.pid}")
except Exception as e:
print(f"Warning: Failed to start server.py: {e}")
print("You may need to start server.py manually.")
# Start background tasks
asyncio.create_task(udp_listener())
asyncio.create_task(tracking_listener())
yield
# Cleanup: Stop server.py subprocess
if server_process:
print("Stopping UDP relay server...")
server_process.terminate()
try:
server_process.wait(timeout=5)
print("UDP relay server stopped")
except subprocess.TimeoutExpired:
print("Forcefully killing UDP relay server...")
server_process.kill()
server_process.wait()
app = FastAPI(lifespan=lifespan)
# Mount static files for serving CSS, JavaScript, and HTML assets
app.mount("/static", StaticFiles(directory="static"), name="static")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# sock.bind(("0.0.0.0", 5002))
@ -68,7 +175,7 @@ async def read_index():
@app.get("/display")
async def read_display():
return FileResponse('player-display.html')
return FileResponse('static/player-display.html')
@app.post("/facialexpressions")
def read_item(weights: list[float]):
@ -86,6 +193,19 @@ class Word(BaseModel):
class WordList(BaseModel):
words: list[str]
class VRConfig(BaseModel):
player1_ip: str
player2_ip: str
server_ip: str
mode: str
class TrackingStart(BaseModel):
group_id: str
condition: str
player1_ip: str
player2_ip: str
active_player_ip: str # The player who mimics (mimicker)
# Global state for current word display
current_word_state = {
"word": "",
@ -94,6 +214,99 @@ current_word_state = {
"startTime": None
}
# Global state for tracking data recording
tracking_state = {
"is_recording": False,
"group_id": "",
"condition": "",
"player1_ip": "",
"player2_ip": "",
"active_player_ip": "", # The mimicker
"experiment_start_time": None,
"samples": [] # List of tracking data samples
}
def parse_tracking_data(data_str):
"""
Parse tracking data from VR headset to extract camera and controller positions/rotations.
Returns dict with center_eye, left_hand, right_hand data or None if parsing fails.
"""
try:
parts = data_str.split(';')
# The data structure from TrackWeights.cs:
# 0-62: Face expressions (63 values)
# 63-66: Root orientation (4 values)
# 67-69: Root position (3 values)
# 70: Root scale (1 value)
# 71: Bone rotations length (1 value)
bone_rot_length = int(float(parts[71]))
# 72 to 72+(bone_rot_length*4)-1: Bone rotations (4 values each)
idx = 72 + (bone_rot_length * 4)
# idx: IsDataValid
idx += 1
# idx: IsDataHighConfidence
idx += 1
# idx: Bone translations length
bone_trans_length = int(float(parts[idx]))
idx += 1
# idx to idx+(bone_trans_length*3)-1: Bone translations (3 values each)
idx += bone_trans_length * 3
# idx: SkeletonChangedCount
idx += 1
# idx to idx+3: Left eye rotation (4 values)
idx += 4
# idx to idx+3: Right eye rotation (4 values)
idx += 4
# Now we're at the data we want!
# Center eye camera: position (3) + rotation (4) = 7 values
center_eye_pos_x = float(parts[idx])
center_eye_pos_y = float(parts[idx + 1])
center_eye_pos_z = float(parts[idx + 2])
center_eye_rot_w = float(parts[idx + 3])
center_eye_rot_x = float(parts[idx + 4])
center_eye_rot_y = float(parts[idx + 5])
center_eye_rot_z = float(parts[idx + 6])
idx += 7
# Left hand controller: position (3) + rotation (4) = 7 values
left_hand_pos_x = float(parts[idx])
left_hand_pos_y = float(parts[idx + 1])
left_hand_pos_z = float(parts[idx + 2])
left_hand_rot_w = float(parts[idx + 3])
left_hand_rot_x = float(parts[idx + 4])
left_hand_rot_y = float(parts[idx + 5])
left_hand_rot_z = float(parts[idx + 6])
idx += 7
# Right hand controller: position (3) + rotation (4) = 7 values
right_hand_pos_x = float(parts[idx])
right_hand_pos_y = float(parts[idx + 1])
right_hand_pos_z = float(parts[idx + 2])
right_hand_rot_w = float(parts[idx + 3])
right_hand_rot_x = float(parts[idx + 4])
right_hand_rot_y = float(parts[idx + 5])
right_hand_rot_z = float(parts[idx + 6])
return {
"center_eye": {
"pos": {"x": center_eye_pos_x, "y": center_eye_pos_y, "z": center_eye_pos_z},
"rot": {"w": center_eye_rot_w, "x": center_eye_rot_x, "y": center_eye_rot_y, "z": center_eye_rot_z}
},
"left_hand": {
"pos": {"x": left_hand_pos_x, "y": left_hand_pos_y, "z": left_hand_pos_z},
"rot": {"w": left_hand_rot_w, "x": left_hand_rot_x, "y": left_hand_rot_y, "z": left_hand_rot_z}
},
"right_hand": {
"pos": {"x": right_hand_pos_x, "y": right_hand_pos_y, "z": right_hand_pos_z},
"rot": {"w": right_hand_rot_w, "x": right_hand_rot_x, "y": right_hand_rot_y, "z": right_hand_rot_z}
}
}
except (IndexError, ValueError) as e:
print(f"Error parsing tracking data: {e}")
return None
@app.post("/word")
def read_word(word: Word):
import time
@ -140,6 +353,156 @@ def shuffle_words(word_list: WordList):
random.shuffle(shuffled)
return { "status": "ok", "shuffled_words": shuffled }
@app.post("/send-config")
def send_vr_config(config: VRConfig):
"""
Send IP and MODE configuration to VR headsets.
This integrates the functionality from control.py into the web interface.
"""
try:
# Create UDP socket for sending commands
cmd_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Send IP configuration to both players
ip_msg = f"IP:{config.server_ip}".encode('utf-8')
cmd_sock.sendto(ip_msg, (config.player1_ip, 5000))
cmd_sock.sendto(ip_msg, (config.player2_ip, 5000))
# Send MODE configuration to both players
mode_msg = f"MODE:{config.mode}".encode('utf-8')
cmd_sock.sendto(mode_msg, (config.player1_ip, 5000))
cmd_sock.sendto(mode_msg, (config.player2_ip, 5000))
cmd_sock.close()
print(f"Sent IP config: {ip_msg.decode()}")
print(f"Sent MODE config: {mode_msg.decode()}")
print(f"To players: {config.player1_ip}, {config.player2_ip}")
return { "status": "ok" }
except Exception as e:
print(f"Error sending VR config: {e}")
return { "status": "error", "message": str(e) }
@app.post("/tracking/start")
def start_tracking(config: TrackingStart):
"""
Start recording tracking data from both players.
"""
import time
tracking_state["is_recording"] = True
tracking_state["group_id"] = config.group_id
tracking_state["condition"] = config.condition
tracking_state["player1_ip"] = config.player1_ip
tracking_state["player2_ip"] = config.player2_ip
tracking_state["active_player_ip"] = config.active_player_ip
tracking_state["experiment_start_time"] = time.time()
tracking_state["samples"] = []
print(f"Started tracking: group={config.group_id}, condition={config.condition}")
print(f" Player1: {config.player1_ip}, Player2: {config.player2_ip}")
print(f" Mimicker: {config.active_player_ip}")
return { "status": "ok", "message": "Tracking started" }
@app.post("/tracking/stop")
def stop_tracking():
"""
Stop recording tracking data.
"""
tracking_state["is_recording"] = False
sample_count = len(tracking_state["samples"])
print(f"Stopped tracking: {sample_count} samples recorded")
return { "status": "ok", "message": f"Tracking stopped. {sample_count} samples recorded." }
@app.get("/tracking/status")
def get_tracking_status():
"""
Get current tracking status.
"""
return {
"is_recording": tracking_state["is_recording"],
"sample_count": len(tracking_state["samples"]),
"group_id": tracking_state["group_id"],
"condition": tracking_state["condition"]
}
@app.get("/tracking/download")
def download_tracking_csv():
"""
Download tracking data as CSV.
"""
import io
from datetime import datetime
if len(tracking_state["samples"]) == 0:
return { "status": "error", "message": "No tracking data available" }
# Create CSV content
output = io.StringIO()
# Write header
header = [
"timestamp", "elapsed_time", "player_id", "role", "group_id", "condition", "current_word", "word_time_remaining",
"center_eye_pos_x", "center_eye_pos_y", "center_eye_pos_z",
"center_eye_rot_w", "center_eye_rot_x", "center_eye_rot_y", "center_eye_rot_z",
"left_hand_pos_x", "left_hand_pos_y", "left_hand_pos_z",
"left_hand_rot_w", "left_hand_rot_x", "left_hand_rot_y", "left_hand_rot_z",
"right_hand_pos_x", "right_hand_pos_y", "right_hand_pos_z",
"right_hand_rot_w", "right_hand_rot_x", "right_hand_rot_y", "right_hand_rot_z"
]
output.write(";".join(header) + "\n")
# Write data rows
for sample in tracking_state["samples"]:
data = sample["data"]
row = [
str(sample["timestamp"]),
f"{sample['elapsed_time']:.4f}",
sample["player_id"],
sample["role"],
tracking_state["group_id"],
tracking_state["condition"],
sample["current_word"],
f"{sample['word_time_remaining']:.4f}",
f"{data['center_eye']['pos']['x']:.4f}",
f"{data['center_eye']['pos']['y']:.4f}",
f"{data['center_eye']['pos']['z']:.4f}",
f"{data['center_eye']['rot']['w']:.4f}",
f"{data['center_eye']['rot']['x']:.4f}",
f"{data['center_eye']['rot']['y']:.4f}",
f"{data['center_eye']['rot']['z']:.4f}",
f"{data['left_hand']['pos']['x']:.4f}",
f"{data['left_hand']['pos']['y']:.4f}",
f"{data['left_hand']['pos']['z']:.4f}",
f"{data['left_hand']['rot']['w']:.4f}",
f"{data['left_hand']['rot']['x']:.4f}",
f"{data['left_hand']['rot']['y']:.4f}",
f"{data['left_hand']['rot']['z']:.4f}",
f"{data['right_hand']['pos']['x']:.4f}",
f"{data['right_hand']['pos']['y']:.4f}",
f"{data['right_hand']['pos']['z']:.4f}",
f"{data['right_hand']['rot']['w']:.4f}",
f"{data['right_hand']['rot']['x']:.4f}",
f"{data['right_hand']['rot']['y']:.4f}",
f"{data['right_hand']['rot']['z']:.4f}"
]
output.write(";".join(row) + "\n")
csv_content = output.getvalue()
output.close()
# Create filename with timestamp
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"{tracking_state['group_id']}_tracking_{timestamp}.csv"
from fastapi.responses import Response
return Response(
content=csv_content,
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
# SSE endpoint
@app.get("/news")

View File

@ -5,345 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VR Charades Experiment Control</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #f5f5f5;
color: #2c3e50;
line-height: 1.5;
}
.header {
background: #2c3e50;
color: white;
padding: 1.5rem 2rem;
border-bottom: 3px solid #34495e;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header p {
font-size: 0.9rem;
color: #bdc3c7;
margin-top: 0.25rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.timer-section {
background: white;
border: 2px solid #34495e;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.timer-label {
font-size: 0.875rem;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.timer-display {
font-family: 'Roboto Mono', monospace;
font-size: 4rem;
font-weight: 500;
color: #2c3e50;
}
.timer-display.warning {
color: #e67e22;
}
.timer-display.danger {
color: #e74c3c;
}
.timer-display.inactive {
color: #bdc3c7;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid #ecf0f1;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #34495e;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 3px;
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f8f9fa;
color: #adb5bd;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.radio-group {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.radio-label input {
cursor: pointer;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 3px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #229954;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #7f8c8d;
}
.btn-group {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.controls-section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.controls-section .btn-group {
margin-top: 0;
}
textarea {
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
}
.word-display {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.word-list-container {
max-height: 500px;
overflow-y: auto;
margin-top: 1rem;
}
.word-item {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 3px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s;
}
.word-item:hover {
background: #f0f0f0;
}
.word-item.active {
background: #e3f2fd;
border-color: #3498db;
}
.word-item.correct {
background: #e8f5e9;
border-color: #27ae60;
}
.word-item.incorrect {
background: #ffebee;
border-color: #e74c3c;
}
.word-content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.word-index {
font-family: 'Roboto Mono', monospace;
font-size: 0.8125rem;
color: #7f8c8d;
min-width: 2.5rem;
}
.word-text {
font-size: 0.9375rem;
color: #2c3e50;
}
.word-timer {
font-family: 'Roboto Mono', monospace;
font-size: 1rem;
color: #34495e;
min-width: 4rem;
text-align: right;
}
.word-checkbox {
margin-left: 1rem;
}
.placeholder {
text-align: center;
padding: 3rem 1rem;
color: #95a5a6;
}
.small-text {
font-size: 0.8125rem;
color: #7f8c8d;
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.main-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.timer-display {
font-size: 3rem;
}
.btn-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="header">
@ -411,6 +73,36 @@
</div>
</div>
<!-- VR Headset Configuration -->
<div class="section">
<h2 class="section-title">VR Headset Configuration</h2>
<div class="form-group">
<label class="form-label">Server IP Address</label>
<input type="text" id="server-ip" class="form-input" placeholder="10.42.0.1">
<div class="small-text">IP address that VR headsets should connect to</div>
</div>
<div class="form-group">
<label class="form-label">Experiment Mode</label>
<select id="experiment-mode" class="form-input">
<option value="1;1;1;0">Dynamic Face</option>
<option value="0;0;0;1">Dynamic Hands</option>
<option value="1;1;1;1" selected>Dynamic Hands+Face</option>
<option value="1;0;0;0">Static Face</option>
<option value="0;0;0;1">Static Hands</option>
<option value="1;0;0;1">Static Hands+Face</option>
</select>
<div class="small-text">Controls which body parts are shown and interaction method</div>
</div>
<div class="btn-group">
<button id="btn-send-config" class="btn btn-warning">Send Configuration to Headsets</button>
</div>
<div id="config-status" class="status-message" style="display: none;"></div>
</div>
<!-- Word List -->
<div class="section">
<h2 class="section-title">Word List</h2>
@ -431,6 +123,10 @@
<div class="btn-group">
<button id="btn-stop" class="btn btn-danger" disabled>Stop Experiment</button>
<button id="btn-save" class="btn btn-primary" disabled>Save Results (CSV)</button>
<button id="btn-download-tracking" class="btn btn-primary">Download Tracking Data (CSV)</button>
</div>
<div class="small-text" style="margin-top: 1rem;">
Tracking data is automatically recorded during experiments and includes camera and controller positions/rotations at 60Hz.
</div>
</div>
@ -446,319 +142,6 @@
</div>
</div>
<script>
let runningWordIndex = -1;
let runningWordList = [];
let experimentStartTime = null;
let totalDurationMs = 0;
let frameId = undefined;
let last = undefined;
let newWord;
let lastWordStatus = -1;
let experimentRunning = false;
function updateButtonStates() {
document.getElementById("btn-stop").disabled = !experimentRunning;
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
document.getElementById("btn-start").disabled = experimentRunning;
document.getElementById("btn-shuffle").disabled = experimentRunning;
}
function createWordItems() {
const wordList = document.getElementById("word-list");
const text = wordList.value;
const display = document.getElementById("word-list-display");
display.innerHTML = "";
runningWordList = [];
runningWordIndex = -1;
const words = text.trim().split('\n').filter(w => w.trim());
words.forEach((word, index) => {
const div = document.createElement("div");
div.classList.add("word-item");
div.setAttribute("data-word", word.trim());
div.setAttribute("data-index", index);
const content = document.createElement("div");
content.classList.add("word-content");
const indexSpan = document.createElement("span");
indexSpan.classList.add("word-index");
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
const textSpan = document.createElement("span");
textSpan.classList.add("word-text");
textSpan.textContent = word.trim();
content.appendChild(indexSpan);
content.appendChild(textSpan);
const timer = document.createElement("div");
timer.classList.add("word-timer");
const timeSeconds = parseFloat(document.getElementById("time-s").value);
timer.textContent = timeSeconds.toFixed(1) + "s";
timer.setAttribute("data-remaining", timeSeconds);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.classList.add("word-checkbox");
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
div.classList.add("correct");
div.classList.remove("incorrect");
} else {
div.classList.remove("correct");
}
});
div.appendChild(content);
div.appendChild(timer);
div.appendChild(checkbox);
display.appendChild(div);
runningWordList.push(div);
});
}
function step(timestamp) {
if (last === undefined) {
last = timestamp;
experimentStartTime = timestamp;
runningWordIndex = 0;
newWord = true;
lastWordStatus = -1;
}
const elapsed = timestamp - last;
last = timestamp;
// Update total timer
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const totalElapsed = timestamp - experimentStartTime;
const remainingMs = totalDurationMs - totalElapsed;
const remainingMinutes = Math.floor(remainingMs / 60000);
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
timerDisplay.textContent =
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
if (remainingMs < 60000) {
timerDisplay.classList.add("danger");
} else if (remainingMs < 120000) {
timerDisplay.classList.add("warning");
}
if (totalElapsed >= totalDurationMs) {
stopExperiment();
alert("Experiment completed: Total duration reached");
return;
}
}
if (runningWordIndex >= runningWordList.length) {
stopExperiment();
return;
}
const currentWordDiv = runningWordList[runningWordIndex];
const timer = currentWordDiv.querySelector(".word-timer");
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
if (newWord) {
newWord = false;
const word = currentWordDiv.getAttribute("data-word");
sendNewWord(word, lastWordStatus, remainingTime);
// Remove active class from all
runningWordList.forEach(div => div.classList.remove("active"));
currentWordDiv.classList.add("active");
}
if (checkbox.checked || remainingTime < 0) {
if (remainingTime < 0) remainingTime = 0;
lastWordStatus = checkbox.checked ? 1 : 0;
timer.textContent = remainingTime.toFixed(1) + "s";
currentWordDiv.classList.remove("active");
if (checkbox.checked) {
currentWordDiv.classList.add("correct");
} else {
currentWordDiv.classList.add("incorrect");
}
runningWordIndex++;
newWord = true;
} else {
timer.textContent = remainingTime.toFixed(1) + "s";
timer.setAttribute("data-remaining", remainingTime);
}
if (runningWordIndex < runningWordList.length) {
frameId = requestAnimationFrame(step);
} else {
stopExperiment();
}
}
function stopExperiment() {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
experimentRunning = false;
updateButtonStates();
console.log("Experiment stopped");
}
function sendNewWord(word, lastWordStatus, timeSeconds) {
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: target,
lastWordStatus: lastWordStatus,
timeSeconds: timeSeconds,
word: word,
}),
});
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: targetOther,
lastWordStatus: lastWordStatus,
timeSeconds: 0.0,
word: "",
}),
});
}
// Event Listeners
document.getElementById("btn-start").addEventListener("click", () => {
const groupId = document.getElementById("group-id").value;
if (!groupId || !groupId.trim()) {
alert("Please enter a Group ID before starting.");
return;
}
const wordList = document.getElementById("word-list").value;
if (!wordList.trim()) {
alert("Please enter a word list before starting.");
return;
}
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
totalDurationMs = totalDurationMin * 60 * 1000;
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const minutes = Math.floor(totalDurationMs / 60000);
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
timerDisplay.textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
} else {
timerDisplay.textContent = "∞";
timerDisplay.className = "timer-display";
}
createWordItems();
if (frameId) cancelAnimationFrame(frameId);
last = undefined;
newWord = undefined;
experimentStartTime = null;
experimentRunning = true;
updateButtonStates();
frameId = requestAnimationFrame(step);
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min`);
});
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
document.getElementById("btn-save").addEventListener("click", () => {
if (runningWordList.length === 0) {
alert("No data to save. Please run the experiment first.");
return;
}
const groupId = document.getElementById("group-id").value || "group";
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
let data = "word;correct;time_left\n";
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
for (let i = 0; i < maxIndex; i++) {
const div = runningWordList[i];
const word = div.getAttribute("data-word");
const checkbox = div.querySelector('input[type="checkbox"]');
const timer = div.querySelector(".word-timer");
const isCorrect = checkbox.checked;
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
data += `${word};${isCorrect};${timeLeft}\n`;
}
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.href = url;
link.download = `${groupId}_results_${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
});
document.getElementById("btn-shuffle").addEventListener("click", async () => {
const wordList = document.getElementById("word-list");
const text = wordList.value.trim();
if (!text) {
alert("Please enter words to shuffle first.");
return;
}
const words = text.split('\n').map(word => word.trim()).filter(word => word);
try {
const response = await fetch("/shuffle", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ words: words }),
});
const result = await response.json();
if (result.status === "ok") {
wordList.value = result.shuffled_words.join('\n');
}
} catch (error) {
console.error("Error shuffling words:", error);
alert("Failed to shuffle words. Please try again.");
}
});
// Initialize button states
updateButtonStates();
</script>
<script src="/static/script.js"></script>
</body>
</html>

View File

@ -1,23 +1,9 @@
import socket
import threading
from datetime import datetime
from time import sleep
CONTROL_ADDR = ("127.0.0.1", 5001)
# TODO: Adjust the following addresses so they match the IP addresses of the
# VR headsets.
# In our case the IP addresses were:
# - for player 1: 10.42.0.38
# - for player 2: 10.42.0.72
#
# The ports are hardcoded to 5001 inside the Unity application, so you
# shouldn't change those.
#
# Note: For this to work the VR headsets must be connected to the same network
# as this server.
DEVICE1_ADDR = ("10.42.0.38", 5001)
DEVICE2_ADDR = ("10.42.0.72", 5001)
# Track connected devices dynamically
# No manual IP configuration needed!
connected_devices = {} # {ip: last_seen_timestamp}
sock_from_A = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_from_A.bind(("0.0.0.0", 5000))
@ -26,21 +12,36 @@ def forward(source_socket):
while True:
try:
data, addr = source_socket.recvfrom(1024 * 16)
target_ip = DEVICE1_ADDR[0] if addr[0] == DEVICE2_ADDR[0] else DEVICE2_ADDR[0]
label = "A→B" if addr == DEVICE1_ADDR else "B→A"
if addr != DEVICE1_ADDR and addr != DEVICE2_ADDR:
label = f"unknown {addr}"
sock_from_A.sendto(data, (target_ip, 5000))
timestamp = datetime.now().strftime("%H:%M:%S")
source_ip = addr[0]
timestamp = datetime.now()
# Register this device
connected_devices[source_ip] = timestamp
# Get list of other active devices (excluding localhost and source)
other_devices = [ip for ip in connected_devices.keys()
if ip != source_ip and not ip.startswith("127.")]
# Forward to all other connected devices
for target_ip in other_devices:
sock_from_A.sendto(data, (target_ip, 5000))
# Also forward to app.py tracking listener on port 5002
# Prepend source IP so app.py can identify which player sent the data
tagged_data = f"SOURCE_IP:{source_ip}|".encode('utf-8') + data
sock_from_A.sendto(tagged_data, ("127.0.0.1", 5002))
# Logging
#if next(counter) % 20 == 0:
# if addr[0] != DEVICE2_ADDR[0]:
# print(f"[{timestamp}] {label}: {data.decode()}")
# print('sent to ', (target_ip, 5000))
if len(other_devices) > 0:
label = f"{source_ip}{', '.join(other_devices)}"
else:
label = f"{source_ip} (no other devices connected)"
# Uncomment for verbose logging:
# print(f"[{timestamp.strftime('%H:%M:%S')}] {label}")
except Exception as e:
print(f"Fehler {label}: {e}")
print(f"Error in relay: {e}")
print("UDP Relay läuft. Strg+C zum Beenden.")
try:

View File

@ -0,0 +1,479 @@
// Global state variables
let runningWordIndex = -1;
let runningWordList = [];
let experimentStartTime = null;
let totalDurationMs = 0;
let frameId = undefined;
let last = undefined;
let newWord;
let lastWordStatus = -1;
let experimentRunning = false;
// Update button states based on experiment status
function updateButtonStates() {
document.getElementById("btn-stop").disabled = !experimentRunning;
document.getElementById("btn-save").disabled = experimentRunning || runningWordList.length === 0;
document.getElementById("btn-start").disabled = experimentRunning;
document.getElementById("btn-shuffle").disabled = experimentRunning;
}
// Create word items from word list
function createWordItems() {
const wordList = document.getElementById("word-list");
const text = wordList.value;
const display = document.getElementById("word-list-display");
display.innerHTML = "";
runningWordList = [];
runningWordIndex = -1;
const words = text.trim().split('\n').filter(w => w.trim());
words.forEach((word, index) => {
const div = document.createElement("div");
div.classList.add("word-item");
div.setAttribute("data-word", word.trim());
div.setAttribute("data-index", index);
const content = document.createElement("div");
content.classList.add("word-content");
const indexSpan = document.createElement("span");
indexSpan.classList.add("word-index");
indexSpan.textContent = `${(index + 1).toString().padStart(2, '0')}.`;
const textSpan = document.createElement("span");
textSpan.classList.add("word-text");
textSpan.textContent = word.trim();
content.appendChild(indexSpan);
content.appendChild(textSpan);
const timer = document.createElement("div");
timer.classList.add("word-timer");
const timeSeconds = parseFloat(document.getElementById("time-s").value);
timer.textContent = timeSeconds.toFixed(1) + "s";
timer.setAttribute("data-remaining", timeSeconds);
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.classList.add("word-checkbox");
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
div.classList.add("correct");
div.classList.remove("incorrect");
} else {
div.classList.remove("correct");
}
});
div.appendChild(content);
div.appendChild(timer);
div.appendChild(checkbox);
display.appendChild(div);
runningWordList.push(div);
});
}
// Main animation loop
function step(timestamp) {
if (last === undefined) {
last = timestamp;
experimentStartTime = timestamp;
runningWordIndex = 0;
newWord = true;
lastWordStatus = -1;
}
const elapsed = timestamp - last;
last = timestamp;
// Update total timer
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const totalElapsed = timestamp - experimentStartTime;
const remainingMs = totalDurationMs - totalElapsed;
const remainingMinutes = Math.floor(remainingMs / 60000);
const remainingSeconds = Math.floor((remainingMs % 60000) / 1000);
timerDisplay.textContent =
`${remainingMinutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
if (remainingMs < 60000) {
timerDisplay.classList.add("danger");
} else if (remainingMs < 120000) {
timerDisplay.classList.add("warning");
}
if (totalElapsed >= totalDurationMs) {
stopExperiment();
alert("Experiment completed: Total duration reached");
return;
}
}
if (runningWordIndex >= runningWordList.length) {
stopExperiment();
return;
}
const currentWordDiv = runningWordList[runningWordIndex];
const timer = currentWordDiv.querySelector(".word-timer");
const checkbox = currentWordDiv.querySelector('input[type="checkbox"]');
let remainingTime = parseFloat(timer.getAttribute("data-remaining")) - (elapsed / 1000);
if (newWord) {
newWord = false;
const word = currentWordDiv.getAttribute("data-word");
sendNewWord(word, lastWordStatus, remainingTime);
// Remove active class from all
runningWordList.forEach(div => div.classList.remove("active"));
currentWordDiv.classList.add("active");
}
if (checkbox.checked || remainingTime < 0) {
if (remainingTime < 0) remainingTime = 0;
lastWordStatus = checkbox.checked ? 1 : 0;
timer.textContent = remainingTime.toFixed(1) + "s";
currentWordDiv.classList.remove("active");
if (checkbox.checked) {
currentWordDiv.classList.add("correct");
} else {
currentWordDiv.classList.add("incorrect");
}
runningWordIndex++;
newWord = true;
} else {
timer.textContent = remainingTime.toFixed(1) + "s";
timer.setAttribute("data-remaining", remainingTime);
}
if (runningWordIndex < runningWordList.length) {
frameId = requestAnimationFrame(step);
} else {
stopExperiment();
}
}
// Stop the experiment
function stopExperiment() {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = undefined;
}
experimentRunning = false;
updateButtonStates();
// Stop tracking recording
stopTracking();
console.log("Experiment stopped");
}
// Start tracking recording
async function startTracking(groupId, condition, activePlayerIp) {
try {
const response = await fetch("/tracking/start", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
group_id: groupId,
condition: condition,
active_player_ip: activePlayerIp
}),
});
const result = await response.json();
console.log("Tracking started:", result.message);
} catch (error) {
console.error("Error starting tracking:", error);
}
}
// Stop tracking recording
async function stopTracking() {
try {
const response = await fetch("/tracking/stop", {
method: "POST",
});
const result = await response.json();
console.log("Tracking stopped:", result.message);
} catch (error) {
console.error("Error stopping tracking:", error);
}
}
// Download tracking data
async function downloadTracking() {
try {
const response = await fetch("/tracking/download");
if (response.ok) {
// Get filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition');
let filename = 'tracking_data.csv';
if (disposition && disposition.includes('filename=')) {
filename = disposition.split('filename=')[1].replace(/"/g, '');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log("Tracking data downloaded");
} else {
const result = await response.json();
alert("Error: " + (result.message || "Failed to download tracking data"));
}
} catch (error) {
console.error("Error downloading tracking:", error);
alert("Failed to download tracking data: " + error.message);
}
}
// Send word to VR headsets
function sendNewWord(word, lastWordStatus, timeSeconds) {
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const target = isPlayer1 ? ipPlayer1 : ipPlayer2;
const targetOther = isPlayer1 ? ipPlayer2 : ipPlayer1;
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: target,
lastWordStatus: lastWordStatus,
timeSeconds: timeSeconds,
word: word,
}),
});
fetch("/word", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
target: targetOther,
lastWordStatus: lastWordStatus,
timeSeconds: 0.0,
word: "",
}),
});
}
// Send VR headset configuration (IP and MODE)
async function sendVRConfig() {
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const mode = document.getElementById("experiment-mode").value;
const serverIp = document.getElementById("server-ip").value;
if (!ipPlayer1 || !ipPlayer2) {
showConfigStatus("Please enter both player IP addresses", "error");
return;
}
if (!serverIp) {
showConfigStatus("Please enter the server IP address", "error");
return;
}
try {
const response = await fetch("/send-config", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
player1_ip: ipPlayer1,
player2_ip: ipPlayer2,
server_ip: serverIp,
mode: mode,
}),
});
const result = await response.json();
if (result.status === "ok") {
showConfigStatus(`Configuration sent successfully! Mode: ${getModeDescription(mode)}`, "success");
} else {
showConfigStatus("Failed to send configuration", "error");
}
} catch (error) {
console.error("Error sending configuration:", error);
showConfigStatus("Error sending configuration: " + error.message, "error");
}
}
// Get human-readable mode description
function getModeDescription(mode) {
const modes = {
"1;1;1;0": "Dynamic Face",
"0;0;0;1": "Dynamic Hands",
"1;1;1;1": "Dynamic Hands+Face",
"1;0;0;0": "Static Face",
"0;0;0;1": "Static Hands",
"1;0;0;1": "Static Hands+Face"
};
return modes[mode] || mode;
}
// Show configuration status message
function showConfigStatus(message, type) {
const statusDiv = document.getElementById("config-status");
statusDiv.textContent = message;
statusDiv.className = `status-message ${type}`;
statusDiv.style.display = "block";
// Auto-hide after 5 seconds
setTimeout(() => {
statusDiv.style.display = "none";
}, 5000);
}
// Event Listeners
document.addEventListener("DOMContentLoaded", () => {
// Start Experiment
document.getElementById("btn-start").addEventListener("click", () => {
const groupId = document.getElementById("group-id").value;
if (!groupId || !groupId.trim()) {
alert("Please enter a Group ID before starting.");
return;
}
const wordList = document.getElementById("word-list").value;
if (!wordList.trim()) {
alert("Please enter a word list before starting.");
return;
}
const totalDurationMin = parseFloat(document.getElementById("total-duration").value) || 0;
totalDurationMs = totalDurationMin * 60 * 1000;
const timerDisplay = document.getElementById("timer-display");
if (totalDurationMs > 0) {
const minutes = Math.floor(totalDurationMs / 60000);
const seconds = Math.floor((totalDurationMs % 60000) / 1000);
timerDisplay.textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
timerDisplay.className = "timer-display";
} else {
timerDisplay.textContent = "∞";
timerDisplay.className = "timer-display";
}
createWordItems();
if (frameId) cancelAnimationFrame(frameId);
last = undefined;
newWord = undefined;
experimentStartTime = null;
experimentRunning = true;
updateButtonStates();
// Start tracking recording
const isPlayer1 = document.querySelector('input[name="active-player"]:checked').value === "player1";
const ipPlayer1 = document.getElementById("ip-player1").value;
const ipPlayer2 = document.getElementById("ip-player2").value;
const activePlayerIp = isPlayer1 ? ipPlayer1 : ipPlayer2;
const condition = document.getElementById("experiment-mode").value;
const conditionName = getModeDescription(condition);
startTracking(groupId, conditionName, activePlayerIp);
frameId = requestAnimationFrame(step);
console.log(`Experiment started: Group=${groupId}, Duration=${totalDurationMin}min, Condition=${conditionName}`);
});
// Stop Experiment
document.getElementById("btn-stop").addEventListener("click", stopExperiment);
// Save Results
document.getElementById("btn-save").addEventListener("click", () => {
if (runningWordList.length === 0) {
alert("No data to save. Please run the experiment first.");
return;
}
const groupId = document.getElementById("group-id").value || "group";
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
let data = "word;correct;time_left\n";
const maxIndex = runningWordIndex >= runningWordList.length ? runningWordList.length : runningWordIndex;
for (let i = 0; i < maxIndex; i++) {
const div = runningWordList[i];
const word = div.getAttribute("data-word");
const checkbox = div.querySelector('input[type="checkbox"]');
const timer = div.querySelector(".word-timer");
const isCorrect = checkbox.checked;
const timeLeft = parseFloat(timer.getAttribute("data-remaining"));
data += `${word};${isCorrect};${timeLeft}\n`;
}
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.href = url;
link.download = `${groupId}_results_${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log(`Saved ${maxIndex} out of ${runningWordList.length} words to CSV`);
});
// Shuffle Words
document.getElementById("btn-shuffle").addEventListener("click", async () => {
const wordList = document.getElementById("word-list");
const text = wordList.value.trim();
if (!text) {
alert("Please enter words to shuffle first.");
return;
}
const words = text.split('\n').map(word => word.trim()).filter(word => word);
try {
const response = await fetch("/shuffle", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ words: words }),
});
const result = await response.json();
if (result.status === "ok") {
wordList.value = result.shuffled_words.join('\n');
}
} catch (error) {
console.error("Error shuffling words:", error);
alert("Failed to shuffle words. Please try again.");
}
});
// Send VR Configuration
document.getElementById("btn-send-config").addEventListener("click", sendVRConfig);
// Download Tracking Data
document.getElementById("btn-download-tracking").addEventListener("click", downloadTracking);
// Initialize button states
updateButtonStates();
});

View File

@ -0,0 +1,365 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: #f5f5f5;
color: #2c3e50;
line-height: 1.5;
}
.header {
background: #2c3e50;
color: white;
padding: 1.5rem 2rem;
border-bottom: 3px solid #34495e;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
}
.header p {
font-size: 0.9rem;
color: #bdc3c7;
margin-top: 0.25rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.timer-section {
background: white;
border: 2px solid #34495e;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.timer-label {
font-size: 0.875rem;
color: #7f8c8d;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 0.5rem;
}
.timer-display {
font-family: 'Roboto Mono', monospace;
font-size: 4rem;
font-weight: 500;
color: #2c3e50;
}
.timer-display.warning {
color: #e67e22;
}
.timer-display.danger {
color: #e74c3c;
}
.timer-display.inactive {
color: #bdc3c7;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid #ecf0f1;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #34495e;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 3px;
font-size: 0.9375rem;
font-family: inherit;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3498db;
}
.form-input:disabled {
background: #f8f9fa;
color: #adb5bd;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.radio-group {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.radio-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.radio-label input {
cursor: pointer;
}
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 3px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3498db;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2980b9;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover:not(:disabled) {
background: #229954;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c0392b;
}
.btn-secondary {
background: #95a5a6;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #7f8c8d;
}
.btn-warning {
background: #f39c12;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #e67e22;
}
.btn-group {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.controls-section {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.controls-section .btn-group {
margin-top: 0;
}
textarea {
font-family: 'Roboto Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
resize: vertical;
}
.word-display {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1.5rem;
}
.word-list-container {
max-height: 500px;
overflow-y: auto;
margin-top: 1rem;
}
.word-item {
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 3px;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
transition: background 0.15s;
}
.word-item:hover {
background: #f0f0f0;
}
.word-item.active {
background: #e3f2fd;
border-color: #3498db;
}
.word-item.correct {
background: #e8f5e9;
border-color: #27ae60;
}
.word-item.incorrect {
background: #ffebee;
border-color: #e74c3c;
}
.word-content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.word-index {
font-family: 'Roboto Mono', monospace;
font-size: 0.8125rem;
color: #7f8c8d;
min-width: 2.5rem;
}
.word-text {
font-size: 0.9375rem;
color: #2c3e50;
}
.word-timer {
font-family: 'Roboto Mono', monospace;
font-size: 1rem;
color: #34495e;
min-width: 4rem;
text-align: right;
}
.word-checkbox {
margin-left: 1rem;
}
.placeholder {
text-align: center;
padding: 3rem 1rem;
color: #95a5a6;
}
.small-text {
font-size: 0.8125rem;
color: #7f8c8d;
margin-top: 0.25rem;
}
.status-message {
padding: 0.75rem 1rem;
border-radius: 3px;
margin-top: 1rem;
font-size: 0.875rem;
}
.status-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
@media (max-width: 768px) {
.main-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
.timer-display {
font-size: 3rem;
}
.btn-group {
flex-direction: column;
}
.btn {
width: 100%;
}
}