webapp
This commit is contained in:
BIN
webapp/__pycache__/app.cpython-314.pyc
Normal file
BIN
webapp/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
741
webapp/app.py
Normal file
741
webapp/app.py
Normal file
@@ -0,0 +1,741 @@
|
||||
"""FastAPI web demo for ClimbingBoardGPT.
|
||||
|
||||
Inference-only design:
|
||||
- model checkpoints and token metadata are loaded once at startup;
|
||||
- board background images are served as static files;
|
||||
- each request returns route hold coordinates/roles as JSON;
|
||||
- the browser draws the overlay as SVG on top of the already-loaded image.
|
||||
|
||||
Local run:
|
||||
|
||||
uvicorn webapp.app:app --host 127.0.0.1 --port 8055
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import torch
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||
|
||||
from climbingboardgpt.config import load_board_config
|
||||
from climbingboardgpt.generation import validity_summary
|
||||
from climbingboardgpt.inference import (
|
||||
generate_route,
|
||||
load_grade_predictor,
|
||||
load_route_generator,
|
||||
predict_frames_grade,
|
||||
predict_route_grade,
|
||||
)
|
||||
from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records
|
||||
|
||||
|
||||
DEVICE = os.getenv("CBGPT_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||
TORCH_THREADS = os.getenv("CBGPT_TORCH_THREADS")
|
||||
TORCH_THREADS_INT = int(TORCH_THREADS) if TORCH_THREADS else None
|
||||
|
||||
MODEL_DIR = Path(os.getenv("CBGPT_MODEL_DIR", REPO_ROOT / "models"))
|
||||
TOKENIZED_DIR = Path(
|
||||
os.getenv("CBGPT_TOKENIZED_DIR", REPO_ROOT / "data" / "processed" / "tokenized")
|
||||
)
|
||||
KNOWN_ROUTES_PATH = Path(
|
||||
os.getenv("CBGPT_KNOWN_ROUTES_PATH", TOKENIZED_DIR / "route_sequences.csv")
|
||||
)
|
||||
|
||||
GENERATOR_PATH = MODEL_DIR / "joint_route_gpt_generator.pth"
|
||||
GRADE_MODEL_PATH = MODEL_DIR / "joint_transformer_grade_predictor.pth"
|
||||
|
||||
BOARD_IMAGE_PATHS = {
|
||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_compose.png",
|
||||
}
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
board: str = Field(..., pattern="^(tb2|kilter)$")
|
||||
angle: int = Field(40, ge=0, le=80)
|
||||
grade: int = Field(6, ge=0, le=16)
|
||||
temperature: float = Field(0.9, ge=0.1, le=2.0)
|
||||
top_k: int = Field(50, ge=1, le=500)
|
||||
max_new_tokens: int = Field(40, ge=4, le=80)
|
||||
valid_only: bool = Field(True, description="Retry generation until a basic-valid climb is sampled.")
|
||||
max_attempts: int = Field(8, ge=1, le=25, description="Maximum attempts when valid_only is true.")
|
||||
|
||||
|
||||
class PredictRequest(BaseModel):
|
||||
board: str = Field(..., pattern="^(tb2|kilter)$")
|
||||
angle: int = Field(..., ge=0, le=80)
|
||||
frames: str = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
def _json_safe(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _json_safe(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(v) for v in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_json_safe(v) for v in value]
|
||||
if hasattr(value, "item"):
|
||||
try:
|
||||
return value.item()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if pd.isna(value):
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _board_config(board: str):
|
||||
try:
|
||||
return app.state.board_configs[board]
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown board: {board}") from exc
|
||||
|
||||
|
||||
def _require_generator():
|
||||
if app.state.generator is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Route generator checkpoint not loaded. Expected {GENERATOR_PATH}.",
|
||||
)
|
||||
return app.state.generator
|
||||
|
||||
|
||||
def _require_grade_predictor():
|
||||
if app.state.grade_predictor is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Grade predictor checkpoint not loaded. Expected {GRADE_MODEL_PATH}.",
|
||||
)
|
||||
return app.state.grade_predictor
|
||||
|
||||
|
||||
def _tokens_to_holds(board: str, tokens: list[str]) -> list[dict[str, Any]]:
|
||||
route_records = tokens_to_route_records(tokens)
|
||||
if route_records.empty:
|
||||
return []
|
||||
|
||||
token_meta = app.state.token_meta
|
||||
coords = token_meta[
|
||||
(token_meta["kind"] == "hold")
|
||||
& (token_meta["board_key"].astype(str) == str(board))
|
||||
][["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
|
||||
["board_key", "placement_id"]
|
||||
)
|
||||
|
||||
merged = route_records.merge(
|
||||
coords,
|
||||
on=["board_token_prefix", "placement_id"],
|
||||
how="left",
|
||||
).dropna(subset=["x", "y"])
|
||||
|
||||
holds = []
|
||||
for _, row in merged.iterrows():
|
||||
holds.append(
|
||||
{
|
||||
"token": str(row["token"]),
|
||||
"placement_id": int(row["placement_id"]),
|
||||
"role": str(row["role"]),
|
||||
"x": float(row["x"]),
|
||||
"y": float(row["y"]),
|
||||
}
|
||||
)
|
||||
return holds
|
||||
|
||||
|
||||
|
||||
def _file_version(path: Path) -> str:
|
||||
"""Small cache-busting version string based on mtime and file size."""
|
||||
try:
|
||||
stat = path.stat()
|
||||
return f"{int(stat.st_mtime)}-{stat.st_size}"
|
||||
except FileNotFoundError:
|
||||
return "missing"
|
||||
|
||||
|
||||
def _static_image_url(board: str) -> str:
|
||||
image_path = BOARD_IMAGE_PATHS[board]
|
||||
return f"/board-images/{image_path.name}?board={board}&v={_file_version(image_path)}"
|
||||
|
||||
|
||||
def _file_info(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": False,
|
||||
"size_bytes": None,
|
||||
"mtime": None,
|
||||
"sha256_16": None,
|
||||
}
|
||||
data = path.read_bytes()
|
||||
stat = path.stat()
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": True,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"sha256_16": hashlib.sha256(data).hexdigest()[:16],
|
||||
}
|
||||
|
||||
|
||||
def _board_available_holds(board: str) -> list[dict[str, Any]]:
|
||||
"""Return clickable hold coordinates for a board.
|
||||
|
||||
Some token-metadata rows can contain missing coordinates for hold-role tokens
|
||||
that exist in the vocabulary but cannot be plotted directly. Those rows must
|
||||
be removed before returning JSON, because FastAPI's JSONResponse rejects NaN.
|
||||
"""
|
||||
token_meta = app.state.token_meta
|
||||
holds = token_meta[
|
||||
(token_meta["kind"] == "hold")
|
||||
& (token_meta["board_key"].astype(str) == str(board))
|
||||
][["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
|
||||
["board_key", "placement_id"]
|
||||
)
|
||||
|
||||
holds = holds.copy()
|
||||
holds["x"] = pd.to_numeric(holds["x"], errors="coerce")
|
||||
holds["y"] = pd.to_numeric(holds["y"], errors="coerce")
|
||||
holds = holds.dropna(subset=["x", "y"])
|
||||
|
||||
return [
|
||||
{
|
||||
"placement_id": int(row["placement_id"]),
|
||||
"x": float(row["x"]),
|
||||
"y": float(row["y"]),
|
||||
}
|
||||
for _, row in holds.sort_values(["y", "x", "placement_id"]).iterrows()
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
def _role_limit_validity(tokens: list[str], requested_board_prefix: str) -> dict[str, Any]:
|
||||
"""Extra webapp validity checks for start/finish counts.
|
||||
|
||||
The lower-level validity check requires at least one start and at least one
|
||||
finish, but BoardLib-style climbs should not have arbitrarily many starts or
|
||||
finishes. For this demo we enforce at most two starts and at most two finishes.
|
||||
"""
|
||||
counts = {
|
||||
"start": 0,
|
||||
"middle": 0,
|
||||
"finish": 0,
|
||||
"foot": 0,
|
||||
"unknown": 0,
|
||||
}
|
||||
|
||||
for token in tokens:
|
||||
match = HOLD_TOKEN_RE.match(str(token))
|
||||
if match is None:
|
||||
continue
|
||||
board_prefix, _placement_id, role = match.groups()
|
||||
if str(board_prefix) != str(requested_board_prefix):
|
||||
continue
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
|
||||
n_start = int(counts.get("start", 0))
|
||||
n_finish = int(counts.get("finish", 0))
|
||||
|
||||
return {
|
||||
"role_counts": counts,
|
||||
"n_start_holds": n_start,
|
||||
"n_finish_holds": n_finish,
|
||||
"has_at_most_two_starts": n_start <= 2,
|
||||
"has_at_most_two_finishes": n_finish <= 2,
|
||||
"role_limits_valid": n_start <= 2 and n_finish <= 2,
|
||||
}
|
||||
|
||||
|
||||
def _combined_validity(tokens: list[str], requested_board_prefix: str) -> dict[str, Any]:
|
||||
"""Combined structural validity used by webapp generation and prediction."""
|
||||
base = validity_summary(tokens, requested_board_prefix=requested_board_prefix)
|
||||
role_limits = _role_limit_validity(tokens, requested_board_prefix=requested_board_prefix)
|
||||
|
||||
base_basic = bool(base.get("basic_valid", False))
|
||||
combined_basic = bool(base_basic and role_limits["role_limits_valid"])
|
||||
|
||||
return {
|
||||
**base,
|
||||
**role_limits,
|
||||
"base_basic_valid": base_basic,
|
||||
"basic_valid": combined_basic,
|
||||
"webapp_basic_valid": combined_basic,
|
||||
}
|
||||
|
||||
|
||||
def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]:
|
||||
"""Human-readable reasons a route should not be grade-predicted."""
|
||||
reasons: list[str] = []
|
||||
|
||||
if int(validity.get("n_hold_tokens", 0)) == 0:
|
||||
reasons.append("no hold tokens were found in the frames string")
|
||||
if not bool(validity.get("one_board_only", True)):
|
||||
reasons.append("the route contains holds from more than one board")
|
||||
if not bool(validity.get("matches_requested_board", True)):
|
||||
reasons.append("the route contains holds from the wrong board")
|
||||
if bool(validity.get("has_duplicate_placements", False)):
|
||||
reasons.append("the route contains duplicate placements")
|
||||
if not bool(validity.get("has_start", False)):
|
||||
reasons.append("the route has no start hold")
|
||||
if not bool(validity.get("has_finish", False)):
|
||||
reasons.append("the route has no finish hold")
|
||||
if int(validity.get("n_start_holds", 0)) > 2:
|
||||
reasons.append("the route has more than two start holds")
|
||||
if int(validity.get("n_finish_holds", 0)) > 2:
|
||||
reasons.append("the route has more than two finish holds")
|
||||
if int(validity.get("n_hold_tokens", 0)) < 3:
|
||||
reasons.append("the route has fewer than 3 holds")
|
||||
|
||||
return reasons
|
||||
|
||||
|
||||
|
||||
HOLD_TOKEN_RE = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
|
||||
|
||||
|
||||
def _angle_key(angle: Any) -> int:
|
||||
try:
|
||||
return int(round(float(angle)))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _route_signature_from_holds(board: str, angle: Any, holds: list[dict[str, Any]]) -> str | None:
|
||||
"""Canonical exact-match signature: board + angle + hold-role multiset."""
|
||||
parts = []
|
||||
for hold in holds:
|
||||
try:
|
||||
placement_id = int(hold["placement_id"])
|
||||
role = str(hold["role"])
|
||||
except Exception:
|
||||
continue
|
||||
parts.append(f"{role}:{placement_id}")
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
return f"{board}|{_angle_key(angle)}|" + "|".join(sorted(parts))
|
||||
|
||||
|
||||
def _holds_from_sequence(sequence: str) -> list[dict[str, Any]]:
|
||||
holds: list[dict[str, Any]] = []
|
||||
for token in str(sequence).split():
|
||||
match = HOLD_TOKEN_RE.match(token)
|
||||
if match is None:
|
||||
continue
|
||||
holds.append(
|
||||
{
|
||||
"board_prefix": match.group(1),
|
||||
"placement_id": int(match.group(2)),
|
||||
"role": match.group(3),
|
||||
}
|
||||
)
|
||||
return holds
|
||||
|
||||
|
||||
|
||||
def _load_available_angles(path: Path) -> dict[str, list[int]]:
|
||||
"""Load available wall angles by board from route_sequences.csv.
|
||||
|
||||
Falls back to a conservative common angle set if the processed route table
|
||||
is unavailable. This keeps the webapp usable for demos even if only model
|
||||
checkpoints/token metadata are present.
|
||||
"""
|
||||
fallback = {
|
||||
"tb2": [20, 25, 30, 35, 40, 45, 50],
|
||||
"kilter": [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70],
|
||||
}
|
||||
if not path.exists():
|
||||
return fallback
|
||||
|
||||
try:
|
||||
df = pd.read_csv(path, usecols=["board_key", "angle"])
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
out: dict[str, list[int]] = {}
|
||||
for board, frame in df.dropna(subset=["board_key", "angle"]).groupby("board_key"):
|
||||
values = sorted({_angle_key(angle) for angle in frame["angle"].tolist()})
|
||||
if values:
|
||||
out[str(board)] = values
|
||||
|
||||
for board, values in fallback.items():
|
||||
out.setdefault(board, values)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
|
||||
def _load_available_grades(path: Path) -> dict[str, list[int]]:
|
||||
"""Load available grouped V-grades by board from route_sequences.csv."""
|
||||
fallback = {
|
||||
"tb2": list(range(0, 16)),
|
||||
"kilter": list(range(0, 16)),
|
||||
}
|
||||
if not path.exists():
|
||||
return fallback
|
||||
|
||||
try:
|
||||
df = pd.read_csv(path, usecols=["board_key", "grouped_v"])
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
out: dict[str, list[int]] = {}
|
||||
for board, frame in df.dropna(subset=["board_key", "grouped_v"]).groupby("board_key"):
|
||||
values = sorted({int(v) for v in frame["grouped_v"].tolist()})
|
||||
if values:
|
||||
out[str(board)] = values
|
||||
|
||||
for board, values in fallback.items():
|
||||
out.setdefault(board, values)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_known_route_lookup(path: Path) -> dict[str, dict[str, Any]]:
|
||||
"""Load exact known-route signatures once at startup.
|
||||
|
||||
This is intentionally an exact O(1) lookup, not a nearest-neighbor search.
|
||||
The key is board + angle + exact hold-role set. Multiple real climbs can
|
||||
share the same signature, so each signature stores a count and a few examples.
|
||||
"""
|
||||
lookup: dict[str, dict[str, Any]] = {}
|
||||
if not path.exists():
|
||||
return lookup
|
||||
|
||||
usecols = [
|
||||
"uuid",
|
||||
"board_key",
|
||||
"climb_name",
|
||||
"setter_username",
|
||||
"angle",
|
||||
"grouped_v",
|
||||
"boulder_grade",
|
||||
"quality_average",
|
||||
"ascensionist_count",
|
||||
"frames",
|
||||
"sequence_no_grade",
|
||||
]
|
||||
|
||||
try:
|
||||
df = pd.read_csv(path, usecols=lambda col: col in set(usecols))
|
||||
except Exception:
|
||||
return lookup
|
||||
|
||||
for _, row in df.iterrows():
|
||||
board = str(row.get("board_key", ""))
|
||||
angle = row.get("angle", 0)
|
||||
holds = _holds_from_sequence(str(row.get("sequence_no_grade", "")))
|
||||
signature = _route_signature_from_holds(board, angle, holds)
|
||||
if signature is None:
|
||||
continue
|
||||
|
||||
entry = lookup.setdefault(signature, {"count": 0, "examples": []})
|
||||
entry["count"] += 1
|
||||
|
||||
if len(entry["examples"]) < 5:
|
||||
example = {
|
||||
"uuid": str(row.get("uuid", "")),
|
||||
"climb_name": None if pd.isna(row.get("climb_name", None)) else str(row.get("climb_name", "")),
|
||||
"setter_username": None if pd.isna(row.get("setter_username", None)) else str(row.get("setter_username", "")),
|
||||
"angle": _angle_key(angle),
|
||||
"grouped_v": None if pd.isna(row.get("grouped_v", None)) else int(row.get("grouped_v")),
|
||||
"boulder_grade": None if pd.isna(row.get("boulder_grade", None)) else str(row.get("boulder_grade", "")),
|
||||
"quality_average": None if pd.isna(row.get("quality_average", None)) else float(row.get("quality_average")),
|
||||
"ascensionist_count": None if pd.isna(row.get("ascensionist_count", None)) else int(row.get("ascensionist_count")),
|
||||
"frames": None if pd.isna(row.get("frames", None)) else str(row.get("frames", "")),
|
||||
}
|
||||
entry["examples"].append(example)
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
signature = _route_signature_from_holds(board, angle, holds)
|
||||
if signature is None:
|
||||
return {
|
||||
"checked": bool(getattr(app.state, "known_route_lookup", None)),
|
||||
"is_known": False,
|
||||
"match_count": 0,
|
||||
"examples": [],
|
||||
"signature": None,
|
||||
}
|
||||
|
||||
lookup = getattr(app.state, "known_route_lookup", {})
|
||||
match = lookup.get(signature)
|
||||
|
||||
return {
|
||||
"checked": bool(lookup),
|
||||
"is_known": match is not None,
|
||||
"match_count": int(match["count"]) if match else 0,
|
||||
"examples": match["examples"] if match else [],
|
||||
"signature": signature,
|
||||
}
|
||||
|
||||
|
||||
def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[str, Any]:
|
||||
board = str(result["board_key"])
|
||||
tokens = list(tokens if tokens is not None else result.get("tokens", []))
|
||||
extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
|
||||
image_path = BOARD_IMAGE_PATHS[board]
|
||||
holds = _tokens_to_holds(board, tokens)
|
||||
angle = result.get("requested_angle", result.get("angle", 0))
|
||||
|
||||
return _json_safe(
|
||||
{
|
||||
**result,
|
||||
"tokens": tokens,
|
||||
"holds": holds,
|
||||
"known_climb": _known_route_status(board, angle, holds),
|
||||
"canvas": {
|
||||
"extent": extent,
|
||||
"x_min": extent[0],
|
||||
"x_max": extent[1],
|
||||
"y_min": extent[2],
|
||||
"y_max": extent[3],
|
||||
"width": extent[1] - extent[0],
|
||||
"height": extent[3] - extent[2],
|
||||
},
|
||||
"background_url": _static_image_url(board),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(title="ClimbingBoardGPT", version="0.1.0")
|
||||
app.mount("/static", StaticFiles(directory=REPO_ROOT / "webapp" / "static"), name="static")
|
||||
app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="board-images")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
if TORCH_THREADS_INT is not None:
|
||||
torch.set_num_threads(TORCH_THREADS_INT)
|
||||
|
||||
app.state.board_configs = {
|
||||
"tb2": load_board_config("tb2", config_dir=REPO_ROOT / "configs"),
|
||||
"kilter": load_board_config("kilter", config_dir=REPO_ROOT / "configs"),
|
||||
}
|
||||
app.state.token_meta = load_token_metadata(TOKENIZED_DIR)
|
||||
app.state.available_angles = _load_available_angles(KNOWN_ROUTES_PATH)
|
||||
app.state.available_grades = _load_available_grades(KNOWN_ROUTES_PATH)
|
||||
started = time.time()
|
||||
app.state.known_route_lookup = _load_known_route_lookup(KNOWN_ROUTES_PATH)
|
||||
app.state.known_route_lookup_seconds = round(time.time() - started, 3)
|
||||
|
||||
app.state.generator = None
|
||||
if GENERATOR_PATH.exists():
|
||||
app.state.generator = load_route_generator(
|
||||
GENERATOR_PATH,
|
||||
device=DEVICE,
|
||||
torch_threads=TORCH_THREADS_INT,
|
||||
)
|
||||
|
||||
app.state.grade_predictor = None
|
||||
if GRADE_MODEL_PATH.exists():
|
||||
app.state.grade_predictor = load_grade_predictor(
|
||||
GRADE_MODEL_PATH,
|
||||
device=DEVICE,
|
||||
torch_threads=TORCH_THREADS_INT,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(REPO_ROOT / "webapp" / "static" / "index.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {
|
||||
"ok": True,
|
||||
"device": DEVICE,
|
||||
"generator_loaded": app.state.generator is not None,
|
||||
"grade_predictor_loaded": app.state.grade_predictor is not None,
|
||||
"known_route_signatures": len(getattr(app.state, "known_route_lookup", {})),
|
||||
"known_route_lookup_seconds": getattr(app.state, "known_route_lookup_seconds", None),
|
||||
"available_angles": getattr(app.state, "available_angles", {}),
|
||||
"available_grades": getattr(app.state, "available_grades", {}),
|
||||
"torch_threads": torch.get_num_threads(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/boards")
|
||||
def boards():
|
||||
payload = {}
|
||||
for board, config in app.state.board_configs.items():
|
||||
extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
|
||||
payload[board] = {
|
||||
"board_key": board,
|
||||
"display_name": config.display_name,
|
||||
"token_prefix": config.token_prefix,
|
||||
"role_definitions": config.role_definitions,
|
||||
"available_angles": getattr(app.state, "available_angles", {}).get(board, []),
|
||||
"available_grades": getattr(app.state, "available_grades", {}).get(board, []),
|
||||
"background_url": _static_image_url(board),
|
||||
"canvas": {
|
||||
"extent": extent,
|
||||
"x_min": extent[0],
|
||||
"x_max": extent[1],
|
||||
"y_min": extent[2],
|
||||
"y_max": extent[3],
|
||||
"width": extent[1] - extent[0],
|
||||
"height": extent[3] - extent[2],
|
||||
},
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@app.get("/api/board-holds/{board}")
|
||||
def board_holds(board: str):
|
||||
config = _board_config(board)
|
||||
return _json_safe({
|
||||
"board_key": board,
|
||||
"display_name": config.display_name,
|
||||
"token_prefix": config.token_prefix,
|
||||
"role_definitions": config.role_definitions,
|
||||
"holds": _board_available_holds(board),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/debug/images")
|
||||
def debug_images():
|
||||
payload = {}
|
||||
for board, image_path in BOARD_IMAGE_PATHS.items():
|
||||
payload[board] = {
|
||||
"background_url": _static_image_url(board),
|
||||
"file": _file_info(image_path),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/generate")
|
||||
def generate(req: GenerateRequest):
|
||||
generator = _require_generator()
|
||||
config = _board_config(req.board)
|
||||
|
||||
attempts = req.max_attempts if req.valid_only else 1
|
||||
result = None
|
||||
sampled_results = []
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
candidate = generate_route(
|
||||
generator=generator,
|
||||
board_config=config,
|
||||
angle=req.angle,
|
||||
grade=req.grade,
|
||||
temperature=req.temperature,
|
||||
top_k=req.top_k,
|
||||
max_new_tokens=req.max_new_tokens,
|
||||
)
|
||||
candidate["generation_attempt"] = attempt
|
||||
combined_validity = _combined_validity(
|
||||
list(candidate.get("tokens", [])),
|
||||
requested_board_prefix=config.token_prefix,
|
||||
)
|
||||
candidate.update(combined_validity)
|
||||
candidate["webapp_validity"] = combined_validity
|
||||
|
||||
sampled_results.append(
|
||||
{
|
||||
"attempt": attempt,
|
||||
"basic_valid": bool(candidate.get("basic_valid")),
|
||||
"base_basic_valid": bool(candidate.get("base_basic_valid")),
|
||||
"role_limits_valid": bool(candidate.get("role_limits_valid")),
|
||||
"n_hold_tokens": int(candidate.get("n_hold_tokens", 0)),
|
||||
"n_start_holds": int(candidate.get("n_start_holds", 0)),
|
||||
"n_finish_holds": int(candidate.get("n_finish_holds", 0)),
|
||||
"frames": candidate.get("frames", ""),
|
||||
}
|
||||
)
|
||||
|
||||
result = candidate
|
||||
if not req.valid_only or bool(candidate.get("basic_valid")):
|
||||
break
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(status_code=500, detail="Generation failed unexpectedly.")
|
||||
|
||||
result["requested_valid_only"] = bool(req.valid_only)
|
||||
result["max_attempts"] = int(attempts)
|
||||
result["attempts_used"] = int(result.get("generation_attempt", 1))
|
||||
result["sampled_attempts"] = sampled_results
|
||||
|
||||
warnings = []
|
||||
if req.valid_only and not bool(result.get("basic_valid")):
|
||||
warnings.append(
|
||||
f"Could not sample a valid climb after {attempts} attempts. Showing the last sample."
|
||||
)
|
||||
if not bool(result.get("role_limits_valid", True)):
|
||||
warnings.append(
|
||||
"Last sample violates start/finish role limits: at most two starts and at most two finishes."
|
||||
)
|
||||
elif req.valid_only and result["attempts_used"] > 1:
|
||||
warnings.append(
|
||||
f"Sampled {result['attempts_used']} climbs before finding a valid one."
|
||||
)
|
||||
elif not bool(result.get("basic_valid")):
|
||||
warnings.append("Generated climb is structurally invalid.")
|
||||
|
||||
if app.state.grade_predictor is not None:
|
||||
grade_result = predict_route_grade(app.state.grade_predictor, result["tokens"])
|
||||
result.update(grade_result)
|
||||
result["critic_v_error"] = (
|
||||
int(result["predicted_grouped_v"]) - int(result["requested_grouped_v"])
|
||||
)
|
||||
|
||||
if "warnings" in result and isinstance(result["warnings"], list):
|
||||
result["warnings"].extend(warnings)
|
||||
else:
|
||||
result["warnings"] = warnings
|
||||
|
||||
return _payload(result)
|
||||
|
||||
|
||||
@app.post("/api/predict")
|
||||
def predict(req: PredictRequest):
|
||||
predictor = _require_grade_predictor()
|
||||
config = _board_config(req.board)
|
||||
|
||||
result = predict_frames_grade(
|
||||
grade_predictor=predictor,
|
||||
frames=req.frames,
|
||||
angle=req.angle,
|
||||
board_config=config,
|
||||
df_token_meta=app.state.token_meta,
|
||||
)
|
||||
|
||||
tokens = list(result["tokens"])
|
||||
validity = _combined_validity(tokens, requested_board_prefix=config.token_prefix)
|
||||
result.update(validity)
|
||||
result["webapp_validity"] = validity
|
||||
|
||||
if not bool(validity.get("basic_valid", False)):
|
||||
reasons = _invalid_prediction_reasons(validity)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_json_safe(
|
||||
{
|
||||
"message": "Cannot predict grade for an invalid climb.",
|
||||
"reasons": reasons,
|
||||
"validity": validity,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
return _payload(result, tokens=tokens)
|
||||
347
webapp/static/app.css
Normal file
347
webapp/static/app.css
Normal file
@@ -0,0 +1,347 @@
|
||||
:root {
|
||||
--bg: #263238;
|
||||
--bg-alt: #1f2a30;
|
||||
--panel: #2f3d43;
|
||||
--panel-soft: #37474f;
|
||||
--ink: #eceff1;
|
||||
--muted: #b0bec5;
|
||||
--faint: #90a4ae;
|
||||
--border: #455a64;
|
||||
--accent: #80cbc4;
|
||||
--accent-2: #ffcc80;
|
||||
--danger: #ef9a9a;
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(128, 203, 196, 0.07), transparent 32rem),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.35rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-alt);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 2.05rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.site-header p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.health {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-2);
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 370px minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
max-width: 1520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card, .result-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card h2, .result-card h2 {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 1.0rem;
|
||||
color: var(--accent);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.7rem 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.28rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #1c272c;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: 2px solid rgba(128, 203, 196, 0.32);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border: 1px solid #4db6ac;
|
||||
border-radius: 6px;
|
||||
padding: 0.68rem 0.9rem;
|
||||
margin-top: 0.4rem;
|
||||
font-weight: 700;
|
||||
color: #102022;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
button:hover { filter: brightness(1.06); }
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.note p, .small {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.builder-list {
|
||||
list-style: none;
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.builder-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px dashed rgba(176, 190, 197, 0.16);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
text-align: center;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.result-header h2 { margin-bottom: 0.25rem; }
|
||||
|
||||
.result-header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.board-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.board-stage img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.board-stage svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.click-target {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.route-marker, .builder-marker {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hover-marker {
|
||||
pointer-events: none;
|
||||
fill: none;
|
||||
stroke: var(--accent-2);
|
||||
stroke-width: 0.55;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.json-block {
|
||||
margin-top: 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.json-block pre {
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
padding: 1rem;
|
||||
background: #111a1f;
|
||||
color: #cfd8dc;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.site-header { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
margin: 0.7rem auto 0.85rem;
|
||||
max-width: 760px;
|
||||
border: 1px solid rgba(255, 204, 128, 0.55);
|
||||
background: rgba(255, 204, 128, 0.12);
|
||||
color: var(--accent-2);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.clear-board {
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
margin: 0.85rem auto 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
.field-help {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--faint);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.explain dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.explain dt {
|
||||
color: var(--accent-2);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.explain dt:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.explain dd {
|
||||
margin: 0.22rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.explain code {
|
||||
color: var(--accent-2);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.25rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.link-list a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
#data-acknowledgement-card p {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
702
webapp/static/app.js
Normal file
702
webapp/static/app.js
Normal file
@@ -0,0 +1,702 @@
|
||||
const state = {
|
||||
boards: {},
|
||||
boardHolds: {},
|
||||
activeBoard: "tb2",
|
||||
lastResult: null,
|
||||
builder: [],
|
||||
};
|
||||
|
||||
const roleStyle = {
|
||||
start: { fill: "#69f0ae", stroke: "#102022", r: 1.55, shape: "circle" },
|
||||
middle: { fill: "#82b1ff", stroke: "#102022", r: 1.55, shape: "circle" },
|
||||
finish: { fill: "#ff8a80", stroke: "#102022", r: 2.25, shape: "star" },
|
||||
foot: { fill: "#ffd740", stroke: "#102022", r: 1.1, shape: "square" },
|
||||
unknown: { fill: "#b0bec5", stroke: "#102022", r: 1.45, shape: "circle" },
|
||||
};
|
||||
|
||||
const builderStyle = {
|
||||
start: { fill: "#00e676", stroke: "#000000", r: 1.85, shape: "circle" },
|
||||
middle: { fill: "#448aff", stroke: "#000000", r: 1.85, shape: "circle" },
|
||||
finish: { fill: "#ff5252", stroke: "#000000", r: 2.6, shape: "star" },
|
||||
foot: { fill: "#ffea00", stroke: "#000000", r: 1.25, shape: "square" },
|
||||
unknown: { fill: "#cfd8dc", stroke: "#000000", r: 1.85, shape: "circle" },
|
||||
};
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const text = await response.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
payload = { detail: text };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload.detail ?? payload;
|
||||
let message;
|
||||
if (typeof detail === "string") {
|
||||
message = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
message = detail.map((item) => item.msg || JSON.stringify(item)).join("\n");
|
||||
} else {
|
||||
message = JSON.stringify(detail, null, 2);
|
||||
}
|
||||
throw new Error(message || `Request failed: ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function setBusy(button, busy) {
|
||||
button.disabled = busy;
|
||||
button.textContent = busy ? "Working…" : button.dataset.label;
|
||||
}
|
||||
|
||||
async function ensureBoardHolds(boardKey) {
|
||||
if (!state.boardHolds[boardKey]) {
|
||||
const payload = await fetchJson(`/api/board-holds/${boardKey}`);
|
||||
state.boardHolds[boardKey] = payload.holds || [];
|
||||
}
|
||||
return state.boardHolds[boardKey];
|
||||
}
|
||||
|
||||
async function setBoardBackground(boardKey) {
|
||||
const board = state.boards[boardKey];
|
||||
if (!board) return;
|
||||
|
||||
state.activeBoard = boardKey;
|
||||
syncAngleSelectors();
|
||||
syncGradeSelector();
|
||||
const bg = $("board-bg");
|
||||
if (bg.getAttribute("src") !== board.background_url) {
|
||||
bg.src = board.background_url;
|
||||
}
|
||||
|
||||
const svg = $("overlay");
|
||||
const c = board.canvas;
|
||||
svg.setAttribute("viewBox", `${c.x_min} ${c.y_min} ${c.width} ${c.height}`);
|
||||
|
||||
await ensureBoardHolds(boardKey);
|
||||
redrawCurrentOverlay();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function preferredAngleForBoard(boardKey, currentValue = null) {
|
||||
const board = state.boards[boardKey] || {};
|
||||
const angles = board.available_angles || [];
|
||||
const current = Number(currentValue);
|
||||
|
||||
if (angles.includes(current)) return current;
|
||||
if (angles.includes(40)) return 40;
|
||||
if (angles.length > 0) return angles[Math.floor(angles.length / 2)];
|
||||
return 40;
|
||||
}
|
||||
|
||||
function populateAngleSelect(selectId, boardKey, selectedValue = null) {
|
||||
const select = $(selectId);
|
||||
if (!select) return;
|
||||
|
||||
const board = state.boards[boardKey] || {};
|
||||
const angles = board.available_angles || [20, 25, 30, 35, 40, 45, 50];
|
||||
const selected = preferredAngleForBoard(boardKey, selectedValue ?? select.value);
|
||||
|
||||
select.innerHTML = "";
|
||||
for (const angle of angles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(angle);
|
||||
option.textContent = `${angle}°`;
|
||||
if (Number(angle) === Number(selected)) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function syncAngleSelectors(angleValue = null) {
|
||||
const boardKey = state.activeBoard || $("gen-board")?.value || $("pred-board")?.value || "tb2";
|
||||
const selected = preferredAngleForBoard(boardKey, angleValue ?? $("gen-angle")?.value ?? $("pred-angle")?.value);
|
||||
|
||||
populateAngleSelect("gen-angle", boardKey, selected);
|
||||
populateAngleSelect("pred-angle", boardKey, selected);
|
||||
|
||||
const genAngle = $("gen-angle");
|
||||
const predAngle = $("pred-angle");
|
||||
if (genAngle) genAngle.value = String(selected);
|
||||
if (predAngle) predAngle.value = String(selected);
|
||||
}
|
||||
|
||||
|
||||
function preferredGradeForBoard(boardKey, currentValue = null) {
|
||||
const board = state.boards[boardKey] || {};
|
||||
const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
|
||||
const current = Number(currentValue);
|
||||
|
||||
if (grades.includes(current)) return current;
|
||||
if (grades.includes(6)) return 6;
|
||||
if (grades.length > 0) return grades[Math.floor(grades.length / 2)];
|
||||
return 6;
|
||||
}
|
||||
|
||||
function populateGradeSelect(selectId, boardKey, selectedValue = null) {
|
||||
const select = $(selectId);
|
||||
if (!select) return;
|
||||
|
||||
const board = state.boards[boardKey] || {};
|
||||
const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
|
||||
const selected = preferredGradeForBoard(boardKey, selectedValue ?? select.value);
|
||||
|
||||
select.innerHTML = "";
|
||||
for (const grade of grades) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(grade);
|
||||
option.textContent = `V${grade}`;
|
||||
if (Number(grade) === Number(selected)) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
function syncGradeSelector(gradeValue = null) {
|
||||
const boardKey = state.activeBoard || $("gen-board")?.value || "tb2";
|
||||
const selected = preferredGradeForBoard(boardKey, gradeValue ?? $("gen-grade")?.value);
|
||||
populateGradeSelect("gen-grade", boardKey, selected);
|
||||
|
||||
const genGrade = $("gen-grade");
|
||||
if (genGrade) genGrade.value = String(selected);
|
||||
}
|
||||
|
||||
function syncBoardSelectors(boardKey) {
|
||||
const genBoard = $("gen-board");
|
||||
const predBoard = $("pred-board");
|
||||
if (genBoard && genBoard.value !== boardKey) genBoard.value = boardKey;
|
||||
if (predBoard && predBoard.value !== boardKey) predBoard.value = boardKey;
|
||||
}
|
||||
|
||||
function setBuilderFromRouteResult(result) {
|
||||
const board = result.board_key;
|
||||
state.builder = (result.holds || []).map((hold) => ({
|
||||
board,
|
||||
placement_id: hold.placement_id,
|
||||
x: hold.x,
|
||||
y: hold.y,
|
||||
role: hold.role,
|
||||
}));
|
||||
syncBuilderToFrames();
|
||||
}
|
||||
|
||||
function activeBuilderSummary() {
|
||||
const board = $("pred-board")?.value || state.activeBoard;
|
||||
const holds = state.builder.filter((hold) => hold.board === board);
|
||||
const starts = holds.filter((hold) => hold.role === "start").length;
|
||||
const finishes = holds.filter((hold) => hold.role === "finish").length;
|
||||
return { holds, starts, finishes };
|
||||
}
|
||||
|
||||
function updateEditingHeader(prefix = "Editing climb") {
|
||||
const summary = activeBuilderSummary();
|
||||
const frameText = $("pred-frames")?.value?.trim() || "";
|
||||
$("result-title").textContent = prefix;
|
||||
$("result-subtitle").textContent =
|
||||
`${summary.holds.length} selected holds | starts=${summary.starts} | finishes=${summary.finishes} | editing`;
|
||||
$("raw-json").textContent = JSON.stringify({
|
||||
mode: "editable_climb",
|
||||
board: $("pred-board")?.value || state.activeBoard,
|
||||
n_holds: summary.holds.length,
|
||||
starts: summary.starts,
|
||||
finishes: summary.finishes,
|
||||
frames: frameText,
|
||||
note: "Run grade prediction to refresh known-climb status after edits."
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
function clearOverlay() {
|
||||
$("overlay").innerHTML = "";
|
||||
}
|
||||
|
||||
function svgEl(name, attrs = {}) {
|
||||
const el = document.createElementNS("http://www.w3.org/2000/svg", name);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
el.setAttribute(key, value);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function starPoints(cx, cy, outerR, innerR, n = 5) {
|
||||
const points = [];
|
||||
for (let i = 0; i < n * 2; i++) {
|
||||
const angle = -Math.PI / 2 + (i * Math.PI) / n;
|
||||
const r = i % 2 === 0 ? outerR : innerR;
|
||||
points.push(`${cx + Math.cos(angle) * r},${cy + Math.sin(angle) * r}`);
|
||||
}
|
||||
return points.join(" ");
|
||||
}
|
||||
|
||||
function drawMarker(group, hold, style, className = "route-marker") {
|
||||
if (style.shape === "square") {
|
||||
const s = style.r * 2;
|
||||
group.appendChild(svgEl("rect", {
|
||||
x: hold.x - style.r,
|
||||
y: hold.y - style.r,
|
||||
width: s,
|
||||
height: s,
|
||||
rx: 0.28,
|
||||
fill: style.fill,
|
||||
stroke: style.stroke,
|
||||
"stroke-width": 0.35,
|
||||
opacity: 0.96,
|
||||
class: className,
|
||||
}));
|
||||
} else if (style.shape === "star") {
|
||||
group.appendChild(svgEl("polygon", {
|
||||
points: starPoints(hold.x, hold.y, style.r, style.r * 0.48),
|
||||
fill: style.fill,
|
||||
stroke: style.stroke,
|
||||
"stroke-width": 0.38,
|
||||
opacity: 0.98,
|
||||
class: className,
|
||||
}));
|
||||
} else {
|
||||
group.appendChild(svgEl("circle", {
|
||||
cx: hold.x,
|
||||
cy: hold.y,
|
||||
r: style.r,
|
||||
fill: style.fill,
|
||||
stroke: style.stroke,
|
||||
"stroke-width": 0.35,
|
||||
opacity: 0.96,
|
||||
class: className,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function transformedGroup(resultOrBoardKey) {
|
||||
const boardKey = typeof resultOrBoardKey === "string" ? resultOrBoardKey : resultOrBoardKey.board_key;
|
||||
const board = state.boards[boardKey];
|
||||
const c = board.canvas;
|
||||
return svgEl("g", {
|
||||
transform: `translate(0 ${c.y_min + c.y_max}) scale(1 -1)`,
|
||||
});
|
||||
}
|
||||
|
||||
function drawSelectableTargets(boardKey, group) {
|
||||
const holds = state.boardHolds[boardKey] || [];
|
||||
for (const hold of holds) {
|
||||
const target = svgEl("circle", {
|
||||
cx: hold.x,
|
||||
cy: hold.y,
|
||||
r: 2.45,
|
||||
fill: "transparent",
|
||||
stroke: "transparent",
|
||||
"stroke-width": 0,
|
||||
class: "click-target",
|
||||
});
|
||||
|
||||
target.addEventListener("mouseenter", () => {
|
||||
const hover = svgEl("circle", {
|
||||
cx: hold.x,
|
||||
cy: hold.y,
|
||||
r: 2.15,
|
||||
class: "hover-marker",
|
||||
id: "hover-marker",
|
||||
});
|
||||
group.appendChild(hover);
|
||||
});
|
||||
|
||||
target.addEventListener("mouseleave", () => {
|
||||
const hover = document.getElementById("hover-marker");
|
||||
if (hover) hover.remove();
|
||||
});
|
||||
|
||||
target.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
addBuilderHold(hold);
|
||||
});
|
||||
|
||||
group.appendChild(target);
|
||||
}
|
||||
}
|
||||
|
||||
function drawOverlay(result) {
|
||||
state.lastResult = result;
|
||||
syncBoardSelectors(result.board_key);
|
||||
state.activeBoard = result.board_key;
|
||||
setBuilderFromRouteResult(result);
|
||||
redrawCurrentOverlay();
|
||||
}
|
||||
|
||||
function redrawCurrentOverlay() {
|
||||
const svg = $("overlay");
|
||||
svg.innerHTML = "";
|
||||
const boardKey = state.activeBoard || $("pred-board")?.value || "tb2";
|
||||
|
||||
const group = transformedGroup(boardKey);
|
||||
svg.appendChild(group);
|
||||
|
||||
for (const hold of state.builder) {
|
||||
if (hold.board !== boardKey) continue;
|
||||
const style = builderStyle[hold.role] || builderStyle.unknown;
|
||||
drawMarker(group, hold, style, "builder-marker");
|
||||
}
|
||||
|
||||
drawSelectableTargets(boardKey, group);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function findBuilderHoldIndex(board, placementId) {
|
||||
return state.builder.findIndex(
|
||||
(hold) => hold.board === board && Number(hold.placement_id) === Number(placementId)
|
||||
);
|
||||
}
|
||||
|
||||
function isBuilderHoldSelected(board, placementId) {
|
||||
return findBuilderHoldIndex(board, placementId) >= 0;
|
||||
}
|
||||
|
||||
function activeBuilderHolds() {
|
||||
return state.builder.filter((hold) => hold.board === state.activeBoard);
|
||||
}
|
||||
|
||||
function roleCountForActiveBoard(role) {
|
||||
return activeBuilderHolds().filter((hold) => hold.role === role).length;
|
||||
}
|
||||
|
||||
function canAddBuilderRole(role) {
|
||||
if ((role === "start" || role === "finish") && roleCountForActiveBoard(role) >= 2) {
|
||||
showWarnings([
|
||||
`You already have two ${role} holds. A climb can have at most two ${role} holds in this builder.`
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function frameStringForBuilder() {
|
||||
const board = state.boards[$("pred-board").value];
|
||||
if (!board) return "";
|
||||
const roleDefinitions = board.role_definitions || {};
|
||||
return state.builder
|
||||
.filter((hold) => hold.board === $("pred-board").value)
|
||||
.map((hold) => `p${hold.placement_id}r${roleDefinitions[hold.role]}`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function syncBuilderToFrames() {
|
||||
$("pred-frames").value = frameStringForBuilder();
|
||||
const list = $("builder-list");
|
||||
list.innerHTML = "";
|
||||
|
||||
const active = state.builder.filter((hold) => hold.board === $("pred-board").value);
|
||||
for (const [idx, hold] of active.entries()) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<span>${idx + 1}. ${hold.role}</span><span>p${hold.placement_id}</span>`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
updatePredictButton();
|
||||
}
|
||||
|
||||
function addBuilderHold(hold) {
|
||||
const board = $("pred-board").value;
|
||||
const role = $("click-role").value;
|
||||
if (board !== state.activeBoard) {
|
||||
$("pred-board").value = state.activeBoard;
|
||||
}
|
||||
|
||||
const existingIndex = findBuilderHoldIndex(state.activeBoard, hold.placement_id);
|
||||
if (existingIndex >= 0) {
|
||||
state.builder.splice(existingIndex, 1);
|
||||
showWarnings([]);
|
||||
syncBuilderToFrames();
|
||||
redrawCurrentOverlay();
|
||||
updateEditingHeader("Editing generated / clicked climb");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAddBuilderRole(role)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.builder.push({
|
||||
board: state.activeBoard,
|
||||
placement_id: hold.placement_id,
|
||||
x: hold.x,
|
||||
y: hold.y,
|
||||
role,
|
||||
});
|
||||
|
||||
showWarnings([]);
|
||||
syncBuilderToFrames();
|
||||
redrawCurrentOverlay();
|
||||
updateEditingHeader("Editing generated / clicked climb");
|
||||
}
|
||||
|
||||
function undoBuilderHold() {
|
||||
const board = $("pred-board").value;
|
||||
for (let i = state.builder.length - 1; i >= 0; i--) {
|
||||
if (state.builder[i].board === board) {
|
||||
state.builder.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
syncBuilderToFrames();
|
||||
redrawCurrentOverlay();
|
||||
updateEditingHeader("Editing generated / clicked climb");
|
||||
}
|
||||
|
||||
function clearBuilder() {
|
||||
clearEntireBoard();
|
||||
}
|
||||
|
||||
|
||||
function showWarnings(warnings = []) {
|
||||
const box = $("warning-box");
|
||||
const normalized = (warnings || []).filter(Boolean).map(String);
|
||||
|
||||
if (!box) {
|
||||
if (normalized.length > 0) {
|
||||
$("result-subtitle").textContent = normalized.join(" | ");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
box.hidden = true;
|
||||
box.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
box.hidden = false;
|
||||
box.textContent = normalized.join("\n");
|
||||
}
|
||||
|
||||
|
||||
function clearClickedHoldsOnly() {
|
||||
state.builder = [];
|
||||
const frames = $("pred-frames");
|
||||
if (frames) frames.value = "";
|
||||
const list = $("builder-list");
|
||||
if (list) list.innerHTML = "";
|
||||
updatePredictButton();
|
||||
}
|
||||
|
||||
function clearBoard() {
|
||||
clearEntireBoard();
|
||||
}
|
||||
|
||||
|
||||
function updatePredictButton() {
|
||||
const button = $("predict-btn");
|
||||
if (!button) return;
|
||||
|
||||
const frames = $("pred-frames").value.trim();
|
||||
const hasFrames = frames.length > 0;
|
||||
button.disabled = !hasFrames;
|
||||
button.title = hasFrames
|
||||
? ""
|
||||
: "Paste a frames string or click holds on the board first.";
|
||||
}
|
||||
|
||||
function clearEntireBoard() {
|
||||
state.lastResult = null;
|
||||
state.builder = [];
|
||||
$("pred-frames").value = "";
|
||||
const list = $("builder-list");
|
||||
if (list) list.innerHTML = "";
|
||||
clearOverlay();
|
||||
showWarnings([]);
|
||||
$("result-title").textContent = "Board cleared";
|
||||
$("result-subtitle").textContent = "Click holds to build a climb, paste a frames string, or generate a new one.";
|
||||
$("raw-json").textContent = "{}";
|
||||
updatePredictButton();
|
||||
redrawCurrentOverlay();
|
||||
}
|
||||
|
||||
|
||||
function knownClimbSummary(result) {
|
||||
const known = result.known_climb;
|
||||
if (!known || !known.checked) return null;
|
||||
if (!known.is_known) return "not in known-climb database";
|
||||
|
||||
const first = (known.examples || [])[0] || {};
|
||||
const name = first.climb_name || "unnamed climb";
|
||||
const grade = first.boulder_grade || (first.grouped_v !== undefined && first.grouped_v !== null ? `V${first.grouped_v}` : "unknown grade");
|
||||
return `known climb: ${name} (${grade}); ${known.match_count} matching route-angle entr${known.match_count === 1 ? "y" : "ies"}`;
|
||||
}
|
||||
|
||||
function summarizeResult(result, mode) {
|
||||
if (mode === "generate") {
|
||||
const pieces = [
|
||||
`${result.board_display_name}`,
|
||||
`requested V${result.requested_grouped_v}`,
|
||||
`${result.requested_angle}°`,
|
||||
];
|
||||
|
||||
if (result.predicted_grouped_v !== undefined) {
|
||||
pieces.push(`predicted V${result.predicted_grouped_v} (${Number(result.predicted_display_difficulty).toFixed(2)})`);
|
||||
if (result.critic_v_error !== undefined) {
|
||||
const sign = Number(result.critic_v_error) >= 0 ? "+" : "";
|
||||
pieces.push(`error ${sign}${result.critic_v_error}V`);
|
||||
}
|
||||
}
|
||||
|
||||
const knownSummary = knownClimbSummary(result);
|
||||
if (knownSummary) pieces.push(knownSummary);
|
||||
$("result-title").textContent = "Generated climb";
|
||||
$("result-subtitle").textContent = pieces.join(" | ");
|
||||
} else {
|
||||
const knownSummary = knownClimbSummary(result);
|
||||
$("result-title").textContent = "Grade prediction";
|
||||
$("result-subtitle").textContent =
|
||||
`${result.board_display_name} | ${result.requested_angle}° | predicted V${result.predicted_grouped_v} (${Number(result.predicted_display_difficulty).toFixed(2)})`
|
||||
+ (knownSummary ? ` | ${knownSummary}` : "");
|
||||
}
|
||||
|
||||
showWarnings(result.warnings || []);
|
||||
$("raw-json").textContent = JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
|
||||
function formatErrorMessage(message) {
|
||||
if (!message) return "Unknown error.";
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
if (parsed.message && Array.isArray(parsed.reasons)) {
|
||||
return `${parsed.message}\n- ${parsed.reasons.join("\n- ")}`;
|
||||
}
|
||||
if (Array.isArray(parsed.reasons)) {
|
||||
return parsed.reasons.map((r) => `- ${r}`).join("\n");
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
const button = $("generate-btn");
|
||||
setBusy(button, true);
|
||||
try {
|
||||
const payload = {
|
||||
board: $("gen-board").value,
|
||||
angle: Number($("gen-angle").value),
|
||||
grade: Number($("gen-grade").value),
|
||||
temperature: Number($("gen-temperature").value),
|
||||
top_k: 50,
|
||||
max_new_tokens: 40,
|
||||
};
|
||||
const result = await fetchJson("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
drawOverlay(result);
|
||||
summarizeResult(result, "generate");
|
||||
} catch (err) {
|
||||
showWarnings([formatErrorMessage(err.message)]);
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function predict() {
|
||||
const button = $("predict-btn");
|
||||
const frames = $("pred-frames").value.trim();
|
||||
if (!frames) {
|
||||
showWarnings(["Paste a frames string or click holds on the board before predicting."]);
|
||||
updatePredictButton();
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(button, true);
|
||||
try {
|
||||
const payload = {
|
||||
board: $("pred-board").value,
|
||||
angle: Number($("pred-angle").value),
|
||||
frames,
|
||||
};
|
||||
const result = await fetchJson("/api/predict", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
drawOverlay(result);
|
||||
summarizeResult(result, "predict");
|
||||
} catch (err) {
|
||||
showWarnings([formatErrorMessage(err.message)]);
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
updatePredictButton();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function addListenerIfPresent(id, eventName, handler) {
|
||||
const element = $(id);
|
||||
if (!element) {
|
||||
console.warn(`Missing optional element #${id}; skipping ${eventName} listener.`);
|
||||
return;
|
||||
}
|
||||
element.addEventListener(eventName, handler);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
$("generate-btn").dataset.label = "Generate";
|
||||
$("predict-btn").dataset.label = "Predict pasted / clicked climb";
|
||||
|
||||
const health = await fetchJson("/api/health");
|
||||
$("health").textContent =
|
||||
`device=${health.device} | generator=${health.generator_loaded ? "loaded" : "missing"} | grade=${health.grade_predictor_loaded ? "loaded" : "missing"}`;
|
||||
|
||||
state.boards = await fetchJson("/api/boards");
|
||||
syncBoardSelectors("tb2");
|
||||
syncAngleSelectors(40);
|
||||
syncGradeSelector(6);
|
||||
await ensureBoardHolds("tb2");
|
||||
await setBoardBackground("tb2");
|
||||
syncAngleSelectors(40);
|
||||
|
||||
addListenerIfPresent("gen-board", "change", async (e) => {
|
||||
syncBoardSelectors(e.target.value);
|
||||
await setBoardBackground(e.target.value);
|
||||
syncGradeSelector();
|
||||
syncBuilderToFrames();
|
||||
updateEditingHeader("Editing climb");
|
||||
});
|
||||
|
||||
addListenerIfPresent("pred-board", "change", async (e) => {
|
||||
syncBoardSelectors(e.target.value);
|
||||
await setBoardBackground(e.target.value);
|
||||
syncGradeSelector();
|
||||
syncBuilderToFrames();
|
||||
updateEditingHeader("Editing climb");
|
||||
});
|
||||
|
||||
addListenerIfPresent("gen-angle", "change", (e) => {
|
||||
syncAngleSelectors(e.target.value);
|
||||
});
|
||||
addListenerIfPresent("pred-angle", "change", (e) => {
|
||||
syncAngleSelectors(e.target.value);
|
||||
});
|
||||
addListenerIfPresent("gen-grade", "change", (e) => {
|
||||
syncGradeSelector(e.target.value);
|
||||
});
|
||||
addListenerIfPresent("pred-frames", "input", updatePredictButton);
|
||||
addListenerIfPresent("generate-btn", "click", generate);
|
||||
addListenerIfPresent("predict-btn", "click", predict);
|
||||
addListenerIfPresent("undo-hold-btn", "click", undoBuilderHold);
|
||||
addListenerIfPresent("clear-holds-btn", "click", clearBoard);
|
||||
addListenerIfPresent("clear-board-btn", "click", clearBoard);
|
||||
updatePredictButton();
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
const health = $("health");
|
||||
if (health) health.textContent = `Error: ${err.message}`;
|
||||
showWarnings([`Initialization error: ${err.message}`]);
|
||||
console.error(err);
|
||||
});
|
||||
175
webapp/static/index.html
Normal file
175
webapp/static/index.html
Normal file
@@ -0,0 +1,175 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ClimbingBoardGPT</title>
|
||||
<link rel="stylesheet" href="/static/app.css?v=16" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div>
|
||||
<p class="eyebrow">ClimbingBoardGPT</p>
|
||||
<h1>Board-climbing route generation and grade prediction</h1>
|
||||
<p>Transformer demo for Tension Board 2 12x12 and Kilter Board Original 16x12 routes.</p>
|
||||
</div>
|
||||
<div id="health" class="health">Loading…</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="controls">
|
||||
<div class="card">
|
||||
<h2>Generate a climb</h2>
|
||||
<label>Board
|
||||
<select id="gen-board">
|
||||
<option value="tb2">Tension Board 2 (12x12)</option>
|
||||
<option value="kilter">Kilter Board Original (16x12)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Angle
|
||||
<select id="gen-angle"></select>
|
||||
</label>
|
||||
<label>Target V-grade
|
||||
<select id="gen-grade"></select>
|
||||
</label>
|
||||
<label>Temperature
|
||||
<input id="gen-temperature" type="number" value="0.9" min="0.1" max="2.0" step="0.1" />
|
||||
<span class="field-help">Lower = safer/common; higher = more creative/weird. Use temperature ≤ 2.</span>
|
||||
</label>
|
||||
<button id="generate-btn">Generate</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Predict grade</h2>
|
||||
<label>Board
|
||||
<select id="pred-board">
|
||||
<option value="tb2">Tension Board 2 (12x12)</option>
|
||||
<option value="kilter">Kilter Board Original (16x12)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Angle
|
||||
<select id="pred-angle"></select>
|
||||
</label>
|
||||
<label>Frames string
|
||||
<textarea id="pred-frames" rows="5" placeholder="Click holds below, or paste p652r5p631r6p322r6p326r7"></textarea>
|
||||
</label>
|
||||
<button id="predict-btn" disabled>Predict pasted / clicked climb</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Click holds to build a climb</h2>
|
||||
<label>Role for next clicked hold
|
||||
<select id="click-role">
|
||||
<option value="start">start</option>
|
||||
<option value="middle" selected>middle</option>
|
||||
<option value="finish">finish</option>
|
||||
<option value="foot">foot</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button id="undo-hold-btn" class="secondary">Undo</button>
|
||||
<button id="clear-holds-btn" class="secondary">Clear board</button>
|
||||
</div>
|
||||
<p class="small">
|
||||
Click a hold on the board image. The app appends the corresponding
|
||||
BoardLib frame token using the selected semantic role.
|
||||
</p>
|
||||
<ul id="builder-list" class="builder-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card explain" id="explain-card">
|
||||
<h2>What the controls mean</h2>
|
||||
<dl>
|
||||
<dt>Temperature</dt>
|
||||
<dd>Sampling randomness. Lower values are more conservative; higher values are more exploratory.</dd>
|
||||
|
||||
<dt>Target V-grade</dt>
|
||||
<dd>The grade token given to the generator. The generated route is also checked by the grade predictor.</dd>
|
||||
|
||||
<dt>Known climb</dt>
|
||||
<dd>An exact match against the tokenized dataset: same board, same angle, and same hold-role set.</dd>
|
||||
|
||||
<dt>Validity</dt>
|
||||
<dd>A structural check: enough holds, no duplicate placements, at least one start, and at least one finish.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card explain">
|
||||
<h2>How this works</h2>
|
||||
<p>
|
||||
Routes are converted into tokens such as
|
||||
<code><BOARD_TB2></code>, <code><ANGLE_40></code>,
|
||||
and <code><TB2_p652_start></code>.
|
||||
</p>
|
||||
<p>
|
||||
The generator samples a token sequence. The grade predictor removes the grade token and estimates difficulty from the board, angle, and hold-role tokens.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card note">
|
||||
<h2>Links</h2>
|
||||
<ul class="link-list">
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
|
||||
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
|
||||
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
|
||||
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="data-acknowledgement-card">
|
||||
<h2>Data acknowledgement</h2>
|
||||
<p>
|
||||
Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="angle-scope-card">
|
||||
<h2>Angle scope</h2>
|
||||
<p>
|
||||
The physical boards can be used at steeper angles than this demo exposes. This model snapshot is intentionally restricted to the angle range used in training/evaluation: TB2 up to 50° and Kilter up to 55°.
|
||||
</p>
|
||||
<p>
|
||||
The restriction avoids asking the models to extrapolate into sparse, noisier high-angle data where grades and route distributions are less reliable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="edge-grade-caveat-card">
|
||||
<h2>High-grade caveat</h2>
|
||||
<p>
|
||||
Generation and prediction are both less reliable at edge-case grades, especially around V10 and above. Those grades are rarer in the data, so the generator has weaker grade control and the predictor has less signal for accurate scoring.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card note">
|
||||
<h2>To-do</h2>
|
||||
<p>
|
||||
This demo currently focuses on the full-board layouts: Tension Board 2 Mirror 12x12 and Kilter Board Original 16x12. Other board sizes are future work.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="viewer">
|
||||
<div class="result-card">
|
||||
<div class="result-header">
|
||||
<h2 id="result-title">Choose a board and run a request</h2>
|
||||
<p id="result-subtitle">The climb overlay will appear below. You can also click holds to build a route for prediction.</p>
|
||||
<div id="warning-box" class="warning-box" hidden></div>
|
||||
<button id="clear-board-btn" class="secondary clear-board">Clear board</button>
|
||||
</div>
|
||||
|
||||
<div id="board-stage" class="board-stage">
|
||||
<img id="board-bg" alt="Board background" />
|
||||
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
</div>
|
||||
|
||||
<details class="json-block">
|
||||
<summary>Raw result JSON</summary>
|
||||
<pre id="raw-json">{}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js?v=16"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user