From 7530eff4d36d5bb3bdb88140261e55976a06c96e Mon Sep 17 00:00:00 2001 From: Sander Land Date: Wed, 1 Jul 2020 21:45:44 +0200 Subject: [PATCH] stuff --- katrain/__main__.py | 1 + katrain/core/engine.py | 24 ++++-- katrain/core/game.py | 26 ++++-- katrain/core/lang.py | 2 +- katrain/gui/style.py | 4 +- katrain/gui/widgets/graph.py | 77 +++++++++++------- .../i18n/locales/es/LC_MESSAGES/katrain.po | 1 - katrain/img/play.png | Bin 5546 -> 5363 bytes 8 files changed, 85 insertions(+), 50 deletions(-) diff --git a/katrain/__main__.py b/katrain/__main__.py index 24adeaf..61ec7c7 100644 --- a/katrain/__main__.py +++ b/katrain/__main__.py @@ -256,6 +256,7 @@ class KaTrainGui(Screen, KaTrainBase): player_info.name = move_tree.root.get_property("P" + bw) self.update_player(bw) self.controls.graph.initialize_from_game(self.game.root) + self.controls.rank_graph.initialize_from_game(self.game.root) self.update_state(redraw_board=True) def _do_ai_move(self, node=None): diff --git a/katrain/core/engine.py b/katrain/core/engine.py index 69065be..d3a400c 100644 --- a/katrain/core/engine.py +++ b/katrain/core/engine.py @@ -101,12 +101,15 @@ class KataGoEngine: self.shutdown(finish=False) self.start() - def check_alive(self, exception_if_dead=False): + def check_alive(self, os_error='', exception_if_dead=False): ok = self.katago_process and self.katago_process.poll() is None if not ok and exception_if_dead: - raise EngineDiedException( - f"Engine died (process {self.katago_process}, poll {self.katago_process and self.katago_process.poll()}) config {self.config}" - ) + if self.katago_process: + os_error += f"status {self.katago_process and self.katago_process.poll()}" + died_msg = i18n._("Engine died unexpectedly").format(error=os_error) + self.katrain.log(died_msg, OUTPUT_ERROR) + self.katago_process = None + raise EngineDiedException(died_msg) return ok def shutdown(self, finish=False): @@ -134,15 +137,22 @@ class KataGoEngine: self.katrain.log(line.decode(errors="ignore").strip(), OUTPUT_KATAGO_STDERR) except Exception as e: print("ERROR in processing KataGo stderr:", line, "Exception", e) - except: + else: + self.check_alive(exception_if_dead=True) + except Exception as e: + self.katrain.log(f"Exception in reading stdout {e}" , OUTPUT_DEBUG) return def _analysis_read_thread(self): while self.katago_process is not None: try: line = self.katago_process.stdout.readline() + if not line: + self.check_alive(exception_if_dead=True) except OSError as e: - raise EngineDiedException(i18n("Engine died unexpectedly").format(error=e)) + self.check_alive(os_error=str(e),exception_if_dead=True) + return + if b"Uncaught exception" in line: self.katrain.log(f"KataGo Engine Failed: {line.decode(errors='ignore')}", OUTPUT_ERROR) return @@ -193,7 +203,7 @@ class KataGoEngine: self.katago_process.stdin.write((json.dumps(query) + "\n").encode()) self.katago_process.stdin.flush() except OSError as e: - self.katrain.log(i18n._("Engine died unexpectedly").format(error=e), OUTPUT_ERROR) + self.check_alive(os_error=str(e),exception_if_dead=True) return # do not raise, since there's nothing to catch it def request_analysis( diff --git a/katrain/core/game.py b/katrain/core/game.py index 77f6ff5..3f715ca 100644 --- a/katrain/core/game.py +++ b/katrain/core/game.py @@ -7,10 +7,18 @@ from typing import Dict, List, Optional, Union from kivy.clock import Clock -from katrain.core.constants import HOMEPAGE, OUTPUT_DEBUG, OUTPUT_INFO, STATUS_ANALYSIS, STATUS_INFO, STATUS_TEACHING +from katrain.core.constants import ( + HOMEPAGE, + OUTPUT_DEBUG, + OUTPUT_INFO, + STATUS_ANALYSIS, + STATUS_INFO, + STATUS_TEACHING, + PLAYER_AI, +) from katrain.core.engine import KataGoEngine from katrain.core.game_node import GameNode -from katrain.core.lang import i18n +from katrain.core.lang import i18n, rank_label from katrain.core.sgf_parser import SGF, Move from katrain.core.utils import var_to_grid @@ -270,12 +278,14 @@ class Game: def player_name(player_info): return f"{i18n._(player_info.player_type)} ({i18n._(player_info.player_subtype)})" - player_names = { - bw: re.sub( - r"['<>:\"/\\|?*]", "", self.root.get_property("P" + bw) or player_name(self.katrain.players_info[bw]) - ) - for bw in "BW" - } + if "KaTrain" in self.root.get_property("AP", ""): + for bw in "BW": + self.root.set_property("P" + bw, player_name(self.katrain.players_info[bw])) + player_info = self.katrain.players_info[bw] + if player_info.player_type == PLAYER_AI: + self.root.set_property(bw + "R", rank_label(player_info.calculated_rank)) + + player_names = {bw: re.sub(r"['<>:\"/\\|?*]", "", self.root.get_property("P" + bw, bw)) for bw in "BW"} game_name = f"katrain_{player_names['B']} vs {player_names['W']} {self.game_id}" file_name = os.path.abspath(os.path.join(path, f"{game_name}.sgf")) os.makedirs(os.path.dirname(file_name), exist_ok=True) diff --git a/katrain/core/lang.py b/katrain/core/lang.py index af3299c..514c25d 100644 --- a/katrain/core/lang.py +++ b/katrain/core/lang.py @@ -85,4 +85,4 @@ def rank_label(rank): if rank > 0: return f"{rank:.0f}{i18n._('strength:dan')}" else: - return f"{1-rank:.0f}{i18n._('strength:kyu')}" \ No newline at end of file + return f"{1-rank:.0f}{i18n._('strength:kyu')}" diff --git a/katrain/gui/style.py b/katrain/gui/style.py index 1d84822..b26d34e 100644 --- a/katrain/gui/style.py +++ b/katrain/gui/style.py @@ -31,8 +31,8 @@ EVAL_COLORS = [ [0.8, 0, 0, 1], [0.9, 0.4, 0.1, 1], [0.85, 0.89, 0.3, 1], - [0.67, 0.9, 0.18, 1.0], - [0.117, 0.588, 0, 1.0], + [0.67, 0.9, 0.18, 1], + [0.117, 0.588, 0, 1], ] EVAL_DOT_MAX_SIZE = 0.5 EVAL_DOT_MIN_SIZE = 0.25 diff --git a/katrain/gui/widgets/graph.py b/katrain/gui/widgets/graph.py index dc7ee6f..00ff4e3 100644 --- a/katrain/gui/widgets/graph.py +++ b/katrain/gui/widgets/graph.py @@ -4,23 +4,25 @@ import threading from kivy.lang import Builder from kivy.metrics import dp from kivy.properties import BooleanProperty, Clock, ListProperty, NumericProperty, StringProperty +from kivy.uix.widget import Widget from kivymd.app import MDApp from katrain.core.constants import OUTPUT_ERROR from katrain.core.lang import rank_label -from katrain.gui.kivyutils import BackgroundMixin -class Graph(BackgroundMixin): +class Graph(Widget): marker_font_size = NumericProperty(0) background_image = StringProperty("img/graph_bg.png") - + background_color = ListProperty([1, 1, 1, 1]) + highlighted_index = NumericProperty(0) nodes = ListProperty([]) hidden = BooleanProperty(False) def __init__(self, **kwargs): super().__init__(**kwargs) self._lock = threading.Lock() + self.redraw_on_highlight_change = True self.bind(pos=self.update_graph, size=self.update_graph) self.redraw_trigger = Clock.create_trigger(self.update_graph, 0.1) @@ -31,19 +33,24 @@ class Graph(BackgroundMixin): node = node.ordered_children[0] self.nodes.append(node) self.highlighted_index = 0 + self.redraw_trigger() def update_graph(self, *args): pass def update_value(self, node): with self._lock: + changed = (self.redraw_on_highlight_change and self.highlighted_index != node.depth) self.highlighted_index = index = node.depth self.nodes.extend([None] * max(0, index - (len(self.nodes) - 1))) + if self.nodes[index]!=node: + changed=True self.nodes[index] = node - if index > 1 and node.parent: # sometimes things go so fast + if index > 1 and node.parent: # sometimes there are gaps backfill, bfnode = index - 1, node.parent while bfnode is not None and self.nodes[backfill] != bfnode: self.nodes[backfill] = bfnode + changed = True backfill -= 1 bfnode = bfnode.parent @@ -51,11 +58,13 @@ class Graph(BackgroundMixin): node is None or not node.children or self.nodes[index + 1] != node.ordered_children[0] ): self.nodes = self.nodes[: index + 1] # on branch switching, don't show history from other branch + changed = True if index == len(self.nodes) - 1: # possibly just switched branch or the line above triggered while node.children: # add children back node = node.ordered_children[0] self.nodes.append(node) - self.redraw_trigger() + if changed: + self.redraw_trigger() class ScoreGraph(Graph): @@ -67,7 +76,6 @@ class ScoreGraph(Graph): score_dot_pos = ListProperty([0, 0]) winrate_dot_pos = ListProperty([0, 0]) - highlighted_index = NumericProperty(None) highlight_size = NumericProperty(dp(6)) score_scale = NumericProperty(5) @@ -173,9 +181,11 @@ class RankGraph(Graph): black_rank_points = ListProperty([]) white_rank_points = ListProperty([]) segment_length = NumericProperty(80) + RANK_CAP = 5 def __init__(self, **kwargs): super().__init__(**kwargs) + self.redraw_on_highlight_change = False self.calculate_trigger = Clock.create_trigger(lambda *args: self.rank_game(), 0.25) self.rank_by_player = {} @@ -189,11 +199,7 @@ class RankGraph(Graph): if not non_obvious_moves: return None num_legal, rank, value = zip(*non_obvious_moves) - rank = list(rank) - for (i, item) in enumerate(rank): - if item > num_legal[i] * 0.09: - rank[i] = num_legal[i] * 0.09 - rank = tuple(rank) + rank = [min(r, nl * 0.09) for r, nl in zip(rank, num_legal)] averagemod_rank = averagemod(rank) averagemod_len_legal = averagemod(num_legal) norm_avemod_len_legal = averagemod_len_legal / num_intersec @@ -209,9 +215,7 @@ class RankGraph(Graph): ) else: rank_kyu = -4 - if rank_kyu < -4: - rank_kyu = -4 - return 1 - rank_kyu # dan rank + return min(RankGraph.RANK_CAP,1 - rank_kyu) # dan rank @staticmethod def calculate_ranks(segment_stats, num_intersec): @@ -241,10 +245,11 @@ class RankGraph(Graph): ranks = {"B": [], "W": []} dx = self.segment_length // 4 - for segment_mid in range(0, len(nodes), dx): + for segment_mid in range(dx, len(nodes), dx): bounds = (max(0, segment_mid - half_seg), min(segment_mid + half_seg, len(nodes))) - for pl, rank in self.calculate_ranks(policy_stats[bounds[0] : bounds[1] + 1], num_intersec).items(): - if bounds[1] - bounds[0] > self.segment_length * 0.75: + num_analyzed = sum(num_mv is not None for num_mv, _, _, _ in policy_stats[bounds[0] : bounds[1] + 1]) + if num_analyzed >= self.segment_length * 0.75: + for pl, rank in self.calculate_ranks(policy_stats[bounds[0] : bounds[1] + 1], num_intersec).items(): ranks[pl].append((segment_mid, rank)) self.rank_by_player = ranks except Exception as e: @@ -270,15 +275,17 @@ class RankGraph(Graph): min_rank = math.floor(min(all_ranks)) max_rank = math.ceil(max(all_ranks)) + if max_rank==min_rank: + min_rank -= 1 if (max_rank - min_rank) % 2 != 0: # make midpoint whole integer - if abs(max_rank - max(all_ranks)) < abs(min(all_ranks) - min_rank): + if abs(max_rank - max(all_ranks)) < abs(min(all_ranks) - min_rank) and max_rank < self.RANK_CAP: max_rank += 1 else: min_rank -= 1 rank_range = max_rank - min_rank self.ids.mid_marker.text = rank_label((max_rank + min_rank) / 2) - self.ids.top_marker.text = rank_label(max_rank) + self.ids.top_marker.text = rank_label(max_rank) + ("+" if max_rank==self.RANK_CAP else "") self.ids.bottom_marker.text = rank_label(min_rank) graph_points = {} @@ -292,12 +299,16 @@ class RankGraph(Graph): ] self.black_rank_points = sum(graph_points["B"], []) self.white_rank_points = sum(graph_points["W"], []) - + else: + self.black_rank_points = [] + self.white_rank_points = [] Builder.load_string( """ #:set GRAPH_CENTER_COLOR [0.5,0.5,0.5] -#:set GRAPH_DOT_COLOR [0.85, 0.3, 0.3,1] +#:set GRAPH_DOT_COLOR [0.85,0.3,0.3,1] +#:set WINRATE_MARKER_COLOR [0.05, 0.7, 0.05, 1] +#:set SCORE_MARKER_COLOR [0.2, 0.6, 0.8, 1] #:import LIGHTER_BACKGROUND_COLOR katrain.gui.style.LIGHTER_BACKGROUND_COLOR #:import BOX_BACKGROUND_COLOR katrain.gui.style.BOX_BACKGROUND_COLOR @@ -307,11 +318,15 @@ Builder.load_string( #:import WHITE katrain.gui.style.WHITE #:import YELLOW katrain.gui.style.YELLOW - : - background_color: BOX_BACKGROUND_COLOR + background_color: BOX_BACKGROUND_COLOR marker_font_size: 0.1 * self.height - canvas: + canvas.before: + Color: + rgba: root.background_color + Rectangle: + size: self.size + pos: self.pos Color: rgba: [1,1,1,1] Rectangle: @@ -320,7 +335,7 @@ Builder.load_string( source: root.background_image : - canvas.after: + canvas: Color: rgba: SCORE_COLOR Line: @@ -351,39 +366,39 @@ Builder.load_string( # score ticks GraphMarkerLabel: font_size: root.marker_font_size - color: SCORE_COLOR + color: SCORE_MARKER_COLOR pos: root.x + root.width - self.width-1, root.pos[1]+root.height - self.font_size - 1 text: 'B+{}'.format(root.score_scale) opacity: int(root.show_score) GraphMarkerLabel: font_size: root.marker_font_size - color: SCORE_COLOR + color: SCORE_MARKER_COLOR pos: root.x + root.width - self.width-1, root.y + root.height*0.5 - self.height/2 + 2 text: i18n._('Jigo') opacity: int(root.show_score) GraphMarkerLabel: font_size: root.marker_font_size - color: SCORE_COLOR + color: SCORE_MARKER_COLOR pos: root.x + root.width - self.width-1, root.pos[1] text: 'W+' + str(int(root.score_scale)) opacity: int(root.show_score) # wr ticks GraphMarkerLabel: font_size: root.marker_font_size - color: WINRATE_COLOR + color: WINRATE_MARKER_COLOR pos: root.pos[0]+1, root.pos[1] + root.height - self.font_size - 1 text: "{}%".format(50 + root.winrate_scale) opacity: int(root.show_winrate) GraphMarkerLabel: font_size: root.marker_font_size - color: WINRATE_COLOR + color: WINRATE_MARKER_COLOR pos:root.pos[0]+1, root.pos[1] text: "{}%".format(50 - root.winrate_scale) opacity: int(root.show_winrate) : background_color: LIGHTER_BACKGROUND_COLOR - canvas.after: + canvas: Color: rgba: WHITE Line: diff --git a/katrain/i18n/locales/es/LC_MESSAGES/katrain.po b/katrain/i18n/locales/es/LC_MESSAGES/katrain.po index 14c7895..1f7e8a9 100644 --- a/katrain/i18n/locales/es/LC_MESSAGES/katrain.po +++ b/katrain/i18n/locales/es/LC_MESSAGES/katrain.po @@ -635,4 +635,3 @@ msgstr "Estimated Strength" #. TODO - label in little popup for analyzing entire game msgid "reanalyze max visits" msgstr "Number of visits per move" - diff --git a/katrain/img/play.png b/katrain/img/play.png index 622e7408a609e61a2701340af981c323331d18a9..a81e1829b9148d26f9e9f20c4976cab8856c7529 100644 GIT binary patch literal 5363 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%0Db8#ot|Bnjoajx?!6vS*Wt zbuLW#wnfiOH^#7z2R@H)Bd45tR+aHC7dF2L(Y3(XR}Jf5B(j(%sleE>r6L6ScD0fO zqm*{CP&?1&;?z}ibpQ6*0NjF4WWhNS@NJIPM|J7*e;hs{PW2YKENB+MM;l*R&5yfO zH3#OpVo-3laJ^gw-G)<#6v^HDDVOqFk2J~$Jrb$9%dw4FIEaE%ye8(mPTh{PHHdh$ z&j&VaANdJ&$qh77MJd~M^_jd5-I;XszGR=fR0*1a3Lu);og&xzd7D#3b*LN!ZaDg{ z;u+T;ib30w{#D$)=%=y6SO0aY(t7JjrE=MAf|!Kgg~FXN(`mG=6wJf4ag z1%~fWMl9vB+%b#F@6az+2Uqhoe>*B~L+RO}Wzt{w61NVX1BAAt_C!AOjsoAV%LG(V zUD`q4H&R1cf|=QTyfb19)691lJKiHpMsJgeC-F@c!b)&|sSE-?^%ZhcJ)A_u*{%K& zc(U2&k5TH?FgG2*iM~vxh5A6go&C>%k_BroJ!G|nag6GoBkJcWsU`f7s_0F0$e*G<@(goGPRGKDp7aKXmRIt(DII&rkDfJL;lNmH$YorgnYQ zshd+I@v{7w+~9F3c&)1rDvw!(c8*HDtx^U zr4twPv9QK3089L;hveybk~lhW7kA0Cb%$pdgTTE`zAE&g3PQd`k@pS=EfBSYBG2sL zXrUOkYfScbEZ8tb(I+!ImMlg~_|QOB`e$rl5ebrQrEkznEtU>yIg*+@-9x6vz&7cM zV%h9bK$K=z6whX_A)*w!YEI_eL~?DJk~b|Wohzx(XD=Pv-mA-}!p>FFp#UX42SnLR zS@rHqg>>-9l<-n?4|9#7K(#AsuFan*-Y)9Wp%E*vNf*m+?2SAc-r&g~j=Vlimo$ve zB&NGaqr4Zf^xgy;kqZ`9lHlfo&!vxB_96JHR7q*8Y9NBIIw{#-?;hxkC|7Ca_7#hZ zNpKfY{sE(1S~y&$%g%ls;$wY}Ea6ehr5ct^wS_S}a*>Yu_bL^Tpo1cx499}+&UO?d zM?~o^lA3ursi{MKD>2GLOUTOjHEM2kGLa5~MRh#%px?^NFp$*r?;-Rv#PVBvBcsAs z7%B6EiZM~imGSLg-c3W!IT^~jzmQMXmy!sHi{xlyl$x?g;Hp3%iH5h2XFg56K#{!} zpILx>lh4i>Mc*glaW3~M6fC|x89eq@@edN*qsXt1eKt8qfvc3sB~rTSc@o^9$`4Bo zOp7S6LPPdtINsfb3MmG%-kw!?xdyP;SlavkadX!p4jy&26q`4@R=%x6`nWJ{EzlQC zhilhAw$^Lq2@N3JP14(~(%Rp-h9ryC6>gGEt61s|f+!Sn8d5G9XeKgNrs5W8CGJBU^$6K^X?ntuEWz4P84> zb89NSm7x%Shl8u7ihI3OnWzUY;MKOSSJeqc)Uw)EFIBcGYGxy1@(ZnnC3f0GHzi_s zkcTIN>7+*PJ`nUI&HySkWEEm|A;%F~5Y;0noaGO3%O|WQeeUXP!`jvRRrz+Fidorh zrgSAkeGcA)x}#IEzf00FC!?9H&9^Z?FC-{;7Iz2U+| Ty8_Ekfc{oGx;tF66U6)vDItmX literal 5546 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