menjunyu commited on
Commit
d83748f
·
verified ·
1 Parent(s): 05eaec6

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +771 -0
app.py ADDED
@@ -0,0 +1,771 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ VideoEval Movie-Level 问卷应用(Hugging Face Spaces)
3
+ 仅保留 Movie-Level 评测,并支持方法级别统计输出。
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import threading
9
+ import html
10
+ import shutil
11
+ import random
12
+ from collections import defaultdict
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ import gradio as gr
18
+ from huggingface_hub import CommitScheduler, HfApi, snapshot_download
19
+
20
+ # 路径配置(按用户要求)
21
+ # Spaces 推荐优先读取当前 Space 仓库内文件(app.py 同级)
22
+ APP_DIR = Path(__file__).resolve().parent
23
+ LOCAL_INPUT_DIR = APP_DIR / "user_study_input"
24
+ LOCAL_OUTPUT_DIR = APP_DIR / "user_study_results"
25
+ DATA_INPUT_DIR = Path("/data/user_study_input")
26
+ DATA_OUTPUT_DIR = Path("/data/user_study_results")
27
+ DATA_REPO_ID = os.environ.get("DATA_REPO_ID", "MemDirector/user_study_input")
28
+ RESULTS_REPO_ID = os.environ.get("RESULTS_REPO_ID", "MemDirector/user_study_results")
29
+ HF_TOKEN = os.environ.get("HF_TOKEN", None)
30
+ HF_TOKEN_FILE = os.environ.get("HF_TOKEN_FILE", "/data/.secrets/hf_token")
31
+ SPACE_MODE = os.environ.get("SPACE_MODE", "repo_first") # repo_first / data_first / hub_only
32
+
33
+ ROOT_DIR = APP_DIR
34
+ INPUT_DIR = LOCAL_INPUT_DIR
35
+ OUTPUT_DIR = LOCAL_OUTPUT_DIR
36
+ STORY_DIR = INPUT_DIR / "clip_movie_story"
37
+ VIDEO_DIR = INPUT_DIR / "video"
38
+
39
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
40
+ scheduler: Optional[CommitScheduler] = None
41
+ hf_api = HfApi()
42
+
43
+
44
+ def _load_hf_token() -> Optional[str]:
45
+ """
46
+ 安全读取 HF token:
47
+ 1) 优先 HF_TOKEN 环境变量(建议在 Spaces Secrets 配置)
48
+ 2) 其次读取服务端文件 HF_TOKEN_FILE
49
+ """
50
+ env_token = os.environ.get("HF_TOKEN", "").strip()
51
+ if env_token:
52
+ return env_token
53
+
54
+ token_file = Path(HF_TOKEN_FILE)
55
+ if token_file.exists():
56
+ try:
57
+ file_token = token_file.read_text(encoding="utf-8").strip()
58
+ if file_token:
59
+ return file_token
60
+ except Exception as e:
61
+ print(f"[INIT] failed to read HF token file: {e}")
62
+ return None
63
+
64
+
65
+ def _set_paths(input_dir: Path, output_dir: Path) -> None:
66
+ global INPUT_DIR, OUTPUT_DIR, STORY_DIR, VIDEO_DIR, ROOT_DIR
67
+ INPUT_DIR = input_dir
68
+ OUTPUT_DIR = output_dir
69
+ STORY_DIR = INPUT_DIR / "clip_movie_story"
70
+ VIDEO_DIR = INPUT_DIR / "video"
71
+ ROOT_DIR = INPUT_DIR.parent
72
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
73
+
74
+
75
+ def _try_use_local_repo_layout() -> bool:
76
+ # Space 仓库内自带 user_study_input 时,直接读取(最符合“已放上去直接跑”)
77
+ if LOCAL_INPUT_DIR.exists():
78
+ _set_paths(LOCAL_INPUT_DIR, LOCAL_OUTPUT_DIR)
79
+ return True
80
+ return False
81
+
82
+
83
+ def _try_use_data_volume_layout() -> bool:
84
+ # 如果使用 /data 持久卷,则可放在 /data/user_study_input
85
+ if DATA_INPUT_DIR.exists():
86
+ _set_paths(DATA_INPUT_DIR, DATA_OUTPUT_DIR)
87
+ return True
88
+ return False
89
+
90
+
91
+ def _try_download_from_hub() -> bool:
92
+ # 最后兜底:从 dataset repo 下载
93
+ if not DATA_REPO_ID:
94
+ return False
95
+ hub_root = APP_DIR / ".hf_space_cache"
96
+ try:
97
+ snapshot_download(
98
+ repo_id=DATA_REPO_ID,
99
+ repo_type="dataset",
100
+ local_dir=str(hub_root),
101
+ token=_load_hf_token(),
102
+ allow_patterns=[
103
+ "clip_movie_story/**",
104
+ "video/**",
105
+ "user_study_input/**",
106
+ "user_study_results/**",
107
+ ],
108
+ )
109
+ except Exception as e:
110
+ print(f"[INIT] snapshot_download failed: {e}")
111
+ return False
112
+
113
+ # 兼容两种 dataset 结构:
114
+ # A) 仓库根目录直接是 clip_movie_story/ 与 video/
115
+ # B) 仓库里有 user_study_input/ 子目录
116
+ if (hub_root / "clip_movie_story").exists() and (hub_root / "video").exists():
117
+ hub_input = hub_root
118
+ elif (hub_root / "user_study_input").exists():
119
+ hub_input = hub_root / "user_study_input"
120
+ else:
121
+ return False
122
+
123
+ # 结果统一写到 Space 仓库内目录,避免写到缓存目录后用户难以定位
124
+ hub_output = LOCAL_OUTPUT_DIR
125
+ _set_paths(hub_input, hub_output)
126
+ return True
127
+
128
+
129
+ def init_space_storage() -> None:
130
+ """
131
+ Hugging Face Spaces 规范:
132
+ - 从 dataset repo 拉取 user_study_input 与 user_study_results 到本地 ROOT_DIR
133
+ - 使用 CommitScheduler 持续回写 user_study_results
134
+ """
135
+ global scheduler
136
+
137
+ if SPACE_MODE == "hub_only":
138
+ ok = _try_download_from_hub()
139
+ elif SPACE_MODE == "data_first":
140
+ ok = _try_use_data_volume_layout() or _try_use_local_repo_layout() or _try_download_from_hub()
141
+ else:
142
+ ok = _try_use_local_repo_layout() or _try_use_data_volume_layout() or _try_download_from_hub()
143
+ print(f"[INIT] storage init mode={SPACE_MODE}, success={ok}, input={INPUT_DIR}, output={OUTPUT_DIR}")
144
+
145
+ if RESULTS_REPO_ID:
146
+ try:
147
+ scheduler = CommitScheduler(
148
+ repo_id=RESULTS_REPO_ID,
149
+ repo_type="dataset",
150
+ folder_path=str(OUTPUT_DIR),
151
+ path_in_repo="user_study_results",
152
+ every=3,
153
+ token=_load_hf_token(),
154
+ )
155
+ print(f"[INIT] CommitScheduler enabled: {RESULTS_REPO_ID}")
156
+ except Exception as e:
157
+ print(f"[INIT] CommitScheduler init failed: {e}")
158
+
159
+
160
+ init_space_storage()
161
+
162
+ # Movie-Level 指标定义(仅保留六个聚合指标)
163
+ MOVIE_CRITERIA: List[Tuple[str, str, str]] = [
164
+ ("NS", "叙事与剧本", "考察剧情是否忠于文本设定,情节推进是否自然连贯、易于理解。"),
165
+ ("AT", "视听与技术", "考察画面清晰度、角色稳定性、物理合理性及音频表现的综合质量。"),
166
+ ("AE", "美学与表现力", "考察镜头设计、构图与风格表达是否具有层次感与艺术表现力。"),
167
+ ("RF", "节奏与流动性", "考察剪辑快慢、段落衔接与音画同步是否顺畅,整体节奏是否舒适。"),
168
+ ("EE", "情感与参与度", "考察作品是否能有效调动情绪,让观众产生共鸣并保持观看投入。"),
169
+ ("OE", "整体体验", "考察作为完整短片的综合观感,包括完成度、可看性与整体吸引力。"),
170
+ ]
171
+
172
+ BASE_METRIC_KEYS = [k for k, _, _ in MOVIE_CRITERIA]
173
+ # 左侧 A 固定 MemDirector;右侧 B 固定 Seedance2.0;展示顺序仍由末尾 shuffle 随机
174
+ FIXED_A_METHOD = "MemDirector"
175
+ FIXED_B_METHOD = "Seedance2.0"
176
+ SAVE_LOCK = threading.Lock()
177
+
178
+ CUSTOM_CSS = """
179
+ .gradio-container {
180
+ max-width: 1300px !important;
181
+ margin-left: auto !important;
182
+ margin-right: auto !important;
183
+ background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%) !important;
184
+ }
185
+ #hero {
186
+ border: 1px solid #d9e5ff;
187
+ border-radius: 20px;
188
+ padding: 24px 26px;
189
+ background: linear-gradient(135deg, #ffffff 0%, #f2f7ff 50%, #eaf2ff 100%);
190
+ margin-bottom: 12px;
191
+ box-shadow: 0 12px 30px rgba(57, 94, 174, 0.12);
192
+ }
193
+ #hero h1 {
194
+ margin: 0 0 8px 0;
195
+ font-size: 2rem;
196
+ color: #1b2a4a;
197
+ }
198
+ #hero p {
199
+ margin: 0;
200
+ color: #41557f;
201
+ }
202
+ .panel {
203
+ border: 1px solid #dbe6fb !important;
204
+ border-radius: 16px !important;
205
+ padding: 16px !important;
206
+ background: #ffffff !important;
207
+ box-shadow: 0 8px 20px rgba(30, 78, 158, 0.08);
208
+ }
209
+ .center-panel {
210
+ max-width: 980px;
211
+ margin-left: auto !important;
212
+ margin-right: auto !important;
213
+ }
214
+ .section-head {
215
+ border: 1px solid #d7e5ff;
216
+ border-radius: 12px;
217
+ background: linear-gradient(180deg, #f7fbff 0%, #eef5ff 100%);
218
+ padding: 10px 14px;
219
+ margin-bottom: 12px;
220
+ color: #233a63;
221
+ font-weight: 700;
222
+ }
223
+ .hint {
224
+ color: #6480ad;
225
+ font-size: 0.9rem;
226
+ }
227
+ .metric-card {
228
+ border: 1px solid #dbe7fb !important;
229
+ border-radius: 14px !important;
230
+ padding: 18px 18px 14px 18px !important;
231
+ background: #fcfeff !important;
232
+ box-shadow: 0 6px 16px rgba(38, 84, 160, 0.06);
233
+ margin-bottom: 8px !important;
234
+ }
235
+ .metric-card p {
236
+ margin-top: 6px !important;
237
+ margin-bottom: 10px !important;
238
+ }
239
+ .sample-card {
240
+ border: 1px solid #deebff;
241
+ border-radius: 14px;
242
+ padding: 14px 16px;
243
+ background: #f9fcff;
244
+ }
245
+ .sample-card h3 {
246
+ margin: 0 0 8px 0;
247
+ color: #273f68;
248
+ }
249
+ .sample-card .sid {
250
+ margin-bottom: 10px;
251
+ color: #3f5f94;
252
+ }
253
+ .sample-card .story-title {
254
+ margin: 0 0 6px 0;
255
+ color: #2b4674;
256
+ font-weight: 600;
257
+ }
258
+ .sample-card .story-body {
259
+ margin: 0;
260
+ color: #334f7c;
261
+ white-space: pre-wrap;
262
+ line-height: 1.6;
263
+ }
264
+ #submit-btn,
265
+ #submit-btn button {
266
+ min-height: 44px !important;
267
+ width: min(520px, 92vw) !important;
268
+ font-size: 1.08rem !important;
269
+ font-weight: 700 !important;
270
+ padding: 0.45rem 1.2rem !important;
271
+ margin: 0 auto !important;
272
+ display: block !important;
273
+ }
274
+ """
275
+
276
+
277
+ def _safe_read_text(path: Path) -> str:
278
+ if not path.exists():
279
+ return ""
280
+ return path.read_text(encoding="utf-8-sig").strip()
281
+
282
+
283
+ def load_dataset_index() -> List[Dict[str, Any]]:
284
+ """扫描输入目录,构建可评测样本列表(每个方法-故事仅保留1个视频)。"""
285
+ stories = {p.stem: _safe_read_text(p) for p in sorted(STORY_DIR.glob("*.txt"))}
286
+ samples: List[Dict[str, Any]] = []
287
+
288
+ if not VIDEO_DIR.exists():
289
+ return samples
290
+
291
+ for method_dir in sorted([d for d in VIDEO_DIR.iterdir() if d.is_dir()]):
292
+ method = method_dir.name
293
+ for story_dir in sorted([d for d in method_dir.iterdir() if d.is_dir()]):
294
+ story_name = story_dir.name
295
+ # 每个方法-故事只评一次:如果有多个视频,默认取排序后第一个
296
+ video_candidates = sorted(story_dir.glob("*.mp4"))
297
+ if not video_candidates:
298
+ continue
299
+ video_path = video_candidates[0]
300
+ sample_id = f"{method}__{story_name}__{video_path.stem}"
301
+ samples.append(
302
+ {
303
+ "sample_id": sample_id,
304
+ "method": method,
305
+ "story_name": story_name,
306
+ "video_name": video_path.name,
307
+ "video_path": str(video_path.resolve()),
308
+ "story_text": stories.get(story_name, ""),
309
+ }
310
+ )
311
+ return samples
312
+
313
+
314
+ def load_evaluated_method_story_pairs() -> set:
315
+ """从结果目录读取已评估的 (method, story_name) 组合。"""
316
+ evaluated = set()
317
+ raw_root = OUTPUT_DIR / "raw_results"
318
+ if not raw_root.exists():
319
+ return evaluated
320
+
321
+ for fp in raw_root.rglob("*.json"):
322
+ try:
323
+ with open(fp, "r", encoding="utf-8-sig") as f:
324
+ data = json.load(f)
325
+ except Exception:
326
+ continue
327
+ sample = data.get("sample", {})
328
+ method = sample.get("method")
329
+ story_name = sample.get("story_name")
330
+ if method and story_name:
331
+ evaluated.add((method, story_name))
332
+ return evaluated
333
+
334
+
335
+ def sync_results_from_hub_to_local() -> None:
336
+ """
337
+ 从远程结果仓库拉取最新结果到本地 OUTPUT_DIR。
338
+ 仅用于“判定哪些样本已评估”,保证展示逻辑以远程为准。
339
+ """
340
+ if not RESULTS_REPO_ID:
341
+ return
342
+ sync_root = APP_DIR / ".hf_results_sync_cache"
343
+ local_raw = OUTPUT_DIR / "raw_results"
344
+ local_agg = OUTPUT_DIR / "method_aggregates.json"
345
+
346
+ # 远程优先:每次同步前先清空本地结果,避免沿用旧数据
347
+ if local_raw.exists():
348
+ shutil.rmtree(local_raw)
349
+ if local_agg.exists():
350
+ local_agg.unlink()
351
+
352
+ # 先清空同步缓存,避免远程已删除文件在本地残留导致“误判已评估”
353
+ if sync_root.exists():
354
+ shutil.rmtree(sync_root)
355
+ try:
356
+ snapshot_download(
357
+ repo_id=RESULTS_REPO_ID,
358
+ repo_type="dataset",
359
+ local_dir=str(sync_root),
360
+ token=_load_hf_token(),
361
+ allow_patterns=["user_study_results/**"],
362
+ force_download=True,
363
+ )
364
+ except Exception as e:
365
+ print(f"[SYNC] pull results repo failed: {e}")
366
+ return
367
+
368
+ remote_results_root = sync_root / "user_study_results"
369
+ if not remote_results_root.exists():
370
+ return
371
+
372
+ remote_raw = remote_results_root / "raw_results"
373
+ if remote_raw.exists():
374
+ shutil.copytree(remote_raw, local_raw)
375
+
376
+ remote_agg = remote_results_root / "method_aggregates.json"
377
+ if remote_agg.exists():
378
+ local_agg.parent.mkdir(parents=True, exist_ok=True)
379
+ shutil.copy2(remote_agg, local_agg)
380
+
381
+
382
+ def build_pending_samples() -> List[Dict[str, Any]]:
383
+ """构建对比样本池:同一 story 下 A 固定为 MemDirector,B 固定为 Seedance2.0。"""
384
+ all_samples = load_dataset_index()
385
+ by_story: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
386
+ for sample in all_samples:
387
+ by_story[sample["story_name"]].append(sample)
388
+
389
+ pending: List[Dict[str, Any]] = []
390
+ for story_name, story_samples in by_story.items():
391
+ by_method = {s["method"]: s for s in story_samples}
392
+ a_sample = by_method.get(FIXED_A_METHOD)
393
+ b_sample = by_method.get(FIXED_B_METHOD)
394
+ if not a_sample or not b_sample:
395
+ continue
396
+ pending.append(
397
+ {
398
+ "pair_id": f"{story_name}__{FIXED_A_METHOD}_vs_{FIXED_B_METHOD}",
399
+ "story_name": story_name,
400
+ "story_text": a_sample.get("story_text", "") or b_sample.get("story_text", ""),
401
+ "A": {
402
+ "method": a_sample["method"],
403
+ "video_name": a_sample["video_name"],
404
+ "video_path": a_sample["video_path"],
405
+ "sample_id": a_sample["sample_id"],
406
+ },
407
+ "B": {
408
+ "method": b_sample["method"],
409
+ "video_name": b_sample["video_name"],
410
+ "video_path": b_sample["video_path"],
411
+ "sample_id": b_sample["sample_id"],
412
+ },
413
+ }
414
+ )
415
+
416
+ random.shuffle(pending)
417
+ for i, sample in enumerate(pending, start=1):
418
+ sample["anon_id"] = f"id_{i:03d}"
419
+ return pending
420
+
421
+
422
+ def build_data_diagnostics(samples: List[Dict[str, Any]]) -> str:
423
+ return (
424
+ f"**SPACE_MODE**: `{SPACE_MODE}` \n"
425
+ f"**DATA_REPO_ID**: `{DATA_REPO_ID}` \n"
426
+ f"**RESULTS_REPO_ID**: `{RESULTS_REPO_ID}` \n"
427
+ f"**ROOT_DIR**: `{ROOT_DIR}` \n"
428
+ f"**INPUT_DIR exists**: `{INPUT_DIR.exists()}` \n"
429
+ f"**STORY_DIR exists**: `{STORY_DIR.exists()}` \n"
430
+ f"**VIDEO_DIR exists**: `{VIDEO_DIR.exists()}` \n"
431
+ f"**Pending samples**: `{len(samples)}`"
432
+ )
433
+
434
+
435
+ def compute_derived(scores: Dict[str, float]) -> Dict[str, float]:
436
+ """计算 CL / CRH / AVG。"""
437
+ cl = ((2 * scores["NS"] + 3 * scores["AT"]) / 5.0) + 0.5 * scores["AE"]
438
+ crh = ((scores["AT"] + 2 * scores["RF"] + scores["EE"] + scores["OE"]) / 5.0) + 0.5 * scores["AE"]
439
+ avg = (
440
+ 2 * scores["NS"]
441
+ + 4 * scores["AT"]
442
+ + 2 * scores["AE"]
443
+ + 2 * scores["RF"]
444
+ + scores["EE"]
445
+ + scores["OE"]
446
+ ) / 12.0
447
+ return {"CL": cl, "CRH": crh, "AVG": avg}
448
+
449
+
450
+ def save_single_result(
451
+ sample: Dict[str, Any],
452
+ evaluator_id: str,
453
+ metric_choice: Dict[str, str],
454
+ method_scores: Dict[str, Dict[str, float]],
455
+ summary: str,
456
+ ) -> Path:
457
+ """保存单个 A/B 对比问卷结果。"""
458
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
459
+ result_dir = OUTPUT_DIR / "raw_results" / sample["story_name"]
460
+ result_dir.mkdir(parents=True, exist_ok=True)
461
+ out_path = result_dir / f"{sample['pair_id']}_{evaluator_id}_{ts}.json"
462
+
463
+ payload = {
464
+ "timestamp": datetime.now().isoformat(),
465
+ "evaluator_id": evaluator_id,
466
+ "pair": sample,
467
+ "metric_choice": metric_choice,
468
+ "method_scores": method_scores,
469
+ "method_derived": {m: compute_derived(v) for m, v in method_scores.items()},
470
+ "summary": summary,
471
+ }
472
+ with open(out_path, "w", encoding="utf-8") as f:
473
+ json.dump(payload, f, ensure_ascii=False, indent=2)
474
+ return out_path
475
+
476
+
477
+ def recompute_method_aggregates() -> Path:
478
+ """
479
+ 统计每个方法各维度均分,并输出 method_aggregates.json。
480
+ 同时给出 CL/CRH/AVG 的方法均值。
481
+ """
482
+ raw_root = OUTPUT_DIR / "raw_results"
483
+ method_scores: Dict[str, Dict[str, List[float]]] = defaultdict(lambda: defaultdict(list))
484
+ method_count: Dict[str, int] = defaultdict(int)
485
+
486
+ if raw_root.exists():
487
+ for fp in raw_root.rglob("*.json"):
488
+ with open(fp, "r", encoding="utf-8-sig") as f:
489
+ data = json.load(f)
490
+ pair_method_scores = data.get("method_scores", {})
491
+ for method, scores in pair_method_scores.items():
492
+ if not all(k in scores for k in BASE_METRIC_KEYS):
493
+ continue
494
+ method_count[method] += 1
495
+ for k in BASE_METRIC_KEYS:
496
+ method_scores[method][k].append(float(scores[k]))
497
+ derived = compute_derived({k: float(scores[k]) for k in BASE_METRIC_KEYS})
498
+ for d_key, d_val in derived.items():
499
+ method_scores[method][d_key].append(float(d_val))
500
+
501
+ agg = {
502
+ "updated_at": datetime.now().isoformat(),
503
+ "metric_keys": BASE_METRIC_KEYS,
504
+ "derived_keys": ["CL", "CRH", "AVG"],
505
+ "methods": {},
506
+ }
507
+ for method in sorted(method_scores.keys()):
508
+ metric_avg = {}
509
+ for key, vals in method_scores[method].items():
510
+ metric_avg[key] = round(sum(vals) / len(vals), 4) if vals else None
511
+ agg["methods"][method] = {
512
+ "num_submissions": method_count[method],
513
+ "avg_scores": metric_avg,
514
+ }
515
+
516
+ out_path = OUTPUT_DIR / "method_aggregates.json"
517
+ with open(out_path, "w", encoding="utf-8") as f:
518
+ json.dump(agg, f, ensure_ascii=False, indent=2)
519
+ return out_path
520
+
521
+
522
+ def push_result_files_to_hub(single_path: Path, agg_path: Path) -> Optional[str]:
523
+ """
524
+ 提交后立即把结果文件上传到 RESULTS_REPO_ID,避免仅依赖定时 CommitScheduler。
525
+ 返回 None 表示成功;返回字符串表示失败原因。
526
+ """
527
+ if not RESULTS_REPO_ID:
528
+ return "未配置 RESULTS_REPO_ID。"
529
+ token = _load_hf_token()
530
+ if not token:
531
+ return "未配置 HF_TOKEN,无法写入 Hugging Face 远程仓库。"
532
+
533
+ try:
534
+ single_rel = single_path.relative_to(OUTPUT_DIR).as_posix()
535
+ hf_api.upload_file(
536
+ path_or_fileobj=str(single_path),
537
+ path_in_repo=f"user_study_results/{single_rel}",
538
+ repo_id=RESULTS_REPO_ID,
539
+ repo_type="dataset",
540
+ token=token,
541
+ )
542
+
543
+ agg_rel = agg_path.relative_to(OUTPUT_DIR).as_posix()
544
+ hf_api.upload_file(
545
+ path_or_fileobj=str(agg_path),
546
+ path_in_repo=f"user_study_results/{agg_rel}",
547
+ repo_id=RESULTS_REPO_ID,
548
+ repo_type="dataset",
549
+ token=token,
550
+ )
551
+ return None
552
+ except Exception as e:
553
+ return str(e)
554
+
555
+
556
+ def build_sample_brief_html(sample: Dict[str, Any], index: int, total: int) -> str:
557
+ story = sample.get("story_text") or "(未找到对应 story 文本,请检查 clip_movie_story 下是否有同名 txt)"
558
+ safe_story = html.escape(story)
559
+ return (
560
+ "<div class='sample-card'>"
561
+ "<div class='story-title'>剧情描述</div>"
562
+ f"<p class='story-body'>{safe_story}</p>"
563
+ "</div>"
564
+ )
565
+
566
+
567
+ def create_app():
568
+ samples = build_pending_samples()
569
+
570
+ with gr.Blocks(
571
+ title="VideoEval Movie-Level Evaluation",
572
+ css=CUSTOM_CSS,
573
+ theme=gr.themes.Soft(primary_hue="blue", secondary_hue="cyan", neutral_hue="slate"),
574
+ ) as app:
575
+ gr.HTML(
576
+ """
577
+ <div id="hero">
578
+ <h1>VideoEval · Movie-Level Evaluation</h1>
579
+ <p>统一电影级评测问卷,支持方法级均分统计(含 CL / CRH / AVG)</p>
580
+ </div>
581
+ """
582
+ )
583
+
584
+ samples_state = gr.State(samples)
585
+ submit_ok_state = gr.State(True)
586
+
587
+ with gr.Row():
588
+ with gr.Column(elem_classes=["panel", "center-panel"]):
589
+ gr.HTML("<div class='section-head' style='text-align:center;'>1) 视频与剧情</div>")
590
+ with gr.Row():
591
+ video_a = gr.Video(label="A", value=samples[0]["A"]["video_path"] if samples else None, height=360)
592
+ video_b = gr.Video(label="B", value=samples[0]["B"]["video_path"] if samples else None, height=360)
593
+ sample_info = gr.HTML(
594
+ "<div class='sample-card'><p class='story-body'>无可用样本</p></div>"
595
+ if not samples else build_sample_brief_html(samples[0], 0, len(samples))
596
+ )
597
+
598
+ status = gr.Markdown("")
599
+ gr.Markdown("## 2) 对比评分(A好 / B好 / 平手)")
600
+
601
+ score_widgets: Dict[str, gr.Radio] = {}
602
+ metric_groups = {
603
+ "I. 叙事与剧本 (NS)": ["NS"],
604
+ "II. 视听与技术 (AT)": ["AT"],
605
+ "III. 美学与表现力 (AE)": ["AE"],
606
+ "IV. 节奏与流动性 (RF)": ["RF"],
607
+ "V. 情感与参与度 (EE)": ["EE"],
608
+ "VI. 整体体验 (OE)": ["OE"],
609
+ }
610
+ criteria_map = {k: (name, desc) for k, name, desc in MOVIE_CRITERIA}
611
+
612
+ for section_title, keys in metric_groups.items():
613
+ with gr.Accordion(section_title, open=True):
614
+ for key in keys:
615
+ name, desc = criteria_map[key]
616
+ with gr.Group(elem_classes=["metric-card"]):
617
+ gr.Markdown(f"**{key} · {name}**")
618
+ gr.Markdown(f"<span class='hint'>{desc}</span>")
619
+ score_widgets[key] = gr.Radio(choices=["A好", "B好", "平手"], label=key)
620
+
621
+ final_summary = gr.Textbox(label="Final Summary(可选)", lines=4, placeholder="总结 A/B 的主要优缺点")
622
+ submit_btn = gr.Button("提交", variant="primary", elem_id="submit-btn")
623
+
624
+ def _submit(summary: str, curr_samples: List[Dict[str, Any]], *score_vals):
625
+ if not curr_samples:
626
+ msg = "❌ 没有可提交样本。"
627
+ gr.Warning(msg)
628
+ return msg, False
629
+ # 由于页面已移除样本选择控件,这里默认提交当前展示的第一个样本。
630
+ sample = curr_samples[0]
631
+ evaluator_id = "anonymous"
632
+
633
+ a_method = sample["A"]["method"]
634
+ b_method = sample["B"]["method"]
635
+ method_scores: Dict[str, Dict[str, float]] = {
636
+ a_method: {k: 0.0 for k in BASE_METRIC_KEYS},
637
+ b_method: {k: 0.0 for k in BASE_METRIC_KEYS},
638
+ }
639
+ metric_choice: Dict[str, str] = {}
640
+ for i, key in enumerate(BASE_METRIC_KEYS):
641
+ raw_score = score_vals[i] if i < len(score_vals) else None
642
+
643
+ if raw_score in (None, "", []):
644
+ msg = f"❌ 请为 `{key}` 打分。"
645
+ gr.Warning(msg)
646
+ return "", False
647
+ if isinstance(raw_score, str) and raw_score.strip().lower() in {"none", "null", "[]"}:
648
+ msg = f"❌ 请为 `{key}` 打分。"
649
+ gr.Warning(msg)
650
+ return "", False
651
+
652
+ choice = str(raw_score).strip()
653
+ if choice not in {"A好", "B好", "平手"}:
654
+ msg = f"❌ `{key}` 的选择无效,请重新选择 A好/B好/平手。"
655
+ gr.Warning(msg)
656
+ return msg, False
657
+
658
+ metric_choice[key] = choice
659
+ if choice == "A好":
660
+ method_scores[a_method][key] = 1.0
661
+ method_scores[b_method][key] = 0.0
662
+ elif choice == "B好":
663
+ method_scores[a_method][key] = 0.0
664
+ method_scores[b_method][key] = 1.0
665
+ else:
666
+ method_scores[a_method][key] = 0.5
667
+ method_scores[b_method][key] = 0.5
668
+
669
+ with SAVE_LOCK:
670
+ # 同步远程最新结果,确保“允许重复提交”后平均分统计包含全量提交。
671
+ sync_results_from_hub_to_local()
672
+ single_path = save_single_result(sample, evaluator_id, metric_choice, method_scores, summary or "")
673
+ agg_path = recompute_method_aggregates()
674
+ push_err = push_result_files_to_hub(single_path, agg_path)
675
+
676
+ if push_err:
677
+ msg = f"❌ 结果已本地保存,但写入远程 `{RESULTS_REPO_ID}` 失败:{push_err}"
678
+ gr.Warning(msg)
679
+ return msg, False
680
+
681
+ _ = (single_path, agg_path)
682
+ return "", True
683
+
684
+ def _refresh_on_load() -> Tuple[Any, Any, str, str, List[Dict[str, Any]]]:
685
+ refreshed_samples = build_pending_samples()
686
+ if not refreshed_samples:
687
+ return None, None, "<div class='sample-card'><p class='story-body'>无可用样本(需要同剧情下至少两个方法)</p></div>", "", refreshed_samples
688
+
689
+ first = refreshed_samples[0]
690
+ return (
691
+ first["A"]["video_path"],
692
+ first["B"]["video_path"],
693
+ build_sample_brief_html(first, 0, len(refreshed_samples)),
694
+ "",
695
+ refreshed_samples,
696
+ )
697
+
698
+ def _refresh_after_submit(
699
+ submit_ok: bool,
700
+ submit_msg: str,
701
+ curr_video_a: Any,
702
+ curr_video_b: Any,
703
+ curr_info: str,
704
+ curr_samples: List[Dict[str, Any]],
705
+ ) -> Tuple[Any, Any, str, str, List[Dict[str, Any]]]:
706
+ submit_msg = (submit_msg or "").strip()
707
+ # 提交失败时,不刷新样本/故事,保持当前页面不变
708
+ if not submit_ok:
709
+ return curr_video_a, curr_video_b, curr_info, submit_msg, curr_samples
710
+
711
+ refreshed_samples = build_pending_samples()
712
+ if not refreshed_samples:
713
+ status_msg = submit_msg
714
+ return None, None, "<div class='sample-card'><p class='story-body'>无可用样本(需要同剧情下至少两个方法)</p></div>", status_msg, refreshed_samples
715
+
716
+ first = refreshed_samples[0]
717
+ status_msg = submit_msg
718
+ return (
719
+ first["A"]["video_path"],
720
+ first["B"]["video_path"],
721
+ build_sample_brief_html(first, 0, len(refreshed_samples)),
722
+ status_msg,
723
+ refreshed_samples,
724
+ )
725
+
726
+ def _clear_scores_after_submit(submit_ok: bool) -> Tuple[Any, ...]:
727
+ # 提交失败时不清空输入,便于用户补充后重提
728
+ if not submit_ok:
729
+ keeps: List[Any] = [gr.update()]
730
+ keeps.extend(gr.update() for _ in BASE_METRIC_KEYS)
731
+ return tuple(keeps)
732
+
733
+ # 提交成功后清空所有分数与总结,避免沿用上一条样本的输入
734
+ clears: List[Any] = [gr.update(value="")]
735
+ clears.extend(gr.update(value=None) for _ in BASE_METRIC_KEYS)
736
+ return tuple(clears)
737
+
738
+ submit_inputs = [final_summary, samples_state]
739
+ for key in BASE_METRIC_KEYS:
740
+ submit_inputs.append(score_widgets[key])
741
+ submit_evt = submit_btn.click(_submit, inputs=submit_inputs, outputs=[status, submit_ok_state])
742
+ submit_evt.then(
743
+ _clear_scores_after_submit,
744
+ inputs=[submit_ok_state],
745
+ outputs=[final_summary] + [score_widgets[k] for k in BASE_METRIC_KEYS],
746
+ )
747
+ submit_evt.then(
748
+ _refresh_after_submit,
749
+ inputs=[submit_ok_state, status, video_a, video_b, sample_info, samples_state],
750
+ outputs=[video_a, video_b, sample_info, status, samples_state],
751
+ )
752
+
753
+ app.load(
754
+ _refresh_on_load,
755
+ outputs=[video_a, video_b, sample_info, status, samples_state],
756
+ )
757
+
758
+ return app
759
+
760
+
761
+ demo = create_app()
762
+
763
+ if __name__ == "__main__":
764
+ allowed_paths = [str(INPUT_DIR.resolve())] if INPUT_DIR.exists() else None
765
+ demo.launch(
766
+ server_name="0.0.0.0",
767
+ server_port=7860,
768
+ share=False,
769
+ show_error=True,
770
+ allowed_paths=allowed_paths,
771
+ )