Source code for miniosl.drawing

"""visualize state and channels"""
from __future__ import annotations
import miniosl
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import logging


font_initialized = False
properties = [
    {
        "heps": +0.02, "veps": +0.1,
        "verticalalignment": 'center',
        "zorder": 2,
        "rotation": 0,
    },
    {
        "heps": +0.04, "veps": -0.15,
        "verticalalignment": 'center',
        "horizontalalignment": 'center',
        "rotation_mode": 'anchor',
        "zorder": 2,
        "rotation": 180,
    }
]
last_move_prop = {
    "x": 6.25, "y": 10,
    "size": 10, "alpha": 0.9, "horizontalalignment": 'left'
}


def setup_font():
    """look for Noto fonts for Japanese characters"""
    # might need addfont in Colab
    # https://matplotlib.org/stable/api/font_manager_api.html#matplotlib.font_manager.FontManager.addfont
    import matplotlib.font_manager as fm
    # add system fonts in advance, especially for colab
    font_dirs = ["/usr/share/fonts"]
    font_files = fm.findSystemFonts(fontpaths=font_dirs)
    for file in font_files:
        if 'JP' in file or 'CJK' in file:
            fm.fontManager.addfont(file)

    fontname = 'Noto Serif CJK JP'  # 'Noto Sans Mono CJK JP'
    font = fm.findfont(fontname)
    if not font and ("NotoSerifCJK" in font):  # 'NotoSansMonoCJKjp'
        logging.warning(f'{fontname} not available')
    matplotlib.rcParams['font.family'] = [fontname]


def place_letter(ax, x: int, y: int, label: str,
                 heps: float, veps: float,
                 **kwargs):
    return ax.text(x+heps, y+veps, label, **kwargs)


def update_letter(text, x: int, y: int, label: str,
                  *,
                  heps: float, veps: float,
                  **kwargs):
    text.set(position=(x+heps, y+veps), text=label, **kwargs)


def put_forward_char(ax, x: int, y: int, label: str,
                     size: int = 15, alpha: float = 1,
                     horizontalalignment: str = 'center'):
    """place a piece label at (x, y) if in [1,9]^2 or outside otherwise

    :param ax: object returned by make_board_fig()
    """
    return place_letter(ax, x, y, label, fontsize=size,
                        alpha=alpha,
                        horizontalalignment=horizontalalignment,
                        **properties[miniosl.black])


def put_reversed_char(ax, x: int, y: int, label: str,
                      size: int = 15):
    """place a piece label at (x, y) in [1,9]^2 with rotation

    :param ax: object returned by make_board_fig()
    """
    return place_letter(ax, x, y, label, fontsize=size,
                        **properties[miniosl.white])


def place_black_hand_piece(ax, pieces: str = '', offset: int = 0):
    """place a sequence of labels in black piece stand

    :param ax: object returned by make_board_fig()
    :param pieces: a sequence of piece letters typically in Japanese
    :param offset: number of protected pieces already written
    """
    lst = []
    for i, char in enumerate(pieces):
        n = i + offset
        c, r = n // 12, n % 12
        p = put_forward_char(ax, -1.95+c*0.85, 1.5+r*0.8, char, size=13)
        lst.append(p)
    return lst


def place_white_hand_piece(ax, pieces: str = '', offset: int = 0):
    """place a sequence of labels (with rotation) in white piece stand

    :param ax: object returned by make_board_fig()
    :param pieces: a sequence of piece letters typically in Japanese
    :param offset: number of protected pieces already written
    """
    lst = []
    for i, char in enumerate(pieces):
        n = i + offset
        c, r = n // 12, n % 12
        p = put_reversed_char(ax, 11.4-c*0.85, 8.5-r*0.8, char, size=13)
        lst.append(p)
    return lst


def make_board_fig(id: int = 4081) -> tuple(
        matplotlib.figure.Figure, matplotlib.axes._axes.Axes
):
    """make a matplotlib.fig (and ax) for an empty board

    :param id: figure id to overwrite if exists
    """
    fig, ax = plt.subplots(figsize=[3.3, 3.3], num=id, clear=True)
    color = matplotlib.rcParams['grid.color']  # respect current theme
    for i in range(10):
        alpha, lw = (1, 1) if i in [0, 9] else (0.45, 0.8)
        ax.plot([i+.5, i+.5], [0.5, 9.5],
                alpha=alpha, linewidth=lw, color=color)  # vert
        ax.plot([0.5, 9.5], [i+.5, i+.5],
                alpha=alpha, linewidth=lw, color=color)  # horiz
    ax.set_position([0, 0, 1, 1])
    ax.set_axis_off()
    ax.set_xlim(-2.5, 12)
    ax.set_ylim(-.5, 12)
    ax.invert_xaxis()
    ax.invert_yaxis()
    for x in range(1, 10):
        put_forward_char(ax, x, 0, str(x), size=10, alpha=0.7)
    for y, char in enumerate(['一', '二', '三', '四', '五', '六', '七', '八', '九']):
        put_forward_char(ax, 0, y+1, char, size=10, alpha=0.7)
    put_forward_char(ax, -2, 0.5, '☗')
    put_reversed_char(ax, 11.5, 9.5, '☖')
    return fig, ax


def add_move_number(ax, msg: str, hoffset: int = 0):
    """add msg at the bottom

    :param ax: object returned by make_board_fig()
    """
    return put_forward_char(ax, 9.25 + hoffset, 10.75, msg,
                            size=10, alpha=.9,
                            horizontalalignment='left')


def add_last_move(ax, last_move: str):
    """show last_move at a line above the bottom

    :param ax: object returned by make_board_fig()
    """
    msg = f'({last_move} まで)' if last_move else ''
    return put_forward_char(ax, label=msg, **last_move_prop)


csadict = {
    'FU': '歩', 'KY': '香', 'KE': '桂', 'GI': '銀', 'KI': '金',
    'KA': '角', 'HI': '飛', 'OU': '王',
    'TO': ' と ', 'NY': '杏', 'NK': '圭', 'NG': '全', 'UM': '馬', 'RY': '龍',
}
kanjirow = [None, '一', '二', '三', '四', '五', '六', '七', '八', '九']
kanjicol = [None, '1', '2', '3', '4', '5', '6', '7', '8', '9']

no_pieces_in_hand = 'なし'
label_hand_b = '先手持駒'
label_hand_w = '後手持駒'
hand_others = '他'


def ptype_to_ja(ptype: miniosl.Ptype) -> str:
    """return single character for ptype

    Note: `ptype.to_ja()` shows promoted pieces with two charcters
    """
    return csadict[ptype.to_csa()]


def hand_pieces_to_ja(state, player):
    ret = ''
    for ptype in miniosl.piece_stand_order:
        cnt = state.count_hand(player, ptype)
        if cnt == 0:
            continue
        ret += csadict[ptype.to_csa()] * cnt
    return ret


def radius(cnt):
    """radius of circle for strength cnt"""
    return 12 * (cnt ** 1/4)


[docs]class ShogiFig: """data holding state and matplotlib objects use `self.fig` for completed image. """ def __init__(self, state: miniosl.BaseState, last_move_ja: str = '', move_number: int = 0, id: int = 4081): global font_initialized if not font_initialized: setup_font() self.state = miniosl.State(state) # clone self.last_move_ja = last_move_ja self.last_to = None self.move_number = move_number self.fig, self.ax = make_board_fig(id=id) self.arts = {} self.place_pieces() self._init_hand() def _decorate_square(self, x: int, y: int, radius: float, color: str, marker: str = 'o', markeredgewidth: float = 1, alpha: float = .5) -> None: return self.ax.plot( x, y, marker, color=color, markersize=radius, markeredgewidth=markeredgewidth, alpha=alpha, zorder=1) def _decorate_king(self, player: miniosl.Player) -> None: x, y = self.state.king_square(player).to_xy() self._decorate_square(x, y, 16, 'C2', marker='s', markeredgewidth=2, alpha=.2) def decorate_cover(self): if not isinstance(self.state, miniosl.State): return self._decorate_king(miniosl.black) self._decorate_king(miniosl.white) plane_c, plane_b, plane_w = ( np.zeros((9, 9)), np.zeros((9, 9)), np.zeros((9, 9)) ) for y in range(1, 10): for x in range(1, 10): sq = miniosl.Square(x, y) eb = self.state.count_cover(miniosl.black, sq) ew = self.state.count_cover(miniosl.white, sq) if eb > 0 and ew > 0: plane_c[y-1][x-1] = eb+ew elif eb > 0: plane_b[y-1][x-1] = eb elif ew > 0: plane_w[y-1][x-1] = ew self.draw_plane(plane_c, 'C3') self.draw_plane(plane_b, 'C4') self.draw_plane(plane_w, 'C5') def draw_plane(self, plane: np.ndarray, color: str, stroke: int = 0) -> None: if plane.shape != (9, 9): raise ValueError(f'unexpected shape of plane {plane.shape}') maximum = np.max(plane) if maximum > 5: plane /= maximum weight = 1 if plane.max() > 2 or plane.max() == 0 else 4/plane.max() for y in range(1, 10): for x in range(1, 10): r = radius(weight*plane[y-1][x-1]) self._decorate_square(x, y, r, color, markeredgewidth=0) def decorate_last_to(self, dst): x, y = dst.to_xy() if 'last_to' in self.arts: self.arts['last_to'].set_data([x], [y]) else: sq, = self._decorate_square(x, y, 16, 'C0', marker='s', markeredgewidth=2, alpha=.3) self.arts['last_to'] = sq self.last_to = dst def draw_piece(self, x, y, piece): kanji = ptype_to_ja(piece.ptype) if (x, y) in self.arts: update_letter(self.arts[(x, y)], x, y, kanji, **properties[piece.color]) return self.arts[(x, y)] if piece.color == miniosl.black: art = put_forward_char(self.ax, x, y, kanji) else: assert piece.color == miniosl.white art = put_reversed_char(self.ax, x, y, kanji) self.arts[(x, y)] = art return art def show_side_to_move(self, flipped: bool): player_to_move = "先手" if self.state.turn == miniosl.black else "後手" if 'turn' in self.arts: self.arts['turn'].set(text=f'手番 {player_to_move}') else: turn = put_forward_char( self.ax, 9.25, 10, f'手番 {player_to_move}', size=10, alpha=.7, horizontalalignment='left' ) self.arts['turn'] = turn if 'last_move_ja' in self.arts: self.arts['last_move_ja'].set(text=f'({self.last_move_ja} まで)') else: art = add_last_move(self.ax, self.last_move_ja) self.arts['last_move_ja'] = art if 'move_number' not in self.arts: art = add_move_number(self.ax, '') self.arts['move_number'] = art if self.move_number > 0: msg = f'{self.move_number}手目' self.arts['move_number'].set(text=msg) if flipped: art = put_forward_char(self.ax, 2.25, 10.75, '先後反転', size=10, alpha=.7, horizontalalignment='left') self.arts['flipped'] = art return [self.arts[_] for _ in ['turn', 'last_move_ja', 'move_number', 'flipped'] if _ in self.arts] def add_comment(self, msg): return add_move_number(self.ax, msg, hoffset=4) def hand_pieces_str(self, player: miniosl.Player) -> str: msg = hand_pieces_to_ja(self.state, player) + ' '*20 if msg[19] != ' ': msg = msg[:19] + '他' return msg[:20] def place_pieces(self): changed = [] for x in range(1, 10): for y in range(1, 10): piece = self.state.piece_at(miniosl.Square(x, y)) if not piece.is_piece(): if (x, y) in self.arts: self.arts[(x, y)].set(text='') changed.append(self.arts[(x, y)]) continue art = self.draw_piece(x, y, piece) self.arts[(x, y)] = art changed.append(art) return changed def _place_hand_pieces(self, color: miniosl.Player): hand = self.hand_pieces_str(color) if color == miniosl.black: return place_black_hand_piece(self.ax, hand) else: return place_white_hand_piece(self.ax, hand) def _init_hand(self): for color in [miniosl.black, miniosl.white]: lst = self._place_hand_pieces(color) if color in self.arts: logging.warning('overwrite arts[color]') self.arts[color] = lst def update_hand(self, color, prev_hand_str=''): hand_str = self.hand_pieces_str(color) changed = [] proc = place_black_hand_piece \ if color == miniosl.black else place_white_hand_piece hand_arts = self.arts[color] for i, c in enumerate(hand_str): if i >= len(hand_arts): hand_arts += proc(self.ax, c, i) changed.append(hand_arts[-1]) elif i >= len(prev_hand_str) or prev_hand_str[i] != c: hand_arts[i].set(text=c) changed.append(hand_arts[i]) if len(prev_hand_str) > len(hand_str): for arts in hand_arts[len(hand_str):]: arts.set(text='') changed += hand_arts[len(hand_str):] return changed def set_state(self, state, last_move_ja: str = '', last_to: miniosl.Square | None = None, move_number: int = 0, repeat_distance: int = 0, repeat_count: int = 0, flipped: bool = False): # for internal use self.state = miniosl.State(state) self.last_move_ja = last_move_ja self.last_to = last_to self.move_number = move_number changed = [] changed += self.place_pieces() changed += self.update_hand(miniosl.black) changed += self.update_hand(miniosl.white) if last_to: self.decorate_last_to(last_to) changed.append(self.arts['last_to']) changed += self.show_side_to_move(flipped) if repeat_distance or repeat_count: logging.warning('repeat not supported yet') return changed
[docs] def make_move(self, move: miniosl.Move | str): """update board and figure""" changed = [] if isinstance(move, str): move = self.state.to_move(move) if not self.state.is_legal(move): raise RuntimeError(f'{move.to_csa} not legal') self.last_move_ja = move.to_ja(self.state, self.last_to) dst = move.dst if move.is_capture(): captured = self.arts[dst.to_xy()] captured.set(text='') changed.append(captured) if not move.is_drop(): src = move.src piece = self.arts[src.to_xy()] heps, veps = [ properties[move.color][_] for _ in ['heps', 'veps'] ] piece.set(position=(dst.x + heps, dst.y + veps)) if move.is_promotion(): piece.set(text=ptype_to_ja(move.ptype)) del self.arts[src.to_xy()] self.arts[dst.to_xy()] = piece changed.append(piece) color = move.color prev_hands = self.hand_pieces_str(color) self.state.make_move(move) if move.is_drop(): art = self.draw_piece(dst.x, dst.y, self.state.piece_at(dst)) changed.append(art) if move.is_capture() or move.is_drop(): changed += self.update_hand(color, prev_hands) self.move_number += 1 self.decorate_last_to(dst) changed.append(self.arts['last_to']) changed += self.show_side_to_move(False) return changed
[docs]def state_to_img(state: miniosl.BaseState, *, decorate: bool = False, plane: np.ndarray | None = None, plane_color: str = 'orange', last_move_ja: str = '', last_to: miniosl.Square | None = None, move_number: int = 0, repeat_distance: int = 0, repeat_count: int = 0, flip_if_white: bool = False, id: int = 4081, ) -> matplotlib.figure.Figure: """make :py:class:`ShogiFig` object including matplotlib figure as `.fig` :param state: state, :param decorate: highlight king location and piece covers for each color, :param plane: 9x9 numpy array to make a mark on squares, :param last_move_ja: last move in japanese, :param last_to: the destination square of the last move, :param move_number: ply in a game record, :param repeat_distance: distance to the latest same position, :param repeat_count: number of the occurrence of this state, :param flip_if_white: `rotate180()` if white to move """ flipped = False if flip_if_white and state.turn == miniosl.white: state = miniosl.State(state.rotate180()) if last_to: last_to = last_to.rotate180() flipped = True if decorate and not isinstance(state, miniosl.State): logging.warning('promote BaseState to State for decoration') state = miniosl.State(state) fig = ShogiFig(state, last_move_ja=last_move_ja, move_number=move_number, id=id) if decorate: fig.decorate_cover() if last_to: fig.decorate_last_to(last_to) if repeat_distance > 0: msg = f' ({repeat_distance}手前と同一局面 {repeat_count}回目)' fig.add_comment(msg) if plane is not None: fig.draw_plane(plane, plane_color, 'C6') fig.show_side_to_move(flipped) return fig
def show_channels(channels, nrows, ncols, flip=False, *, japanese=False): import matplotlib.pyplot as plt from mpl_toolkits.axes_grid1 import ImageGrid fig = plt.figure(figsize=(ncols*2.5, nrows*2)) grid = ImageGrid(fig, 111, nrows_ncols=(nrows, ncols), axes_pad=0.3, label_mode='all') dan = kanjirow[1:] if japanese else np.arange(1, 10) for i, ax in enumerate(grid): if flip: ax.set_xticks(np.arange(9), np.arange(9, 0, -1)) ax.set_yticks(np.arange(9), reversed(dan)) else: ax.set_xticks(np.arange(9), np.arange(1, 10)) ax.set_yticks(np.arange(9), dan) ax.xaxis.tick_top() ax.yaxis.tick_right() ax.imshow(channels[i], cmap='Oranges', vmin=0, vmax=1, interpolation='none') if flip: ax.invert_yaxis() else: ax.invert_xaxis() ax.tick_params(axis='both', length=0) return plt.show() class ShogiAnimation: """""" def __init__(self, record: miniosl.MiniRecord, offset: int = 0): self.record = miniosl.MiniRecord(record) self.fig = miniosl.ShogiFig(record.initial_state) self.offset = offset self.last_n = None def _start(self): state = self.record.replay(self.offset) return self.fig.set_state(state, move_number=self.offset) def __call__(self, n): if n == 0: self.last_n = n return self._start() move = self.record.moves[n-1] if n != self.last_n + 1: logging.warning(f'{self.last_n=} v.s. {n=}') self.last_n = n state = self.record.replay(self.offset + n) return self.fig.set_state(state, move_number=self.offset + n, last_to=move.dst) self.last_n = n return self.fig.make_move(move) def animate(self, frames, **kwargs): import matplotlib.animation if plt.rcParams["animation.html"] == 'none': plt.rc('animation', html='jshtml') frames = min(frames, self.record.move_size() - self.offset + 1) return matplotlib.animation.FuncAnimation( self.fig.fig, self, init_func=self._start, frames=frames, **kwargs, blit=True, )