From f0ea67bb45e1033562194f608e811a16758a430c Mon Sep 17 00:00:00 2001 From: Pawel Date: Fri, 22 May 2026 13:57:56 -0400 Subject: [PATCH] webapp --- Dockerfile.webapp | 24 + README.md | 97 +++- docker-compose.webapp.yml | 16 + pyproject.toml | 14 +- requirements.txt | 3 - webapp/__pycache__/app.cpython-314.pyc | Bin 0 -> 39090 bytes webapp/app.py | 741 +++++++++++++++++++++++++ webapp/static/app.css | 347 ++++++++++++ webapp/static/app.js | 702 +++++++++++++++++++++++ webapp/static/index.html | 175 ++++++ 10 files changed, 2096 insertions(+), 23 deletions(-) create mode 100644 Dockerfile.webapp create mode 100644 docker-compose.webapp.yml create mode 100644 webapp/__pycache__/app.cpython-314.pyc create mode 100644 webapp/app.py create mode 100644 webapp/static/app.css create mode 100644 webapp/static/app.js create mode 100644 webapp/static/index.html diff --git a/Dockerfile.webapp b/Dockerfile.webapp new file mode 100644 index 0000000..e1a3ed9 --- /dev/null +++ b/Dockerfile.webapp @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt pyproject.toml README.md ./ +COPY src ./src +COPY configs ./configs +COPY images ./images +COPY webapp ./webapp + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir fastapi "uvicorn[standard]" pydantic \ + && pip install --no-cache-dir -e . + +EXPOSE 8055 + +CMD ["uvicorn", "webapp.app:app", "--host", "0.0.0.0", "--port", "8055"] diff --git a/README.md b/README.md index 6c81b8e..baff419 100644 --- a/README.md +++ b/README.md @@ -134,10 +134,11 @@ Grade consistency of generated climbs, measured by the trained grade predictor: | Exact requested V-grade | 28.2% | 29.5% | 27.0% | | Within ±1 V-grade | 70.8% | 68.5% | 73.0% | | Within ±2 V-grades | 92.0% | 90.5% | 93.5% | -| Mean V-grade error | -- | -0.18 | -0.30 | +| Mean V-grade error | — | -0.18 | -0.30 | Interpretation: the generator is usually structurally valid and usually close to the requested grade according to the critic, but exact grade control remains imperfect. That is expected: this is a small GPT-style model trained on symbolic route data, not a production setter. + --- ## Repository layout @@ -378,7 +379,7 @@ python scripts/demo_generate_tb2.py \ --annotate ``` -### CPU/VPS-friendly run +### CPU- friendly run ```bash python scripts/demo_generate_tb2.py \ @@ -576,26 +577,96 @@ The visualizations are calibrated to match the existing board images, but any ch src/climbingboardgpt/visualization.py ``` + --- -## Next step: webapp demo +## Webapp demo -The next planned layer is a simple webapp with: +The repository includes a lightweight FastAPI webapp. It is inference-only: -1. grade prediction from board + angle + frames string, -2. route generation from board + angle + target grade, -3. rendered PNG output for both generated climbs and user-submitted climbs. +- loads the generator and grade predictor once at startup, +- serves the TB2/Kilter board images as static assets, +- returns hold coordinates and roles as JSON, +- draws the climb overlay in the browser as SVG. -The webapp should use the same backend helpers already added here: +### Run locally + +From the repository root: + +```bash +pip install fastapi "uvicorn[standard]" pydantic +uvicorn webapp.app:app --host 127.0.0.1 --port 8055 +``` + +Then open: ```text -load_route_generator(...) -generate_route(...) -load_grade_predictor(...) -predict_frames_grade(...) -visualize_route_tokens(...) +http://127.0.0.1:8055 ``` +### Run with Docker + +```bash +docker compose -f docker-compose.webapp.yml up -d --build +``` + +The service binds to localhost only: + +```text +127.0.0.1:8055 +``` + +### Required files for the webapp + +The webapp does not need raw SQLite databases. It needs: + +```text +models/joint_route_gpt_generator.pth +models/joint_transformer_grade_predictor.pth +data/processed/tokenized/token_metadata.csv +data/processed/tokenized/token_vocab.json +configs/ +images/ +src/climbingboardgpt/ +webapp/ +``` + +### API endpoints + +```text +GET /api/health +GET /api/boards +POST /api/generate +POST /api/predict +``` + +Example generation payload: + +```json +{ + "board": "tb2", + "angle": 40, + "grade": 6, + "temperature": 0.9, + "top_k": 50, + "max_new_tokens": 40 +} +``` + +Example prediction payload: + +```json +{ + "board": "kilter", + "angle": 40, + "frames": "p1127r12p1196r13p1216r13p1388r14" +} +``` + +Board-size-specific generation is a planned future extension. For now, the demo uses the full TB2 12x12 and Kilter 16x12-style background images and placement sets. + + + # License This project is licensed under the MIT License. See the [`LICENSE`](LICENSE) file for details. diff --git a/docker-compose.webapp.yml b/docker-compose.webapp.yml new file mode 100644 index 0000000..2fd5f63 --- /dev/null +++ b/docker-compose.webapp.yml @@ -0,0 +1,16 @@ +services: + climbingboardgpt-webapp: + build: + context: . + dockerfile: Dockerfile.webapp + container_name: climbingboardgpt-webapp + restart: unless-stopped + ports: + - "127.0.0.1:8055:8055" + environment: + CBGPT_DEVICE: "cpu" + CBGPT_TORCH_THREADS: "1" + volumes: + - ./models:/app/models:ro + - ./data/processed/tokenized:/app/data/processed/tokenized:ro + - ./images:/app/images:ro diff --git a/pyproject.toml b/pyproject.toml index 5341fdf..b0e2c07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,14 +7,14 @@ name = "climbingboardgpt" version = "0.2.1" description = "Unified TB2/Kilter transformer route modeling, grade prediction, and GPT-style route generation." readme = "README.md" -requires-python = ">=3.10" +requires-python = "" dependencies = [ - "numpy>=1.24", - "pandas>=2.0", - "scipy>=1.10", - "scikit-learn>=1.3", - "matplotlib>=3.7", - "torch>=2.1", + "numpy", + "pandas", + "scipy", + "scikit-learn", + "matplotlib", + "torch" ] [tool.setuptools.packages.find] diff --git a/requirements.txt b/requirements.txt index c460b5f..a8e98b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,3 @@ scipy scikit-learn matplotlib torch -jupyter -nbformat -boardlib diff --git a/webapp/__pycache__/app.cpython-314.pyc b/webapp/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d423413c5cff728aa2ba78f27fb74a0ebe22829 GIT binary patch literal 39090 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 QKnIkOqKiH!A6sw#KLpG>6aWAK literal 0 HcmV?d00001 diff --git a/webapp/app.py b/webapp/app.py new file mode 100644 index 0000000..a032538 --- /dev/null +++ b/webapp/app.py @@ -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) diff --git a/webapp/static/app.css b/webapp/static/app.css new file mode 100644 index 0000000..d467f8c --- /dev/null +++ b/webapp/static/app.css @@ -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; +} diff --git a/webapp/static/app.js b/webapp/static/app.js new file mode 100644 index 0000000..8a880e1 --- /dev/null +++ b/webapp/static/app.js @@ -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 = `${idx + 1}. ${hold.role}p${hold.placement_id}`; + 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); +}); diff --git a/webapp/static/index.html b/webapp/static/index.html new file mode 100644 index 0000000..18eda8f --- /dev/null +++ b/webapp/static/index.html @@ -0,0 +1,175 @@ + + + + + + ClimbingBoardGPT + + + + + +
+
+
+

Generate a climb

+ + + + + +
+ +
+

Predict grade

+ + + + +
+ +
+

Click holds to build a climb

+ +
+ + +
+

+ Click a hold on the board image. The app appends the corresponding + BoardLib frame token using the selected semantic role. +

+
    +
    + +
    +

    What the controls mean

    +
    +
    Temperature
    +
    Sampling randomness. Lower values are more conservative; higher values are more exploratory.
    + +
    Target V-grade
    +
    The grade token given to the generator. The generated route is also checked by the grade predictor.
    + +
    Known climb
    +
    An exact match against the tokenized dataset: same board, same angle, and same hold-role set.
    + +
    Validity
    +
    A structural check: enough holds, no duplicate placements, at least one start, and at least one finish.
    +
    +
    + +
    +

    How this works

    +

    + Routes are converted into tokens such as + <BOARD_TB2>, <ANGLE_40>, + and <TB2_p652_start>. +

    +

    + The generator samples a token sequence. The grade predictor removes the grade token and estimates difficulty from the board, angle, and hold-role tokens. +

    +
    + + + +
    +

    Data acknowledgement

    +

    + Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter. +

    +
    + +
    +

    Angle scope

    +

    + 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°. +

    +

    + The restriction avoids asking the models to extrapolate into sparse, noisier high-angle data where grades and route distributions are less reliable. +

    +
    + +
    +

    High-grade caveat

    +

    + 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. +

    +
    + +
    +

    To-do

    +

    + 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. +

    +
    +
    + +
    +
    +
    +

    Choose a board and run a request

    +

    The climb overlay will appear below. You can also click holds to build a route for prediction.

    + + +
    + +
    + Board background + +
    + +
    + Raw result JSON +
    {}
    +
    +
    +
    +
    + + + +