test / app.py
qwer5112's picture
u
63d9c3f
Raw
History Blame Contribute Delete
47.6 kB
from __future__ import annotations
import copy
import hashlib
import json
import os
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import gradio as gr
import spaces
@spaces.GPU
def initialize_gpu_runtime() -> str:
"""ZeroGPU entry point required by the Space runtime."""
return "ready"
STIMULI_DIR = Path(os.environ.get("STIMULI_DIR", "/data/stimuli")).resolve()
RESPONSE_DIR = Path(os.environ.get("RESPONSE_DIR", "/data/responses")).resolve()
MAX_VARIANTS = 26
METRIC_LABELS = {
"overall_quality": "Overall Quality",
"semantic_alignment": "Semantic Alignment",
"temporal_alignment": "Temporal Alignment",
}
RATING_CHOICES = (1, 2, 3, 4, 5)
WRITE_LOCK = threading.Lock()
def list_variant_filenames(sample_dir: Path) -> tuple[str, ...]:
"""Return the sorted, non-hidden MP4 filenames in one sample directory."""
try:
return tuple(
sorted(
path.name
for path in sample_dir.iterdir()
if path.is_file()
and not path.name.startswith(".")
and path.suffix.lower() == ".mp4"
)
)
except OSError as error:
raise RuntimeError(
f"Could not read sample directory {sample_dir}: {error}"
) from error
def discover_stimuli(
stimuli_dir: Path,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
"""Discover samples and validate their shared set of MP4 variants."""
if not stimuli_dir.exists():
raise RuntimeError(
f"Stimuli directory does not exist: {stimuli_dir}. "
"Confirm that the Hugging Face Bucket is mounted at /data."
)
if not stimuli_dir.is_dir():
raise RuntimeError(f"Stimuli path is not a directory: {stimuli_dir}")
try:
sample_dirs = sorted(
(
path
for path in stimuli_dir.iterdir()
if path.is_dir() and not path.name.startswith(".")
),
key=lambda path: path.name,
)
except OSError as error:
raise RuntimeError(
f"Could not read stimuli directory {stimuli_dir}: {error}"
) from error
if not sample_dirs:
raise RuntimeError(f"No sample directories were found in {stimuli_dir}.")
variant_filenames = list_variant_filenames(sample_dirs[0])
if not variant_filenames:
raise RuntimeError(
f"No non-hidden MP4 variants were found in {sample_dirs[0]}."
)
if len(variant_filenames) > MAX_VARIANTS:
raise RuntimeError(
f"Found {len(variant_filenames)} MP4 variants in {sample_dirs[0]}, "
f"but at most {MAX_VARIANTS} are supported by Variant A-Z."
)
expected_variants = set(variant_filenames)
inconsistent_samples = []
for sample_dir in sample_dirs[1:]:
sample_variants = set(list_variant_filenames(sample_dir))
missing_files = sorted(expected_variants - sample_variants)
unexpected_files = sorted(sample_variants - expected_variants)
if missing_files or unexpected_files:
details = []
if missing_files:
details.append(f"missing {', '.join(missing_files)}")
if unexpected_files:
details.append(f"unexpected {', '.join(unexpected_files)}")
inconsistent_samples.append(
f"{sample_dir.name}: {'; '.join(details)}"
)
if inconsistent_samples:
details = "; ".join(inconsistent_samples)
raise RuntimeError(
f"Inconsistent MP4 variants in {stimuli_dir}: {details}"
)
sample_ids = tuple(sample_dir.name for sample_dir in sample_dirs)
print(
f"Loaded {len(sample_ids)} stimuli with {len(variant_filenames)} variants "
f"from {stimuli_dir}: {', '.join(sample_ids)}",
flush=True,
)
return sample_ids, variant_filenames
SAMPLE_IDS, VARIANT_FILENAMES = discover_stimuli(STIMULI_DIR)
VARIANT_LABELS = tuple(
f"Variant {chr(ord('A') + index)}" for index in range(len(VARIANT_FILENAMES))
)
VARIANT_KEYS = {
variant_label: variant_label.lower().replace(" ", "_")
for variant_label in VARIANT_LABELS
}
RATING_FIELDS = tuple(
(variant_label, metric_key)
for variant_label in VARIANT_LABELS
for metric_key in METRIC_LABELS
)
DEFAULT_LANGUAGE = "en"
UI_TEXT = {
"en": {
"title": "🎧 SCRV2A Human Evaluation",
"intro": f"""
## Instructions
Thank you for participating in this evaluation.
You will evaluate **{len(SAMPLE_IDS)} samples**. Each sample contains
**{len(VARIANT_FILENAMES)} anonymized variants**, each paired with a soundtrack
generated by a different method.
> **Important:** Variant labels are randomized independently for every sample.
> As a result, the same label (for example, **Variant A**) may represent a
> different generation method in another sample. Please evaluate each sample
> independently.
### Evaluation criteria
For each variant, rate the generated soundtrack from three perspectives:
- **Overall Quality:** the overall perceptual quality and naturalness of the audio.
- **Semantic Alignment:** how well the audio matches the visible content and events.
- **Temporal Alignment:** how accurately the audio is synchronized with visible events.
**Rating scale:** Select exactly one score for each metric on a 1–5 scale, where
**1 = Worst** and **5 = Best**.
""",
"language_button": "中文",
"consent_prompt": """
## Consent
Please read the information above before proceeding. To begin the evaluation,
please indicate your consent by checking the box below.
""",
"consent_label": (
"I have read and understood the information above, and I agree to "
"participate in this evaluation. I understand that my responses will "
"be collected, anonymized, and used for research and publication "
"purposes."
),
"method_output": "Method Output",
"metric_labels": METRIC_LABELS,
"rating_scale": "1 = Worst · 5 = Best",
"variant_prefix": "Variant",
"start": "Start evaluation",
"previous": "Previous",
"next": "Next",
"submit": "Submit",
"progress": "### Sample {current} of {total}",
"completion": """
# Thank you!
Thank you for participating in this evaluation.
""",
"invalid_response_id": "The evaluation state contains an invalid response ID.",
"missing_rating": "Select {metric} for {variant}.",
"invalid_rating": "Select a valid {metric} rating for {variant}.",
"rating_range": "The {metric} rating for {variant} must be from 1 to 5.",
"start_first": "Start the evaluation before entering ratings.",
"consent_required": (
"You must agree to participate before starting the evaluation."
),
"expected_ratings": "Expected {count} ratings.",
"incomplete_response": "The response for {sample_id} is incomplete.",
"save_failed": "The response could not be saved: {error}",
},
"zh": {
"title": "🎧 SCRV2A 人工评测",
"intro": f"""
## 评测说明
感谢您参与本次评测。
您将评测 **{len(SAMPLE_IDS)} 个样本**。每个样本包含
**{len(VARIANT_FILENAMES)} 个匿名版本**,每个版本均配有由不同方法生成的音轨。
> **请注意:** 每个样本中的版本标签都会独立随机排列。因此,同一标签
>(例如 **版本 A**)在不同样本中可能对应不同的生成方法。请分别独立评价
> 每个样本。
### 评测指标
请从以下三个角度评价每个版本生成的音轨:
- **整体质量:** 音频整体的听感质量与自然度。
- **语义对齐:** 音频与画面内容及事件的匹配程度。
- **时间对齐:** 音频与画面事件在时间上的同步程度。
**评分标准:** 每项指标必须且只能选择一个 1–5 分的评分,其中
**1 = 最差**、**5 = 最好**。
""",
"language_button": "English",
"consent_prompt": """
## 参与同意
请在继续之前仔细阅读以上信息。要开始评测,请勾选下方复选框以表示您同意参与。
""",
"consent_label": (
"我已阅读并理解以上信息,并同意参与本次评测。我理解,我的回答将被收集、"
"匿名化,并用于研究与论文发表。"
),
"method_output": "方法输出",
"metric_labels": {
"overall_quality": "整体质量",
"semantic_alignment": "语义对齐",
"temporal_alignment": "时间对齐",
},
"rating_scale": "1 = 最差 · 5 = 最好",
"variant_prefix": "版本",
"start": "开始评测",
"previous": "上一个样本",
"next": "下一个样本",
"submit": "提交",
"progress": "### 样本 {current} / {total}",
"completion": """
# 感谢参与!
感谢您参与本次评测。
""",
"invalid_response_id": "评测状态中的匿名响应 ID 无效。",
"missing_rating": "{variant}:请选择“{metric}”评分。",
"invalid_rating": "{variant}:“{metric}”评分无效,请重新选择。",
"rating_range": "{variant}:“{metric}”评分必须为 1 至 5。",
"start_first": "请先开始评测,再进行评分。",
"consent_required": "开始评测前,您必须勾选同意参与。",
"expected_ratings": "评分数量不正确,应为 {count} 项。",
"incomplete_response": "样本 {sample_id} 的评分尚未完成。",
"save_failed": "无法保存评测结果:{error}",
},
}
def normalize_language(language: Any) -> str:
"""Return a supported interface language code."""
return language if language in UI_TEXT else DEFAULT_LANGUAGE
def interface_text(language: Any, key: str) -> Any:
"""Return one localized interface value."""
return UI_TEXT[normalize_language(language)][key]
def metric_display_label(metric_key: str, language: Any) -> str:
"""Return a localized metric label."""
return interface_text(language, "metric_labels")[metric_key]
def variant_display_label(variant_label: str, language: Any) -> str:
"""Return a localized blind variant label without changing its identity."""
variant_suffix = variant_label.rsplit(" ", maxsplit=1)[-1]
return f"{interface_text(language, 'variant_prefix')} {variant_suffix}"
def progress_markdown(current_index: int, language: Any) -> str:
"""Return localized progress text for one sample index."""
return interface_text(language, "progress").format(
current=current_index + 1,
total=len(SAMPLE_IDS),
)
def page_title_html(language: Any) -> str:
"""Build a title without Markdown heading anchors."""
return f"<h1>{interface_text(language, 'title')}</h1>"
def matrix_header_html(label: str, scale: str | None = None) -> str:
"""Build the controlled HTML used by one matrix header cell."""
scale_html = (
f'<div class="matrix-header-scale">{scale}</div>' if scale else ""
)
return f'<div class="matrix-header-title">{label}</div>{scale_html}'
def mobile_metric_html(metric_key: str, language: Any) -> str:
"""Build one localized metric label shown only in the narrow layout."""
return f"<div>{metric_display_label(metric_key, language)}</div>"
def add_matrix_divider() -> None:
"""Add one decorative divider track to the desktop rating matrix."""
gr.HTML(
'<span aria-hidden="true"></span>',
min_width=0,
apply_default_css=False,
elem_classes="matrix-divider",
)
def normalize_response_id(response_id: Any, language: Any = DEFAULT_LANGUAGE) -> str:
"""Validate and normalize the internally generated response UUID."""
try:
parsed_id = uuid.UUID(str(response_id))
except (AttributeError, TypeError, ValueError) as error:
raise ValueError(interface_text(language, "invalid_response_id")) from error
if parsed_id.version != 4:
raise ValueError(interface_text(language, "invalid_response_id"))
return parsed_id.hex
def variant_mapping(response_id: str, sample_id: str) -> dict[str, str]:
"""Assign variant filenames to blind labels for one response and sample."""
variant_order = sorted(
VARIANT_FILENAMES,
key=lambda filename: hashlib.sha256(
f"{response_id}\0{sample_id}\0{filename}".encode("utf-8")
).digest(),
)
return dict(zip(VARIANT_LABELS, variant_order, strict=True))
def empty_answer() -> dict[str, Any]:
"""Create an unanswered sample record for the in-browser session state."""
return {
variant_label: {metric_key: None for metric_key in METRIC_LABELS}
for variant_label in VARIANT_LABELS
}
def create_evaluation_state() -> dict[str, Any]:
"""Create the complete server-side state for a new anonymous response."""
response_id = uuid.uuid4().hex
return {
"response_id": response_id,
"current_index": 0,
"variant_mappings": {
sample_id: variant_mapping(response_id, sample_id)
for sample_id in SAMPLE_IDS
},
"answers": {sample_id: empty_answer() for sample_id in SAMPLE_IDS},
}
def validate_rating(
value: Any,
variant_label: str,
metric_key: str,
language: Any = DEFAULT_LANGUAGE,
) -> int:
"""Validate and normalize one required 1--5 metric rating."""
metric_label = metric_display_label(metric_key, language)
displayed_variant = variant_display_label(variant_label, language)
if value is None or isinstance(value, bool):
raise ValueError(
interface_text(language, "missing_rating").format(
metric=metric_label,
variant=displayed_variant,
)
)
try:
rating = int(value)
except (TypeError, ValueError) as error:
raise ValueError(
interface_text(language, "invalid_rating").format(
metric=metric_label,
variant=displayed_variant,
)
) from error
if rating not in RATING_CHOICES or str(value).strip() not in {
str(choice) for choice in RATING_CHOICES
}:
raise ValueError(
interface_text(language, "rating_range").format(
metric=metric_label,
variant=displayed_variant,
)
)
return rating
def selections_are_complete(*rating_values: Any) -> bool:
"""Return whether all required selections contain valid values."""
if len(rating_values) != len(RATING_FIELDS):
return False
try:
for (variant_label, metric_key), value in zip(
RATING_FIELDS, rating_values, strict=True
):
validate_rating(value, variant_label, metric_key)
except ValueError:
return False
return True
def update_navigation_buttons(
state: dict[str, Any] | None,
language: Any,
*rating_values: Any,
) -> tuple[Any, Any]:
"""Enable navigation only after all required selections are complete."""
is_complete = selections_are_complete(*rating_values)
is_last = bool(state) and state["current_index"] == len(SAMPLE_IDS) - 1
return (
gr.Button(
value=interface_text(language, "next"),
visible=not is_last,
interactive=is_complete,
),
gr.Button(
value=interface_text(language, "submit"),
visible=is_last,
interactive=is_complete,
),
)
def save_current_answer(
state: dict[str, Any],
*rating_values: Any,
require_complete: bool,
language: Any = DEFAULT_LANGUAGE,
) -> dict[str, Any]:
"""Copy the UI fields into the current sample's session record."""
if not state:
raise ValueError(interface_text(language, "start_first"))
updated_state = copy.deepcopy(state)
sample_id = SAMPLE_IDS[updated_state["current_index"]]
if len(rating_values) != len(RATING_FIELDS):
raise ValueError(
interface_text(language, "expected_ratings").format(
count=len(RATING_FIELDS)
)
)
normalized_answer = empty_answer()
for (variant_label, metric_key), value in zip(
RATING_FIELDS, rating_values, strict=True
):
if value is None and not require_complete:
normalized_value = None
else:
normalized_value = validate_rating(
value,
variant_label,
metric_key,
language,
)
normalized_answer[variant_label][metric_key] = normalized_value
updated_state["answers"][sample_id] = normalized_answer
return updated_state
def media_paths(state: dict[str, Any]) -> tuple[str, ...]:
"""Return the randomized variant video paths for the current sample."""
sample_id = SAMPLE_IDS[state["current_index"]]
sample_dir = STIMULI_DIR / sample_id
mapping = state["variant_mappings"][sample_id]
return tuple(
str(sample_dir / mapping[variant_label])
for variant_label in VARIANT_LABELS
)
def render_current_sample(
state: dict[str, Any], language: Any = DEFAULT_LANGUAGE
) -> tuple[Any, ...]:
"""Build Gradio component updates for the state's current sample."""
current_index = state["current_index"]
sample_id = SAMPLE_IDS[current_index]
answer = state["answers"][sample_id]
video_paths = media_paths(state)
rating_values = tuple(
answer[variant_label][metric_key]
for variant_label, metric_key in RATING_FIELDS
)
is_first = current_index == 0
is_last = current_index == len(SAMPLE_IDS) - 1
is_complete = selections_are_complete(*rating_values)
return (
state,
gr.Markdown(value=progress_markdown(current_index, language)),
*(
gr.Video(
value=video_path,
label=variant_display_label(variant_label, language),
)
for variant_label, video_path in zip(
VARIANT_LABELS, video_paths, strict=True
)
),
*(
gr.Radio(
value=rating_value,
label=(
f"{variant_display_label(variant_label, language)} — "
f"{metric_display_label(metric_key, language)}"
),
)
for (variant_label, metric_key), rating_value in zip(
RATING_FIELDS, rating_values, strict=True
)
),
gr.Button(
value=interface_text(language, "previous"),
interactive=not is_first,
),
gr.Button(
value=interface_text(language, "next"),
visible=not is_last,
interactive=is_complete,
),
gr.Button(
value=interface_text(language, "submit"),
visible=is_last,
interactive=is_complete,
),
)
def update_start_button(consented: Any) -> Any:
"""Enable evaluation start only after the participant gives consent."""
return gr.Button(interactive=bool(consented))
def start_evaluation(language: Any, consented: Any) -> tuple[Any, ...]:
"""Create an anonymous response and display the first evaluation sample."""
if not consented:
raise gr.Error(interface_text(language, "consent_required"))
state = create_evaluation_state()
rendered_sample = render_current_sample(state, language)
return (
rendered_sample[0],
gr.Column(visible=False),
gr.Column(visible=True),
*rendered_sample[1:],
)
def go_to_previous_sample(
state: dict[str, Any],
language: Any,
*rating_values: Any,
) -> tuple[Any, ...]:
"""Save the current draft and display the previous sample."""
try:
updated_state = save_current_answer(
state,
*rating_values,
require_complete=False,
language=language,
)
except ValueError as error:
raise gr.Error(str(error)) from error
updated_state["current_index"] = max(0, updated_state["current_index"] - 1)
return render_current_sample(updated_state, language)
def go_to_next_sample(
state: dict[str, Any],
language: Any,
*rating_values: Any,
) -> tuple[Any, ...]:
"""Validate the current answer and display the next sample."""
try:
updated_state = save_current_answer(
state,
*rating_values,
require_complete=True,
language=language,
)
except ValueError as error:
raise gr.Error(str(error)) from error
updated_state["current_index"] = min(
len(SAMPLE_IDS) - 1, updated_state["current_index"] + 1
)
return render_current_sample(updated_state, language)
def build_response_document(
state: dict[str, Any], language: Any = DEFAULT_LANGUAGE
) -> dict[str, Any]:
"""Validate all answers and create the persistent JSON document."""
response_id = normalize_response_id(state.get("response_id"), language)
response_items = []
for sample_id in SAMPLE_IDS:
answer = state.get("answers", {}).get(sample_id)
if not answer:
raise ValueError(
interface_text(language, "incomplete_response").format(
sample_id=sample_id
)
)
# Recompute the mapping from the validated ID instead of trusting UI state.
mapping = variant_mapping(response_id, sample_id)
ratings = {}
for variant_label in VARIANT_LABELS:
variant_answer = answer.get(variant_label, {})
variant_key = VARIANT_KEYS[variant_label]
ratings[variant_key] = {
metric_key: validate_rating(
variant_answer.get(metric_key),
variant_label,
metric_key,
language,
)
for metric_key in METRIC_LABELS
}
response_items.append(
{
"sample_id": sample_id,
"variant_mapping": {
VARIANT_KEYS[variant_label]: mapping[variant_label]
for variant_label in VARIANT_LABELS
},
"ratings": ratings,
}
)
return {
"schema_version": 6,
"response_id": response_id,
"submitted_at_utc": datetime.now(timezone.utc).isoformat(),
"sample_order": list(SAMPLE_IDS),
"responses": response_items,
}
def write_response_document(response: dict[str, Any]) -> Path:
"""Atomically write one uniquely identified response document."""
response_id = normalize_response_id(response.get("response_id"))
response_path = RESPONSE_DIR / f"{response_id}.json"
temporary_path = RESPONSE_DIR / f".{response_id}.{uuid.uuid4().hex}.tmp"
with WRITE_LOCK:
RESPONSE_DIR.mkdir(parents=True, exist_ok=True)
try:
with temporary_path.open("x", encoding="utf-8") as output_file:
json.dump(response, output_file, ensure_ascii=False, indent=2)
output_file.write("\n")
output_file.flush()
os.replace(temporary_path, response_path)
finally:
temporary_path.unlink(missing_ok=True)
return response_path
def response_object_path(response_path: Path) -> str:
"""Return the response path as it appears inside the mounted Bucket."""
try:
filename = response_path.relative_to(RESPONSE_DIR)
return (Path(RESPONSE_DIR.name) / filename).as_posix()
except ValueError:
return response_path.as_posix()
def submit_response(
state: dict[str, Any],
language: Any,
*rating_values: Any,
) -> tuple[dict[str, Any], Any, Any, Any]:
"""Validate all samples and persist one anonymous JSON response."""
try:
completed_state = save_current_answer(
state,
*rating_values,
require_complete=True,
language=language,
)
response = build_response_document(completed_state, language)
response_path = write_response_document(response)
except (KeyError, OSError, TypeError, ValueError) as error:
message = interface_text(language, "save_failed").format(error=error)
raise gr.Error(message) from error
receipt = response["response_id"][:12]
object_path = response_object_path(response_path)
print(
f"Saved response receipt={receipt} bucket_path={object_path}",
flush=True,
)
return (
completed_state,
gr.Column(visible=False),
gr.Column(visible=True),
gr.Markdown(value=interface_text(language, "completion")),
)
def empty_sample_updates(language: Any) -> tuple[Any, ...]:
"""Return localized component updates before an evaluation has started."""
return (
None,
gr.Markdown(value=""),
*(
gr.Video(label=variant_display_label(variant_label, language))
for variant_label in VARIANT_LABELS
),
*(
gr.Radio(
label=(
f"{variant_display_label(variant_label, language)} — "
f"{metric_display_label(metric_key, language)}"
)
)
for variant_label, metric_key in RATING_FIELDS
),
gr.Button(
value=interface_text(language, "previous"), interactive=False
),
gr.Button(
value=interface_text(language, "next"), interactive=False
),
gr.Button(
value=interface_text(language, "submit"),
visible=False,
interactive=False,
),
)
def toggle_language(
language: Any,
state: dict[str, Any] | None,
consented: Any,
*rating_values: Any,
) -> tuple[Any, ...]:
"""Switch languages while preserving the current evaluation draft."""
current_language = normalize_language(language)
new_language = "zh" if current_language == "en" else "en"
if state:
try:
updated_state = save_current_answer(
state,
*rating_values,
require_complete=False,
language=new_language,
)
except ValueError as error:
raise gr.Error(str(error)) from error
sample_updates = render_current_sample(updated_state, new_language)
else:
sample_updates = empty_sample_updates(new_language)
header_updates = (
gr.HTML(
value=matrix_header_html(
interface_text(new_language, "method_output")
)
),
*(
gr.HTML(
value=matrix_header_html(
metric_display_label(metric_key, new_language),
interface_text(new_language, "rating_scale"),
)
)
for metric_key in METRIC_LABELS
),
)
mobile_metric_updates = tuple(
gr.HTML(value=mobile_metric_html(metric_key, new_language))
for _, metric_key in RATING_FIELDS
)
return (
new_language,
gr.HTML(value=page_title_html(new_language)),
gr.Markdown(value=interface_text(new_language, "intro")),
gr.Button(value=interface_text(new_language, "language_button")),
gr.Markdown(value=interface_text(new_language, "consent_prompt")),
gr.Checkbox(
value=bool(consented),
label=interface_text(new_language, "consent_label"),
),
*header_updates,
*mobile_metric_updates,
gr.Markdown(value=interface_text(new_language, "completion")),
gr.Button(
value=interface_text(new_language, "start"),
visible=not bool(state),
interactive=bool(consented),
),
*sample_updates,
)
CSS = """
.gradio-container {
width: min(100%, 1440px) !important;
max-width: 1440px !important;
margin: 0 auto !important;
padding-inline: clamp(8px, 1.5vw, 24px) !important;
box-sizing: border-box;
}
.page-shell {
width: 100%;
margin: 0 auto;
min-width: 0;
}
.page-title, .matrix-header, .completion-page {
text-align: center;
}
.page-title h1 {
margin: 0 0 10px !important;
font-family: inherit !important;
font-size: clamp(1.45rem, 2.3vw, 2rem) !important;
font-weight: 700 !important;
line-height: 1.2;
letter-spacing: normal;
}
.page-title h1 * {
font: inherit !important;
}
.language-button {
width: min(140px, 100%) !important;
margin: 0 auto 8px !important;
}
.page-intro {
width: 100% !important;
max-width: 900px;
margin: 0 auto 14px !important;
padding: clamp(18px, 2.4vw, 28px) !important;
box-sizing: border-box;
text-align: left;
background: var(--block-background-fill);
border: 1px solid var(--border-color-primary);
border-radius: 14px;
box-shadow: var(--block-shadow);
}
.page-intro .prose {
max-width: none !important;
}
.page-intro h2, .consent-instruction h2 {
margin: 0 0 12px !important;
font-size: clamp(1.12rem, 1.7vw, 1.35rem) !important;
font-weight: 650 !important;
line-height: 1.25;
}
.page-intro h3 {
margin: 18px 0 8px !important;
font-size: clamp(0.98rem, 1.35vw, 1.12rem) !important;
font-weight: 650 !important;
line-height: 1.3;
}
.page-intro p {
margin: 0 0 12px !important;
font-size: clamp(0.88rem, 1.2vw, 1rem);
line-height: 1.6;
}
.page-intro ul {
display: block;
max-width: none;
margin: 4px 0 14px !important;
padding-inline-start: 1.35rem;
text-align: left;
}
.page-intro li {
margin-block: 5px;
font-size: clamp(0.86rem, 1.15vw, 0.98rem);
line-height: 1.5;
}
.page-intro blockquote {
margin: 14px 0 16px !important;
padding: 11px 14px !important;
background: var(--background-fill-secondary);
border: 1px solid var(--border-color-primary);
border-inline-start: 3px solid var(--border-color-accent-subdued);
border-radius: 8px;
color: var(--body-text-color);
}
.page-intro blockquote p {
margin: 0 !important;
}
.consent-panel {
width: min(100%, 900px) !important;
margin: 0 auto !important;
padding: clamp(16px, 2.2vw, 24px) !important;
box-sizing: border-box;
gap: 8px !important;
background: var(--background-fill-secondary);
border: 1px solid var(--border-color-primary);
border-inline-start: 3px solid var(--border-color-accent-subdued);
border-radius: 14px;
box-shadow: var(--block-shadow);
}
.consent-instruction {
text-align: left;
}
.consent-instruction .prose {
max-width: none !important;
margin: 0 !important;
}
.consent-instruction p {
margin: 0 0 4px !important;
font-size: clamp(0.88rem, 1.2vw, 1rem);
line-height: 1.6;
}
.consent-checkbox {
width: 100% !important;
margin: 0 0 8px !important;
}
.consent-checkbox label {
align-items: flex-start !important;
font-weight: 500 !important;
line-height: 1.5 !important;
}
.start-button {
width: min(320px, 100%) !important;
margin: 4px auto 0 !important;
}
.evaluation-card {
width: 100%;
margin: 0 auto;
border: 1px solid var(--border-color-primary);
border-radius: 14px;
padding: clamp(8px, 1vw, 12px);
overflow-x: hidden;
box-sizing: border-box;
min-width: 0;
}
.compact-progress .prose {
text-align: center;
margin: 0 !important;
}
.rating-matrix {
--matrix-video-width: 260px;
--matrix-gap: clamp(4px, 0.8vw, 12px);
width: 100% !important;
min-width: 0;
}
.matrix-divider {
display: none !important;
}
.matrix-header, .rating-row {
display: grid !important;
grid-template-columns: var(--matrix-video-width) repeat(3, minmax(0, 1fr));
gap: var(--matrix-gap) !important;
width: 100%;
min-width: 0;
}
.matrix-header > *, .rating-row > * {
width: auto !important;
min-width: 0 !important;
flex: none !important;
}
.matrix-header {
align-items: end;
}
.video-header, .metric-header, .matrix-header-content {
width: 100% !important;
min-width: 0 !important;
max-width: 100%;
overflow: visible !important;
}
.matrix-header-content {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: flex-end;
line-height: 1.2;
}
.matrix-header-title {
width: 100%;
margin: 0;
font-size: clamp(0.78rem, 1.25vw, 1.05rem) !important;
font-weight: 600;
overflow-wrap: anywhere;
}
.matrix-header-scale {
width: 100%;
margin-top: 2px;
font-size: clamp(0.68rem, 0.9vw, 0.82rem);
overflow-wrap: anywhere;
}
.rating-row {
align-items: center;
}
.video-cell, .metric-cell, .method-video, .metric-radio {
min-width: 0 !important;
width: 100% !important;
}
.method-video video {
width: 100% !important;
height: auto !important;
max-height: none;
aspect-ratio: 1 / 1;
object-fit: contain;
}
.metric-radio .wrap,
.metric-radio [role="radiogroup"] {
display: grid !important;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: clamp(2px, 0.35vw, 5px) !important;
width: 100%;
min-width: 0;
}
.metric-radio .wrap > label,
.metric-radio [role="radiogroup"] > label {
min-width: 0 !important;
justify-content: center;
padding-inline: clamp(2px, 0.35vw, 6px) !important;
font-size: clamp(0.72rem, 1vw, 0.9rem);
}
.mobile-metric-label {
display: none;
margin-bottom: 5px;
text-align: center;
font-size: clamp(0.72rem, 2.2vw, 0.88rem);
font-weight: 600;
line-height: 1.2;
}
.evaluation-card button {
font-size: clamp(0.78rem, 1vw, 0.95rem);
}
.completion-page {
min-height: 60vh;
justify-content: center;
}
@media (min-width: 1201px) {
/* Flatten logical rows into a shared grid with real 1px divider tracks. */
.rating-matrix {
display: grid !important;
grid-template-columns:
var(--matrix-video-width) 1px minmax(0, 1fr)
1px minmax(0, 1fr)
1px minmax(0, 1fr);
column-gap: 0 !important;
row-gap: 0 !important;
align-items: stretch;
}
.matrix-header, .rating-row {
display: contents !important;
}
.matrix-header > :not(.matrix-divider),
.rating-row > :not(.matrix-divider) {
align-self: stretch;
box-sizing: border-box;
}
.matrix-header > :not(.matrix-divider) {
display: grid !important;
align-items: end;
padding: 8px var(--matrix-gap) 14px;
}
.rating-row > :not(.matrix-divider) {
padding: 12px var(--matrix-gap);
}
.matrix-header > :first-child,
.rating-row > :first-child {
padding-inline-start: 0;
}
.matrix-divider {
display: block !important;
align-self: stretch !important;
width: 1px !important;
min-width: 1px !important;
height: 100% !important;
min-height: 100% !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: var(--border-color-primary) !important;
background: color-mix(
in srgb, var(--body-text-color) 24%, transparent
) !important;
pointer-events: none;
}
.matrix-divider > * {
display: none !important;
}
.rating-row > .metric-cell {
display: grid !important;
align-items: center !important;
}
.metric-cell > .metric-radio {
align-self: center;
margin-block: 0 !important;
}
}
@media (max-width: 1100px) {
.gradio-container {
padding-inline: clamp(6px, 1vw, 12px) !important;
}
.evaluation-card {
border-radius: 10px;
}
.matrix-header, .rating-row {
gap: clamp(3px, 0.55vw, 6px) !important;
}
}
@media (max-width: 600px) {
.page-title h1 {
margin-bottom: 8px !important;
}
.page-intro {
margin-bottom: 10px !important;
padding: 16px !important;
border-radius: 10px;
}
.page-intro h2, .consent-instruction h2 {
margin-bottom: 10px !important;
}
.page-intro h3 {
margin-top: 15px !important;
}
.page-intro blockquote {
padding: 10px 12px !important;
}
.consent-panel {
padding: 15px !important;
border-radius: 10px;
}
}
@media (max-width: 1200px) {
.rating-matrix {
display: flex !important;
flex-direction: column;
}
.matrix-header {
display: none !important;
}
.matrix-divider {
display: none !important;
}
.rating-row {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"video"
"overall"
"semantic"
"temporal";
gap: clamp(7px, 1.8vw, 12px) !important;
padding: clamp(8px, 2vw, 12px);
border: 1px solid var(--border-color-primary);
border-radius: 10px;
}
.rating-row > .video-cell {
grid-area: video;
width: min(100%, 420px) !important;
max-width: 420px;
margin-inline: auto;
justify-self: center;
}
.metric-overall-quality {
grid-area: overall;
}
.metric-semantic-alignment {
grid-area: semantic;
}
.metric-temporal-alignment {
grid-area: temporal;
}
.mobile-metric-label {
display: block;
}
}
@media (max-width: 480px) {
.mobile-metric-label {
text-align: left;
}
.metric-radio .wrap > label,
.metric-radio [role="radiogroup"] > label {
min-height: 38px;
}
}
"""
with gr.Blocks(title="V2A Human Evaluation") as demo:
evaluation_state = gr.State(value=None)
language_state = gr.State(value=DEFAULT_LANGUAGE)
with gr.Column(elem_classes="page-shell") as survey_page:
page_title = gr.HTML(
page_title_html(DEFAULT_LANGUAGE),
apply_default_css=False,
elem_classes="page-title",
)
language_button = None
# --- If want to hide the language toggle button, comment out the following line. ---
# language_button = gr.Button(
# interface_text(DEFAULT_LANGUAGE, "language_button"),
# size="sm",
# elem_classes="language-button",
# )
# --- If want to hide the language toggle button, comment out the above line. ---
intro = gr.Markdown(
interface_text(DEFAULT_LANGUAGE, "intro"), elem_classes="page-intro"
)
with gr.Column(elem_classes="consent-panel") as consent_panel:
consent_prompt = gr.Markdown(
interface_text(DEFAULT_LANGUAGE, "consent_prompt"),
elem_classes="consent-instruction",
)
consent_checkbox = gr.Checkbox(
value=False,
label=interface_text(DEFAULT_LANGUAGE, "consent_label"),
container=False,
min_width=0,
elem_classes="consent-checkbox",
)
start_button = gr.Button(
interface_text(DEFAULT_LANGUAGE, "start"),
variant="primary",
interactive=False,
elem_classes="start-button",
)
with gr.Column(
visible=False, elem_classes="evaluation-card"
) as evaluation_panel:
progress = gr.Markdown(elem_classes="compact-progress")
video_components = []
rating_components = []
header_components = []
mobile_metric_components = []
with gr.Column(elem_classes="rating-matrix"):
with gr.Row(elem_classes="matrix-header"):
with gr.Column(min_width=0, elem_classes="video-header"):
header_components.append(
gr.HTML(
matrix_header_html(
interface_text(DEFAULT_LANGUAGE, "method_output")
),
min_width=0,
apply_default_css=False,
elem_classes="matrix-header-content",
)
)
for metric_key in METRIC_LABELS:
add_matrix_divider()
with gr.Column(min_width=0, elem_classes="metric-header"):
header_components.append(
gr.HTML(
matrix_header_html(
metric_display_label(
metric_key, DEFAULT_LANGUAGE
),
interface_text(
DEFAULT_LANGUAGE, "rating_scale"
),
),
min_width=0,
apply_default_css=False,
elem_classes="matrix-header-content",
)
)
for variant_label in VARIANT_LABELS:
with gr.Row(equal_height=True, elem_classes="rating-row"):
with gr.Column(min_width=0, elem_classes="video-cell"):
video_components.append(
gr.Video(
label=variant_display_label(
variant_label, DEFAULT_LANGUAGE
),
width="100%",
min_width=0,
interactive=False,
include_audio=True,
buttons=[],
elem_classes="method-video",
)
)
for metric_key in METRIC_LABELS:
add_matrix_divider()
metric_class = f"metric-{metric_key.replace('_', '-')}"
with gr.Column(
min_width=0,
elem_classes=["metric-cell", metric_class]
):
mobile_metric_components.append(
gr.HTML(
mobile_metric_html(
metric_key, DEFAULT_LANGUAGE
),
min_width=0,
apply_default_css=False,
elem_classes="mobile-metric-label",
)
)
rating_components.append(
gr.Radio(
choices=list(RATING_CHOICES),
label=(
f"{variant_display_label(variant_label, DEFAULT_LANGUAGE)} — "
f"{metric_display_label(metric_key, DEFAULT_LANGUAGE)}"
),
show_label=False,
container=False,
min_width=0,
elem_classes="metric-radio",
)
)
with gr.Row():
previous_button = gr.Button(
interface_text(DEFAULT_LANGUAGE, "previous"),
interactive=False,
)
next_button = gr.Button(
interface_text(DEFAULT_LANGUAGE, "next"),
variant="primary",
interactive=False,
)
submit_button = gr.Button(
interface_text(DEFAULT_LANGUAGE, "submit"),
variant="primary",
visible=False,
interactive=False,
)
with gr.Column(visible=False, elem_classes="completion-page") as completion_page:
completion_message = gr.Markdown(
interface_text(DEFAULT_LANGUAGE, "completion")
)
sample_outputs = [
evaluation_state,
progress,
*video_components,
*rating_components,
previous_button,
next_button,
submit_button,
]
answer_inputs = [
evaluation_state,
language_state,
*rating_components,
]
selection_inputs = rating_components
if language_button is not None:
language_button.click(
fn=toggle_language,
inputs=[
language_state,
evaluation_state,
consent_checkbox,
*rating_components,
],
outputs=[
language_state,
page_title,
intro,
language_button,
consent_prompt,
consent_checkbox,
*header_components,
*mobile_metric_components,
completion_message,
start_button,
*sample_outputs,
],
api_name=False,
)
consent_checkbox.input(
fn=update_start_button,
inputs=consent_checkbox,
outputs=start_button,
api_name=False,
)
start_button.click(
fn=start_evaluation,
inputs=[language_state, consent_checkbox],
outputs=[
evaluation_state,
consent_panel,
evaluation_panel,
*sample_outputs[1:],
],
api_name=False,
)
previous_button.click(
fn=go_to_previous_sample,
inputs=answer_inputs,
outputs=sample_outputs,
api_name=False,
)
next_button.click(
fn=go_to_next_sample,
inputs=answer_inputs,
outputs=sample_outputs,
api_name=False,
)
submit_button.click(
fn=submit_response,
inputs=answer_inputs,
outputs=[
evaluation_state,
survey_page,
completion_page,
completion_message,
],
api_name=False,
)
for selection_input in selection_inputs:
selection_input.input(
fn=update_navigation_buttons,
inputs=answer_inputs,
outputs=[next_button, submit_button],
api_name=False,
)
if __name__ == "__main__":
demo.launch(
css=CSS,
allowed_paths=[str(STIMULI_DIR)],
blocked_paths=[str(RESPONSE_DIR)],
)