Added & fixed some documentation
This commit is contained in:
@@ -232,6 +232,30 @@ ClimbingBoardGPT/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Developer code map
|
||||||
|
|
||||||
|
Most reusable behavior lives in `src/climbingboardgpt/`:
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `config.py` | Board-specific JSON config loading and role mappings |
|
||||||
|
| `data.py` | SQLite queries and board data loading |
|
||||||
|
| `tokenization.py` | Frames parsing, canonical route ordering, token grammar, vocabulary, token metadata |
|
||||||
|
| `datasets.py` | PyTorch dataset adapters for grade prediction and GPT training |
|
||||||
|
| `models.py` | Transformer encoder regressor and GPT-style route generator |
|
||||||
|
| `generation.py` | Prompt construction, top-k sampling, generated-route validity, frames reconstruction |
|
||||||
|
| `inference.py` | Checkpoint loading and demo/webapp inference helpers |
|
||||||
|
| `evaluation.py` | Validity, novelty, nearest-route, and geometry metrics for generated climbs |
|
||||||
|
| `visualization.py` | Matplotlib board overlays and calibrated board canvases |
|
||||||
|
| `metrics.py`, `grades.py`, `utils.py` | Shared grade mapping, reporting metrics, JSON/split/reproducibility helpers |
|
||||||
|
|
||||||
|
The numbered scripts are the pipeline entry points. The `webapp/` directory is
|
||||||
|
the inference-only FastAPI demo plus the browser-side SVG route builder. The
|
||||||
|
notebooks document the executed analysis runs; the maintained importable code is
|
||||||
|
the package and scripts above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
Create and activate a virtual environment:
|
Create and activate a virtual environment:
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse generation, scoring, and visualization options."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Generate ClimbingBoardGPT routes and save route visualizations.",
|
description="Generate ClimbingBoardGPT routes and save route visualizations.",
|
||||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
@@ -96,6 +97,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
|
|
||||||
def default_background_for_board(board: str) -> Path | None:
|
def default_background_for_board(board: str) -> Path | None:
|
||||||
|
"""Return the bundled board image path for a board when it exists."""
|
||||||
candidates = {
|
candidates = {
|
||||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
||||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
||||||
@@ -105,6 +107,7 @@ def default_background_for_board(board: str) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Generate routes, optionally score them, and save images plus a CSV."""
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
board_config = load_board_for_demo(args.board, config_dir=REPO_ROOT / "configs")
|
board_config = load_board_for_demo(args.board, config_dir=REPO_ROOT / "configs")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Delegate to the generic demo so board-specific wrappers stay tiny.
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(REPO_ROOT / "scripts" / "demo_generate_and_visualize.py"),
|
str(REPO_ROOT / "scripts" / "demo_generate_and_visualize.py"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Delegate to the generic demo so board-specific wrappers stay tiny.
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(REPO_ROOT / "scripts" / "demo_generate_and_visualize.py"),
|
str(REPO_ROOT / "scripts" / "demo_generate_and_visualize.py"),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_
|
|||||||
|
|
||||||
|
|
||||||
def default_background_for_board(board: str) -> Path | None:
|
def default_background_for_board(board: str) -> Path | None:
|
||||||
|
"""Return the bundled board image path for a board when it exists."""
|
||||||
candidates = {
|
candidates = {
|
||||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
||||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
||||||
@@ -55,6 +56,7 @@ def default_background_for_board(board: str) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse board, frames, model, and optional visualization settings."""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Predict climb grade from board, angle, and frames string.",
|
description="Predict climb grade from board, angle, and frames string.",
|
||||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
@@ -99,6 +101,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Predict a frames string's grade and optionally save a route overlay."""
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
board_config = load_board_for_demo(args.board, config_dir=REPO_ROOT / "configs")
|
board_config = load_board_for_demo(args.board, config_dir=REPO_ROOT / "configs")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Delegate to the generic demo so board-specific wrappers stay tiny.
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(REPO_ROOT / "scripts" / "demo_predict_grade.py"),
|
str(REPO_ROOT / "scripts" / "demo_predict_grade.py"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Delegate to the generic demo so board-specific wrappers stay tiny.
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
str(REPO_ROOT / "scripts" / "demo_predict_grade.py"),
|
str(REPO_ROOT / "scripts" / "demo_predict_grade.py"),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"""PyTorch dataset adapters for tokenized climbing-board routes."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
@@ -5,7 +6,15 @@ from torch.utils.data import Dataset
|
|||||||
|
|
||||||
|
|
||||||
class RouteGradeDataset(Dataset):
|
class RouteGradeDataset(Dataset):
|
||||||
|
"""Dataset for transformer encoder grade prediction.
|
||||||
|
|
||||||
|
Each item returns a padded token sequence, a boolean attention mask, the
|
||||||
|
continuous display-difficulty target, and a small amount of route identity
|
||||||
|
metadata used when writing prediction CSVs.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, df, max_len: int, pad_id: int):
|
def __init__(self, df, max_len: int, pad_id: int):
|
||||||
|
"""Store model IDs and labels from a tokenized route DataFrame."""
|
||||||
self.row_ids = df["row_id"].tolist() if "row_id" in df.columns else df.index.tolist()
|
self.row_ids = df["row_id"].tolist() if "row_id" in df.columns else df.index.tolist()
|
||||||
self.ids = df["model_ids"].tolist()
|
self.ids = df["model_ids"].tolist()
|
||||||
self.targets = df["display_difficulty"].astype(float).values
|
self.targets = df["display_difficulty"].astype(float).values
|
||||||
@@ -15,9 +24,11 @@ class RouteGradeDataset(Dataset):
|
|||||||
self.pad_id = int(pad_id)
|
self.pad_id = int(pad_id)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
|
"""Return the number of route examples."""
|
||||||
return len(self.ids)
|
return len(self.ids)
|
||||||
|
|
||||||
def __getitem__(self, idx: int):
|
def __getitem__(self, idx: int):
|
||||||
|
"""Return one padded encoder example and its regression target."""
|
||||||
ids = list(self.ids[idx])[: self.max_len]
|
ids = list(self.ids[idx])[: self.max_len]
|
||||||
mask = [1] * len(ids)
|
mask = [1] * len(ids)
|
||||||
if len(ids) < self.max_len:
|
if len(ids) < self.max_len:
|
||||||
@@ -36,15 +47,25 @@ class RouteGradeDataset(Dataset):
|
|||||||
|
|
||||||
|
|
||||||
class RouteGPTDataset(Dataset):
|
class RouteGPTDataset(Dataset):
|
||||||
|
"""Dataset for causal next-token route generation.
|
||||||
|
|
||||||
|
The full sequence is padded once, then split into ``input_ids`` and
|
||||||
|
``target_ids`` shifted by one position for teacher-forced language-model
|
||||||
|
training.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, df, max_len: int, pad_id: int):
|
def __init__(self, df, max_len: int, pad_id: int):
|
||||||
|
"""Store GPT token ID sequences from a tokenized route DataFrame."""
|
||||||
self.ids = df["gpt_ids"].tolist()
|
self.ids = df["gpt_ids"].tolist()
|
||||||
self.max_len = int(max_len)
|
self.max_len = int(max_len)
|
||||||
self.pad_id = int(pad_id)
|
self.pad_id = int(pad_id)
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
|
"""Return the number of route examples."""
|
||||||
return len(self.ids)
|
return len(self.ids)
|
||||||
|
|
||||||
def __getitem__(self, idx: int):
|
def __getitem__(self, idx: int):
|
||||||
|
"""Return one padded causal-language-model training example."""
|
||||||
ids = list(self.ids[idx])[: self.max_len]
|
ids = list(self.ids[idx])[: self.max_len]
|
||||||
if len(ids) < self.max_len:
|
if len(ids) < self.max_len:
|
||||||
ids += [self.pad_id] * (self.max_len - len(ids))
|
ids += [self.pad_id] * (self.max_len - len(ids))
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""Evaluation utilities for generated climbing-board routes.
|
||||||
|
|
||||||
|
The helpers in this module are intentionally model-agnostic: they work from
|
||||||
|
tokens, frames strings, and token metadata so notebooks, scripts, and tests can
|
||||||
|
reuse the same route validity, novelty, and geometry calculations.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -11,10 +17,12 @@ from .tokenization import parse_tokens, tokens_to_hold_records
|
|||||||
|
|
||||||
|
|
||||||
def parse_token_list(value) -> list[str]:
|
def parse_token_list(value) -> list[str]:
|
||||||
|
"""Compatibility wrapper around the shared token parser."""
|
||||||
return parse_tokens(value)
|
return parse_tokens(value)
|
||||||
|
|
||||||
|
|
||||||
def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]:
|
def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]:
|
||||||
|
"""Compute evaluation-specific route-validity flags from hold records."""
|
||||||
placements = [int(record["placement_id"]) for record in records]
|
placements = [int(record["placement_id"]) for record in records]
|
||||||
roles = [str(record["role"]) for record in records]
|
roles = [str(record["role"]) for record in records]
|
||||||
prefixes = [str(record["board_token_prefix"]) for record in records]
|
prefixes = [str(record["board_token_prefix"]) for record in records]
|
||||||
@@ -51,16 +59,19 @@ def validity_from_records(records: list[dict[str, object]], requested_board_pref
|
|||||||
|
|
||||||
|
|
||||||
def frames_to_holds(frames: str | None) -> list[tuple[int, int]]:
|
def frames_to_holds(frames: str | None) -> list[tuple[int, int]]:
|
||||||
|
"""Parse a frames string into ``(placement_id, role_id)`` pairs."""
|
||||||
if not isinstance(frames, str):
|
if not isinstance(frames, str):
|
||||||
return []
|
return []
|
||||||
return [(int(p), int(r)) for p, r in re.findall(r"p(\d+)r(\d+)", frames)]
|
return [(int(p), int(r)) for p, r in re.findall(r"p(\d+)r(\d+)", frames)]
|
||||||
|
|
||||||
|
|
||||||
def holds_to_placement_set(holds: Iterable[tuple[int, int]]) -> frozenset[int]:
|
def holds_to_placement_set(holds: Iterable[tuple[int, int]]) -> frozenset[int]:
|
||||||
|
"""Drop role IDs and represent a route by its unique placement IDs."""
|
||||||
return frozenset(int(placement_id) for placement_id, _ in holds)
|
return frozenset(int(placement_id) for placement_id, _ in holds)
|
||||||
|
|
||||||
|
|
||||||
def jaccard(a: frozenset[int], b: frozenset[int]) -> float:
|
def jaccard(a: frozenset[int], b: frozenset[int]) -> float:
|
||||||
|
"""Return Jaccard similarity between two placement sets."""
|
||||||
if not a and not b:
|
if not a and not b:
|
||||||
return 1.0
|
return 1.0
|
||||||
if not a or not b:
|
if not a or not b:
|
||||||
@@ -73,6 +84,7 @@ def nearest_real_route_same_board(
|
|||||||
generated_board_key: str,
|
generated_board_key: str,
|
||||||
real_df: pd.DataFrame,
|
real_df: pd.DataFrame,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
"""Find the most similar real route on the same board by Jaccard score."""
|
||||||
board_frame = real_df[real_df["board_key"] == generated_board_key]
|
board_frame = real_df[real_df["board_key"] == generated_board_key]
|
||||||
if board_frame.empty:
|
if board_frame.empty:
|
||||||
return {
|
return {
|
||||||
@@ -100,6 +112,7 @@ def nearest_real_route_same_board(
|
|||||||
|
|
||||||
|
|
||||||
def build_placement_coords(df_token_meta: pd.DataFrame) -> dict[tuple[str, int], dict[str, float]]:
|
def build_placement_coords(df_token_meta: pd.DataFrame) -> dict[tuple[str, int], dict[str, float]]:
|
||||||
|
"""Build a placement-coordinate lookup from token metadata."""
|
||||||
hold_meta = df_token_meta[df_token_meta["kind"] == "hold"].dropna(subset=["placement_id"]).copy()
|
hold_meta = df_token_meta[df_token_meta["kind"] == "hold"].dropna(subset=["placement_id"]).copy()
|
||||||
coords = {}
|
coords = {}
|
||||||
for _, row in hold_meta.drop_duplicates(["board_key", "placement_id"]).iterrows():
|
for _, row in hold_meta.drop_duplicates(["board_key", "placement_id"]).iterrows():
|
||||||
@@ -116,6 +129,12 @@ def simple_route_features(
|
|||||||
records: list[dict[str, object]],
|
records: list[dict[str, object]],
|
||||||
placement_coords: dict[tuple[str, int], dict[str, float]],
|
placement_coords: dict[tuple[str, int], dict[str, float]],
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
|
"""Compute simple geometric route features from hold coordinates.
|
||||||
|
|
||||||
|
These features are descriptive rather than a full climbing-physics model:
|
||||||
|
height/width describe route spread, and hand-reach distances summarize the
|
||||||
|
pairwise spacing among start/middle/finish holds.
|
||||||
|
"""
|
||||||
rows = []
|
rows = []
|
||||||
for record in records:
|
for record in records:
|
||||||
key = (str(board_key), int(record["placement_id"]))
|
key = (str(board_key), int(record["placement_id"]))
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"""Sampling and structural-validity helpers for route generation."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -9,6 +10,7 @@ from .tokenization import tokens_to_hold_records
|
|||||||
|
|
||||||
|
|
||||||
def top_k_filter(logits: torch.Tensor, k: int | None) -> torch.Tensor:
|
def top_k_filter(logits: torch.Tensor, k: int | None) -> torch.Tensor:
|
||||||
|
"""Mask logits outside the top ``k`` choices for each batch row."""
|
||||||
if k is None or k <= 0 or k >= logits.size(-1):
|
if k is None or k <= 0 or k >= logits.size(-1):
|
||||||
return logits
|
return logits
|
||||||
values, _ = torch.topk(logits, k)
|
values, _ = torch.topk(logits, k)
|
||||||
@@ -27,6 +29,11 @@ def sample_ids(
|
|||||||
eos_id: int | None = None,
|
eos_id: int | None = None,
|
||||||
forbidden_ids: Iterable[int] | None = None,
|
forbidden_ids: Iterable[int] | None = None,
|
||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
|
"""Autoregressively sample token IDs from a trained route generator.
|
||||||
|
|
||||||
|
The returned list includes the prompt IDs and all sampled IDs up to either
|
||||||
|
``max_new_tokens`` or the first sampled ``eos_id``.
|
||||||
|
"""
|
||||||
model.eval()
|
model.eval()
|
||||||
sequence = torch.tensor([prompt_ids], dtype=torch.long, device=device)
|
sequence = torch.tensor([prompt_ids], dtype=torch.long, device=device)
|
||||||
forbidden_ids = set(forbidden_ids or [])
|
forbidden_ids = set(forbidden_ids or [])
|
||||||
@@ -36,6 +43,8 @@ def sample_ids(
|
|||||||
logits, _ = model(idx_cond)
|
logits, _ = model(idx_cond)
|
||||||
logits = logits[:, -1, :] / max(temperature, 1e-6)
|
logits = logits[:, -1, :] / max(temperature, 1e-6)
|
||||||
|
|
||||||
|
# Special tokens like <PAD> and <CLS> are valid vocabulary entries but
|
||||||
|
# should never be emitted in the middle of a generated climb.
|
||||||
for token_id in forbidden_ids:
|
for token_id in forbidden_ids:
|
||||||
logits[:, int(token_id)] = -float("inf")
|
logits[:, int(token_id)] = -float("inf")
|
||||||
|
|
||||||
@@ -51,6 +60,7 @@ def sample_ids(
|
|||||||
|
|
||||||
|
|
||||||
def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]:
|
def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]:
|
||||||
|
"""Build the conditioning prefix used before sampling hold tokens."""
|
||||||
return [
|
return [
|
||||||
"<BOS>",
|
"<BOS>",
|
||||||
f"<BOARD_{board_prefix}>",
|
f"<BOARD_{board_prefix}>",
|
||||||
@@ -60,10 +70,12 @@ def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
def hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
||||||
|
"""Extract hold records from generated tokens."""
|
||||||
return tokens_to_hold_records(tokens)
|
return tokens_to_hold_records(tokens)
|
||||||
|
|
||||||
|
|
||||||
def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]:
|
def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]:
|
||||||
|
"""Summarize basic structural validity for generated token sequences."""
|
||||||
records = hold_records(tokens)
|
records = hold_records(tokens)
|
||||||
placements = [record["placement_id"] for record in records]
|
placements = [record["placement_id"] for record in records]
|
||||||
roles = [record["role"] for record in records]
|
roles = [record["role"] for record in records]
|
||||||
@@ -94,6 +106,11 @@ def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None =
|
|||||||
|
|
||||||
|
|
||||||
def generated_tokens_to_frames(tokens: Iterable[str], role_name_to_id: dict[str, int], board_prefix: str | None = None) -> str:
|
def generated_tokens_to_frames(tokens: Iterable[str], role_name_to_id: dict[str, int], board_prefix: str | None = None) -> str:
|
||||||
|
"""Convert generated hold tokens back into a frames string.
|
||||||
|
|
||||||
|
Duplicate placements and unknown roles are skipped, matching the forgiving
|
||||||
|
cleanup used by the demo scripts and webapp.
|
||||||
|
"""
|
||||||
pieces = []
|
pieces = []
|
||||||
seen = set()
|
seen = set()
|
||||||
for record in hold_records(tokens):
|
for record in hold_records(tokens):
|
||||||
@@ -121,6 +138,7 @@ def generate_one(
|
|||||||
top_k: int | None = 50,
|
top_k: int | None = 50,
|
||||||
max_new_tokens: int = 40,
|
max_new_tokens: int = 40,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
"""Generate one route and return tokens, frames, request metadata, validity."""
|
||||||
unk_id = stoi["<UNK>"]
|
unk_id = stoi["<UNK>"]
|
||||||
eos_id = stoi["<EOS>"]
|
eos_id = stoi["<EOS>"]
|
||||||
forbidden_ids = [
|
forbidden_ids = [
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
"""Grade-scale helpers for BoardLib display difficulty and grouped V grades."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# BoardLib display difficulties are integer-like values. This project groups
|
||||||
|
# them into V grades so TB2 and Kilter can share a compact grade-token space.
|
||||||
GRADE_TO_V = {
|
GRADE_TO_V = {
|
||||||
10: 0, 11: 0, 12: 0,
|
10: 0, 11: 0, 12: 0,
|
||||||
13: 1, 14: 1,
|
13: 1, 14: 1,
|
||||||
@@ -22,10 +25,12 @@ GRADE_TO_V = {
|
|||||||
|
|
||||||
|
|
||||||
def to_grouped_v(display_difficulty: float) -> int:
|
def to_grouped_v(display_difficulty: float) -> int:
|
||||||
|
"""Map a continuous display difficulty to the nearest grouped V grade."""
|
||||||
rounded = int(round(float(display_difficulty)))
|
rounded = int(round(float(display_difficulty)))
|
||||||
rounded = max(min(rounded, max(GRADE_TO_V)), min(GRADE_TO_V))
|
rounded = max(min(rounded, max(GRADE_TO_V)), min(GRADE_TO_V))
|
||||||
return GRADE_TO_V[rounded]
|
return GRADE_TO_V[rounded]
|
||||||
|
|
||||||
|
|
||||||
def grade_token(display_difficulty: float) -> str:
|
def grade_token(display_difficulty: float) -> str:
|
||||||
|
"""Return the grade-conditioning token for a display difficulty value."""
|
||||||
return f"<GRADE_V{to_grouped_v(display_difficulty)}>"
|
return f"<GRADE_V{to_grouped_v(display_difficulty)}>"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"""Metrics used to evaluate continuous grade predictions."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -10,6 +11,7 @@ from .grades import to_grouped_v
|
|||||||
|
|
||||||
|
|
||||||
def regression_metrics(y_true, y_pred) -> dict[str, float]:
|
def regression_metrics(y_true, y_pred) -> dict[str, float]:
|
||||||
|
"""Compute difficulty-scale and grouped-V-grade prediction metrics."""
|
||||||
y_true = np.asarray(y_true)
|
y_true = np.asarray(y_true)
|
||||||
y_pred = np.asarray(y_pred)
|
y_pred = np.asarray(y_pred)
|
||||||
true_v = np.asarray([to_grouped_v(x) for x in y_true])
|
true_v = np.asarray([to_grouped_v(x) for x in y_true])
|
||||||
@@ -28,6 +30,7 @@ def regression_metrics(y_true, y_pred) -> dict[str, float]:
|
|||||||
|
|
||||||
|
|
||||||
def metrics_by_board(pred_df: pd.DataFrame) -> pd.DataFrame:
|
def metrics_by_board(pred_df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Compute regression metrics separately for each board in a prediction table."""
|
||||||
rows = []
|
rows = []
|
||||||
for board_key, frame in pred_df.groupby("board_key"):
|
for board_key, frame in pred_df.groupby("board_key"):
|
||||||
metrics = regression_metrics(frame["y_true"].values, frame["y_pred"].values)
|
metrics = regression_metrics(frame["y_true"].values, frame["y_pred"].values)
|
||||||
@@ -36,6 +39,7 @@ def metrics_by_board(pred_df: pd.DataFrame) -> pd.DataFrame:
|
|||||||
|
|
||||||
|
|
||||||
def print_metrics(name: str, metrics: dict[str, float]) -> None:
|
def print_metrics(name: str, metrics: dict[str, float]) -> None:
|
||||||
|
"""Pretty-print a metric dictionary in the training scripts."""
|
||||||
print(name)
|
print(name)
|
||||||
print("-" * len(name))
|
print("-" * len(name))
|
||||||
for key, value in metrics.items():
|
for key, value in metrics.items():
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"""Neural network definitions for grade prediction and route generation."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
@@ -6,7 +7,13 @@ import torch.nn.functional as F
|
|||||||
|
|
||||||
|
|
||||||
class JointRouteTransformerRegressor(nn.Module):
|
class JointRouteTransformerRegressor(nn.Module):
|
||||||
"""Transformer encoder for joint TB2/Kilter route difficulty prediction."""
|
"""Transformer encoder for joint TB2/Kilter route difficulty prediction.
|
||||||
|
|
||||||
|
Inputs are token IDs plus an attention mask. Token, position, and learned
|
||||||
|
projections of coordinate metadata are added before the encoder. The first
|
||||||
|
``<CLS>`` position is then used as a pooled route representation for scalar
|
||||||
|
difficulty regression.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -20,6 +27,7 @@ class JointRouteTransformerRegressor(nn.Module):
|
|||||||
dropout: float = 0.10,
|
dropout: float = 0.10,
|
||||||
pad_id: int = 0,
|
pad_id: int = 0,
|
||||||
):
|
):
|
||||||
|
"""Create the encoder, coordinate projection, and regression head."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.vocab_size = vocab_size
|
self.vocab_size = vocab_size
|
||||||
self.max_len = max_len
|
self.max_len = max_len
|
||||||
@@ -55,9 +63,12 @@ class JointRouteTransformerRegressor(nn.Module):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
||||||
|
"""Return one continuous difficulty prediction per input sequence."""
|
||||||
batch_size, seq_len = input_ids.shape
|
batch_size, seq_len = input_ids.shape
|
||||||
positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0).expand(batch_size, seq_len)
|
positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0).expand(batch_size, seq_len)
|
||||||
|
|
||||||
|
# Coordinate features are indexed by token ID, so every occurrence of a
|
||||||
|
# hold token gets the same physical x/y hint wherever it appears.
|
||||||
x = self.token_emb(input_ids) + self.pos_emb(positions)
|
x = self.token_emb(input_ids) + self.pos_emb(positions)
|
||||||
x = x + self.coord_proj(self.coord_features[input_ids])
|
x = x + self.coord_proj(self.coord_features[input_ids])
|
||||||
|
|
||||||
@@ -70,7 +81,11 @@ class JointRouteTransformerRegressor(nn.Module):
|
|||||||
|
|
||||||
|
|
||||||
class JointRouteGPT(nn.Module):
|
class JointRouteGPT(nn.Module):
|
||||||
"""Tiny GPT-style causal transformer for board-conditioned route generation."""
|
"""Tiny GPT-style causal transformer for board-conditioned route generation.
|
||||||
|
|
||||||
|
PyTorch's ``TransformerEncoder`` is used with a causal mask, which makes it
|
||||||
|
behave like a decoder-only language model for short route sequences.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -82,6 +97,7 @@ class JointRouteGPT(nn.Module):
|
|||||||
dropout: float = 0.10,
|
dropout: float = 0.10,
|
||||||
pad_id: int = 0,
|
pad_id: int = 0,
|
||||||
):
|
):
|
||||||
|
"""Create the token/position embeddings, causal blocks, and LM head."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.vocab_size = vocab_size
|
self.vocab_size = vocab_size
|
||||||
self.block_size = block_size
|
self.block_size = block_size
|
||||||
@@ -114,6 +130,7 @@ class JointRouteGPT(nn.Module):
|
|||||||
idx: torch.Tensor,
|
idx: torch.Tensor,
|
||||||
targets: torch.Tensor | None = None,
|
targets: torch.Tensor | None = None,
|
||||||
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
||||||
|
"""Return next-token logits and, when targets are supplied, CE loss."""
|
||||||
_, seq_len = idx.shape
|
_, seq_len = idx.shape
|
||||||
if seq_len > self.block_size:
|
if seq_len > self.block_size:
|
||||||
idx = idx[:, -self.block_size :]
|
idx = idx[:, -self.block_size :]
|
||||||
@@ -126,6 +143,8 @@ class JointRouteGPT(nn.Module):
|
|||||||
torch.ones(seq_len, seq_len, device=idx.device, dtype=torch.bool),
|
torch.ones(seq_len, seq_len, device=idx.device, dtype=torch.bool),
|
||||||
diagonal=1,
|
diagonal=1,
|
||||||
)
|
)
|
||||||
|
# Padding masks suppress attention to right-padded context tokens while
|
||||||
|
# the causal mask suppresses attention to future positions.
|
||||||
key_padding_mask = idx.eq(self.pad_id)
|
key_padding_mask = idx.eq(self.pad_id)
|
||||||
|
|
||||||
h = self.blocks(
|
h = self.blocks(
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
"""Path discovery helpers for scripts that can be launched from any directory."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def find_project_root(start: str | Path | None = None) -> Path:
|
def find_project_root(start: str | Path | None = None) -> Path:
|
||||||
|
"""Walk upward until the repository root markers are found.
|
||||||
|
|
||||||
|
The project root is identified by both ``pyproject.toml`` and ``configs``.
|
||||||
|
If neither marker pair is found, the resolved starting directory is returned
|
||||||
|
so callers still have a deterministic base path.
|
||||||
|
"""
|
||||||
current = Path(start).resolve() if start is not None else Path.cwd().resolve()
|
current = Path(start).resolve() if start is not None else Path.cwd().resolve()
|
||||||
for candidate in [current, *current.parents]:
|
for candidate in [current, *current.parents]:
|
||||||
if (candidate / "pyproject.toml").exists() and (candidate / "configs").exists():
|
if (candidate / "pyproject.toml").exists() and (candidate / "configs").exists():
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""Route tokenization helpers shared by training, evaluation, and demos.
|
||||||
|
|
||||||
|
The project represents every climb as a short symbolic sequence. Board,
|
||||||
|
angle, grade, and hold-role information are all encoded as tokens, while hold
|
||||||
|
tokens are namespaced by board so placement IDs from different products cannot
|
||||||
|
collide.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -19,6 +26,8 @@ SPECIAL_TOKENS = [
|
|||||||
"<MASK>",
|
"<MASK>",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# The token grammar is intentionally centralized here so training, generation,
|
||||||
|
# evaluation, and the webapp parse the same strings in the same way.
|
||||||
ANGLE_TOKEN_PATTERN = re.compile(r"^<ANGLE_(-?\d+)>$")
|
ANGLE_TOKEN_PATTERN = re.compile(r"^<ANGLE_(-?\d+)>$")
|
||||||
GRADE_TOKEN_PATTERN = re.compile(r"^<GRADE_V(\d+)>$")
|
GRADE_TOKEN_PATTERN = re.compile(r"^<GRADE_V(\d+)>$")
|
||||||
BOARD_TOKEN_PATTERN = re.compile(r"^<BOARD_([A-Z0-9_]+)>$")
|
BOARD_TOKEN_PATTERN = re.compile(r"^<BOARD_([A-Z0-9_]+)>$")
|
||||||
@@ -34,6 +43,12 @@ ROLE_SORT_ORDER = {
|
|||||||
|
|
||||||
|
|
||||||
def parse_frames(frames_str: str | None) -> list[tuple[int, int]]:
|
def parse_frames(frames_str: str | None) -> list[tuple[int, int]]:
|
||||||
|
"""Parse a frames string into ``(placement_id, role_id)`` pairs.
|
||||||
|
|
||||||
|
Frames strings are compact concatenations such as ``p344r5p369r6``. Invalid
|
||||||
|
or missing input returns an empty list so callers can skip unusable climbs
|
||||||
|
without special-case exception handling.
|
||||||
|
"""
|
||||||
if not isinstance(frames_str, str):
|
if not isinstance(frames_str, str):
|
||||||
return []
|
return []
|
||||||
matches = re.findall(r"p(\d+)r(\d+)", frames_str)
|
matches = re.findall(r"p(\d+)r(\d+)", frames_str)
|
||||||
@@ -78,6 +93,7 @@ def tokens_to_hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
|||||||
|
|
||||||
|
|
||||||
def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], dict]:
|
def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], dict]:
|
||||||
|
"""Build a coordinate/metadata lookup keyed by ``(board_key, placement_id)``."""
|
||||||
rows = {}
|
rows = {}
|
||||||
for _, row in df_placements.iterrows():
|
for _, row in df_placements.iterrows():
|
||||||
key = (str(row["board_key"]), int(row["placement_id"]))
|
key = (str(row["board_key"]), int(row["placement_id"]))
|
||||||
@@ -86,6 +102,7 @@ def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int],
|
|||||||
|
|
||||||
|
|
||||||
def role_name(role_id: int, config: BoardConfig) -> str:
|
def role_name(role_id: int, config: BoardConfig) -> str:
|
||||||
|
"""Map a board-specific numeric role ID to a shared semantic role name."""
|
||||||
return config.role_id_to_name.get(int(role_id), "unknown")
|
return config.role_id_to_name.get(int(role_id), "unknown")
|
||||||
|
|
||||||
|
|
||||||
@@ -94,6 +111,7 @@ def placement_xy(
|
|||||||
placement_id: int,
|
placement_id: int,
|
||||||
placement_lookup: dict[tuple[str, int], dict],
|
placement_lookup: dict[tuple[str, int], dict],
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
|
"""Return raw board coordinates for a placement, or NaNs if unknown."""
|
||||||
row = placement_lookup.get((str(board_key), int(placement_id)))
|
row = placement_lookup.get((str(board_key), int(placement_id)))
|
||||||
if row is None:
|
if row is None:
|
||||||
return (float("nan"), float("nan"))
|
return (float("nan"), float("nan"))
|
||||||
@@ -105,7 +123,15 @@ def canonicalize_holds(
|
|||||||
config: BoardConfig,
|
config: BoardConfig,
|
||||||
placement_lookup: dict[tuple[str, int], dict],
|
placement_lookup: dict[tuple[str, int], dict],
|
||||||
) -> list[tuple[int, int]]:
|
) -> list[tuple[int, int]]:
|
||||||
|
"""Sort holds into the canonical route order used by all model inputs.
|
||||||
|
|
||||||
|
Frames preserve setter/storage order, which is not always stable
|
||||||
|
across routes or boards. Canonical ordering keeps starts first, hand/foot
|
||||||
|
holds in a bottom-to-top scan, and finishes last, giving the models a more
|
||||||
|
consistent sequence grammar.
|
||||||
|
"""
|
||||||
def key(pair: tuple[int, int]):
|
def key(pair: tuple[int, int]):
|
||||||
|
"""Sort by semantic role, then board position, then placement ID."""
|
||||||
placement_id, role_id = pair
|
placement_id, role_id = pair
|
||||||
x, y = placement_xy(config.board_key, placement_id, placement_lookup)
|
x, y = placement_xy(config.board_key, placement_id, placement_lookup)
|
||||||
name = role_name(role_id, config)
|
name = role_name(role_id, config)
|
||||||
@@ -120,10 +146,12 @@ def canonicalize_holds(
|
|||||||
|
|
||||||
|
|
||||||
def board_token(config: BoardConfig) -> str:
|
def board_token(config: BoardConfig) -> str:
|
||||||
|
"""Return the special conditioning token for a board config."""
|
||||||
return f"<BOARD_{config.token_prefix}>"
|
return f"<BOARD_{config.token_prefix}>"
|
||||||
|
|
||||||
|
|
||||||
def angle_token(angle: float) -> str:
|
def angle_token(angle: float) -> str:
|
||||||
|
"""Round a wall angle into the shared angle-token format."""
|
||||||
return f"<ANGLE_{int(round(float(angle)))}>"
|
return f"<ANGLE_{int(round(float(angle)))}>"
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +160,7 @@ def hold_token(
|
|||||||
role_id: int,
|
role_id: int,
|
||||||
config: BoardConfig,
|
config: BoardConfig,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Return a board-namespaced hold token for a placement and role."""
|
||||||
semantic_role = role_name(role_id, config)
|
semantic_role = role_name(role_id, config)
|
||||||
return f"<{config.token_prefix}_p{int(placement_id)}_{semantic_role}>"
|
return f"<{config.token_prefix}_p{int(placement_id)}_{semantic_role}>"
|
||||||
|
|
||||||
@@ -143,6 +172,12 @@ def tokenize_route(
|
|||||||
include_grade: bool = True,
|
include_grade: bool = True,
|
||||||
canonical: bool = True,
|
canonical: bool = True,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
"""Tokenize one climb row into the sequence consumed by the models.
|
||||||
|
|
||||||
|
``include_grade=True`` is used for GPT-style generation, where the target
|
||||||
|
grade is a conditioning token. ``include_grade=False`` is used for grade
|
||||||
|
prediction so the model cannot read the answer from its input.
|
||||||
|
"""
|
||||||
holds = parse_frames(row["frames"])
|
holds = parse_frames(row["frames"])
|
||||||
if canonical:
|
if canonical:
|
||||||
holds = canonicalize_holds(holds, config, placement_lookup)
|
holds = canonicalize_holds(holds, config, placement_lookup)
|
||||||
@@ -165,6 +200,12 @@ def build_route_records(
|
|||||||
configs_by_key: dict[str, BoardConfig],
|
configs_by_key: dict[str, BoardConfig],
|
||||||
placement_lookup: dict[tuple[str, int], dict],
|
placement_lookup: dict[tuple[str, int], dict],
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
|
"""Create one training/evaluation record per climb-angle row.
|
||||||
|
|
||||||
|
The returned frame keeps both human-readable route metadata and model-ready
|
||||||
|
token sequences, which lets downstream scripts save compact CSV summaries
|
||||||
|
while still retaining the richer JSONL training artifacts.
|
||||||
|
"""
|
||||||
records: list[dict] = []
|
records: list[dict] = []
|
||||||
|
|
||||||
for _, row in df_climbs.iterrows():
|
for _, row in df_climbs.iterrows():
|
||||||
@@ -230,6 +271,7 @@ def build_route_records(
|
|||||||
|
|
||||||
|
|
||||||
def build_vocab(df_routes: pd.DataFrame) -> tuple[list[str], dict[str, int], dict[int, str]]:
|
def build_vocab(df_routes: pd.DataFrame) -> tuple[list[str], dict[str, int], dict[int, str]]:
|
||||||
|
"""Build the shared token vocabulary from grade-conditioned sequences."""
|
||||||
all_tokens: list[str] = []
|
all_tokens: list[str] = []
|
||||||
for tokens in df_routes["tokens_with_grade"]:
|
for tokens in df_routes["tokens_with_grade"]:
|
||||||
all_tokens.extend(tokens)
|
all_tokens.extend(tokens)
|
||||||
@@ -245,11 +287,13 @@ def build_vocab(df_routes: pd.DataFrame) -> tuple[list[str], dict[str, int], dic
|
|||||||
|
|
||||||
|
|
||||||
def encode(tokens: Iterable[str], stoi: dict[str, int]) -> list[int]:
|
def encode(tokens: Iterable[str], stoi: dict[str, int]) -> list[int]:
|
||||||
|
"""Convert tokens to integer IDs, using ``<UNK>`` for unseen tokens."""
|
||||||
unk_id = stoi["<UNK>"]
|
unk_id = stoi["<UNK>"]
|
||||||
return [stoi.get(token, unk_id) for token in tokens]
|
return [stoi.get(token, unk_id) for token in tokens]
|
||||||
|
|
||||||
|
|
||||||
def decode(ids: Iterable[int], itos: dict[int, str]) -> list[str]:
|
def decode(ids: Iterable[int], itos: dict[int, str]) -> list[str]:
|
||||||
|
"""Convert integer IDs back to token strings."""
|
||||||
return [itos.get(int(idx), "<UNK>") for idx in ids]
|
return [itos.get(int(idx), "<UNK>") for idx in ids]
|
||||||
|
|
||||||
|
|
||||||
@@ -260,6 +304,12 @@ def build_token_metadata(
|
|||||||
placement_lookup: dict[tuple[str, int], dict],
|
placement_lookup: dict[tuple[str, int], dict],
|
||||||
configs_by_prefix: dict[str, BoardConfig],
|
configs_by_prefix: dict[str, BoardConfig],
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
|
"""Build per-token metadata used for coordinate features and plotting.
|
||||||
|
|
||||||
|
Hold tokens receive raw coordinates, normalized coordinates in ``[-1, 1]``,
|
||||||
|
role labels, and board identity. Non-hold tokens keep neutral coordinate
|
||||||
|
features so the grade predictor can safely index every token ID.
|
||||||
|
"""
|
||||||
bounds = {}
|
bounds = {}
|
||||||
for board_key, frame in df_placements.groupby("board_key"):
|
for board_key, frame in df_placements.groupby("board_key"):
|
||||||
xs = frame["x"].astype(float)
|
xs = frame["x"].astype(float)
|
||||||
@@ -272,6 +322,7 @@ def build_token_metadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
def normalize(value: float, lo: float, hi: float) -> float:
|
def normalize(value: float, lo: float, hi: float) -> float:
|
||||||
|
"""Scale one coordinate into ``[-1, 1]`` with safe missing-value handling."""
|
||||||
if pd.isna(value) or hi == lo:
|
if pd.isna(value) or hi == lo:
|
||||||
return 0.0
|
return 0.0
|
||||||
return 2 * ((float(value) - lo) / (hi - lo)) - 1
|
return 2 * ((float(value) - lo) / (hi - lo)) - 1
|
||||||
@@ -353,6 +404,7 @@ def vocab_payload(
|
|||||||
itos: dict[int, str],
|
itos: dict[int, str],
|
||||||
configs_by_key: dict[str, BoardConfig],
|
configs_by_key: dict[str, BoardConfig],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Package vocabulary and board metadata for JSON serialization."""
|
||||||
return {
|
return {
|
||||||
"stoi": stoi,
|
"stoi": stoi,
|
||||||
"itos": {str(k): v for k, v in itos.items()},
|
"itos": {str(k): v for k, v in itos.items()},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
"""Small shared utilities for reproducibility, JSON output, and data splits."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -11,6 +12,7 @@ from sklearn.model_selection import train_test_split
|
|||||||
|
|
||||||
|
|
||||||
def set_seed(seed: int) -> None:
|
def set_seed(seed: int) -> None:
|
||||||
|
"""Seed Python, NumPy, and PyTorch when PyTorch is installed."""
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
np.random.seed(seed)
|
np.random.seed(seed)
|
||||||
try:
|
try:
|
||||||
@@ -23,6 +25,7 @@ def set_seed(seed: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def json_safe(obj: Any) -> Any:
|
def json_safe(obj: Any) -> Any:
|
||||||
|
"""Convert NumPy/pandas values into JSON-serializable Python objects."""
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {str(k): json_safe(v) for k, v in obj.items()}
|
return {str(k): json_safe(v) for k, v in obj.items()}
|
||||||
if isinstance(obj, (list, tuple)):
|
if isinstance(obj, (list, tuple)):
|
||||||
@@ -44,6 +47,7 @@ def json_safe(obj: Any) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def write_json(path: str | Path, payload: Any) -> None:
|
def write_json(path: str | Path, payload: Any) -> None:
|
||||||
|
"""Write an object as indented UTF-8 JSON after ``json_safe`` cleanup."""
|
||||||
path = Path(path)
|
path = Path(path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(json.dumps(json_safe(payload), indent=2), encoding="utf-8")
|
path.write_text(json.dumps(json_safe(payload), indent=2), encoding="utf-8")
|
||||||
@@ -55,6 +59,12 @@ def safe_train_test_split(
|
|||||||
random_state: int,
|
random_state: int,
|
||||||
stratify_col: str | None = None,
|
stratify_col: str | None = None,
|
||||||
):
|
):
|
||||||
|
"""Split a DataFrame with optional stratification and graceful fallback.
|
||||||
|
|
||||||
|
scikit-learn raises when a requested stratum is too small. The tokenization
|
||||||
|
pipeline prefers stratified splits when possible, but falls back to an
|
||||||
|
unstratified split rather than failing on tiny smoke-test subsets.
|
||||||
|
"""
|
||||||
stratify = None
|
stratify = None
|
||||||
if stratify_col is not None and stratify_col in df.columns:
|
if stratify_col is not None and stratify_col in df.columns:
|
||||||
counts = df[stratify_col].value_counts()
|
counts = df[stratify_col].value_counts()
|
||||||
@@ -110,6 +120,7 @@ def assign_group_splits(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def key_frame(frame: pd.DataFrame) -> set[tuple]:
|
def key_frame(frame: pd.DataFrame) -> set[tuple]:
|
||||||
|
"""Return stringified group keys so pandas dtypes cannot affect joins."""
|
||||||
return set(map(tuple, frame[group_cols].astype(str).values.tolist()))
|
return set(map(tuple, frame[group_cols].astype(str).values.tolist()))
|
||||||
|
|
||||||
train_keys = key_frame(train_groups)
|
train_keys = key_frame(train_groups)
|
||||||
@@ -117,6 +128,7 @@ def assign_group_splits(
|
|||||||
test_keys = key_frame(test_groups)
|
test_keys = key_frame(test_groups)
|
||||||
|
|
||||||
def split_for_row(row) -> str:
|
def split_for_row(row) -> str:
|
||||||
|
"""Map one original row back to its group-level split assignment."""
|
||||||
key = tuple(str(row[col]) for col in group_cols)
|
key = tuple(str(row[col]) for col in group_cols)
|
||||||
if key in train_keys:
|
if key in train_keys:
|
||||||
return "train"
|
return "train"
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ def board_canvas_settings(board_key: str, df_token_meta: pd.DataFrame | None = N
|
|||||||
|
|
||||||
|
|
||||||
def _board_holds(df_token_meta: pd.DataFrame, board_key: str) -> pd.DataFrame:
|
def _board_holds(df_token_meta: pd.DataFrame, board_key: str) -> pd.DataFrame:
|
||||||
|
"""Return one metadata row per plotted hold for a board."""
|
||||||
holds = df_token_meta[
|
holds = df_token_meta[
|
||||||
(df_token_meta["kind"] == "hold")
|
(df_token_meta["kind"] == "hold")
|
||||||
& (df_token_meta["board_key"].astype(str) == str(board_key))
|
& (df_token_meta["board_key"].astype(str) == str(board_key))
|
||||||
@@ -118,6 +119,7 @@ def _route_with_coords(
|
|||||||
df_token_meta: pd.DataFrame,
|
df_token_meta: pd.DataFrame,
|
||||||
board_key: str,
|
board_key: str,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
|
"""Attach x/y coordinates to route hold records using token metadata."""
|
||||||
holds = _board_holds(df_token_meta, board_key)
|
holds = _board_holds(df_token_meta, board_key)
|
||||||
coords = holds[["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
|
coords = holds[["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
|
||||||
["board_key", "placement_id"]
|
["board_key", "placement_id"]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from climbingboardgpt.tokenization import (
|
|||||||
|
|
||||||
|
|
||||||
class CoreBehaviorTest(unittest.TestCase):
|
class CoreBehaviorTest(unittest.TestCase):
|
||||||
|
"""Smoke tests for token grammar, validity checks, and route matching."""
|
||||||
|
|
||||||
def test_to_grouped_v_clamps_and_maps_display_difficulty(self):
|
def test_to_grouped_v_clamps_and_maps_display_difficulty(self):
|
||||||
self.assertEqual(to_grouped_v(10), 0)
|
self.assertEqual(to_grouped_v(10), 0)
|
||||||
self.assertEqual(to_grouped_v(22), 6)
|
self.assertEqual(to_grouped_v(22), 6)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ from climbingboardgpt.utils import json_safe
|
|||||||
from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records
|
from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records
|
||||||
|
|
||||||
|
|
||||||
|
# Environment variables keep deployment-specific paths and resource limits out
|
||||||
|
# of code, while the defaults make a local checkout runnable without config.
|
||||||
DEVICE = os.getenv("CBGPT_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
|
DEVICE = os.getenv("CBGPT_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
TORCH_THREADS = os.getenv("CBGPT_TORCH_THREADS")
|
TORCH_THREADS = os.getenv("CBGPT_TORCH_THREADS")
|
||||||
TORCH_THREADS_INT = int(TORCH_THREADS) if TORCH_THREADS else None
|
TORCH_THREADS_INT = int(TORCH_THREADS) if TORCH_THREADS else None
|
||||||
@@ -66,6 +68,8 @@ BOARD_IMAGE_PATHS = {
|
|||||||
|
|
||||||
|
|
||||||
class GenerateRequest(BaseModel):
|
class GenerateRequest(BaseModel):
|
||||||
|
"""JSON body for ``POST /api/generate``."""
|
||||||
|
|
||||||
board: str = Field(..., pattern="^(tb2|kilter)$")
|
board: str = Field(..., pattern="^(tb2|kilter)$")
|
||||||
angle: int = Field(40, ge=0, le=80)
|
angle: int = Field(40, ge=0, le=80)
|
||||||
grade: int = Field(6, ge=0, le=16)
|
grade: int = Field(6, ge=0, le=16)
|
||||||
@@ -77,12 +81,15 @@ class GenerateRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class PredictRequest(BaseModel):
|
class PredictRequest(BaseModel):
|
||||||
|
"""JSON body for ``POST /api/predict``."""
|
||||||
|
|
||||||
board: str = Field(..., pattern="^(tb2|kilter)$")
|
board: str = Field(..., pattern="^(tb2|kilter)$")
|
||||||
angle: int = Field(..., ge=0, le=80)
|
angle: int = Field(..., ge=0, le=80)
|
||||||
frames: str = Field(..., min_length=1, max_length=500)
|
frames: str = Field(..., min_length=1, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
def _board_config(board: str):
|
def _board_config(board: str):
|
||||||
|
"""Return the loaded board config or translate unknown boards to HTTP 400."""
|
||||||
try:
|
try:
|
||||||
return app.state.board_configs[board]
|
return app.state.board_configs[board]
|
||||||
except KeyError as exc:
|
except KeyError as exc:
|
||||||
@@ -90,6 +97,7 @@ def _board_config(board: str):
|
|||||||
|
|
||||||
|
|
||||||
def _require_generator():
|
def _require_generator():
|
||||||
|
"""Return the loaded generator or raise HTTP 503 with a useful path hint."""
|
||||||
if app.state.generator is None:
|
if app.state.generator is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -99,6 +107,7 @@ def _require_generator():
|
|||||||
|
|
||||||
|
|
||||||
def _require_grade_predictor():
|
def _require_grade_predictor():
|
||||||
|
"""Return the loaded grade predictor or raise HTTP 503 with a path hint."""
|
||||||
if app.state.grade_predictor is None:
|
if app.state.grade_predictor is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -108,6 +117,7 @@ def _require_grade_predictor():
|
|||||||
|
|
||||||
|
|
||||||
def _tokens_to_holds(board: str, tokens: list[str]) -> list[dict[str, Any]]:
|
def _tokens_to_holds(board: str, tokens: list[str]) -> list[dict[str, Any]]:
|
||||||
|
"""Join route tokens to board coordinates for browser-side SVG drawing."""
|
||||||
route_records = tokens_to_route_records(tokens)
|
route_records = tokens_to_route_records(tokens)
|
||||||
if route_records.empty:
|
if route_records.empty:
|
||||||
return []
|
return []
|
||||||
@@ -151,11 +161,13 @@ def _file_version(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _static_image_url(board: str) -> str:
|
def _static_image_url(board: str) -> str:
|
||||||
|
"""Return the static board image URL with a cache-busting query string."""
|
||||||
image_path = BOARD_IMAGE_PATHS[board]
|
image_path = BOARD_IMAGE_PATHS[board]
|
||||||
return f"/board-images/{image_path.name}?board={board}&v={_file_version(image_path)}"
|
return f"/board-images/{image_path.name}?board={board}&v={_file_version(image_path)}"
|
||||||
|
|
||||||
|
|
||||||
def _file_info(path: Path) -> dict[str, Any]:
|
def _file_info(path: Path) -> dict[str, Any]:
|
||||||
|
"""Return lightweight debug metadata for a static file."""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return {
|
return {
|
||||||
"path": str(path),
|
"path": str(path),
|
||||||
@@ -285,6 +297,7 @@ def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _angle_key(angle: Any) -> int:
|
def _angle_key(angle: Any) -> int:
|
||||||
|
"""Normalize angle-like values for signatures and selectors."""
|
||||||
try:
|
try:
|
||||||
return int(round(float(angle)))
|
return int(round(float(angle)))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -309,6 +322,7 @@ def _route_signature_from_holds(board: str, angle: Any, holds: list[dict[str, An
|
|||||||
|
|
||||||
|
|
||||||
def _holds_from_sequence(sequence: str) -> list[dict[str, Any]]:
|
def _holds_from_sequence(sequence: str) -> list[dict[str, Any]]:
|
||||||
|
"""Extract exact-match hold-role records from a no-grade token sequence."""
|
||||||
holds: list[dict[str, Any]] = []
|
holds: list[dict[str, Any]] = []
|
||||||
for record in tokens_to_hold_records(str(sequence).split()):
|
for record in tokens_to_hold_records(str(sequence).split()):
|
||||||
holds.append(
|
holds.append(
|
||||||
@@ -439,6 +453,7 @@ def _load_known_route_lookup(path: Path) -> dict[str, dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) -> dict[str, Any]:
|
def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""Check whether a route exactly matches a tokenized dataset route."""
|
||||||
signature = _route_signature_from_holds(board, angle, holds)
|
signature = _route_signature_from_holds(board, angle, holds)
|
||||||
if signature is None:
|
if signature is None:
|
||||||
return {
|
return {
|
||||||
@@ -462,6 +477,7 @@ def _known_route_status(board: str, angle: Any, holds: list[dict[str, Any]]) ->
|
|||||||
|
|
||||||
|
|
||||||
def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[str, Any]:
|
def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
"""Attach drawable holds, canvas data, image URL, and known-route status."""
|
||||||
board = str(result["board_key"])
|
board = str(result["board_key"])
|
||||||
tokens = list(tokens if tokens is not None else result.get("tokens", []))
|
tokens = list(tokens if tokens is not None else result.get("tokens", []))
|
||||||
extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
|
extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
|
||||||
@@ -491,6 +507,7 @@ def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[st
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Load model/checkpoint state once for the process lifetime."""
|
||||||
if TORCH_THREADS_INT is not None:
|
if TORCH_THREADS_INT is not None:
|
||||||
torch.set_num_threads(TORCH_THREADS_INT)
|
torch.set_num_threads(TORCH_THREADS_INT)
|
||||||
|
|
||||||
@@ -530,11 +547,13 @@ app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="bo
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def index():
|
def index():
|
||||||
|
"""Serve the single-page web UI."""
|
||||||
return FileResponse(REPO_ROOT / "webapp" / "static" / "index.html")
|
return FileResponse(REPO_ROOT / "webapp" / "static" / "index.html")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
|
"""Return runtime readiness and deployment diagnostics."""
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"device": DEVICE,
|
"device": DEVICE,
|
||||||
@@ -550,6 +569,7 @@ def health():
|
|||||||
|
|
||||||
@app.get("/api/boards")
|
@app.get("/api/boards")
|
||||||
def boards():
|
def boards():
|
||||||
|
"""Return board metadata needed to initialize client-side controls."""
|
||||||
payload = {}
|
payload = {}
|
||||||
for board, config in app.state.board_configs.items():
|
for board, config in app.state.board_configs.items():
|
||||||
extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
|
extent = [float(v) for v in BOARD_CANVAS[board]["extent"]]
|
||||||
@@ -576,6 +596,7 @@ def boards():
|
|||||||
|
|
||||||
@app.get("/api/board-holds/{board}")
|
@app.get("/api/board-holds/{board}")
|
||||||
def board_holds(board: str):
|
def board_holds(board: str):
|
||||||
|
"""Return all clickable holds for a board."""
|
||||||
config = _board_config(board)
|
config = _board_config(board)
|
||||||
return json_safe({
|
return json_safe({
|
||||||
"board_key": board,
|
"board_key": board,
|
||||||
@@ -588,6 +609,7 @@ def board_holds(board: str):
|
|||||||
|
|
||||||
@app.get("/api/debug/images")
|
@app.get("/api/debug/images")
|
||||||
def debug_images():
|
def debug_images():
|
||||||
|
"""Return static-board-image debug metadata."""
|
||||||
payload = {}
|
payload = {}
|
||||||
for board, image_path in BOARD_IMAGE_PATHS.items():
|
for board, image_path in BOARD_IMAGE_PATHS.items():
|
||||||
payload[board] = {
|
payload[board] = {
|
||||||
@@ -599,6 +621,7 @@ def debug_images():
|
|||||||
|
|
||||||
@app.post("/api/generate")
|
@app.post("/api/generate")
|
||||||
def generate(req: GenerateRequest):
|
def generate(req: GenerateRequest):
|
||||||
|
"""Generate a climb and optionally retry until webapp validity passes."""
|
||||||
generator = _require_generator()
|
generator = _require_generator()
|
||||||
config = _board_config(req.board)
|
config = _board_config(req.board)
|
||||||
|
|
||||||
@@ -682,6 +705,7 @@ def generate(req: GenerateRequest):
|
|||||||
|
|
||||||
@app.post("/api/predict")
|
@app.post("/api/predict")
|
||||||
def predict(req: PredictRequest):
|
def predict(req: PredictRequest):
|
||||||
|
"""Predict grade for a user-supplied frames string after validity checks."""
|
||||||
predictor = _require_grade_predictor()
|
predictor = _require_grade_predictor()
|
||||||
config = _board_config(req.board)
|
config = _board_config(req.board)
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* Theme tokens shared by the full single-page demo. */
|
||||||
:root {
|
:root {
|
||||||
--base00: #263238;
|
--base00: #263238;
|
||||||
--base01: #2e3c43;
|
--base01: #2e3c43;
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Page shell and header status. */
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
@@ -90,6 +92,7 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Control cards, form fields, and action buttons. */
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -202,6 +205,7 @@ button.secondary:hover {
|
|||||||
border-bottom: 1px dashed rgba(176, 190, 197, 0.16);
|
border-bottom: 1px dashed rgba(176, 190, 197, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Result panel and board overlay stage. */
|
||||||
.result-header {
|
.result-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 0.85rem;
|
margin-bottom: 0.85rem;
|
||||||
@@ -248,6 +252,7 @@ button.secondary:hover {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SVG overlay interaction layers. */
|
||||||
.click-target {
|
.click-target {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
@@ -279,12 +284,14 @@ button.secondary:hover {
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stack controls above the board on narrower screens. */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.layout { grid-template-columns: 1fr; }
|
.layout { grid-template-columns: 1fr; }
|
||||||
.site-header { flex-direction: column; }
|
.site-header { flex-direction: column; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Supporting notes, explanations, links, and footer content. */
|
||||||
.checkbox-label {
|
.checkbox-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/*
|
||||||
|
* Browser-side controller for the ClimbingBoardGPT demo.
|
||||||
|
*
|
||||||
|
* The server returns route tokens, board coordinates, and canvas metadata as
|
||||||
|
* JSON. This file keeps the current UI state, draws holds as SVG markers over
|
||||||
|
* the board image, and serializes clicked holds back into frames strings for
|
||||||
|
* grade prediction.
|
||||||
|
*/
|
||||||
const state = {
|
const state = {
|
||||||
boards: {},
|
boards: {},
|
||||||
boardHolds: {},
|
boardHolds: {},
|
||||||
@@ -6,6 +14,8 @@ const state = {
|
|||||||
builder: [],
|
builder: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Marker styles are expressed in board-coordinate units because the SVG viewBox
|
||||||
|
// is calibrated to the same coordinate system as token_metadata.csv.
|
||||||
const roleStyle = {
|
const roleStyle = {
|
||||||
start: { fill: "#69f0ae", stroke: "#102022", r: 1.55, shape: "circle" },
|
start: { fill: "#69f0ae", stroke: "#102022", r: 1.55, shape: "circle" },
|
||||||
middle: { fill: "#82b1ff", stroke: "#102022", r: 1.55, shape: "circle" },
|
middle: { fill: "#82b1ff", stroke: "#102022", r: 1.55, shape: "circle" },
|
||||||
@@ -14,6 +24,8 @@ const roleStyle = {
|
|||||||
unknown: { fill: "#b0bec5", stroke: "#102022", r: 1.45, shape: "circle" },
|
unknown: { fill: "#b0bec5", stroke: "#102022", r: 1.45, shape: "circle" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The builder uses brighter styling than generated routes so edited holds stand
|
||||||
|
// out after a user clicks or removes placements.
|
||||||
const builderStyle = {
|
const builderStyle = {
|
||||||
start: { fill: "#00e676", stroke: "#000000", r: 1.85, shape: "circle" },
|
start: { fill: "#00e676", stroke: "#000000", r: 1.85, shape: "circle" },
|
||||||
middle: { fill: "#448aff", stroke: "#000000", r: 1.85, shape: "circle" },
|
middle: { fill: "#448aff", stroke: "#000000", r: 1.85, shape: "circle" },
|
||||||
@@ -22,10 +34,12 @@ const builderStyle = {
|
|||||||
unknown: { fill: "#cfd8dc", stroke: "#000000", r: 1.85, shape: "circle" },
|
unknown: { fill: "#cfd8dc", stroke: "#000000", r: 1.85, shape: "circle" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Return an element by ID. */
|
||||||
function $(id) {
|
function $(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch JSON and normalize FastAPI/Pydantic errors into readable exceptions. */
|
||||||
async function fetchJson(url, options = {}) {
|
async function fetchJson(url, options = {}) {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
@@ -50,11 +64,13 @@ async function fetchJson(url, options = {}) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle a button's disabled state and working label. */
|
||||||
function setBusy(button, busy) {
|
function setBusy(button, busy) {
|
||||||
button.disabled = busy;
|
button.disabled = busy;
|
||||||
button.textContent = busy ? "Working…" : button.dataset.label;
|
button.textContent = busy ? "Working…" : button.dataset.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load and cache the clickable hold coordinate list for a board. */
|
||||||
async function ensureBoardHolds(boardKey) {
|
async function ensureBoardHolds(boardKey) {
|
||||||
if (!state.boardHolds[boardKey]) {
|
if (!state.boardHolds[boardKey]) {
|
||||||
const payload = await fetchJson(`/api/board-holds/${boardKey}`);
|
const payload = await fetchJson(`/api/board-holds/${boardKey}`);
|
||||||
@@ -63,6 +79,7 @@ async function ensureBoardHolds(boardKey) {
|
|||||||
return state.boardHolds[boardKey];
|
return state.boardHolds[boardKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Switch the displayed board image and overlay coordinate system. */
|
||||||
async function setBoardBackground(boardKey) {
|
async function setBoardBackground(boardKey) {
|
||||||
const board = state.boards[boardKey];
|
const board = state.boards[boardKey];
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
@@ -85,6 +102,7 @@ async function setBoardBackground(boardKey) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** Pick a sensible angle for the active board when the current value is absent. */
|
||||||
function preferredAngleForBoard(boardKey, currentValue = null) {
|
function preferredAngleForBoard(boardKey, currentValue = null) {
|
||||||
const board = state.boards[boardKey] || {};
|
const board = state.boards[boardKey] || {};
|
||||||
const angles = board.available_angles || [];
|
const angles = board.available_angles || [];
|
||||||
@@ -96,6 +114,7 @@ function preferredAngleForBoard(boardKey, currentValue = null) {
|
|||||||
return 40;
|
return 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rebuild an angle select using angles available in the processed dataset. */
|
||||||
function populateAngleSelect(selectId, boardKey, selectedValue = null) {
|
function populateAngleSelect(selectId, boardKey, selectedValue = null) {
|
||||||
const select = $(selectId);
|
const select = $(selectId);
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@@ -116,6 +135,7 @@ function populateAngleSelect(selectId, boardKey, selectedValue = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep generation and prediction angle selectors in sync. */
|
||||||
function syncAngleSelectors(angleValue = null) {
|
function syncAngleSelectors(angleValue = null) {
|
||||||
const boardKey = state.activeBoard || $("gen-board")?.value || $("pred-board")?.value || "tb2";
|
const boardKey = state.activeBoard || $("gen-board")?.value || $("pred-board")?.value || "tb2";
|
||||||
const selected = preferredAngleForBoard(boardKey, angleValue ?? $("gen-angle")?.value ?? $("pred-angle")?.value);
|
const selected = preferredAngleForBoard(boardKey, angleValue ?? $("gen-angle")?.value ?? $("pred-angle")?.value);
|
||||||
@@ -130,6 +150,7 @@ function syncAngleSelectors(angleValue = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Pick a sensible grade for the active board when the current value is absent. */
|
||||||
function preferredGradeForBoard(boardKey, currentValue = null) {
|
function preferredGradeForBoard(boardKey, currentValue = null) {
|
||||||
const board = state.boards[boardKey] || {};
|
const board = state.boards[boardKey] || {};
|
||||||
const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
|
const grades = board.available_grades || Array.from({ length: 16 }, (_, i) => i);
|
||||||
@@ -141,6 +162,7 @@ function preferredGradeForBoard(boardKey, currentValue = null) {
|
|||||||
return 6;
|
return 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rebuild a grade select using grades available in the processed dataset. */
|
||||||
function populateGradeSelect(selectId, boardKey, selectedValue = null) {
|
function populateGradeSelect(selectId, boardKey, selectedValue = null) {
|
||||||
const select = $(selectId);
|
const select = $(selectId);
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@@ -161,6 +183,7 @@ function populateGradeSelect(selectId, boardKey, selectedValue = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keep the generation grade selector valid for the active board. */
|
||||||
function syncGradeSelector(gradeValue = null) {
|
function syncGradeSelector(gradeValue = null) {
|
||||||
const boardKey = state.activeBoard || $("gen-board")?.value || "tb2";
|
const boardKey = state.activeBoard || $("gen-board")?.value || "tb2";
|
||||||
const selected = preferredGradeForBoard(boardKey, gradeValue ?? $("gen-grade")?.value);
|
const selected = preferredGradeForBoard(boardKey, gradeValue ?? $("gen-grade")?.value);
|
||||||
@@ -170,6 +193,7 @@ function syncGradeSelector(gradeValue = null) {
|
|||||||
if (genGrade) genGrade.value = String(selected);
|
if (genGrade) genGrade.value = String(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mirror the active board between generation and prediction controls. */
|
||||||
function syncBoardSelectors(boardKey) {
|
function syncBoardSelectors(boardKey) {
|
||||||
const genBoard = $("gen-board");
|
const genBoard = $("gen-board");
|
||||||
const predBoard = $("pred-board");
|
const predBoard = $("pred-board");
|
||||||
@@ -177,6 +201,7 @@ function syncBoardSelectors(boardKey) {
|
|||||||
if (predBoard && predBoard.value !== boardKey) predBoard.value = boardKey;
|
if (predBoard && predBoard.value !== boardKey) predBoard.value = boardKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace the editable builder state with holds from an API result. */
|
||||||
function setBuilderFromRouteResult(result) {
|
function setBuilderFromRouteResult(result) {
|
||||||
const board = result.board_key;
|
const board = result.board_key;
|
||||||
state.builder = (result.holds || []).map((hold) => ({
|
state.builder = (result.holds || []).map((hold) => ({
|
||||||
@@ -189,6 +214,7 @@ function setBuilderFromRouteResult(result) {
|
|||||||
syncBuilderToFrames();
|
syncBuilderToFrames();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Summarize the active board's edited route for headers and validation hints. */
|
||||||
function activeBuilderSummary() {
|
function activeBuilderSummary() {
|
||||||
const board = $("pred-board")?.value || state.activeBoard;
|
const board = $("pred-board")?.value || state.activeBoard;
|
||||||
const holds = state.builder.filter((hold) => hold.board === board);
|
const holds = state.builder.filter((hold) => hold.board === board);
|
||||||
@@ -197,6 +223,7 @@ function activeBuilderSummary() {
|
|||||||
return { holds, starts, finishes };
|
return { holds, starts, finishes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show an editable-route status message while the user is clicking holds. */
|
||||||
function updateEditingHeader(prefix = "Editing climb") {
|
function updateEditingHeader(prefix = "Editing climb") {
|
||||||
const summary = activeBuilderSummary();
|
const summary = activeBuilderSummary();
|
||||||
const frameText = $("pred-frames")?.value?.trim() || "";
|
const frameText = $("pred-frames")?.value?.trim() || "";
|
||||||
@@ -214,10 +241,12 @@ function updateEditingHeader(prefix = "Editing climb") {
|
|||||||
}, null, 2);
|
}, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove all SVG children from the overlay. */
|
||||||
function clearOverlay() {
|
function clearOverlay() {
|
||||||
$("overlay").innerHTML = "";
|
$("overlay").innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create an SVG element with a simple attribute object. */
|
||||||
function svgEl(name, attrs = {}) {
|
function svgEl(name, attrs = {}) {
|
||||||
const el = document.createElementNS("http://www.w3.org/2000/svg", name);
|
const el = document.createElementNS("http://www.w3.org/2000/svg", name);
|
||||||
for (const [key, value] of Object.entries(attrs)) {
|
for (const [key, value] of Object.entries(attrs)) {
|
||||||
@@ -226,6 +255,7 @@ function svgEl(name, attrs = {}) {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return polygon points for the finish-hold star marker. */
|
||||||
function starPoints(cx, cy, outerR, innerR, n = 5) {
|
function starPoints(cx, cy, outerR, innerR, n = 5) {
|
||||||
const points = [];
|
const points = [];
|
||||||
for (let i = 0; i < n * 2; i++) {
|
for (let i = 0; i < n * 2; i++) {
|
||||||
@@ -236,6 +266,7 @@ function starPoints(cx, cy, outerR, innerR, n = 5) {
|
|||||||
return points.join(" ");
|
return points.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw one route or builder marker into a transformed SVG group. */
|
||||||
function drawMarker(group, hold, style, className = "route-marker") {
|
function drawMarker(group, hold, style, className = "route-marker") {
|
||||||
if (style.shape === "square") {
|
if (style.shape === "square") {
|
||||||
const s = style.r * 2;
|
const s = style.r * 2;
|
||||||
@@ -274,6 +305,7 @@ function drawMarker(group, hold, style, className = "route-marker") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return a group that flips board coordinates into image/SVG screen space. */
|
||||||
function transformedGroup(resultOrBoardKey) {
|
function transformedGroup(resultOrBoardKey) {
|
||||||
const boardKey = typeof resultOrBoardKey === "string" ? resultOrBoardKey : resultOrBoardKey.board_key;
|
const boardKey = typeof resultOrBoardKey === "string" ? resultOrBoardKey : resultOrBoardKey.board_key;
|
||||||
const board = state.boards[boardKey];
|
const board = state.boards[boardKey];
|
||||||
@@ -283,6 +315,7 @@ function transformedGroup(resultOrBoardKey) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draw transparent click targets over every known hold on the active board. */
|
||||||
function drawSelectableTargets(boardKey, group) {
|
function drawSelectableTargets(boardKey, group) {
|
||||||
const holds = state.boardHolds[boardKey] || [];
|
const holds = state.boardHolds[boardKey] || [];
|
||||||
for (const hold of holds) {
|
for (const hold of holds) {
|
||||||
@@ -321,6 +354,7 @@ function drawSelectableTargets(boardKey, group) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render an API result and make it editable through the route builder. */
|
||||||
function drawOverlay(result) {
|
function drawOverlay(result) {
|
||||||
state.lastResult = result;
|
state.lastResult = result;
|
||||||
syncBoardSelectors(result.board_key);
|
syncBoardSelectors(result.board_key);
|
||||||
@@ -329,6 +363,7 @@ function drawOverlay(result) {
|
|||||||
redrawCurrentOverlay();
|
redrawCurrentOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redraw builder markers and click targets from the current state. */
|
||||||
function redrawCurrentOverlay() {
|
function redrawCurrentOverlay() {
|
||||||
const svg = $("overlay");
|
const svg = $("overlay");
|
||||||
svg.innerHTML = "";
|
svg.innerHTML = "";
|
||||||
@@ -348,24 +383,29 @@ function redrawCurrentOverlay() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** Find the selected builder hold index for a board placement. */
|
||||||
function findBuilderHoldIndex(board, placementId) {
|
function findBuilderHoldIndex(board, placementId) {
|
||||||
return state.builder.findIndex(
|
return state.builder.findIndex(
|
||||||
(hold) => hold.board === board && Number(hold.placement_id) === Number(placementId)
|
(hold) => hold.board === board && Number(hold.placement_id) === Number(placementId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return true when a board placement is already present in the builder. */
|
||||||
function isBuilderHoldSelected(board, placementId) {
|
function isBuilderHoldSelected(board, placementId) {
|
||||||
return findBuilderHoldIndex(board, placementId) >= 0;
|
return findBuilderHoldIndex(board, placementId) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return builder holds belonging to the currently displayed board. */
|
||||||
function activeBuilderHolds() {
|
function activeBuilderHolds() {
|
||||||
return state.builder.filter((hold) => hold.board === state.activeBoard);
|
return state.builder.filter((hold) => hold.board === state.activeBoard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Count a role among builder holds on the currently displayed board. */
|
||||||
function roleCountForActiveBoard(role) {
|
function roleCountForActiveBoard(role) {
|
||||||
return activeBuilderHolds().filter((hold) => hold.role === role).length;
|
return activeBuilderHolds().filter((hold) => hold.role === role).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enforce the same start/finish limits used by the web API. */
|
||||||
function canAddBuilderRole(role) {
|
function canAddBuilderRole(role) {
|
||||||
if ((role === "start" || role === "finish") && roleCountForActiveBoard(role) >= 2) {
|
if ((role === "start" || role === "finish") && roleCountForActiveBoard(role) >= 2) {
|
||||||
showWarnings([
|
showWarnings([
|
||||||
@@ -376,6 +416,7 @@ function canAddBuilderRole(role) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert the builder route into a frames string. */
|
||||||
function frameStringForBuilder() {
|
function frameStringForBuilder() {
|
||||||
const board = state.boards[$("pred-board").value];
|
const board = state.boards[$("pred-board").value];
|
||||||
if (!board) return "";
|
if (!board) return "";
|
||||||
@@ -386,6 +427,7 @@ function frameStringForBuilder() {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update the frames textarea and selected-hold list from builder state. */
|
||||||
function syncBuilderToFrames() {
|
function syncBuilderToFrames() {
|
||||||
$("pred-frames").value = frameStringForBuilder();
|
$("pred-frames").value = frameStringForBuilder();
|
||||||
const list = $("builder-list");
|
const list = $("builder-list");
|
||||||
@@ -400,6 +442,7 @@ function syncBuilderToFrames() {
|
|||||||
updatePredictButton();
|
updatePredictButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add or remove a clicked hold, using the currently selected semantic role. */
|
||||||
function addBuilderHold(hold) {
|
function addBuilderHold(hold) {
|
||||||
const board = $("pred-board").value;
|
const board = $("pred-board").value;
|
||||||
const role = $("click-role").value;
|
const role = $("click-role").value;
|
||||||
@@ -435,6 +478,7 @@ function addBuilderHold(hold) {
|
|||||||
updateEditingHeader("Editing generated / clicked climb");
|
updateEditingHeader("Editing generated / clicked climb");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove the most recently added hold for the selected prediction board. */
|
||||||
function undoBuilderHold() {
|
function undoBuilderHold() {
|
||||||
const board = $("pred-board").value;
|
const board = $("pred-board").value;
|
||||||
for (let i = state.builder.length - 1; i >= 0; i--) {
|
for (let i = state.builder.length - 1; i >= 0; i--) {
|
||||||
@@ -448,11 +492,13 @@ function undoBuilderHold() {
|
|||||||
updateEditingHeader("Editing generated / clicked climb");
|
updateEditingHeader("Editing generated / clicked climb");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible clear handler retained for older markup. */
|
||||||
function clearBuilder() {
|
function clearBuilder() {
|
||||||
clearEntireBoard();
|
clearEntireBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Show warning text in the dedicated box, falling back to the subtitle. */
|
||||||
function showWarnings(warnings = []) {
|
function showWarnings(warnings = []) {
|
||||||
const box = $("warning-box");
|
const box = $("warning-box");
|
||||||
const normalized = (warnings || []).filter(Boolean).map(String);
|
const normalized = (warnings || []).filter(Boolean).map(String);
|
||||||
@@ -475,6 +521,7 @@ function showWarnings(warnings = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Clear builder state without changing result headers or overlay background. */
|
||||||
function clearClickedHoldsOnly() {
|
function clearClickedHoldsOnly() {
|
||||||
state.builder = [];
|
state.builder = [];
|
||||||
const frames = $("pred-frames");
|
const frames = $("pred-frames");
|
||||||
@@ -484,11 +531,13 @@ function clearClickedHoldsOnly() {
|
|||||||
updatePredictButton();
|
updatePredictButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Public clear action for both clear buttons. */
|
||||||
function clearBoard() {
|
function clearBoard() {
|
||||||
clearEntireBoard();
|
clearEntireBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Enable prediction only when a frames string is available. */
|
||||||
function updatePredictButton() {
|
function updatePredictButton() {
|
||||||
const button = $("predict-btn");
|
const button = $("predict-btn");
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
@@ -501,6 +550,7 @@ function updatePredictButton() {
|
|||||||
: "Paste a frames string or click holds on the board first.";
|
: "Paste a frames string or click holds on the board first.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset route, raw JSON, warnings, and overlay markers for the current board. */
|
||||||
function clearEntireBoard() {
|
function clearEntireBoard() {
|
||||||
state.lastResult = null;
|
state.lastResult = null;
|
||||||
state.builder = [];
|
state.builder = [];
|
||||||
@@ -517,6 +567,7 @@ function clearEntireBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Format exact-match known-climb metadata for the result subtitle. */
|
||||||
function knownClimbSummary(result) {
|
function knownClimbSummary(result) {
|
||||||
const known = result.known_climb;
|
const known = result.known_climb;
|
||||||
if (!known || !known.checked) return null;
|
if (!known || !known.checked) return null;
|
||||||
@@ -528,6 +579,7 @@ function knownClimbSummary(result) {
|
|||||||
return `known climb: ${name} (${grade}); ${known.match_count} matching route-angle entr${known.match_count === 1 ? "y" : "ies"}`;
|
return `known climb: ${name} (${grade}); ${known.match_count} matching route-angle entr${known.match_count === 1 ? "y" : "ies"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render human-readable result text and raw JSON for an API response. */
|
||||||
function summarizeResult(result, mode) {
|
function summarizeResult(result, mode) {
|
||||||
if (mode === "generate") {
|
if (mode === "generate") {
|
||||||
const pieces = [
|
const pieces = [
|
||||||
@@ -561,6 +613,7 @@ function summarizeResult(result, mode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Convert structured API error JSON into a compact warning message. */
|
||||||
function formatErrorMessage(message) {
|
function formatErrorMessage(message) {
|
||||||
if (!message) return "Unknown error.";
|
if (!message) return "Unknown error.";
|
||||||
try {
|
try {
|
||||||
@@ -577,6 +630,7 @@ function formatErrorMessage(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submit a generation request and render the sampled route. */
|
||||||
async function generate() {
|
async function generate() {
|
||||||
const button = $("generate-btn");
|
const button = $("generate-btn");
|
||||||
setBusy(button, true);
|
setBusy(button, true);
|
||||||
@@ -603,6 +657,7 @@ async function generate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Submit a grade-prediction request for the current frames string. */
|
||||||
async function predict() {
|
async function predict() {
|
||||||
const button = $("predict-btn");
|
const button = $("predict-btn");
|
||||||
const frames = $("pred-frames").value.trim();
|
const frames = $("pred-frames").value.trim();
|
||||||
@@ -635,6 +690,7 @@ async function predict() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Attach a listener only when optional markup exists. */
|
||||||
function addListenerIfPresent(id, eventName, handler) {
|
function addListenerIfPresent(id, eventName, handler) {
|
||||||
const element = $(id);
|
const element = $(id);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
@@ -644,6 +700,7 @@ function addListenerIfPresent(id, eventName, handler) {
|
|||||||
element.addEventListener(eventName, handler);
|
element.addEventListener(eventName, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Initialize server state, controls, board background, and event listeners. */
|
||||||
async function init() {
|
async function init() {
|
||||||
$("generate-btn").dataset.label = "Generate";
|
$("generate-btn").dataset.label = "Generate";
|
||||||
$("predict-btn").dataset.label = "Predict pasted / clicked climb";
|
$("predict-btn").dataset.label = "Predict pasted / clicked climb";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/static/app.css?v=17" />
|
<link rel="stylesheet" href="/static/app.css?v=17" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Top-level status: the app script replaces this with model readiness. -->
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">ClimbingBoardGPT</p>
|
<p class="eyebrow">ClimbingBoardGPT</p>
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
|
<!-- Left column: generation controls, prediction controls, and project notes. -->
|
||||||
<section class="controls">
|
<section class="controls">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Generate a climb</h2>
|
<h2>Generate a climb</h2>
|
||||||
@@ -158,6 +160,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Right column: generated/predicted route result and SVG board overlay. -->
|
||||||
<section class="viewer">
|
<section class="viewer">
|
||||||
<div class="result-card">
|
<div class="result-card">
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
@@ -184,6 +187,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- External project links and license metadata. -->
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<span>© Pawel Sarkowicz</span>
|
<span>© Pawel Sarkowicz</span>
|
||||||
<a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a>
|
<a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user