Files
ClimbingBoardGPT/src/climbingboardgpt/visualization.py
2026-05-24 20:00:40 -04:00

322 lines
9.7 KiB
Python

"""Visualization utilities for generated ClimbingBoardGPT routes.
The route-overlay functions here deliberately mimic the old TB2/Kilter
notebook convention: draw the board composite image with the product-size
coordinate extent, then scatter route holds in board coordinates.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable
import matplotlib.pyplot as plt
import pandas as pd
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
# min/max of the actual holds. The hold coordinates are inset by about 4in,
# so using hold min/max directly shifts/stretches the background image.
BOARD_CANVAS = {
"tb2": {
"extent": [-68, 68, 0, 144],
"figsize": (16, 14),
"image_aspect": "auto",
},
"kilter": {
"extent": [-24, 168, 0, 156],
"figsize": (17, 12),
"image_aspect": "equal",
},
}
ROLE_COLORS = {
"start": "#2ecc71",
"middle": "#3498db",
"finish": "#e74c3c",
"foot": "#f1c40f",
"unknown": "#9ca3af",
}
ROLE_MARKERS = {
"start": "o",
"middle": "o",
"finish": "*",
"foot": "s",
"unknown": "o",
}
ROLE_SIZES = {
"start": 150,
"middle": 150,
"finish": 230,
"foot": 95,
"unknown": 150,
}
def tokens_to_route_records(tokens: Iterable[str]) -> pd.DataFrame:
"""Extract generated hold records from model tokens."""
return pd.DataFrame(tokens_to_hold_records(tokens))
def load_token_metadata(tokenized_dir: str | Path) -> pd.DataFrame:
"""Load token metadata produced by ``scripts/01_tokenize_routes.py``."""
tokenized_dir = Path(tokenized_dir)
path = tokenized_dir / "token_metadata.csv"
if not path.exists():
raise FileNotFoundError(
f"Could not find {path}. Run scripts/01_tokenize_routes.py first."
)
return pd.read_csv(path)
def board_canvas_settings(board_key: str, df_token_meta: pd.DataFrame | None = None) -> dict[str, object]:
"""Return board canvas settings.
Known boards use hand-calibrated extents from the old notebooks. Unknown
boards fall back to coordinate bounds from ``token_metadata.csv``.
"""
board_key = str(board_key)
if board_key in BOARD_CANVAS:
return dict(BOARD_CANVAS[board_key])
if df_token_meta is None:
raise ValueError(f"No board canvas settings for board_key={board_key!r}.")
holds = _board_holds(df_token_meta, board_key)
x_min, x_max = float(holds["x"].min()), float(holds["x"].max())
y_min, y_max = float(holds["y"].min()), float(holds["y"].max())
x_pad = max((x_max - x_min) * 0.06, 1.0)
y_pad = max((y_max - y_min) * 0.06, 1.0)
return {
"extent": [x_min - x_pad, x_max + x_pad, y_min - y_pad, y_max + y_pad],
"figsize": (8, 10),
"image_aspect": "auto",
}
def _board_holds(df_token_meta: pd.DataFrame, board_key: str) -> pd.DataFrame:
holds = df_token_meta[
(df_token_meta["kind"] == "hold")
& (df_token_meta["board_key"].astype(str) == str(board_key))
].copy()
if holds.empty:
raise ValueError(
f"No hold metadata found for board_key={board_key!r}. "
"Check token_metadata.csv and board config."
)
holds = holds.drop_duplicates(["board_key", "placement_id"]).copy()
return holds
def _route_with_coords(
route_records: pd.DataFrame,
df_token_meta: pd.DataFrame,
board_key: str,
) -> pd.DataFrame:
holds = _board_holds(df_token_meta, board_key)
coords = holds[["board_key", "board_token_prefix", "placement_id", "x", "y"]].drop_duplicates(
["board_key", "placement_id"]
)
merged = route_records.merge(
coords,
on=["board_token_prefix", "placement_id"],
how="left",
)
return merged
def visualize_route_tokens(
tokens: Iterable[str],
df_token_meta: pd.DataFrame,
board_key: str,
title: str | None = None,
subtitle: str | None = None,
output_path: str | Path | None = None,
annotate: bool = False,
show_all_holds: bool | None = None,
background_image: str | Path | None = None,
figsize: tuple[float, float] | None = None,
dpi: int = 160,
):
"""Visualize a generated route as a board overlay plot.
If a background image is supplied, the plot uses the calibrated canvas
extent from the old project notebooks. If no image is supplied, it falls
back to a clean coordinate-board style and shows available holds.
"""
route_records = tokens_to_route_records(tokens)
if route_records.empty:
raise ValueError("No hold tokens found in generated sequence.")
board_holds = _board_holds(df_token_meta, board_key)
route_df = _route_with_coords(route_records, df_token_meta, board_key)
route_df = route_df.dropna(subset=["x", "y"]).copy()
if route_df.empty:
raise ValueError(
"Generated route contained hold tokens, but none matched the board metadata."
)
canvas = board_canvas_settings(board_key, df_token_meta)
extent = [float(v) for v in canvas["extent"]]
x_min, x_max, y_min, y_max = extent
image_aspect = str(canvas.get("image_aspect", "auto"))
figsize = figsize or canvas.get("figsize", (8, 10))
background_exists = background_image is not None and Path(background_image).exists()
if show_all_holds is None:
show_all_holds = not background_exists
fig, ax = plt.subplots(figsize=figsize)
if background_exists:
img = plt.imread(Path(background_image))
ax.imshow(
img,
extent=extent,
aspect=image_aspect,
alpha=1.0,
zorder=0,
)
if show_all_holds:
ax.scatter(
board_holds["x"],
board_holds["y"],
s=22,
c="#d1d5db",
alpha=0.45,
linewidths=0,
label="available holds",
zorder=1,
)
# Draw route holds role-by-role so the legend is meaningful.
for role, frame in route_df.groupby("role", sort=False):
ax.scatter(
frame["x"],
frame["y"],
s=ROLE_SIZES.get(role, 150),
c=ROLE_COLORS.get(role, ROLE_COLORS["unknown"]),
marker=ROLE_MARKERS.get(role, "o"),
edgecolors="#111827",
linewidths=1.0,
alpha=0.96,
label=role,
zorder=3,
)
if annotate:
for _, row in route_df.iterrows():
ax.text(
row["x"],
row["y"],
str(int(row["placement_id"])),
ha="center",
va="center",
fontsize=7,
fontweight="bold",
color="white",
bbox=dict(
boxstyle="circle,pad=0.12",
alpha=0.45,
facecolor="#111827",
edgecolor="white",
linewidth=0.8,
),
zorder=4,
)
ax.set_xlim(x_min, x_max)
ax.set_ylim(y_min, y_max)
if image_aspect == "equal":
ax.set_aspect("equal", adjustable="box")
ax.set_xlabel("X Position")
ax.set_ylabel("Y Position")
# Put the title and subtitle at the figure level, not the axes level.
# This avoids the old overlap where ax.set_title(...) and ax.text(y=1.01)
# competed for the same narrow top margin.
has_header = bool(title or subtitle)
if title:
fig.suptitle(
title,
fontsize=14,
fontweight="bold",
y=0.985,
)
if subtitle:
fig.text(
0.5,
0.958,
subtitle,
ha="center",
va="top",
fontsize=9,
color="#4b5563",
)
if background_exists:
ax.grid(False)
else:
ax.grid(True, alpha=0.18)
ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1.0), frameon=False)
# Reserve top space for the figure-level title/subtitle.
if has_header:
fig.tight_layout(rect=[0, 0, 1, 0.925])
else:
fig.tight_layout()
if output_path is not None:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, dpi=dpi, bbox_inches="tight")
return fig, ax, route_df
def visualize_route_result(
result: dict[str, object],
df_token_meta: pd.DataFrame,
output_path: str | Path | None = None,
annotate: bool = False,
background_image: str | Path | None = None,
):
"""Visualize a result dictionary returned by ``generate_route``."""
board_key = str(result["board_key"])
tokens = parse_tokens(result["tokens"])
title = (
f"{str(result.get('board_display_name', board_key))} "
f"generated V{int(result['requested_grouped_v'])} @ {int(result['requested_angle'])}°"
)
subtitle_parts = [
f"valid={result.get('basic_valid')}",
f"holds={result.get('n_hold_tokens')}",
]
if "predicted_grouped_v" in result:
subtitle_parts.append(
f"predicted V{int(result['predicted_grouped_v'])}"
f" ({float(result['predicted_display_difficulty']):.2f})"
)
if "critic_v_error" in result:
subtitle_parts.append(f"error {int(result['critic_v_error']):+d}V")
subtitle_parts.append(f"temperature={result.get('temperature')}")
subtitle = " | ".join(subtitle_parts)
return visualize_route_tokens(
tokens=tokens,
df_token_meta=df_token_meta,
board_key=board_key,
title=title,
subtitle=subtitle,
output_path=output_path,
annotate=annotate,
background_image=background_image,
)