Files
katrain-qt/katrain/gui/badukpan.py
T
485b55c42d 1.11.1 (#520)
* certificate fix

* Added a sound effect for the capturing of stones (#523)

* fix cache missing

* Fixes for some edge cases for the capturing sound effect (#526)

* Added a sound effect for the capturing of stones

* Fixed edge cases for the capturing sound effect

* minor bug fix

* Refactoring sounds for playing a move

* simplify capture sound, upgrade black

* fix handicap

* fix stone sound bug, note on sleep deadlock

* board rotation (#532)

* implementation of the rotation of the board view and some refactoring (#531)

* minor cleanup

Co-authored-by: Patrick <63347771+Funkenschlag1@users.noreply.github.com>
Co-authored-by: Sander Land <sander.land@cognite.com>

Co-authored-by: Sander Land <sander@chatdesk.com>
Co-authored-by: Patrick <63347771+Funkenschlag1@users.noreply.github.com>
Co-authored-by: Sander Land <sander.land@cognite.com>
2022-06-23 19:14:56 +02:00

1047 lines
49 KiB
Python

import math
import time
from typing import List, Optional
import numpy as np
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.graphics.context_instructions import Color
from kivy.graphics.vertex_instructions import Ellipse, Line, Rectangle
from kivy.metrics import dp
from kivy.properties import BooleanProperty, ListProperty, NumericProperty, ObjectProperty
from kivy.uix.dropdown import DropDown
from kivy.uix.widget import Widget
from kivymd.app import MDApp
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.floatlayout import MDFloatLayout
from katrain.core.constants import (
MODE_PLAY,
OUTPUT_DEBUG,
OUTPUT_EXTRA_DEBUG,
STATUS_TEACHING,
TOP_MOVE_DELTA_SCORE,
TOP_MOVE_DELTA_WINRATE,
TOP_MOVE_NOTHING,
TOP_MOVE_OPTIONS,
TOP_MOVE_SCORE,
TOP_MOVE_VISITS,
TOP_MOVE_WINRATE,
)
from katrain.core.game import Move
from katrain.core.lang import i18n
from katrain.core.utils import evaluation_class, format_visits, var_to_grid, json_truncate_arrays
from katrain.gui.kivyutils import draw_circle, draw_text, cached_texture
from katrain.gui.popups import I18NPopup, ReAnalyzeGamePopup, GameReportPopup, TsumegoFramePopup
from katrain.gui.theme import Theme
class BadukPanWidget(Widget):
def __init__(self, **kwargs):
super(BadukPanWidget, self).__init__(**kwargs)
self.trainer_config = {}
self.ghost_stone = []
self.gridpos = np.zeros((0,0,2))
self.initial_gridpos_x = []
self.initial_gridpos_y = []
self.rotation_degree = 0
self.grid_size = 0
self.stone_size = 0
self.selecting_region_of_interest = False
self.region_of_interest = []
self.draw_coords_enabled = True
self.active_pv_moves = []
self.animating_pv = None
self.animating_pv_index = None
self.last_mouse_pos = (0, 0)
Window.bind(mouse_pos=self.on_mouse_pos)
self.redraw_board_contents_trigger = Clock.create_trigger(self.draw_board_contents, 0.05)
self.redraw_trigger = Clock.create_trigger(self.redraw, 0.05)
self.redraw_hover_contents_trigger = Clock.create_trigger(self.draw_hover_contents, 0.01)
self.bind(size=self.redraw_trigger, pos=self.redraw_trigger)
Clock.schedule_interval(self.animate_pv, 0.1)
def toggle_coordinates(self):
self.draw_coords_enabled = not self.draw_coords_enabled
self.redraw_trigger()
return self.draw_coords_enabled
def get_enable_coordinates(self):
return self.draw_coords_enabled
# stone placement functions
def _find_closest(self, pos_x, pos_y):
xd = abs(self.gridpos[0,0,0] - pos_x)
xp = 0
yd = abs(self.gridpos[0,0,1] - pos_y)
yp = 0
for y in range(0, len(self.gridpos)):
for x in range(0, len(self.gridpos[0])):
if abs(self.gridpos[y,x,0] - pos_x) <= xd and abs(self.gridpos[y,x,1] - pos_y) <= yd:
xd = abs(self.gridpos[y,x,0] - pos_x)
xp = x
yd = abs(self.gridpos[y,x,1] - pos_y)
yp = y
return xd, xp, yd, yp
def check_next_move_ghost(self, touch):
if not self.initial_gridpos_x:
return
xd, xp, yd, yp = self._find_closest(touch.x, touch.y)
prev_ghost = self.ghost_stone
if max(yd, xd) < self.grid_size / 2 and (xp, yp) not in [m.coords for m in self.katrain.game.stones]:
self.ghost_stone = (xp, yp)
else:
self.ghost_stone = None
if prev_ghost != self.ghost_stone:
self.redraw_hover_contents_trigger()
def update_box_selection(self, touch, second_point=True):
if not self.initial_gridpos_x:
return
_, xp, _, yp = self._find_closest(touch.x, touch.y)
if second_point and len(self.region_of_interest) == 4:
self.region_of_interest[1] = xp
self.region_of_interest[3] = yp
else:
self.region_of_interest = [xp, xp, yp, yp]
self.redraw_hover_contents_trigger()
def on_touch_down(self, touch):
animating_pv = self.animating_pv
if "button" in touch.profile:
if touch.button == "left":
if self.selecting_region_of_interest:
self.update_box_selection(touch, second_point=False)
else:
self.check_next_move_ghost(touch)
elif touch.button == "middle" and animating_pv:
pv, node, _, _ = animating_pv
upto = self.animating_pv_index or 1e9
for i, gtpmove in enumerate(pv):
if i <= upto: # up to move when scrolling, or all
node = node.play(Move.from_gtp(gtpmove, node.next_player))
node.analyze(self.katrain.engine, analyze_fast=True)
self.katrain.controls.move_tree.redraw_tree_trigger()
if ("button" not in touch.profile) or (touch.button not in ["scrollup", "scrolldown", "middle"]):
self.set_animating_pv(None, None) # any click/touch kills PV from label/move
def on_touch_move(self, touch):
if "button" in touch.profile and touch.button != "left":
return
if self.selecting_region_of_interest:
return self.update_box_selection(touch)
else:
return self.check_next_move_ghost(touch)
def on_mouse_pos(self, *args): # https://gist.github.com/opqopq/15c707dc4cffc2b6455f
if self.get_root_window(): # don't proceed if I'm not displayed <=> If have no parent
pos = args[1]
rel_pos = self.to_widget(*pos) # compensate for relative layout
inside = self.collide_point(*rel_pos)
if inside and self.active_pv_moves and not self.selecting_region_of_interest:
near_move = [
(pv, node)
for move, pv, node in self.active_pv_moves
if move[0] < len(self.gridpos[0])
and move[1] < len(self.gridpos)
and abs(rel_pos[0] - self.gridpos[move[1],move[0],0]) < self.grid_size / 2
and abs(rel_pos[1] - self.gridpos[move[1],move[0],1]) < self.grid_size / 2
]
if near_move:
self.set_animating_pv(near_move[0][0], near_move[0][1])
elif self.animating_pv is not None:
self.set_animating_pv(None, None) # any click kills PV from label/move
if inside and self.animating_pv is not None:
d_sq = (pos[0] - self.animating_pv[3][0]) ** 2 + (pos[1] - self.animating_pv[3][1])
if d_sq > 2 * self.stone_size**2: # move too far from where it was activated
self.set_animating_pv(None, None) # any click kills PV from label/move
self.last_mouse_pos = pos
def on_touch_up(self, touch):
if ("button" in touch.profile and touch.button != "left") or not self.initial_gridpos_x:
return
katrain = self.katrain
if self.selecting_region_of_interest:
if len(self.region_of_interest) == 4:
self.katrain.game.set_region_of_interest(self.region_of_interest)
self.region_of_interest = []
self.selecting_region_of_interest = False
elif self.ghost_stone and ("button" not in touch.profile or touch.button == "left"):
game = self.katrain and self.katrain.game
current_node = game and self.katrain.game.current_node
if (
current_node
and not current_node.children
and not self.katrain.next_player_info.ai
and not self.katrain.controls.timer.paused
and self.katrain.play_analyze_mode == MODE_PLAY
and self.katrain.config("timer/main_time", 0) * 60 - game.main_time_used <= 0
and current_node.time_used < self.katrain.config("timer/minimal_use", 0)
):
self.katrain.controls.set_status(
i18n._("move too fast").format(num=self.katrain.config("timer/minimal_use", 0)), STATUS_TEACHING
)
else:
katrain("play", self.ghost_stone)
elif not self.ghost_stone:
xd, xp, yd, yp = self._find_closest(touch.x, touch.y)
nodes_here = [
node for node in katrain.game.current_node.nodes_from_root if node.move and node.move.coords == (xp, yp)
]
if nodes_here and max(yd, xd) < self.grid_size / 2: # load old comment
if touch.is_double_tap: # navigate to move
katrain.game.set_current_node(nodes_here[-1].parent)
katrain.update_state()
else: # load comments & pv
katrain.log(
f"\nAnalysis:\n{json_truncate_arrays(nodes_here[-1].analysis,lim=5)}", OUTPUT_EXTRA_DEBUG
)
katrain.log(
f"\nParent Analysis:\n{json_truncate_arrays(nodes_here[-1].parent.analysis,lim=5)}",
OUTPUT_EXTRA_DEBUG,
)
katrain.log(
f"\nRoot Stats:\n{json_truncate_arrays(nodes_here[-1].analysis['root'],lim=5)}", OUTPUT_DEBUG
)
katrain.controls.info.text = nodes_here[-1].comment(sgf=True)
katrain.controls.active_comment_node = nodes_here[-1]
if nodes_here[-1].parent.analysis_exists:
self.set_animating_pv(nodes_here[-1].parent.candidate_moves[0]["pv"], nodes_here[-1].parent)
self.ghost_stone = None
self.redraw_hover_contents_trigger() # remove ghost
# drawing functions
def redraw(self, *_args):
self.draw_board()
self.draw_board_contents()
def draw_stone(self, x, y, player, alpha=1, innercol=None, evalcol=None, evalscale=1.0, scale=1.0):
stone_size = self.stone_size * scale
Color(1, 1, 1, alpha)
Rectangle(
pos=(self.gridpos[y,x,0] - stone_size, self.gridpos[y,x,1] - stone_size),
size=(2 * stone_size, 2 * stone_size),
texture=cached_texture(Theme.STONE_TEXTURE[player]),
)
if evalcol:
eval_radius = math.sqrt(evalscale) # scale area by evalscale
evalsize = self.stone_size * (
Theme.EVAL_DOT_MIN_SIZE + eval_radius * (Theme.EVAL_DOT_MAX_SIZE - Theme.EVAL_DOT_MIN_SIZE)
)
Color(*evalcol)
Rectangle(
pos=(self.gridpos[y,x,0] - evalsize, self.gridpos[y,x,1] - evalsize),
size=(2 * evalsize, 2 * evalsize),
texture=cached_texture(Theme.EVAL_DOT_TEXTURE),
)
if innercol:
Color(*innercol)
inner_size = stone_size * 0.8
Rectangle(
pos=(self.gridpos[y,x,0] - inner_size, self.gridpos[y,x,1] - inner_size),
size=(2 * inner_size, 2 * inner_size),
texture=cached_texture(Theme.LAST_MOVE_TEXTURE),
)
def eval_color(self, points_lost, show_dots_for_class: List[bool] = None) -> Optional[List[float]]:
i = evaluation_class(points_lost, self.trainer_config["eval_thresholds"])
colors = Theme.EVAL_COLORS[self.trainer_config["theme"]]
if show_dots_for_class is None or show_dots_for_class[i]:
return colors[i]
def draw_board(self, *_args):
if not (self.katrain and self.katrain.game):
return
katrain = self.katrain
board_size_x, board_size_y = katrain.game.board_size
with self.canvas.before:
self.canvas.before.clear()
grid_spaces_margin_x, grid_spaces_margin_y = self.get_grid_spaces_margins()
h = round(self.height, 4)
w = round(self.width, 4)
x_grid_spaces, y_grid_spaces = self.calculate_grid_spaces(board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y)
self.grid_size = self.calculate_grid_size(w, h, x_grid_spaces, y_grid_spaces)
board_width_with_margins, board_height_with_margins = self.calculate_board_margins(x_grid_spaces, y_grid_spaces, self.grid_size)
extra_px_margin_x, extra_px_margin_y = self.calculate_extra_px_margins(w, h, board_width_with_margins, board_height_with_margins)
self.stone_size = self.calculate_stone_size(self.grid_size)
# if not initiated or if changed
if (
(len(self.gridpos) == 0 or abs(self.pos[0] + extra_px_margin_x
+ math.floor(grid_spaces_margin_x[0] * self.grid_size + 0.5) - self.initial_gridpos_x[0]) > 0.001)
):
self.initial_gridpos_x, self.initial_gridpos_y = self.initialize_gridpos_x_y(board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y, extra_px_margin_x, extra_px_margin_y, self.grid_size)
if self.rotation_degree == 0:
self.initialize_gridpos()
if (self.rotation_degree == 90 or self.rotation_degree == 270) and board_size_x != board_size_y:
board_size_y, board_size_x = katrain.game.board_size
x_grid_spaces, y_grid_spaces = self.calculate_grid_spaces(board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y)
self.grid_size = self.calculate_grid_size(w, h, x_grid_spaces, y_grid_spaces)
self.stone_size = self.calculate_stone_size(self.grid_size)
current_gridpos_x, current_gridpos_y = self.calculate_rotated_gridpos()
else:
current_gridpos_x = self.initial_gridpos_x[:]
current_gridpos_y = self.initial_gridpos_y[:]
# if window size got changed
if (
(self.gridpos[0,0,0] not in current_gridpos_x or self.gridpos[0,0,1] not in current_gridpos_y
or (self.gridpos[len(self.gridpos) - 1,len(self.gridpos[0]) - 1,0] in current_gridpos_x
or self.gridpos[len(self.gridpos) - 1,len(self.gridpos[0]) - 1,1] in current_gridpos_y))
):
self.resize_board()
self.draw_board_background(katrain, current_gridpos_x, current_gridpos_y, x_grid_spaces, y_grid_spaces, grid_spaces_margin_x, grid_spaces_margin_y)
self.draw_lines(current_gridpos_x, current_gridpos_y)
self.draw_star_points(board_size_x, board_size_y)
self.draw_coordinates(current_gridpos_x, current_gridpos_y)
def get_grid_spaces_margins(self):
if self.draw_coords_enabled:
grid_spaces_margin_x = [1.5, 0.75] # left, right
grid_spaces_margin_y = [1.5, 0.75] # bottom, top
else: # no coordinates means remove the offset
grid_spaces_margin_x = [0.75, 0.75] # left, right
grid_spaces_margin_y = [0.75, 0.75] # bottom, top
return grid_spaces_margin_x, grid_spaces_margin_y
def calculate_grid_spaces(self, board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y):
x_grid_spaces = board_size_x - 1 + sum(grid_spaces_margin_x)
y_grid_spaces = board_size_y - 1 + sum(grid_spaces_margin_y)
return x_grid_spaces, y_grid_spaces
def calculate_grid_size(self, width, height, x_grid_spaces, y_grid_spaces):
# grid size is rounded to an integer to avoid rounding errors that
# produce tiny gaps between shaded grid squares
return math.floor(min(width / x_grid_spaces, height / y_grid_spaces) + 0.1)
def calculate_board_margins(self, x_grid_spaces, y_grid_spaces, grid_size):
board_width_with_margins = x_grid_spaces * grid_size
board_height_with_margins = y_grid_spaces * grid_size
return board_width_with_margins, board_height_with_margins
def calculate_extra_px_margins(self, width, height, board_width_with_margins, board_height_with_margins):
extra_px_margin_x = round((width - board_width_with_margins) / 2, 4)
extra_px_margin_y = round((height - board_height_with_margins) / 2, 4)
return extra_px_margin_x, extra_px_margin_y
def calculate_stone_size(self, grid_size):
return grid_size * Theme.STONE_SIZE
def initialize_gridpos(self):
self.gridpos = np.zeros((len(self.initial_gridpos_y), len(self.initial_gridpos_x),2))
for y in range(len(self.initial_gridpos_y)):
for x in range(len(self.initial_gridpos_x)):
self.gridpos[y][x] = [self.initial_gridpos_x[x], self.initial_gridpos_y[y]]
def initialize_gridpos_x_y(self, board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y, extra_px_margin_x, extra_px_margin_y, grid_size):
gridpos_x = [
round(self.pos[0] + extra_px_margin_x + math.floor((grid_spaces_margin_x[0] + i) * grid_size + 0.5), 4)
for i in range(board_size_x)
]
gridpos_y = [
round(self.pos[1] + extra_px_margin_y + math.floor((grid_spaces_margin_y[0] + i) * grid_size + 0.5), 4)
for i in range(board_size_y)
]
return gridpos_x, gridpos_y
def calculate_rotated_gridpos(self):
board_size_y, board_size_x = self.katrain.game.board_size
grid_spaces_margin_x, grid_spaces_margin_y = self.get_grid_spaces_margins()
h = round(self.height, 4)
w = round(self.width, 4)
x_grid_spaces, y_grid_spaces = self.calculate_grid_spaces(board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y)
grid_size = self.calculate_grid_size(w, h , x_grid_spaces, y_grid_spaces)
board_width_with_margins, board_height_with_margins = self.calculate_board_margins(x_grid_spaces, y_grid_spaces, grid_size)
extra_px_margin_x, extra_px_margin_y = self.calculate_extra_px_margins(w, h, board_width_with_margins, board_height_with_margins)
return self.initialize_gridpos_x_y(board_size_x, board_size_y, grid_spaces_margin_x, grid_spaces_margin_y, extra_px_margin_x, extra_px_margin_y, grid_size)
def resize_board(self):
rotated_gridpos_x, rotated_gridpos_y = self.calculate_rotated_gridpos()
current_gridpos_x = []
current_gridpos_y = []
for yi in range(len(self.gridpos)):
for xi in range(len(self.gridpos[0])):
current_gridpos_x.append(self.gridpos[yi,xi,0])
current_gridpos_y.append(self.gridpos[yi,xi,1])
sorted_current_gridpos_x = list(set(current_gridpos_x))
sorted_current_gridpos_x.sort()
sorted_current_gridpos_y = list(set(current_gridpos_y))
sorted_current_gridpos_y.sort()
for yi in range(len(self.gridpos)):
for xi in range(len(self.gridpos[0])):
index_x = sorted_current_gridpos_x.index(self.gridpos[yi,xi,0])
index_y = sorted_current_gridpos_y.index(self.gridpos[yi,xi,1])
if self.rotation_degree == 90 or self.rotation_degree == 270:
self.gridpos[yi][xi] = [rotated_gridpos_x[index_x], rotated_gridpos_y[index_y]]
else:
self.gridpos[yi][xi] = [self.initial_gridpos_x[index_x], self.initial_gridpos_y[index_y]]
def draw_board_background(self, katrain, gridpos_x, gridpos_y, x_grid_spaces, y_grid_spaces, grid_spaces_margin_x, grid_spaces_margin_y):
if katrain.game.insert_mode:
Color(*Theme.INSERT_BOARD_COLOR_TINT) # dreamy
else:
Color(*Theme.BOARD_COLOR_TINT) # image is a bit too light
Rectangle(
pos=(
gridpos_x[0] - self.grid_size * grid_spaces_margin_x[0],
gridpos_y[0] - self.grid_size * grid_spaces_margin_y[0],
),
size=(self.grid_size * x_grid_spaces, self.grid_size * y_grid_spaces),
texture=cached_texture(Theme.BOARD_TEXTURE),
)
def draw_lines(self, gridpos_x, gridpos_y):
Color(*Theme.LINE_COLOR)
for i in range(len(gridpos_x)):
Line(points=[(gridpos_x[i], gridpos_y[0]), (gridpos_x[i], gridpos_y[-1])])
for i in range(len(gridpos_y)):
Line(points=[(gridpos_x[0], gridpos_y[i]), (gridpos_x[-1], gridpos_y[i])])
def draw_star_points(self, board_size_x, board_size_y):
def star_point_coords(size):
star_point_pos = 3 if size <= 11 else 4
if size < 7:
return []
return [star_point_pos - 1, size - star_point_pos] + (
[int(size / 2)] if size % 2 == 1 and size > 7 else []
)
starpt_size = self.grid_size * Theme.STARPOINT_SIZE
for x in star_point_coords(board_size_x):
for y in star_point_coords(board_size_y):
draw_circle((self.gridpos[y,x,0], self.gridpos[y,x,1]), starpt_size, Theme.LINE_COLOR)
def draw_coordinates(self, gridpos_x, gridpos_y):
if self.draw_coords_enabled:
board_size_x, board_size_y = self.katrain.game.board_size
Color(0.25, 0.25, 0.25)
coord_offset = round(self.grid_size * 1.5 / 2, 12)
if (self.rotation_degree == 90 or self.rotation_degree == 270) and board_size_x != board_size_y:
board_size_y, board_size_x = self.katrain.game.board_size
for i in range(board_size_x):
draw_text(
pos=(gridpos_x[i], gridpos_y[0] - coord_offset),
text=self.get_x_coordinate_text(i, board_size_x),
font_size=self.grid_size / 1.5,
font_name="Roboto",
)
for i in range(board_size_y):
draw_text(
pos=(gridpos_x[0] - coord_offset, gridpos_y[i]),
text=self.get_y_coordinate_text(i, board_size_y),
font_size=self.grid_size / 1.5,
font_name="Roboto",
)
def get_x_coordinate_text(self, i, board_size_x):
x_text = Move.GTP_COORD[i]
if (self.rotation_degree == 90):
x_text = str(i + 1)
elif (self.rotation_degree == 180):
x_text = Move.GTP_COORD[board_size_x - i - 1]
elif (self.rotation_degree == 270):
x_text = str(board_size_x - i)
return x_text
def get_y_coordinate_text(self, i, board_size_y):
y_text = str(i + 1)
if (self.rotation_degree == 90):
y_text = Move.GTP_COORD[board_size_y - i - 1]
elif (self.rotation_degree == 180):
y_text = str(board_size_y - i)
elif (self.rotation_degree == 270):
y_text = Move.GTP_COORD[i]
return y_text
def draw_board_contents(self, *_args):
if not (self.katrain and self.katrain.game):
return
katrain = self.katrain
board_size_x, board_size_y = katrain.game.board_size
if self.gridpos.shape[0] < board_size_x or self.gridpos.shape[1] < board_size_y:
return # race condition
show_n_eval = self.trainer_config.get("eval_on_show_last", 3)
with self.canvas:
self.canvas.clear()
current_node = katrain.game.current_node
# ownership - allow one move out of date for smooth animation,
# drawn first so the board is shaded underneath all other elements.
ownership = current_node.ownership or (current_node.parent and current_node.parent.ownership)
if katrain.analysis_controls.ownership.active and ownership:
if (
current_node.children
and katrain.controls.status_state[1] == STATUS_TEACHING
and current_node.children[-1].auto_undo
and current_node.children[-1].ownership
): # loss
loss_grid = var_to_grid(
[a - b for a, b in zip(current_node.children[-1].ownership, ownership)],
(board_size_x, board_size_y),
)
for y in range(board_size_y - 1, -1, -1):
for x in range(board_size_x):
loss = max(0, (-1 if current_node.children[-1].move.player == "B" else 1) * loss_grid[y][x])
if loss > 0:
Color(*Theme.EVAL_COLORS[self.trainer_config["theme"]][1][:3], loss)
Rectangle(
pos=(
self.gridpos[y,x,0] - self.grid_size / 2,
self.gridpos[y,x,1] - self.grid_size / 2,
),
size=(self.grid_size, self.grid_size),
)
else:
ownership_grid = var_to_grid(ownership, (board_size_x, board_size_y))
for y in range(board_size_y - 1, -1, -1):
for x in range(board_size_x):
ix_owner = "B" if ownership_grid[y][x] > 0 else "W"
Color(
*Theme.STONE_COLORS[ix_owner][:3], abs(ownership_grid[y][x]) * Theme.OWNERSHIP_MAX_ALPHA
)
Rectangle(
pos=(self.gridpos[y,x,0] - self.grid_size / 2, self.gridpos[y,x,1] - self.grid_size / 2),
size=(self.grid_size, self.grid_size),
)
# stones
all_dots_off = not katrain.analysis_controls.eval.active
has_stone = {}
drawn_stone = {}
for m in katrain.game.stones:
has_stone[m.coords] = m.player
show_dots_for = {
p: self.trainer_config["eval_show_ai"] or katrain.players_info[p].human for p in Move.PLAYERS
}
show_dots_for_class = self.trainer_config["show_dots"]
nodes = katrain.game.current_node.nodes_from_root
realized_points_lost = None
for i, node in enumerate(nodes[::-1]): # reverse order!
points_lost = node.points_lost
evalscale = 1
if points_lost and realized_points_lost:
if points_lost <= 0.5 and realized_points_lost <= 1.5:
evalscale = 0
else:
evalscale = min(1, max(0, realized_points_lost / points_lost))
placements = node.placements
for m in node.moves + placements:
if has_stone.get(m.coords) and not drawn_stone.get(m.coords): # skip captures, last only for
move_eval_on = not all_dots_off and show_dots_for.get(m.player) and i < show_n_eval
if move_eval_on and points_lost is not None:
evalcol = self.eval_color(points_lost, show_dots_for_class)
else:
evalcol = None
inner = Theme.STONE_COLORS[m.opponent] if i == 0 and m not in placements else None
drawn_stone[m.coords] = m.player
self.draw_stone(
x=m.coords[0],
y=m.coords[1],
player=m.player,
innercol=inner,
evalcol=evalcol,
evalscale=evalscale,
)
realized_points_lost = node.parent_realized_points_lost
if katrain.game.current_node.is_root and katrain.debug_level >= 3: # secret ;)
for y in range(0, board_size_y):
evalcol = self.eval_color(16 * y / board_size_y)
self.draw_stone(0, y, "B", evalcol=evalcol, evalscale=y / (board_size_y - 1))
self.draw_stone(1, y, "B", innercol=Theme.STONE_COLORS["W"], evalcol=evalcol)
self.draw_stone(2, y, "W", evalcol=evalcol, evalscale=y / (board_size_y - 1))
self.draw_stone(3, y, "W", innercol=Theme.STONE_COLORS["B"], evalcol=evalcol)
policy = current_node.policy
if (
not policy
and current_node.parent
and current_node.parent.policy
and katrain.last_player_info.ai
and katrain.next_player_info.ai
):
policy = (
current_node.parent.policy
) # in the case of AI self-play we allow the policy to be one step out of date
pass_btn = katrain.board_controls.pass_btn
pass_btn.canvas.after.clear()
if katrain.analysis_controls.policy.active and policy:
policy_grid = var_to_grid(policy, (board_size_x, board_size_y))
best_move_policy = max(*policy)
colors = Theme.EVAL_COLORS[self.trainer_config["theme"]]
text_lb = 0.01 * 0.01
for y in range(board_size_y - 1, -1, -1):
for x in range(board_size_x):
move_policy = policy_grid[y][x]
if move_policy < 0:
continue
pol_order = max(0, 5 + int(math.log10(max(1e-9, move_policy - 1e-9))))
if move_policy > text_lb:
draw_circle(
(self.gridpos[y,x,0], self.gridpos[y,x,1]),
self.stone_size * Theme.HINT_SCALE * 0.98,
Theme.APPROX_BOARD_COLOR,
)
scale = 0.95
else:
scale = 0.5
draw_circle(
(self.gridpos[y,x,0], self.gridpos[y,x,1]),
Theme.HINT_SCALE * self.stone_size * scale,
(*colors[pol_order][:3], Theme.POLICY_ALPHA),
)
if move_policy > text_lb:
Color(*Theme.HINT_TEXT_COLOR)
draw_text(
pos=(self.gridpos[y,x,0], self.gridpos[y,x,1]),
text=f"{100 * move_policy :.2f}"[:4] + "%",
font_name="Roboto",
font_size=self.grid_size / 4,
halign="center",
)
if move_policy == best_move_policy:
Color(*Theme.TOP_MOVE_BORDER_COLOR[:3], Theme.POLICY_ALPHA)
Line(
circle=(
self.gridpos[y,x,0],
self.gridpos[y,x,1],
self.stone_size - dp(1.2),
),
width=dp(2),
)
with pass_btn.canvas.after:
move_policy = policy[-1]
pol_order = 5 - int(-math.log10(max(1e-9, move_policy - 1e-9)))
if pol_order >= 0:
draw_circle(
(pass_btn.pos[0] + pass_btn.width / 2, pass_btn.pos[1] + pass_btn.height / 2),
pass_btn.height / 2,
(*colors[pol_order][:3], Theme.GHOST_ALPHA),
)
self.redraw_hover_contents_trigger()
def draw_roi_box(self, region_of_interest, width:float=2):
xmin, xmax, ymin, ymax = region_of_interest
Color(*Theme.REGION_BORDER_COLOR)
Line(
rectangle=(
self.gridpos[ymin,xmin,0] - self.grid_size / 3,
self.gridpos[ymin,xmin,1] - self.grid_size / 3,
(xmax - xmin + 2 / 3) * self.grid_size,
(ymax - ymin + 2 / 3) * self.grid_size,
),
width=width,
)
def format_loss(self, x: float) -> str:
if self.trainer_config.get("extra_precision"):
if abs(x) < 0.005:
return "0.0"
if 0 < x <= 0.995:
return "+" + f"{x:.2f}"[1:]
elif -0.995 <= x < 0:
return "-" + f"{x:.2f}"[2:]
return f"{x:+.1f}"
def draw_hover_contents(self, *_args):
ghost_alpha = Theme.GHOST_ALPHA
katrain = self.katrain
game_ended = katrain.game.end_result
current_node = katrain.game.current_node
next_player = current_node.next_player
board_size_x, board_size_y = katrain.game.board_size
if len(self.gridpos[0]) < board_size_x or len(self.gridpos) < board_size_y:
return # race condition
with self.canvas.after:
self.canvas.after.clear()
self.active_pv_moves = []
# hints or PV
hint_moves = []
if (
katrain.analysis_controls.hints.active
and not katrain.analysis_controls.policy.active
and not game_ended
):
hint_moves = current_node.candidate_moves
elif katrain.controls.status_state[1] == STATUS_TEACHING: # show score hint for teaching undo
hint_moves = [
m
for m in current_node.candidate_moves
for c in current_node.children
if c.move and c.auto_undo and c.move.gtp() == m["move"]
]
top_move_coords = None
child_moves = {c.move.gtp() for c in current_node.children if c.move}
if hint_moves:
low_visits_threshold = katrain.config("trainer/low_visits", 25)
top_moves_show = [
opt
for opt in [
katrain.config("trainer/top_moves_show"),
katrain.config("trainer/top_moves_show_secondary"),
]
if opt in TOP_MOVE_OPTIONS and opt != TOP_MOVE_NOTHING
]
for move_dict in hint_moves:
move = Move.from_gtp(move_dict["move"])
if move.coords is not None:
engine_best_move = move_dict.get("order", 99) == 0
scale = Theme.HINT_SCALE
text_on = True
alpha = Theme.HINTS_ALPHA
if (
move_dict["visits"] < low_visits_threshold
and not engine_best_move
and not move_dict["move"] in child_moves
):
scale = Theme.UNCERTAIN_HINT_SCALE
text_on = False
alpha = Theme.HINTS_LO_ALPHA
if "pv" in move_dict:
self.active_pv_moves.append((move.coords, move_dict["pv"], current_node))
else:
katrain.log(f"PV missing for move_dict {move_dict}", OUTPUT_DEBUG)
evalsize = self.stone_size * scale
evalcol = self.eval_color(move_dict["pointsLost"])
if text_on and top_moves_show: # remove grid lines using a board colored circle
draw_circle(
(self.gridpos[move.coords[1],move.coords[0],0], self.gridpos[move.coords[1],move.coords[0],1]),
self.stone_size * scale * 0.98,
Theme.APPROX_BOARD_COLOR,
)
Color(*evalcol[:3], alpha)
Rectangle(
pos=(self.gridpos[move.coords[1],move.coords[0],0] - evalsize, self.gridpos[move.coords[1],move.coords[0],1] - evalsize),
size=(2 * evalsize, 2 * evalsize),
texture=cached_texture(Theme.TOP_MOVE_TEXTURE),
)
if text_on and top_moves_show: # TODO: faster if not sized?
keys = {"size": self.grid_size / 3, "smallsize": self.grid_size / 3.33}
player_sign = current_node.player_sign(next_player)
if len(top_moves_show) == 1:
fmt = "[size={size:.0f}]{" + top_moves_show[0] + "}[/size]"
else:
fmt = (
"[size={size:.0f}]{"
+ top_moves_show[0]
+ "}[/size]\n[size={smallsize:.0f}]{"
+ top_moves_show[1]
+ "}[/size]"
)
keys[TOP_MOVE_DELTA_SCORE] = self.format_loss(-move_dict["pointsLost"])
# def fmt_maybe_missing(arg,sign,digits=1):
# return str(round(sign*arg,digits)) if arg is not None else "N/A"
keys[TOP_MOVE_SCORE] = f"{player_sign * move_dict['scoreLead']:.1f}"
winrate = move_dict["winrate"] if player_sign == 1 else 1 - move_dict["winrate"]
keys[TOP_MOVE_WINRATE] = f"{winrate*100:.1f}"
keys[TOP_MOVE_DELTA_WINRATE] = f"{-move_dict['winrateLost']:+.1%}"
keys[TOP_MOVE_VISITS] = format_visits(move_dict["visits"])
Color(*Theme.HINT_TEXT_COLOR)
draw_text(
pos=(self.gridpos[move.coords[1],move.coords[0],0], self.gridpos[move.coords[1],move.coords[0],1]),
text=fmt.format(**keys),
font_name="Roboto",
markup=True,
line_height=0.85,
halign="center",
)
if engine_best_move:
top_move_coords = move.coords
Color(*Theme.TOP_MOVE_BORDER_COLOR)
Line(
circle=(
self.gridpos[move.coords[1],move.coords[0],0],
self.gridpos[move.coords[1],move.coords[0],1],
self.stone_size - dp(1.2),
),
width=dp(1.2),
)
# children of current moves in undo / review
if katrain.analysis_controls.show_children.active:
for child_node in current_node.children:
move = child_node.move
if move and move.coords is not None:
if child_node.analysis_exists:
self.active_pv_moves.append(
(move.coords, [move.gtp()] + child_node.candidate_moves[0]["pv"], current_node)
)
if move.coords != top_move_coords: # for contrast
dashed_width = 18
Color(*Theme.NEXT_MOVE_DASH_CONTRAST_COLORS[child_node.player])
Line(
circle=(
self.gridpos[move.coords[1],move.coords[0],0],
self.gridpos[move.coords[1],move.coords[0],1],
self.stone_size - dp(1.2),
),
width=dp(1.2),
)
else:
dashed_width = 10
Color(*Theme.STONE_COLORS[child_node.player])
for s in range(0, 360, 30):
Line(
circle=(
self.gridpos[move.coords[1],move.coords[0],0],
self.gridpos[move.coords[1],move.coords[0],1],
self.stone_size - dp(1.2),
s,
s + dashed_width,
),
width=dp(1.2),
)
if self.selecting_region_of_interest and len(self.region_of_interest) == 4:
x1, x2, y1, y2 = self.region_of_interest
self.draw_roi_box([min(x1, x2), max(x1, x2), min(y1, y2), max(y1, y2)], width=dp(2))
else:
# hover next move ghost stone
if self.ghost_stone:
self.draw_stone(*self.ghost_stone, next_player, alpha=ghost_alpha)
animating_pv = self.animating_pv
if animating_pv:
pv, node, start_time, _ = animating_pv
up_to_move = self.get_animate_pv_index()
self.draw_pv(pv, node, up_to_move)
if getattr(self.katrain.game, "region_of_interest", None):
self.draw_roi_box(self.katrain.game.region_of_interest, width=dp(1.25))
# pass circle
if current_node.is_pass or game_ended:
if game_ended:
text = game_ended
katrain.controls.timer.paused = True
else:
text = i18n._("board-pass")
Color(*Theme.PASS_CIRCLE_COLOR)
center = (self.initial_gridpos_x[int(board_size_x / 2)], self.initial_gridpos_y[int(board_size_y / 2)])
size = min(self.width, self.height) * 0.227
Ellipse(pos=(center[0] - size / 2, center[1] - size / 2), size=(size, size))
Color(*Theme.PASS_CIRCLE_TEXT_COLOR)
draw_text(pos=center, text=text, font_size=size * 0.25, halign="center")
def animate_pv(self, _dt):
if self.animating_pv:
self.redraw_hover_contents_trigger()
def draw_pv(self, pv, node, up_to_move):
katrain = self.katrain
next_last_player = [node.next_player, Move.opponent_player(node.next_player)]
cn = katrain.game.current_node
if node != cn and node.parent != cn:
hide_node = cn
while hide_node and hide_node.move and hide_node != node:
if not hide_node.move.is_pass:
pos = (self.gridpos[hide_node.move.coords[1],hide_node.move.coords[0],0], self.gridpos[hide_node.move.coords[1],hide_node.move.coords[0],1])
draw_circle(pos, self.stone_size, [0.85, 0.68, 0.40, 0.8])
hide_node = hide_node.parent
for i, gtpmove in enumerate(pv):
if i > up_to_move:
return
move_player = next_last_player[i % 2]
coords = Move.from_gtp(gtpmove).coords
if coords is None: # tee-hee
sizefac = katrain.board_controls.pass_btn.size[1] / 2 / self.stone_size
board_coords = [
katrain.board_controls.pass_btn.pos[0]
+ katrain.board_controls.pass_btn.size[0]
+ self.stone_size * sizefac,
katrain.board_controls.pass_btn.pos[1] + katrain.board_controls.pass_btn.size[1] / 2,
]
else:
board_coords = (self.gridpos[coords[1],coords[0],0], self.gridpos[coords[1],coords[0],1])
sizefac = 1
stone_size = self.stone_size * sizefac
Color(1, 1, 1, 1)
Rectangle( # not sure why the -1 here, but seems to center better
pos=(board_coords[0] - stone_size - 1, board_coords[1] - stone_size),
size=(2 * stone_size + 1, 2 * stone_size + 1),
texture=cached_texture(Theme.STONE_TEXTURE[move_player]),
)
Color(*Theme.PV_TEXT_COLORS[move_player])
draw_text(pos=board_coords, text=str(i + 1), font_size=self.grid_size * sizefac / 1.45, font_name="Roboto")
def set_animating_pv(self, pv, node):
self.animating_pv_index = None
if pv is None:
self.animating_pv = None
elif node is not None and (
not self.animating_pv or not (self.animating_pv[0] == pv and self.animating_pv[1] == node)
):
self.animating_pv = (pv, node, time.time(), self.last_mouse_pos)
self.redraw_hover_contents_trigger()
def adjust_animate_pv_index(self, delta=1):
self.animating_pv_index = max(0, self.get_animate_pv_index() + delta)
def get_animate_pv_index(self):
if self.animating_pv_index is None:
if self.animating_pv:
pv, node, start_time, _ = self.animating_pv
delay = self.katrain.config("general/anim_pv_time", 0.5)
return (time.time() - start_time) / max(delay, 0.1)
else:
return 0
return self.animating_pv_index
def rotate_gridpos(self):
board_size_x, board_size_y = self.katrain.game.board_size
if board_size_x != board_size_y:
if self.rotation_degree == 90 or self.rotation_degree == 270:
rotated_gridpos_x, rotated_gridpos_y = self.calculate_rotated_gridpos()
diff = round(abs(rotated_gridpos_x[0] - rotated_gridpos_y[0]), 4)
x0 = rotated_gridpos_x[0]
y0 = rotated_gridpos_y[0]
else:
diff = round(abs(self.initial_gridpos_x[0] - self.initial_gridpos_y[0]), 4)
x0 = self.initial_gridpos_x[0]
y0 = self.initial_gridpos_y[0]
pos = self.gridpos.tolist()
for yi in range(len(self.gridpos)):
for xi in range(len(self.gridpos[0])):
if (self.rotation_degree == 90 or self.rotation_degree == 270):
gridpos_x = pos[len(self.gridpos) - 1 - yi][:]
else:
gridpos_x = pos[yi][:]
gridpos_x.reverse()
x = pos[yi][xi][1]
y = gridpos_x[xi][0]
if x0 > y0:
x = round(x + diff, 4)
y = round(y - diff, 4)
elif y0 > x0:
x = round(x - diff, 4)
y = round(y + diff, 4)
self.gridpos[yi][xi] = (x, y)
else:
self.gridpos = np.rot90(self.gridpos, -1, (0, 1))
self.rotation_degree += 90
if self.rotation_degree == 360:
self.rotation_degree = 0
if board_size_x != board_size_y:
self.resize_board()
Clock.schedule_once(self.redraw)
def show_pv_from_comments(self, pv_str):
self.set_animating_pv(pv_str[1:].split(" "), self.katrain.controls.active_comment_node.parent)
class AnalysisDropDown(DropDown):
def open_game_analysis_popup(self, *_args):
analysis_popup = I18NPopup(title_key="analysis:game", size=[dp(500), dp(350)], content=ReAnalyzeGamePopup())
analysis_popup.content.popup = analysis_popup
analysis_popup.content.katrain = MDApp.get_running_app().gui
analysis_popup.open()
def open_report_popup(self, *_args):
report_popup = I18NPopup(
title_key="analysis:report",
size=[dp(750), dp(750)],
content=GameReportPopup(katrain=MDApp.get_running_app().gui),
)
report_popup.content.popup = report_popup
report_popup.open()
def open_tsumego_frame_popup(self, *_args):
analysis_popup = I18NPopup(
title_key="analysis:tsumegoframe", size=[dp(500), dp(350)], content=TsumegoFramePopup()
)
analysis_popup.content.popup = analysis_popup
analysis_popup.content.katrain = MDApp.get_running_app().gui
analysis_popup.open()
class AnalysisControls(MDBoxLayout):
dropdown = ObjectProperty(None)
is_open = BooleanProperty(False)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.build_dropdown()
def on_is_open(self, instance, value):
if value:
max_content_width = max(option.content_width for option in self.dropdown.container.children)
self.dropdown.width = max_content_width
self.dropdown.open(self.analysis_button)
elif self.dropdown.attach_to:
self.dropdown.dismiss()
def close_dropdown(self, *largs):
self.is_open = False
def toggle_dropdown(self, *largs):
self.is_open = not self.is_open
def build_dropdown(self):
self.dropdown = AnalysisDropDown(auto_width=False)
self.dropdown.bind(on_dismiss=self.close_dropdown)
class BadukPanControls(MDFloatLayout):
engine_status_col = ListProperty(Theme.ENGINE_DOWN_COLOR)
engine_status_pondering = NumericProperty(-1)
queries_remaining = NumericProperty(0)