This commit is contained in:
Pawel
2026-05-22 13:57:56 -04:00
parent 86d582a572
commit f0ea67bb45
10 changed files with 2096 additions and 23 deletions

Binary file not shown.

741
webapp/app.py Normal file
View 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
View 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
View 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
View 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>&lt;BOARD_TB2&gt;</code>, <code>&lt;ANGLE_40&gt;</code>,
and <code>&lt;TB2_p652_start&gt;</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>