Add web demo polish and smoke-test pipeline
This commit is contained in:
53
README.md
53
README.md
@@ -158,7 +158,7 @@ ClimbingBoardGPT/
|
||||
│ └── processed/
|
||||
├── images/
|
||||
│ ├── tb2_board_12x12_composite.png
|
||||
│ └── kilter-original-16x12_compose.png
|
||||
│ └── kilter-original-16x12_composite.png
|
||||
├── models/
|
||||
│ ├── joint_transformer_grade_predictor.pth
|
||||
│ └── joint_route_gpt_generator.pth
|
||||
@@ -247,6 +247,53 @@ They define board-specific details such as:
|
||||
|
||||
The demo scripts do **not** need the raw databases if the processed tokenization artifacts and trained model checkpoints already exist.
|
||||
|
||||
The interactive webapp also needs local demo assets:
|
||||
|
||||
```text
|
||||
data/processed/tokenized/token_metadata.csv
|
||||
models/joint_transformer_grade_predictor.pth
|
||||
models/joint_route_gpt_generator.pth
|
||||
images/tb2_board_12x12_composite.png
|
||||
images/kilter-original-16x12_composite.png
|
||||
```
|
||||
|
||||
These files are ignored by git because they are generated or binary artifacts. Recreate them with the training pipeline, copy them from a previous run, or mount them into the Docker container as shown in `docker-compose.webapp.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Fast test pipeline
|
||||
|
||||
To verify that scripts `01` through `04` still work without retraining the full models, run the pipeline into a temporary output directory with a tiny data sample and tiny CPU-only models:
|
||||
|
||||
```bash
|
||||
python scripts/01_tokenize_routes.py \
|
||||
--out-dir /tmp/cbgpt_smoke/tokenized \
|
||||
--max-routes-per-board 20
|
||||
|
||||
python scripts/02_train_grade_predictor.py \
|
||||
--tokenized-dir /tmp/cbgpt_smoke/tokenized \
|
||||
--out-dir /tmp/cbgpt_smoke/grade_prediction \
|
||||
--model-dir /tmp/cbgpt_smoke/models \
|
||||
--smoke-test
|
||||
|
||||
python scripts/03_train_route_generator.py \
|
||||
--tokenized-dir /tmp/cbgpt_smoke/tokenized \
|
||||
--out-dir /tmp/cbgpt_smoke/generation \
|
||||
--model-dir /tmp/cbgpt_smoke/models \
|
||||
--smoke-test \
|
||||
--generate-angles 40 \
|
||||
--generate-grades 6
|
||||
|
||||
python scripts/04_evaluate_generated_routes.py \
|
||||
--tokenized-dir /tmp/cbgpt_smoke/tokenized \
|
||||
--generated-dir /tmp/cbgpt_smoke/generation \
|
||||
--out-dir /tmp/cbgpt_smoke/evaluation \
|
||||
--grade-model-path /tmp/cbgpt_smoke/models/joint_transformer_grade_predictor.pth \
|
||||
--device cpu
|
||||
```
|
||||
|
||||
The resulting metrics and generated climbs are not meaningful. This path is only a code-path check: it verifies database loading, tokenization, training loops, checkpoint saving/loading, generation, and evaluation without touching the normal `data/processed` or `models` outputs.
|
||||
|
||||
---
|
||||
|
||||
## Full training pipeline
|
||||
@@ -360,7 +407,7 @@ The visualization uses calibrated board backgrounds:
|
||||
|
||||
```text
|
||||
images/tb2_board_12x12_composite.png
|
||||
images/kilter-original-16x12_compose.png
|
||||
images/kilter-original-16x12_composite.png
|
||||
```
|
||||
|
||||
These are overlaid using product-size coordinate windows:
|
||||
@@ -688,4 +735,4 @@ Example prediction payload:
|
||||
# License
|
||||
This project is licensed under the MIT License. See the [`LICENSE`](LICENSE) file for details.
|
||||
|
||||
The project is for educational purposes. Climb data belongs to Tension Climbing and Kilter respectively.
|
||||
The project is for educational purposes. Climb data belongs to Tension Climbing and Kilter respectively.
|
||||
|
||||
@@ -9,12 +9,15 @@ description = "Unified TB2/Kilter transformer route modeling, grade prediction,
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pandas",
|
||||
"scipy",
|
||||
"scikit-learn",
|
||||
"matplotlib",
|
||||
"torch"
|
||||
"numpy>=1.26",
|
||||
"pandas>=2.1",
|
||||
"scipy>=1.11",
|
||||
"scikit-learn>=1.3",
|
||||
"matplotlib>=3.8",
|
||||
"torch>=2.0",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.27",
|
||||
"pydantic>=2.0"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
numpy
|
||||
pandas
|
||||
scipy
|
||||
scikit-learn
|
||||
matplotlib
|
||||
torch
|
||||
numpy>=1.26
|
||||
pandas>=2.1
|
||||
scipy>=1.11
|
||||
scikit-learn>=1.3
|
||||
matplotlib>=3.8
|
||||
torch>=2.0
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
pydantic>=2.0
|
||||
|
||||
@@ -104,6 +104,12 @@ Examples:
|
||||
default=3,
|
||||
help="Random seed for reproducible splits (default: 3)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-routes-per-board",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Optional smoke-test row limit per board before tokenization.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -121,6 +127,8 @@ def main() -> None:
|
||||
8. Save all artifacts to disk
|
||||
"""
|
||||
args = parse_args()
|
||||
if args.max_routes_per_board is not None and args.max_routes_per_board < 3:
|
||||
raise ValueError("--max-routes-per-board must be at least 3 so train/val/test splits can exist.")
|
||||
|
||||
# Set random seed for reproducibility
|
||||
# This ensures train/val/test splits are the same across runs
|
||||
@@ -165,7 +173,13 @@ def main() -> None:
|
||||
# placement 369 with role 6 (middle)
|
||||
# placement 603 with role 7 (finish)
|
||||
print("\nLoading data from databases...")
|
||||
df_climbs, df_placements = load_multi_board_data(configs, project_root=REPO_ROOT)
|
||||
if args.max_routes_per_board is not None:
|
||||
print(f" Smoke-test limit: loading at most {args.max_routes_per_board:,} climb-angle rows per board")
|
||||
df_climbs, df_placements = load_multi_board_data(
|
||||
configs,
|
||||
project_root=REPO_ROOT,
|
||||
max_climbs_per_board=args.max_routes_per_board,
|
||||
)
|
||||
placement_lookup = make_placement_lookup(df_placements)
|
||||
|
||||
print(f" Total climb-angle entries: {len(df_climbs):,}")
|
||||
|
||||
@@ -62,8 +62,11 @@ from climbingboardgpt.datasets import RouteGradeDataset
|
||||
from climbingboardgpt.grades import to_grouped_v
|
||||
from climbingboardgpt.metrics import metrics_by_board, print_metrics, regression_metrics
|
||||
from climbingboardgpt.models import JointRouteTransformerRegressor
|
||||
from climbingboardgpt.tokenization import encode as encode_tokens
|
||||
from climbingboardgpt.utils import set_seed, write_json
|
||||
|
||||
MSE_LOSS = nn.MSELoss()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command-line arguments for grade predictor training.
|
||||
@@ -101,9 +104,31 @@ accuracy (within ±1 V-grade).
|
||||
parser.add_argument("--dropout", type=float, default=0.10, help="Dropout probability")
|
||||
parser.add_argument("--seed", type=int, default=3, help="Random seed")
|
||||
parser.add_argument("--device", type=str, default=None, help="Device (cpu or cuda)")
|
||||
parser.add_argument("--num-workers", type=int, default=0, help="DataLoader worker processes")
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
help="Use a tiny CPU model and one epoch to exercise the training/evaluation code path.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def apply_smoke_test_defaults(args: argparse.Namespace) -> None:
|
||||
"""Mutate args to a tiny deterministic configuration for code-path checks."""
|
||||
if not args.smoke_test:
|
||||
return
|
||||
args.epochs = 1
|
||||
args.patience = 1
|
||||
args.batch_size = min(args.batch_size, 16)
|
||||
args.d_model = 32
|
||||
args.nhead = 2
|
||||
args.num_layers = 1
|
||||
args.dim_feedforward = 64
|
||||
args.dropout = 0.0
|
||||
args.device = "cpu"
|
||||
args.num_workers = 0
|
||||
|
||||
|
||||
def build_coord_features(df_token_meta: pd.DataFrame, vocab_size: int) -> torch.Tensor:
|
||||
"""Build coordinate feature matrix for the transformer model.
|
||||
|
||||
@@ -148,9 +173,8 @@ def run_epoch(model, loader, device, optimizer=None):
|
||||
"""
|
||||
is_train = optimizer is not None
|
||||
model.train(is_train)
|
||||
criterion = nn.MSELoss()
|
||||
|
||||
losses, preds, targets, uuids, boards = [], [], [], [], []
|
||||
losses, preds, targets, row_ids, uuids, boards = [], [], [], [], [], []
|
||||
|
||||
for batch in loader:
|
||||
input_ids = batch["input_ids"].to(device)
|
||||
@@ -162,7 +186,7 @@ def run_epoch(model, loader, device, optimizer=None):
|
||||
|
||||
# Forward pass: model predicts difficulty from token sequence
|
||||
pred = model(input_ids, attention_mask)
|
||||
loss = criterion(pred, target)
|
||||
loss = MSE_LOSS(pred, target)
|
||||
|
||||
if is_train:
|
||||
# Backward pass: compute gradients and update weights
|
||||
@@ -174,11 +198,12 @@ def run_epoch(model, loader, device, optimizer=None):
|
||||
losses.append(loss.item() * input_ids.size(0))
|
||||
preds.extend(pred.detach().cpu().numpy().tolist())
|
||||
targets.extend(target.detach().cpu().numpy().tolist())
|
||||
row_ids.extend(batch["row_id"].detach().cpu().numpy().tolist())
|
||||
uuids.extend(batch["uuid"])
|
||||
boards.extend(batch["board_key"])
|
||||
|
||||
avg_loss = sum(losses) / max(1, len(loader.dataset))
|
||||
return avg_loss, np.asarray(preds), np.asarray(targets), uuids, boards
|
||||
return avg_loss, np.asarray(preds), np.asarray(targets), row_ids, uuids, boards
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -195,6 +220,7 @@ def main() -> None:
|
||||
8. Save model checkpoint and metrics
|
||||
"""
|
||||
args = parse_args()
|
||||
apply_smoke_test_defaults(args)
|
||||
set_seed(args.seed)
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.model_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -215,7 +241,7 @@ def main() -> None:
|
||||
df_token_meta = pd.read_csv(meta_path)
|
||||
|
||||
pad_id = stoi["<PAD>"]
|
||||
unk_id = stoi["<UNK>"]
|
||||
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Prepare input sequences
|
||||
@@ -223,15 +249,13 @@ def main() -> None:
|
||||
# For grade prediction, we use the "no_grade" version of the sequence
|
||||
# and prepend <CLS> for sequence-level pooling.
|
||||
# The model must PREDICT the grade, not see it in the input!
|
||||
def encode(tokens):
|
||||
return [stoi.get(token, unk_id) for token in tokens]
|
||||
|
||||
df_routes["tokens_no_grade"] = df_routes["sequence_no_grade"].fillna("").str.split()
|
||||
df_routes["model_tokens"] = df_routes["tokens_no_grade"].apply(
|
||||
lambda tokens: ["<CLS>"] + tokens[1:] if tokens else ["<CLS>"]
|
||||
)
|
||||
df_routes["model_ids"] = df_routes["model_tokens"].apply(encode)
|
||||
df_routes["model_ids"] = df_routes["model_tokens"].apply(lambda tokens: encode_tokens(tokens, stoi))
|
||||
df_routes["seq_len"] = df_routes["model_ids"].apply(len)
|
||||
df_routes["row_id"] = np.arange(len(df_routes), dtype=np.int64)
|
||||
max_len = int(df_routes["seq_len"].max())
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -245,14 +269,17 @@ def main() -> None:
|
||||
val_ds = RouteGradeDataset(val_df, max_len=max_len, pad_id=pad_id)
|
||||
test_ds = RouteGradeDataset(test_df, max_len=max_len, pad_id=pad_id)
|
||||
|
||||
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True)
|
||||
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
|
||||
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
|
||||
loader_kwargs = {
|
||||
"num_workers": int(args.num_workers),
|
||||
"pin_memory": device.type == "cuda",
|
||||
}
|
||||
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, **loader_kwargs)
|
||||
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
|
||||
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Initialize model
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||
coord_features = build_coord_features(df_token_meta, vocab_size=len(stoi))
|
||||
|
||||
model = JointRouteTransformerRegressor(
|
||||
@@ -286,8 +313,8 @@ def main() -> None:
|
||||
|
||||
print("\nStarting training...")
|
||||
for epoch in range(1, args.epochs + 1):
|
||||
train_loss, train_pred, train_true, _, _ = run_epoch(model, train_loader, device, optimizer)
|
||||
val_loss, val_pred, val_true, _, _ = run_epoch(model, val_loader, device, optimizer=None)
|
||||
train_loss, train_pred, train_true, _, _, _ = run_epoch(model, train_loader, device, optimizer)
|
||||
val_loss, val_pred, val_true, _, _, _ = run_epoch(model, val_loader, device, optimizer=None)
|
||||
|
||||
train_metrics = regression_metrics(train_true, train_pred)
|
||||
val_metrics = regression_metrics(val_true, val_pred)
|
||||
@@ -332,10 +359,11 @@ def main() -> None:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 6: Test set evaluation
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
test_loss, test_pred, test_true, test_uuid, test_board = run_epoch(model, test_loader, device, optimizer=None)
|
||||
test_loss, test_pred, test_true, test_row_id, test_uuid, test_board = run_epoch(model, test_loader, device, optimizer=None)
|
||||
overall_metrics = regression_metrics(test_true, test_pred)
|
||||
|
||||
pred_df = pd.DataFrame({
|
||||
"row_id": test_row_id,
|
||||
"uuid": test_uuid,
|
||||
"board_key": test_board,
|
||||
"y_true": test_true,
|
||||
@@ -344,13 +372,15 @@ def main() -> None:
|
||||
"true_v": [to_grouped_v(value) for value in test_true],
|
||||
"pred_v": [to_grouped_v(value) for value in test_pred],
|
||||
})
|
||||
pred_df = pred_df.merge(
|
||||
df_routes[["uuid", "climb_name", "angle", "boulder_grade", "sequence_no_grade"]],
|
||||
on="uuid",
|
||||
how="left",
|
||||
)
|
||||
board_metrics_df = metrics_by_board(pred_df)
|
||||
|
||||
pred_df = pred_df.merge(
|
||||
df_routes[["row_id", "climb_name", "angle", "boulder_grade", "sequence_no_grade"]],
|
||||
on="row_id",
|
||||
how="left",
|
||||
validate="one_to_one",
|
||||
)
|
||||
|
||||
print_metrics("Overall joint test performance", overall_metrics)
|
||||
print("\nBoard-specific test performance:")
|
||||
print(board_metrics_df.to_string(index=False))
|
||||
@@ -390,4 +420,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -63,6 +63,7 @@ from climbingboardgpt.config import load_board_configs, parse_board_keys
|
||||
from climbingboardgpt.datasets import RouteGPTDataset
|
||||
from climbingboardgpt.generation import generate_one
|
||||
from climbingboardgpt.models import JointRouteGPT
|
||||
from climbingboardgpt.tokenization import encode as encode_tokens
|
||||
from climbingboardgpt.utils import set_seed
|
||||
|
||||
|
||||
@@ -106,9 +107,32 @@ specific board, or leave unset to generate for all boards.
|
||||
parser.add_argument("--generate-grades", type=str, default=None, help="Comma-separated V-grades")
|
||||
parser.add_argument("--seed", type=int, default=3, help="Random seed")
|
||||
parser.add_argument("--device", type=str, default=None, help="Device (cpu or cuda)")
|
||||
parser.add_argument("--num-workers", type=int, default=0, help="DataLoader worker processes")
|
||||
parser.add_argument(
|
||||
"--smoke-test",
|
||||
action="store_true",
|
||||
help="Use a tiny CPU model, one epoch, and a tiny generation grid to exercise the full code path.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def apply_smoke_test_defaults(args: argparse.Namespace) -> None:
|
||||
"""Mutate args to a tiny deterministic configuration for code-path checks."""
|
||||
if not args.smoke_test:
|
||||
return
|
||||
args.epochs = 1
|
||||
args.patience = 1
|
||||
args.batch_size = min(args.batch_size, 16)
|
||||
args.n_embd = 32
|
||||
args.n_head = 2
|
||||
args.n_layer = 1
|
||||
args.dropout = 0.0
|
||||
args.max_new_tokens = min(args.max_new_tokens, 16)
|
||||
args.n_per_condition = 1
|
||||
args.device = "cpu"
|
||||
args.num_workers = 0
|
||||
|
||||
|
||||
def evaluate_loss(model, loader, device) -> float:
|
||||
"""Evaluate the model on a data loader, returning average loss.
|
||||
|
||||
@@ -168,6 +192,7 @@ def main() -> None:
|
||||
7. Save model checkpoint and generated routes
|
||||
"""
|
||||
args = parse_args()
|
||||
apply_smoke_test_defaults(args)
|
||||
set_seed(args.seed)
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.model_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -185,7 +210,7 @@ def main() -> None:
|
||||
stoi = {str(k): int(v) for k, v in vocab["stoi"].items()}
|
||||
itos = {int(k): str(v) for k, v in vocab["itos"].items()}
|
||||
pad_id = stoi["<PAD>"]
|
||||
unk_id = stoi["<UNK>"]
|
||||
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Prepare sequences for causal language modeling
|
||||
@@ -198,11 +223,8 @@ def main() -> None:
|
||||
#
|
||||
# The input is shifted right by one position compared to the target.
|
||||
# This is the standard causal language modeling setup.
|
||||
def encode(tokens):
|
||||
return [stoi.get(token, unk_id) for token in tokens]
|
||||
|
||||
df_routes["gpt_tokens"] = df_routes["sequence_with_grade"].fillna("").str.split()
|
||||
df_routes["gpt_ids"] = df_routes["gpt_tokens"].apply(encode)
|
||||
df_routes["gpt_ids"] = df_routes["gpt_tokens"].apply(lambda tokens: encode_tokens(tokens, stoi))
|
||||
df_routes["seq_len"] = df_routes["gpt_ids"].apply(len)
|
||||
max_len = int(df_routes["seq_len"].max())
|
||||
if max_len < 2:
|
||||
@@ -220,14 +242,17 @@ def main() -> None:
|
||||
val_ds = RouteGPTDataset(val_df, max_len=max_len, pad_id=pad_id)
|
||||
test_ds = RouteGPTDataset(test_df, max_len=max_len, pad_id=pad_id)
|
||||
|
||||
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True)
|
||||
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False)
|
||||
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False)
|
||||
loader_kwargs = {
|
||||
"num_workers": int(args.num_workers),
|
||||
"pin_memory": device.type == "cuda",
|
||||
}
|
||||
train_loader = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, **loader_kwargs)
|
||||
val_loader = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
|
||||
test_loader = DataLoader(test_ds, batch_size=args.batch_size, shuffle=False, **loader_kwargs)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Initialize model
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||
model = JointRouteGPT(
|
||||
vocab_size=len(stoi),
|
||||
block_size=block_size,
|
||||
@@ -385,4 +410,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -49,6 +49,7 @@ from climbingboardgpt.evaluation import (
|
||||
tokens_to_hold_records,
|
||||
validity_from_records,
|
||||
)
|
||||
from climbingboardgpt.checkpoints import load_checkpoint
|
||||
from climbingboardgpt.grades import to_grouped_v
|
||||
from climbingboardgpt.models import JointRouteTransformerRegressor
|
||||
|
||||
@@ -86,10 +87,7 @@ def load_grade_critic(model_path: Path, device: torch.device):
|
||||
"""
|
||||
if not model_path.exists():
|
||||
return None
|
||||
try:
|
||||
checkpoint = torch.load(model_path, map_location=device, weights_only=False)
|
||||
except TypeError:
|
||||
checkpoint = torch.load(model_path, map_location=device)
|
||||
checkpoint = load_checkpoint(model_path, map_location=device, trusted=True)
|
||||
|
||||
cfg = checkpoint["config"]
|
||||
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
||||
@@ -333,4 +331,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
@@ -88,7 +88,7 @@ def parse_args() -> argparse.Namespace:
|
||||
help=(
|
||||
"Optional board image to draw under the scatter plot. "
|
||||
"If omitted, the script automatically uses images/tb2_board_12x12_composite.png "
|
||||
"for TB2 and images/kilter-original-16x12_compose.png for Kilter when present."
|
||||
"for TB2 and images/kilter-original-16x12_composite.png for Kilter when present."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
@@ -98,7 +98,7 @@ def parse_args() -> argparse.Namespace:
|
||||
def default_background_for_board(board: str) -> Path | None:
|
||||
candidates = {
|
||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_compose.png",
|
||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
||||
}
|
||||
path = candidates.get(board)
|
||||
return path if path is not None and path.exists() else None
|
||||
|
||||
@@ -48,7 +48,7 @@ from climbingboardgpt.visualization import load_token_metadata, visualize_route_
|
||||
def default_background_for_board(board: str) -> Path | None:
|
||||
candidates = {
|
||||
"tb2": REPO_ROOT / "images" / "tb2_board_12x12_composite.png",
|
||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_compose.png",
|
||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
||||
}
|
||||
path = candidates.get(board)
|
||||
return path if path is not None and path.exists() else None
|
||||
|
||||
33
src/climbingboardgpt/checkpoints.py
Normal file
33
src/climbingboardgpt/checkpoints.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def load_checkpoint(
|
||||
checkpoint_path: str | Path,
|
||||
map_location: str | torch.device,
|
||||
*,
|
||||
trusted: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Load a PyTorch checkpoint, preferring safer weights-only loading.
|
||||
|
||||
Set ``trusted=True`` only for checkpoints produced by this project or an
|
||||
otherwise trusted source. Older PyTorch versions do not support
|
||||
``weights_only``; those fall back to the legacy loader for compatibility.
|
||||
"""
|
||||
checkpoint_path = Path(checkpoint_path)
|
||||
|
||||
try:
|
||||
return torch.load(checkpoint_path, map_location=map_location, weights_only=True)
|
||||
except TypeError:
|
||||
return torch.load(checkpoint_path, map_location=map_location)
|
||||
except Exception as exc:
|
||||
if not trusted:
|
||||
raise RuntimeError(
|
||||
"Could not load checkpoint with weights_only=True. "
|
||||
"Only retry with trusted=True for checkpoints from a trusted source."
|
||||
) from exc
|
||||
return torch.load(checkpoint_path, map_location=map_location, weights_only=False)
|
||||
@@ -135,12 +135,14 @@ def build_placements_query(config: BoardConfig) -> tuple[str, list]:
|
||||
def load_board_data(
|
||||
config: BoardConfig,
|
||||
project_root: str | Path | None = None,
|
||||
max_climbs: int | None = None,
|
||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Load climbs and placements data for a single board.
|
||||
|
||||
Args:
|
||||
config: Board configuration
|
||||
project_root: Path to project root (for resolving db_path)
|
||||
max_climbs: Optional row limit for fast smoke-test loads.
|
||||
|
||||
Returns:
|
||||
Tuple of (climbs DataFrame, placements DataFrame)
|
||||
@@ -154,6 +156,11 @@ def load_board_data(
|
||||
|
||||
climbs_query, climbs_params = build_climbs_query(config)
|
||||
placements_query, placements_params = build_placements_query(config)
|
||||
if max_climbs is not None:
|
||||
if max_climbs < 1:
|
||||
raise ValueError("max_climbs must be at least 1.")
|
||||
climbs_query = f"{climbs_query}\nORDER BY c.uuid, cs.angle\nLIMIT ?"
|
||||
climbs_params = [*climbs_params, int(max_climbs)]
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
df_climbs = pd.read_sql_query(climbs_query, conn, params=climbs_params)
|
||||
@@ -174,6 +181,7 @@ def load_board_data(
|
||||
def load_multi_board_data(
|
||||
configs: list[BoardConfig],
|
||||
project_root: str | Path | None = None,
|
||||
max_climbs_per_board: int | None = None,
|
||||
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
||||
"""Load and concatenate data from multiple boards.
|
||||
|
||||
@@ -184,6 +192,7 @@ def load_multi_board_data(
|
||||
Args:
|
||||
configs: List of board configurations
|
||||
project_root: Path to project root
|
||||
max_climbs_per_board: Optional row limit per board for smoke tests.
|
||||
|
||||
Returns:
|
||||
Tuple of (combined climbs DataFrame, combined placements DataFrame)
|
||||
@@ -192,11 +201,15 @@ def load_multi_board_data(
|
||||
placement_frames = []
|
||||
|
||||
for config in configs:
|
||||
climbs, placements = load_board_data(config, project_root=project_root)
|
||||
climbs, placements = load_board_data(
|
||||
config,
|
||||
project_root=project_root,
|
||||
max_climbs=max_climbs_per_board,
|
||||
)
|
||||
climb_frames.append(climbs)
|
||||
placement_frames.append(placements)
|
||||
|
||||
return (
|
||||
pd.concat(climb_frames, ignore_index=True),
|
||||
pd.concat(placement_frames, ignore_index=True),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from torch.utils.data import Dataset
|
||||
|
||||
class RouteGradeDataset(Dataset):
|
||||
def __init__(self, df, max_len: int, pad_id: int):
|
||||
self.row_ids = df["row_id"].tolist() if "row_id" in df.columns else df.index.tolist()
|
||||
self.ids = df["model_ids"].tolist()
|
||||
self.targets = df["display_difficulty"].astype(float).values
|
||||
self.uuids = df["uuid"].tolist()
|
||||
@@ -28,6 +29,7 @@ class RouteGradeDataset(Dataset):
|
||||
"input_ids": torch.tensor(ids, dtype=torch.long),
|
||||
"attention_mask": torch.tensor(mask, dtype=torch.bool),
|
||||
"target": torch.tensor(self.targets[idx], dtype=torch.float32),
|
||||
"row_id": int(self.row_ids[idx]),
|
||||
"uuid": self.uuids[idx],
|
||||
"board_key": self.boards[idx],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
@@ -8,38 +7,11 @@ import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.spatial.distance import pdist
|
||||
|
||||
HOLD_TOKEN_PATTERN = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
|
||||
from .tokenization import parse_tokens, tokens_to_hold_records
|
||||
|
||||
|
||||
def parse_token_list(value) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except Exception:
|
||||
pass
|
||||
return value.split()
|
||||
|
||||
|
||||
def tokens_to_hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
||||
rows = []
|
||||
for token in tokens:
|
||||
match = HOLD_TOKEN_PATTERN.match(token)
|
||||
if match is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"token": token,
|
||||
"board_token_prefix": match.group(1),
|
||||
"placement_id": int(match.group(2)),
|
||||
"role": match.group(3),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
return parse_tokens(value)
|
||||
|
||||
|
||||
def validity_from_records(records: list[dict[str, object]], requested_board_prefix: str | None = None) -> dict[str, object]:
|
||||
@@ -102,30 +74,30 @@ def nearest_real_route_same_board(
|
||||
real_df: pd.DataFrame,
|
||||
) -> dict[str, object]:
|
||||
board_frame = real_df[real_df["board_key"] == generated_board_key]
|
||||
best = {
|
||||
"nearest_real_jaccard": -1.0,
|
||||
"nearest_real_uuid": None,
|
||||
"nearest_real_name": None,
|
||||
"nearest_real_grouped_v": None,
|
||||
"nearest_real_angle": None,
|
||||
if board_frame.empty:
|
||||
return {
|
||||
"nearest_real_jaccard": np.nan,
|
||||
"nearest_real_uuid": None,
|
||||
"nearest_real_name": None,
|
||||
"nearest_real_grouped_v": None,
|
||||
"nearest_real_angle": None,
|
||||
"novelty_distance": np.nan,
|
||||
}
|
||||
|
||||
similarities = board_frame["hold_set"].map(lambda hold_set: jaccard(generated_set, hold_set))
|
||||
best_idx = similarities.idxmax()
|
||||
row = board_frame.loc[best_idx]
|
||||
|
||||
nearest_real_jaccard = float(similarities.loc[best_idx])
|
||||
return {
|
||||
"nearest_real_jaccard": nearest_real_jaccard,
|
||||
"nearest_real_uuid": row["uuid"],
|
||||
"nearest_real_name": row["climb_name"],
|
||||
"nearest_real_grouped_v": row["grouped_v"],
|
||||
"nearest_real_angle": row["angle"],
|
||||
"novelty_distance": 1.0 - nearest_real_jaccard,
|
||||
}
|
||||
|
||||
for _, row in board_frame.iterrows():
|
||||
similarity = jaccard(generated_set, row["hold_set"])
|
||||
if similarity > best["nearest_real_jaccard"]:
|
||||
best.update(
|
||||
{
|
||||
"nearest_real_jaccard": similarity,
|
||||
"nearest_real_uuid": row["uuid"],
|
||||
"nearest_real_name": row["climb_name"],
|
||||
"nearest_real_grouped_v": row["grouped_v"],
|
||||
"nearest_real_angle": row["angle"],
|
||||
}
|
||||
)
|
||||
|
||||
best["novelty_distance"] = 1.0 - float(best["nearest_real_jaccard"])
|
||||
return best
|
||||
|
||||
|
||||
def build_placement_coords(df_token_meta: pd.DataFrame) -> dict[tuple[str, int], dict[str, float]]:
|
||||
hold_meta = df_token_meta[df_token_meta["kind"] == "hold"].dropna(subset=["placement_id"]).copy()
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
HOLD_TOKEN_PATTERN = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
|
||||
from .tokenization import tokens_to_hold_records
|
||||
|
||||
|
||||
def top_k_filter(logits: torch.Tensor, k: int | None) -> torch.Tensor:
|
||||
@@ -61,20 +60,7 @@ def prompt_tokens(board_prefix: str, angle: int, grouped_v: int) -> list[str]:
|
||||
|
||||
|
||||
def hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
||||
rows = []
|
||||
for token in tokens:
|
||||
match = HOLD_TOKEN_PATTERN.match(token)
|
||||
if match is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"board_prefix": match.group(1),
|
||||
"placement_id": int(match.group(2)),
|
||||
"role": match.group(3),
|
||||
"token": token,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
return tokens_to_hold_records(tokens)
|
||||
|
||||
|
||||
def validity_summary(tokens: Iterable[str], requested_board_prefix: str | None = None) -> dict[str, object]:
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from .checkpoints import load_checkpoint
|
||||
from .config import BoardConfig, load_board_config
|
||||
from .generation import generate_one
|
||||
from .grades import to_grouped_v
|
||||
@@ -75,10 +76,7 @@ def load_grade_predictor(
|
||||
|
||||
resolved_device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||
|
||||
try:
|
||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device, weights_only=False)
|
||||
except TypeError:
|
||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device)
|
||||
checkpoint = load_checkpoint(checkpoint_path, map_location=resolved_device, trusted=True)
|
||||
|
||||
cfg = checkpoint["config"]
|
||||
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
||||
@@ -176,10 +174,7 @@ def load_route_generator(
|
||||
|
||||
resolved_device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
||||
|
||||
try:
|
||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device, weights_only=False)
|
||||
except TypeError:
|
||||
checkpoint = torch.load(checkpoint_path, map_location=resolved_device)
|
||||
checkpoint = load_checkpoint(checkpoint_path, map_location=resolved_device, trusted=True)
|
||||
|
||||
cfg = checkpoint["config"]
|
||||
stoi = {str(k): int(v) for k, v in checkpoint["stoi"].items()}
|
||||
@@ -332,4 +327,3 @@ def predict_frames_grade(
|
||||
"requested_angle": int(angle),
|
||||
"frames": frames,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import ast
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
@@ -39,6 +40,43 @@ def parse_frames(frames_str: str | None) -> list[tuple[int, int]]:
|
||||
return [(int(placement_id), int(role_id)) for placement_id, role_id in matches]
|
||||
|
||||
|
||||
def parse_tokens(value) -> list[str]:
|
||||
"""Parse tokens from a list, repr-style list string, or whitespace sequence."""
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value]
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) for v in parsed]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return value.split()
|
||||
|
||||
|
||||
def tokens_to_hold_records(tokens: Iterable[str]) -> list[dict[str, object]]:
|
||||
"""Extract hold records from model tokens using the shared hold-token grammar."""
|
||||
rows: list[dict[str, object]] = []
|
||||
for token in tokens:
|
||||
match = HOLD_TOKEN_PATTERN.match(str(token))
|
||||
if match is None:
|
||||
continue
|
||||
board_prefix = match.group(1)
|
||||
rows.append(
|
||||
{
|
||||
"token": str(token),
|
||||
"board_token_prefix": board_prefix,
|
||||
"board_prefix": board_prefix,
|
||||
"placement_id": int(match.group(2)),
|
||||
"role": match.group(3),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def make_placement_lookup(df_placements: pd.DataFrame) -> dict[tuple[str, int], dict]:
|
||||
rows = {}
|
||||
for _, row in df_placements.iterrows():
|
||||
|
||||
@@ -94,7 +94,6 @@ def assign_group_splits(
|
||||
``train``, ``val``, or ``test``.
|
||||
"""
|
||||
group_df = df[group_cols + ([stratify_col] if stratify_col else [])].copy()
|
||||
group_df["__row_index"] = range(len(group_df))
|
||||
group_df = group_df.drop_duplicates(group_cols).reset_index(drop=True)
|
||||
|
||||
train_groups, temp_groups = safe_train_test_split(
|
||||
|
||||
@@ -6,15 +6,13 @@ coordinate extent, then scatter route holds in board coordinates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import pandas as pd
|
||||
|
||||
HOLD_TOKEN_PATTERN = re.compile(r"^<([A-Z0-9_]+)_p(\d+)_(start|middle|finish|foot|unknown)>$")
|
||||
from .tokenization import parse_tokens, tokens_to_hold_records
|
||||
|
||||
# These are the same coordinate windows used in the earlier visualization
|
||||
# notebooks. They come from the product size geometry rather than from the
|
||||
@@ -58,39 +56,9 @@ ROLE_SIZES = {
|
||||
}
|
||||
|
||||
|
||||
def parse_tokens(value) -> list[str]:
|
||||
"""Parse a generated token list from a list, repr string, or sequence string."""
|
||||
if isinstance(value, list):
|
||||
return [str(v) for v in value]
|
||||
if not isinstance(value, str):
|
||||
return []
|
||||
|
||||
try:
|
||||
parsed = ast.literal_eval(value)
|
||||
if isinstance(parsed, list):
|
||||
return [str(v) for v in parsed]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return value.split()
|
||||
|
||||
|
||||
def tokens_to_route_records(tokens: Iterable[str]) -> pd.DataFrame:
|
||||
"""Extract generated hold records from model tokens."""
|
||||
rows = []
|
||||
for token in tokens:
|
||||
match = HOLD_TOKEN_PATTERN.match(str(token))
|
||||
if match is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"token": token,
|
||||
"board_token_prefix": match.group(1),
|
||||
"placement_id": int(match.group(2)),
|
||||
"role": match.group(3),
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
return pd.DataFrame(tokens_to_hold_records(tokens))
|
||||
|
||||
|
||||
def load_token_metadata(tokenized_dir: str | Path) -> pd.DataFrame:
|
||||
|
||||
136
tests/test_core.py
Normal file
136
tests/test_core.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "src"))
|
||||
|
||||
from climbingboardgpt.config import BoardConfig
|
||||
from climbingboardgpt.evaluation import nearest_real_route_same_board
|
||||
from climbingboardgpt.generation import validity_summary
|
||||
from climbingboardgpt.grades import to_grouped_v
|
||||
from climbingboardgpt.tokenization import (
|
||||
parse_frames,
|
||||
parse_tokens,
|
||||
tokenize_route,
|
||||
tokens_to_hold_records,
|
||||
)
|
||||
|
||||
|
||||
class CoreBehaviorTest(unittest.TestCase):
|
||||
def test_to_grouped_v_clamps_and_maps_display_difficulty(self):
|
||||
self.assertEqual(to_grouped_v(10), 0)
|
||||
self.assertEqual(to_grouped_v(22), 6)
|
||||
self.assertEqual(to_grouped_v(99), 16)
|
||||
|
||||
def test_parse_frames_extracts_placement_role_pairs(self):
|
||||
self.assertEqual(parse_frames("p344r5p369r6p603r7"), [(344, 5), (369, 6), (603, 7)])
|
||||
self.assertEqual(parse_frames(None), [])
|
||||
|
||||
def test_shared_token_parser_handles_repr_and_sequence_strings(self):
|
||||
self.assertEqual(parse_tokens("['<BOS>', '<EOS>']"), ["<BOS>", "<EOS>"])
|
||||
self.assertEqual(parse_tokens("<BOS> <EOS>"), ["<BOS>", "<EOS>"])
|
||||
|
||||
def test_tokens_to_hold_records_extracts_shared_shape(self):
|
||||
records = tokens_to_hold_records(["<BOS>", "<TB2_p344_start>", "<TB2_p603_finish>"])
|
||||
self.assertEqual(
|
||||
records,
|
||||
[
|
||||
{
|
||||
"token": "<TB2_p344_start>",
|
||||
"board_token_prefix": "TB2",
|
||||
"board_prefix": "TB2",
|
||||
"placement_id": 344,
|
||||
"role": "start",
|
||||
},
|
||||
{
|
||||
"token": "<TB2_p603_finish>",
|
||||
"board_token_prefix": "TB2",
|
||||
"board_prefix": "TB2",
|
||||
"placement_id": 603,
|
||||
"role": "finish",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
def test_validity_summary_flags_basic_valid_route(self):
|
||||
summary = validity_summary(
|
||||
["<TB2_p344_start>", "<TB2_p369_middle>", "<TB2_p603_finish>"],
|
||||
requested_board_prefix="TB2",
|
||||
)
|
||||
self.assertTrue(summary["basic_valid"])
|
||||
self.assertEqual(summary["n_hold_tokens"], 3)
|
||||
|
||||
def test_tokenize_route_uses_canonical_hold_order(self):
|
||||
config = BoardConfig(
|
||||
board_key="tb2",
|
||||
display_name="TB2",
|
||||
token_prefix="TB2",
|
||||
db_path=Path("unused.db"),
|
||||
layout_id=1,
|
||||
max_angle=None,
|
||||
min_fa_date=None,
|
||||
placement_y_max=None,
|
||||
include_mirror_placement_id=False,
|
||||
role_definitions={"start": 5, "middle": 6, "finish": 7, "foot": 8},
|
||||
)
|
||||
placement_lookup = {
|
||||
("tb2", 603): {"x": 0, "y": 120},
|
||||
("tb2", 344): {"x": 0, "y": 10},
|
||||
("tb2", 369): {"x": 0, "y": 80},
|
||||
}
|
||||
row = {
|
||||
"frames": "p603r7p369r6p344r5",
|
||||
"angle": 40,
|
||||
"display_difficulty": 22,
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
tokenize_route(row, config, placement_lookup),
|
||||
[
|
||||
"<BOS>",
|
||||
"<BOARD_TB2>",
|
||||
"<ANGLE_40>",
|
||||
"<GRADE_V6>",
|
||||
"<TB2_p344_start>",
|
||||
"<TB2_p369_middle>",
|
||||
"<TB2_p603_finish>",
|
||||
"<EOS>",
|
||||
],
|
||||
)
|
||||
|
||||
def test_nearest_real_route_same_board_finds_best_jaccard_match(self):
|
||||
real_df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"board_key": "tb2",
|
||||
"hold_set": frozenset({1, 2, 3}),
|
||||
"uuid": "a",
|
||||
"climb_name": "close",
|
||||
"grouped_v": 5,
|
||||
"angle": 40,
|
||||
},
|
||||
{
|
||||
"board_key": "tb2",
|
||||
"hold_set": frozenset({7, 8}),
|
||||
"uuid": "b",
|
||||
"climb_name": "far",
|
||||
"grouped_v": 8,
|
||||
"angle": 45,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
result = nearest_real_route_same_board(frozenset({1, 2, 4}), "tb2", real_df)
|
||||
|
||||
self.assertEqual(result["nearest_real_uuid"], "a")
|
||||
self.assertAlmostEqual(result["nearest_real_jaccard"], 0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,9 +14,9 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -39,6 +39,8 @@ from climbingboardgpt.inference import (
|
||||
predict_frames_grade,
|
||||
predict_route_grade,
|
||||
)
|
||||
from climbingboardgpt.tokenization import tokens_to_hold_records
|
||||
from climbingboardgpt.utils import json_safe
|
||||
from climbingboardgpt.visualization import BOARD_CANVAS, load_token_metadata, tokens_to_route_records
|
||||
|
||||
|
||||
@@ -59,7 +61,7 @@ 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",
|
||||
"kilter": REPO_ROOT / "images" / "kilter-original-16x12_composite.png",
|
||||
}
|
||||
|
||||
|
||||
@@ -80,26 +82,6 @@ class PredictRequest(BaseModel):
|
||||
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]
|
||||
@@ -240,13 +222,10 @@ def _role_limit_validity(tokens: list[str], requested_board_prefix: str) -> dict
|
||||
"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):
|
||||
for record in tokens_to_hold_records(tokens):
|
||||
if str(record["board_token_prefix"]) != str(requested_board_prefix):
|
||||
continue
|
||||
role = str(record["role"])
|
||||
counts[role] = counts.get(role, 0) + 1
|
||||
|
||||
n_start = int(counts.get("start", 0))
|
||||
@@ -305,10 +284,6 @@ def _invalid_prediction_reasons(validity: dict[str, Any]) -> list[str]:
|
||||
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)))
|
||||
@@ -335,15 +310,12 @@ def _route_signature_from_holds(board: str, angle: Any, holds: list[dict[str, An
|
||||
|
||||
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
|
||||
for record in tokens_to_hold_records(str(sequence).split()):
|
||||
holds.append(
|
||||
{
|
||||
"board_prefix": match.group(1),
|
||||
"placement_id": int(match.group(2)),
|
||||
"role": match.group(3),
|
||||
"board_prefix": str(record["board_token_prefix"]),
|
||||
"placement_id": int(record["placement_id"]),
|
||||
"role": str(record["role"]),
|
||||
}
|
||||
)
|
||||
return holds
|
||||
@@ -497,7 +469,7 @@ def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[st
|
||||
holds = _tokens_to_holds(board, tokens)
|
||||
angle = result.get("requested_angle", result.get("angle", 0))
|
||||
|
||||
return _json_safe(
|
||||
return json_safe(
|
||||
{
|
||||
**result,
|
||||
"tokens": tokens,
|
||||
@@ -517,13 +489,8 @@ def _payload(result: dict[str, Any], tokens: list[str] | None = None) -> dict[st
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if TORCH_THREADS_INT is not None:
|
||||
torch.set_num_threads(TORCH_THREADS_INT)
|
||||
|
||||
@@ -553,6 +520,12 @@ def startup() -> None:
|
||||
device=DEVICE,
|
||||
torch_threads=TORCH_THREADS_INT,
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="ClimbingBoardGPT", version="0.1.0", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=REPO_ROOT / "webapp" / "static"), name="static")
|
||||
app.mount("/board-images", StaticFiles(directory=REPO_ROOT / "images"), name="board-images")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -604,7 +577,7 @@ def boards():
|
||||
@app.get("/api/board-holds/{board}")
|
||||
def board_holds(board: str):
|
||||
config = _board_config(board)
|
||||
return _json_safe({
|
||||
return json_safe({
|
||||
"board_key": board,
|
||||
"display_name": config.display_name,
|
||||
"token_prefix": config.token_prefix,
|
||||
@@ -729,7 +702,7 @@ def predict(req: PredictRequest):
|
||||
reasons = _invalid_prediction_reasons(validity)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_json_safe(
|
||||
detail=json_safe(
|
||||
{
|
||||
"message": "Cannot predict grade for an invalid climb.",
|
||||
"reasons": reasons,
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
: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;
|
||||
--base00: #263238;
|
||||
--base01: #2e3c43;
|
||||
--base02: #314549;
|
||||
--base03: #546e7a;
|
||||
--base04: #b2ccd6;
|
||||
--base05: #eeffff;
|
||||
--base07: #ffffff;
|
||||
--base08: #f07178;
|
||||
--base0a: #ffcb6b;
|
||||
--base0b: #c3e88d;
|
||||
--base0c: #89ddff;
|
||||
--base0d: #82aaff;
|
||||
--base0e: #c792ea;
|
||||
--base0f: #ff5370;
|
||||
--bg: var(--base00);
|
||||
--off-bg: var(--base01);
|
||||
--inner-bg: var(--base02);
|
||||
--fg: var(--base05);
|
||||
--off-fg: var(--base04);
|
||||
--muted: var(--base03);
|
||||
--link: var(--base0d);
|
||||
--hover: var(--base0c);
|
||||
--highlight: var(--base0a);
|
||||
--logo: var(--base0b);
|
||||
--danger: var(--base08);
|
||||
--border: rgba(178, 204, 214, 0.18);
|
||||
--mono: "Fira Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -19,57 +33,60 @@
|
||||
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);
|
||||
font-size: 16px;
|
||||
line-height: 1.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.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);
|
||||
gap: 1.5rem;
|
||||
max-width: 78rem;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
margin: 0;
|
||||
color: var(--logo);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 2.05rem);
|
||||
letter-spacing: -0.04em;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-header p {
|
||||
margin: 0.4rem 0 0;
|
||||
.site-header h1::before {
|
||||
content: "# ";
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-header p {
|
||||
margin: 0;
|
||||
color: var(--off-fg);
|
||||
}
|
||||
|
||||
.health {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-2);
|
||||
color: var(--highlight);
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--inner-bg);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 370px minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
max-width: 1520px;
|
||||
grid-template-columns: 22rem minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
padding: 2rem 1rem 1rem;
|
||||
max-width: 78rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -80,25 +97,28 @@ body {
|
||||
}
|
||||
|
||||
.card, .result-card {
|
||||
background: var(--panel);
|
||||
background: var(--off-bg);
|
||||
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;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.card h2::before, .result-card h2::before {
|
||||
content: "## ";
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.7rem 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
@@ -106,16 +126,15 @@ input, select, textarea {
|
||||
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;
|
||||
color: var(--fg);
|
||||
background: var(--inner-bg);
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: 2px solid rgba(128, 203, 196, 0.32);
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid rgba(137, 221, 255, 0.28);
|
||||
border-color: var(--hover);
|
||||
}
|
||||
|
||||
textarea {
|
||||
@@ -126,26 +145,33 @@ textarea {
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border: 1px solid #4db6ac;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--link);
|
||||
padding: 0.68rem 0.9rem;
|
||||
margin-top: 0.4rem;
|
||||
font-weight: 700;
|
||||
color: #102022;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
background: var(--link);
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
button:hover { filter: brightness(1.06); }
|
||||
button:hover {
|
||||
border-color: var(--hover);
|
||||
background: var(--hover);
|
||||
}
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
color: var(--link);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
color: var(--bg);
|
||||
border-color: var(--hover);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -153,7 +179,7 @@ button.secondary {
|
||||
}
|
||||
|
||||
.note p, .small {
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -162,7 +188,7 @@ button.secondary {
|
||||
list-style: none;
|
||||
margin: 0.8rem 0 0;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
font-size: 0.78rem;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
@@ -185,19 +211,27 @@ button.secondary {
|
||||
|
||||
.result-header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.result-note {
|
||||
max-width: 760px;
|
||||
margin: 0 auto 0.85rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.board-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
background: #111827;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.board-stage img {
|
||||
@@ -225,24 +259,23 @@ button.secondary {
|
||||
.hover-marker {
|
||||
pointer-events: none;
|
||||
fill: none;
|
||||
stroke: var(--accent-2);
|
||||
stroke: var(--highlight);
|
||||
stroke-width: 0.55;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.json-block {
|
||||
margin-top: 1rem;
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
}
|
||||
|
||||
.json-block pre {
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
padding: 1rem;
|
||||
background: #111a1f;
|
||||
color: #cfd8dc;
|
||||
background: var(--inner-bg);
|
||||
color: var(--off-fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
@@ -266,10 +299,9 @@ button.secondary {
|
||||
.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;
|
||||
border: 1px solid rgba(255, 203, 107, 0.55);
|
||||
background: rgba(255, 203, 107, 0.12);
|
||||
color: var(--highlight);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
@@ -287,7 +319,7 @@ button.secondary {
|
||||
.field-help {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--faint);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -297,7 +329,7 @@ button.secondary {
|
||||
}
|
||||
|
||||
.explain dt {
|
||||
color: var(--accent-2);
|
||||
color: var(--highlight);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
@@ -308,16 +340,15 @@ button.secondary {
|
||||
|
||||
.explain dd {
|
||||
margin: 0.22rem 0 0;
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.explain code {
|
||||
color: var(--accent-2);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--off-fg);
|
||||
background: var(--inner-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.25rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
@@ -325,23 +356,47 @@ button.secondary {
|
||||
.link-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--muted);
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.link-list li::marker {
|
||||
content: "* ";
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.link-list a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.link-list a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--hover);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
max-width: 78rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.site-footer a:hover {
|
||||
color: var(--hover);
|
||||
}
|
||||
|
||||
|
||||
#data-acknowledgement-card p {
|
||||
color: var(--muted);
|
||||
#data-acknowledgement-card p,
|
||||
#model-disclaimer-card p {
|
||||
color: var(--off-fg);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ClimbingBoardGPT</title>
|
||||
<link rel="stylesheet" href="/static/app.css?v=16" />
|
||||
<link rel="stylesheet" href="/static/app.css?v=17" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
@@ -94,6 +94,13 @@
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="model-disclaimer-card">
|
||||
<h2>Research demo caveat</h2>
|
||||
<p>
|
||||
This is an experimental model demo. Generated climbs and predicted grades may be wrong, especially at rare grades or sparse board/angle combinations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card explain">
|
||||
<h2>How this works</h2>
|
||||
<p>
|
||||
@@ -111,6 +118,7 @@
|
||||
<ul class="link-list">
|
||||
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT repo</a></li>
|
||||
<li><a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a></li>
|
||||
<li><a href="https://github.com/psark007/Tension-Board-2-Analysis" target="_blank" rel="noreferrer">Tension Board 2 Analysis repo</a></li>
|
||||
<li><a href="https://github.com/psark007/Kilter-Board-Analysis" target="_blank" rel="noreferrer">Kilter Board Analysis repo</a></li>
|
||||
<li><a href="https://tensionclimbing.com/products/tension-board-2" target="_blank" rel="noreferrer">Tension Board 2</a></li>
|
||||
@@ -118,10 +126,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card note" id="data-acknowledgement-card">
|
||||
<div class="card note" id="data-acknowledgement-card">
|
||||
<h2>Data acknowledgement</h2>
|
||||
<p>
|
||||
Tension Board 2 route data is from Tension Climbing. Kilter Board route data is from Kilter.
|
||||
Board layouts, hold metadata, and route data are derived from Tension Board 2 and Kilter Board datasets. This project is unaffiliated with Tension Climbing or Kilter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -159,6 +167,10 @@
|
||||
<button id="clear-board-btn" class="secondary clear-board">Clear board</button>
|
||||
</div>
|
||||
|
||||
<p class="result-note">
|
||||
Known climb means an exact match against the tokenized dataset for the same board, angle, and hold-role set.
|
||||
</p>
|
||||
|
||||
<div id="board-stage" class="board-stage">
|
||||
<img id="board-bg" alt="Board background" />
|
||||
<svg id="overlay" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||
@@ -172,6 +184,13 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js?v=16"></script>
|
||||
<footer class="site-footer">
|
||||
<span>© Pawel Sarkowicz</span>
|
||||
<a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a>
|
||||
<a href="https://github.com/psark007/ClimbingBoardGPT" target="_blank" rel="noreferrer">ClimbingBoardGPT</a>
|
||||
<a href="https://github.com/psark007/ClimbingBoardGPT/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js?v=17"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user