import csv import itertools import random import json import os import uuid from datetime import datetime from io import BytesIO from typing import Dict, List, Tuple, Optional import gradio as gr try: from huggingface_hub import HfApi except Exception: # optional dependency at runtime HfApi = None # type: ignore BASE_DIR = os.path.dirname(__file__) PERSIST_DIR = os.environ.get("PERSIST_DIR", "/data") # Persistent local storage inside HF Spaces PERSIST_DIR = os.environ.get("PERSIST_DIR", "/data") TASK_CONFIG = { "Scene Composition & Object Insertion": { "folder": "scene_composition_and_object_insertion", "score_fields": [ ("physical_interaction_fidelity_score", "物理交互保真度 (Physical Interaction Fidelity)"), ("optical_effect_accuracy_score", "光学效应准确度 (Optical Effect Accuracy)"), ("semantic_functional_alignment_score", "语义/功能对齐度 (Semantic/Functional Alignment)"), ("overall_photorealism_score", "整体真实感 (Overall Photorealism)"), ], }, } def _csv_path_for_task(task_name: str, filename: str) -> str: folder = TASK_CONFIG[task_name]["folder"] return os.path.join(BASE_DIR, folder, filename) def _persist_csv_path_for_task(task_name: str) -> str: folder = TASK_CONFIG[task_name]["folder"] return os.path.join(PERSIST_DIR, folder, "evaluation_results.csv") def _resolve_image_path(path: str) -> str: return path if os.path.isabs(path) else os.path.join(BASE_DIR, path) def _file_exists_under_base(rel_or_abs_path: str) -> bool: """Check if file exists, resolving relative paths under BASE_DIR.""" check_path = rel_or_abs_path if os.path.isabs(rel_or_abs_path) else os.path.join(BASE_DIR, rel_or_abs_path) return os.path.exists(check_path) def _load_task_rows(task_name: str) -> List[Dict[str, str]]: csv_path = _csv_path_for_task(task_name, "results.csv") if not os.path.exists(csv_path): raise FileNotFoundError(f"未找到任务 {task_name} 的结果文件: {csv_path}") with open(csv_path, newline="", encoding="utf-8") as csv_file: reader = csv.DictReader(csv_file) return [row for row in reader] def _build_image_pairs(rows: List[Dict[str, str]], task_name: str) -> List[Dict[str, str]]: grouped: Dict[Tuple[str, str], List[Dict[str, str]]] = {} for row in rows: key = (row["test_id"], row["org_img"]) grouped.setdefault(key, []).append(row) pairs: List[Dict[str, str]] = [] folder = TASK_CONFIG[task_name]["folder"] for (test_id, org_img), entries in grouped.items(): for model_a, model_b in itertools.combinations(entries, 2): if model_a["model_name"] == model_b["model_name"]: continue org_path = os.path.join(folder, org_img) path_a = os.path.join(folder, model_a["path"]) path_b = os.path.join(folder, model_b["path"]) # Validate existence to avoid UI errors if not (_file_exists_under_base(org_path) and _file_exists_under_base(path_a) and _file_exists_under_base(path_b)): try: print("[VisArena] Skipping invalid paths for test_id=", test_id, { "org": org_path, "a": path_a, "b": path_b, }) except Exception: pass continue pair = { "test_id": test_id, "org_img": org_path, "model1_name": model_a["model_name"], "model1_res": model_a["res"], "model1_path": path_a, "model2_name": model_b["model_name"], "model2_res": model_b["res"], "model2_path": path_b, } pairs.append(pair) def sort_key(item: Dict[str, str]): test_id = item["test_id"] try: test_id_key = int(test_id) except ValueError: test_id_key = test_id return (test_id_key, item["model1_name"], item["model2_name"]) pairs.sort(key=sort_key) return pairs def _read_existing_eval_keys(task_name: str) -> set: """Read already-evaluated pair keys from persistent CSV, return a set of keys. Key is (test_id, frozenset({model1_name, model2_name}), org_img) to ignore A/B order. """ keys = set() csv_path = _persist_csv_path_for_task(task_name) if not os.path.exists(csv_path): return keys try: with open(csv_path, newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for r in reader: tid = str(r.get("test_id", "")).strip() m1 = str(r.get("model1_name", "")).strip() m2 = str(r.get("model2_name", "")).strip() org = str(r.get("org_img", "")).strip() if tid and m1 and m2 and org: keys.add((tid, frozenset({m1, m2}), org)) except Exception: pass return keys def _schedule_round_robin_by_test_id(pairs: List[Dict[str, str]], seed: Optional[int] = None) -> List[Dict[str, str]]: """Interleave pairs across test_ids for balanced coverage; shuffle within each group. """ groups: Dict[str, List[Dict[str, str]]] = {} for p in pairs: groups.setdefault(p["test_id"], []).append(p) rnd = random.Random(seed) for lst in groups.values(): rnd.shuffle(lst) # round-robin drain ordered: List[Dict[str, str]] = [] while True: progressed = False for tid in sorted(groups.keys(), key=lambda x: (int(x) if x.isdigit() else x)): if groups[tid]: ordered.append(groups[tid].pop()) progressed = True if not progressed: break return ordered def load_task(task_name: str): if not task_name: raise gr.Error("Please select a task first.") rows = _load_task_rows(task_name) pairs = _build_image_pairs(rows, task_name) # Filter out already evaluated pairs from persistent CSV done_keys = _read_existing_eval_keys(task_name) def key_of(p: Dict[str, str]): return (p["test_id"], frozenset({p["model1_name"], p["model2_name"]}), p["org_img"]) pairs = [p for p in pairs if key_of(p) not in done_keys] # Balanced schedule across test_ids with a stable randomization seed_env = os.environ.get("SCHEDULE_SEED") seed = int(seed_env) if seed_env and seed_env.isdigit() else None pairs = _schedule_round_robin_by_test_id(pairs, seed=seed) # Assign A/B order to counteract position bias: alternate after scheduling for idx, p in enumerate(pairs): p["swap"] = bool(idx % 2) # True -> A=B's image; False -> A=A's image if not pairs: raise gr.Error("No valid image pairs found for evaluation. Please check the data.") return pairs def _format_pair_header(_pair: Dict[str, str]) -> str: # Mask model identity in UI; keep header neutral return "" def _build_eval_row(pair: Dict[str, str], scores: Dict[str, int]) -> Dict[str, object]: row = { "eval_date": datetime.utcnow().isoformat(), "test_id": pair["test_id"], "model1_name": pair["model1_name"], "model2_name": pair["model2_name"], "org_img": pair["org_img"], "model1_res": pair["model1_res"], "model2_res": pair["model2_res"], "model1_path": pair["model1_path"], "model2_path": pair["model2_path"], } row.update(scores) return row def _local_persist_csv_path(task_name: str) -> str: folder = TASK_CONFIG[task_name]["folder"] return os.path.join(PERSIST_DIR, folder, "evaluation_results.csv") def _append_local_persist_csv(task_name: str, row: Dict[str, object]) -> bool: csv_path = _local_persist_csv_path(task_name) os.makedirs(os.path.dirname(csv_path), exist_ok=True) csv_exists = os.path.exists(csv_path) fieldnames = [ "eval_date", "test_id", "model1_name", "model2_name", "org_img", "model1_res", "model2_res", "model1_path", "model2_path", "model1_physical_interaction_fidelity_score", "model1_optical_effect_accuracy_score", "model1_semantic_functional_alignment_score", "model1_overall_photorealism_score", "model2_physical_interaction_fidelity_score", "model2_optical_effect_accuracy_score", "model2_semantic_functional_alignment_score", "model2_overall_photorealism_score", ] try: with open(csv_path, "a", newline="", encoding="utf-8") as csv_file: writer = csv.DictWriter(csv_file, fieldnames=fieldnames) if not csv_exists: writer.writeheader() writer.writerow(row) return True except Exception: return False def _upload_eval_record_to_dataset(task_name: str, row: Dict[str, object]) -> Tuple[bool, str]: """Upload a single-eval JSONL record to a dataset repo. Repo is taken from EVAL_REPO_ID env or defaults to 'peiranli0930/VisEval'. Returns (ok, message) for UI feedback and debugging. """ if HfApi is None: return False, "huggingface_hub not installed" token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN") repo_id = os.environ.get("EVAL_REPO_ID", "peiranli0930/VisEval") if not token: return False, "Missing write token (HF_TOKEN/HUGGINGFACEHUB_API_TOKEN)" if not repo_id: return False, "EVAL_REPO_ID is not set" try: from huggingface_hub import CommitOperationAdd api = HfApi(token=token) date_prefix = datetime.utcnow().strftime("%Y-%m-%d") folder = TASK_CONFIG[task_name]["folder"] uid = str(uuid.uuid4()) path_in_repo = f"submissions/{folder}/{date_prefix}/{uid}.jsonl" payload = (json.dumps(row, ensure_ascii=False) + "\n").encode("utf-8") operations = [CommitOperationAdd(path_in_repo=path_in_repo, path_or_fileobj=BytesIO(payload))] api.create_commit( repo_id=repo_id, repo_type="dataset", operations=operations, commit_message=f"Add eval {folder} {row.get('test_id')} {uid}", ) return True, f"Uploaded: {repo_id}/{path_in_repo}" except Exception as e: # Print to logs for debugging in Space try: print("[VisArena] Upload to dataset failed:", repr(e)) except Exception: pass return False, f"Exception: {type(e).__name__}: {e}" def on_task_change(task_name: str, _state_pairs: List[Dict[str, str]]): pairs = load_task(task_name) pair = pairs[0] header = _format_pair_header(pair) # Defaults for A and B (8 sliders total) default_scores = [3, 3, 3, 3, 3, 3, 3, 3] # Pick display order according to swap flag a_path = pair["model2_path"] if pair.get("swap") else pair["model1_path"] b_path = pair["model1_path"] if pair.get("swap") else pair["model2_path"] max_index = max(0, len(pairs) - 1) return ( pairs, gr.update(value=0, minimum=0, maximum=max_index, visible=(len(pairs) > 1)), gr.update(value=header), _resolve_image_path(pair["org_img"]), _resolve_image_path(a_path), _resolve_image_path(b_path), *default_scores, gr.update(value=f"Total {len(pairs)} pairs pending evaluation."), ) def on_pair_navigate(index: int, pairs: List[Dict[str, str]]): if not pairs: raise gr.Error("请先选择任务。") index = int(index) index = max(0, min(index, len(pairs) - 1)) pair = pairs[index] header = _format_pair_header(pair) a_path = pair["model2_path"] if pair.get("swap") else pair["model1_path"] b_path = pair["model1_path"] if pair.get("swap") else pair["model2_path"] return ( gr.update(value=index), gr.update(value=header), _resolve_image_path(pair["org_img"]), _resolve_image_path(a_path), _resolve_image_path(b_path), 3, 3, 3, 3, # A 3, 3, 3, 3, # B ) def on_submit( task_name: str, index: int, pairs: List[Dict[str, str]], a_physical_score: int, a_optical_score: int, a_semantic_score: int, a_overall_score: int, b_physical_score: int, b_optical_score: int, b_semantic_score: int, b_overall_score: int, ): if not task_name: raise gr.Error("请先选择任务。") if not pairs: raise gr.Error("No image pairs loaded for the current task.") pair = pairs[index] score_map = { # Model A "model1_physical_interaction_fidelity_score": int(a_physical_score), "model1_optical_effect_accuracy_score": int(a_optical_score), "model1_semantic_functional_alignment_score": int(a_semantic_score), "model1_overall_photorealism_score": int(a_overall_score), # Model B "model2_physical_interaction_fidelity_score": int(b_physical_score), "model2_optical_effect_accuracy_score": int(b_optical_score), "model2_semantic_functional_alignment_score": int(b_semantic_score), "model2_overall_photorealism_score": int(b_overall_score), } # Map A/B scores to the correct model columns depending on swap if pair.get("swap"): # UI A == model2, UI B == model1 score_map = { "model1_physical_interaction_fidelity_score": int(b_physical_score), "model1_optical_effect_accuracy_score": int(b_optical_score), "model1_semantic_functional_alignment_score": int(b_semantic_score), "model1_overall_photorealism_score": int(b_overall_score), "model2_physical_interaction_fidelity_score": int(a_physical_score), "model2_optical_effect_accuracy_score": int(a_optical_score), "model2_semantic_functional_alignment_score": int(a_semantic_score), "model2_overall_photorealism_score": int(a_overall_score), } row = _build_eval_row(pair, score_map) ok_local = _append_local_persist_csv(task_name, row) ok_hub, hub_msg = _upload_eval_record_to_dataset(task_name, row) next_index = min(index + 1, len(pairs) - 1) info = f"Saved evaluation for Test ID {pair['test_id']}." info += " Local persistence " + ("succeeded" if ok_local else "failed") + "." info += " Dataset upload " + ("succeeded" if ok_hub else "failed") + (f" ({hub_msg})" if hub_msg else "") + "." if next_index != index: pair = pairs[next_index] header = _format_pair_header(pair) a_path = pair["model2_path"] if pair.get("swap") else pair["model1_path"] b_path = pair["model1_path"] if pair.get("swap") else pair["model2_path"] return ( gr.update(value=next_index), gr.update(value=header), _resolve_image_path(pair["org_img"]), _resolve_image_path(a_path), _resolve_image_path(b_path), 3, 3, 3, 3, 3, 3, 3, 3, gr.update(value=info + f" Moved to next pair ({next_index + 1}/{len(pairs)})."), ) return ( gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), 3, 3, 3, 3, 3, 3, 3, 3, gr.update(value=info + " This is the last pair."), ) with gr.Blocks(title="VisArena Human Evaluation") as demo: gr.Markdown( """ # VisArena Human Evaluation Please select a task and rate the generated images. Each score ranges from 1 (poor) to 5 (excellent). """ ) with gr.Row(): task_selector = gr.Dropdown( label="Task", choices=list(TASK_CONFIG.keys()), interactive=True, value="Scene Composition & Object Insertion", ) index_slider = gr.Slider( label="Pair Index", value=0, minimum=0, maximum=0, step=1, interactive=True, visible=False, ) pair_state = gr.State([]) pair_header = gr.Markdown("") # Layout: Original on top, two outputs below with their own sliders with gr.Row(): with gr.Column(scale=12): orig_image = gr.Image(type="filepath", label="Original", interactive=False) with gr.Row(): with gr.Column(scale=6): model1_image = gr.Image(type="filepath", label="Output A", interactive=False) a_physical_input = gr.Slider(1, 5, value=3, step=1, label="A: Physical Interaction Fidelity") a_optical_input = gr.Slider(1, 5, value=3, step=1, label="A: Optical Effect Accuracy") a_semantic_input = gr.Slider(1, 5, value=3, step=1, label="A: Semantic/Functional Alignment") a_overall_input = gr.Slider(1, 5, value=3, step=1, label="A: Overall Photorealism") with gr.Column(scale=6): model2_image = gr.Image(type="filepath", label="Output B", interactive=False) b_physical_input = gr.Slider(1, 5, value=3, step=1, label="B: Physical Interaction Fidelity") b_optical_input = gr.Slider(1, 5, value=3, step=1, label="B: Optical Effect Accuracy") b_semantic_input = gr.Slider(1, 5, value=3, step=1, label="B: Semantic/Functional Alignment") b_overall_input = gr.Slider(1, 5, value=3, step=1, label="B: Overall Photorealism") submit_button = gr.Button("Submit Evaluation", variant="primary") feedback_box = gr.Markdown("") # Event bindings task_selector.change( fn=on_task_change, inputs=[task_selector, pair_state], outputs=[ pair_state, index_slider, pair_header, orig_image, model1_image, model2_image, a_physical_input, a_optical_input, a_semantic_input, a_overall_input, b_physical_input, b_optical_input, b_semantic_input, b_overall_input, feedback_box, ], ) index_slider.release( fn=on_pair_navigate, inputs=[index_slider, pair_state], outputs=[ index_slider, pair_header, orig_image, model1_image, model2_image, a_physical_input, a_optical_input, a_semantic_input, a_overall_input, b_physical_input, b_optical_input, b_semantic_input, b_overall_input, ], ) submit_button.click( fn=on_submit, inputs=[ task_selector, index_slider, pair_state, a_physical_input, a_optical_input, a_semantic_input, a_overall_input, b_physical_input, b_optical_input, b_semantic_input, b_overall_input, ], outputs=[ index_slider, pair_header, orig_image, model1_image, model2_image, a_physical_input, a_optical_input, a_semantic_input, a_overall_input, b_physical_input, b_optical_input, b_semantic_input, b_overall_input, feedback_box, ], ) # Auto-load default task on startup demo.load( fn=on_task_change, inputs=[task_selector, pair_state], outputs=[ pair_state, index_slider, pair_header, orig_image, model1_image, model2_image, a_physical_input, a_optical_input, a_semantic_input, a_overall_input, b_physical_input, b_optical_input, b_semantic_input, b_overall_input, feedback_box, ], ) if __name__ == "__main__": demo.queue().launch()