Spaces:
Build error
Build error
Upload app code and config
Browse files- .gitattributes +1 -35
- .gitignore +11 -0
- README.md +86 -6
- analyze_results.bat +33 -0
- analyze_results.py +438 -0
- app.py +2092 -0
- assets/instruction_diagram.svg +45 -0
- data/study_config.json +69 -0
- deploy_to_hf_space.bat +35 -0
- requirements.txt +5 -0
- study_utils.py +1353 -0
.gitattributes
CHANGED
|
@@ -1,35 +1 @@
|
|
| 1 |
-
*.
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 1 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
.gradio/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
*.pyo
|
| 6 |
+
*.tmp
|
| 7 |
+
|
| 8 |
+
results/
|
| 9 |
+
|
| 10 |
+
.DS_Store
|
| 11 |
+
Thumbs.db
|
README.md
CHANGED
|
@@ -1,12 +1,92 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AnyAct User Study
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: indigo
|
|
|
|
| 5 |
sdk: gradio
|
| 6 |
+
python_version: "3.10"
|
| 7 |
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# AnyAct User Study
|
| 12 |
+
|
| 13 |
+
This repository contains a self-contained Gradio questionnaire for pairwise human motion reenactment evaluation.
|
| 14 |
+
|
| 15 |
+
## Included Data
|
| 16 |
+
|
| 17 |
+
The Space-ready copy already bundles all 30 study cases inside this repository:
|
| 18 |
+
|
| 19 |
+
- `videos/reference`
|
| 20 |
+
- `videos/anyact`
|
| 21 |
+
- `videos/vlm_hy_motion`
|
| 22 |
+
- `videos/echomotion`
|
| 23 |
+
|
| 24 |
+
The app reads those files through `data/study_config.json`, so it no longer depends on directories outside the repository.
|
| 25 |
+
|
| 26 |
+
## Runtime Storage
|
| 27 |
+
|
| 28 |
+
By default, the app stores runtime files under:
|
| 29 |
+
|
| 30 |
+
- `results/responses.csv`
|
| 31 |
+
- `results/responses.jsonl`
|
| 32 |
+
- `results/participants`
|
| 33 |
+
- `results/plots`
|
| 34 |
+
|
| 35 |
+
For Hugging Face Spaces, you should mount persistent storage and set:
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
USER_STUDY_RESULTS_DIR=/data/user_study_results
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
If the app is running inside a Space and `/data` exists, it automatically falls back to `/data/user_study_results` even without that variable.
|
| 42 |
+
|
| 43 |
+
## Local Run
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
python -m venv .venv
|
| 47 |
+
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
|
| 48 |
+
.\.venv\Scripts\python.exe app.py --share
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
## Hugging Face Spaces Deployment
|
| 52 |
+
|
| 53 |
+
### Option 1: Browser Upload
|
| 54 |
+
|
| 55 |
+
1. Create a new Hugging Face Space.
|
| 56 |
+
2. Choose `Gradio` as the SDK.
|
| 57 |
+
3. Upload the contents of this repository.
|
| 58 |
+
4. In the Space settings, optionally add persistent storage and set `USER_STUDY_RESULTS_DIR=/data/user_study_results`.
|
| 59 |
+
|
| 60 |
+
### Option 2: CLI
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
hf auth login
|
| 64 |
+
deploy_to_hf_space.bat username/space-name
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
The helper script creates the Space if needed and uploads this repository while excluding `.venv`, `.gradio`, `__pycache__`, and `results`.
|
| 68 |
+
|
| 69 |
+
## Files That Should Not Be Uploaded As Runtime Artifacts
|
| 70 |
+
|
| 71 |
+
The repository includes `.gitignore` rules so that local environments and transient results are not pushed by mistake:
|
| 72 |
+
|
| 73 |
+
- `.venv`
|
| 74 |
+
- `.gradio`
|
| 75 |
+
- `__pycache__`
|
| 76 |
+
- `results`
|
| 77 |
+
|
| 78 |
+
## Analysis
|
| 79 |
+
|
| 80 |
+
To analyze collected responses:
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
.\.venv\Scripts\python.exe analyze_results.py
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
This writes:
|
| 87 |
+
|
| 88 |
+
- `summary.csv`
|
| 89 |
+
- `participant_overview.csv`
|
| 90 |
+
- `plots/preference_barplot.png`
|
| 91 |
+
|
| 92 |
+
inside the chosen results directory.
|
analyze_results.bat
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal
|
| 3 |
+
|
| 4 |
+
cd /d "%~dp0"
|
| 5 |
+
|
| 6 |
+
echo Running result analysis with the project virtual environment...
|
| 7 |
+
|
| 8 |
+
if not exist ".venv\Scripts\python.exe" (
|
| 9 |
+
echo [ERROR] Project virtual environment not found: .venv\Scripts\python.exe
|
| 10 |
+
echo Please create the virtual environment first or run the project setup again.
|
| 11 |
+
pause
|
| 12 |
+
exit /b 1
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
".venv\Scripts\python.exe" "analyze_results.py"
|
| 16 |
+
if errorlevel 1 (
|
| 17 |
+
echo.
|
| 18 |
+
echo Analysis failed.
|
| 19 |
+
pause
|
| 20 |
+
exit /b 1
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
echo.
|
| 24 |
+
echo Opening generated outputs...
|
| 25 |
+
|
| 26 |
+
if exist "results\summary.csv" start "" "results\summary.csv"
|
| 27 |
+
if exist "results\participant_overview.csv" start "" "results\participant_overview.csv"
|
| 28 |
+
if exist "results\plots\preference_barplot.png" start "" "results\plots\preference_barplot.png"
|
| 29 |
+
if exist "results" start "" explorer "results"
|
| 30 |
+
|
| 31 |
+
echo.
|
| 32 |
+
echo Analysis completed successfully.
|
| 33 |
+
pause
|
analyze_results.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Dict, List
|
| 7 |
+
|
| 8 |
+
import matplotlib.pyplot as plt
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from study_utils import CSV_COLUMNS, get_results_dir, load_study_config
|
| 12 |
+
|
| 13 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 14 |
+
|
| 15 |
+
METRIC_COLUMNS = {
|
| 16 |
+
"similarity": "answer_similarity",
|
| 17 |
+
"quality": "answer_quality",
|
| 18 |
+
"overall_preference": "answer_preference",
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
METRIC_LABELS = {
|
| 22 |
+
"similarity": "Motion Similarity",
|
| 23 |
+
"quality": "Motion Quality",
|
| 24 |
+
"overall_preference": "Overall Preference",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
METRIC_COLORS = {
|
| 28 |
+
"similarity": "#cfeedd",
|
| 29 |
+
"quality": "#cfe8f5",
|
| 30 |
+
"overall_preference": "#ddd0ee",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
METHOD_COLORS = {
|
| 34 |
+
"anyact": "#7ea6e0",
|
| 35 |
+
"vlm_hy_motion": "#f2c27b",
|
| 36 |
+
"echomotion": "#9fd0b0",
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def parse_args() -> argparse.Namespace:
|
| 41 |
+
parser = argparse.ArgumentParser(description="Analyze pairwise user study results.")
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--config",
|
| 44 |
+
type=Path,
|
| 45 |
+
default=PROJECT_ROOT / "data" / "study_config.json",
|
| 46 |
+
help="Path to study_config.json.",
|
| 47 |
+
)
|
| 48 |
+
parser.add_argument(
|
| 49 |
+
"--input",
|
| 50 |
+
type=Path,
|
| 51 |
+
default=None,
|
| 52 |
+
help="Optional path to responses.csv or responses.jsonl. Defaults to results/responses.csv if available.",
|
| 53 |
+
)
|
| 54 |
+
parser.add_argument(
|
| 55 |
+
"--results-dir",
|
| 56 |
+
type=Path,
|
| 57 |
+
default=get_results_dir(PROJECT_ROOT),
|
| 58 |
+
help="Directory that stores responses and analysis outputs.",
|
| 59 |
+
)
|
| 60 |
+
parser.add_argument(
|
| 61 |
+
"--study-id",
|
| 62 |
+
type=str,
|
| 63 |
+
default=None,
|
| 64 |
+
help="Optional study_id to analyze. Defaults to the study_id in study_config.json.",
|
| 65 |
+
)
|
| 66 |
+
parser.add_argument(
|
| 67 |
+
"--include-incomplete",
|
| 68 |
+
action="store_true",
|
| 69 |
+
help="Include participants who have not answered every question in the selected study.",
|
| 70 |
+
)
|
| 71 |
+
return parser.parse_args()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def load_canonical_dataframe(input_path: Path | None, results_dir: Path) -> pd.DataFrame:
|
| 75 |
+
if input_path is None:
|
| 76 |
+
csv_path = results_dir / "responses.csv"
|
| 77 |
+
jsonl_path = results_dir / "responses.jsonl"
|
| 78 |
+
else:
|
| 79 |
+
csv_path = input_path if input_path.suffix.lower() == ".csv" else None
|
| 80 |
+
jsonl_path = input_path if input_path.suffix.lower() == ".jsonl" else None
|
| 81 |
+
|
| 82 |
+
latest_records: Dict[tuple[str, str, str], tuple[tuple[str, str, str], Dict[str, object]]] = {}
|
| 83 |
+
|
| 84 |
+
if jsonl_path and jsonl_path.exists() and jsonl_path.stat().st_size > 0:
|
| 85 |
+
with jsonl_path.open("r", encoding="utf-8") as handle:
|
| 86 |
+
for line in handle:
|
| 87 |
+
if not line.strip():
|
| 88 |
+
continue
|
| 89 |
+
try:
|
| 90 |
+
record = json.loads(line)
|
| 91 |
+
except json.JSONDecodeError:
|
| 92 |
+
continue
|
| 93 |
+
if "participant_id" not in record or "question_id" not in record:
|
| 94 |
+
continue
|
| 95 |
+
key = (
|
| 96 |
+
str(record.get("study_id", "")),
|
| 97 |
+
str(record["participant_id"]),
|
| 98 |
+
str(record["question_id"]),
|
| 99 |
+
)
|
| 100 |
+
sort_key = (
|
| 101 |
+
str(record.get("event_saved_at") or record.get("answered_at") or ""),
|
| 102 |
+
str(record.get("updated_at") or ""),
|
| 103 |
+
str(record.get("answered_at") or ""),
|
| 104 |
+
)
|
| 105 |
+
canonical_row = {column: record.get(column, "") for column in CSV_COLUMNS}
|
| 106 |
+
previous = latest_records.get(key)
|
| 107 |
+
if previous is None or sort_key >= previous[0]:
|
| 108 |
+
latest_records[key] = (sort_key, canonical_row)
|
| 109 |
+
|
| 110 |
+
if csv_path and csv_path.exists() and csv_path.stat().st_size > 0:
|
| 111 |
+
df = pd.read_csv(csv_path)
|
| 112 |
+
if not df.empty:
|
| 113 |
+
for _, row in df.iterrows():
|
| 114 |
+
record = row.to_dict()
|
| 115 |
+
if "participant_id" not in record or "question_id" not in record:
|
| 116 |
+
continue
|
| 117 |
+
key = (
|
| 118 |
+
str(record.get("study_id", "")),
|
| 119 |
+
str(record["participant_id"]),
|
| 120 |
+
str(record["question_id"]),
|
| 121 |
+
)
|
| 122 |
+
sort_key = (
|
| 123 |
+
str(record.get("event_saved_at") or record.get("answered_at") or ""),
|
| 124 |
+
str(record.get("updated_at") or ""),
|
| 125 |
+
str(record.get("answered_at") or ""),
|
| 126 |
+
)
|
| 127 |
+
canonical_row = {column: record.get(column, "") for column in CSV_COLUMNS}
|
| 128 |
+
previous = latest_records.get(key)
|
| 129 |
+
if previous is None or sort_key >= previous[0]:
|
| 130 |
+
latest_records[key] = (sort_key, canonical_row)
|
| 131 |
+
|
| 132 |
+
if latest_records:
|
| 133 |
+
canonical_df = pd.DataFrame(row for _, row in latest_records.values())
|
| 134 |
+
if not canonical_df.empty:
|
| 135 |
+
return canonical_df
|
| 136 |
+
|
| 137 |
+
raise FileNotFoundError(
|
| 138 |
+
"No response file was found. Please ensure results/responses.csv or results/responses.jsonl exists."
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def normalize_dataframe(df: pd.DataFrame) -> pd.DataFrame:
|
| 143 |
+
df = df.copy()
|
| 144 |
+
for column in ["study_id", "participant_id", "question_id", "left_method", "right_method", "result_a_method", "result_b_method"]:
|
| 145 |
+
if column in df.columns:
|
| 146 |
+
df[column] = df[column].astype(str)
|
| 147 |
+
for column in METRIC_COLUMNS.values():
|
| 148 |
+
if column in df.columns:
|
| 149 |
+
df[column] = df[column].fillna("").astype(str).str.strip().str.lower()
|
| 150 |
+
if "question_position" in df.columns:
|
| 151 |
+
df["question_position"] = pd.to_numeric(df["question_position"], errors="coerce")
|
| 152 |
+
if "total_questions" in df.columns:
|
| 153 |
+
df["total_questions"] = pd.to_numeric(df["total_questions"], errors="coerce")
|
| 154 |
+
if "answered_at" in df.columns:
|
| 155 |
+
df["answered_at"] = pd.to_datetime(df["answered_at"], errors="coerce")
|
| 156 |
+
return df
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def normalize_choice_for_analysis(raw_value: object) -> str:
|
| 160 |
+
if pd.isna(raw_value):
|
| 161 |
+
return ""
|
| 162 |
+
cleaned = str(raw_value or "").strip().lower()
|
| 163 |
+
compact = cleaned.replace(" ", "").replace("_", "").replace("-", "")
|
| 164 |
+
if compact in {"", "nan", "none"}:
|
| 165 |
+
return ""
|
| 166 |
+
if compact in {"left", "resulta", "a"}:
|
| 167 |
+
return "resulta"
|
| 168 |
+
if compact in {"right", "resultb", "b"}:
|
| 169 |
+
return "resultb"
|
| 170 |
+
if compact in {"tie", "equal", "same"}:
|
| 171 |
+
return "tie"
|
| 172 |
+
return cleaned
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def filter_to_selected_study(df: pd.DataFrame, study_id: str) -> pd.DataFrame:
|
| 176 |
+
if "study_id" not in df.columns:
|
| 177 |
+
return df
|
| 178 |
+
filtered = df[df["study_id"].astype(str) == study_id].copy()
|
| 179 |
+
if filtered.empty:
|
| 180 |
+
raise ValueError(
|
| 181 |
+
f"No responses were found for study_id='{study_id}'. "
|
| 182 |
+
"Please check your study_config.json or pass --study-id explicitly."
|
| 183 |
+
)
|
| 184 |
+
return filtered
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def filter_to_completed_participants(df: pd.DataFrame) -> pd.DataFrame:
|
| 188 |
+
"""
|
| 189 |
+
Keep only participants who answered every question in the selected study.
|
| 190 |
+
This avoids mixing partial pilot sessions into the final analysis.
|
| 191 |
+
"""
|
| 192 |
+
rows: List[pd.DataFrame] = []
|
| 193 |
+
|
| 194 |
+
for participant_id, participant_df in df.groupby("participant_id", sort=False):
|
| 195 |
+
expected_questions = int(participant_df["total_questions"].dropna().max())
|
| 196 |
+
answered_questions = participant_df["question_id"].nunique()
|
| 197 |
+
if answered_questions >= expected_questions > 0:
|
| 198 |
+
rows.append(participant_df)
|
| 199 |
+
|
| 200 |
+
if not rows:
|
| 201 |
+
raise ValueError(
|
| 202 |
+
"No completed participants were found for the selected study. "
|
| 203 |
+
"Use --include-incomplete if you want to analyze partial responses."
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
return pd.concat(rows, ignore_index=True)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def compute_method_percentages(df: pd.DataFrame, config: dict) -> pd.DataFrame:
|
| 210 |
+
rows: List[dict] = []
|
| 211 |
+
configured_pairs = [tuple(pair) for pair in config.get("pair_order", [])]
|
| 212 |
+
|
| 213 |
+
for pair in configured_pairs:
|
| 214 |
+
method_a, method_b = pair
|
| 215 |
+
pair_id = f"{method_a}_vs_{method_b}"
|
| 216 |
+
pair_df = df[df["pair_id"].astype(str) == pair_id].copy()
|
| 217 |
+
if pair_df.empty:
|
| 218 |
+
continue
|
| 219 |
+
|
| 220 |
+
pair_method_ids = [method_a, method_b]
|
| 221 |
+
pair_label = (
|
| 222 |
+
f"{config['methods'][method_a]['display_name']} vs "
|
| 223 |
+
f"{config['methods'][method_b]['display_name']}"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
for metric_name, column_name in METRIC_COLUMNS.items():
|
| 227 |
+
selected_credit = {method_id: 0.0 for method_id in pair_method_ids}
|
| 228 |
+
appearances = {method_id: 0 for method_id in pair_method_ids}
|
| 229 |
+
|
| 230 |
+
for _, row in pair_df.iterrows():
|
| 231 |
+
result_a_method = str(row.get("result_a_method") or "").strip()
|
| 232 |
+
if not result_a_method or result_a_method.lower() == "nan":
|
| 233 |
+
result_a_method = str(row.get("left_method") or "").strip()
|
| 234 |
+
|
| 235 |
+
result_b_method = str(row.get("result_b_method") or "").strip()
|
| 236 |
+
if not result_b_method or result_b_method.lower() == "nan":
|
| 237 |
+
result_b_method = str(row.get("right_method") or "").strip()
|
| 238 |
+
|
| 239 |
+
selected_method = str(row.get(f"{column_name}_method", "") or "").strip()
|
| 240 |
+
if selected_method.lower() == "nan":
|
| 241 |
+
selected_method = ""
|
| 242 |
+
answer = normalize_choice_for_analysis(row.get(column_name, ""))
|
| 243 |
+
|
| 244 |
+
if result_a_method not in selected_credit or result_b_method not in selected_credit:
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
appearances[result_a_method] += 1
|
| 248 |
+
appearances[result_b_method] += 1
|
| 249 |
+
|
| 250 |
+
if selected_method in selected_credit:
|
| 251 |
+
selected_credit[selected_method] += 1.0
|
| 252 |
+
elif answer == "left" or answer == "resulta":
|
| 253 |
+
selected_credit[result_a_method] += 1.0
|
| 254 |
+
elif answer == "right" or answer == "resultb":
|
| 255 |
+
selected_credit[result_b_method] += 1.0
|
| 256 |
+
elif answer == "tie":
|
| 257 |
+
selected_credit[result_a_method] += 0.5
|
| 258 |
+
selected_credit[result_b_method] += 0.5
|
| 259 |
+
|
| 260 |
+
for method_id in pair_method_ids:
|
| 261 |
+
denominator = appearances[method_id]
|
| 262 |
+
percentage = (selected_credit[method_id] / denominator * 100.0) if denominator else 0.0
|
| 263 |
+
rows.append(
|
| 264 |
+
{
|
| 265 |
+
"pair_id": pair_id,
|
| 266 |
+
"pair_label": pair_label,
|
| 267 |
+
"metric": metric_name,
|
| 268 |
+
"metric_label": METRIC_LABELS[metric_name],
|
| 269 |
+
"method_id": method_id,
|
| 270 |
+
"method_name": config["methods"][method_id]["display_name"],
|
| 271 |
+
"selected_credit": round(selected_credit[method_id], 4),
|
| 272 |
+
"appearances": denominator,
|
| 273 |
+
"selected_percentage": round(percentage, 4),
|
| 274 |
+
}
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
return pd.DataFrame(rows)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def build_analysis_overview(df: pd.DataFrame, study_id: str) -> pd.DataFrame:
|
| 281 |
+
participant_stats = (
|
| 282 |
+
df.groupby("participant_id")
|
| 283 |
+
.agg(
|
| 284 |
+
answered_questions=("question_id", "nunique"),
|
| 285 |
+
total_questions=("total_questions", "max"),
|
| 286 |
+
first_answered_at=("answered_at", "min"),
|
| 287 |
+
last_answered_at=("answered_at", "max"),
|
| 288 |
+
)
|
| 289 |
+
.reset_index()
|
| 290 |
+
)
|
| 291 |
+
participant_stats.insert(1, "study_id", study_id)
|
| 292 |
+
return participant_stats
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def plot_summary(summary_df: pd.DataFrame, plot_path: Path, config: dict) -> None:
|
| 296 |
+
metric_order = ["similarity", "quality", "overall_preference"]
|
| 297 |
+
metric_positions = list(range(len(metric_order)))
|
| 298 |
+
pair_order = [tuple(pair) for pair in config.get("pair_order", [])]
|
| 299 |
+
|
| 300 |
+
plot_specs: List[dict] = []
|
| 301 |
+
for pair_index, pair in enumerate(pair_order):
|
| 302 |
+
method_a, method_b = pair
|
| 303 |
+
pair_id = f"{method_a}_vs_{method_b}"
|
| 304 |
+
method_a_name = config["methods"][method_a]["display_name"]
|
| 305 |
+
method_b_name = config["methods"][method_b]["display_name"]
|
| 306 |
+
plot_specs.append(
|
| 307 |
+
{
|
| 308 |
+
"pair_id": pair_id,
|
| 309 |
+
"method_id": method_a,
|
| 310 |
+
"legend_label": f"{method_a_name} (vs {method_b_name})",
|
| 311 |
+
"color": METHOD_COLORS.get(method_a, "#cbd5e1"),
|
| 312 |
+
"hatch": None if pair_index == 0 else "//",
|
| 313 |
+
}
|
| 314 |
+
)
|
| 315 |
+
plot_specs.append(
|
| 316 |
+
{
|
| 317 |
+
"pair_id": pair_id,
|
| 318 |
+
"method_id": method_b,
|
| 319 |
+
"legend_label": method_b_name,
|
| 320 |
+
"color": METHOD_COLORS.get(method_b, "#cbd5e1"),
|
| 321 |
+
"hatch": None,
|
| 322 |
+
}
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
bar_width = 0.16
|
| 326 |
+
if plot_specs:
|
| 327 |
+
center = (len(plot_specs) - 1) / 2
|
| 328 |
+
offsets = [(index - center) * 0.18 for index in range(len(plot_specs))]
|
| 329 |
+
else:
|
| 330 |
+
offsets = []
|
| 331 |
+
|
| 332 |
+
fig, ax = plt.subplots(figsize=(5.4, 3.5), dpi=300)
|
| 333 |
+
fig.patch.set_facecolor("white")
|
| 334 |
+
ax.set_facecolor("white")
|
| 335 |
+
|
| 336 |
+
for spec_index, spec in enumerate(plot_specs):
|
| 337 |
+
method_summary = (
|
| 338 |
+
summary_df[
|
| 339 |
+
(summary_df["pair_id"] == spec["pair_id"])
|
| 340 |
+
& (summary_df["method_id"] == spec["method_id"])
|
| 341 |
+
].set_index("metric")
|
| 342 |
+
.reindex(metric_order)
|
| 343 |
+
)
|
| 344 |
+
positions = [center + offsets[spec_index] for center in metric_positions]
|
| 345 |
+
values = method_summary["selected_percentage"].tolist()
|
| 346 |
+
bars = ax.bar(
|
| 347 |
+
positions,
|
| 348 |
+
values,
|
| 349 |
+
width=bar_width * 0.92,
|
| 350 |
+
label=spec["legend_label"],
|
| 351 |
+
color=spec["color"],
|
| 352 |
+
edgecolor="white",
|
| 353 |
+
linewidth=0.6,
|
| 354 |
+
hatch=spec["hatch"],
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
for bar, value in zip(bars, values):
|
| 358 |
+
ax.text(
|
| 359 |
+
bar.get_x() + bar.get_width() / 2,
|
| 360 |
+
value + 1.4,
|
| 361 |
+
f"{value:.1f}%",
|
| 362 |
+
va="bottom",
|
| 363 |
+
ha="center",
|
| 364 |
+
fontsize=8,
|
| 365 |
+
color="#334155",
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
ax.set_xticks(metric_positions)
|
| 369 |
+
ax.set_xticklabels([METRIC_LABELS[metric_name] for metric_name in metric_order], fontsize=8)
|
| 370 |
+
ax.set_ylabel("Selection Rate When Shown (%)", fontsize=8)
|
| 371 |
+
|
| 372 |
+
x_max = max(100.0, float(summary_df["selected_percentage"].max()) + 8.0)
|
| 373 |
+
ax.set_ylim(0, x_max)
|
| 374 |
+
ax.tick_params(axis="y", labelsize=8)
|
| 375 |
+
ax.tick_params(axis="x", length=0)
|
| 376 |
+
ax.grid(axis="y", color="#edf2f7", linewidth=0.8)
|
| 377 |
+
ax.set_axisbelow(True)
|
| 378 |
+
ax.spines["top"].set_visible(False)
|
| 379 |
+
ax.spines["right"].set_visible(False)
|
| 380 |
+
ax.legend(
|
| 381 |
+
frameon=False,
|
| 382 |
+
fontsize=7.0,
|
| 383 |
+
loc="upper center",
|
| 384 |
+
bbox_to_anchor=(0.5, 1.12),
|
| 385 |
+
ncol=2,
|
| 386 |
+
handlelength=1.2,
|
| 387 |
+
columnspacing=1.2,
|
| 388 |
+
)
|
| 389 |
+
plt.tight_layout(pad=0.7)
|
| 390 |
+
|
| 391 |
+
plot_path.parent.mkdir(parents=True, exist_ok=True)
|
| 392 |
+
fig.savefig(plot_path, bbox_inches="tight", dpi=300)
|
| 393 |
+
pdf_path = plot_path.with_suffix(".pdf")
|
| 394 |
+
fig.savefig(pdf_path, bbox_inches="tight")
|
| 395 |
+
plt.close(fig)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def main() -> None:
|
| 399 |
+
args = parse_args()
|
| 400 |
+
config = load_study_config(args.config)
|
| 401 |
+
study_id = args.study_id or config["study_id"]
|
| 402 |
+
results_dir = args.results_dir.resolve()
|
| 403 |
+
results_dir.mkdir(parents=True, exist_ok=True)
|
| 404 |
+
(results_dir / "plots").mkdir(parents=True, exist_ok=True)
|
| 405 |
+
|
| 406 |
+
df = load_canonical_dataframe(args.input, results_dir)
|
| 407 |
+
df = normalize_dataframe(df)
|
| 408 |
+
if df.empty:
|
| 409 |
+
raise ValueError("The response table is empty. No analysis output was generated.")
|
| 410 |
+
|
| 411 |
+
df = filter_to_selected_study(df, study_id)
|
| 412 |
+
if not args.include_incomplete:
|
| 413 |
+
df = filter_to_completed_participants(df)
|
| 414 |
+
|
| 415 |
+
summary_df = compute_method_percentages(df, config)
|
| 416 |
+
overview_df = build_analysis_overview(df, study_id)
|
| 417 |
+
|
| 418 |
+
summary_path = results_dir / "summary.csv"
|
| 419 |
+
overview_path = results_dir / "participant_overview.csv"
|
| 420 |
+
plot_path = results_dir / "plots" / "preference_barplot.png"
|
| 421 |
+
|
| 422 |
+
summary_df.to_csv(summary_path, index=False)
|
| 423 |
+
overview_df.to_csv(overview_path, index=False)
|
| 424 |
+
plot_summary(summary_df, plot_path, config)
|
| 425 |
+
|
| 426 |
+
participant_count = overview_df["participant_id"].nunique()
|
| 427 |
+
response_count = df["question_id"].nunique()
|
| 428 |
+
|
| 429 |
+
print(f"Study ID: {study_id}")
|
| 430 |
+
print(f"Participants analyzed: {participant_count}")
|
| 431 |
+
print(f"Unique questions covered: {response_count}")
|
| 432 |
+
print(f"Saved summary table to: {summary_path}")
|
| 433 |
+
print(f"Saved participant overview to: {overview_path}")
|
| 434 |
+
print(f"Saved plot to: {plot_path}")
|
| 435 |
+
|
| 436 |
+
|
| 437 |
+
if __name__ == "__main__":
|
| 438 |
+
main()
|
app.py
ADDED
|
@@ -0,0 +1,2092 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import base64
|
| 5 |
+
import html
|
| 6 |
+
import os
|
| 7 |
+
import re
|
| 8 |
+
import shutil
|
| 9 |
+
import subprocess
|
| 10 |
+
import warnings
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Tuple
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
from gradio import processing_utils as gr_processing_utils
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
import imageio_ffmpeg
|
| 19 |
+
except ImportError: # pragma: no cover - optional runtime dependency
|
| 20 |
+
imageio_ffmpeg = None
|
| 21 |
+
|
| 22 |
+
from study_utils import (
|
| 23 |
+
CHOICE_OPTIONS,
|
| 24 |
+
build_completion_markdown,
|
| 25 |
+
build_question_payload,
|
| 26 |
+
create_or_resume_participant,
|
| 27 |
+
ensure_runtime_dirs,
|
| 28 |
+
ensure_video_thumbnail,
|
| 29 |
+
generate_participant_id,
|
| 30 |
+
get_instruction_case,
|
| 31 |
+
get_results_dir,
|
| 32 |
+
load_study_config,
|
| 33 |
+
move_question_pointer,
|
| 34 |
+
prepare_reference_videos_for_web,
|
| 35 |
+
sanitize_participant_id,
|
| 36 |
+
save_current_answer,
|
| 37 |
+
ensure_synchronized_study_videos,
|
| 38 |
+
upgrade_existing_results_schema,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def default_server_name() -> str:
|
| 45 |
+
return "0.0.0.0" if os.environ.get("SPACE_ID") else "127.0.0.1"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def ensure_local_ffmpeg() -> None:
|
| 49 |
+
if imageio_ffmpeg is None:
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
ffmpeg_source = Path(imageio_ffmpeg.get_ffmpeg_exe()).resolve()
|
| 53 |
+
runtime_bin = get_results_dir(PROJECT_ROOT) / "runtime_bin"
|
| 54 |
+
runtime_bin.mkdir(parents=True, exist_ok=True)
|
| 55 |
+
ffmpeg_target = runtime_bin / "ffmpeg.exe"
|
| 56 |
+
|
| 57 |
+
if not ffmpeg_target.exists():
|
| 58 |
+
shutil.copy2(ffmpeg_source, ffmpeg_target)
|
| 59 |
+
|
| 60 |
+
os.environ["IMAGEIO_FFMPEG_EXE"] = str(ffmpeg_target)
|
| 61 |
+
current_path = os.environ.get("PATH", "")
|
| 62 |
+
runtime_bin_str = str(runtime_bin)
|
| 63 |
+
if runtime_bin_str.lower() not in current_path.lower():
|
| 64 |
+
os.environ["PATH"] = runtime_bin_str + os.pathsep + current_path
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _probe_video_codec_with_ffmpeg(video_path: str | Path) -> tuple[str, str]:
|
| 68 |
+
if imageio_ffmpeg is None:
|
| 69 |
+
return "", ""
|
| 70 |
+
|
| 71 |
+
path = Path(video_path)
|
| 72 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 73 |
+
result = subprocess.run(
|
| 74 |
+
[ffmpeg_exe, "-i", str(path)],
|
| 75 |
+
capture_output=True,
|
| 76 |
+
text=True,
|
| 77 |
+
encoding="utf-8",
|
| 78 |
+
errors="ignore",
|
| 79 |
+
)
|
| 80 |
+
probe_text = result.stderr or ""
|
| 81 |
+
codec_match = re.search(r"Video:\s*([^\s,(]+)", probe_text)
|
| 82 |
+
codec_name = (codec_match.group(1) if codec_match else "").strip().lower()
|
| 83 |
+
return path.suffix.lower(), codec_name
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def patched_video_is_playable(video_filepath: str) -> bool:
|
| 87 |
+
"""
|
| 88 |
+
Avoid Gradio's hard dependency on ffprobe by checking playability
|
| 89 |
+
with the bundled imageio-ffmpeg binary instead.
|
| 90 |
+
"""
|
| 91 |
+
try:
|
| 92 |
+
container, video_codec = _probe_video_codec_with_ffmpeg(video_filepath)
|
| 93 |
+
return (container, video_codec) in {
|
| 94 |
+
(".mp4", "h264"),
|
| 95 |
+
(".mp4", "av1"),
|
| 96 |
+
(".ogg", "theora"),
|
| 97 |
+
(".webm", "vp9"),
|
| 98 |
+
(".webm", "vp8"),
|
| 99 |
+
(".webm", "av1"),
|
| 100 |
+
}
|
| 101 |
+
except Exception:
|
| 102 |
+
return True
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def patch_gradio_video_probe() -> None:
|
| 106 |
+
gr_processing_utils.video_is_playable = patched_video_is_playable
|
| 107 |
+
|
| 108 |
+
warnings.filterwarnings(
|
| 109 |
+
"ignore",
|
| 110 |
+
message=r"The 'css' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*",
|
| 111 |
+
category=DeprecationWarning,
|
| 112 |
+
)
|
| 113 |
+
warnings.filterwarnings(
|
| 114 |
+
"ignore",
|
| 115 |
+
message=r"The 'theme' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*",
|
| 116 |
+
category=DeprecationWarning,
|
| 117 |
+
)
|
| 118 |
+
warnings.filterwarnings(
|
| 119 |
+
"ignore",
|
| 120 |
+
message=r"The 'head' parameter in the Blocks constructor will be removed in Gradio 6\.0\..*",
|
| 121 |
+
category=DeprecationWarning,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
CUSTOM_HEAD = """
|
| 125 |
+
<script>
|
| 126 |
+
function anyactForceLightTheme() {
|
| 127 |
+
document.documentElement.classList.remove("dark");
|
| 128 |
+
document.body.classList.remove("dark");
|
| 129 |
+
document.documentElement.setAttribute("data-theme", "light");
|
| 130 |
+
document.body.setAttribute("data-theme", "light");
|
| 131 |
+
document.documentElement.style.colorScheme = "light";
|
| 132 |
+
document.body.style.colorScheme = "light";
|
| 133 |
+
try {
|
| 134 |
+
localStorage.setItem("theme", "light");
|
| 135 |
+
localStorage.setItem("gradio-theme", "light");
|
| 136 |
+
localStorage.setItem("gradio_mode", "light");
|
| 137 |
+
} catch (error) {}
|
| 138 |
+
document.querySelectorAll(".dark").forEach((node) => node.classList.remove("dark"));
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function anyactForcePlayVideos() {
|
| 142 |
+
const videos = document.querySelectorAll("video");
|
| 143 |
+
videos.forEach((video) => {
|
| 144 |
+
if (!video) return;
|
| 145 |
+
const attemptPlay = () => video.play().catch(() => {
|
| 146 |
+
video.muted = true;
|
| 147 |
+
video.play().catch(() => {});
|
| 148 |
+
});
|
| 149 |
+
if (video.readyState >= 2) {
|
| 150 |
+
attemptPlay();
|
| 151 |
+
} else {
|
| 152 |
+
video.addEventListener("loadeddata", attemptPlay, { once: true });
|
| 153 |
+
}
|
| 154 |
+
});
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function anyactGetStudyVideoElement(elemId) {
|
| 158 |
+
const root = document.getElementById(elemId);
|
| 159 |
+
return root ? root.querySelector("video") : null;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function anyactSetupStudyVideoSync() {
|
| 163 |
+
const reference = anyactGetStudyVideoElement("study-reference-video");
|
| 164 |
+
const left = anyactGetStudyVideoElement("study-left-video");
|
| 165 |
+
const right = anyactGetStudyVideoElement("study-right-video");
|
| 166 |
+
|
| 167 |
+
if (!reference || !left || !right) {
|
| 168 |
+
if (window.__anyactStudySync && typeof window.__anyactStudySync.cleanup === "function") {
|
| 169 |
+
window.__anyactStudySync.cleanup();
|
| 170 |
+
}
|
| 171 |
+
window.__anyactStudySync = null;
|
| 172 |
+
return;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const trio = [reference, left, right];
|
| 176 |
+
const signature = trio.map((video) => video.currentSrc || video.src || "").join("|");
|
| 177 |
+
if (window.__anyactStudySync && window.__anyactStudySync.signature === signature) {
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (window.__anyactStudySync && typeof window.__anyactStudySync.cleanup === "function") {
|
| 182 |
+
window.__anyactStudySync.cleanup();
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
let suppressEvents = false;
|
| 186 |
+
let lastReferenceTime = 0;
|
| 187 |
+
const cleanupFns = [];
|
| 188 |
+
|
| 189 |
+
const withSuppressedEvents = (fn) => {
|
| 190 |
+
suppressEvents = true;
|
| 191 |
+
try {
|
| 192 |
+
fn();
|
| 193 |
+
} finally {
|
| 194 |
+
clearTimeout(window.__anyactStudySyncSuppressTimer);
|
| 195 |
+
window.__anyactStudySyncSuppressTimer = setTimeout(() => {
|
| 196 |
+
suppressEvents = false;
|
| 197 |
+
}, 120);
|
| 198 |
+
}
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
const syncTimes = (masterVideo, force = false) => {
|
| 202 |
+
const targetTime = Number.isFinite(masterVideo.currentTime) ? masterVideo.currentTime : 0;
|
| 203 |
+
trio.forEach((video) => {
|
| 204 |
+
if (video === masterVideo) return;
|
| 205 |
+
if (force || Math.abs((video.currentTime || 0) - targetTime) > 0.05) {
|
| 206 |
+
try {
|
| 207 |
+
video.currentTime = targetTime;
|
| 208 |
+
} catch (error) {}
|
| 209 |
+
}
|
| 210 |
+
});
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
const syncPlaybackRate = (masterVideo) => {
|
| 214 |
+
trio.forEach((video) => {
|
| 215 |
+
if (video === masterVideo) return;
|
| 216 |
+
if (video.playbackRate !== masterVideo.playbackRate) {
|
| 217 |
+
video.playbackRate = masterVideo.playbackRate;
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
const playAll = (masterVideo = reference) => {
|
| 223 |
+
withSuppressedEvents(() => {
|
| 224 |
+
syncTimes(masterVideo, true);
|
| 225 |
+
syncPlaybackRate(masterVideo);
|
| 226 |
+
});
|
| 227 |
+
trio.forEach((video) => {
|
| 228 |
+
video.loop = true;
|
| 229 |
+
video.muted = true;
|
| 230 |
+
video.playsInline = true;
|
| 231 |
+
const playPromise = video.play();
|
| 232 |
+
if (playPromise && typeof playPromise.catch === "function") {
|
| 233 |
+
playPromise.catch(() => {});
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
const pauseOthers = (sourceVideo) => {
|
| 239 |
+
trio.forEach((video) => {
|
| 240 |
+
if (video === sourceVideo) return;
|
| 241 |
+
if (!video.paused) {
|
| 242 |
+
try {
|
| 243 |
+
video.pause();
|
| 244 |
+
} catch (error) {}
|
| 245 |
+
}
|
| 246 |
+
});
|
| 247 |
+
};
|
| 248 |
+
|
| 249 |
+
const restartAll = () => {
|
| 250 |
+
withSuppressedEvents(() => {
|
| 251 |
+
trio.forEach((video) => {
|
| 252 |
+
try {
|
| 253 |
+
video.pause();
|
| 254 |
+
} catch (error) {}
|
| 255 |
+
try {
|
| 256 |
+
video.currentTime = 0;
|
| 257 |
+
} catch (error) {}
|
| 258 |
+
});
|
| 259 |
+
});
|
| 260 |
+
playAll(reference);
|
| 261 |
+
};
|
| 262 |
+
|
| 263 |
+
const bind = (video, eventName, handler) => {
|
| 264 |
+
video.addEventListener(eventName, handler);
|
| 265 |
+
cleanupFns.push(() => video.removeEventListener(eventName, handler));
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
trio.forEach((video) => {
|
| 269 |
+
video.loop = true;
|
| 270 |
+
video.muted = true;
|
| 271 |
+
video.playsInline = true;
|
| 272 |
+
|
| 273 |
+
bind(video, "play", () => {
|
| 274 |
+
if (suppressEvents) return;
|
| 275 |
+
playAll(video);
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
bind(video, "pause", () => {
|
| 279 |
+
if (suppressEvents) return;
|
| 280 |
+
withSuppressedEvents(() => {
|
| 281 |
+
pauseOthers(video);
|
| 282 |
+
});
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
bind(video, "seeked", () => {
|
| 286 |
+
if (suppressEvents) return;
|
| 287 |
+
withSuppressedEvents(() => {
|
| 288 |
+
syncTimes(video, true);
|
| 289 |
+
});
|
| 290 |
+
});
|
| 291 |
+
|
| 292 |
+
bind(video, "ratechange", () => {
|
| 293 |
+
if (suppressEvents) return;
|
| 294 |
+
withSuppressedEvents(() => {
|
| 295 |
+
syncPlaybackRate(video);
|
| 296 |
+
});
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
bind(video, "loadeddata", () => {
|
| 300 |
+
if (trio.every((item) => item.readyState >= 2)) {
|
| 301 |
+
playAll(reference);
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
bind(reference, "ended", () => {
|
| 307 |
+
if (suppressEvents) return;
|
| 308 |
+
restartAll();
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
const driftTimer = setInterval(() => {
|
| 312 |
+
const currentReferenceTime = Number.isFinite(reference.currentTime) ? reference.currentTime : 0;
|
| 313 |
+
const loopWrapped = lastReferenceTime > 0.35 && currentReferenceTime + 0.2 < lastReferenceTime;
|
| 314 |
+
if (loopWrapped) {
|
| 315 |
+
withSuppressedEvents(() => {
|
| 316 |
+
syncTimes(reference, true);
|
| 317 |
+
syncPlaybackRate(reference);
|
| 318 |
+
});
|
| 319 |
+
if (!reference.paused) {
|
| 320 |
+
trio.forEach((video) => {
|
| 321 |
+
const playPromise = video.play();
|
| 322 |
+
if (playPromise && typeof playPromise.catch === "function") {
|
| 323 |
+
playPromise.catch(() => {});
|
| 324 |
+
}
|
| 325 |
+
});
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
lastReferenceTime = currentReferenceTime;
|
| 329 |
+
if (document.hidden || reference.paused) return;
|
| 330 |
+
syncTimes(reference, false);
|
| 331 |
+
}, 200);
|
| 332 |
+
cleanupFns.push(() => clearInterval(driftTimer));
|
| 333 |
+
|
| 334 |
+
window.__anyactStudySync = {
|
| 335 |
+
signature,
|
| 336 |
+
cleanup: () => {
|
| 337 |
+
cleanupFns.forEach((cleanup) => {
|
| 338 |
+
try {
|
| 339 |
+
cleanup();
|
| 340 |
+
} catch (error) {}
|
| 341 |
+
});
|
| 342 |
+
},
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
if (trio.every((video) => video.readyState >= 2)) {
|
| 346 |
+
playAll(reference);
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
window.addEventListener("load", () => {
|
| 351 |
+
anyactForceLightTheme();
|
| 352 |
+
anyactForcePlayVideos();
|
| 353 |
+
anyactSetupStudyVideoSync();
|
| 354 |
+
const observer = new MutationObserver(() => {
|
| 355 |
+
clearTimeout(window.__anyactVideoTimer);
|
| 356 |
+
window.__anyactVideoTimer = setTimeout(() => {
|
| 357 |
+
anyactForceLightTheme();
|
| 358 |
+
anyactForcePlayVideos();
|
| 359 |
+
anyactSetupStudyVideoSync();
|
| 360 |
+
}, 250);
|
| 361 |
+
});
|
| 362 |
+
observer.observe(document.body, { childList: true, subtree: true, attributes: true });
|
| 363 |
+
});
|
| 364 |
+
</script>
|
| 365 |
+
"""
|
| 366 |
+
|
| 367 |
+
CUSTOM_CSS = """
|
| 368 |
+
:root,
|
| 369 |
+
html,
|
| 370 |
+
body {
|
| 371 |
+
color-scheme: light !important;
|
| 372 |
+
background: #eef2f7 !important;
|
| 373 |
+
color: #0f172a !important;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.gradio-container {
|
| 377 |
+
max-width: 1380px !important;
|
| 378 |
+
margin: 0 auto !important;
|
| 379 |
+
padding-bottom: 32px !important;
|
| 380 |
+
font-family: "Segoe UI", "Helvetica Neue", sans-serif !important;
|
| 381 |
+
background:
|
| 382 |
+
radial-gradient(circle at top left, rgba(203, 213, 225, 0.26), transparent 28%),
|
| 383 |
+
linear-gradient(180deg, #f7f8fb 0%, #eef2f7 100%);
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.gradio-container,
|
| 387 |
+
.gradio-container *,
|
| 388 |
+
.gradio-container .dark,
|
| 389 |
+
.gradio-container .dark * {
|
| 390 |
+
--body-background-fill: #f7f8fb !important;
|
| 391 |
+
--body-background-fill-subdued: #eef2f7 !important;
|
| 392 |
+
--background-fill-primary: #ffffff !important;
|
| 393 |
+
--background-fill-secondary: #f8fafc !important;
|
| 394 |
+
--block-background-fill: #ffffff !important;
|
| 395 |
+
--block-border-color: #d8dee8 !important;
|
| 396 |
+
--panel-background-fill: #ffffff !important;
|
| 397 |
+
--panel-border-color: #d8dee8 !important;
|
| 398 |
+
--input-background-fill: #ffffff !important;
|
| 399 |
+
--input-border-color: #cbd5e1 !important;
|
| 400 |
+
--checkbox-background-color: #ffffff !important;
|
| 401 |
+
--checkbox-border-color: #94a3b8 !important;
|
| 402 |
+
--checkbox-label-text-color: #0f172a !important;
|
| 403 |
+
--body-text-color: #0f172a !important;
|
| 404 |
+
--block-label-text-color: #0f172a !important;
|
| 405 |
+
--block-title-text-color: #0f172a !important;
|
| 406 |
+
--button-secondary-background-fill: #ffffff !important;
|
| 407 |
+
--button-secondary-text-color: #1d4ed8 !important;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
.gradio-container div,
|
| 411 |
+
.gradio-container section,
|
| 412 |
+
.gradio-container article,
|
| 413 |
+
.gradio-container form,
|
| 414 |
+
.gradio-container fieldset,
|
| 415 |
+
.gradio-container label,
|
| 416 |
+
.gradio-container [data-testid="block"],
|
| 417 |
+
.gradio-container [data-testid="textbox"],
|
| 418 |
+
.gradio-container [data-testid="checkbox"],
|
| 419 |
+
.gradio-container [data-testid="radio"],
|
| 420 |
+
.gradio-container [data-testid="markdown"],
|
| 421 |
+
.gradio-container [data-testid="group"] {
|
| 422 |
+
color: #0f172a !important;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
.gradio-container,
|
| 426 |
+
.gradio-container .prose,
|
| 427 |
+
.gradio-container .prose p,
|
| 428 |
+
.gradio-container .prose li,
|
| 429 |
+
.gradio-container .prose strong,
|
| 430 |
+
.gradio-container .prose h1,
|
| 431 |
+
.gradio-container .prose h2,
|
| 432 |
+
.gradio-container .prose h3,
|
| 433 |
+
.gradio-container .prose h4,
|
| 434 |
+
.gradio-container label,
|
| 435 |
+
.gradio-container p,
|
| 436 |
+
.gradio-container li,
|
| 437 |
+
.gradio-container h1,
|
| 438 |
+
.gradio-container h2,
|
| 439 |
+
.gradio-container h3,
|
| 440 |
+
.gradio-container h4 {
|
| 441 |
+
color: #0f172a !important;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.block-title h1,
|
| 445 |
+
.block-title h2,
|
| 446 |
+
.section-heading {
|
| 447 |
+
font-family: "Libre Baskerville", Georgia, serif !important;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.hero-card,
|
| 451 |
+
.panel-card,
|
| 452 |
+
.form-card {
|
| 453 |
+
background: rgba(255, 255, 255, 0.96);
|
| 454 |
+
border: 1px solid #d8dee8;
|
| 455 |
+
border-radius: 20px;
|
| 456 |
+
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.06);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.hero-card {
|
| 460 |
+
padding: 28px 30px;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.panel-card {
|
| 464 |
+
padding: 22px 24px;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
.form-card {
|
| 468 |
+
padding: 18px;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.form-card textarea,
|
| 472 |
+
.form-card input {
|
| 473 |
+
background: #ffffff !important;
|
| 474 |
+
color: #0f172a !important;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.form-card,
|
| 478 |
+
.question-card,
|
| 479 |
+
.form-card > div,
|
| 480 |
+
.question-card > div,
|
| 481 |
+
.form-card [data-testid],
|
| 482 |
+
.question-card [data-testid],
|
| 483 |
+
.form-card .prose,
|
| 484 |
+
.question-card .prose,
|
| 485 |
+
.hero-card [data-testid],
|
| 486 |
+
.panel-card [data-testid] {
|
| 487 |
+
background: #ffffff !important;
|
| 488 |
+
color: #0f172a !important;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.gradio-container input,
|
| 492 |
+
.gradio-container textarea,
|
| 493 |
+
.gradio-container select {
|
| 494 |
+
background: #ffffff !important;
|
| 495 |
+
color: #0f172a !important;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.gradio-container [data-testid="checkbox"],
|
| 499 |
+
.gradio-container [data-testid="checkbox"] * {
|
| 500 |
+
background: transparent !important;
|
| 501 |
+
color: #0f172a !important;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.gradio-container [data-testid="textbox"],
|
| 505 |
+
.gradio-container [data-testid="textbox"] > *,
|
| 506 |
+
.gradio-container [data-testid="textbox"] textarea,
|
| 507 |
+
.gradio-container [data-testid="textbox"] input {
|
| 508 |
+
background: #ffffff !important;
|
| 509 |
+
color: #0f172a !important;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.gradio-container [data-testid="markdown"],
|
| 513 |
+
.gradio-container [data-testid="markdown"] > *,
|
| 514 |
+
.gradio-container [data-testid="group"],
|
| 515 |
+
.gradio-container [data-testid="group"] > * {
|
| 516 |
+
background: transparent !important;
|
| 517 |
+
color: #0f172a !important;
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
.instruction-shell {
|
| 521 |
+
display: flex;
|
| 522 |
+
flex-direction: column;
|
| 523 |
+
gap: 20px;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.instruction-top {
|
| 527 |
+
display: grid;
|
| 528 |
+
grid-template-columns: 1.02fr 0.98fr;
|
| 529 |
+
gap: 20px;
|
| 530 |
+
align-items: start;
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
.instruction-copy {
|
| 534 |
+
display: flex;
|
| 535 |
+
flex-direction: column;
|
| 536 |
+
gap: 16px;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.instruction-top-right {
|
| 540 |
+
display: flex;
|
| 541 |
+
flex-direction: column;
|
| 542 |
+
gap: 16px;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.lead-text {
|
| 546 |
+
color: #334155;
|
| 547 |
+
font-size: 17px;
|
| 548 |
+
line-height: 1.6;
|
| 549 |
+
margin: 0;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.instruction-list {
|
| 553 |
+
margin: 0;
|
| 554 |
+
padding-left: 20px;
|
| 555 |
+
color: #334155;
|
| 556 |
+
line-height: 1.6;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.example-caption-note {
|
| 560 |
+
margin: 0 0 10px 0;
|
| 561 |
+
padding: 10px 14px;
|
| 562 |
+
border-radius: 14px;
|
| 563 |
+
border: 1px solid #d9e2ec;
|
| 564 |
+
background: #f8fbff;
|
| 565 |
+
color: #334155;
|
| 566 |
+
line-height: 1.6;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
.instruction-bottom {
|
| 570 |
+
display: grid;
|
| 571 |
+
grid-template-columns: 1.08fr 0.92fr;
|
| 572 |
+
gap: 20px;
|
| 573 |
+
align-items: start;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.instruction-bottom-left,
|
| 577 |
+
.instruction-bottom-right {
|
| 578 |
+
display: flex;
|
| 579 |
+
flex-direction: column;
|
| 580 |
+
gap: 14px;
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
.metric-card {
|
| 584 |
+
border: 1px solid #d9e2ec;
|
| 585 |
+
border-radius: 16px;
|
| 586 |
+
padding: 16px;
|
| 587 |
+
background: #fbfcfe;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
.metric-card h4 {
|
| 591 |
+
margin: 0 0 8px 0;
|
| 592 |
+
font-size: 17px;
|
| 593 |
+
color: #0f172a;
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.metric-card p {
|
| 597 |
+
margin: 0;
|
| 598 |
+
color: #475569;
|
| 599 |
+
line-height: 1.55;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.metric-stack {
|
| 603 |
+
display: flex;
|
| 604 |
+
flex-direction: column;
|
| 605 |
+
gap: 12px;
|
| 606 |
+
margin-top: 14px;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.diagram-card {
|
| 610 |
+
border: 1px solid #d9e2ec;
|
| 611 |
+
border-radius: 18px;
|
| 612 |
+
padding: 18px;
|
| 613 |
+
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
.case-walkthrough {
|
| 617 |
+
display: flex;
|
| 618 |
+
flex-direction: column;
|
| 619 |
+
gap: 14px;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
.case-walkthrough-head h3 {
|
| 623 |
+
margin: 0;
|
| 624 |
+
font-size: 24px;
|
| 625 |
+
color: #0f172a;
|
| 626 |
+
font-family: "Libre Baskerville", Georgia, serif !important;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.case-walkthrough-head p {
|
| 630 |
+
margin: 6px 0 0 0;
|
| 631 |
+
color: #475569;
|
| 632 |
+
line-height: 1.55;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.walkthrough-grid {
|
| 636 |
+
display: grid;
|
| 637 |
+
grid-template-columns: 1.15fr 1fr 1fr;
|
| 638 |
+
gap: 12px;
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.thumb-card {
|
| 642 |
+
background: #ffffff;
|
| 643 |
+
border: 1px solid #d8e2ef;
|
| 644 |
+
border-radius: 16px;
|
| 645 |
+
overflow: hidden;
|
| 646 |
+
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.thumb-card.ref-card {
|
| 650 |
+
border-color: #93c5fd;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.thumb-image {
|
| 654 |
+
aspect-ratio: 1.2 / 1;
|
| 655 |
+
background: #eef2f7;
|
| 656 |
+
overflow: hidden;
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.thumb-image img {
|
| 660 |
+
width: 100%;
|
| 661 |
+
height: 100%;
|
| 662 |
+
object-fit: cover;
|
| 663 |
+
display: block;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.thumb-body {
|
| 667 |
+
padding: 12px 13px 14px;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.thumb-title {
|
| 671 |
+
display: inline-block;
|
| 672 |
+
font-size: 12px;
|
| 673 |
+
font-weight: 700;
|
| 674 |
+
letter-spacing: 0.06em;
|
| 675 |
+
text-transform: uppercase;
|
| 676 |
+
color: #1d4ed8;
|
| 677 |
+
background: #dbeafe;
|
| 678 |
+
border-radius: 999px;
|
| 679 |
+
padding: 5px 9px;
|
| 680 |
+
margin-bottom: 8px;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.thumb-card.candidate-card .thumb-title {
|
| 684 |
+
color: #0f172a;
|
| 685 |
+
background: #eaf1fb;
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
.thumb-body h4 {
|
| 689 |
+
margin: 0 0 6px 0;
|
| 690 |
+
font-size: 17px;
|
| 691 |
+
color: #0f172a;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.thumb-body p {
|
| 695 |
+
margin: 0;
|
| 696 |
+
font-size: 14px;
|
| 697 |
+
color: #475569;
|
| 698 |
+
line-height: 1.5;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.walkthrough-note {
|
| 702 |
+
border: 1px solid #d9e2ec;
|
| 703 |
+
border-radius: 16px;
|
| 704 |
+
background: #ffffff;
|
| 705 |
+
padding: 14px 15px;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.walkthrough-note > strong {
|
| 709 |
+
display: block;
|
| 710 |
+
color: #0f172a;
|
| 711 |
+
margin-bottom: 6px;
|
| 712 |
+
font-size: 15px;
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
.walkthrough-note p {
|
| 716 |
+
margin: 0;
|
| 717 |
+
color: #475569;
|
| 718 |
+
line-height: 1.6;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
.walkthrough-note p + p {
|
| 722 |
+
margin-top: 10px;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.walkthrough-note p strong {
|
| 726 |
+
display: inline;
|
| 727 |
+
margin: 0;
|
| 728 |
+
font-size: inherit;
|
| 729 |
+
color: #0f172a;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
.walkthrough-note.accent-note {
|
| 733 |
+
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
|
| 734 |
+
border-color: #bfdbfe;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.progress-chip {
|
| 738 |
+
display: inline-block;
|
| 739 |
+
padding: 8px 14px;
|
| 740 |
+
border-radius: 999px;
|
| 741 |
+
background: #0f172a;
|
| 742 |
+
color: white;
|
| 743 |
+
font-weight: 700;
|
| 744 |
+
letter-spacing: 0.01em;
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
.meta-line {
|
| 748 |
+
margin-top: 8px;
|
| 749 |
+
color: #475569;
|
| 750 |
+
font-size: 15px;
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
.study-shell .gr-video {
|
| 754 |
+
border-radius: 16px !important;
|
| 755 |
+
overflow: hidden !important;
|
| 756 |
+
border: 1px solid #dbe2ea !important;
|
| 757 |
+
background: #f8fafc !important;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
.gradio-container video {
|
| 761 |
+
background: #0f172a !important;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.video-panel {
|
| 765 |
+
background: #ffffff;
|
| 766 |
+
}
|
| 767 |
+
|
| 768 |
+
.reference-panel {
|
| 769 |
+
border: 1px solid #bfdbfe !important;
|
| 770 |
+
box-shadow: 0 12px 28px rgba(37, 99, 235, 0.08);
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.candidate-panel {
|
| 774 |
+
border: 1px solid #dbe4ee !important;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.video-caption {
|
| 778 |
+
color: #475569;
|
| 779 |
+
font-size: 14px;
|
| 780 |
+
margin-top: -4px;
|
| 781 |
+
margin-bottom: 10px;
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
.question-card {
|
| 785 |
+
padding: 18px 20px;
|
| 786 |
+
border-radius: 18px;
|
| 787 |
+
border: 1px solid #d9e2ec;
|
| 788 |
+
background: rgba(255, 255, 255, 0.9);
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.choice-input {
|
| 792 |
+
margin-top: 8px;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.choice-input fieldset {
|
| 796 |
+
border: 1px solid #d7e3f4 !important;
|
| 797 |
+
border-radius: 16px !important;
|
| 798 |
+
background: #f8fbff !important;
|
| 799 |
+
padding: 12px 14px !important;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.choice-input label {
|
| 803 |
+
border: 1px solid #cbdcf5 !important;
|
| 804 |
+
border-radius: 12px !important;
|
| 805 |
+
background: #ffffff !important;
|
| 806 |
+
padding: 10px 12px !important;
|
| 807 |
+
color: #1e293b !important;
|
| 808 |
+
font-weight: 600 !important;
|
| 809 |
+
transition: all 0.2s ease !important;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.choice-input label:hover {
|
| 813 |
+
border-color: #60a5fa !important;
|
| 814 |
+
background: #eff6ff !important;
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
.choice-input label:has(input:checked) {
|
| 818 |
+
border-color: #3b82f6 !important;
|
| 819 |
+
background: linear-gradient(180deg, #dbeafe 0%, #eff6ff 100%) !important;
|
| 820 |
+
color: #1d4ed8 !important;
|
| 821 |
+
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12) !important;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.choice-input input[type="radio"] {
|
| 825 |
+
accent-color: #2563eb !important;
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
.gradio-container button {
|
| 829 |
+
transition: transform 0.15s ease, box-shadow 0.15s ease !important;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.gradio-container button.primary {
|
| 833 |
+
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%) !important;
|
| 834 |
+
color: #ffffff !important;
|
| 835 |
+
border: none !important;
|
| 836 |
+
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18) !important;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.gradio-container button.primary:hover {
|
| 840 |
+
transform: translateY(-1px);
|
| 841 |
+
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.22) !important;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.gradio-container button.secondary {
|
| 845 |
+
background: #ffffff !important;
|
| 846 |
+
color: #1d4ed8 !important;
|
| 847 |
+
border: 1px solid #bfdbfe !important;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
.language-switch-row {
|
| 851 |
+
justify-content: flex-end;
|
| 852 |
+
margin-bottom: 8px;
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
.language-switch {
|
| 856 |
+
min-width: 210px;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
.language-switch fieldset {
|
| 860 |
+
border: 1px solid #cfe0fb !important;
|
| 861 |
+
border-radius: 999px !important;
|
| 862 |
+
background: rgba(255, 255, 255, 0.92) !important;
|
| 863 |
+
padding: 6px !important;
|
| 864 |
+
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.08) !important;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.language-switch label {
|
| 868 |
+
border: none !important;
|
| 869 |
+
border-radius: 999px !important;
|
| 870 |
+
background: transparent !important;
|
| 871 |
+
color: #475569 !important;
|
| 872 |
+
min-height: 38px !important;
|
| 873 |
+
padding: 8px 16px !important;
|
| 874 |
+
font-weight: 700 !important;
|
| 875 |
+
transition: all 0.18s ease !important;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
.language-switch label:hover {
|
| 879 |
+
background: #eff6ff !important;
|
| 880 |
+
color: #1d4ed8 !important;
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.language-switch label:has(input:checked) {
|
| 884 |
+
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%) !important;
|
| 885 |
+
color: #ffffff !important;
|
| 886 |
+
box-shadow: 0 8px 16px rgba(37, 99, 235, 0.18) !important;
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
.language-switch input[type="radio"] {
|
| 890 |
+
display: none !important;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
.thank-card {
|
| 894 |
+
max-width: 760px;
|
| 895 |
+
margin: 0 auto;
|
| 896 |
+
padding: 28px 30px;
|
| 897 |
+
text-align: left;
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
.muted-caption {
|
| 901 |
+
color: #64748b;
|
| 902 |
+
font-size: 14px;
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
@media (max-width: 900px) {
|
| 906 |
+
.instruction-top,
|
| 907 |
+
.instruction-bottom,
|
| 908 |
+
.walkthrough-grid {
|
| 909 |
+
grid-template-columns: 1fr;
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
"""
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
LANGUAGE_CHOICES = [("English", "en"), ("中文", "zh")]
|
| 916 |
+
LANGUAGE_SWITCH_LABEL = "Language / 语言"
|
| 917 |
+
|
| 918 |
+
TEXT = {
|
| 919 |
+
"en": {
|
| 920 |
+
"language_label": "Language",
|
| 921 |
+
"study_title": "Human Motion Reenactment User Study",
|
| 922 |
+
"example_comparison": "Example Comparison",
|
| 923 |
+
"reference_video": "Reference Video",
|
| 924 |
+
"candidate_a": "Result A",
|
| 925 |
+
"candidate_b": "Result B",
|
| 926 |
+
"reference_caption": "Reference",
|
| 927 |
+
"result_a_caption": "Result A",
|
| 928 |
+
"result_b_caption": "Result B",
|
| 929 |
+
"result_a_left": "Result A",
|
| 930 |
+
"result_b_right": "Result B",
|
| 931 |
+
"participant_id_label": "Participant ID (read-only)",
|
| 932 |
+
"participant_setup_title": "Participant Setup",
|
| 933 |
+
"generate_fresh_id": "Generate Fresh Participant ID",
|
| 934 |
+
"start_continue": "Start / Continue Study",
|
| 935 |
+
"previous": "Previous",
|
| 936 |
+
"next": "Next",
|
| 937 |
+
"submit_study": "Submit Study",
|
| 938 |
+
"question_similarity": "Which result better matches the reference motion?",
|
| 939 |
+
"question_quality": "Which result has better motion quality?",
|
| 940 |
+
"question_preference": "Which result do you overall prefer?",
|
| 941 |
+
"left_choice": "Result A",
|
| 942 |
+
"right_choice": "Result B",
|
| 943 |
+
"saved_progress_restored": "Saved progress restored.",
|
| 944 |
+
"answer_all_required": "Please answer all three questions before continuing.",
|
| 945 |
+
"session_empty": "Session state was empty. Please return to the start page and continue this browser session.",
|
| 946 |
+
"browser_saved_id_notice": (
|
| 947 |
+
"This browser already has a saved participant ID. Click **Start / Continue Study** "
|
| 948 |
+
"to resume the same session safely."
|
| 949 |
+
),
|
| 950 |
+
"fresh_id_notice": (
|
| 951 |
+
"A fresh participant ID has been created for this browser. "
|
| 952 |
+
"Use it only if you are starting a brand-new session."
|
| 953 |
+
),
|
| 954 |
+
"completed_id_notice": (
|
| 955 |
+
"This participant ID has already completed the study. "
|
| 956 |
+
"Generate a fresh ID if you need a brand-new session on this browser."
|
| 957 |
+
),
|
| 958 |
+
"study_instruction": "Watch the reference clip and both anonymous results before answering the three questions on Motion Similarity, Motion Quality, and Overall Preference.",
|
| 959 |
+
"question_word": "Question",
|
| 960 |
+
"saved_responses": "Saved responses",
|
| 961 |
+
"participant_id_meta": "Participant ID",
|
| 962 |
+
"thank_you_title": "Thank you for completing the study.",
|
| 963 |
+
"thank_you_saved": "Your responses have been saved successfully.",
|
| 964 |
+
"thank_you_completed_at": "Completed at",
|
| 965 |
+
"thank_you_close": "You may now close this page.",
|
| 966 |
+
},
|
| 967 |
+
"zh": {
|
| 968 |
+
"language_label": "语言",
|
| 969 |
+
"study_title": "人体动作重演用户研究",
|
| 970 |
+
"example_comparison": "示例对比",
|
| 971 |
+
"reference_video": "参考视频",
|
| 972 |
+
"candidate_a": "结果 A",
|
| 973 |
+
"candidate_b": "结果 B",
|
| 974 |
+
"reference_caption": "参考视频",
|
| 975 |
+
"result_a_caption": "结果 A",
|
| 976 |
+
"result_b_caption": "结果 B",
|
| 977 |
+
"result_a_left": "结果 A",
|
| 978 |
+
"result_b_right": "结果 B",
|
| 979 |
+
"participant_id_label": "参与者编号(只读)",
|
| 980 |
+
"participant_setup_title": "参与者设置",
|
| 981 |
+
"generate_fresh_id": "生成新的参与者编号",
|
| 982 |
+
"start_continue": "开始 / 继续问卷",
|
| 983 |
+
"previous": "上一题",
|
| 984 |
+
"next": "下一题",
|
| 985 |
+
"submit_study": "提交问卷",
|
| 986 |
+
"question_similarity": "哪个结果与参考动作更匹配?",
|
| 987 |
+
"question_quality": "哪个结果的动作质量更好?",
|
| 988 |
+
"question_preference": "你整体更偏好哪个结果?",
|
| 989 |
+
"left_choice": "结果 A",
|
| 990 |
+
"right_choice": "结果 B",
|
| 991 |
+
"saved_progress_restored": "已恢复先前保存的进度。",
|
| 992 |
+
"answer_all_required": "请先回答完这三个问题,再继续下一题。",
|
| 993 |
+
"session_empty": "当前会话为空。请返回起始页后继续此浏览器中的问卷会话。",
|
| 994 |
+
"browser_saved_id_notice": (
|
| 995 |
+
"当前浏览器已保存参与者编号。点击 **开始 / 继续问卷** 可安全地恢复同一会话。"
|
| 996 |
+
),
|
| 997 |
+
"fresh_id_notice": (
|
| 998 |
+
"当前浏览器已生成一个新的参与者编号。仅当你需要开始一个全新的问卷会话时再使用它。"
|
| 999 |
+
),
|
| 1000 |
+
"completed_id_notice": (
|
| 1001 |
+
"该参与者编号已经完成本次问卷。若你需要在此浏览器中开始全新会话,请生成新的编号。"
|
| 1002 |
+
),
|
| 1003 |
+
"study_instruction": "请先观看参考视频和两个匿名结果,再回答动作相似性、动作质量和整体偏好这三个问题。",
|
| 1004 |
+
"question_word": "题目",
|
| 1005 |
+
"saved_responses": "已保存回答",
|
| 1006 |
+
"participant_id_meta": "参与者编号",
|
| 1007 |
+
"thank_you_title": "感谢你完成本次问卷。",
|
| 1008 |
+
"thank_you_saved": "你的回答已成功保存。",
|
| 1009 |
+
"thank_you_completed_at": "完成时间",
|
| 1010 |
+
"thank_you_close": "现在可以关闭此页面。",
|
| 1011 |
+
},
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
def normalize_language(language: str | None) -> str:
|
| 1016 |
+
return language if language in TEXT else "en"
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
def tr(language: str | None, key: str, **kwargs: Any) -> str:
|
| 1020 |
+
return TEXT[normalize_language(language)][key].format(**kwargs)
|
| 1021 |
+
|
| 1022 |
+
|
| 1023 |
+
def choice_options_for_language(language: str | None) -> list[tuple[str, str]]:
|
| 1024 |
+
language = normalize_language(language)
|
| 1025 |
+
return [
|
| 1026 |
+
(tr(language, "left_choice"), "ResultA"),
|
| 1027 |
+
(tr(language, "right_choice"), "ResultB"),
|
| 1028 |
+
]
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
def video_caption_html(text: str) -> str:
|
| 1032 |
+
return f"<div class='video-caption'>{html.escape(text)}</div>"
|
| 1033 |
+
|
| 1034 |
+
|
| 1035 |
+
def build_participant_setup_markdown(language: str | None) -> str:
|
| 1036 |
+
language = normalize_language(language)
|
| 1037 |
+
if language == "zh":
|
| 1038 |
+
return """
|
| 1039 |
+
### 参与者设置
|
| 1040 |
+
|
| 1041 |
+
当前公开问卷链接会为本浏览器自动创建并保存参与者编号。之后如果你仍使用同一浏览器再次访问,就可以自动恢复同一份作答进度,而无需手动输入编号。
|
| 1042 |
+
|
| 1043 |
+
只有在你希望于当前浏览器中开始一个全新的作答会话时,才需要点击 **生成新的参与者编号**。请不要将下方显示的编号分享给其他参与者。
|
| 1044 |
+
""".strip()
|
| 1045 |
+
|
| 1046 |
+
return """
|
| 1047 |
+
### Participant Setup
|
| 1048 |
+
|
| 1049 |
+
This public study link now creates and stores a participant ID automatically for the current browser. Returning on the
|
| 1050 |
+
same browser will safely continue the same session without asking you to type an ID manually.
|
| 1051 |
+
|
| 1052 |
+
Use **Generate Fresh Participant ID** only when starting a completely new participation on this browser. Please do not
|
| 1053 |
+
share the ID shown below with other participants.
|
| 1054 |
+
""".strip()
|
| 1055 |
+
|
| 1056 |
+
|
| 1057 |
+
def build_progress_markdown(state: dict[str, Any], language: str | None) -> str:
|
| 1058 |
+
question = state["questions"][state["current_index"]]
|
| 1059 |
+
answered_count = len(state.get("answers", {}))
|
| 1060 |
+
language = normalize_language(language)
|
| 1061 |
+
if language == "zh":
|
| 1062 |
+
return (
|
| 1063 |
+
f"<div class='meta-line'>{tr(language, 'participant_id_meta')}: <code>{state['participant_id']}</code></div>"
|
| 1064 |
+
f"<div class='meta-line'>{tr(language, 'saved_responses')}: {answered_count} / {question['total_questions']}</div>"
|
| 1065 |
+
)
|
| 1066 |
+
|
| 1067 |
+
return (
|
| 1068 |
+
f"<div class='meta-line'>{tr(language, 'participant_id_meta')}: <code>{state['participant_id']}</code></div>"
|
| 1069 |
+
f"<div class='meta-line'>{tr(language, 'saved_responses')}: {answered_count} / {question['total_questions']}</div>"
|
| 1070 |
+
)
|
| 1071 |
+
|
| 1072 |
+
|
| 1073 |
+
def build_completion_markdown_local(state: dict[str, Any], language: str | None) -> str:
|
| 1074 |
+
completed_at = state.get("completed_at") or ""
|
| 1075 |
+
total_questions = len(state.get("questions", []))
|
| 1076 |
+
answered_count = len(state.get("answers", {}))
|
| 1077 |
+
language = normalize_language(language)
|
| 1078 |
+
if language == "zh":
|
| 1079 |
+
return f"""
|
| 1080 |
+
## {tr(language, "thank_you_title")}
|
| 1081 |
+
|
| 1082 |
+
{tr(language, "thank_you_saved")}
|
| 1083 |
+
|
| 1084 |
+
- {tr(language, "participant_id_meta")}: `{state["participant_id"]}`
|
| 1085 |
+
- {tr(language, "saved_responses")}: `{answered_count} / {total_questions}`
|
| 1086 |
+
- {tr(language, "thank_you_completed_at")}: `{completed_at}`
|
| 1087 |
+
|
| 1088 |
+
{tr(language, "thank_you_close")}
|
| 1089 |
+
""".strip()
|
| 1090 |
+
|
| 1091 |
+
return f"""
|
| 1092 |
+
## {tr(language, "thank_you_title")}
|
| 1093 |
+
|
| 1094 |
+
{tr(language, "thank_you_saved")}
|
| 1095 |
+
|
| 1096 |
+
- {tr(language, "participant_id_meta")}: `{state["participant_id"]}`
|
| 1097 |
+
- {tr(language, "saved_responses")}: `{answered_count} / {total_questions}`
|
| 1098 |
+
- {tr(language, "thank_you_completed_at")}: `{completed_at}`
|
| 1099 |
+
|
| 1100 |
+
{tr(language, "thank_you_close")}
|
| 1101 |
+
""".strip()
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
def image_to_data_uri(image_path: str | Path) -> str:
|
| 1105 |
+
path = Path(image_path)
|
| 1106 |
+
if not path.exists():
|
| 1107 |
+
return ""
|
| 1108 |
+
suffix = path.suffix.lower()
|
| 1109 |
+
mime_type = "image/jpeg" if suffix in {".jpg", ".jpeg"} else "image/png"
|
| 1110 |
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
| 1111 |
+
return f"data:{mime_type};base64,{encoded}"
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
def build_instruction_case_html(intro_case: dict[str, Any], language: str | None) -> str:
|
| 1115 |
+
reference_thumb = image_to_data_uri(ensure_video_thumbnail(intro_case["reference_video"], PROJECT_ROOT))
|
| 1116 |
+
result_a_thumb = image_to_data_uri(
|
| 1117 |
+
ensure_video_thumbnail(intro_case["method_videos"]["anyact"], PROJECT_ROOT)
|
| 1118 |
+
)
|
| 1119 |
+
result_b_thumb = image_to_data_uri(
|
| 1120 |
+
ensure_video_thumbnail(intro_case["method_videos"]["vlm_hy_motion"], PROJECT_ROOT)
|
| 1121 |
+
)
|
| 1122 |
+
language = normalize_language(language)
|
| 1123 |
+
|
| 1124 |
+
if language == "zh":
|
| 1125 |
+
return f"""
|
| 1126 |
+
<div class="case-walkthrough">
|
| 1127 |
+
<div class="case-walkthrough-head">
|
| 1128 |
+
<h3>示例对比</h3>
|
| 1129 |
+
<p>
|
| 1130 |
+
下图说明问卷如何进行比较。参与者需要将一个源角色的运动视频与两个人体重演结果进行比较,
|
| 1131 |
+
页面上它们显示为 <strong>结果 A</strong> 和 <strong>结果 B</strong>。灰色区域表示地板。
|
| 1132 |
+
</p>
|
| 1133 |
+
</div>
|
| 1134 |
+
|
| 1135 |
+
<div class="walkthrough-grid">
|
| 1136 |
+
<div class="thumb-card ref-card">
|
| 1137 |
+
<div class="thumb-image"><img src="{reference_thumb}" alt="参考案例缩略图"></div>
|
| 1138 |
+
<div class="thumb-body">
|
| 1139 |
+
<div class="thumb-title">参考视频</div>
|
| 1140 |
+
<h4>源角色运动视频</h4>
|
| 1141 |
+
<p>该视频提供需要被模仿的角色运动,主要体现姿态和动作动态。</p>
|
| 1142 |
+
</div>
|
| 1143 |
+
</div>
|
| 1144 |
+
|
| 1145 |
+
<div class="thumb-card candidate-card">
|
| 1146 |
+
<div class="thumb-image"><img src="{result_a_thumb}" alt="匿名结果 A 缩略图"></div>
|
| 1147 |
+
<div class="thumb-body">
|
| 1148 |
+
<div class="thumb-title">结果 A</div>
|
| 1149 |
+
<h4>模仿参考角色运动的人体重演</h4>
|
| 1150 |
+
<p>这是结果 A 对参考角色运动进行人体重演后的表现。</p>
|
| 1151 |
+
</div>
|
| 1152 |
+
</div>
|
| 1153 |
+
|
| 1154 |
+
<div class="thumb-card candidate-card">
|
| 1155 |
+
<div class="thumb-image"><img src="{result_b_thumb}" alt="匿名结果 B 缩略图"></div>
|
| 1156 |
+
<div class="thumb-body">
|
| 1157 |
+
<div class="thumb-title">结果 B</div>
|
| 1158 |
+
<h4>模仿参考角色运动的人体重演</h4>
|
| 1159 |
+
<p>这是结果 B 对参考角色运动进行人体重演后的表现。</p>
|
| 1160 |
+
</div>
|
| 1161 |
+
</div>
|
| 1162 |
+
</div>
|
| 1163 |
+
</div>
|
| 1164 |
+
""".strip()
|
| 1165 |
+
|
| 1166 |
+
return f"""
|
| 1167 |
+
<div class="case-walkthrough">
|
| 1168 |
+
<div class="case-walkthrough-head">
|
| 1169 |
+
<h3>Example Comparison</h3>
|
| 1170 |
+
<p>
|
| 1171 |
+
The figure below shows how the questionnaire works. Participants compare one video of the source character's motion against
|
| 1172 |
+
two human reenactment results that imitate that motion, displayed as <strong>Result A</strong> and <strong>Result B</strong>.
|
| 1173 |
+
The gray area indicates the floor.
|
| 1174 |
+
</p>
|
| 1175 |
+
</div>
|
| 1176 |
+
|
| 1177 |
+
<div class="walkthrough-grid">
|
| 1178 |
+
<div class="thumb-card ref-card">
|
| 1179 |
+
<div class="thumb-image"><img src="{reference_thumb}" alt="Reference case thumbnail"></div>
|
| 1180 |
+
<div class="thumb-body">
|
| 1181 |
+
<div class="thumb-title">Reference</div>
|
| 1182 |
+
<h4>Source character motion video</h4>
|
| 1183 |
+
<p>This video provides the character motion to be imitated, mainly in terms of pose and action dynamics.</p>
|
| 1184 |
+
</div>
|
| 1185 |
+
</div>
|
| 1186 |
+
|
| 1187 |
+
<div class="thumb-card candidate-card">
|
| 1188 |
+
<div class="thumb-image"><img src="{result_a_thumb}" alt="Anonymous Result A thumbnail"></div>
|
| 1189 |
+
<div class="thumb-body">
|
| 1190 |
+
<div class="thumb-title">Result A</div>
|
| 1191 |
+
<h4>Human reenactment imitating the reference character motion</h4>
|
| 1192 |
+
<p>This is the human-motion reenactment shown as Result A.</p>
|
| 1193 |
+
</div>
|
| 1194 |
+
</div>
|
| 1195 |
+
|
| 1196 |
+
<div class="thumb-card candidate-card">
|
| 1197 |
+
<div class="thumb-image"><img src="{result_b_thumb}" alt="Anonymous Result B thumbnail"></div>
|
| 1198 |
+
<div class="thumb-body">
|
| 1199 |
+
<div class="thumb-title">Result B</div>
|
| 1200 |
+
<h4>Human reenactment imitating the reference character motion</h4>
|
| 1201 |
+
<p>This is the human-motion reenactment shown as Result B.</p>
|
| 1202 |
+
</div>
|
| 1203 |
+
</div>
|
| 1204 |
+
</div>
|
| 1205 |
+
</div>
|
| 1206 |
+
""".strip()
|
| 1207 |
+
|
| 1208 |
+
|
| 1209 |
+
def build_judging_instruction_html(language: str | None) -> str:
|
| 1210 |
+
language = normalize_language(language)
|
| 1211 |
+
if language == "zh":
|
| 1212 |
+
return """
|
| 1213 |
+
<div class="walkthrough-note">
|
| 1214 |
+
<strong>参与者需要判断什么</strong>
|
| 1215 |
+
<p>
|
| 1216 |
+
对于每一组对比,你都需要回答三个问题,分别对应 <strong>动作相似性</strong>、<strong>动作质量</strong> 和
|
| 1217 |
+
<strong>整体偏好</strong>。请直接依据页面上显示的结果 A 和结果 B 进行判断。
|
| 1218 |
+
</p>
|
| 1219 |
+
<div class="metric-stack">
|
| 1220 |
+
<div class="metric-card">
|
| 1221 |
+
<h4>动作相似性</h4>
|
| 1222 |
+
<p>哪个结果与参考动作更匹配,尤其是在姿态对齐和时间动态方面?</p>
|
| 1223 |
+
</div>
|
| 1224 |
+
<div class="metric-card">
|
| 1225 |
+
<h4>动作质量</h4>
|
| 1226 |
+
<p>哪个结果看起来更自然、更平滑,并且更符合人体动作的物理合理性?请主要关注全身动作、身体协调、平衡性和时间连续性,并可适当忽略面部表情与手指细节。</p>
|
| 1227 |
+
</div>
|
| 1228 |
+
<div class="metric-card">
|
| 1229 |
+
<h4>整体偏好</h4>
|
| 1230 |
+
<p>综合考虑动作相似性和动作质量后,你整体更偏好哪个结果?</p>
|
| 1231 |
+
</div>
|
| 1232 |
+
</div>
|
| 1233 |
+
</div>
|
| 1234 |
+
""".strip()
|
| 1235 |
+
|
| 1236 |
+
return """
|
| 1237 |
+
<div class="walkthrough-note">
|
| 1238 |
+
<strong>What participants are asked to judge</strong>
|
| 1239 |
+
<p>
|
| 1240 |
+
For each comparison, please answer three questions covering <strong>Motion Similarity</strong>,
|
| 1241 |
+
<strong>Motion Quality</strong>, and <strong>Overall Preference</strong>. Please base your choice directly on the
|
| 1242 |
+
page labels, namely Result A and Result B.
|
| 1243 |
+
</p>
|
| 1244 |
+
<div class="metric-stack">
|
| 1245 |
+
<div class="metric-card">
|
| 1246 |
+
<h4>Motion Similarity</h4>
|
| 1247 |
+
<p>Which result better matches the reference motion, especially in terms of pose alignment and temporal dynamics?</p>
|
| 1248 |
+
</div>
|
| 1249 |
+
<div class="metric-card">
|
| 1250 |
+
<h4>Motion Quality</h4>
|
| 1251 |
+
<p>Which result appears more natural, smooth, and physically plausible as a human motion sequence (mainly focusing on whole-body motion, body coordination, balance, and temporal continuity, while reasonably ignoring facial expressions and fine finger motion)?</p>
|
| 1252 |
+
</div>
|
| 1253 |
+
<div class="metric-card">
|
| 1254 |
+
<h4>Overall Preference</h4>
|
| 1255 |
+
<p>Considering both similarity and quality together, which result do you prefer overall?</p>
|
| 1256 |
+
</div>
|
| 1257 |
+
</div>
|
| 1258 |
+
</div>
|
| 1259 |
+
""".strip()
|
| 1260 |
+
|
| 1261 |
+
|
| 1262 |
+
def build_instruction_html(
|
| 1263 |
+
config: dict[str, Any],
|
| 1264 |
+
case_walkthrough_html: str,
|
| 1265 |
+
judging_instruction_html: str,
|
| 1266 |
+
language: str | None,
|
| 1267 |
+
) -> str:
|
| 1268 |
+
total_questions = config["participant_question_total"]
|
| 1269 |
+
language = normalize_language(language)
|
| 1270 |
+
if language == "zh":
|
| 1271 |
+
return f"""
|
| 1272 |
+
<div class="hero-card">
|
| 1273 |
+
<div class="instruction-shell">
|
| 1274 |
+
<div class="instruction-top">
|
| 1275 |
+
<div class="instruction-copy">
|
| 1276 |
+
<div class="block-title">
|
| 1277 |
+
<h1>{tr(language, "study_title")}</h1>
|
| 1278 |
+
</div>
|
| 1279 |
+
<p class="lead-text">
|
| 1280 |
+
本研究用于评估人体动作重演结果的主观感知质量。每一道题中,你将观看
|
| 1281 |
+
一个<strong>参考视频</strong>和两个<strong>匿名结果</strong>,它们在页面上显示为
|
| 1282 |
+
<strong>结果 A</strong> 和 <strong>结果 B</strong>。你需要围绕
|
| 1283 |
+
<strong>动作相似性</strong>、<strong>动作质量</strong> 和 <strong>整体偏好</strong>
|
| 1284 |
+
三个指标完成判断。
|
| 1285 |
+
</p>
|
| 1286 |
+
<ul class="instruction-list">
|
| 1287 |
+
<li>每位参与者需要完成 <strong>{total_questions}</strong> 个成对对比样本。</li>
|
| 1288 |
+
<li>每次比较都会同时展示一个参考视频,以及并排显示的结果 A 和结果 B。</li>
|
| 1289 |
+
<li>结果 A 和结果 B 的时长可能不完全相同,但它们都对应对整段参考视频中角色运动的重演;请忽略这种长度差异。</li>
|
| 1290 |
+
<li>请在进入下一页之前回答完当前题目的三个问题。</li>
|
| 1291 |
+
</ul>
|
| 1292 |
+
{judging_instruction_html}
|
| 1293 |
+
</div>
|
| 1294 |
+
<div class="instruction-top-right">
|
| 1295 |
+
<div class="diagram-card">
|
| 1296 |
+
{case_walkthrough_html}
|
| 1297 |
+
</div>
|
| 1298 |
+
<div class="walkthrough-note accent-note">
|
| 1299 |
+
<strong>如何理解本任务</strong>
|
| 1300 |
+
<p>
|
| 1301 |
+
本研究关注的是<strong>人去模仿参考视频中角色的运动</strong>。
|
| 1302 |
+
一个优秀的结果应当既忠实保留参考运动,又能呈现自然、平稳、符合人体运动规律的动作。
|
| 1303 |
+
</p>
|
| 1304 |
+
<p>
|
| 1305 |
+
当原始角色和人体构造不完全一致时,可以接受使用人体其他肢体去模拟缺失的功能部位,
|
| 1306 |
+
例如用手臂对应翅膀,只要整体动作意图和动态仍然合理并接近参考动作。
|
| 1307 |
+
</p>
|
| 1308 |
+
</div>
|
| 1309 |
+
</div>
|
| 1310 |
+
</div>
|
| 1311 |
+
</div>
|
| 1312 |
+
</div>
|
| 1313 |
+
""".strip()
|
| 1314 |
+
|
| 1315 |
+
return f"""
|
| 1316 |
+
<div class="hero-card">
|
| 1317 |
+
<div class="instruction-shell">
|
| 1318 |
+
<div class="instruction-top">
|
| 1319 |
+
<div class="instruction-copy">
|
| 1320 |
+
<div class="block-title">
|
| 1321 |
+
<h1>Human Motion Reenactment User Study</h1>
|
| 1322 |
+
</div>
|
| 1323 |
+
<p class="lead-text">
|
| 1324 |
+
This study evaluates perceptual quality in human motion reenactment. In each question, you will watch
|
| 1325 |
+
one <strong>reference video</strong> and two <strong>anonymous results</strong>, displayed on the page as
|
| 1326 |
+
<strong>Result A</strong> and <strong>Result B</strong>. You will answer three evaluation questions covering
|
| 1327 |
+
<strong>Motion Similarity</strong>, <strong>Motion Quality</strong>, and <strong>Overall Preference</strong>.
|
| 1328 |
+
</p>
|
| 1329 |
+
<ul class="instruction-list">
|
| 1330 |
+
<li>Each participant completes <strong>{total_questions}</strong> pairwise comparison samples.</li>
|
| 1331 |
+
<li>Each comparison presents one reference clip together with Result A and Result B shown side by side.</li>
|
| 1332 |
+
<li>Result A and Result B may have different durations, but both are reenactments of the character motion over the full reference clip; please ignore this length difference.</li>
|
| 1333 |
+
<li>Please answer all three questions before moving to the next page.</li>
|
| 1334 |
+
</ul>
|
| 1335 |
+
{judging_instruction_html}
|
| 1336 |
+
</div>
|
| 1337 |
+
<div class="instruction-top-right">
|
| 1338 |
+
<div class="diagram-card">
|
| 1339 |
+
{case_walkthrough_html}
|
| 1340 |
+
</div>
|
| 1341 |
+
<div class="walkthrough-note accent-note">
|
| 1342 |
+
<strong>How to interpret the task</strong>
|
| 1343 |
+
<p>
|
| 1344 |
+
The goal is to assess how well a human reenactment imitates the motion of the character in the reference video.
|
| 1345 |
+
A strong result should preserve the reference motion while still looking smooth, stable, and physically natural as human movement.
|
| 1346 |
+
</p>
|
| 1347 |
+
<p>
|
| 1348 |
+
When the source character does not map directly to a human body, it is acceptable to use other human limbs
|
| 1349 |
+
to simulate the missing functional parts, such as using arms to mimic wings, as long as the motion intent
|
| 1350 |
+
and dynamics remain plausible and close to the reference.
|
| 1351 |
+
</p>
|
| 1352 |
+
</div>
|
| 1353 |
+
</div>
|
| 1354 |
+
</div>
|
| 1355 |
+
</div>
|
| 1356 |
+
</div>
|
| 1357 |
+
""".strip()
|
| 1358 |
+
|
| 1359 |
+
|
| 1360 |
+
def build_example_caption(language: str | None) -> str:
|
| 1361 |
+
language = normalize_language(language)
|
| 1362 |
+
if language == "zh":
|
| 1363 |
+
return """
|
| 1364 |
+
<div class="example-caption-note">
|
| 1365 |
+
下方展示的是同一个示例案例在正式问卷中的实际观看布局。参与者需要将参考视频与结果 A、结果 B 进行比较,并回答三个评价问题。
|
| 1366 |
+
</div>
|
| 1367 |
+
""".strip()
|
| 1368 |
+
|
| 1369 |
+
return """
|
| 1370 |
+
<div class="example-caption-note">
|
| 1371 |
+
Below is the same example case shown in the actual questionnaire layout. Participants compare the reference clip
|
| 1372 |
+
against Result A and Result B and answer the three evaluation questions.
|
| 1373 |
+
</div>
|
| 1374 |
+
""".strip()
|
| 1375 |
+
|
| 1376 |
+
|
| 1377 |
+
def resolve_browser_participant_id(browser_participant_id: str | None) -> str:
|
| 1378 |
+
sanitized = sanitize_participant_id(browser_participant_id)
|
| 1379 |
+
return sanitized or generate_participant_id()
|
| 1380 |
+
|
| 1381 |
+
|
| 1382 |
+
def build_intro_component_updates(
|
| 1383 |
+
config: dict[str, Any],
|
| 1384 |
+
intro_case: dict[str, Any],
|
| 1385 |
+
language: str | None,
|
| 1386 |
+
) -> tuple[Any, ...]:
|
| 1387 |
+
language = normalize_language(language)
|
| 1388 |
+
case_walkthrough_html = build_instruction_case_html(intro_case, language)
|
| 1389 |
+
judging_instruction_html = build_judging_instruction_html(language)
|
| 1390 |
+
return (
|
| 1391 |
+
gr.update(value=build_instruction_html(config, case_walkthrough_html, judging_instruction_html, language)),
|
| 1392 |
+
gr.update(value=build_example_caption(language), visible=False),
|
| 1393 |
+
gr.update(label=tr(language, "reference_video")),
|
| 1394 |
+
gr.update(value=video_caption_html(tr(language, "reference_caption"))),
|
| 1395 |
+
gr.update(label=tr(language, "candidate_a")),
|
| 1396 |
+
gr.update(value=video_caption_html(tr(language, "result_a_caption"))),
|
| 1397 |
+
gr.update(label=tr(language, "candidate_b")),
|
| 1398 |
+
gr.update(value=video_caption_html(tr(language, "result_b_caption"))),
|
| 1399 |
+
gr.update(value=build_participant_setup_markdown(language)),
|
| 1400 |
+
gr.update(value=tr(language, "generate_fresh_id")),
|
| 1401 |
+
gr.update(value=tr(language, "start_continue")),
|
| 1402 |
+
)
|
| 1403 |
+
|
| 1404 |
+
|
| 1405 |
+
def build_study_component_updates(
|
| 1406 |
+
language: str | None,
|
| 1407 |
+
similarity_value: str | None,
|
| 1408 |
+
quality_value: str | None,
|
| 1409 |
+
preference_value: str | None,
|
| 1410 |
+
show_previous: bool,
|
| 1411 |
+
show_next: bool,
|
| 1412 |
+
show_submit: bool,
|
| 1413 |
+
) -> tuple[Any, ...]:
|
| 1414 |
+
language = normalize_language(language)
|
| 1415 |
+
return (
|
| 1416 |
+
gr.update(label=tr(language, "reference_video")),
|
| 1417 |
+
gr.update(label=tr(language, "result_a_left")),
|
| 1418 |
+
gr.update(label=tr(language, "result_b_right")),
|
| 1419 |
+
gr.update(
|
| 1420 |
+
choices=choice_options_for_language(language),
|
| 1421 |
+
label=tr(language, "question_similarity"),
|
| 1422 |
+
value=similarity_value,
|
| 1423 |
+
),
|
| 1424 |
+
gr.update(
|
| 1425 |
+
choices=choice_options_for_language(language),
|
| 1426 |
+
label=tr(language, "question_quality"),
|
| 1427 |
+
value=quality_value,
|
| 1428 |
+
),
|
| 1429 |
+
gr.update(
|
| 1430 |
+
choices=choice_options_for_language(language),
|
| 1431 |
+
label=tr(language, "question_preference"),
|
| 1432 |
+
value=preference_value,
|
| 1433 |
+
),
|
| 1434 |
+
gr.update(value=tr(language, "previous"), visible=show_previous),
|
| 1435 |
+
gr.update(value=tr(language, "next"), visible=show_next),
|
| 1436 |
+
gr.update(value=tr(language, "submit_study"), visible=show_submit),
|
| 1437 |
+
gr.update(value=video_caption_html(tr(language, "reference_caption"))),
|
| 1438 |
+
gr.update(value=video_caption_html(tr(language, "result_a_caption"))),
|
| 1439 |
+
gr.update(value=video_caption_html(tr(language, "result_b_caption"))),
|
| 1440 |
+
)
|
| 1441 |
+
|
| 1442 |
+
|
| 1443 |
+
def render_intro_view(
|
| 1444 |
+
config: dict[str, Any],
|
| 1445 |
+
intro_case: dict[str, Any],
|
| 1446 |
+
language: str | None,
|
| 1447 |
+
participant_id: str | None = None,
|
| 1448 |
+
browser_participant_id: str | None = None,
|
| 1449 |
+
start_message: str = "",
|
| 1450 |
+
) -> Tuple[Any, ...]:
|
| 1451 |
+
language = normalize_language(language)
|
| 1452 |
+
resolved_participant_id = resolve_browser_participant_id(browser_participant_id or participant_id)
|
| 1453 |
+
intro_updates = build_intro_component_updates(config, intro_case, language)
|
| 1454 |
+
study_updates = build_study_component_updates(
|
| 1455 |
+
language,
|
| 1456 |
+
similarity_value=None,
|
| 1457 |
+
quality_value=None,
|
| 1458 |
+
preference_value=None,
|
| 1459 |
+
show_previous=False,
|
| 1460 |
+
show_next=True,
|
| 1461 |
+
show_submit=False,
|
| 1462 |
+
)
|
| 1463 |
+
return (
|
| 1464 |
+
gr.update(visible=True),
|
| 1465 |
+
gr.update(visible=False),
|
| 1466 |
+
gr.update(visible=False),
|
| 1467 |
+
{},
|
| 1468 |
+
"",
|
| 1469 |
+
"",
|
| 1470 |
+
tr(language, "study_instruction"),
|
| 1471 |
+
None,
|
| 1472 |
+
None,
|
| 1473 |
+
None,
|
| 1474 |
+
study_updates[3],
|
| 1475 |
+
study_updates[4],
|
| 1476 |
+
study_updates[5],
|
| 1477 |
+
"",
|
| 1478 |
+
study_updates[6],
|
| 1479 |
+
study_updates[7],
|
| 1480 |
+
study_updates[8],
|
| 1481 |
+
start_message,
|
| 1482 |
+
"",
|
| 1483 |
+
gr.update(value=resolved_participant_id, label=tr(language, "participant_id_label")),
|
| 1484 |
+
resolved_participant_id,
|
| 1485 |
+
language,
|
| 1486 |
+
gr.update(value=language, label=LANGUAGE_SWITCH_LABEL),
|
| 1487 |
+
*intro_updates,
|
| 1488 |
+
study_updates[9],
|
| 1489 |
+
study_updates[10],
|
| 1490 |
+
study_updates[11],
|
| 1491 |
+
)
|
| 1492 |
+
|
| 1493 |
+
|
| 1494 |
+
def render_question_view(
|
| 1495 |
+
config: dict[str, Any],
|
| 1496 |
+
intro_case: dict[str, Any],
|
| 1497 |
+
language: str | None,
|
| 1498 |
+
state: dict[str, Any],
|
| 1499 |
+
study_message: str = "",
|
| 1500 |
+
draft_answers: tuple[str | None, str | None, str | None] | None = None,
|
| 1501 |
+
) -> Tuple[Any, ...]:
|
| 1502 |
+
language = normalize_language(language)
|
| 1503 |
+
payload = build_question_payload(state)
|
| 1504 |
+
synced_videos = ensure_synchronized_study_videos(
|
| 1505 |
+
reference_video=payload["reference_video"],
|
| 1506 |
+
left_video=payload["left_video"],
|
| 1507 |
+
right_video=payload["right_video"],
|
| 1508 |
+
project_root=PROJECT_ROOT,
|
| 1509 |
+
)
|
| 1510 |
+
similarity_value = draft_answers[0] if draft_answers and draft_answers[0] is not None else payload["answer_similarity"]
|
| 1511 |
+
quality_value = draft_answers[1] if draft_answers and draft_answers[1] is not None else payload["answer_quality"]
|
| 1512 |
+
preference_value = draft_answers[2] if draft_answers and draft_answers[2] is not None else payload["answer_preference"]
|
| 1513 |
+
intro_updates = build_intro_component_updates(config, intro_case, language)
|
| 1514 |
+
study_updates = build_study_component_updates(
|
| 1515 |
+
language,
|
| 1516 |
+
similarity_value=similarity_value,
|
| 1517 |
+
quality_value=quality_value,
|
| 1518 |
+
preference_value=preference_value,
|
| 1519 |
+
show_previous=payload["show_previous"],
|
| 1520 |
+
show_next=payload["show_next"],
|
| 1521 |
+
show_submit=payload["show_submit"],
|
| 1522 |
+
)
|
| 1523 |
+
return (
|
| 1524 |
+
gr.update(visible=False),
|
| 1525 |
+
gr.update(visible=True),
|
| 1526 |
+
gr.update(visible=False),
|
| 1527 |
+
state,
|
| 1528 |
+
payload["question_token"],
|
| 1529 |
+
build_progress_markdown(state, language),
|
| 1530 |
+
tr(language, "study_instruction"),
|
| 1531 |
+
gr.update(value=synced_videos["reference_video"], label=tr(language, "reference_video")),
|
| 1532 |
+
gr.update(value=synced_videos["left_video"], label=tr(language, "result_a_left")),
|
| 1533 |
+
gr.update(value=synced_videos["right_video"], label=tr(language, "result_b_right")),
|
| 1534 |
+
study_updates[3],
|
| 1535 |
+
study_updates[4],
|
| 1536 |
+
study_updates[5],
|
| 1537 |
+
study_message,
|
| 1538 |
+
study_updates[6],
|
| 1539 |
+
study_updates[7],
|
| 1540 |
+
study_updates[8],
|
| 1541 |
+
"",
|
| 1542 |
+
"",
|
| 1543 |
+
gr.update(value=state["participant_id"], label=tr(language, "participant_id_label")),
|
| 1544 |
+
state["participant_id"],
|
| 1545 |
+
language,
|
| 1546 |
+
gr.update(value=language, label=LANGUAGE_SWITCH_LABEL),
|
| 1547 |
+
*intro_updates,
|
| 1548 |
+
study_updates[9],
|
| 1549 |
+
study_updates[10],
|
| 1550 |
+
study_updates[11],
|
| 1551 |
+
)
|
| 1552 |
+
|
| 1553 |
+
|
| 1554 |
+
def render_thank_you_view(
|
| 1555 |
+
config: dict[str, Any],
|
| 1556 |
+
intro_case: dict[str, Any],
|
| 1557 |
+
language: str | None,
|
| 1558 |
+
state: dict[str, Any],
|
| 1559 |
+
) -> Tuple[Any, ...]:
|
| 1560 |
+
language = normalize_language(language)
|
| 1561 |
+
intro_updates = build_intro_component_updates(config, intro_case, language)
|
| 1562 |
+
study_updates = build_study_component_updates(
|
| 1563 |
+
language,
|
| 1564 |
+
similarity_value=None,
|
| 1565 |
+
quality_value=None,
|
| 1566 |
+
preference_value=None,
|
| 1567 |
+
show_previous=False,
|
| 1568 |
+
show_next=False,
|
| 1569 |
+
show_submit=False,
|
| 1570 |
+
)
|
| 1571 |
+
return (
|
| 1572 |
+
gr.update(visible=False),
|
| 1573 |
+
gr.update(visible=False),
|
| 1574 |
+
gr.update(visible=True),
|
| 1575 |
+
state,
|
| 1576 |
+
"",
|
| 1577 |
+
"",
|
| 1578 |
+
tr(language, "study_instruction"),
|
| 1579 |
+
gr.update(value=None),
|
| 1580 |
+
gr.update(value=None),
|
| 1581 |
+
gr.update(value=None),
|
| 1582 |
+
study_updates[3],
|
| 1583 |
+
study_updates[4],
|
| 1584 |
+
study_updates[5],
|
| 1585 |
+
"",
|
| 1586 |
+
study_updates[6],
|
| 1587 |
+
study_updates[7],
|
| 1588 |
+
study_updates[8],
|
| 1589 |
+
"",
|
| 1590 |
+
build_completion_markdown_local(state, language),
|
| 1591 |
+
gr.update(value=state["participant_id"], label=tr(language, "participant_id_label")),
|
| 1592 |
+
state["participant_id"],
|
| 1593 |
+
language,
|
| 1594 |
+
gr.update(value=language, label=LANGUAGE_SWITCH_LABEL),
|
| 1595 |
+
*intro_updates,
|
| 1596 |
+
study_updates[9],
|
| 1597 |
+
study_updates[10],
|
| 1598 |
+
study_updates[11],
|
| 1599 |
+
)
|
| 1600 |
+
|
| 1601 |
+
|
| 1602 |
+
def _drop_language_selector_update(payload: Tuple[Any, ...]) -> Tuple[Any, ...]:
|
| 1603 |
+
language_selector_index = 22
|
| 1604 |
+
return payload[:language_selector_index] + payload[language_selector_index + 1 :]
|
| 1605 |
+
|
| 1606 |
+
|
| 1607 |
+
def build_demo(config_path: Path) -> gr.Blocks:
|
| 1608 |
+
ensure_local_ffmpeg()
|
| 1609 |
+
patch_gradio_video_probe()
|
| 1610 |
+
config = load_study_config(config_path)
|
| 1611 |
+
ensure_runtime_dirs(PROJECT_ROOT)
|
| 1612 |
+
config = prepare_reference_videos_for_web(config, PROJECT_ROOT)
|
| 1613 |
+
upgrade_existing_results_schema(PROJECT_ROOT, config)
|
| 1614 |
+
default_language = "en"
|
| 1615 |
+
choice_options = choice_options_for_language(default_language)
|
| 1616 |
+
|
| 1617 |
+
intro_case = get_instruction_case(config)
|
| 1618 |
+
example_left_path = intro_case["method_videos"]["anyact"]
|
| 1619 |
+
example_right_path = intro_case["method_videos"]["vlm_hy_motion"]
|
| 1620 |
+
|
| 1621 |
+
with gr.Blocks(
|
| 1622 |
+
title=config["study_title"],
|
| 1623 |
+
css=CUSTOM_CSS,
|
| 1624 |
+
head=CUSTOM_HEAD,
|
| 1625 |
+
theme=gr.themes.Soft(
|
| 1626 |
+
primary_hue="blue",
|
| 1627 |
+
secondary_hue="sky",
|
| 1628 |
+
neutral_hue="slate",
|
| 1629 |
+
),
|
| 1630 |
+
) as demo:
|
| 1631 |
+
participant_state = gr.State({})
|
| 1632 |
+
question_token = gr.State("")
|
| 1633 |
+
browser_participant_id = gr.BrowserState(
|
| 1634 |
+
"",
|
| 1635 |
+
storage_key=f"{config['study_id']}_participant_id",
|
| 1636 |
+
)
|
| 1637 |
+
browser_language = gr.BrowserState(
|
| 1638 |
+
default_language,
|
| 1639 |
+
storage_key=f"{config['study_id']}_language",
|
| 1640 |
+
)
|
| 1641 |
+
|
| 1642 |
+
with gr.Row(elem_classes=["language-switch-row"]):
|
| 1643 |
+
language_selector = gr.Radio(
|
| 1644 |
+
choices=LANGUAGE_CHOICES,
|
| 1645 |
+
value=default_language,
|
| 1646 |
+
label=LANGUAGE_SWITCH_LABEL,
|
| 1647 |
+
interactive=True,
|
| 1648 |
+
show_label=False,
|
| 1649 |
+
elem_classes=["language-switch"],
|
| 1650 |
+
)
|
| 1651 |
+
|
| 1652 |
+
with gr.Column(visible=True) as intro_panel:
|
| 1653 |
+
intro_instruction_html = gr.HTML(
|
| 1654 |
+
build_instruction_html(
|
| 1655 |
+
config=config,
|
| 1656 |
+
case_walkthrough_html=build_instruction_case_html(intro_case, default_language),
|
| 1657 |
+
judging_instruction_html=build_judging_instruction_html(default_language),
|
| 1658 |
+
language=default_language,
|
| 1659 |
+
)
|
| 1660 |
+
)
|
| 1661 |
+
example_caption_md = gr.Markdown(build_example_caption(default_language), visible=False)
|
| 1662 |
+
with gr.Row(visible=False):
|
| 1663 |
+
with gr.Column(scale=5):
|
| 1664 |
+
intro_reference_video = gr.Video(
|
| 1665 |
+
value=intro_case["reference_video"],
|
| 1666 |
+
label=tr(default_language, "reference_video"),
|
| 1667 |
+
autoplay=True,
|
| 1668 |
+
loop=True,
|
| 1669 |
+
elem_classes=["panel-card", "video-panel", "reference-panel"],
|
| 1670 |
+
)
|
| 1671 |
+
intro_reference_caption = gr.Markdown(video_caption_html(tr(default_language, "reference_caption")))
|
| 1672 |
+
with gr.Column(scale=4):
|
| 1673 |
+
intro_left_video = gr.Video(
|
| 1674 |
+
value=example_left_path,
|
| 1675 |
+
label=tr(default_language, "candidate_a"),
|
| 1676 |
+
autoplay=True,
|
| 1677 |
+
loop=True,
|
| 1678 |
+
elem_classes=["panel-card", "video-panel", "candidate-panel"],
|
| 1679 |
+
)
|
| 1680 |
+
intro_left_caption = gr.Markdown(video_caption_html(tr(default_language, "result_a_caption")))
|
| 1681 |
+
with gr.Column(scale=4):
|
| 1682 |
+
intro_right_video = gr.Video(
|
| 1683 |
+
value=example_right_path,
|
| 1684 |
+
label=tr(default_language, "candidate_b"),
|
| 1685 |
+
autoplay=True,
|
| 1686 |
+
loop=True,
|
| 1687 |
+
elem_classes=["panel-card", "video-panel", "candidate-panel"],
|
| 1688 |
+
)
|
| 1689 |
+
intro_right_caption = gr.Markdown(video_caption_html(tr(default_language, "result_b_caption")))
|
| 1690 |
+
|
| 1691 |
+
with gr.Group(elem_classes=["form-card"]):
|
| 1692 |
+
participant_setup_md = gr.Markdown(build_participant_setup_markdown(default_language))
|
| 1693 |
+
participant_id_box = gr.Textbox(
|
| 1694 |
+
label=tr(default_language, "participant_id_label"),
|
| 1695 |
+
interactive=False,
|
| 1696 |
+
)
|
| 1697 |
+
regenerate_button = gr.Button(tr(default_language, "generate_fresh_id"))
|
| 1698 |
+
start_message = gr.Markdown()
|
| 1699 |
+
start_button = gr.Button(tr(default_language, "start_continue"), variant="primary")
|
| 1700 |
+
|
| 1701 |
+
with gr.Column(visible=False, elem_classes=["study-shell"]) as study_panel:
|
| 1702 |
+
progress_html = gr.HTML()
|
| 1703 |
+
study_notice = gr.Markdown(tr(default_language, "study_instruction"))
|
| 1704 |
+
with gr.Row():
|
| 1705 |
+
with gr.Column(scale=5):
|
| 1706 |
+
reference_video = gr.Video(
|
| 1707 |
+
label=tr(default_language, "reference_video"),
|
| 1708 |
+
autoplay=True,
|
| 1709 |
+
loop=True,
|
| 1710 |
+
elem_id="study-reference-video",
|
| 1711 |
+
elem_classes=["panel-card", "video-panel", "reference-panel"],
|
| 1712 |
+
)
|
| 1713 |
+
study_reference_caption = gr.Markdown(video_caption_html(tr(default_language, "reference_caption")))
|
| 1714 |
+
with gr.Column(scale=4):
|
| 1715 |
+
left_video = gr.Video(
|
| 1716 |
+
label=tr(default_language, "result_a_left"),
|
| 1717 |
+
autoplay=True,
|
| 1718 |
+
loop=True,
|
| 1719 |
+
elem_id="study-left-video",
|
| 1720 |
+
elem_classes=["panel-card", "video-panel", "candidate-panel"],
|
| 1721 |
+
)
|
| 1722 |
+
study_left_caption = gr.Markdown(video_caption_html(tr(default_language, "result_a_caption")))
|
| 1723 |
+
with gr.Column(scale=4):
|
| 1724 |
+
right_video = gr.Video(
|
| 1725 |
+
label=tr(default_language, "result_b_right"),
|
| 1726 |
+
autoplay=True,
|
| 1727 |
+
loop=True,
|
| 1728 |
+
elem_id="study-right-video",
|
| 1729 |
+
elem_classes=["panel-card", "video-panel", "candidate-panel"],
|
| 1730 |
+
)
|
| 1731 |
+
study_right_caption = gr.Markdown(video_caption_html(tr(default_language, "result_b_caption")))
|
| 1732 |
+
|
| 1733 |
+
with gr.Group(elem_classes=["question-card"]):
|
| 1734 |
+
similarity_radio = gr.Radio(
|
| 1735 |
+
choices=choice_options,
|
| 1736 |
+
label=tr(default_language, "question_similarity"),
|
| 1737 |
+
elem_classes=["choice-input"],
|
| 1738 |
+
)
|
| 1739 |
+
quality_radio = gr.Radio(
|
| 1740 |
+
choices=choice_options,
|
| 1741 |
+
label=tr(default_language, "question_quality"),
|
| 1742 |
+
elem_classes=["choice-input"],
|
| 1743 |
+
)
|
| 1744 |
+
preference_radio = gr.Radio(
|
| 1745 |
+
choices=choice_options,
|
| 1746 |
+
label=tr(default_language, "question_preference"),
|
| 1747 |
+
elem_classes=["choice-input"],
|
| 1748 |
+
)
|
| 1749 |
+
study_message = gr.Markdown()
|
| 1750 |
+
|
| 1751 |
+
with gr.Row():
|
| 1752 |
+
previous_button = gr.Button(tr(default_language, "previous"))
|
| 1753 |
+
next_button = gr.Button(tr(default_language, "next"), variant="primary")
|
| 1754 |
+
submit_button = gr.Button(tr(default_language, "submit_study"), variant="primary", visible=False)
|
| 1755 |
+
|
| 1756 |
+
with gr.Column(visible=False) as thank_panel:
|
| 1757 |
+
with gr.Group(elem_classes=["hero-card", "thank-card"]):
|
| 1758 |
+
thank_you_markdown = gr.Markdown()
|
| 1759 |
+
|
| 1760 |
+
outputs = [
|
| 1761 |
+
intro_panel,
|
| 1762 |
+
study_panel,
|
| 1763 |
+
thank_panel,
|
| 1764 |
+
participant_state,
|
| 1765 |
+
question_token,
|
| 1766 |
+
progress_html,
|
| 1767 |
+
study_notice,
|
| 1768 |
+
reference_video,
|
| 1769 |
+
left_video,
|
| 1770 |
+
right_video,
|
| 1771 |
+
similarity_radio,
|
| 1772 |
+
quality_radio,
|
| 1773 |
+
preference_radio,
|
| 1774 |
+
study_message,
|
| 1775 |
+
previous_button,
|
| 1776 |
+
next_button,
|
| 1777 |
+
submit_button,
|
| 1778 |
+
start_message,
|
| 1779 |
+
thank_you_markdown,
|
| 1780 |
+
participant_id_box,
|
| 1781 |
+
browser_participant_id,
|
| 1782 |
+
browser_language,
|
| 1783 |
+
language_selector,
|
| 1784 |
+
intro_instruction_html,
|
| 1785 |
+
example_caption_md,
|
| 1786 |
+
intro_reference_video,
|
| 1787 |
+
intro_reference_caption,
|
| 1788 |
+
intro_left_video,
|
| 1789 |
+
intro_left_caption,
|
| 1790 |
+
intro_right_video,
|
| 1791 |
+
intro_right_caption,
|
| 1792 |
+
participant_setup_md,
|
| 1793 |
+
regenerate_button,
|
| 1794 |
+
start_button,
|
| 1795 |
+
study_reference_caption,
|
| 1796 |
+
study_left_caption,
|
| 1797 |
+
study_right_caption,
|
| 1798 |
+
]
|
| 1799 |
+
outputs_without_language_selector = outputs[:22] + outputs[23:]
|
| 1800 |
+
|
| 1801 |
+
def initialize_page(saved_participant_id: str, saved_language: str) -> Tuple[Any, ...]:
|
| 1802 |
+
language = normalize_language(saved_language)
|
| 1803 |
+
resolved_participant_id = resolve_browser_participant_id(saved_participant_id)
|
| 1804 |
+
start_message = ""
|
| 1805 |
+
if sanitize_participant_id(saved_participant_id):
|
| 1806 |
+
start_message = tr(language, "browser_saved_id_notice")
|
| 1807 |
+
return render_intro_view(
|
| 1808 |
+
config=config,
|
| 1809 |
+
intro_case=intro_case,
|
| 1810 |
+
language=language,
|
| 1811 |
+
participant_id=resolved_participant_id,
|
| 1812 |
+
browser_participant_id=resolved_participant_id,
|
| 1813 |
+
start_message=start_message,
|
| 1814 |
+
)
|
| 1815 |
+
|
| 1816 |
+
def handle_generate_new_id(current_language: str) -> Tuple[Any, str, str]:
|
| 1817 |
+
language = normalize_language(current_language)
|
| 1818 |
+
new_participant_id = generate_participant_id()
|
| 1819 |
+
return (
|
| 1820 |
+
gr.update(value=new_participant_id, label=tr(language, "participant_id_label")),
|
| 1821 |
+
new_participant_id,
|
| 1822 |
+
tr(language, "fresh_id_notice"),
|
| 1823 |
+
)
|
| 1824 |
+
|
| 1825 |
+
def handle_start(
|
| 1826 |
+
participant_id: str,
|
| 1827 |
+
current_language: str,
|
| 1828 |
+
request: gr.Request,
|
| 1829 |
+
) -> Tuple[Any, ...]:
|
| 1830 |
+
language = normalize_language(current_language)
|
| 1831 |
+
state, status = create_or_resume_participant(
|
| 1832 |
+
project_root=PROJECT_ROOT,
|
| 1833 |
+
config=config,
|
| 1834 |
+
participant_id=participant_id,
|
| 1835 |
+
request=request,
|
| 1836 |
+
)
|
| 1837 |
+
|
| 1838 |
+
if status == "completed":
|
| 1839 |
+
return render_intro_view(
|
| 1840 |
+
config=config,
|
| 1841 |
+
intro_case=intro_case,
|
| 1842 |
+
language=language,
|
| 1843 |
+
participant_id=state["participant_id"],
|
| 1844 |
+
browser_participant_id=state["participant_id"],
|
| 1845 |
+
start_message=tr(language, "completed_id_notice"),
|
| 1846 |
+
)
|
| 1847 |
+
|
| 1848 |
+
study_message_text = tr(language, "saved_progress_restored") if status == "resumed" else ""
|
| 1849 |
+
return render_question_view(
|
| 1850 |
+
config=config,
|
| 1851 |
+
intro_case=intro_case,
|
| 1852 |
+
language=language,
|
| 1853 |
+
state=state,
|
| 1854 |
+
study_message=study_message_text,
|
| 1855 |
+
)
|
| 1856 |
+
|
| 1857 |
+
def handle_previous(
|
| 1858 |
+
state: dict[str, Any],
|
| 1859 |
+
current_token: str,
|
| 1860 |
+
current_browser_participant_id: str,
|
| 1861 |
+
current_language: str,
|
| 1862 |
+
) -> Tuple[Any, ...]:
|
| 1863 |
+
language = normalize_language(current_language)
|
| 1864 |
+
if not state:
|
| 1865 |
+
resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id)
|
| 1866 |
+
return render_intro_view(
|
| 1867 |
+
config=config,
|
| 1868 |
+
intro_case=intro_case,
|
| 1869 |
+
language=language,
|
| 1870 |
+
participant_id=resolved_participant_id,
|
| 1871 |
+
browser_participant_id=resolved_participant_id,
|
| 1872 |
+
start_message=tr(language, "session_empty"),
|
| 1873 |
+
)
|
| 1874 |
+
|
| 1875 |
+
updated_state, message = move_question_pointer(
|
| 1876 |
+
project_root=PROJECT_ROOT,
|
| 1877 |
+
participant_id=state["participant_id"],
|
| 1878 |
+
question_token=current_token,
|
| 1879 |
+
direction="previous",
|
| 1880 |
+
)
|
| 1881 |
+
return render_question_view(
|
| 1882 |
+
config=config,
|
| 1883 |
+
intro_case=intro_case,
|
| 1884 |
+
language=language,
|
| 1885 |
+
state=updated_state,
|
| 1886 |
+
study_message=message,
|
| 1887 |
+
)
|
| 1888 |
+
|
| 1889 |
+
def handle_next_or_submit(
|
| 1890 |
+
state: dict[str, Any],
|
| 1891 |
+
current_token: str,
|
| 1892 |
+
answer_similarity: str,
|
| 1893 |
+
answer_quality: str,
|
| 1894 |
+
answer_preference: str,
|
| 1895 |
+
action: str,
|
| 1896 |
+
current_browser_participant_id: str,
|
| 1897 |
+
current_language: str,
|
| 1898 |
+
) -> Tuple[Any, ...]:
|
| 1899 |
+
language = normalize_language(current_language)
|
| 1900 |
+
if not state:
|
| 1901 |
+
resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id)
|
| 1902 |
+
return render_intro_view(
|
| 1903 |
+
config=config,
|
| 1904 |
+
intro_case=intro_case,
|
| 1905 |
+
language=language,
|
| 1906 |
+
participant_id=resolved_participant_id,
|
| 1907 |
+
browser_participant_id=resolved_participant_id,
|
| 1908 |
+
start_message=tr(language, "session_empty"),
|
| 1909 |
+
)
|
| 1910 |
+
|
| 1911 |
+
if not answer_similarity or not answer_quality or not answer_preference:
|
| 1912 |
+
return render_question_view(
|
| 1913 |
+
config=config,
|
| 1914 |
+
intro_case=intro_case,
|
| 1915 |
+
language=language,
|
| 1916 |
+
state=state,
|
| 1917 |
+
study_message=tr(language, "answer_all_required"),
|
| 1918 |
+
draft_answers=(answer_similarity, answer_quality, answer_preference),
|
| 1919 |
+
)
|
| 1920 |
+
|
| 1921 |
+
updated_state, message, status = save_current_answer(
|
| 1922 |
+
project_root=PROJECT_ROOT,
|
| 1923 |
+
participant_id=state["participant_id"],
|
| 1924 |
+
question_token=current_token,
|
| 1925 |
+
answer_similarity=answer_similarity,
|
| 1926 |
+
answer_quality=answer_quality,
|
| 1927 |
+
answer_preference=answer_preference,
|
| 1928 |
+
action=action,
|
| 1929 |
+
)
|
| 1930 |
+
if status == "completed":
|
| 1931 |
+
return render_thank_you_view(
|
| 1932 |
+
config=config,
|
| 1933 |
+
intro_case=intro_case,
|
| 1934 |
+
language=language,
|
| 1935 |
+
state=updated_state,
|
| 1936 |
+
)
|
| 1937 |
+
return render_question_view(
|
| 1938 |
+
config=config,
|
| 1939 |
+
intro_case=intro_case,
|
| 1940 |
+
language=language,
|
| 1941 |
+
state=updated_state,
|
| 1942 |
+
study_message=message,
|
| 1943 |
+
)
|
| 1944 |
+
|
| 1945 |
+
def handle_language_change(
|
| 1946 |
+
selected_language: str,
|
| 1947 |
+
state: dict[str, Any],
|
| 1948 |
+
current_browser_participant_id: str,
|
| 1949 |
+
current_token: str,
|
| 1950 |
+
answer_similarity: str | None,
|
| 1951 |
+
answer_quality: str | None,
|
| 1952 |
+
answer_preference: str | None,
|
| 1953 |
+
current_start_message: str,
|
| 1954 |
+
current_study_message: str,
|
| 1955 |
+
) -> Tuple[Any, ...]:
|
| 1956 |
+
language = normalize_language(selected_language)
|
| 1957 |
+
if state:
|
| 1958 |
+
if state.get("completed_at"):
|
| 1959 |
+
return _drop_language_selector_update(
|
| 1960 |
+
render_thank_you_view(
|
| 1961 |
+
config=config,
|
| 1962 |
+
intro_case=intro_case,
|
| 1963 |
+
language=language,
|
| 1964 |
+
state=state,
|
| 1965 |
+
)
|
| 1966 |
+
)
|
| 1967 |
+
return _drop_language_selector_update(
|
| 1968 |
+
render_question_view(
|
| 1969 |
+
config=config,
|
| 1970 |
+
intro_case=intro_case,
|
| 1971 |
+
language=language,
|
| 1972 |
+
state=state,
|
| 1973 |
+
study_message=current_study_message,
|
| 1974 |
+
draft_answers=(answer_similarity, answer_quality, answer_preference),
|
| 1975 |
+
)
|
| 1976 |
+
)
|
| 1977 |
+
|
| 1978 |
+
resolved_participant_id = resolve_browser_participant_id(current_browser_participant_id)
|
| 1979 |
+
return _drop_language_selector_update(
|
| 1980 |
+
render_intro_view(
|
| 1981 |
+
config=config,
|
| 1982 |
+
intro_case=intro_case,
|
| 1983 |
+
language=language,
|
| 1984 |
+
participant_id=resolved_participant_id,
|
| 1985 |
+
browser_participant_id=resolved_participant_id,
|
| 1986 |
+
start_message=current_start_message,
|
| 1987 |
+
)
|
| 1988 |
+
)
|
| 1989 |
+
|
| 1990 |
+
demo.load(initialize_page, inputs=[browser_participant_id, browser_language], outputs=outputs)
|
| 1991 |
+
regenerate_button.click(
|
| 1992 |
+
handle_generate_new_id,
|
| 1993 |
+
inputs=[browser_language],
|
| 1994 |
+
outputs=[participant_id_box, browser_participant_id, start_message],
|
| 1995 |
+
)
|
| 1996 |
+
start_button.click(handle_start, inputs=[browser_participant_id, browser_language], outputs=outputs)
|
| 1997 |
+
previous_button.click(
|
| 1998 |
+
handle_previous,
|
| 1999 |
+
inputs=[participant_state, question_token, browser_participant_id, browser_language],
|
| 2000 |
+
outputs=outputs,
|
| 2001 |
+
)
|
| 2002 |
+
next_button.click(
|
| 2003 |
+
lambda state, token, similarity, quality, preference, browser_pid, current_language: handle_next_or_submit(
|
| 2004 |
+
state, token, similarity, quality, preference, "next", browser_pid, current_language
|
| 2005 |
+
),
|
| 2006 |
+
inputs=[
|
| 2007 |
+
participant_state,
|
| 2008 |
+
question_token,
|
| 2009 |
+
similarity_radio,
|
| 2010 |
+
quality_radio,
|
| 2011 |
+
preference_radio,
|
| 2012 |
+
browser_participant_id,
|
| 2013 |
+
browser_language,
|
| 2014 |
+
],
|
| 2015 |
+
outputs=outputs,
|
| 2016 |
+
)
|
| 2017 |
+
submit_button.click(
|
| 2018 |
+
lambda state, token, similarity, quality, preference, browser_pid, current_language: handle_next_or_submit(
|
| 2019 |
+
state, token, similarity, quality, preference, "submit", browser_pid, current_language
|
| 2020 |
+
),
|
| 2021 |
+
inputs=[
|
| 2022 |
+
participant_state,
|
| 2023 |
+
question_token,
|
| 2024 |
+
similarity_radio,
|
| 2025 |
+
quality_radio,
|
| 2026 |
+
preference_radio,
|
| 2027 |
+
browser_participant_id,
|
| 2028 |
+
browser_language,
|
| 2029 |
+
],
|
| 2030 |
+
outputs=outputs,
|
| 2031 |
+
)
|
| 2032 |
+
language_selector.change(
|
| 2033 |
+
handle_language_change,
|
| 2034 |
+
inputs=[
|
| 2035 |
+
language_selector,
|
| 2036 |
+
participant_state,
|
| 2037 |
+
browser_participant_id,
|
| 2038 |
+
question_token,
|
| 2039 |
+
similarity_radio,
|
| 2040 |
+
quality_radio,
|
| 2041 |
+
preference_radio,
|
| 2042 |
+
start_message,
|
| 2043 |
+
study_message,
|
| 2044 |
+
],
|
| 2045 |
+
outputs=outputs_without_language_selector,
|
| 2046 |
+
)
|
| 2047 |
+
|
| 2048 |
+
return demo
|
| 2049 |
+
|
| 2050 |
+
|
| 2051 |
+
def parse_args() -> argparse.Namespace:
|
| 2052 |
+
parser = argparse.ArgumentParser(description="Launch the Gradio user study application.")
|
| 2053 |
+
parser.add_argument(
|
| 2054 |
+
"--config",
|
| 2055 |
+
type=Path,
|
| 2056 |
+
default=PROJECT_ROOT / "data" / "study_config.json",
|
| 2057 |
+
help="Path to the study configuration JSON file.",
|
| 2058 |
+
)
|
| 2059 |
+
parser.add_argument(
|
| 2060 |
+
"--port",
|
| 2061 |
+
type=int,
|
| 2062 |
+
default=int(os.environ.get("PORT", "7860")),
|
| 2063 |
+
help="Server port for Gradio.",
|
| 2064 |
+
)
|
| 2065 |
+
parser.add_argument(
|
| 2066 |
+
"--server-name",
|
| 2067 |
+
type=str,
|
| 2068 |
+
default=os.environ.get("GRADIO_SERVER_NAME", default_server_name()),
|
| 2069 |
+
help="Server bind address for Gradio.",
|
| 2070 |
+
)
|
| 2071 |
+
parser.add_argument(
|
| 2072 |
+
"--share",
|
| 2073 |
+
action="store_true",
|
| 2074 |
+
help="Enable Gradio's temporary public share link.",
|
| 2075 |
+
)
|
| 2076 |
+
return parser.parse_args()
|
| 2077 |
+
|
| 2078 |
+
|
| 2079 |
+
def main() -> None:
|
| 2080 |
+
args = parse_args()
|
| 2081 |
+
demo = build_demo(args.config)
|
| 2082 |
+
demo.queue()
|
| 2083 |
+
demo.launch(
|
| 2084 |
+
server_name=args.server_name,
|
| 2085 |
+
server_port=args.port,
|
| 2086 |
+
share=args.share,
|
| 2087 |
+
allowed_paths=[str(PROJECT_ROOT)],
|
| 2088 |
+
)
|
| 2089 |
+
|
| 2090 |
+
|
| 2091 |
+
if __name__ == "__main__":
|
| 2092 |
+
main()
|
assets/instruction_diagram.svg
ADDED
|
|
data/study_config.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"study_id": "anyact_human_motion_user_study_30sample_v3",
|
| 3 |
+
"study_title": "Human Motion Reenactment User Study",
|
| 4 |
+
"question_order": "shuffle_per_participant",
|
| 5 |
+
"allow_tie_option": false,
|
| 6 |
+
"disjoint_case_sampling": true,
|
| 7 |
+
"per_participant_pair_limits": {
|
| 8 |
+
"anyact_vs_vlm_hy_motion": 15,
|
| 9 |
+
"anyact_vs_echomotion": 15
|
| 10 |
+
},
|
| 11 |
+
"instruction_case_id": "case_003",
|
| 12 |
+
"reference": {
|
| 13 |
+
"directory": "../videos/reference",
|
| 14 |
+
"glob": "{source_key}_crop.mp4"
|
| 15 |
+
},
|
| 16 |
+
"methods": {
|
| 17 |
+
"anyact": {
|
| 18 |
+
"display_name": "AnyAct (ours)",
|
| 19 |
+
"directory": "../videos/anyact",
|
| 20 |
+
"glob": "{source_key}#0#*_gen.mp4"
|
| 21 |
+
},
|
| 22 |
+
"vlm_hy_motion": {
|
| 23 |
+
"display_name": "VLM+HY-Motion",
|
| 24 |
+
"directory": "../videos/vlm_hy_motion",
|
| 25 |
+
"glob": "{source_key}#0#*_gen_joints.mp4"
|
| 26 |
+
},
|
| 27 |
+
"echomotion": {
|
| 28 |
+
"display_name": "EchoMotion",
|
| 29 |
+
"directory": "../videos/echomotion",
|
| 30 |
+
"glob": "{source_key}_crop.mp4"
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
+
"pair_order": [
|
| 34 |
+
["anyact", "vlm_hy_motion"],
|
| 35 |
+
["anyact", "echomotion"]
|
| 36 |
+
],
|
| 37 |
+
"cases": [
|
| 38 |
+
{"case_id": "case_001", "source_key": "animal_dance2_776p"},
|
| 39 |
+
{"case_id": "case_002", "source_key": "bear_flap1_720p"},
|
| 40 |
+
{"case_id": "case_003", "source_key": "bear_jump1_540p"},
|
| 41 |
+
{"case_id": "case_004", "source_key": "bird_fly1_720p"},
|
| 42 |
+
{"case_id": "case_005", "source_key": "butterfly_fly3_720p"},
|
| 43 |
+
{"case_id": "case_006", "source_key": "cartoonhuman_dance1_istock"},
|
| 44 |
+
{"case_id": "case_007", "source_key": "chicken_walk1_istock"},
|
| 45 |
+
{"case_id": "case_008", "source_key": "crab_walk2_istock"},
|
| 46 |
+
{"case_id": "case_009", "source_key": "dear_walk1_istock"},
|
| 47 |
+
{"case_id": "case_010", "source_key": "dinosaur_walk2_720p"},
|
| 48 |
+
{"case_id": "case_011", "source_key": "dinosaur_walk7_istock"},
|
| 49 |
+
{"case_id": "case_012", "source_key": "frog_swim3_istock"},
|
| 50 |
+
{"case_id": "case_013", "source_key": "ghost_dance1_istock"},
|
| 51 |
+
{"case_id": "case_014", "source_key": "gorilla_walk1_720p"},
|
| 52 |
+
{"case_id": "case_015", "source_key": "human_jump1_istock"},
|
| 53 |
+
{"case_id": "case_016", "source_key": "kangeroo_jump4_istock"},
|
| 54 |
+
{"case_id": "case_017", "source_key": "kangeroo_jump5_1080p"},
|
| 55 |
+
{"case_id": "case_018", "source_key": "labubu_jump1_1080p"},
|
| 56 |
+
{"case_id": "case_019", "source_key": "luckycat_wave5_istock"},
|
| 57 |
+
{"case_id": "case_020", "source_key": "monkey_swing3_istock"},
|
| 58 |
+
{"case_id": "case_021", "source_key": "monkey_walk3_720p"},
|
| 59 |
+
{"case_id": "case_022", "source_key": "monster_fly2_720p"},
|
| 60 |
+
{"case_id": "case_023", "source_key": "monster_jump1_720p"},
|
| 61 |
+
{"case_id": "case_024", "source_key": "penguin_walk4_istock"},
|
| 62 |
+
{"case_id": "case_025", "source_key": "penguin_walk5_istock"},
|
| 63 |
+
{"case_id": "case_026", "source_key": "plane_fly1_720p"},
|
| 64 |
+
{"case_id": "case_027", "source_key": "rabit_jump1_720p"},
|
| 65 |
+
{"case_id": "case_028", "source_key": "seal_walk1_istock"},
|
| 66 |
+
{"case_id": "case_029", "source_key": "squirrel_eat1_720p"},
|
| 67 |
+
{"case_id": "case_030", "source_key": "turtle_swim3_istock"}
|
| 68 |
+
]
|
| 69 |
+
}
|
deploy_to_hf_space.bat
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal
|
| 3 |
+
cd /d %~dp0
|
| 4 |
+
|
| 5 |
+
if "%~1"=="" (
|
| 6 |
+
echo Usage: deploy_to_hf_space.bat username/space-name
|
| 7 |
+
echo Example: deploy_to_hf_space.bat yourname/anyact-user-study
|
| 8 |
+
exit /b 1
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
set "SPACE_REPO=%~1"
|
| 12 |
+
|
| 13 |
+
echo [1/3] Checking Hugging Face authentication...
|
| 14 |
+
hf auth whoami >nul 2>nul
|
| 15 |
+
if errorlevel 1 (
|
| 16 |
+
echo [ERROR] Hugging Face CLI is not logged in.
|
| 17 |
+
echo Please run: hf auth login
|
| 18 |
+
exit /b 1
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
echo [2/3] Creating the Space if needed...
|
| 22 |
+
hf repos create %SPACE_REPO% --type space --space-sdk gradio --public --exist-ok
|
| 23 |
+
if errorlevel 1 (
|
| 24 |
+
echo [ERROR] Failed to create or access the target Space.
|
| 25 |
+
exit /b 1
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
echo [3/3] Uploading the project...
|
| 29 |
+
hf upload-large-folder %SPACE_REPO% . --repo-type space --exclude ".venv/**" --exclude ".gradio/**" --exclude "__pycache__/**" --exclude "results/**"
|
| 30 |
+
if errorlevel 1 (
|
| 31 |
+
echo [ERROR] Upload failed.
|
| 32 |
+
exit /b 1
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
echo [DONE] Space uploaded successfully: https://huggingface.co/spaces/%SPACE_REPO%
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0,<6.0.0
|
| 2 |
+
pandas>=2.2.0
|
| 3 |
+
matplotlib>=3.8.0
|
| 4 |
+
filelock>=3.16.0
|
| 5 |
+
imageio-ffmpeg>=0.6.0
|
study_utils.py
ADDED
|
@@ -0,0 +1,1353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import copy
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import random
|
| 9 |
+
import re
|
| 10 |
+
import subprocess
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Dict, List, Tuple
|
| 16 |
+
|
| 17 |
+
from filelock import FileLock
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
import imageio_ffmpeg
|
| 21 |
+
except ImportError: # pragma: no cover - optional runtime dependency
|
| 22 |
+
imageio_ffmpeg = None
|
| 23 |
+
|
| 24 |
+
METHOD_FALLBACK_LABELS = {
|
| 25 |
+
"anyact": "AnyAct (ours)",
|
| 26 |
+
"vlm_hy_motion": "VLM+HY-Motion",
|
| 27 |
+
"echomotion": "EchoMotion",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
PAIRWISE_METHOD_PAIRS: List[Tuple[str, str]] = [
|
| 31 |
+
("anyact", "vlm_hy_motion"),
|
| 32 |
+
("anyact", "echomotion"),
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
CHOICE_OPTIONS = ["ResultA", "ResultB"]
|
| 36 |
+
|
| 37 |
+
CHOICE_RESULT_A = "ResultA"
|
| 38 |
+
CHOICE_RESULT_B = "ResultB"
|
| 39 |
+
CHOICE_TIE = "Tie"
|
| 40 |
+
|
| 41 |
+
CSV_COLUMNS = [
|
| 42 |
+
"participant_id",
|
| 43 |
+
"consent",
|
| 44 |
+
"study_id",
|
| 45 |
+
"study_title",
|
| 46 |
+
"question_id",
|
| 47 |
+
"question_position",
|
| 48 |
+
"total_questions",
|
| 49 |
+
"case_id",
|
| 50 |
+
"case_title",
|
| 51 |
+
"source_key",
|
| 52 |
+
"pair_id",
|
| 53 |
+
"result_a_method",
|
| 54 |
+
"result_b_method",
|
| 55 |
+
"left_method",
|
| 56 |
+
"right_method",
|
| 57 |
+
"reference_video",
|
| 58 |
+
"result_a_video",
|
| 59 |
+
"result_b_video",
|
| 60 |
+
"left_video",
|
| 61 |
+
"right_video",
|
| 62 |
+
"answer_similarity",
|
| 63 |
+
"answer_similarity_method",
|
| 64 |
+
"answer_similarity_video",
|
| 65 |
+
"answer_quality",
|
| 66 |
+
"answer_quality_method",
|
| 67 |
+
"answer_quality_video",
|
| 68 |
+
"answer_preference",
|
| 69 |
+
"answer_preference_method",
|
| 70 |
+
"answer_preference_video",
|
| 71 |
+
"answered_at",
|
| 72 |
+
"duration_seconds",
|
| 73 |
+
"session_hash",
|
| 74 |
+
"user_agent",
|
| 75 |
+
"started_at",
|
| 76 |
+
"updated_at",
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def now_iso() -> str:
|
| 81 |
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_results_dir(project_root: Path) -> Path:
|
| 85 |
+
explicit_dir = os.environ.get("USER_STUDY_RESULTS_DIR", "").strip()
|
| 86 |
+
if explicit_dir:
|
| 87 |
+
return Path(explicit_dir).expanduser().resolve()
|
| 88 |
+
|
| 89 |
+
if os.environ.get("SPACE_ID"):
|
| 90 |
+
space_data_dir = Path("/data")
|
| 91 |
+
if space_data_dir.exists():
|
| 92 |
+
return (space_data_dir / "user_study_results").resolve()
|
| 93 |
+
|
| 94 |
+
return (project_root / "results").resolve()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def ensure_runtime_dirs(project_root: Path) -> None:
|
| 98 |
+
results_dir = get_results_dir(project_root)
|
| 99 |
+
for path in [
|
| 100 |
+
results_dir,
|
| 101 |
+
results_dir / "participants",
|
| 102 |
+
results_dir / "participants_archive",
|
| 103 |
+
results_dir / "plots",
|
| 104 |
+
results_dir / "locks",
|
| 105 |
+
]:
|
| 106 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 107 |
+
|
| 108 |
+
responses_csv = results_dir / "responses.csv"
|
| 109 |
+
if not responses_csv.exists():
|
| 110 |
+
with responses_csv.open("w", newline="", encoding="utf-8") as handle:
|
| 111 |
+
writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
|
| 112 |
+
writer.writeheader()
|
| 113 |
+
|
| 114 |
+
responses_jsonl = results_dir / "responses.jsonl"
|
| 115 |
+
responses_jsonl.touch(exist_ok=True)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def generate_participant_id() -> str:
|
| 119 |
+
return str(uuid.uuid4())
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def sanitize_participant_id(raw_value: str | None) -> str:
|
| 123 |
+
cleaned = re.sub(r"[^A-Za-z0-9_-]", "_", (raw_value or "").strip())
|
| 124 |
+
return cleaned[:80]
|
| 125 |
+
|
| 126 |
+
def humanize_source_key(source_key: str) -> str:
|
| 127 |
+
return source_key.replace("_", " ").strip().title()
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def stable_int_seed(text: str) -> int:
|
| 131 |
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
| 132 |
+
return int(digest[:16], 16)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def build_pair_id(method_a: str, method_b: str) -> str:
|
| 136 |
+
return f"{method_a}_vs_{method_b}"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def normalize_choice_value(raw_value: Any) -> str:
|
| 140 |
+
if raw_value is None:
|
| 141 |
+
return ""
|
| 142 |
+
|
| 143 |
+
cleaned = str(raw_value).strip()
|
| 144 |
+
if not cleaned:
|
| 145 |
+
return ""
|
| 146 |
+
|
| 147 |
+
compact = re.sub(r"[\s_-]+", "", cleaned).lower()
|
| 148 |
+
if compact in {"left", "resulta", "a"}:
|
| 149 |
+
return CHOICE_RESULT_A
|
| 150 |
+
if compact in {"right", "resultb", "b"}:
|
| 151 |
+
return CHOICE_RESULT_B
|
| 152 |
+
if compact in {"tie", "equal", "same"}:
|
| 153 |
+
return CHOICE_TIE
|
| 154 |
+
return cleaned
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _sync_result_slot_fields(row: Dict[str, Any], case: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
| 158 |
+
result_a_method = str(row.get("result_a_method") or row.get("left_method") or "").strip()
|
| 159 |
+
result_b_method = str(row.get("result_b_method") or row.get("right_method") or "").strip()
|
| 160 |
+
|
| 161 |
+
result_a_video = str(row.get("result_a_video") or row.get("left_video") or "").strip()
|
| 162 |
+
result_b_video = str(row.get("result_b_video") or row.get("right_video") or "").strip()
|
| 163 |
+
|
| 164 |
+
if case is not None:
|
| 165 |
+
method_videos = case.get("method_videos", {})
|
| 166 |
+
if result_a_method in method_videos:
|
| 167 |
+
result_a_video = str(method_videos[result_a_method])
|
| 168 |
+
if result_b_method in method_videos:
|
| 169 |
+
result_b_video = str(method_videos[result_b_method])
|
| 170 |
+
if case.get("reference_video"):
|
| 171 |
+
row["reference_video"] = case["reference_video"]
|
| 172 |
+
|
| 173 |
+
row["result_a_method"] = result_a_method
|
| 174 |
+
row["result_b_method"] = result_b_method
|
| 175 |
+
row["left_method"] = result_a_method
|
| 176 |
+
row["right_method"] = result_b_method
|
| 177 |
+
row["result_a_video"] = result_a_video
|
| 178 |
+
row["result_b_video"] = result_b_video
|
| 179 |
+
row["left_video"] = result_a_video
|
| 180 |
+
row["right_video"] = result_b_video
|
| 181 |
+
return row
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _resolve_choice_targets(row: Dict[str, Any], raw_choice: Any) -> tuple[str, str]:
|
| 185 |
+
normalized_choice = normalize_choice_value(raw_choice)
|
| 186 |
+
if normalized_choice == CHOICE_RESULT_A:
|
| 187 |
+
return (
|
| 188 |
+
str(row.get("result_a_method") or row.get("left_method") or "").strip(),
|
| 189 |
+
str(row.get("result_a_video") or row.get("left_video") or "").strip(),
|
| 190 |
+
)
|
| 191 |
+
if normalized_choice == CHOICE_RESULT_B:
|
| 192 |
+
return (
|
| 193 |
+
str(row.get("result_b_method") or row.get("right_method") or "").strip(),
|
| 194 |
+
str(row.get("result_b_video") or row.get("right_video") or "").strip(),
|
| 195 |
+
)
|
| 196 |
+
return "", ""
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def upgrade_response_row_schema(row: Dict[str, Any], case: Dict[str, Any] | None = None) -> Dict[str, Any]:
|
| 200 |
+
upgraded = row
|
| 201 |
+
_sync_result_slot_fields(upgraded, case=case)
|
| 202 |
+
|
| 203 |
+
for metric_key in ["answer_similarity", "answer_quality", "answer_preference"]:
|
| 204 |
+
normalized_choice = normalize_choice_value(upgraded.get(metric_key))
|
| 205 |
+
if normalized_choice:
|
| 206 |
+
upgraded[metric_key] = normalized_choice
|
| 207 |
+
elif metric_key not in upgraded:
|
| 208 |
+
upgraded[metric_key] = ""
|
| 209 |
+
|
| 210 |
+
selected_method, selected_video = _resolve_choice_targets(upgraded, upgraded.get(metric_key))
|
| 211 |
+
upgraded[f"{metric_key}_method"] = selected_method
|
| 212 |
+
upgraded[f"{metric_key}_video"] = selected_video
|
| 213 |
+
|
| 214 |
+
return upgraded
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _web_video_cache_dir(project_root: Path) -> Path:
|
| 218 |
+
cache_dir = get_results_dir(project_root) / "web_video_cache"
|
| 219 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 220 |
+
return cache_dir
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _thumbnail_cache_dir(project_root: Path) -> Path:
|
| 224 |
+
cache_dir = get_results_dir(project_root) / "thumbnail_cache"
|
| 225 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 226 |
+
return cache_dir
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _synced_video_cache_dir(project_root: Path) -> Path:
|
| 230 |
+
cache_dir = get_results_dir(project_root) / "synced_video_cache"
|
| 231 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 232 |
+
return cache_dir
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _probe_video_stream(video_path: Path) -> Dict[str, str]:
|
| 236 |
+
if imageio_ffmpeg is None:
|
| 237 |
+
return {}
|
| 238 |
+
|
| 239 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 240 |
+
result = subprocess.run(
|
| 241 |
+
[ffmpeg_exe, "-i", str(video_path)],
|
| 242 |
+
capture_output=True,
|
| 243 |
+
text=True,
|
| 244 |
+
encoding="utf-8",
|
| 245 |
+
errors="ignore",
|
| 246 |
+
)
|
| 247 |
+
stderr_text = result.stderr or ""
|
| 248 |
+
match = re.search(r"Video:\s*([^\s,(]+).*?(yuv[a-zA-Z0-9]+)?", stderr_text)
|
| 249 |
+
if not match:
|
| 250 |
+
return {}
|
| 251 |
+
|
| 252 |
+
codec_name = (match.group(1) or "").strip().lower()
|
| 253 |
+
pixel_format = (match.group(2) or "").strip().lower()
|
| 254 |
+
return {
|
| 255 |
+
"codec_name": codec_name,
|
| 256 |
+
"pixel_format": pixel_format,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _parse_duration_to_seconds(duration_text: str) -> float:
|
| 261 |
+
hours, minutes, seconds = duration_text.split(":")
|
| 262 |
+
return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _probe_video_timing(video_path: Path) -> Dict[str, float]:
|
| 266 |
+
if imageio_ffmpeg is None:
|
| 267 |
+
return {}
|
| 268 |
+
|
| 269 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 270 |
+
result = subprocess.run(
|
| 271 |
+
[ffmpeg_exe, "-i", str(video_path)],
|
| 272 |
+
capture_output=True,
|
| 273 |
+
text=True,
|
| 274 |
+
encoding="utf-8",
|
| 275 |
+
errors="ignore",
|
| 276 |
+
)
|
| 277 |
+
stderr_text = result.stderr or ""
|
| 278 |
+
|
| 279 |
+
duration_match = re.search(r"Duration:\s*(\d+:\d+:\d+(?:\.\d+)?)", stderr_text)
|
| 280 |
+
fps_match = re.search(r"(\d+(?:\.\d+)?)\s+fps", stderr_text)
|
| 281 |
+
if fps_match is None:
|
| 282 |
+
fps_match = re.search(r"(\d+(?:\.\d+)?)\s+tbr", stderr_text)
|
| 283 |
+
|
| 284 |
+
metadata: Dict[str, float] = {}
|
| 285 |
+
if duration_match:
|
| 286 |
+
metadata["duration_seconds"] = _parse_duration_to_seconds(duration_match.group(1))
|
| 287 |
+
if fps_match:
|
| 288 |
+
metadata["fps"] = float(fps_match.group(1))
|
| 289 |
+
return metadata
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _format_ffmpeg_fps(value: float) -> str:
|
| 293 |
+
rounded = round(value)
|
| 294 |
+
if abs(value - rounded) < 1e-6:
|
| 295 |
+
return str(int(rounded))
|
| 296 |
+
return f"{value:.3f}".rstrip("0").rstrip(".")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _sync_single_video_to_duration(
|
| 300 |
+
source_path: Path,
|
| 301 |
+
target_path: Path,
|
| 302 |
+
target_duration: float,
|
| 303 |
+
target_fps: float,
|
| 304 |
+
) -> None:
|
| 305 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 306 |
+
source_timing = _probe_video_timing(source_path)
|
| 307 |
+
source_duration = float(source_timing.get("duration_seconds", 0.0) or 0.0)
|
| 308 |
+
pad_duration = max(0.0, target_duration - source_duration)
|
| 309 |
+
fps_literal = _format_ffmpeg_fps(target_fps)
|
| 310 |
+
filter_graph = (
|
| 311 |
+
f"fps={fps_literal},"
|
| 312 |
+
f"tpad=stop_mode=clone:stop_duration={pad_duration:.6f},"
|
| 313 |
+
f"trim=duration={target_duration:.6f},"
|
| 314 |
+
"setpts=PTS-STARTPTS"
|
| 315 |
+
)
|
| 316 |
+
command = [
|
| 317 |
+
ffmpeg_exe,
|
| 318 |
+
"-y",
|
| 319 |
+
"-i",
|
| 320 |
+
str(source_path),
|
| 321 |
+
"-an",
|
| 322 |
+
"-vf",
|
| 323 |
+
filter_graph,
|
| 324 |
+
"-c:v",
|
| 325 |
+
"libx264",
|
| 326 |
+
"-preset",
|
| 327 |
+
"veryfast",
|
| 328 |
+
"-pix_fmt",
|
| 329 |
+
"yuv420p",
|
| 330 |
+
"-movflags",
|
| 331 |
+
"+faststart",
|
| 332 |
+
str(target_path),
|
| 333 |
+
]
|
| 334 |
+
result = subprocess.run(
|
| 335 |
+
command,
|
| 336 |
+
capture_output=True,
|
| 337 |
+
text=True,
|
| 338 |
+
encoding="utf-8",
|
| 339 |
+
errors="ignore",
|
| 340 |
+
)
|
| 341 |
+
if result.returncode != 0 or not target_path.exists():
|
| 342 |
+
raise RuntimeError(
|
| 343 |
+
f"Failed to create synchronized study video: {source_path}\n{result.stderr}"
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def ensure_synchronized_study_videos(
|
| 348 |
+
reference_video: str,
|
| 349 |
+
left_video: str,
|
| 350 |
+
right_video: str,
|
| 351 |
+
project_root: Path,
|
| 352 |
+
target_fps: float = 30.0,
|
| 353 |
+
) -> Dict[str, str]:
|
| 354 |
+
"""
|
| 355 |
+
Create browser-playable synchronized copies for the three study videos.
|
| 356 |
+
|
| 357 |
+
The shorter videos are padded by cloning their last frame so that all three
|
| 358 |
+
outputs share the same fps and total duration. If synchronization fails for
|
| 359 |
+
any reason, the original paths are returned to keep the study app usable.
|
| 360 |
+
"""
|
| 361 |
+
raw_paths = {
|
| 362 |
+
"reference_video": Path(reference_video).resolve(),
|
| 363 |
+
"left_video": Path(left_video).resolve(),
|
| 364 |
+
"right_video": Path(right_video).resolve(),
|
| 365 |
+
}
|
| 366 |
+
if imageio_ffmpeg is None or not all(path.exists() for path in raw_paths.values()):
|
| 367 |
+
return {key: str(path) for key, path in raw_paths.items()}
|
| 368 |
+
|
| 369 |
+
try:
|
| 370 |
+
durations = []
|
| 371 |
+
for path in raw_paths.values():
|
| 372 |
+
timing = _probe_video_timing(path)
|
| 373 |
+
durations.append(float(timing.get("duration_seconds", 0.0) or 0.0))
|
| 374 |
+
|
| 375 |
+
target_duration = max(durations)
|
| 376 |
+
if target_duration <= 0:
|
| 377 |
+
return {key: str(path) for key, path in raw_paths.items()}
|
| 378 |
+
|
| 379 |
+
cache_dir = _synced_video_cache_dir(project_root)
|
| 380 |
+
signature = hashlib.sha1(
|
| 381 |
+
"::".join(
|
| 382 |
+
[
|
| 383 |
+
"sync_v1",
|
| 384 |
+
f"fps={_format_ffmpeg_fps(target_fps)}",
|
| 385 |
+
*(
|
| 386 |
+
f"{path.as_posix()}::{path.stat().st_mtime_ns}::{path.stat().st_size}"
|
| 387 |
+
for path in raw_paths.values()
|
| 388 |
+
),
|
| 389 |
+
]
|
| 390 |
+
).encode("utf-8")
|
| 391 |
+
).hexdigest()[:16]
|
| 392 |
+
trio_dir = cache_dir / signature
|
| 393 |
+
lock_path = trio_dir.with_suffix(".lock")
|
| 394 |
+
|
| 395 |
+
with FileLock(str(lock_path)):
|
| 396 |
+
trio_dir.mkdir(parents=True, exist_ok=True)
|
| 397 |
+
output_paths = {
|
| 398 |
+
"reference_video": trio_dir / "reference.mp4",
|
| 399 |
+
"left_video": trio_dir / "left.mp4",
|
| 400 |
+
"right_video": trio_dir / "right.mp4",
|
| 401 |
+
}
|
| 402 |
+
ready = all(path.exists() and path.stat().st_size > 0 for path in output_paths.values())
|
| 403 |
+
if not ready:
|
| 404 |
+
for key, source_path in raw_paths.items():
|
| 405 |
+
_sync_single_video_to_duration(
|
| 406 |
+
source_path=source_path,
|
| 407 |
+
target_path=output_paths[key],
|
| 408 |
+
target_duration=target_duration,
|
| 409 |
+
target_fps=target_fps,
|
| 410 |
+
)
|
| 411 |
+
return {key: str(path) for key, path in output_paths.items()}
|
| 412 |
+
except Exception as exc:
|
| 413 |
+
print(f"[warn] Falling back to original study videos because sync generation failed: {exc}")
|
| 414 |
+
return {key: str(path) for key, path in raw_paths.items()}
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def ensure_web_playable_video(video_path: str, project_root: Path) -> str:
|
| 418 |
+
source_path = Path(video_path).resolve()
|
| 419 |
+
if not source_path.exists() or imageio_ffmpeg is None:
|
| 420 |
+
return str(source_path)
|
| 421 |
+
|
| 422 |
+
stream_info = _probe_video_stream(source_path)
|
| 423 |
+
if (
|
| 424 |
+
source_path.suffix.lower() == ".mp4"
|
| 425 |
+
and stream_info.get("codec_name") == "h264"
|
| 426 |
+
and (not stream_info.get("pixel_format") or stream_info.get("pixel_format") == "yuv420p")
|
| 427 |
+
):
|
| 428 |
+
return str(source_path)
|
| 429 |
+
|
| 430 |
+
cache_dir = _web_video_cache_dir(project_root)
|
| 431 |
+
signature = hashlib.sha1(
|
| 432 |
+
f"{source_path.as_posix()}::{source_path.stat().st_mtime_ns}::{source_path.stat().st_size}".encode("utf-8")
|
| 433 |
+
).hexdigest()[:12]
|
| 434 |
+
target_path = cache_dir / f"{source_path.stem}_{signature}.mp4"
|
| 435 |
+
lock_path = target_path.with_suffix(".lock")
|
| 436 |
+
|
| 437 |
+
with FileLock(str(lock_path)):
|
| 438 |
+
if target_path.exists() and target_path.stat().st_size > 0:
|
| 439 |
+
return str(target_path)
|
| 440 |
+
|
| 441 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 442 |
+
command = [
|
| 443 |
+
ffmpeg_exe,
|
| 444 |
+
"-y",
|
| 445 |
+
"-i",
|
| 446 |
+
str(source_path),
|
| 447 |
+
"-an",
|
| 448 |
+
"-c:v",
|
| 449 |
+
"libx264",
|
| 450 |
+
"-pix_fmt",
|
| 451 |
+
"yuv420p",
|
| 452 |
+
"-movflags",
|
| 453 |
+
"+faststart",
|
| 454 |
+
str(target_path),
|
| 455 |
+
]
|
| 456 |
+
result = subprocess.run(
|
| 457 |
+
command,
|
| 458 |
+
capture_output=True,
|
| 459 |
+
text=True,
|
| 460 |
+
encoding="utf-8",
|
| 461 |
+
errors="ignore",
|
| 462 |
+
)
|
| 463 |
+
if result.returncode != 0 or not target_path.exists():
|
| 464 |
+
raise RuntimeError(
|
| 465 |
+
f"Failed to convert video for browser playback: {source_path}\n{result.stderr}"
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
return str(target_path)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def prepare_reference_videos_for_web(config: Dict[str, Any], project_root: Path) -> Dict[str, Any]:
|
| 472 |
+
for case in config.get("cases", []):
|
| 473 |
+
case["reference_video"] = ensure_web_playable_video(case["reference_video"], project_root)
|
| 474 |
+
return config
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def ensure_video_thumbnail(
|
| 478 |
+
video_path: str,
|
| 479 |
+
project_root: Path,
|
| 480 |
+
time_seconds: float = 0.8,
|
| 481 |
+
width: int = 480,
|
| 482 |
+
) -> str:
|
| 483 |
+
source_path = Path(video_path).resolve()
|
| 484 |
+
if not source_path.exists() or imageio_ffmpeg is None:
|
| 485 |
+
return ""
|
| 486 |
+
|
| 487 |
+
cache_dir = _thumbnail_cache_dir(project_root)
|
| 488 |
+
signature = hashlib.sha1(
|
| 489 |
+
f"{source_path.as_posix()}::{source_path.stat().st_mtime_ns}::{source_path.stat().st_size}::{time_seconds}::{width}".encode(
|
| 490 |
+
"utf-8"
|
| 491 |
+
)
|
| 492 |
+
).hexdigest()[:12]
|
| 493 |
+
target_path = cache_dir / f"{source_path.stem}_{signature}.jpg"
|
| 494 |
+
lock_path = target_path.with_suffix(".lock")
|
| 495 |
+
|
| 496 |
+
with FileLock(str(lock_path)):
|
| 497 |
+
if target_path.exists() and target_path.stat().st_size > 0:
|
| 498 |
+
return str(target_path)
|
| 499 |
+
|
| 500 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 501 |
+
command = [
|
| 502 |
+
ffmpeg_exe,
|
| 503 |
+
"-y",
|
| 504 |
+
"-ss",
|
| 505 |
+
str(time_seconds),
|
| 506 |
+
"-i",
|
| 507 |
+
str(source_path),
|
| 508 |
+
"-frames:v",
|
| 509 |
+
"1",
|
| 510 |
+
"-vf",
|
| 511 |
+
f"scale={width}:-1",
|
| 512 |
+
"-q:v",
|
| 513 |
+
"2",
|
| 514 |
+
str(target_path),
|
| 515 |
+
]
|
| 516 |
+
result = subprocess.run(
|
| 517 |
+
command,
|
| 518 |
+
capture_output=True,
|
| 519 |
+
text=True,
|
| 520 |
+
encoding="utf-8",
|
| 521 |
+
errors="ignore",
|
| 522 |
+
)
|
| 523 |
+
if result.returncode != 0 or not target_path.exists():
|
| 524 |
+
raise RuntimeError(
|
| 525 |
+
f"Failed to extract thumbnail from video: {source_path}\n{result.stderr}"
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
return str(target_path)
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def _resolve_path(config_dir: Path, raw_path: str) -> Path:
|
| 532 |
+
path = Path(raw_path)
|
| 533 |
+
if path.is_absolute():
|
| 534 |
+
return path
|
| 535 |
+
return (config_dir / path).resolve()
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def _resolve_single_match(config_dir: Path, directory: str, pattern: str, source_key: str) -> Path:
|
| 539 |
+
base_dir = _resolve_path(config_dir, directory)
|
| 540 |
+
if not base_dir.exists():
|
| 541 |
+
raise FileNotFoundError(f"Configured directory does not exist: {base_dir}")
|
| 542 |
+
|
| 543 |
+
resolved_pattern = pattern.format(source_key=source_key)
|
| 544 |
+
matches = sorted(base_dir.glob(resolved_pattern))
|
| 545 |
+
if not matches:
|
| 546 |
+
raise FileNotFoundError(
|
| 547 |
+
f"No video matched pattern '{resolved_pattern}' inside '{base_dir}' for source_key='{source_key}'."
|
| 548 |
+
)
|
| 549 |
+
if len(matches) > 1:
|
| 550 |
+
match_str = ", ".join(str(match) for match in matches)
|
| 551 |
+
raise ValueError(
|
| 552 |
+
f"Pattern '{resolved_pattern}' for source_key='{source_key}' matched multiple files: {match_str}"
|
| 553 |
+
)
|
| 554 |
+
return matches[0].resolve()
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
def _normalize_case(
|
| 558 |
+
raw_case: Dict[str, Any],
|
| 559 |
+
raw_config: Dict[str, Any],
|
| 560 |
+
config_dir: Path,
|
| 561 |
+
method_ids: List[str],
|
| 562 |
+
) -> Dict[str, Any]:
|
| 563 |
+
case_id = raw_case["case_id"]
|
| 564 |
+
source_key = raw_case.get("source_key", case_id)
|
| 565 |
+
case_title = raw_case.get("title") or humanize_source_key(source_key)
|
| 566 |
+
|
| 567 |
+
if raw_case.get("reference_video") and raw_case.get("method_videos"):
|
| 568 |
+
reference_video = _resolve_path(config_dir, raw_case["reference_video"]).resolve()
|
| 569 |
+
method_videos = {
|
| 570 |
+
method_id: _resolve_path(config_dir, raw_case["method_videos"][method_id]).resolve()
|
| 571 |
+
for method_id in method_ids
|
| 572 |
+
}
|
| 573 |
+
else:
|
| 574 |
+
reference_cfg = raw_config["reference"]
|
| 575 |
+
reference_video = _resolve_single_match(
|
| 576 |
+
config_dir=config_dir,
|
| 577 |
+
directory=reference_cfg["directory"],
|
| 578 |
+
pattern=reference_cfg["glob"],
|
| 579 |
+
source_key=source_key,
|
| 580 |
+
)
|
| 581 |
+
method_videos = {}
|
| 582 |
+
for method_id in method_ids:
|
| 583 |
+
method_cfg = raw_config["methods"][method_id]
|
| 584 |
+
method_videos[method_id] = _resolve_single_match(
|
| 585 |
+
config_dir=config_dir,
|
| 586 |
+
directory=method_cfg["directory"],
|
| 587 |
+
pattern=method_cfg["glob"],
|
| 588 |
+
source_key=source_key,
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
missing_files = [reference_video, *method_videos.values()]
|
| 592 |
+
for path in missing_files:
|
| 593 |
+
if not path.exists():
|
| 594 |
+
raise FileNotFoundError(f"Missing video file for case '{case_id}': {path}")
|
| 595 |
+
|
| 596 |
+
return {
|
| 597 |
+
"case_id": case_id,
|
| 598 |
+
"source_key": source_key,
|
| 599 |
+
"case_title": case_title,
|
| 600 |
+
"reference_video": str(reference_video),
|
| 601 |
+
"method_videos": {method_id: str(path) for method_id, path in method_videos.items()},
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
def load_study_config(config_path: str | Path) -> Dict[str, Any]:
|
| 606 |
+
config_path = Path(config_path).resolve()
|
| 607 |
+
config_dir = config_path.parent
|
| 608 |
+
|
| 609 |
+
with config_path.open("r", encoding="utf-8") as handle:
|
| 610 |
+
raw_config = json.load(handle)
|
| 611 |
+
|
| 612 |
+
if "methods" not in raw_config or "cases" not in raw_config:
|
| 613 |
+
raise ValueError("study_config.json must define both 'methods' and 'cases'.")
|
| 614 |
+
|
| 615 |
+
method_ids = list(raw_config["methods"].keys())
|
| 616 |
+
if set(method_ids) != set(METHOD_FALLBACK_LABELS.keys()):
|
| 617 |
+
raise ValueError(
|
| 618 |
+
"This sample project expects exactly three methods: anyact, vlm_hy_motion, echomotion."
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
pair_order = raw_config.get("pair_order", [list(pair) for pair in PAIRWISE_METHOD_PAIRS])
|
| 622 |
+
normalized_pairs: List[Tuple[str, str]] = []
|
| 623 |
+
for raw_pair in pair_order:
|
| 624 |
+
if len(raw_pair) != 2:
|
| 625 |
+
raise ValueError(f"Each pair_order entry must contain exactly two methods: {raw_pair}")
|
| 626 |
+
left, right = raw_pair
|
| 627 |
+
if left not in method_ids or right not in method_ids:
|
| 628 |
+
raise ValueError(f"Unknown method in pair_order: {raw_pair}")
|
| 629 |
+
normalized_pairs.append((left, right))
|
| 630 |
+
|
| 631 |
+
methods = {}
|
| 632 |
+
for method_id, method_cfg in raw_config["methods"].items():
|
| 633 |
+
methods[method_id] = {
|
| 634 |
+
"display_name": method_cfg.get("display_name", METHOD_FALLBACK_LABELS[method_id]),
|
| 635 |
+
"directory": method_cfg.get("directory", ""),
|
| 636 |
+
"glob": method_cfg.get("glob", ""),
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
cases = [
|
| 640 |
+
_normalize_case(
|
| 641 |
+
raw_case=raw_case,
|
| 642 |
+
raw_config=raw_config,
|
| 643 |
+
config_dir=config_dir,
|
| 644 |
+
method_ids=method_ids,
|
| 645 |
+
)
|
| 646 |
+
for raw_case in raw_config["cases"]
|
| 647 |
+
]
|
| 648 |
+
|
| 649 |
+
raw_pair_limits = raw_config.get("per_participant_pair_limits", {})
|
| 650 |
+
pair_sample_limits: Dict[str, int] = {}
|
| 651 |
+
for method_a, method_b in normalized_pairs:
|
| 652 |
+
pair_id = build_pair_id(method_a, method_b)
|
| 653 |
+
raw_limit = raw_pair_limits.get(pair_id, len(cases))
|
| 654 |
+
try:
|
| 655 |
+
limit_value = int(raw_limit)
|
| 656 |
+
except (TypeError, ValueError) as exc:
|
| 657 |
+
raise ValueError(f"Invalid per_participant_pair_limits value for '{pair_id}': {raw_limit}") from exc
|
| 658 |
+
if limit_value <= 0 or limit_value > len(cases):
|
| 659 |
+
raise ValueError(
|
| 660 |
+
f"per_participant_pair_limits['{pair_id}'] must be within [1, {len(cases)}], got {limit_value}."
|
| 661 |
+
)
|
| 662 |
+
pair_sample_limits[pair_id] = limit_value
|
| 663 |
+
|
| 664 |
+
disjoint_case_sampling = bool(raw_config.get("disjoint_case_sampling", False))
|
| 665 |
+
if disjoint_case_sampling and sum(pair_sample_limits.values()) > len(cases):
|
| 666 |
+
raise ValueError(
|
| 667 |
+
"disjoint_case_sampling=True requires the sum of per-participant pair limits "
|
| 668 |
+
f"to be <= number of cases ({len(cases)})."
|
| 669 |
+
)
|
| 670 |
+
|
| 671 |
+
case_ids = {case["case_id"] for case in cases}
|
| 672 |
+
instruction_case_id = raw_config.get("instruction_case_id")
|
| 673 |
+
if instruction_case_id and instruction_case_id not in case_ids:
|
| 674 |
+
raise ValueError(f"instruction_case_id='{instruction_case_id}' is not present in cases.")
|
| 675 |
+
if not instruction_case_id:
|
| 676 |
+
instruction_case_id = cases[0]["case_id"]
|
| 677 |
+
|
| 678 |
+
return {
|
| 679 |
+
"study_id": raw_config.get("study_id", "anyact_user_study"),
|
| 680 |
+
"study_title": raw_config.get("study_title", "Human Motion Reenactment User Study"),
|
| 681 |
+
"question_order": raw_config.get("question_order", "shuffle_per_participant"),
|
| 682 |
+
"allow_tie_option": raw_config.get("allow_tie_option", True),
|
| 683 |
+
"pair_order": normalized_pairs,
|
| 684 |
+
"pair_sample_limits": pair_sample_limits,
|
| 685 |
+
"disjoint_case_sampling": disjoint_case_sampling,
|
| 686 |
+
"question_bank_total": len(cases) * len(normalized_pairs),
|
| 687 |
+
"participant_question_total": sum(pair_sample_limits.values()),
|
| 688 |
+
"methods": methods,
|
| 689 |
+
"cases": cases,
|
| 690 |
+
"instruction_case_id": instruction_case_id,
|
| 691 |
+
"config_path": str(config_path),
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
def get_instruction_case(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 696 |
+
target_case_id = config["instruction_case_id"]
|
| 697 |
+
for case in config["cases"]:
|
| 698 |
+
if case["case_id"] == target_case_id:
|
| 699 |
+
return case
|
| 700 |
+
raise KeyError(f"Instruction case '{target_case_id}' was not found.")
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def build_questions(config: Dict[str, Any], participant_id: str) -> List[Dict[str, Any]]:
|
| 704 |
+
questions: List[Dict[str, Any]] = []
|
| 705 |
+
cases = list(config["cases"])
|
| 706 |
+
pair_case_assignments: Dict[str, List[Dict[str, Any]]] = {}
|
| 707 |
+
|
| 708 |
+
if config.get("disjoint_case_sampling"):
|
| 709 |
+
shuffled_cases = list(cases)
|
| 710 |
+
assignment_rng = random.Random(stable_int_seed(f"{config['study_id']}::{participant_id}::case_assignment"))
|
| 711 |
+
assignment_rng.shuffle(shuffled_cases)
|
| 712 |
+
|
| 713 |
+
cursor = 0
|
| 714 |
+
for method_a, method_b in config["pair_order"]:
|
| 715 |
+
pair_id = build_pair_id(method_a, method_b)
|
| 716 |
+
sample_size = config["pair_sample_limits"][pair_id]
|
| 717 |
+
selected_cases = shuffled_cases[cursor : cursor + sample_size]
|
| 718 |
+
if len(selected_cases) != sample_size:
|
| 719 |
+
raise ValueError(
|
| 720 |
+
f"Not enough unique cases to assign pair '{pair_id}'. Requested {sample_size}, got {len(selected_cases)}."
|
| 721 |
+
)
|
| 722 |
+
pair_case_assignments[pair_id] = selected_cases
|
| 723 |
+
cursor += sample_size
|
| 724 |
+
else:
|
| 725 |
+
for method_a, method_b in config["pair_order"]:
|
| 726 |
+
pair_id = build_pair_id(method_a, method_b)
|
| 727 |
+
sample_size = config["pair_sample_limits"][pair_id]
|
| 728 |
+
pair_rng = random.Random(stable_int_seed(f"{config['study_id']}::{participant_id}::{pair_id}::sample"))
|
| 729 |
+
pair_case_assignments[pair_id] = pair_rng.sample(cases, sample_size)
|
| 730 |
+
|
| 731 |
+
for method_a, method_b in config["pair_order"]:
|
| 732 |
+
pair_id = build_pair_id(method_a, method_b)
|
| 733 |
+
for case in pair_case_assignments[pair_id]:
|
| 734 |
+
order_rng = random.Random(
|
| 735 |
+
stable_int_seed(f"{config['study_id']}::{participant_id}::{case['case_id']}::{method_a}::{method_b}")
|
| 736 |
+
)
|
| 737 |
+
result_a_method, result_b_method = (method_a, method_b)
|
| 738 |
+
if order_rng.random() < 0.5:
|
| 739 |
+
result_a_method, result_b_method = result_b_method, result_a_method
|
| 740 |
+
|
| 741 |
+
questions.append(
|
| 742 |
+
{
|
| 743 |
+
"case_id": case["case_id"],
|
| 744 |
+
"case_title": case["case_title"],
|
| 745 |
+
"source_key": case["source_key"],
|
| 746 |
+
"pair_id": pair_id,
|
| 747 |
+
"reference_video": case["reference_video"],
|
| 748 |
+
"result_a_method": result_a_method,
|
| 749 |
+
"result_b_method": result_b_method,
|
| 750 |
+
"left_method": result_a_method,
|
| 751 |
+
"right_method": result_b_method,
|
| 752 |
+
"result_a_video": case["method_videos"][result_a_method],
|
| 753 |
+
"result_b_video": case["method_videos"][result_b_method],
|
| 754 |
+
"left_video": case["method_videos"][result_a_method],
|
| 755 |
+
"right_video": case["method_videos"][result_b_method],
|
| 756 |
+
}
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
if config["question_order"] == "shuffle_per_participant":
|
| 760 |
+
shuffle_rng = random.Random(stable_int_seed(f"{config['study_id']}::{participant_id}::question_order"))
|
| 761 |
+
shuffle_rng.shuffle(questions)
|
| 762 |
+
|
| 763 |
+
total_questions = len(questions)
|
| 764 |
+
for index, question in enumerate(questions, start=1):
|
| 765 |
+
question["question_number"] = index
|
| 766 |
+
question["question_id"] = f"Q{index:03d}_{question['case_id']}_{question['pair_id']}"
|
| 767 |
+
question["total_questions"] = total_questions
|
| 768 |
+
|
| 769 |
+
return questions
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def _state_path(project_root: Path, participant_id: str) -> Path:
|
| 773 |
+
return get_results_dir(project_root) / "participants" / f"{participant_id}.json"
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def _archive_dir(project_root: Path) -> Path:
|
| 777 |
+
return get_results_dir(project_root) / "participants_archive"
|
| 778 |
+
|
| 779 |
+
|
| 780 |
+
def _lock_path(project_root: Path) -> Path:
|
| 781 |
+
return get_results_dir(project_root) / "locks" / "results.lock"
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def _responses_jsonl_path(project_root: Path) -> Path:
|
| 785 |
+
return get_results_dir(project_root) / "responses.jsonl"
|
| 786 |
+
|
| 787 |
+
|
| 788 |
+
def _responses_csv_path(project_root: Path) -> Path:
|
| 789 |
+
return get_results_dir(project_root) / "responses.csv"
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def _read_state_unlocked(project_root: Path, participant_id: str) -> Dict[str, Any] | None:
|
| 793 |
+
path = _state_path(project_root, participant_id)
|
| 794 |
+
if not path.exists():
|
| 795 |
+
return None
|
| 796 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 797 |
+
return json.load(handle)
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:
|
| 801 |
+
temp_path = path.with_suffix(path.suffix + ".tmp")
|
| 802 |
+
with temp_path.open("w", encoding="utf-8") as handle:
|
| 803 |
+
json.dump(data, handle, ensure_ascii=False, indent=2)
|
| 804 |
+
os.replace(temp_path, path)
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def _write_state_unlocked(project_root: Path, state: Dict[str, Any]) -> None:
|
| 808 |
+
_atomic_write_json(_state_path(project_root, state["participant_id"]), state)
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
def _archive_state_unlocked(project_root: Path, state: Dict[str, Any]) -> None:
|
| 812 |
+
archive_dir = _archive_dir(project_root)
|
| 813 |
+
timestamp = re.sub(r"[^0-9A-Za-z_-]", "-", now_iso())
|
| 814 |
+
filename = f"{state.get('participant_id', 'participant')}__{state.get('study_id', 'study')}__{timestamp}.json"
|
| 815 |
+
_atomic_write_json(archive_dir / filename, state)
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
def _append_jsonl_unlocked(project_root: Path, payload: Dict[str, Any]) -> None:
|
| 819 |
+
jsonl_path = _responses_jsonl_path(project_root)
|
| 820 |
+
with jsonl_path.open("a", encoding="utf-8") as handle:
|
| 821 |
+
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def _normalize_canonical_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
| 825 |
+
upgraded_row = upgrade_response_row_schema(row)
|
| 826 |
+
return {column: upgraded_row.get(column, "") for column in CSV_COLUMNS}
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def _canonical_row_key(row: Dict[str, Any]) -> Tuple[str, str, str] | None:
|
| 830 |
+
participant_id = str(row.get("participant_id", "")).strip()
|
| 831 |
+
question_id = str(row.get("question_id", "")).strip()
|
| 832 |
+
if not participant_id or not question_id:
|
| 833 |
+
return None
|
| 834 |
+
study_id = str(row.get("study_id", "")).strip()
|
| 835 |
+
return study_id, participant_id, question_id
|
| 836 |
+
|
| 837 |
+
|
| 838 |
+
def _canonical_row_sort_key(row: Dict[str, Any]) -> Tuple[str, str, str]:
|
| 839 |
+
return (
|
| 840 |
+
str(row.get("event_saved_at") or row.get("answered_at") or ""),
|
| 841 |
+
str(row.get("updated_at") or ""),
|
| 842 |
+
str(row.get("answered_at") or ""),
|
| 843 |
+
)
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def _merge_canonical_rows(*row_groups: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 847 |
+
merged_rows: Dict[Tuple[str, str, str], Tuple[Tuple[str, str, str], Dict[str, Any]]] = {}
|
| 848 |
+
for rows in row_groups:
|
| 849 |
+
for row in rows:
|
| 850 |
+
row_key = _canonical_row_key(row)
|
| 851 |
+
if row_key is None:
|
| 852 |
+
continue
|
| 853 |
+
sort_key = _canonical_row_sort_key(row)
|
| 854 |
+
previous = merged_rows.get(row_key)
|
| 855 |
+
if previous is None or sort_key >= previous[0]:
|
| 856 |
+
merged_rows[row_key] = (sort_key, _normalize_canonical_row(row))
|
| 857 |
+
|
| 858 |
+
canonical_rows = [payload for _, payload in merged_rows.values()]
|
| 859 |
+
canonical_rows.sort(
|
| 860 |
+
key=lambda row: (row.get("answered_at", ""), row.get("participant_id", ""), row.get("question_id", ""))
|
| 861 |
+
)
|
| 862 |
+
return canonical_rows
|
| 863 |
+
|
| 864 |
+
|
| 865 |
+
def _load_canonical_rows_from_jsonl_unlocked(project_root: Path) -> List[Dict[str, Any]]:
|
| 866 |
+
jsonl_path = _responses_jsonl_path(project_root)
|
| 867 |
+
if not jsonl_path.exists() or jsonl_path.stat().st_size <= 0:
|
| 868 |
+
return []
|
| 869 |
+
|
| 870 |
+
latest_rows: Dict[Tuple[str, str, str], Tuple[Tuple[str, str, str], Dict[str, Any]]] = {}
|
| 871 |
+
with jsonl_path.open("r", encoding="utf-8") as handle:
|
| 872 |
+
for line in handle:
|
| 873 |
+
if not line.strip():
|
| 874 |
+
continue
|
| 875 |
+
try:
|
| 876 |
+
record = json.loads(line)
|
| 877 |
+
except json.JSONDecodeError:
|
| 878 |
+
# A truncated trailing line should not make the whole study unreadable.
|
| 879 |
+
continue
|
| 880 |
+
|
| 881 |
+
row_key = _canonical_row_key(record)
|
| 882 |
+
if row_key is None:
|
| 883 |
+
continue
|
| 884 |
+
|
| 885 |
+
sort_key = _canonical_row_sort_key(record)
|
| 886 |
+
previous = latest_rows.get(row_key)
|
| 887 |
+
if previous is None or sort_key >= previous[0]:
|
| 888 |
+
latest_rows[row_key] = (sort_key, _normalize_canonical_row(record))
|
| 889 |
+
|
| 890 |
+
rows = [payload for _, payload in latest_rows.values()]
|
| 891 |
+
rows.sort(key=lambda row: (row.get("answered_at", ""), row.get("participant_id", ""), row.get("question_id", "")))
|
| 892 |
+
return rows
|
| 893 |
+
|
| 894 |
+
|
| 895 |
+
def _load_canonical_rows_from_state_files_unlocked(project_root: Path) -> List[Dict[str, Any]]:
|
| 896 |
+
rows: List[Dict[str, Any]] = []
|
| 897 |
+
state_dirs = [
|
| 898 |
+
get_results_dir(project_root) / "participants",
|
| 899 |
+
_archive_dir(project_root),
|
| 900 |
+
]
|
| 901 |
+
for state_dir in state_dirs:
|
| 902 |
+
for state_path in sorted(state_dir.glob("*.json")):
|
| 903 |
+
with state_path.open("r", encoding="utf-8") as handle:
|
| 904 |
+
state = json.load(handle)
|
| 905 |
+
for row in state.get("answers", {}).values():
|
| 906 |
+
rows.append(_normalize_canonical_row(row))
|
| 907 |
+
rows.sort(key=lambda row: (row.get("answered_at", ""), row.get("participant_id", ""), row.get("question_id", "")))
|
| 908 |
+
return rows
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
def _all_canonical_rows_unlocked(project_root: Path) -> List[Dict[str, Any]]:
|
| 912 |
+
state_rows = _load_canonical_rows_from_state_files_unlocked(project_root)
|
| 913 |
+
jsonl_rows = _load_canonical_rows_from_jsonl_unlocked(project_root)
|
| 914 |
+
merged_rows = _merge_canonical_rows(state_rows, jsonl_rows)
|
| 915 |
+
if merged_rows:
|
| 916 |
+
return merged_rows
|
| 917 |
+
return []
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
def _export_csv_unlocked(project_root: Path) -> None:
|
| 921 |
+
csv_path = _responses_csv_path(project_root)
|
| 922 |
+
temp_path = csv_path.with_suffix(".tmp")
|
| 923 |
+
rows = _all_canonical_rows_unlocked(project_root)
|
| 924 |
+
with temp_path.open("w", newline="", encoding="utf-8") as handle:
|
| 925 |
+
writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
|
| 926 |
+
writer.writeheader()
|
| 927 |
+
for row in rows:
|
| 928 |
+
writer.writerow(row)
|
| 929 |
+
os.replace(temp_path, csv_path)
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
def get_current_question(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 933 |
+
return state["questions"][state["current_index"]]
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def question_stable_key(question: Dict[str, Any]) -> str:
|
| 937 |
+
return f"{question['case_id']}::{question['pair_id']}"
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
def refresh_state_video_paths(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
| 941 |
+
case_lookup = {case["case_id"]: case for case in config["cases"]}
|
| 942 |
+
|
| 943 |
+
for question in state.get("questions", []):
|
| 944 |
+
case = case_lookup.get(question.get("case_id"))
|
| 945 |
+
if not case:
|
| 946 |
+
continue
|
| 947 |
+
upgrade_response_row_schema(question, case=case)
|
| 948 |
+
|
| 949 |
+
for answer in state.get("answers", {}).values():
|
| 950 |
+
case = case_lookup.get(answer.get("case_id"))
|
| 951 |
+
if not case:
|
| 952 |
+
continue
|
| 953 |
+
upgrade_response_row_schema(answer, case=case)
|
| 954 |
+
|
| 955 |
+
return state
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
def sync_state_with_config(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
| 959 |
+
new_questions = build_questions(config=config, participant_id=state["participant_id"])
|
| 960 |
+
old_questions = state.get("questions", [])
|
| 961 |
+
old_answers = state.get("answers", {})
|
| 962 |
+
case_lookup = {case["case_id"]: case for case in config["cases"]}
|
| 963 |
+
|
| 964 |
+
old_current_key = None
|
| 965 |
+
if old_questions:
|
| 966 |
+
old_index = min(max(int(state.get("current_index", 0)), 0), len(old_questions) - 1)
|
| 967 |
+
old_current_key = question_stable_key(old_questions[old_index])
|
| 968 |
+
|
| 969 |
+
old_questions_by_key = {
|
| 970 |
+
question_stable_key(question): question
|
| 971 |
+
for question in old_questions
|
| 972 |
+
if question.get("case_id") and question.get("pair_id")
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
old_rows_by_key = {
|
| 976 |
+
question_stable_key(answer_row): answer_row
|
| 977 |
+
for answer_row in old_answers.values()
|
| 978 |
+
if answer_row.get("case_id") and answer_row.get("pair_id")
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
synced_answers: Dict[str, Dict[str, Any]] = {}
|
| 982 |
+
for question in new_questions:
|
| 983 |
+
stable_key = question_stable_key(question)
|
| 984 |
+
case = case_lookup.get(question["case_id"])
|
| 985 |
+
previous_question = old_questions_by_key.get(stable_key)
|
| 986 |
+
if previous_question:
|
| 987 |
+
question["result_a_method"] = previous_question.get("result_a_method") or previous_question.get("left_method")
|
| 988 |
+
question["result_b_method"] = previous_question.get("result_b_method") or previous_question.get("right_method")
|
| 989 |
+
_sync_result_slot_fields(question, case=case)
|
| 990 |
+
|
| 991 |
+
previous_row = old_rows_by_key.get(stable_key)
|
| 992 |
+
if not previous_row:
|
| 993 |
+
continue
|
| 994 |
+
|
| 995 |
+
upgraded_row = {
|
| 996 |
+
**previous_row,
|
| 997 |
+
"study_id": config["study_id"],
|
| 998 |
+
"study_title": config["study_title"],
|
| 999 |
+
"question_id": question["question_id"],
|
| 1000 |
+
"question_position": question["question_number"],
|
| 1001 |
+
"total_questions": question["total_questions"],
|
| 1002 |
+
"case_id": question["case_id"],
|
| 1003 |
+
"case_title": question["case_title"],
|
| 1004 |
+
"source_key": question["source_key"],
|
| 1005 |
+
"pair_id": question["pair_id"],
|
| 1006 |
+
"result_a_method": question["result_a_method"],
|
| 1007 |
+
"result_b_method": question["result_b_method"],
|
| 1008 |
+
"left_method": question["left_method"],
|
| 1009 |
+
"right_method": question["right_method"],
|
| 1010 |
+
"reference_video": question["reference_video"],
|
| 1011 |
+
"result_a_video": question["result_a_video"],
|
| 1012 |
+
"result_b_video": question["result_b_video"],
|
| 1013 |
+
"left_video": question["left_video"],
|
| 1014 |
+
"right_video": question["right_video"],
|
| 1015 |
+
}
|
| 1016 |
+
synced_answers[question["question_id"]] = upgrade_response_row_schema(upgraded_row, case=case)
|
| 1017 |
+
|
| 1018 |
+
current_index = 0
|
| 1019 |
+
if new_questions:
|
| 1020 |
+
if old_current_key is not None:
|
| 1021 |
+
matched_index = next(
|
| 1022 |
+
(index for index, question in enumerate(new_questions) if question_stable_key(question) == old_current_key),
|
| 1023 |
+
None,
|
| 1024 |
+
)
|
| 1025 |
+
if matched_index is not None:
|
| 1026 |
+
current_index = matched_index
|
| 1027 |
+
|
| 1028 |
+
first_unanswered_index = next(
|
| 1029 |
+
(
|
| 1030 |
+
index
|
| 1031 |
+
for index, question in enumerate(new_questions)
|
| 1032 |
+
if question["question_id"] not in synced_answers
|
| 1033 |
+
),
|
| 1034 |
+
None,
|
| 1035 |
+
)
|
| 1036 |
+
if first_unanswered_index is not None:
|
| 1037 |
+
current_index = first_unanswered_index
|
| 1038 |
+
else:
|
| 1039 |
+
current_index = len(new_questions) - 1
|
| 1040 |
+
|
| 1041 |
+
state["study_id"] = config["study_id"]
|
| 1042 |
+
state["study_title"] = config["study_title"]
|
| 1043 |
+
state["questions"] = new_questions
|
| 1044 |
+
state["answers"] = synced_answers
|
| 1045 |
+
state["current_index"] = current_index
|
| 1046 |
+
|
| 1047 |
+
if new_questions and len(synced_answers) == len(new_questions):
|
| 1048 |
+
state["completed_at"] = state.get("completed_at") or now_iso()
|
| 1049 |
+
state["status"] = "completed"
|
| 1050 |
+
state["current_question_started_at"] = None
|
| 1051 |
+
else:
|
| 1052 |
+
state["completed_at"] = None
|
| 1053 |
+
state["status"] = "in_progress"
|
| 1054 |
+
state["current_question_started_at"] = time.time()
|
| 1055 |
+
|
| 1056 |
+
return refresh_state_video_paths(state, config)
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
def _upgrade_state_schema(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
|
| 1060 |
+
upgraded_state = copy.deepcopy(state)
|
| 1061 |
+
upgraded_state["study_title"] = config["study_title"]
|
| 1062 |
+
return refresh_state_video_paths(upgraded_state, config)
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
def upgrade_existing_results_schema(project_root: Path, config: Dict[str, Any]) -> None:
|
| 1066 |
+
ensure_runtime_dirs(project_root)
|
| 1067 |
+
state_dirs = [
|
| 1068 |
+
get_results_dir(project_root) / "participants",
|
| 1069 |
+
_archive_dir(project_root),
|
| 1070 |
+
]
|
| 1071 |
+
|
| 1072 |
+
with FileLock(str(_lock_path(project_root))):
|
| 1073 |
+
for state_dir in state_dirs:
|
| 1074 |
+
for state_path in sorted(state_dir.glob("*.json")):
|
| 1075 |
+
try:
|
| 1076 |
+
with state_path.open("r", encoding="utf-8") as handle:
|
| 1077 |
+
state = json.load(handle)
|
| 1078 |
+
except json.JSONDecodeError:
|
| 1079 |
+
continue
|
| 1080 |
+
|
| 1081 |
+
upgraded_state = _upgrade_state_schema(state, config)
|
| 1082 |
+
if upgraded_state != state:
|
| 1083 |
+
_atomic_write_json(state_path, upgraded_state)
|
| 1084 |
+
|
| 1085 |
+
_export_csv_unlocked(project_root)
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
def create_or_resume_participant(
|
| 1089 |
+
project_root: Path,
|
| 1090 |
+
config: Dict[str, Any],
|
| 1091 |
+
participant_id: str | None,
|
| 1092 |
+
request: Any = None,
|
| 1093 |
+
) -> Tuple[Dict[str, Any], str]:
|
| 1094 |
+
ensure_runtime_dirs(project_root)
|
| 1095 |
+
|
| 1096 |
+
participant_id = sanitize_participant_id(participant_id)
|
| 1097 |
+
if not participant_id:
|
| 1098 |
+
participant_id = generate_participant_id()
|
| 1099 |
+
|
| 1100 |
+
session_hash = getattr(request, "session_hash", "") if request is not None else ""
|
| 1101 |
+
user_agent = request.headers.get("user-agent", "") if request is not None and getattr(request, "headers", None) else ""
|
| 1102 |
+
|
| 1103 |
+
with FileLock(str(_lock_path(project_root))):
|
| 1104 |
+
existing_state = _read_state_unlocked(project_root, participant_id)
|
| 1105 |
+
if existing_state:
|
| 1106 |
+
if existing_state.get("study_id") != config["study_id"]:
|
| 1107 |
+
if existing_state.get("answers"):
|
| 1108 |
+
_archive_state_unlocked(project_root, existing_state)
|
| 1109 |
+
timestamp = now_iso()
|
| 1110 |
+
fresh_state = {
|
| 1111 |
+
"participant_id": participant_id,
|
| 1112 |
+
"consent": True,
|
| 1113 |
+
"study_id": config["study_id"],
|
| 1114 |
+
"study_title": config["study_title"],
|
| 1115 |
+
"created_at": timestamp,
|
| 1116 |
+
"started_at": timestamp,
|
| 1117 |
+
"updated_at": timestamp,
|
| 1118 |
+
"completed_at": None,
|
| 1119 |
+
"status": "in_progress",
|
| 1120 |
+
"session_hash": session_hash,
|
| 1121 |
+
"user_agent": user_agent,
|
| 1122 |
+
"current_index": 0,
|
| 1123 |
+
"current_question_started_at": time.time(),
|
| 1124 |
+
"questions": build_questions(config=config, participant_id=participant_id),
|
| 1125 |
+
"answers": {},
|
| 1126 |
+
}
|
| 1127 |
+
_write_state_unlocked(project_root, fresh_state)
|
| 1128 |
+
return fresh_state, "started"
|
| 1129 |
+
|
| 1130 |
+
existing_state = sync_state_with_config(existing_state, config)
|
| 1131 |
+
if existing_state.get("completed_at"):
|
| 1132 |
+
existing_state["session_hash"] = session_hash or existing_state.get("session_hash", "")
|
| 1133 |
+
existing_state["user_agent"] = user_agent or existing_state.get("user_agent", "")
|
| 1134 |
+
existing_state["updated_at"] = now_iso()
|
| 1135 |
+
_write_state_unlocked(project_root, existing_state)
|
| 1136 |
+
return existing_state, "completed"
|
| 1137 |
+
|
| 1138 |
+
existing_state["session_hash"] = session_hash or existing_state.get("session_hash", "")
|
| 1139 |
+
existing_state["user_agent"] = user_agent or existing_state.get("user_agent", "")
|
| 1140 |
+
existing_state["study_title"] = config["study_title"]
|
| 1141 |
+
existing_state["updated_at"] = now_iso()
|
| 1142 |
+
existing_state["current_question_started_at"] = time.time()
|
| 1143 |
+
_write_state_unlocked(project_root, existing_state)
|
| 1144 |
+
return existing_state, "resumed"
|
| 1145 |
+
|
| 1146 |
+
timestamp = now_iso()
|
| 1147 |
+
state = {
|
| 1148 |
+
"participant_id": participant_id,
|
| 1149 |
+
"consent": True,
|
| 1150 |
+
"study_id": config["study_id"],
|
| 1151 |
+
"study_title": config["study_title"],
|
| 1152 |
+
"created_at": timestamp,
|
| 1153 |
+
"started_at": timestamp,
|
| 1154 |
+
"updated_at": timestamp,
|
| 1155 |
+
"completed_at": None,
|
| 1156 |
+
"status": "in_progress",
|
| 1157 |
+
"session_hash": session_hash,
|
| 1158 |
+
"user_agent": user_agent,
|
| 1159 |
+
"current_index": 0,
|
| 1160 |
+
"current_question_started_at": time.time(),
|
| 1161 |
+
"questions": build_questions(config=config, participant_id=participant_id),
|
| 1162 |
+
"answers": {},
|
| 1163 |
+
}
|
| 1164 |
+
_write_state_unlocked(project_root, state)
|
| 1165 |
+
return state, "started"
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
def move_question_pointer(
|
| 1169 |
+
project_root: Path,
|
| 1170 |
+
participant_id: str,
|
| 1171 |
+
question_token: str | None,
|
| 1172 |
+
direction: str,
|
| 1173 |
+
) -> Tuple[Dict[str, Any], str]:
|
| 1174 |
+
with FileLock(str(_lock_path(project_root))):
|
| 1175 |
+
state = _read_state_unlocked(project_root, participant_id)
|
| 1176 |
+
if state is None:
|
| 1177 |
+
raise ValueError("Participant session could not be found.")
|
| 1178 |
+
|
| 1179 |
+
if state.get("completed_at"):
|
| 1180 |
+
return state, "This study session has already been submitted."
|
| 1181 |
+
|
| 1182 |
+
current_question = get_current_question(state)
|
| 1183 |
+
if question_token and current_question["question_id"] != question_token:
|
| 1184 |
+
return state, "A newer page state was already loaded. Restored the latest progress."
|
| 1185 |
+
|
| 1186 |
+
if direction == "previous" and state["current_index"] > 0:
|
| 1187 |
+
state["current_index"] -= 1
|
| 1188 |
+
state["current_question_started_at"] = time.time()
|
| 1189 |
+
state["updated_at"] = now_iso()
|
| 1190 |
+
_write_state_unlocked(project_root, state)
|
| 1191 |
+
|
| 1192 |
+
return state, ""
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
def _build_response_row(
|
| 1196 |
+
state: Dict[str, Any],
|
| 1197 |
+
question: Dict[str, Any],
|
| 1198 |
+
answer_similarity: str,
|
| 1199 |
+
answer_quality: str,
|
| 1200 |
+
answer_preference: str,
|
| 1201 |
+
duration_seconds: float,
|
| 1202 |
+
) -> Dict[str, Any]:
|
| 1203 |
+
timestamp = now_iso()
|
| 1204 |
+
response_row = {
|
| 1205 |
+
"participant_id": state["participant_id"],
|
| 1206 |
+
"consent": state.get("consent", True),
|
| 1207 |
+
"study_id": state["study_id"],
|
| 1208 |
+
"study_title": state["study_title"],
|
| 1209 |
+
"question_id": question["question_id"],
|
| 1210 |
+
"question_position": question["question_number"],
|
| 1211 |
+
"total_questions": question["total_questions"],
|
| 1212 |
+
"case_id": question["case_id"],
|
| 1213 |
+
"case_title": question["case_title"],
|
| 1214 |
+
"source_key": question["source_key"],
|
| 1215 |
+
"pair_id": question["pair_id"],
|
| 1216 |
+
"result_a_method": question.get("result_a_method") or question.get("left_method"),
|
| 1217 |
+
"result_b_method": question.get("result_b_method") or question.get("right_method"),
|
| 1218 |
+
"left_method": question["left_method"],
|
| 1219 |
+
"right_method": question["right_method"],
|
| 1220 |
+
"reference_video": question["reference_video"],
|
| 1221 |
+
"result_a_video": question.get("result_a_video") or question.get("left_video"),
|
| 1222 |
+
"result_b_video": question.get("result_b_video") or question.get("right_video"),
|
| 1223 |
+
"left_video": question["left_video"],
|
| 1224 |
+
"right_video": question["right_video"],
|
| 1225 |
+
"answer_similarity": normalize_choice_value(answer_similarity),
|
| 1226 |
+
"answer_quality": normalize_choice_value(answer_quality),
|
| 1227 |
+
"answer_preference": normalize_choice_value(answer_preference),
|
| 1228 |
+
"answered_at": timestamp,
|
| 1229 |
+
"duration_seconds": round(duration_seconds, 3),
|
| 1230 |
+
"session_hash": state.get("session_hash", ""),
|
| 1231 |
+
"user_agent": state.get("user_agent", ""),
|
| 1232 |
+
"started_at": state.get("started_at", ""),
|
| 1233 |
+
"updated_at": timestamp,
|
| 1234 |
+
}
|
| 1235 |
+
return upgrade_response_row_schema(response_row)
|
| 1236 |
+
|
| 1237 |
+
|
| 1238 |
+
def save_current_answer(
|
| 1239 |
+
project_root: Path,
|
| 1240 |
+
participant_id: str,
|
| 1241 |
+
question_token: str,
|
| 1242 |
+
answer_similarity: str,
|
| 1243 |
+
answer_quality: str,
|
| 1244 |
+
answer_preference: str,
|
| 1245 |
+
action: str,
|
| 1246 |
+
) -> Tuple[Dict[str, Any], str, str]:
|
| 1247 |
+
if action not in {"next", "submit"}:
|
| 1248 |
+
raise ValueError(f"Unsupported action: {action}")
|
| 1249 |
+
|
| 1250 |
+
with FileLock(str(_lock_path(project_root))):
|
| 1251 |
+
state = _read_state_unlocked(project_root, participant_id)
|
| 1252 |
+
if state is None:
|
| 1253 |
+
raise ValueError("Participant session could not be found.")
|
| 1254 |
+
|
| 1255 |
+
if state.get("completed_at"):
|
| 1256 |
+
return state, "This study session has already been submitted.", "completed"
|
| 1257 |
+
|
| 1258 |
+
current_question = get_current_question(state)
|
| 1259 |
+
if current_question["question_id"] != question_token:
|
| 1260 |
+
return state, "A newer page state was already loaded. Restored the latest progress.", "stale"
|
| 1261 |
+
|
| 1262 |
+
elapsed = max(0.0, time.time() - float(state.get("current_question_started_at") or time.time()))
|
| 1263 |
+
previous_row = state["answers"].get(question_token)
|
| 1264 |
+
response_row = _build_response_row(
|
| 1265 |
+
state=state,
|
| 1266 |
+
question=current_question,
|
| 1267 |
+
answer_similarity=answer_similarity,
|
| 1268 |
+
answer_quality=answer_quality,
|
| 1269 |
+
answer_preference=answer_preference,
|
| 1270 |
+
duration_seconds=elapsed,
|
| 1271 |
+
)
|
| 1272 |
+
|
| 1273 |
+
state["answers"][question_token] = response_row
|
| 1274 |
+
state["updated_at"] = response_row["answered_at"]
|
| 1275 |
+
event_type = "answer_updated" if previous_row else "answer_saved"
|
| 1276 |
+
|
| 1277 |
+
if action == "next":
|
| 1278 |
+
if state["current_index"] < len(state["questions"]) - 1:
|
| 1279 |
+
state["current_index"] += 1
|
| 1280 |
+
state["current_question_started_at"] = time.time()
|
| 1281 |
+
status = "advanced"
|
| 1282 |
+
message = "Response saved."
|
| 1283 |
+
else:
|
| 1284 |
+
state["completed_at"] = response_row["answered_at"]
|
| 1285 |
+
state["status"] = "completed"
|
| 1286 |
+
state["current_question_started_at"] = None
|
| 1287 |
+
status = "completed"
|
| 1288 |
+
message = "All responses have been submitted."
|
| 1289 |
+
else:
|
| 1290 |
+
state["completed_at"] = response_row["answered_at"]
|
| 1291 |
+
state["status"] = "completed"
|
| 1292 |
+
state["current_question_started_at"] = None
|
| 1293 |
+
status = "completed"
|
| 1294 |
+
message = "All responses have been submitted."
|
| 1295 |
+
|
| 1296 |
+
_write_state_unlocked(project_root, state)
|
| 1297 |
+
_append_jsonl_unlocked(
|
| 1298 |
+
project_root,
|
| 1299 |
+
{
|
| 1300 |
+
"event_type": event_type,
|
| 1301 |
+
"event_saved_at": response_row["answered_at"],
|
| 1302 |
+
**response_row,
|
| 1303 |
+
},
|
| 1304 |
+
)
|
| 1305 |
+
_export_csv_unlocked(project_root)
|
| 1306 |
+
|
| 1307 |
+
return state, message, status
|
| 1308 |
+
|
| 1309 |
+
|
| 1310 |
+
def build_question_payload(state: Dict[str, Any]) -> Dict[str, Any]:
|
| 1311 |
+
question = get_current_question(state)
|
| 1312 |
+
saved_answers = state.get("answers", {}).get(question["question_id"], {})
|
| 1313 |
+
answered_count = len(state.get("answers", {}))
|
| 1314 |
+
|
| 1315 |
+
return {
|
| 1316 |
+
"question_token": question["question_id"],
|
| 1317 |
+
"progress_markdown": (
|
| 1318 |
+
f"<div class='progress-chip'>Question {question['question_number']} / {question['total_questions']}</div>"
|
| 1319 |
+
f"<div class='meta-line'>Participant ID: <code>{state['participant_id']}</code></div>"
|
| 1320 |
+
f"<div class='meta-line'>Saved responses: {answered_count} / {question['total_questions']}</div>"
|
| 1321 |
+
),
|
| 1322 |
+
"instruction_markdown": (
|
| 1323 |
+
"Watch the reference clip and both anonymous candidates before answering all three questions."
|
| 1324 |
+
),
|
| 1325 |
+
"reference_video": question["reference_video"],
|
| 1326 |
+
"result_a_video": question.get("result_a_video") or question["left_video"],
|
| 1327 |
+
"result_b_video": question.get("result_b_video") or question["right_video"],
|
| 1328 |
+
"left_video": question["left_video"],
|
| 1329 |
+
"right_video": question["right_video"],
|
| 1330 |
+
"answer_similarity": normalize_choice_value(saved_answers.get("answer_similarity")),
|
| 1331 |
+
"answer_quality": normalize_choice_value(saved_answers.get("answer_quality")),
|
| 1332 |
+
"answer_preference": normalize_choice_value(saved_answers.get("answer_preference")),
|
| 1333 |
+
"show_previous": question["question_number"] > 1,
|
| 1334 |
+
"show_next": question["question_number"] < question["total_questions"],
|
| 1335 |
+
"show_submit": question["question_number"] == question["total_questions"],
|
| 1336 |
+
}
|
| 1337 |
+
|
| 1338 |
+
|
| 1339 |
+
def build_completion_markdown(state: Dict[str, Any]) -> str:
|
| 1340 |
+
completed_at = state.get("completed_at") or now_iso()
|
| 1341 |
+
total_questions = len(state.get("questions", []))
|
| 1342 |
+
answered_count = len(state.get("answers", {}))
|
| 1343 |
+
return f"""
|
| 1344 |
+
## Thank you for completing the study.
|
| 1345 |
+
|
| 1346 |
+
Your responses have been saved successfully.
|
| 1347 |
+
|
| 1348 |
+
- Participant ID: `{state["participant_id"]}`
|
| 1349 |
+
- Saved answers: `{answered_count} / {total_questions}`
|
| 1350 |
+
- Completed at: `{completed_at}`
|
| 1351 |
+
|
| 1352 |
+
You may now close this page.
|
| 1353 |
+
""".strip()
|