Spaces:
Running
Running
import math | |
import os | |
import queue | |
import threading | |
import tkinter as tk | |
from ctypes import c_int, c_long, pointer, string_at | |
from datetime import datetime | |
from tkinter import filedialog, messagebox, ttk | |
import cv2 | |
import numpy as np | |
from PIL import Image, ImageTk | |
# from transformers import pipeline | |
import dds | |
from identify_cards import (determine_and_correct_orientation, find_center_box, | |
find_rank_candidates, get_suit_from_color_rules, | |
get_suit_from_image_rules, load_suit_templates, | |
recognize_cards, save_img_with_rect) | |
from utils import (PrintTable, arrange_hand, convert2ddTableDeal, convert2dup, | |
convert2pbn, convert2pbn_board, convert2pbn_txt, | |
convert2xhd, convert_hands_to_binary_deal, is_text_valid, | |
reshape_table) | |
# --- グローバル変数・設定 --- | |
# print("TrOCRのAIモデルを読み込んでいます...(初回は数分かかります)") | |
# try: | |
# trocr_pipeline = pipeline( | |
# "image-to-text", model="microsoft/trocr-base-printed" | |
# ) | |
# print("TrOCRの準備が完了しました。") | |
# except Exception as e: | |
# print(f"TrOCRモデルのロード中にエラー: {e}") | |
# trocr_pipeline = None | |
DEFAULT_THRESHOLDS = { | |
"L_black": 65.0, | |
"a_green": 126.0, | |
"a_black": 120.0, # 250.0, # 120.0, | |
# "a_black2": 126.0, | |
"a_red": 134.0, | |
"b_orange": 137.0, | |
"ba_green": 0.0, | |
"ba_black": -4.5, | |
"ab_black": 250.0, | |
"b_black": 121.3, | |
"b_black2": 132.5, | |
# "b_black2": 127.0, | |
"a_orange": 150.0, | |
"b_red": 145.0, | |
"a_b_red": 9.0, | |
} | |
SUITS_BY_COLOR = { | |
"black": "S", | |
"green": "C", | |
"red": "H", | |
"orange": "D", | |
"unknown": "*", | |
} | |
PLAYER_ORDER = ["north", "south", "west", "east"] | |
MARGIN = 200 | |
SUIT_TEMPLATE_PATH = "templates/suits/" | |
def validate_deal(hands): | |
if not hands: | |
return False, "分析対象のカードデータがありません" | |
total_cards = [] | |
# 手札が合計52枚あるかチェック | |
for player, hand in hands.items(): | |
if len(hand) != 13: | |
return ( | |
False, | |
f"エラー: {player.capitalize()}の手札が13枚ではありません", | |
) | |
for card in hand: | |
if card in total_cards: | |
return ( | |
False, | |
f"エラー: 重複したカードが検出されました ({card})", | |
) | |
total_cards.append(card) | |
return True, "デックは正常です" | |
def load_image(path): | |
try: | |
# ファイルをバイナリモードで安全に読み込む | |
with open(path, "rb") as f: | |
# バイトデータをNumPy配列に変換 | |
file_bytes = np.asarray(bytearray(f.read()), dtype=np.uint8) | |
# NumPy配列(メモリ上のデータ)から画像をデコード | |
image = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR) | |
if image is None: | |
raise ValueError( | |
"OpenCVが画像をデコードできませんでした。ファイルが破損しているか、非対応の形式の可能性があります。" | |
) | |
return image, "" | |
# image_objects[filename] = image | |
except Exception as e: | |
# ファイル読み込み自体のエラーをキャッチ | |
# image_objects[filename] = None | |
return None, e | |
def get_player_region_image(image, box): | |
bx, by, bw, bh = box | |
h, w, _ = image.shape | |
player_regions = { | |
"north": image[0:by, :], | |
"south": image[by + bh : h, :], | |
"west": image[by - MARGIN : by + bh + MARGIN, 0:bx], | |
"east": image[by - MARGIN : by + bh + MARGIN, bx + bw : w], | |
} | |
# 向きの補正 | |
for player, region in player_regions.items(): | |
if region is not None and region.size > 0: | |
if player == "north": | |
player_regions[player] = cv2.rotate(region, cv2.ROTATE_180) | |
elif player == "east": | |
player_regions[player] = cv2.rotate( | |
region, cv2.ROTATE_90_CLOCKWISE | |
) | |
elif player == "west": | |
player_regions[player] = cv2.rotate( | |
region, cv2.ROTATE_90_COUNTERCLOCKWISE | |
) | |
return player_regions | |
def analyze_image_data(image_paths, progress_queue, trocr_pipeline): | |
all_results = [] | |
num_total_files = len(image_paths) | |
progress_queue.put("テンプレート画像読み込み中...") | |
suit_templates = load_suit_templates(SUIT_TEMPLATE_PATH) | |
if not suit_templates: | |
raise ValueError( | |
f"エラー: {SUIT_TEMPLATE_PATH} フォルダにスートのテンプレート画像が見つかりません。" | |
) | |
try: | |
progress_queue.put("ステージ1/3: 文字候補を検出中...") | |
all_candidates_global = [] | |
# image_objects = {} | |
for i, image_path in enumerate(image_paths): | |
filename = os.path.basename(image_path) | |
progress_queue.put(f"分析中 ({i+1}/{num_total_files}): {filename}") | |
image, error = load_image(image_path) | |
if image is None: | |
all_results.append( | |
{ | |
"filename": filename, | |
"error": f"画像読み込みエラー: {error}", | |
} | |
) | |
# box = find_center_box(image) | |
print("detect board") | |
rotated_image, box, scale = determine_and_correct_orientation( | |
image, progress_queue.put | |
) | |
if box is None: | |
all_results.append( | |
{"filename": filename, "error": "中央ボードの検出に失敗"} | |
) | |
continue | |
print(box) | |
save_img_with_rect("debug_rotated.jpg", rotated_image, [box]) | |
player_regions = get_player_region_image(rotated_image, box) | |
for player, region in player_regions.items(): | |
candidates = find_rank_candidates( | |
region, suit_templates, player, scale | |
) | |
for cand in candidates: | |
cand["filename"] = filename | |
cand["player"] = player | |
all_candidates_global.append(cand) | |
progress_queue.put( | |
"ステージ2/3: 文字認識を実行中... (時間がかかります)" | |
) | |
if not all_candidates_global or not trocr_pipeline: | |
progress_queue.put("認識する文字候補がありませんでした。") | |
progress_queue.put( | |
all_results | |
) # エラーがあった画像の結果だけを返す | |
return | |
candidates_pil_images = [ | |
Image.fromarray(cv2.cvtColor(c["img"], cv2.COLOR_BGR2RGB)) | |
for c in all_candidates_global | |
] | |
ocr_results = trocr_pipeline(candidates_pil_images) | |
# --- ステージ3: 結果の仕分けと最終的なカードの特定 --- | |
progress_queue.put("ステージ3/3: 認識結果を仕分け中...") | |
# まず、ファイルごとに結果を格納する辞書を準備 | |
temp_results = { | |
os.path.basename(p): {player: [] for player in PLAYER_ORDER} | |
for p in image_paths | |
} | |
print(temp_results) | |
print([result[0]["generated_text"] for result in ocr_results]) | |
raw_data = [] | |
blacks = [] | |
reds = [] | |
for i, result in enumerate(ocr_results): | |
text = result[0]["generated_text"].upper().strip() | |
print(text, is_text_valid(text)) | |
text = is_text_valid(text) | |
if text is not None: | |
candidate_info = all_candidates_global[i] | |
print( | |
f"--- 診断中: ランク '{text}' of {candidate_info['player']} at {candidate_info['pos']} with thick:{candidate_info["thickness"]} ---" | |
) | |
color_name, avg_lab = get_suit_from_image_rules( | |
candidate_info["no_pad"], DEFAULT_THRESHOLDS | |
) | |
if color_name == "black" or color_name == "green": | |
blacks.append( | |
f"{color_name.ljust(6)}: a{avg_lab[1]:.2f},b{avg_lab[2]:.2f},a+b{avg_lab[1]+avg_lab[2]:.2f},a-b{avg_lab[1]-avg_lab[2]:.2f}" | |
) | |
if color_name == "red" or color_name == "orange": | |
reds.append( | |
f"{color_name.ljust(6)}: a{avg_lab[1]:.2f},b{avg_lab[2]:.2f},a+b{avg_lab[1]+avg_lab[2]:.2f},a-b{avg_lab[1]-avg_lab[2]:.2f}" | |
) | |
print(color_name) | |
if color_name == "mark": | |
continue | |
candidate_info["avg_lab"] = avg_lab | |
candidate_info["color"] = color_name | |
candidate_info["name"] = text | |
raw_data.append(candidate_info) | |
if color_name in SUITS_BY_COLOR: | |
suit = SUITS_BY_COLOR[color_name] | |
card_name = f"{suit}{text}" | |
filename = os.path.basename(candidate_info["filename"]) | |
player = candidate_info["player"] | |
temp_results[filename][player].append(card_name) | |
print("\r\n".join(blacks)) | |
# print("\r\n".join(reds)) | |
progress_queue.put(raw_data) | |
except Exception as e: | |
progress_queue.put(f"致命的なエラー: {e}") | |
def convert2txt(all_result, title): | |
res = "" | |
for result in all_result: | |
res += "=" * 40 + "\n" | |
res += f"{title}\n" | |
res += "=" * 40 + "\n" | |
if "error" in result: | |
res += f" エラー: {result['error']}\n" | |
elif "hands" in result: | |
for player in PLAYER_ORDER: | |
hand = result["hands"].get(player, []) | |
res += f" {player.capitalize()}: {', '.join(hand) if hand else '(なし)'}\n" | |
res += "\n" | |
return res | |
def parse_hand_string(hand): | |
hand_list = hand.replace(" ", "").split(",") | |
return hand_list | |
XHD = "xhdファイル" | |
DUP = "dupファイル" | |
PBN = "pbnファイル" | |
# --- Tkinter GUI アプリケーションクラス --- | |
class CardRecognizerApp: | |
def __init__(self, root): | |
self.root = root | |
self.root.title("トランプカード認識アプリ") | |
self.root.geometry("800x600") | |
self.filepaths = [] | |
self.all_result = [] | |
self.thread = None | |
self.q = queue.Queue() | |
self.format_var = tk.StringVar() | |
self.raw_rank_data = [] | |
self.trocr_pipeline = None | |
self.model_loading_thread = None | |
self.model_loaded = threading.Event() | |
self.model_load_error = None | |
self.selected_deal_var = tk.StringVar() | |
self.setup_ui() | |
self.start_model_loading() | |
def setup_ui(self): | |
# スタイル | |
style = ttk.Style() | |
style.configure( | |
"TButton", padding=6, relief="flat", font=("Yu Gothic UI", 10) | |
) | |
style.configure("TLabel", padding=5, font=("Yu Gothic UI", 10)) | |
style.configure("Header.TLabel", font=("Yu Gothic UI", 14, "bold")) | |
# メインフレーム | |
main_frame = ttk.Frame(root, padding="20") | |
main_frame.pack(fill=tk.BOTH, expand=True) | |
top_frame = ttk.Frame(main_frame) | |
top_frame.pack(fill=tk.X, pady=10) | |
# ファイル選択部分 | |
file_frame = ttk.Frame(main_frame) | |
file_frame.pack(fill=tk.X, pady=10) | |
self.select_button = ttk.Button( | |
file_frame, text="画像ファイルを選択", command=self.select_files | |
) | |
self.select_button.pack(side=tk.LEFT, padx=5) | |
self.filepath_label = ttk.Label( | |
file_frame, text="ファイルが選択されていません", anchor=tk.W | |
) | |
self.filepath_label.pack(side=tk.LEFT, fill=tk.X, expand=True) | |
nav_frame = ttk.Frame(main_frame) | |
nav_frame.pack(fill=tk.X, pady=5) | |
ttk.Label(nav_frame, text="表示中のディール:").pack( | |
side=tk.LEFT, padx=(0, 5) | |
) | |
self.deal_selector_combo = ttk.Combobox( | |
nav_frame, | |
textvariable=self.selected_deal_var, | |
state="disabled", | |
width=50, | |
) | |
self.deal_selector_combo.pack(side=tk.LEFT) | |
self.deal_selector_combo.bind( | |
"<<ComboboxSelected>>", self.on_deal_selected | |
) | |
# 分析開始ボタン | |
self.analyze_button = ttk.Button( | |
main_frame, | |
text="分析開始", | |
command=self.start_analysis_thread, | |
state=tk.DISABLED, | |
) | |
self.analyze_button.pack(pady=10, fill=tk.X) | |
self.export_button = ttk.Button( | |
top_frame, | |
text="結果を出力", | |
command=self.export_results, | |
state=tk.DISABLED, | |
) | |
self.export_button.pack(side=tk.LEFT, padx=5) | |
self.dds_single_button = ttk.Button( | |
top_frame, | |
text="現在のボードをDDS分析", | |
command=self.start_dds_single_analysis, | |
state=tk.DISABLED, | |
) | |
self.dds_single_button.pack(side=tk.LEFT, padx=(15, 5)) | |
self.dds_button = ttk.Button( | |
top_frame, | |
text="全てのファイルをDDS分析", | |
command=self.start_dds_analysis, | |
state=tk.DISABLED, | |
) | |
self.dds_button.pack(side=tk.LEFT, padx=5) | |
self.debugger_button = ttk.Button( | |
top_frame, | |
text="カラーデバッガー", | |
command=self.open_debugger, | |
state=tk.DISABLED, | |
) | |
self.debugger_button.pack(side=tk.LEFT, padx=5) | |
# 結果表示部分 | |
results_frame = ttk.Frame(main_frame, padding="10") | |
results_frame.pack(fill=tk.BOTH, expand=True, pady=10) | |
results_frame.columnconfigure(1, weight=1) | |
self.north_hand_var = tk.StringVar(results_frame) | |
self.south_hand_var = tk.StringVar(results_frame) | |
self.west_hand_var = tk.StringVar(results_frame) | |
self.east_hand_var = tk.StringVar(results_frame) | |
self.player_vars = { | |
"north": self.north_hand_var, | |
"south": self.south_hand_var, | |
"west": self.west_hand_var, | |
"east": self.east_hand_var, | |
} | |
self.result_entries = {} | |
ttk.Label( | |
results_frame, text="最終分析結果:", style="Header.TLabel" | |
).grid(row=0, column=0, columnspan=2, sticky=tk.W, pady=10) | |
for i, player in enumerate(PLAYER_ORDER, 1): | |
ttk.Label( | |
results_frame, | |
text=f"{player.capitalize()}:", | |
style="Header.TLabel", | |
).grid(row=i, column=0, sticky=tk.NW, padx=5, pady=5) | |
entry = ttk.Entry( | |
results_frame, textvariable=self.player_vars[player] | |
) | |
entry.grid(row=i, column=1, sticky=tk.EW, padx=5, pady=5) | |
self.result_entries[player] = entry | |
# self.result_labels[player] = ttk.Label( | |
# results_frame, | |
# text="-", | |
# wraplength=550, | |
# anchor=tk.W, | |
# justify=tk.LEFT, | |
# ) | |
# self.result_labels[player].grid( | |
# row=i, column=1, sticky=tk.NW, padx=5, pady=5 | |
# ) | |
# ステータスバー | |
self.status_var = tk.StringVar() | |
self.status_var.set("AIモデルを初期化中...") | |
status_bar = ttk.Label( | |
root, | |
textvariable=self.status_var, | |
relief=tk.SUNKEN, | |
anchor=tk.W, | |
padding=5, | |
) | |
status_bar.pack(side=tk.BOTTOM, fill=tk.X) | |
def start_model_loading(self): | |
self.model_loading_thread = threading.Thread( | |
target=self.initialize_model | |
) | |
self.model_loading_thread.demon = True | |
self.model_loading_thread.start() | |
self.root.after(100, self.check_model_status) | |
def initialize_model(self): | |
"""バックグラウンドで実行されるAIモデル読み込み処理""" | |
try: | |
print("TrOCRのAIモデルを読み込んでいます...") | |
self.trocr_pipeline = pipeline( | |
"image-to-text", model="microsoft/trocr-small-printed" | |
) | |
print("TrOCRの準備が完了しました。") | |
except Exception as e: | |
self.model_load_error = e | |
finally: | |
self.model_loaded.set() # 読み込み完了(成功または失敗)を通知 | |
def check_model_status(self): | |
"""モデルの読み込みが完了したか定期的にチェックする""" | |
if not self.model_loaded.is_set(): | |
# まだロード中なら100ms後にもう一度チェック | |
self.root.after(100, self.check_model_status) | |
return | |
if self.model_load_error: | |
# エラーが発生した場合 | |
messagebox.showerror( | |
"起動エラー", | |
f"TrOCRモデルの読み込みに失敗しました。\nエラー: {self.model_load_error}", | |
) | |
self.status_var.set("AIモデルの読み込みに失敗しました。") | |
else: | |
# 成功した場合 | |
self.status_var.set("準備完了") | |
self.filepath_label.config(text="ファイルが選択されていません") | |
if len(self.filepaths) > 0: | |
self.analyze_button.config(state=tk.NORMAL) # ボタンを有効化 | |
def select_files(self): | |
self.filepaths = filedialog.askopenfilenames( | |
title="画像ファイルを選択 (複数選択可)", | |
filetypes=( | |
("JPEGファイル", "*.jpg;*.jpeg"), | |
("PNGファイル", "*.png"), | |
("すべてのファイル", "*.*"), | |
), | |
) | |
if self.filepaths: | |
if len(self.filepaths) == 1: | |
self.filepath_label.config( | |
text=os.path.basename(self.filepaths[0]) | |
) | |
else: | |
self.filepath_label.config( | |
text=f"{len(self.filepaths)}個のファイルが選択されました" | |
) | |
if self.trocr_pipeline is not None: | |
self.analyze_button.config(state=tk.NORMAL) | |
self.export_button.config(state=tk.DISABLED) | |
self.status_var.set(f"{len(self.filepaths)}個のファイルを選択") | |
def start_analysis_thread(self): | |
if not self.filepaths: | |
return | |
self.analyze_button.config(state=tk.DISABLED) | |
self.select_button.config(state=tk.DISABLED) | |
self.export_button.config(state=tk.DISABLED) | |
self.dds_button.config(state=tk.DISABLED) | |
self.dds_single_button.config(state=tk.DISABLED) | |
self.all_result = [] | |
self.deal_selector_combo.set("") | |
self.deal_selector_combo.config(state=tk.DISABLED) | |
self.status_var.set("分析処理を開始します...") | |
# 以前の結果をクリア | |
for player in PLAYER_ORDER: | |
# self.result_labels[player].config(text="-") | |
self.player_vars[player] = "" | |
self.result_entries[player].delete(0, tk.END) | |
# 分析処理を別スレッドで実行 | |
self.thread = threading.Thread( | |
target=analyze_image_data, | |
args=(self.filepaths, self.q, self.trocr_pipeline), | |
) | |
self.thread.daemon = True | |
self.thread.start() | |
# キューを監視する | |
self.root.after(100, self.process_queue) | |
def process_queue(self): | |
try: | |
msg = self.q.get_nowait() | |
if isinstance(msg, list): | |
# 最終結果が来た場合 | |
self.raw_rank_data = msg | |
# self.all_result = msg | |
# self.display_last_results() | |
self.status_var.set("分析完了!") | |
self.analyze_button.config(state=tk.NORMAL) | |
self.select_button.config(state=tk.NORMAL) | |
self.arrange_data() | |
self.setup_deal_selector() | |
if self.raw_rank_data: | |
self.debugger_button.config(state=tk.NORMAL) | |
if self.all_result: | |
self.export_button.config(state=tk.NORMAL) | |
if any("hands" in result for result in self.all_result): | |
self.dds_button.config(state=tk.NORMAL) | |
else: | |
# 途中の進捗メッセージの場合 | |
self.status_var.set(msg) | |
self.root.after( | |
100, self.process_queue | |
) # 次のメッセージをチェック | |
except queue.Empty: | |
# キューが空なら、再度チェック | |
self.root.after(100, self.process_queue) | |
def arrange_data(self): | |
# 生データから最終的な手札を作成・表示 | |
self.all_result = [] | |
temp_hands = {} # ファイルごとの手札を一時保存 | |
for rank_data in self.raw_rank_data: | |
filename = rank_data["filename"] | |
if filename not in temp_hands: | |
temp_hands[filename] = {p: [] for p in PLAYER_ORDER} | |
color_name = rank_data["color"] | |
suit = SUITS_BY_COLOR[color_name] | |
card_name = f"{suit}{rank_data['name']}" | |
temp_hands[filename][rank_data["player"]].append(card_name) | |
# 整形してall_resultsに格納 | |
for filename, hands in temp_hands.items(): | |
self.all_result.append( | |
{ | |
"filename": filename, | |
"hands": { | |
player: arrange_hand(cards) | |
for player, cards in hands.items() | |
}, | |
} | |
) | |
def display_last_results(self): | |
self.arrange_data() | |
if not self.all_result: | |
print("all_result is none") | |
return | |
last_result = self.all_result[-1] | |
if "error" in last_result: | |
self.filepath_label.config( | |
text=f"エラー ({last_result['filename']}): {last_result['error']}" | |
) | |
elif "hands" in last_result: | |
self.filepath_label.config( | |
text=f"最終分析ファイル: {last_result['filename']}" | |
) | |
self.last_analyzed_hands = last_result["hands"] | |
self.dds_button.config(state=tk.NORMAL) | |
self.dds_single_button.config(state=tk.NORMAL) | |
for player, hand in last_result["hands"].items(): | |
if player in self.player_vars: | |
self.result_entries[player].insert( | |
tk.END, ", ".join(hand) if hand else "" | |
) | |
# self.set_text() | |
# self.player_vars[player].set( | |
# ", ".join(hand) if hand else "" | |
# ) | |
# self.result_labels[player].config( | |
# text=", ".join(hand) if hand else "(なし)" | |
# ) | |
def setup_deal_selector(self): | |
successful_files = [ | |
r["filename"] for r in self.all_result if "hands" in r | |
] | |
if successful_files: | |
self.deal_selector_combo["values"] = successful_files | |
self.deal_selector_combo.config(state="readonly") | |
self.deal_selector_combo.set(successful_files[0]) | |
self.on_deal_selected() | |
else: | |
self.filepath_label.config( | |
text="分析に成功したディールがありませんでした" | |
) | |
def on_deal_selected(self, event=None): | |
selected_filename = self.deal_selector_combo.get() | |
if not selected_filename: | |
return | |
for result in self.all_result: | |
if result.get("filename") == selected_filename: | |
if "hands" in result: | |
self.filepath_label.config( | |
text=f"表示中: {result['filename']}" | |
) | |
for player, hand in result["hands"].items(): | |
if player in self.player_vars: | |
self.result_entries[player].delete(0, tk.END) | |
self.result_entries[player].insert( | |
tk.END, ", ".join(hand) if hand else "" | |
) | |
self.dds_single_button.config(state=tk.NORMAL) | |
return | |
def start_dds_analysis(self): | |
valid_deals = [] | |
for result in self.all_result: | |
if "hands" in result: | |
is_valid, _ = validate_deal(result["hands"]) | |
if is_valid: | |
valid_deals.append(result) | |
if len(valid_deals) == 0: | |
messagebox.showwarning( | |
"分析不可", "分析対象となる正常なディールがありません。" | |
) | |
return | |
self.status_var.set(f"{len(valid_deals)}件のディールを分析中...") | |
self.root.update_idletasks() | |
try: | |
deals = dds.ddTableDealsPBN() | |
deals.noOfTables = len(valid_deals) | |
for i, result in enumerate(valid_deals): | |
pbn_deal_string = convert2pbn_txt(result["hands"], "N") | |
print(pbn_deal_string) | |
# table_deal_pbn = dds.ddTableDealPBN() | |
# table_deal_pbn.cards = pbn_deal_string.encode("utf-8") | |
deals.deals[i].cards = pbn_deal_string.encode("utf-8") | |
dds.SetMaxThreads(0) | |
table_res = dds.ddTablesRes() | |
per_res = dds.allParResults() | |
# table_res_pointer = pointer(table_res) | |
res = dds.CalcAllTablesPBN( | |
pointer(deals), | |
0, | |
(c_int * 5)(0, 0, 0, 0, 0), | |
pointer(table_res), | |
pointer(per_res), | |
) | |
print("dds") | |
if res != dds.RETURN_NO_FAULT: | |
err_char_p = dds.ErrorMessage(res) | |
err_string = ( | |
string_at(err_char_p).decode("utf-8") | |
if err_char_p | |
else "Unknown error" | |
) | |
raise RuntimeError( | |
f"DDS Solver failed with code: {res} ({err_string})" | |
) | |
filenames = [d["filename"] for d in valid_deals] | |
# 3. 結果を新しいウィンドウで表示 | |
DDSResultsWindow(self.root, table_res, filenames, False) | |
self.status_var.set("ダブルダミー分析が完了しました。") | |
except Exception as e: | |
messagebox.showerror( | |
"DDS分析エラー", f"分析中にエラーが発生しました:\n{e}" | |
) | |
self.status_var.set("DDS分析中にエラーが発生しました。") | |
def start_dds_single_analysis(self): | |
edited_hands = { | |
player: parse_hand_string(var.get()) | |
for player, var in self.result_entries.items() | |
} | |
self.status_var.set("ディールを分析中...") | |
self.root.update_idletasks() | |
try: | |
print(edited_hands) | |
deals = dds.ddTableDealPBN() | |
pbn_deal_string = convert2pbn_txt(edited_hands, "N") | |
print(pbn_deal_string) | |
# table_deal_pbn = dds.ddTableDealPBN() | |
# table_deal_pbn.cards = pbn_deal_string.encode("utf-8") | |
deals.cards = pbn_deal_string.encode("utf-8") | |
table_res = dds.ddTableResults() | |
# table_res_pointer = pointer(table_res) | |
res = dds.CalcDDtablePBN(deals, table_res) | |
print("dds") | |
if res != dds.RETURN_NO_FAULT: | |
err_char_p = dds.ErrorMessage(res) | |
err_string = ( | |
string_at(err_char_p).decode("utf-8") | |
if err_char_p | |
else "Unknown error" | |
) | |
raise RuntimeError( | |
f"DDS Solver failed with code: {res} ({err_string})" | |
) | |
# 3. 結果を新しいウィンドウで表示 | |
DDSResultsWindow(self.root, table_res, ["現在のハンド"], True) | |
self.status_var.set("ダブルダミー分析が完了しました。") | |
except Exception as e: | |
messagebox.showerror( | |
"DDS分析エラー", f"分析中にエラーが発生しました:\n{e}" | |
) | |
self.status_var.set("DDS分析中にエラーが発生しました。") | |
def open_debugger(self): | |
if not self.raw_rank_data: | |
messagebox.showwarning( | |
"データなし", | |
"デバッグするデータがありません。まず画像を分析してください。", | |
) | |
return | |
ColorDebuggerWindow(self.root, self.raw_rank_data) | |
def export_results(self): | |
if not self.all_result: | |
messagebox.showwarning( | |
"エクスポート不可", "エクスポートするデータがありません。" | |
) | |
return | |
save_path = filedialog.asksaveasfilename( | |
title="結果を保存", | |
defaultextension=".xhd", | |
filetypes=[ | |
("xhdファイル", "*.xhd"), | |
("dupファイル", "*.dup"), | |
("pbnファイル", "*.pbn"), | |
], | |
initialfile=f"card_analysis_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}", | |
) | |
if not save_path: | |
return | |
filename, ext = os.path.splitext(os.path.basename(save_path)) | |
encoding = "utf-8" | |
if ext == ".xhd": | |
text = convert2xhd(self.all_result, filename) | |
encoding = "shift_jis" | |
elif ext == ".dup": | |
text = convert2dup(self.all_result, filename) | |
elif ext == ".pbn": | |
text = convert2pbn(self.all_result, filename) | |
else: | |
text = convert2txt(self.all_result, filename) | |
print(text) | |
try: | |
with open(save_path, "w", encoding=encoding) as f: | |
f.write(text) | |
messagebox.showinfo( | |
"成功", f"結果が正常に保存されました:\n{save_path}" | |
) | |
self.status_var.set("結果をテキストファイルに出力しました。") | |
except Exception as e: | |
messagebox.showerror( | |
"エラー", f"ファイルへの書き込み中にエラーが発生しました:\n{e}" | |
) | |
class ColorDebuggerWindow(tk.Toplevel): | |
def __init__(self, parent, raw_data): | |
super().__init__(parent) | |
self.title("カラーデバッガー") | |
self.geometry("800x700") | |
self.raw_data = raw_data | |
# Entryウィジェット用の変数を初期化 | |
self.l_thresh_var = tk.StringVar( | |
value=str(DEFAULT_THRESHOLDS["L_black"]) | |
) | |
self.a_green_thresh_var = tk.StringVar( | |
value=str(DEFAULT_THRESHOLDS["a_green"]) | |
) | |
self.a_black_thresh_var = tk.StringVar( | |
value=str(DEFAULT_THRESHOLDS["a_black"]) | |
) | |
self.b_black_thresh_var = tk.StringVar( | |
value=str(DEFAULT_THRESHOLDS["b_black"]) | |
) | |
self.a_red_thresh_var = tk.StringVar( | |
value=str(DEFAULT_THRESHOLDS["a_red"]) | |
) | |
self.b_orange_thresh_var = tk.StringVar( | |
value=str(DEFAULT_THRESHOLDS["b_orange"]) | |
) | |
# --- GUIレイアウト --- | |
# 制御フレーム | |
control_frame = ttk.Frame(self, padding=10) | |
control_frame.pack(fill=tk.X) | |
# 数値入力ボックス (Entry) を作成 | |
ttk.Label(control_frame, text="L(黒)<").pack(side=tk.LEFT, padx=(0, 2)) | |
ttk.Entry(control_frame, textvariable=self.l_thresh_var, width=8).pack( | |
side=tk.LEFT, padx=(0, 10) | |
) | |
ttk.Label(control_frame, text="a(緑)<").pack(side=tk.LEFT, padx=(0, 2)) | |
ttk.Entry( | |
control_frame, textvariable=self.a_green_thresh_var, width=8 | |
).pack(side=tk.LEFT, padx=(0, 10)) | |
ttk.Label(control_frame, text="a(黒)>").pack(side=tk.LEFT, padx=(0, 2)) | |
ttk.Entry( | |
control_frame, textvariable=self.a_black_thresh_var, width=8 | |
).pack(side=tk.LEFT, padx=(0, 10)) | |
ttk.Label(control_frame, text="b(黒)<").pack(side=tk.LEFT, padx=(0, 2)) | |
ttk.Entry( | |
control_frame, textvariable=self.b_black_thresh_var, width=8 | |
).pack(side=tk.LEFT, padx=(0, 10)) | |
ttk.Label(control_frame, text="a(赤)>").pack(side=tk.LEFT, padx=(0, 2)) | |
ttk.Entry( | |
control_frame, textvariable=self.a_red_thresh_var, width=8 | |
).pack(side=tk.LEFT, padx=(0, 10)) | |
ttk.Label(control_frame, text="b(橙)>").pack(side=tk.LEFT, padx=(0, 2)) | |
ttk.Entry( | |
control_frame, textvariable=self.b_orange_thresh_var, width=8 | |
).pack(side=tk.LEFT, padx=(0, 10)) | |
# 更新ボタン | |
ttk.Button( | |
control_frame, | |
text="閾値を更新して再判定", | |
command=self.update_classifications, | |
).pack(side=tk.LEFT, padx=20) | |
# 結果表示用のキャンバスとスクロールバー | |
canvas_frame = ttk.Frame(self) | |
canvas_frame.pack(fill=tk.BOTH, expand=True) | |
self.canvas = tk.Canvas(canvas_frame) | |
scrollbar = ttk.Scrollbar( | |
canvas_frame, orient="vertical", command=self.canvas.yview | |
) | |
self.scrollable_frame = ttk.Frame(self.canvas) | |
self.scrollable_frame.bind( | |
"<Configure>", | |
lambda e: self.canvas.configure( | |
scrollregion=self.canvas.bbox("all") | |
), | |
) | |
self.canvas.create_window( | |
(0, 0), window=self.scrollable_frame, anchor="nw" | |
) | |
self.canvas.configure(yscrollcommand=scrollbar.set) | |
self.canvas.pack(side="left", fill="both", expand=True) | |
scrollbar.pack(side="right", fill="y") | |
self.photo_images = [] | |
self.update_classifications() | |
def update_classifications(self, event=None): | |
# 既存のウィジェットをクリア | |
for widget in self.scrollable_frame.winfo_children(): | |
widget.destroy() | |
try: | |
# 入力された文字列を数値に変換 | |
thresholds = DEFAULT_THRESHOLDS.copy() | |
thresholds["L_black"] = float(self.l_thresh_var.get()) | |
thresholds["a_green"] = float(self.a_green_thresh_var.get()) | |
thresholds["a_red"] = float(self.a_red_thresh_var.get()) | |
thresholds["a_black"] = float(self.a_black_thresh_var.get()) | |
thresholds["b_black"] = float(self.b_black_thresh_var.get()) | |
thresholds["b_orange"] = float(self.b_orange_thresh_var.get()) | |
except ValueError: | |
messagebox.showerror( | |
"入力エラー", "閾値には数値を入力してください。" | |
) | |
return | |
# 既存のウィジェットをクリア | |
for widget in self.scrollable_frame.winfo_children(): | |
widget.destroy() | |
self.photo_images.clear() # 参照をクリア | |
for i, rank_data in enumerate(self.raw_data): | |
avg_lab = rank_data["avg_lab"] | |
color_name, avg_lab = get_suit_from_color_rules( | |
avg_lab, thresholds | |
) | |
rank_data["color"] = color_name | |
# --- 各ランクの情報を表示 --- | |
row_frame = ttk.Frame(self.scrollable_frame, padding=5) | |
row_frame.pack(fill=tk.X) | |
# 画像パッチを表示 | |
pil_img = Image.fromarray( | |
cv2.cvtColor(rank_data["img"], cv2.COLOR_BGR2RGB) | |
) | |
pil_img.thumbnail((40, 50)) | |
photo_img = ImageTk.PhotoImage(pil_img) | |
self.photo_images.append(photo_img) | |
ttk.Label(row_frame, image=photo_img).pack(side=tk.LEFT) | |
# 情報をテキストで表示 | |
info_text = ( | |
f"Player: {rank_data["player"]} | Rank: {rank_data['name']} | " | |
f"L:{avg_lab[0]:.1f} a:{avg_lab[1]:.1f} b:{avg_lab[2]:.1f} -> " | |
f"判定: {color_name.upper()}" | |
) | |
ttk.Label( | |
row_frame, text=info_text, font=("Courier New", 10) | |
).pack(side=tk.LEFT, padx=10) | |
class DDSResultsWindow(tk.Toplevel): | |
def __init__(self, parent, solved_table, filenames, is_single): | |
super().__init__(parent) | |
self.title("ダブルダミー分析結果") | |
self.geometry("490x180") | |
notebook = ttk.Notebook(self) | |
notebook.pack(pady=10, padx=10, fill="both", expand=True) | |
# print(solved_table.noOfBoards) | |
if is_single: | |
frame = ttk.Frame(self, padding="10") | |
frame.pack(fill=tk.BOTH, expand=True) | |
# ★★★ 重要な修正点:columnsタプルに 'player' を明確に含める ★★★ | |
column_ids = ("player", "nt", "s", "h", "d", "c") | |
tree = ttk.Treeview(frame, columns=column_ids, show="headings") | |
tree.pack(fill=tk.BOTH, expand=True) | |
# ヘッダー(列のタイトル)の設定 | |
tree.heading("player", text="Declarer") | |
tree.heading("nt", text="NT") | |
tree.heading("s", text="Spades ♠") | |
tree.heading("h", text="Hearts ♥") | |
tree.heading("d", text="Diamonds ♦") | |
tree.heading("c", text="Clubs ♣") | |
# 各列の幅と文字揃えを設定 | |
tree.column("player", width=80, anchor=tk.W, stretch=tk.NO) | |
tree.column("nt", width=50, anchor=tk.CENTER) | |
tree.column("s", width=80, anchor=tk.CENTER) | |
tree.column("h", width=80, anchor=tk.CENTER) | |
tree.column("d", width=80, anchor=tk.CENTER) | |
tree.column("c", width=80, anchor=tk.CENTER) | |
# DDSライブラリの規約に合わせて、表示するプレイヤーの順番を定義 | |
players_map = {0: "North", 1: "East", 2: "South", 3: "West"} | |
suits_map = {4: "nt", 0: "s", 1: "h", 2: "d", 3: "c"} | |
# テーブルデータを整形 | |
table_data = {p_name: {} for p_name in players_map.values()} | |
for suit_idx, suit_name in suits_map.items(): | |
for player_idx, player_name in players_map.items(): | |
tricks = solved_table.resTable[suit_idx][player_idx] | |
table_data[player_name][suit_name] = tricks | |
# テーブルにデータを挿入 | |
for player_name in ["North", "South", "East", "West"]: | |
# valuesの最初の要素が 'player' カラムに対応 | |
row_values = [player_name] + [ | |
table_data[player_name][s] | |
for s in ["nt", "s", "h", "d", "c"] | |
] | |
tree.insert("", tk.END, values=tuple(row_values)) | |
else: | |
for i in range(solved_table.noOfBoards // 20): | |
filename = filenames[i] | |
table = solved_table.results[i] | |
frame = ttk.Frame(notebook, padding="10") | |
notebook.add(frame, text=os.path.basename(filename)[:20]) | |
# Treeviewウィジェットでテーブルを作成 | |
tree = ttk.Treeview( | |
frame, | |
columns=("player", "nt", "s", "h", "d", "c"), | |
show="headings", | |
height=5, | |
) | |
tree.pack(fill=tk.BOTH, expand=True) | |
# ヘッダーの設定 | |
tree.heading("player", text="Declarer") | |
tree.heading("nt", text="NT") | |
tree.heading("s", text="Spades ♠") | |
tree.heading("h", text="Hearts ♥") | |
tree.heading("d", text="Diamonds ♦") | |
tree.heading("c", text="Clubs ♣") | |
tree.column("player", width=80, anchor=tk.W, stretch=tk.NO) | |
tree.column("nt", width=60, anchor=tk.CENTER) | |
tree.column("s", width=80, anchor=tk.CENTER) | |
tree.column("h", width=80, anchor=tk.CENTER) | |
tree.column("d", width=80, anchor=tk.CENTER) | |
tree.column("c", width=80, anchor=tk.CENTER) | |
table_data = reshape_table(table) | |
print(table_data) | |
# テーブルにデータを挿入 | |
for player_name in ["North", "South", "East", "West"]: | |
row_values = [player_name] + [ | |
table_data[player_name][s] | |
for s in ["nt", "s", "h", "d", "c"] | |
] | |
tree.insert("", tk.END, values=tuple(row_values)) | |
if __name__ == "__main__": | |
root = tk.Tk() | |
app = CardRecognizerApp(root) | |
root.mainloop() | |