Show partial submissions only on tabs they were scored on

#3
by atdokmeci - opened
Files changed (1) hide show
  1. util/render.py +440 -435
util/render.py CHANGED
@@ -1,435 +1,440 @@
1
- """Build per-pillar HTML leaderboard tables for the VANTAGE-Bench leaderboard.
2
-
3
- Primary API
4
- -----------
5
- build_all_html_tables(filtered_models, global_ranks) -> dict[str, str]
6
- Returns one hand-rendered HTML <table> string per pillar key in
7
- config.PILLARS ('overall', 'spatial', 'st', 'temporal', 'semantic'),
8
- all sharing the Overall tab's visual style (striping, hover, badges,
9
- bold column-max, row data-id for the click bridge in app.py).
10
-
11
- make_radar_svg(model) -> str
12
- 4-axis radar chart SVG (220 × 160 px) for the model detail side panel.
13
- Axes: Semantic · Spatial · Sp-Temp · Temporal (clockwise from top).
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import math
19
-
20
- from .config import PILLARS, TASKS, TASK_METRIC_LABELS
21
- from .data import ModelRecord
22
-
23
- _MISSING = "—"
24
-
25
- # Short task key (config.TASKS) → JSON score field in ModelRecord.scores.
26
- _TASK_JSON_FIELD: dict[str, str] = {
27
- "loc": "2d_localization",
28
- "ground": "2d_referring_expressions",
29
- "pointing": "2d_spatial_pointing",
30
- "sot": "single_object_tracking",
31
- "temploc": "temporal_localization",
32
- "dvc": "dense_video_captioning",
33
- "ev": "event_verification",
34
- "vqa": "video_qa",
35
- }
36
-
37
- # Overall tab: ordered (display_label, json_field) pairs.
38
- # Task columns ordered to align with pillar super-header groups injected by JS
39
- # (see _RESIZE_JS in app.py): Spatial (3) · Spatio-Temp (1) · Temporal (2) · Semantic (2).
40
- _OVERALL_SCORE_COLS: list[tuple[str, str]] = [
41
- # Spatial
42
- ("Obj Loc", "2d_localization"),
43
- ("Ref Exp", "2d_referring_expressions"),
44
- ("Pointing", "2d_spatial_pointing"),
45
- # Spatio-Temporal
46
- ("SOT", "single_object_tracking"),
47
- # Temporal
48
- ("Temp Loc", "temporal_localization"),
49
- ("DVC", "dense_video_captioning"),
50
- # Semantic
51
- ("Event Ver", "event_verification"),
52
- ("VQA", "video_qa"),
53
- ]
54
-
55
- # Pillar group spec for the Overall-tab super-header row.
56
- # Each entry: (colspan, label). Two empty leading cells cover the # and Name columns.
57
- OVERALL_PILLAR_GROUPS: list[tuple[int, str]] = [
58
- (2, ""), # # + Name
59
- (3, "Spatial"),
60
- (1, "Spatio-Temp"),
61
- (2, "Temporal"),
62
- (2, "Semantic"),
63
- ]
64
-
65
- # Pillar aggregate score column shown immediately after Name on each pillar tab.
66
- # Reuses the same pre-computed pillar field from the model record — no recomputation.
67
- _PILLAR_AGGREGATE_COL: dict[str, tuple[str, str]] = {
68
- "spatial": ("Spatial", "spatial"),
69
- "st": ("Sp-Temp", "spatio_temporal"),
70
- "temporal": ("Temporal", "temporal"),
71
- "semantic": ("Semantic", "semantic"),
72
- }
73
-
74
-
75
- # -- Shared primitives -----------------------------------------------------
76
-
77
-
78
- def _sort_by_rank(
79
- models: list[ModelRecord], rank_map: dict[str, int]
80
- ) -> list[ModelRecord]:
81
- sentinel = float("inf")
82
- return sorted(models, key=lambda m: (rank_map.get(m.id, sentinel), m.name))
83
-
84
-
85
- def _column_maxes(
86
- models: list[ModelRecord], json_fields: list[str]
87
- ) -> dict[str, float]:
88
- """Maximum score per field across the given model set."""
89
- maxes: dict[str, float] = {}
90
- for f in json_fields:
91
- vals = [m.scores[f] for m in models if f in m.scores]
92
- if vals:
93
- maxes[f] = max(vals)
94
- return maxes
95
-
96
-
97
- def _model_html(m: ModelRecord, rank_one_id: str | None) -> str:
98
- """HTML cell for the Model column: name + inline badges, org sub-line, type badges."""
99
- name_part = f"<b>{m.name}</b>" if m.id == rank_one_id else m.name
100
- if m.model_url:
101
- name_core = f'<a href="{m.model_url}" target="_blank" class="mc-link">{name_part}</a>'
102
- else:
103
- name_core = name_part
104
- verified_badge = '<span class="b b-verified">✓</span>' if m.verified else ""
105
- name_html = f'<span class="mc-name-row">{name_core}{verified_badge}</span>'
106
-
107
- badges: list[str] = []
108
- if m.result_type == "ensemble":
109
- badges.append('<span class="b b-ensemble">system / pipeline</span>')
110
- else:
111
- badges.append('<span class="b b-single">single</span>')
112
- if m.type == "open":
113
- badges.append('<span class="b b-open">open</span>')
114
- else:
115
- badges.append('<span class="b b-prop">prop.</span>')
116
- if m.is_new:
117
- badges.append('<span class="b b-new">new</span>')
118
- badge_html = "".join(badges)
119
- return (
120
- f'<span data-n="{m.name}" class="mc">'
121
- f'{name_html}'
122
- f'<span class="mc-org">{m.organization}</span>'
123
- f'<span class="mc-badges">{badge_html}</span>'
124
- f'</span>'
125
- )
126
-
127
-
128
- def _score_cols_for_pillar(
129
- pillar: str,
130
- selected_tasks: list[str] | None = None,
131
- ) -> list[tuple[str, str]]:
132
- """Return [(display_label, json_field)] for one pillar.
133
-
134
- selected_tasks: JSON field names to include (Gradio column-toggle).
135
- None means show all. Only applies to non-overall pillars.
136
- """
137
- if pillar == "overall":
138
- return list(_OVERALL_SCORE_COLS)
139
- task_keys = PILLARS[pillar]
140
- if selected_tasks is not None:
141
- task_keys = [tk for tk in task_keys if _TASK_JSON_FIELD[tk] in selected_tasks]
142
- cols: list[tuple[str, str]] = []
143
- # Lead each pillar tab with its pre-computed pillar aggregate score.
144
- agg = _PILLAR_AGGREGATE_COL.get(pillar)
145
- if agg is not None:
146
- cols.append(agg)
147
- for tk in task_keys:
148
- json_field = _TASK_JSON_FIELD[tk]
149
- metric = TASK_METRIC_LABELS.get(json_field, "")
150
- label = f"{TASKS[tk]} ({metric})" if metric else TASKS[tk]
151
- cols.append((label, json_field))
152
- return cols
153
-
154
-
155
- # -- Primary API -----------------------------------------------------------
156
-
157
-
158
- def build_overall_html_table(
159
- filtered_models: list[ModelRecord],
160
- rank_map: dict[str, int],
161
- ) -> str:
162
- """Hand-rendered HTML <table> for the Overall tab.
163
-
164
- Used instead of gr.Dataframe because Gradio's DataFrame component
165
- does not cleanly support multi-level (grouped) column headers.
166
- Renders a two-row header:
167
- # · Name · Overall span both rows (rowspan=2)
168
- Spatial (×3) | Spatio-Temp (×1) | Temporal (×2) | Semantic (×2)
169
- Obj Loc | Ref Exp | Pointing | SOT | Temp Loc | DVC | Event Ver | VQA
170
-
171
- The Overall column sits between Name and the pillar groups as a
172
- standalone (non-grouped) column showing each model's stored overall
173
- score from ModelRecord.scores["overall"].
174
-
175
- Preserves: striping, hover, bold column-max, model badges, rank order,
176
- scroll behavior.
177
- """
178
- sorted_models = _sort_by_rank(filtered_models, rank_map)
179
- rank_one_id = next(
180
- (m.id for m in sorted_models if rank_map.get(m.id) == 1), None
181
- )
182
-
183
- score_cols = _OVERALL_SCORE_COLS
184
- json_fields = [f for _, f in score_cols] + ["overall"]
185
- col_max = _column_maxes(filtered_models, json_fields)
186
- overall_max = col_max.get("overall")
187
-
188
- # Render the table directly (no .table-wrap wrapper, no resize handle):
189
- # the gr.HTML element itself acts as the rectangular container, with
190
- # styling on .lb-table-overall in css.py. This avoids Gradio's
191
- # rounded/clipped DataFrame-shell visuals.
192
- parts: list[str] = ['<table class="lb-overall-table">']
193
-
194
- # Column-width control via <colgroup> (avoids nth-child collisions across
195
- # the two header rows). The Spatio-Temporal column gets a wider class
196
- # because its lone task (SOT) sits under the long "Spatio-Temp" pillar
197
- # super-header — we want that header to fit on one line.
198
- _ST_FIELD = "single_object_tracking"
199
- parts.append('<colgroup>')
200
- parts.append('<col class="col-rank">')
201
- parts.append('<col class="col-name">')
202
- parts.append('<col class="col-overall">')
203
- for _, field in score_cols:
204
- cls = "col-score col-score-st" if field == _ST_FIELD else "col-score"
205
- parts.append(f'<col class="{cls}">')
206
- parts.append('</colgroup>')
207
-
208
- # Header: two rows. # / Name / Overall span both via rowspan=2.
209
- parts.append('<thead>')
210
- parts.append('<tr class="lb-group-row">')
211
- parts.append('<th class="lb-corner" rowspan="2">#</th>')
212
- parts.append('<th class="lb-corner" rowspan="2">Name</th>')
213
- parts.append('<th class="lb-corner lb-corner-num" rowspan="2">Overall</th>')
214
- for span, label in [(3, "Spatial"), (1, "Spatio-Temp"),
215
- (2, "Temporal"), (2, "Semantic")]:
216
- parts.append(f'<th class="lb-group" colspan="{span}">{label}</th>')
217
- parts.append('</tr>')
218
-
219
- parts.append('<tr class="lb-task-row">')
220
- for label, _ in score_cols:
221
- parts.append(f'<th>{label}</th>')
222
- parts.append('</tr>')
223
- parts.append('</thead>')
224
-
225
- # Body
226
- # Total column count: # + Name + Overall + 8 task scores = 11.
227
- total_cols = 3 + len(score_cols)
228
- parts.append('<tbody>')
229
- if not sorted_models:
230
- parts.append(
231
- f'<tr><td colspan="{total_cols}" class="lb-empty-row">'
232
- f'No models match — adjust the filters.</td></tr>'
233
- )
234
- for i, m in enumerate(sorted_models, 1):
235
- parts.append(f'<tr data-id="{m.id}">')
236
- parts.append(f'<td class="lb-rank">{i}</td>')
237
- parts.append(f'<td class="lb-name">{_model_html(m, rank_one_id)}</td>')
238
-
239
- # Overall cell — bold if it's the column max.
240
- ov = m.scores.get("overall")
241
- if ov is None:
242
- parts.append(f'<td class="lb-score lb-overall">{_MISSING}</td>')
243
- else:
244
- is_max = overall_max is not None and ov == overall_max
245
- cls = "lb-score lb-overall lb-max" if is_max else "lb-score lb-overall"
246
- parts.append(f'<td class="{cls}">{ov:.2f}</td>')
247
-
248
- for _, f in score_cols:
249
- v = m.scores.get(f)
250
- if v is None:
251
- parts.append(f'<td class="lb-score">{_MISSING}</td>')
252
- else:
253
- m_val = col_max.get(f)
254
- is_max = m_val is not None and v == m_val
255
- cls = "lb-score lb-max" if is_max else "lb-score"
256
- parts.append(f'<td class="{cls}">{v:.2f}</td>')
257
- parts.append('</tr>')
258
- parts.append('</tbody>')
259
- parts.append('</table>')
260
- return "".join(parts)
261
-
262
-
263
- def build_pillar_html_table(
264
- pillar: str,
265
- filtered_models: list[ModelRecord],
266
- rank_map: dict[str, int],
267
- ) -> str:
268
- """Hand-rendered HTML <table> for a pillar tab (Spatial / Sp-Temp /
269
- Temporal / Semantic) — mirrors build_overall_html_table's markup and
270
- CSS classes so every tab shares one visual style.
271
-
272
- Single-row header (no pillar super-header grouping needed on these
273
- tabs). The pillar's own aggregate score is the first score column and
274
- gets the same purple-outlined "headline" treatment as the Overall
275
- column on the Overall tab (`lb-agg` mirrors `lb-overall`).
276
- """
277
- sorted_models = _sort_by_rank(filtered_models, rank_map)
278
- rank_one_id = next(
279
- (m.id for m in sorted_models if rank_map.get(m.id) == 1), None
280
- )
281
-
282
- score_cols = _score_cols_for_pillar(pillar)
283
- json_fields = [f for _, f in score_cols]
284
- col_max = _column_maxes(filtered_models, json_fields)
285
- agg_field = _PILLAR_AGGREGATE_COL[pillar][1] if pillar in _PILLAR_AGGREGATE_COL else None
286
-
287
- parts: list[str] = ['<table class="lb-pillar-table">']
288
-
289
- parts.append('<colgroup>')
290
- parts.append('<col class="col-rank">')
291
- parts.append('<col class="col-name">')
292
- for _, field in score_cols:
293
- cls = "col-score col-agg" if field == agg_field else "col-score"
294
- parts.append(f'<col class="{cls}">')
295
- parts.append('</colgroup>')
296
-
297
- parts.append('<thead>')
298
- parts.append('<tr class="lb-header-row">')
299
- parts.append('<th class="lb-corner">#</th>')
300
- parts.append('<th class="lb-corner">Name</th>')
301
- for label, field in score_cols:
302
- if field == agg_field:
303
- parts.append(f'<th class="lb-agg-th">{label}</th>')
304
- else:
305
- parts.append(f'<th>{label}</th>')
306
- parts.append('</tr>')
307
- parts.append('</thead>')
308
-
309
- total_cols = 2 + len(score_cols)
310
- parts.append('<tbody>')
311
- if not sorted_models:
312
- parts.append(
313
- f'<tr><td colspan="{total_cols}" class="lb-empty-row">'
314
- f'No models match — adjust the filters.</td></tr>'
315
- )
316
- for i, m in enumerate(sorted_models, 1):
317
- parts.append(f'<tr data-id="{m.id}">')
318
- parts.append(f'<td class="lb-rank">{i}</td>')
319
- parts.append(f'<td class="lb-name">{_model_html(m, rank_one_id)}</td>')
320
-
321
- for _, f in score_cols:
322
- is_agg = f == agg_field
323
- v = m.scores.get(f)
324
- base_cls = "lb-score lb-agg" if is_agg else "lb-score"
325
- if v is None:
326
- parts.append(f'<td class="{base_cls}">{_MISSING}</td>')
327
- else:
328
- m_val = col_max.get(f)
329
- is_max = m_val is not None and v == m_val
330
- cls = f"{base_cls} lb-max" if is_max else base_cls
331
- parts.append(f'<td class="{cls}">{v:.2f}</td>')
332
- parts.append('</tr>')
333
- parts.append('</tbody>')
334
- parts.append('</table>')
335
- return "".join(parts)
336
-
337
-
338
- def build_all_html_tables(
339
- filtered_models: list[ModelRecord],
340
- global_ranks: dict[str, dict[str, int]],
341
- ) -> dict[str, str]:
342
- """Return {pillar_key: html_table_str} for every tab, Overall included.
343
-
344
- Replaces build_all_tables + build_overall_html_table as the single
345
- entry point once every tab renders as a hand-built HTML table instead
346
- of a mix of gr.HTML (Overall) and gr.Dataframe (the four pillars).
347
- """
348
- result: dict[str, str] = {
349
- "overall": build_overall_html_table(filtered_models, global_ranks["overall"]),
350
- }
351
- for pillar in PILLARS:
352
- if pillar == "overall":
353
- continue
354
- result[pillar] = build_pillar_html_table(
355
- pillar, filtered_models, global_ranks[pillar]
356
- )
357
- return result
358
-
359
-
360
- # -- Radar SVG ------------------------------------------------------------
361
-
362
-
363
- def make_radar_svg(m: ModelRecord) -> str:
364
- """4-axis radar chart SVG for the model detail side panel.
365
-
366
- Axes (clockwise from top): Semantic · Spatial · Sp-Temp · Temporal.
367
- Canvas: 220 × 160 px. Scores assumed in [0, 100].
368
- """
369
- W, H = 220, 160
370
- cx, cy = W / 2, 82.0 # slightly below centre for top-label room
371
- chart_r = 50.0 # polygon radius
372
- label_r = 67.0 # label ring radius
373
-
374
- labels = ["Semantic", "Spatial", "Sp-Temp", "Temporal"]
375
- fields = ["semantic", "spatial", "spatio_temporal", "temporal"]
376
- vals: list[float] = []
377
- for f in fields:
378
- raw = m.scores.get(f)
379
- v = float(raw) / 100.0 if raw is not None else 0.0
380
- vals.append(max(0.0, min(1.0, v)))
381
-
382
- N = 4
383
- step = 2 * math.pi / N
384
- off = -math.pi / 2 # index 0 points straight up
385
-
386
- parts: list[str] = [
387
- f'<svg xmlns="http://www.w3.org/2000/svg" '
388
- f'width="{W}" height="{H}" viewBox="0 0 {W} {H}">'
389
- ]
390
-
391
- # Grid rings (4 concentric)
392
- for g in range(1, 5):
393
- rg = chart_r * g / 4
394
- parts.append(
395
- f'<circle cx="{cx}" cy="{cy}" r="{rg:.1f}" '
396
- f'fill="none" stroke="#e5e7eb" stroke-width="0.5"/>'
397
- )
398
-
399
- # Axis lines
400
- for i in range(N):
401
- a = off + i * step
402
- x2 = cx + chart_r * math.cos(a)
403
- y2 = cy + chart_r * math.sin(a)
404
- parts.append(
405
- f'<line x1="{cx}" y1="{cy}" '
406
- f'x2="{x2:.1f}" y2="{y2:.1f}" '
407
- f'stroke="#e5e7eb" stroke-width="0.5"/>'
408
- )
409
-
410
- # Data polygon
411
- poly_pts = " ".join(
412
- f"{cx + chart_r * v * math.cos(off + i * step):.1f},"
413
- f"{cy + chart_r * v * math.sin(off + i * step):.1f}"
414
- for i, v in enumerate(vals)
415
- )
416
- parts.append(
417
- f'<polygon points="{poly_pts}" '
418
- f'fill="rgba(37,99,235,0.12)" stroke="#2563eb" '
419
- f'stroke-width="1.5" stroke-linejoin="round"/>'
420
- )
421
-
422
- # Labels
423
- for i, lbl in enumerate(labels):
424
- a = off + i * step
425
- lx = cx + label_r * math.cos(a)
426
- ly = cy + label_r * math.sin(a)
427
- parts.append(
428
- f'<text x="{lx:.1f}" y="{ly:.1f}" '
429
- f'text-anchor="middle" dominant-baseline="middle" '
430
- f'font-size="9" fill="#6b7280" font-family="sans-serif">'
431
- f'{lbl}</text>'
432
- )
433
-
434
- parts.append("</svg>")
435
- return "".join(parts)
 
 
 
 
 
 
1
+ """Build per-pillar HTML leaderboard tables for the VANTAGE-Bench leaderboard.
2
+
3
+ Primary API
4
+ -----------
5
+ build_all_html_tables(filtered_models, global_ranks) -> dict[str, str]
6
+ Returns one hand-rendered HTML <table> string per pillar key in
7
+ config.PILLARS ('overall', 'spatial', 'st', 'temporal', 'semantic'),
8
+ all sharing the Overall tab's visual style (striping, hover, badges,
9
+ bold column-max, row data-id for the click bridge in app.py).
10
+
11
+ make_radar_svg(model) -> str
12
+ 4-axis radar chart SVG (220 × 160 px) for the model detail side panel.
13
+ Axes: Semantic · Spatial · Sp-Temp · Temporal (clockwise from top).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+
20
+ from .config import PILLARS, TASKS, TASK_METRIC_LABELS
21
+ from .data import ModelRecord
22
+
23
+ _MISSING = "—"
24
+
25
+ # Short task key (config.TASKS) → JSON score field in ModelRecord.scores.
26
+ _TASK_JSON_FIELD: dict[str, str] = {
27
+ "loc": "2d_localization",
28
+ "ground": "2d_referring_expressions",
29
+ "pointing": "2d_spatial_pointing",
30
+ "sot": "single_object_tracking",
31
+ "temploc": "temporal_localization",
32
+ "dvc": "dense_video_captioning",
33
+ "ev": "event_verification",
34
+ "vqa": "video_qa",
35
+ }
36
+
37
+ # Overall tab: ordered (display_label, json_field) pairs.
38
+ # Task columns ordered to align with pillar super-header groups injected by JS
39
+ # (see _RESIZE_JS in app.py): Spatial (3) · Spatio-Temp (1) · Temporal (2) · Semantic (2).
40
+ _OVERALL_SCORE_COLS: list[tuple[str, str]] = [
41
+ # Spatial
42
+ ("Obj Loc", "2d_localization"),
43
+ ("Ref Exp", "2d_referring_expressions"),
44
+ ("Pointing", "2d_spatial_pointing"),
45
+ # Spatio-Temporal
46
+ ("SOT", "single_object_tracking"),
47
+ # Temporal
48
+ ("Temp Loc", "temporal_localization"),
49
+ ("DVC", "dense_video_captioning"),
50
+ # Semantic
51
+ ("Event Ver", "event_verification"),
52
+ ("VQA", "video_qa"),
53
+ ]
54
+
55
+ # Pillar group spec for the Overall-tab super-header row.
56
+ # Each entry: (colspan, label). Two empty leading cells cover the # and Name columns.
57
+ OVERALL_PILLAR_GROUPS: list[tuple[int, str]] = [
58
+ (2, ""), # # + Name
59
+ (3, "Spatial"),
60
+ (1, "Spatio-Temp"),
61
+ (2, "Temporal"),
62
+ (2, "Semantic"),
63
+ ]
64
+
65
+ # Pillar aggregate score column shown immediately after Name on each pillar tab.
66
+ # Reuses the same pre-computed pillar field from the model record — no recomputation.
67
+ _PILLAR_AGGREGATE_COL: dict[str, tuple[str, str]] = {
68
+ "spatial": ("Spatial", "spatial"),
69
+ "st": ("Sp-Temp", "spatio_temporal"),
70
+ "temporal": ("Temporal", "temporal"),
71
+ "semantic": ("Semantic", "semantic"),
72
+ }
73
+
74
+
75
+ # -- Shared primitives -----------------------------------------------------
76
+
77
+
78
+ def _sort_by_rank(
79
+ models: list[ModelRecord], rank_map: dict[str, int]
80
+ ) -> list[ModelRecord]:
81
+ sentinel = float("inf")
82
+ return sorted(models, key=lambda m: (rank_map.get(m.id, sentinel), m.name))
83
+
84
+
85
+ def _column_maxes(
86
+ models: list[ModelRecord], json_fields: list[str]
87
+ ) -> dict[str, float]:
88
+ """Maximum score per field across the given model set."""
89
+ maxes: dict[str, float] = {}
90
+ for f in json_fields:
91
+ vals = [m.scores[f] for m in models if f in m.scores]
92
+ if vals:
93
+ maxes[f] = max(vals)
94
+ return maxes
95
+
96
+
97
+ def _model_html(m: ModelRecord, rank_one_id: str | None) -> str:
98
+ """HTML cell for the Model column: name + inline badges, org sub-line, type badges."""
99
+ name_part = f"<b>{m.name}</b>" if m.id == rank_one_id else m.name
100
+ if m.model_url:
101
+ name_core = f'<a href="{m.model_url}" target="_blank" class="mc-link">{name_part}</a>'
102
+ else:
103
+ name_core = name_part
104
+ verified_badge = '<span class="b b-verified">✓</span>' if m.verified else ""
105
+ name_html = f'<span class="mc-name-row">{name_core}{verified_badge}</span>'
106
+
107
+ badges: list[str] = []
108
+ if m.result_type == "ensemble":
109
+ badges.append('<span class="b b-ensemble">system / pipeline</span>')
110
+ else:
111
+ badges.append('<span class="b b-single">single</span>')
112
+ if m.type == "open":
113
+ badges.append('<span class="b b-open">open</span>')
114
+ else:
115
+ badges.append('<span class="b b-prop">prop.</span>')
116
+ if m.is_new:
117
+ badges.append('<span class="b b-new">new</span>')
118
+ badge_html = "".join(badges)
119
+ return (
120
+ f'<span data-n="{m.name}" class="mc">'
121
+ f'{name_html}'
122
+ f'<span class="mc-org">{m.organization}</span>'
123
+ f'<span class="mc-badges">{badge_html}</span>'
124
+ f'</span>'
125
+ )
126
+
127
+
128
+ def _score_cols_for_pillar(
129
+ pillar: str,
130
+ selected_tasks: list[str] | None = None,
131
+ ) -> list[tuple[str, str]]:
132
+ """Return [(display_label, json_field)] for one pillar.
133
+
134
+ selected_tasks: JSON field names to include (Gradio column-toggle).
135
+ None means show all. Only applies to non-overall pillars.
136
+ """
137
+ if pillar == "overall":
138
+ return list(_OVERALL_SCORE_COLS)
139
+ task_keys = PILLARS[pillar]
140
+ if selected_tasks is not None:
141
+ task_keys = [tk for tk in task_keys if _TASK_JSON_FIELD[tk] in selected_tasks]
142
+ cols: list[tuple[str, str]] = []
143
+ # Lead each pillar tab with its pre-computed pillar aggregate score.
144
+ agg = _PILLAR_AGGREGATE_COL.get(pillar)
145
+ if agg is not None:
146
+ cols.append(agg)
147
+ for tk in task_keys:
148
+ json_field = _TASK_JSON_FIELD[tk]
149
+ metric = TASK_METRIC_LABELS.get(json_field, "")
150
+ label = f"{TASKS[tk]} ({metric})" if metric else TASKS[tk]
151
+ cols.append((label, json_field))
152
+ return cols
153
+
154
+
155
+ # -- Primary API -----------------------------------------------------------
156
+
157
+
158
+ def build_overall_html_table(
159
+ filtered_models: list[ModelRecord],
160
+ rank_map: dict[str, int],
161
+ ) -> str:
162
+ """Hand-rendered HTML <table> for the Overall tab.
163
+
164
+ Used instead of gr.Dataframe because Gradio's DataFrame component
165
+ does not cleanly support multi-level (grouped) column headers.
166
+ Renders a two-row header:
167
+ # · Name · Overall span both rows (rowspan=2)
168
+ Spatial (×3) | Spatio-Temp (×1) | Temporal (×2) | Semantic (×2)
169
+ Obj Loc | Ref Exp | Pointing | SOT | Temp Loc | DVC | Event Ver | VQA
170
+
171
+ The Overall column sits between Name and the pillar groups as a
172
+ standalone (non-grouped) column showing each model's stored overall
173
+ score from ModelRecord.scores["overall"].
174
+
175
+ Preserves: striping, hover, bold column-max, model badges, rank order,
176
+ scroll behavior.
177
+ """
178
+ # Partial submissions without an overall score are left off this tab.
179
+ filtered_models = [m for m in filtered_models if "overall" in m.scores]
180
+ sorted_models = _sort_by_rank(filtered_models, rank_map)
181
+ rank_one_id = next(
182
+ (m.id for m in sorted_models if rank_map.get(m.id) == 1), None
183
+ )
184
+
185
+ score_cols = _OVERALL_SCORE_COLS
186
+ json_fields = [f for _, f in score_cols] + ["overall"]
187
+ col_max = _column_maxes(filtered_models, json_fields)
188
+ overall_max = col_max.get("overall")
189
+
190
+ # Render the table directly (no .table-wrap wrapper, no resize handle):
191
+ # the gr.HTML element itself acts as the rectangular container, with
192
+ # styling on .lb-table-overall in css.py. This avoids Gradio's
193
+ # rounded/clipped DataFrame-shell visuals.
194
+ parts: list[str] = ['<table class="lb-overall-table">']
195
+
196
+ # Column-width control via <colgroup> (avoids nth-child collisions across
197
+ # the two header rows). The Spatio-Temporal column gets a wider class
198
+ # because its lone task (SOT) sits under the long "Spatio-Temp" pillar
199
+ # super-header — we want that header to fit on one line.
200
+ _ST_FIELD = "single_object_tracking"
201
+ parts.append('<colgroup>')
202
+ parts.append('<col class="col-rank">')
203
+ parts.append('<col class="col-name">')
204
+ parts.append('<col class="col-overall">')
205
+ for _, field in score_cols:
206
+ cls = "col-score col-score-st" if field == _ST_FIELD else "col-score"
207
+ parts.append(f'<col class="{cls}">')
208
+ parts.append('</colgroup>')
209
+
210
+ # Header: two rows. # / Name / Overall span both via rowspan=2.
211
+ parts.append('<thead>')
212
+ parts.append('<tr class="lb-group-row">')
213
+ parts.append('<th class="lb-corner" rowspan="2">#</th>')
214
+ parts.append('<th class="lb-corner" rowspan="2">Name</th>')
215
+ parts.append('<th class="lb-corner lb-corner-num" rowspan="2">Overall</th>')
216
+ for span, label in [(3, "Spatial"), (1, "Spatio-Temp"),
217
+ (2, "Temporal"), (2, "Semantic")]:
218
+ parts.append(f'<th class="lb-group" colspan="{span}">{label}</th>')
219
+ parts.append('</tr>')
220
+
221
+ parts.append('<tr class="lb-task-row">')
222
+ for label, _ in score_cols:
223
+ parts.append(f'<th>{label}</th>')
224
+ parts.append('</tr>')
225
+ parts.append('</thead>')
226
+
227
+ # Body
228
+ # Total column count: # + Name + Overall + 8 task scores = 11.
229
+ total_cols = 3 + len(score_cols)
230
+ parts.append('<tbody>')
231
+ if not sorted_models:
232
+ parts.append(
233
+ f'<tr><td colspan="{total_cols}" class="lb-empty-row">'
234
+ f'No models match — adjust the filters.</td></tr>'
235
+ )
236
+ for i, m in enumerate(sorted_models, 1):
237
+ parts.append(f'<tr data-id="{m.id}">')
238
+ parts.append(f'<td class="lb-rank">{i}</td>')
239
+ parts.append(f'<td class="lb-name">{_model_html(m, rank_one_id)}</td>')
240
+
241
+ # Overall cell — bold if it's the column max.
242
+ ov = m.scores.get("overall")
243
+ if ov is None:
244
+ parts.append(f'<td class="lb-score lb-overall">{_MISSING}</td>')
245
+ else:
246
+ is_max = overall_max is not None and ov == overall_max
247
+ cls = "lb-score lb-overall lb-max" if is_max else "lb-score lb-overall"
248
+ parts.append(f'<td class="{cls}">{ov:.2f}</td>')
249
+
250
+ for _, f in score_cols:
251
+ v = m.scores.get(f)
252
+ if v is None:
253
+ parts.append(f'<td class="lb-score">{_MISSING}</td>')
254
+ else:
255
+ m_val = col_max.get(f)
256
+ is_max = m_val is not None and v == m_val
257
+ cls = "lb-score lb-max" if is_max else "lb-score"
258
+ parts.append(f'<td class="{cls}">{v:.2f}</td>')
259
+ parts.append('</tr>')
260
+ parts.append('</tbody>')
261
+ parts.append('</table>')
262
+ return "".join(parts)
263
+
264
+
265
+ def build_pillar_html_table(
266
+ pillar: str,
267
+ filtered_models: list[ModelRecord],
268
+ rank_map: dict[str, int],
269
+ ) -> str:
270
+ """Hand-rendered HTML <table> for a pillar tab (Spatial / Sp-Temp /
271
+ Temporal / Semantic) — mirrors build_overall_html_table's markup and
272
+ CSS classes so every tab shares one visual style.
273
+
274
+ Single-row header (no pillar super-header grouping needed on these
275
+ tabs). The pillar's own aggregate score is the first score column and
276
+ gets the same purple-outlined "headline" treatment as the Overall
277
+ column on the Overall tab (`lb-agg` mirrors `lb-overall`).
278
+ """
279
+ agg_field = _PILLAR_AGGREGATE_COL[pillar][1] if pillar in _PILLAR_AGGREGATE_COL else None
280
+ # Partial submissions appear only on the pillars they were scored on.
281
+ if agg_field is not None:
282
+ filtered_models = [m for m in filtered_models if agg_field in m.scores]
283
+ sorted_models = _sort_by_rank(filtered_models, rank_map)
284
+ rank_one_id = next(
285
+ (m.id for m in sorted_models if rank_map.get(m.id) == 1), None
286
+ )
287
+
288
+ score_cols = _score_cols_for_pillar(pillar)
289
+ json_fields = [f for _, f in score_cols]
290
+ col_max = _column_maxes(filtered_models, json_fields)
291
+
292
+ parts: list[str] = ['<table class="lb-pillar-table">']
293
+
294
+ parts.append('<colgroup>')
295
+ parts.append('<col class="col-rank">')
296
+ parts.append('<col class="col-name">')
297
+ for _, field in score_cols:
298
+ cls = "col-score col-agg" if field == agg_field else "col-score"
299
+ parts.append(f'<col class="{cls}">')
300
+ parts.append('</colgroup>')
301
+
302
+ parts.append('<thead>')
303
+ parts.append('<tr class="lb-header-row">')
304
+ parts.append('<th class="lb-corner">#</th>')
305
+ parts.append('<th class="lb-corner">Name</th>')
306
+ for label, field in score_cols:
307
+ if field == agg_field:
308
+ parts.append(f'<th class="lb-agg-th">{label}</th>')
309
+ else:
310
+ parts.append(f'<th>{label}</th>')
311
+ parts.append('</tr>')
312
+ parts.append('</thead>')
313
+
314
+ total_cols = 2 + len(score_cols)
315
+ parts.append('<tbody>')
316
+ if not sorted_models:
317
+ parts.append(
318
+ f'<tr><td colspan="{total_cols}" class="lb-empty-row">'
319
+ f'No models match — adjust the filters.</td></tr>'
320
+ )
321
+ for i, m in enumerate(sorted_models, 1):
322
+ parts.append(f'<tr data-id="{m.id}">')
323
+ parts.append(f'<td class="lb-rank">{i}</td>')
324
+ parts.append(f'<td class="lb-name">{_model_html(m, rank_one_id)}</td>')
325
+
326
+ for _, f in score_cols:
327
+ is_agg = f == agg_field
328
+ v = m.scores.get(f)
329
+ base_cls = "lb-score lb-agg" if is_agg else "lb-score"
330
+ if v is None:
331
+ parts.append(f'<td class="{base_cls}">{_MISSING}</td>')
332
+ else:
333
+ m_val = col_max.get(f)
334
+ is_max = m_val is not None and v == m_val
335
+ cls = f"{base_cls} lb-max" if is_max else base_cls
336
+ parts.append(f'<td class="{cls}">{v:.2f}</td>')
337
+ parts.append('</tr>')
338
+ parts.append('</tbody>')
339
+ parts.append('</table>')
340
+ return "".join(parts)
341
+
342
+
343
+ def build_all_html_tables(
344
+ filtered_models: list[ModelRecord],
345
+ global_ranks: dict[str, dict[str, int]],
346
+ ) -> dict[str, str]:
347
+ """Return {pillar_key: html_table_str} for every tab, Overall included.
348
+
349
+ Replaces build_all_tables + build_overall_html_table as the single
350
+ entry point once every tab renders as a hand-built HTML table instead
351
+ of a mix of gr.HTML (Overall) and gr.Dataframe (the four pillars).
352
+ """
353
+ result: dict[str, str] = {
354
+ "overall": build_overall_html_table(filtered_models, global_ranks["overall"]),
355
+ }
356
+ for pillar in PILLARS:
357
+ if pillar == "overall":
358
+ continue
359
+ result[pillar] = build_pillar_html_table(
360
+ pillar, filtered_models, global_ranks[pillar]
361
+ )
362
+ return result
363
+
364
+
365
+ # -- Radar SVG ------------------------------------------------------------
366
+
367
+
368
+ def make_radar_svg(m: ModelRecord) -> str:
369
+ """4-axis radar chart SVG for the model detail side panel.
370
+
371
+ Axes (clockwise from top): Semantic · Spatial · Sp-Temp · Temporal.
372
+ Canvas: 220 × 160 px. Scores assumed in [0, 100].
373
+ """
374
+ W, H = 220, 160
375
+ cx, cy = W / 2, 82.0 # slightly below centre for top-label room
376
+ chart_r = 50.0 # polygon radius
377
+ label_r = 67.0 # label ring radius
378
+
379
+ labels = ["Semantic", "Spatial", "Sp-Temp", "Temporal"]
380
+ fields = ["semantic", "spatial", "spatio_temporal", "temporal"]
381
+ vals: list[float] = []
382
+ for f in fields:
383
+ raw = m.scores.get(f)
384
+ v = float(raw) / 100.0 if raw is not None else 0.0
385
+ vals.append(max(0.0, min(1.0, v)))
386
+
387
+ N = 4
388
+ step = 2 * math.pi / N
389
+ off = -math.pi / 2 # index 0 points straight up
390
+
391
+ parts: list[str] = [
392
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
393
+ f'width="{W}" height="{H}" viewBox="0 0 {W} {H}">'
394
+ ]
395
+
396
+ # Grid rings (4 concentric)
397
+ for g in range(1, 5):
398
+ rg = chart_r * g / 4
399
+ parts.append(
400
+ f'<circle cx="{cx}" cy="{cy}" r="{rg:.1f}" '
401
+ f'fill="none" stroke="#e5e7eb" stroke-width="0.5"/>'
402
+ )
403
+
404
+ # Axis lines
405
+ for i in range(N):
406
+ a = off + i * step
407
+ x2 = cx + chart_r * math.cos(a)
408
+ y2 = cy + chart_r * math.sin(a)
409
+ parts.append(
410
+ f'<line x1="{cx}" y1="{cy}" '
411
+ f'x2="{x2:.1f}" y2="{y2:.1f}" '
412
+ f'stroke="#e5e7eb" stroke-width="0.5"/>'
413
+ )
414
+
415
+ # Data polygon
416
+ poly_pts = " ".join(
417
+ f"{cx + chart_r * v * math.cos(off + i * step):.1f},"
418
+ f"{cy + chart_r * v * math.sin(off + i * step):.1f}"
419
+ for i, v in enumerate(vals)
420
+ )
421
+ parts.append(
422
+ f'<polygon points="{poly_pts}" '
423
+ f'fill="rgba(37,99,235,0.12)" stroke="#2563eb" '
424
+ f'stroke-width="1.5" stroke-linejoin="round"/>'
425
+ )
426
+
427
+ # Labels
428
+ for i, lbl in enumerate(labels):
429
+ a = off + i * step
430
+ lx = cx + label_r * math.cos(a)
431
+ ly = cy + label_r * math.sin(a)
432
+ parts.append(
433
+ f'<text x="{lx:.1f}" y="{ly:.1f}" '
434
+ f'text-anchor="middle" dominant-baseline="middle" '
435
+ f'font-size="9" fill="#6b7280" font-family="sans-serif">'
436
+ f'{lbl}</text>'
437
+ )
438
+
439
+ parts.append("</svg>")
440
+ return "".join(parts)