|
|
| 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 |
| ) |
| ) |
|
|
| |
| 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 |
| |
| |
| |
| |
| |
| |
| |
| 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)], |
| ) |
|
|