TacSIm / app.py
pengsc's picture
Upload app.py
60a116c verified
Raw
History Blame Contribute Delete
39.6 kB
import functools
import hashlib
import io
import json
import os
import shutil
import tempfile
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import gradio as gr
import pandas as pd
from huggingface_hub import (
HfApi,
hf_hub_download,
snapshot_download,
)
try:
import spaces
except ImportError:
class _SpacesFallback:
@staticmethod
def GPU(*decorator_args, **decorator_kwargs):
if (
len(decorator_args) == 1
and callable(decorator_args[0])
and not decorator_kwargs
):
return decorator_args[0]
def decorator(function):
return function
return decorator
spaces = _SpacesFallback()
from football_trajectory_similarity import (
FootballTrajectorySimilarity,
)
APP_TITLE = "TacSIm Online Benchmark"
BENCHMARK_VERSION = "v2.0-batch"
OFFICIAL_FRAME_INTERVAL = 0.1
OFFICIAL_HORIZONS = (3.0, 5.0, 10.0)
OFFICIAL_GRIDS = (
(10, 6),
(15, 10),
(20, 12),
(30, 20),
(105, 68),
)
OFFICIAL_GRID_LABELS = [
f"{length}x{width}"
for length, width in OFFICIAL_GRIDS
]
LOCAL_TEST_DIR = Path(
os.getenv("LOCAL_TEST_DIR", "Test")
)
TEST_DATASET_REPO_ID = os.getenv(
"TEST_DATASET_REPO_ID",
"",
).strip()
TEST_DATASET_SUBDIR = os.getenv(
"TEST_DATASET_SUBDIR",
"",
).strip().strip("/")
TEST_DATASET_REVISION = os.getenv(
"TEST_DATASET_REVISION",
"main",
).strip()
LEADERBOARD_REPO_ID = os.getenv(
"LEADERBOARD_REPO_ID",
"",
).strip()
SHARED_HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
TEST_DATASET_TOKEN = os.getenv(
"TEST_DATASET_TOKEN",
SHARED_HF_TOKEN,
).strip()
LEADERBOARD_HF_TOKEN = os.getenv(
"LEADERBOARD_HF_TOKEN",
SHARED_HF_TOKEN,
).strip()
MAX_ZIP_FILES = int(
os.getenv("MAX_ZIP_FILES", "50000")
)
MAX_ZIP_UNCOMPRESSED_BYTES = int(
os.getenv(
"MAX_ZIP_UNCOMPRESSED_BYTES",
str(5 * 1024 * 1024 * 1024),
)
)
@spaces.GPU(duration=5)
def _zerogpu_registration_probe():
"""Register one short ZeroGPU callback.
The actual TacSIm evaluation is CPU-only and must not be wrapped
by spaces.GPU, otherwise large Test splits are rejected by the
ZeroGPU per-call duration limit.
"""
return "ZeroGPU callback registered."
LEADERBOARD_GRID_ORDER = [
"10x6",
"15x10",
"20x12",
"30x20",
"105x68",
]
LEADERBOARD_HORIZON_ORDER = [
"3s",
"5s",
"10s",
]
SCORE_COLUMN_SPECS = [
(
f"{grid.replace('x', '×')} · {horizon}",
horizon,
grid,
)
for grid in LEADERBOARD_GRID_ORDER
for horizon in LEADERBOARD_HORIZON_ORDER
]
SCORE_COLUMNS = [
display_name
for display_name, _, _ in SCORE_COLUMN_SPECS
]
DEFAULT_RANKING_METRIC = SCORE_COLUMNS[0]
LEADERBOARD_COLUMNS = [
"Rank",
"Method",
*SCORE_COLUMNS,
"Test Files",
"Team / Institution",
"Submitter",
"Paper / Code",
"Submitted",
]
def _safe_text(value, field_name, max_length):
text = str(value or "").strip()
if len(text) > max_length:
raise gr.Error(
f"{field_name} must contain at most "
f"{max_length} characters."
)
return text
def _safe_url(value):
url = _safe_text(value, "Paper / Code URL", 500)
if not url:
return ""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise gr.Error(
"Paper / Code URL must start with "
"http:// or https://."
)
return url
def _sha256_file(file_path):
digest = hashlib.sha256()
with open(file_path, "rb") as file_object:
for block in iter(
lambda: file_object.read(1024 * 1024),
b"",
):
digest.update(block)
return digest.hexdigest()
def _combined_file_hash(file_map):
digest = hashlib.sha256()
for file_name in sorted(file_map):
digest.update(file_name.encode("utf-8"))
digest.update(b"\0")
digest.update(
_sha256_file(file_map[file_name]).encode(
"ascii"
)
)
digest.update(b"\n")
return digest.hexdigest()
def _collect_csv_files(root_directory):
root_directory = Path(root_directory)
if not root_directory.exists():
raise gr.Error(
f"Test directory was not found: "
f"{root_directory}"
)
csv_files = sorted(
path
for path in root_directory.rglob("*.csv")
if path.is_file()
)
if not csv_files:
raise gr.Error(
f"No CSV files were found under "
f"{root_directory}."
)
file_map = {}
for file_path in csv_files:
file_name = file_path.name
if file_name in file_map:
raise gr.Error(
"Duplicate CSV basenames were found in the "
f"test set: {file_name}. Filenames must be "
"unique across all subdirectories."
)
file_map[file_name] = file_path
return file_map
@functools.lru_cache(maxsize=1)
def _resolve_test_file_map():
if TEST_DATASET_REPO_ID:
# Download all CSV files from the private Dataset repository.
# This is robust to repositories where "Test" is a split name
# rather than a physical folder.
snapshot_root = Path(
snapshot_download(
repo_id=TEST_DATASET_REPO_ID,
repo_type="dataset",
revision=TEST_DATASET_REVISION,
token=TEST_DATASET_TOKEN or None,
allow_patterns=[
"*.csv",
"**/*.csv",
],
)
)
configured_root = (
snapshot_root / TEST_DATASET_SUBDIR
if TEST_DATASET_SUBDIR
else snapshot_root
)
# Prefer the configured subdirectory when it physically exists.
# Otherwise, fall back to recursively scanning the repository
# snapshot. This handles Dataset Viewer split names and files
# stored directly at the repository root.
if (
TEST_DATASET_SUBDIR
and configured_root.exists()
and any(configured_root.rglob("*.csv"))
):
test_root = configured_root
else:
test_root = snapshot_root
else:
test_root = LOCAL_TEST_DIR
return _collect_csv_files(test_root)
@functools.lru_cache(maxsize=1)
def _test_set_metadata():
file_map = _resolve_test_file_map()
return {
"number_of_files": len(file_map),
"sha256": _combined_file_hash(file_map),
"file_names": sorted(file_map),
"source": (
(
f"dataset:{TEST_DATASET_REPO_ID}/"
f"{TEST_DATASET_SUBDIR or '<auto>'}"
)
if TEST_DATASET_REPO_ID
else f"local:{LOCAL_TEST_DIR}"
),
}
def _is_zip_symlink(zip_info):
unix_mode = zip_info.external_attr >> 16
return (unix_mode & 0o170000) == 0o120000
def _safe_extract_submission(zip_path, output_directory):
output_directory = Path(output_directory)
output_directory.mkdir(
parents=True,
exist_ok=True,
)
try:
archive = zipfile.ZipFile(zip_path, "r")
except zipfile.BadZipFile as error:
raise gr.Error(
"The uploaded file is not a valid ZIP archive."
) from error
with archive:
members = [
member
for member in archive.infolist()
if not member.is_dir()
]
if len(members) > MAX_ZIP_FILES:
raise gr.Error(
f"The ZIP contains too many files "
f"({len(members)}). Maximum: "
f"{MAX_ZIP_FILES}."
)
total_size = sum(
member.file_size
for member in members
)
if total_size > MAX_ZIP_UNCOMPRESSED_BYTES:
raise gr.Error(
"The uncompressed ZIP content is too large."
)
csv_count = 0
output_root = output_directory.resolve()
for member in members:
member_path = Path(member.filename)
if (
member_path.is_absolute()
or ".." in member_path.parts
or _is_zip_symlink(member)
):
raise gr.Error(
"The ZIP archive contains an unsafe path."
)
if "__MACOSX" in member_path.parts:
continue
if member_path.suffix.lower() != ".csv":
continue
destination = (
output_directory / member_path
).resolve()
try:
destination.relative_to(output_root)
except ValueError as error:
raise gr.Error(
"The ZIP archive contains an unsafe path."
) from error
destination.parent.mkdir(
parents=True,
exist_ok=True,
)
with archive.open(member, "r") as source:
with open(destination, "wb") as target:
shutil.copyfileobj(source, target)
csv_count += 1
if csv_count == 0:
raise gr.Error(
"The ZIP archive does not contain any CSV files."
)
def _validate_submission_file_set(
reference_file_map,
submission_file_map,
):
reference_names = set(reference_file_map)
submission_names = set(submission_file_map)
missing = sorted(
reference_names - submission_names
)
extra = sorted(
submission_names - reference_names
)
if missing or extra:
messages = []
if missing:
preview = ", ".join(missing[:20])
messages.append(
f"Missing {len(missing)} required file(s): "
f"{preview}"
+ (" ..." if len(missing) > 20 else "")
)
if extra:
preview = ", ".join(extra[:20])
messages.append(
f"Found {len(extra)} unexpected file(s): "
f"{preview}"
+ (" ..." if len(extra) > 20 else "")
)
raise gr.Error(
"\n".join(messages)
)
def _evaluate_file_pair(
file_name,
reference_file,
generated_file,
):
evaluator = FootballTrajectorySimilarity(
reference_file=reference_file,
generated_file=generated_file,
frame_interval=OFFICIAL_FRAME_INTERVAL,
horizons=OFFICIAL_HORIZONS,
grid_resolutions=OFFICIAL_GRIDS,
)
rows = evaluator.evaluate()
for row in rows:
row["File"] = file_name
row["TacSIm Score"] = (
row["Spatial Occupancy Similarity"]
+ row["Movement Vector Similarity"]
) / 2.0
return rows
def _aggregate_batch_results(detail_table):
ball_metrics = [
"TacSIm Score",
"Spatial Occupancy Similarity",
"Movement Vector Similarity",
]
aggregate_ball = (
detail_table.groupby(
["Horizon", "Grid"],
as_index=False,
)[ball_metrics]
.mean()
)
team_detail = (
detail_table[
[
"File",
"Horizon",
"Team Formation Compactness Similarity",
"Team Speed Similarity",
]
]
.drop_duplicates(
subset=["File", "Horizon"]
)
)
aggregate_team = (
team_detail.groupby(
"Horizon",
as_index=False,
)[
[
"Team Formation Compactness Similarity",
"Team Speed Similarity",
]
]
.mean()
)
horizon_scores = (
detail_table.groupby(
"Horizon"
)["TacSIm Score"]
.mean()
.to_dict()
)
summary = {
"overall_score": (
float(
detail_table["TacSIm Score"].mean()
)
* 100.0
),
"score_3s": (
float(
horizon_scores.get(
"3s",
float("nan"),
)
)
* 100.0
),
"score_5s": (
float(
horizon_scores.get(
"5s",
float("nan"),
)
)
* 100.0
),
"score_10s": (
float(
horizon_scores.get(
"10s",
float("nan"),
)
)
* 100.0
),
"spatial_score": (
float(
detail_table[
"Spatial Occupancy Similarity"
].mean()
)
* 100.0
),
"movement_score": (
float(
detail_table[
"Movement Vector Similarity"
].mean()
)
* 100.0
),
"compactness_score": (
float(
team_detail[
"Team Formation Compactness Similarity"
].mean()
)
* 100.0
),
"speed_score": (
float(
team_detail[
"Team Speed Similarity"
].mean()
)
* 100.0
),
}
summary = {
key: round(value, 2)
for key, value in summary.items()
}
display_ball = aggregate_ball.copy()
display_team = aggregate_team.copy()
display_ball[ball_metrics] = (
display_ball[ball_metrics] * 100.0
)
team_metrics = [
"Team Formation Compactness Similarity",
"Team Speed Similarity",
]
display_team[team_metrics] = (
display_team[team_metrics] * 100.0
)
display_ball = display_ball.round(2)
display_team = display_team.round(2)
summary_table = pd.DataFrame(
[
{
"3s Mean": summary["score_3s"],
"5s Mean": summary["score_5s"],
"10s Mean": summary["score_10s"],
"Spatial Mean": summary[
"spatial_score"
],
"Movement Mean": summary[
"movement_score"
],
"Compactness Mean": summary[
"compactness_score"
],
"Speed Mean": summary["speed_score"],
}
]
)
return (
summary,
summary_table,
aggregate_ball,
aggregate_team,
display_ball,
display_team,
)
def _write_batch_reports(
detail_table,
aggregate_ball,
aggregate_team,
summary,
metadata,
):
report_directory = Path(
tempfile.mkdtemp(
prefix="tacsim_batch_report_"
)
)
detail_path = (
report_directory
/ "per_file_detailed_results.csv"
)
aggregate_ball_path = (
report_directory
/ "aggregate_ball_results.csv"
)
aggregate_team_path = (
report_directory
/ "aggregate_team_results.csv"
)
summary_path = (
report_directory
/ "summary.json"
)
report_zip_path = (
report_directory
/ "tacsim_evaluation_report.zip"
)
detail_table.to_csv(
detail_path,
index=False,
encoding="utf-8-sig",
)
aggregate_ball.to_csv(
aggregate_ball_path,
index=False,
encoding="utf-8-sig",
)
aggregate_team.to_csv(
aggregate_team_path,
index=False,
encoding="utf-8-sig",
)
summary_payload = {
"metadata": metadata,
"summary": summary,
}
summary_path.write_text(
json.dumps(
summary_payload,
indent=2,
ensure_ascii=False,
),
encoding="utf-8",
)
with zipfile.ZipFile(
report_zip_path,
"w",
zipfile.ZIP_DEFLATED,
) as archive:
archive.write(
detail_path,
detail_path.name,
)
archive.write(
aggregate_ball_path,
aggregate_ball_path.name,
)
archive.write(
aggregate_team_path,
aggregate_team_path.name,
)
archive.write(
summary_path,
summary_path.name,
)
return (
str(aggregate_ball_path),
str(detail_path),
str(summary_path),
str(report_zip_path),
)
def _run_batch_evaluation(
submission_zip,
progress,
):
if not submission_zip:
raise gr.Error(
"A submission ZIP file is required."
)
reference_file_map = _resolve_test_file_map()
test_metadata = _test_set_metadata()
with tempfile.TemporaryDirectory(
prefix="tacsim_submission_"
) as temporary_directory:
extraction_root = (
Path(temporary_directory) / "submission"
)
progress(
0.01,
desc="Extracting submission ZIP",
)
_safe_extract_submission(
submission_zip,
extraction_root,
)
submission_file_map = (
_collect_csv_files(extraction_root)
)
_validate_submission_file_set(
reference_file_map,
submission_file_map,
)
submission_hash = _combined_file_hash(
submission_file_map
)
rows = []
total_files = len(reference_file_map)
for file_index, file_name in enumerate(
sorted(reference_file_map),
start=1,
):
progress(
file_index / total_files,
desc=(
f"Evaluating {file_index}/"
f"{total_files}: {file_name}"
),
)
try:
rows.extend(
_evaluate_file_pair(
file_name=file_name,
reference_file=(
reference_file_map[
file_name
]
),
generated_file=(
submission_file_map[
file_name
]
),
)
)
except Exception as error:
raise gr.Error(
f"Evaluation failed for "
f"{file_name}: {error}"
) from error
detail_table = pd.DataFrame(rows)
(
summary,
summary_table,
aggregate_ball,
aggregate_team,
display_ball,
display_team,
) = _aggregate_batch_results(detail_table)
metadata = {
"benchmark_version": BENCHMARK_VERSION,
"frame_interval": OFFICIAL_FRAME_INTERVAL,
"horizons": list(OFFICIAL_HORIZONS),
"grids": OFFICIAL_GRID_LABELS,
"number_of_test_files": (
test_metadata["number_of_files"]
),
"test_set_sha256": (
test_metadata["sha256"]
),
"test_source": test_metadata["source"],
"submission_sha256": submission_hash,
"movement_vector_definition":
"direct cosine similarity",
"goalkeeper_excluded_from_team_metrics": True,
"aggregation": (
"Arithmetic mean across all matched "
"test trajectories."
),
}
(
aggregate_csv,
detail_csv,
summary_json,
report_zip,
) = _write_batch_reports(
detail_table=detail_table,
aggregate_ball=aggregate_ball,
aggregate_team=aggregate_team,
summary=summary,
metadata=metadata,
)
evaluation_state = {
"metadata": metadata,
"summary": summary,
"aggregate_ball_results": (
aggregate_ball.to_dict(
orient="records"
)
),
"aggregate_team_results": (
aggregate_team.to_dict(
orient="records"
)
),
}
status = (
f"Evaluation completed for "
f"{test_metadata['number_of_files']} "
f"matched test trajectories. Scores are "
f"displayed on a 0–100 scale."
)
return (
status,
summary_table,
display_ball,
display_team,
aggregate_csv,
detail_csv,
summary_json,
report_zip,
evaluation_state,
)
def run_official_benchmark(
submission_zip,
progress=gr.Progress(),
):
try:
return _run_batch_evaluation(
submission_zip,
progress,
)
except gr.Error:
raise
except Exception as error:
raise gr.Error(str(error)) from error
def _leaderboard_api():
if not LEADERBOARD_REPO_ID:
raise gr.Error(
"LEADERBOARD_REPO_ID is not configured "
"in Space Settings → Variables."
)
if not LEADERBOARD_HF_TOKEN:
raise gr.Error(
"LEADERBOARD_HF_TOKEN or HF_TOKEN is "
"not configured in Space Settings → Secrets."
)
return HfApi(
token=LEADERBOARD_HF_TOKEN
)
def _submission_record_files():
if not LEADERBOARD_REPO_ID:
return []
try:
api = HfApi(
token=LEADERBOARD_HF_TOKEN or None
)
files = api.list_repo_files(
repo_id=LEADERBOARD_REPO_ID,
repo_type="dataset",
)
except Exception:
return []
return [
file_name
for file_name in files
if (
file_name.startswith("submissions/")
and file_name.endswith(".json")
)
]
def _load_submission_records():
records = []
try:
current_test_hash = (
_test_set_metadata()["sha256"]
)
except Exception:
current_test_hash = None
for file_name in _submission_record_files():
try:
local_path = hf_hub_download(
repo_id=LEADERBOARD_REPO_ID,
filename=file_name,
repo_type="dataset",
token=(
LEADERBOARD_HF_TOKEN
or None
),
)
payload = json.loads(
Path(local_path).read_text(
encoding="utf-8"
)
)
if (
payload.get("benchmark_version")
!= BENCHMARK_VERSION
):
continue
if (
current_test_hash
and payload.get("test_set_sha256")
!= current_test_hash
):
continue
records.append(payload)
except Exception:
continue
return records
def _normalize_grid_label(value):
return (
str(value or "")
.strip()
.lower()
.replace("×", "x")
.replace(" ", "")
)
def _score_value_to_percent(value):
try:
numeric_value = float(value)
except (TypeError, ValueError):
return float("nan")
if not pd.notna(numeric_value):
return float("nan")
# Stored batch aggregate values are normally in [0, 1].
# This also supports future records already stored on a 0–100 scale.
if -1.000001 <= numeric_value <= 1.000001:
numeric_value *= 100.0
return round(numeric_value, 2)
def _extract_scale_scores(record):
score_lookup = {}
for row in record.get(
"aggregate_ball_results",
[],
):
horizon = str(
row.get("Horizon", "")
).strip()
grid = _normalize_grid_label(
row.get("Grid", "")
)
key = (horizon, grid)
score_lookup[key] = _score_value_to_percent(
row.get("TacSIm Score")
)
extracted = {}
for display_name, horizon, grid in SCORE_COLUMN_SPECS:
extracted[display_name] = score_lookup.get(
(horizon, grid),
float("nan"),
)
return extracted
def _leaderboard_dataframe(
records,
ranking_metric=DEFAULT_RANKING_METRIC,
):
if ranking_metric not in SCORE_COLUMNS:
ranking_metric = DEFAULT_RANKING_METRIC
if not records:
return pd.DataFrame(
columns=LEADERBOARD_COLUMNS
)
rows = []
for record in records:
row = {
"Method": record.get(
"method",
"",
),
"Test Files": record.get(
"number_of_test_files",
"",
),
"Team / Institution": record.get(
"team",
"",
),
"Submitter": record.get(
"submitter",
"",
),
"Paper / Code": record.get(
"paper_url",
"",
),
"Submitted": record.get(
"submitted_at",
"",
),
}
row.update(
_extract_scale_scores(record)
)
rows.append(row)
dataframe = pd.DataFrame(rows)
for score_column in SCORE_COLUMNS:
if score_column not in dataframe.columns:
dataframe[score_column] = float("nan")
dataframe[score_column] = pd.to_numeric(
dataframe[score_column],
errors="coerce",
).round(2)
valid_mask = dataframe[
ranking_metric
].notna()
dataframe["_SelectedMetricRank"] = pd.NA
if valid_mask.any():
dataframe.loc[
valid_mask,
"_SelectedMetricRank",
] = (
dataframe.loc[
valid_mask,
ranking_metric,
]
.rank(
method="min",
ascending=False,
)
.astype("Int64")
)
dataframe = dataframe.sort_values(
by=[
ranking_metric,
"Method",
"Submitted",
],
ascending=[
False,
True,
True,
],
na_position="last",
).reset_index(drop=True)
dataframe.insert(
0,
"Rank",
dataframe.pop(
"_SelectedMetricRank"
),
)
return dataframe[LEADERBOARD_COLUMNS]
def refresh_leaderboard(
ranking_metric=DEFAULT_RANKING_METRIC,
):
if ranking_metric not in SCORE_COLUMNS:
ranking_metric = DEFAULT_RANKING_METRIC
records = _load_submission_records()
dataframe = _leaderboard_dataframe(
records,
ranking_metric,
)
if not LEADERBOARD_REPO_ID:
status = (
"Leaderboard storage is not configured. "
"Set LEADERBOARD_REPO_ID and a write token."
)
else:
status = (
f"Loaded {len(dataframe)} valid "
f"submission(s) for {BENCHMARK_VERSION}. "
f"Ranked independently by: {ranking_metric}. "
"Higher is better; tied scores share the same rank."
)
return dataframe, status
def submit_to_leaderboard(
method_name,
team_name,
submitter_name,
paper_url,
confirmation,
evaluation_state,
ranking_metric,
):
if not evaluation_state:
raise gr.Error(
"Run the official benchmark before "
"submitting."
)
if not confirmation:
raise gr.Error(
"Confirm that this is a genuine model "
"submission before continuing."
)
method = _safe_text(
method_name,
"Method name",
120,
)
team = _safe_text(
team_name,
"Team / Institution",
160,
)
submitter = _safe_text(
submitter_name,
"Submitter",
120,
)
url = _safe_url(paper_url)
if not method:
raise gr.Error(
"Method name is required."
)
if not submitter:
raise gr.Error(
"Submitter name is required."
)
api = _leaderboard_api()
metadata = evaluation_state["metadata"]
submission_hash = metadata[
"submission_sha256"
]
for existing in _load_submission_records():
if (
existing.get("submission_sha256")
== submission_hash
and existing.get(
"method",
"",
).casefold() == method.casefold()
):
raise gr.Error(
"This method and submission have "
"already been added."
)
submission_id = str(uuid.uuid4())
submitted_at = datetime.now(
timezone.utc
).replace(microsecond=0).isoformat()
payload = {
"submission_id": submission_id,
"benchmark_version": BENCHMARK_VERSION,
"method": method,
"team": team,
"submitter": submitter,
"paper_url": url,
"submitted_at": submitted_at,
"submission_sha256": submission_hash,
"test_set_sha256": metadata[
"test_set_sha256"
],
"number_of_test_files": metadata[
"number_of_test_files"
],
"summary": evaluation_state["summary"],
"evaluation_metadata": metadata,
"aggregate_ball_results":
evaluation_state[
"aggregate_ball_results"
],
"aggregate_team_results":
evaluation_state[
"aggregate_team_results"
],
}
payload_bytes = json.dumps(
payload,
indent=2,
ensure_ascii=False,
).encode("utf-8")
api.upload_file(
path_or_fileobj=io.BytesIO(
payload_bytes
),
path_in_repo=(
f"submissions/{submission_id}.json"
),
repo_id=LEADERBOARD_REPO_ID,
repo_type="dataset",
commit_message=(
f"Add leaderboard submission: "
f"{method}"
),
)
leaderboard, _ = refresh_leaderboard(
ranking_metric
)
return (
f"Submission accepted: {method}. "
f"Submission ID: {submission_id}",
leaderboard,
)
def benchmark_status():
try:
metadata = _test_set_metadata()
status = (
f"Benchmark {BENCHMARK_VERSION} is ready. "
f"Backend test files: "
f"{metadata['number_of_files']}. "
f"Test-set hash: "
f"{metadata['sha256'][:12]}..."
)
except Exception as error:
status = (
f"Benchmark test set is not ready: "
f"{error}"
)
return status
def build_interface():
with gr.Blocks(title=APP_TITLE) as demo:
evaluation_state = gr.State(value=None)
# Hidden event used only to register a valid ZeroGPU callback.
# The official full-test evaluation itself runs on CPU.
zerogpu_probe_button = gr.Button(
"ZeroGPU registration",
visible=False,
)
zerogpu_probe_output = gr.Textbox(
visible=False,
)
zerogpu_probe_button.click(
fn=_zerogpu_registration_probe,
inputs=[],
outputs=[zerogpu_probe_output],
api_visibility="private",
)
gr.Markdown(
f"""
# {APP_TITLE}
The backend contains the complete hidden **Test** split. Upload one ZIP
containing a generated CSV for every test trajectory.
The submission CSV filenames must exactly match the backend test
filenames. Folder nesting inside the ZIP is allowed, but CSV basenames
must be unique.
**Official settings**
- Benchmark version: `{BENCHMARK_VERSION}`
- Frame interval: `0.1s`
- Horizons: `3s`, `5s`, `10s`
- Grids: `10×6`, `15×10`, `20×12`, `30×20`, `105×68`
- TacSIm Score: `(Spatial Occupancy + Movement Vector) / 2`
- Each scale-and-horizon score is reported separately
- Display scale: `0–100`
"""
)
backend_status = gr.Textbox(
label="Backend Test-Set Status",
value=benchmark_status(),
interactive=False,
)
with gr.Tab("Official Batch Benchmark"):
gr.Markdown(
"""
## Submission ZIP format
```text
submission.zip
├── 00001.csv
├── 00002.csv
├── 00003.csv
└── ...
```
A wrapper folder is also accepted:
```text
submission.zip
└── MyMethod/
├── 00001.csv
├── 00002.csv
└── ...
```
"""
)
submission_zip = gr.File(
label="Generated Test Trajectories (.zip)",
file_types=[".zip"],
type="filepath",
)
run_button = gr.Button(
"Run Full Test-Set Evaluation",
variant="primary",
)
evaluation_status = gr.Textbox(
label="Evaluation Status",
interactive=False,
)
summary_table = gr.Dataframe(
label="Aggregate Submission Summary",
interactive=False,
)
ball_table = gr.Dataframe(
label="Average Ball Metrics by Horizon and Grid",
interactive=False,
)
team_table = gr.Dataframe(
label="Average Team Metrics by Horizon",
interactive=False,
)
with gr.Row():
aggregate_csv = gr.File(
label="Aggregate Ball CSV"
)
detail_csv = gr.File(
label="Per-File Detailed CSV"
)
with gr.Row():
summary_json = gr.File(
label="Summary JSON"
)
report_zip = gr.File(
label="Complete Evaluation Report"
)
run_button.click(
fn=run_official_benchmark,
inputs=[submission_zip],
outputs=[
evaluation_status,
summary_table,
ball_table,
team_table,
aggregate_csv,
detail_csv,
summary_json,
report_zip,
evaluation_state,
],
show_progress="full",
)
gr.Markdown(
"""
## Submit evaluated result to the leaderboard
Only the result currently stored in this evaluation session can be
submitted. Synthetic, manually edited, or incomplete submissions must
not be entered as model results.
"""
)
with gr.Row():
method_name = gr.Textbox(
label="Method Name",
placeholder="Example: Foresight-MAIL",
)
team_name = gr.Textbox(
label="Team / Institution",
placeholder="Optional",
)
with gr.Row():
submitter_name = gr.Textbox(
label="Submitter",
placeholder=(
"Name or Hugging Face username"
),
)
paper_url = gr.Textbox(
label="Paper / Code URL",
placeholder="https://...",
)
confirmation = gr.Checkbox(
label=(
"I confirm that this ZIP contains "
"genuine model outputs for the complete "
"test split and has not been manually "
"edited to alter benchmark scores."
),
value=False,
)
submit_button = gr.Button(
"Submit to Leaderboard"
)
submission_status = gr.Textbox(
label="Submission Status",
interactive=False,
)
with gr.Tab("Leaderboard"):
gr.Markdown(
f"""
## TacSIm Leaderboard — {BENCHMARK_VERSION}
The leaderboard reports a separate TacSIm **Score** for every official
grid resolution and prediction horizon. There is no Overall score.
Select any score column below to rank methods independently by that
column. Higher is better. Tied values receive the same rank.
"""
)
ranking_metric = gr.Dropdown(
choices=SCORE_COLUMNS,
value=DEFAULT_RANKING_METRIC,
label="Ranking Metric",
info=(
"Rank and sort the leaderboard by one "
"scale-and-horizon score."
),
)
leaderboard_table = gr.Dataframe(
headers=LEADERBOARD_COLUMNS,
value=pd.DataFrame(
columns=LEADERBOARD_COLUMNS
),
interactive=False,
wrap=True,
)
leaderboard_status = gr.Textbox(
label="Leaderboard Status",
interactive=False,
)
refresh_button = gr.Button(
"Refresh Leaderboard"
)
refresh_button.click(
fn=refresh_leaderboard,
inputs=[ranking_metric],
outputs=[
leaderboard_table,
leaderboard_status,
],
api_name="refresh_leaderboard",
)
ranking_metric.change(
fn=refresh_leaderboard,
inputs=[ranking_metric],
outputs=[
leaderboard_table,
leaderboard_status,
],
)
demo.load(
fn=refresh_leaderboard,
inputs=[ranking_metric],
outputs=[
leaderboard_table,
leaderboard_status,
],
)
submit_button.click(
fn=submit_to_leaderboard,
inputs=[
method_name,
team_name,
submitter_name,
paper_url,
confirmation,
evaluation_state,
ranking_metric,
],
outputs=[
submission_status,
leaderboard_table,
],
api_visibility="private",
)
gr.Markdown(
"""
## Required columns in every generated CSV
`player_1_x`, `player_1_y`, ..., `player_11_x`, `player_11_y`,
`ball_x`, and `ball_y`.
Additional columns, including action columns, are allowed and ignored
by the trajectory evaluator.
"""
)
return demo
if __name__ == "__main__":
app = build_interface()
app.queue(
default_concurrency_limit=1
).launch()