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 0000000..1d42341 Binary files /dev/null and b/webapp/__pycache__/app.cpython-314.pyc differ 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 +
    {}
    +
    +
    +
    +
    + + + +