From bf3214e3031aee499572e9c98e942c4f7993151f Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:29:06 +0800 Subject: [PATCH 01/14] Update main.py --- main.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 7721a90..e2bad64 100644 --- a/main.py +++ b/main.py @@ -18,15 +18,15 @@ import loadData import auto_fetch from maa_adb_connector import MaaAdbConnector, MaaFrameworkDetector -from dark_mode_style_fix import DarkModeStyleFix -import similar_history_match +from gui.dark_mode_style_fix import DarkModeStyleFix +from gui.similar_history_match import HistoryMatch +from gui.simular_history_match_ui import HistoryMatchUI import recognize from recognize import MONSTER_COUNT from specialmonster import SpecialMonsterHandler import data_package import winrt_capture from config import FIELD_FEATURE_COUNT, MONSTER_DATA -from simular_history_match_ui import HistoryMatchUI from input_panel_ui import InputPanelUI logging.getLogger().setLevel(logging.DEBUG) @@ -42,12 +42,12 @@ try: - from predict import CannotModel + from core.predict import CannotModel from train import UnitAwareTransformer logger.info("Using PyTorch model for predictions.") except: - from predict_onnx import CannotModel + from core.predict_onnx import CannotModel logger.info("Using ONNX model for predictions.") @@ -123,7 +123,7 @@ def __init__(self): # 初始化UI后加载历史数据 logger.info("尝试获取错题本") self.history_match = None - self.history_match = similar_history_match.HistoryMatch() + self.history_match = HistoryMatch() # Ensure feat_past and N_history are initialized try: self.history_match.feat_past = np.hstack([self.history_match.past_left, self.history_match.past_right]) From 138a9d5bde91c9b94c04919440d717200b5a00f9 Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:29:21 +0800 Subject: [PATCH 02/14] Update main_old.py --- main_old.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main_old.py b/main_old.py index 89d18ed..ab9f444 100644 --- a/main_old.py +++ b/main_old.py @@ -7,12 +7,12 @@ import numpy as np import math from PIL import Image, ImageTk -from predict import CannotModel +from core.predict import CannotModel import loadData import recognize from train import UnitAwareTransformer from recognize import MONSTER_COUNT -from similar_history_match import HistoryMatch +from gui.similar_history_match import HistoryMatch from auto_fetch import AutoFetch logging.getLogger().setLevel(logging.DEBUG) From d496ad97f425ba27078c3f4f1be862129a99430e Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:29:43 +0800 Subject: [PATCH 03/14] Update multi_instance.py --- multi_instance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/multi_instance.py b/multi_instance.py index 2ed65b2..3ec7c1b 100644 --- a/multi_instance.py +++ b/multi_instance.py @@ -5,7 +5,7 @@ import subprocess from pathlib import Path from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QPlainTextEdit, QSpinBox, QComboBox, QCheckBox, QMessageBox, QSplitter, QScrollArea, QFrame, QLineEdit ) @@ -109,10 +109,10 @@ def get_cannot_model(): if _cannot_model is None: logger.info("首次初始化 CannotModel...") try: - from predict import CannotModel + from core.predict import CannotModel logger.info("Using PyTorch model for predictions.") except Exception: - from predict_onnx import CannotModel + from core.predict_onnx import CannotModel logger.info("Using ONNX model for predictions.") _cannot_model = CannotModel() From 1069a336516ccc0bc10f689a2a4de0f997f1dcff Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:30:26 +0800 Subject: [PATCH 04/14] Update convert_model.py --- tools/convert_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/convert_model.py b/tools/convert_model.py index f55852b..c8856c2 100644 --- a/tools/convert_model.py +++ b/tools/convert_model.py @@ -1,9 +1,9 @@ import sys sys.path.append(".") -import predict +import core.predict from train import UnitAwareTransformer -import predict_onnx +import core.predict_onnx import numpy as np from recognize import MONSTER_COUNT From cfabfd9839334569ea75d2f4599db19f265c26fd Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:31:01 +0800 Subject: [PATCH 05/14] Create __init__.py --- core/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 core/__init__.py diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ + From bdb19b43f376a4d4782d0a08fe02eec80909a78b Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:31:15 +0800 Subject: [PATCH 06/14] Add files via upload --- core/predict.py | 257 +++++++++++++++++++++++++++++++++++++++++++ core/predict_onnx.py | 182 ++++++++++++++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 core/predict.py create mode 100644 core/predict_onnx.py diff --git a/core/predict.py b/core/predict.py new file mode 100644 index 0000000..59edb44 --- /dev/null +++ b/core/predict.py @@ -0,0 +1,257 @@ +import re +from datetime import datetime +from functools import cache +from pathlib import Path + +import numpy as np +import torch +import logging + +from config import MONSTER_COUNT +from config import FIELD_FEATURE_COUNT + +logger = logging.getLogger(__name__) + +def get_device(prefer_gpu=True): + """ + prefer_gpu (bool): 是否优先尝试使用GPU + """ + if prefer_gpu: + if torch.cuda.is_available(): + logger.info("Use torch with cuda") + return torch.device("cuda") + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + logger.info("Use torch with mps") + return torch.device("mps") # Apple Silicon GPU + elif hasattr(torch, "xpu") and torch.xpu.is_available(): # Intel GPU + logger.info("Use torch with xpu") + return torch.device("xpu") + logger.info("Use torch with cpu") + return torch.device("cpu") + +class CannotModel: + def __init__(self, model_path="models"): + self.device = get_device() + self.is_model_loaded = False + self.model_path = self._resolve_model_path(model_path) + try: + self.load_model() # 初始化时加载模型 + self.is_model_loaded = True + except Exception as e: + logger.error(f"模型加载失败: {e}") + self.model = None + + def _resolve_model_path(self, path): + """ + Resolves the model path. If a directory is given, finds the latest model file. + If a file is given, returns it directly. + """ + if Path(path).is_dir(): + logger.info(f"Searching for the latest model in directory: {path}") + model_dir = Path(path) + models = [f for f in model_dir.iterdir() if f.suffix == ".pth" and f.is_file()] + if not models: + logger.error(f"No model files (.pth) found in {path}") + + priority = {"loss": 0, "acc": 1, "full": 2} + valid_models = [] + + pattern = re.compile( + r"best_model_(acc|loss|full)_data\d+_acc\d+\.\d+_loss\d+\.\d+_(\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2})\.pth$" + ) + + for model_file_path in models: + match = pattern.match(model_file_path.name) + if match: + model_type = match.group(1) + timestamp_str = match.group(2) # Group 2 captures the timestamp + try: + model_time = datetime.strptime( + timestamp_str, "%Y_%m_%d_%H_%M_%S" + ) + valid_models.append((model_time, priority.get(model_type, 3), model_file_path)) + except ValueError: + continue # Ignore files with malformed timestamps + + if valid_models: + # Sort by time DESC, then priority ASC (loss=0, acc=1, full=2) + valid_models.sort(key=lambda x: (x[0], -x[1]), reverse=True) + latest_model_path = valid_models[0][2] + logger.info(f"Found latest model: {latest_model_path}") + return str(latest_model_path) + else: + logger.error( + f"No models with the expected name format found in {path}" + ) + + elif Path(path).is_file(): + logger.info(f"Using specified model file: {path}") + return path + else: + logger.error(f"Provided model path is invalid: {path}") + return "" + + def load_model(self): + """初始化时加载模型""" + try: + if not Path(self.model_path).exists(): + raise FileNotFoundError( + rf"未找到训练好的模型文件 {self.model_path},请先训练模型" + ) + + try: + model = torch.load( + self.model_path, + map_location=self.device, + weights_only=False, + ) + except TypeError: # 如果旧版本 PyTorch 不认识 weights_only + model = torch.load( + self.model_path, map_location=self.device + ) + model.eval() + self.model = model.to(self.device) + + except Exception as e: + error_msg = f"模型加载失败: {str(e)}" + if "missing keys" in str(e): + error_msg += "\n可能是模型结构不匹配,请重新训练模型" + raise e # 无法继续运行,退出程序 + + def export_onnx(self,outputpath, monster_count=MONSTER_COUNT): + # 确保模型在 CPU 上(避免设备不一致) + self.model = self.model.cpu() + self.model.eval() + + # 生成虚拟输入(与模型同设备) + device = next(self.model.parameters()).device + dummy_left_counts = torch.randint(0, 10, (1, monster_count), dtype=torch.int16, device=device) + dummy_right_counts = torch.randint(0, 10, (1, monster_count), dtype=torch.int16, device=device) + + # 获取符号和绝对值张量(确保在相同设备) + left_signs = torch.sign(dummy_left_counts.to(torch.int64)).to(device) + left_counts = torch.abs(dummy_left_counts.to(torch.int64)).to(device) + right_signs = torch.sign(dummy_right_counts.to(torch.int64)).to(device) + right_counts = torch.abs(dummy_right_counts.to(torch.int64)).to(device) + + # 导出参数 + input_names = ["left_signs", "left_counts", "right_signs", "right_counts"] + dynamic_axes = {name: {0: 'batch_size'} for name in input_names} + dynamic_axes["output"] = {0: 'batch_size'} + + # 导出 ONNX + torch.onnx.export( + self.model, + (left_signs, left_counts, right_signs, right_counts), + outputpath, + input_names=input_names, + output_names=["output"], + dynamic_axes=dynamic_axes, + opset_version=20, + verbose=True # 开启详细输出便于调试 + ) + + def get_prediction(self, left_counts: np.typing.ArrayLike, right_counts: np.typing.ArrayLike): + if self.model is None: + raise RuntimeError("模型未正确初始化") + + # 转换为张量并处理符号和绝对值 + left_signs = ( + torch.sign(torch.tensor(left_counts, dtype=torch.int16)) + .unsqueeze(0) + .to(self.device) + ) + left_counts = ( + torch.abs(torch.tensor(left_counts, dtype=torch.int16)) + .unsqueeze(0) + .to(self.device) + ) + right_signs = ( + torch.sign(torch.tensor(right_counts, dtype=torch.int16)) + .unsqueeze(0) + .to(self.device) + ) + right_counts = ( + torch.abs(torch.tensor(right_counts, dtype=torch.int16)) + .unsqueeze(0) + .to(self.device) + ) + + # 预测流程 + with torch.no_grad(): + # 使用修改后的模型前向传播流程 + prediction = self.model( + left_signs, left_counts, right_signs, right_counts + ).item() + + # 确保预测值在有效范围内 + if np.isnan(prediction) or np.isinf(prediction): + logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") + prediction = 0.5 + + # 检查预测结果是否在[0,1]范围内 + if prediction < 0 or prediction > 1: + prediction = max(0, min(1, prediction)) + + return prediction + + def get_prediction_with_terrain(self, full_features: np.typing.ArrayLike): + """使用包含地形特征的完整特征向量进行预测""" + if self.model is None: + raise RuntimeError("模型未正确初始化") + + # 检查特征向量长度 + expected_length = MONSTER_COUNT * 2 + FIELD_FEATURE_COUNT * 2 # 77L + 6L + 77R + 6R = 166 + if len(full_features) != expected_length: + logger.warning(f"特征向量长度不匹配: 期望{expected_length}, 实际{len(full_features)}") + # 如果长度不匹配,回退到原始方法 + left_counts = full_features[:MONSTER_COUNT] + right_counts = full_features[MONSTER_COUNT:MONSTER_COUNT*2] + return self.get_prediction(left_counts, right_counts) + + # 提取各个部分 + left_monsters = full_features[:MONSTER_COUNT] # 1L-77L + left_terrain = full_features[MONSTER_COUNT:MONSTER_COUNT+FIELD_FEATURE_COUNT] # 78L-83L + right_monsters = full_features[MONSTER_COUNT+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT] # 1R-77R + right_terrain = full_features[MONSTER_COUNT*2+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT*2] # 78R-83R + + # 合并怪物特征和地形特征(按照训练时的格式) + left_counts = np.concatenate([left_monsters, left_terrain]) + right_counts = np.concatenate([right_monsters, right_terrain]) + + # 转换为张量并处理符号和绝对值 + # 对于怪物特征,使用符号和绝对值 + # 对于地形特征,不需要符号处理(地形特征本身就是0/1值) + left_monster_signs = torch.sign(torch.tensor(left_monsters, dtype=torch.int16)) + left_terrain_signs = torch.ones_like(torch.tensor(left_terrain, dtype=torch.int16)) # 地形特征符号为1 + left_signs = torch.cat([left_monster_signs, left_terrain_signs]).unsqueeze(0).to(self.device) + + left_monster_counts = torch.abs(torch.tensor(left_monsters, dtype=torch.int16)) + left_terrain_counts = torch.tensor(left_terrain, dtype=torch.int16) # 地形特征直接使用原值 + left_counts_tensor = torch.cat([left_monster_counts, left_terrain_counts]).unsqueeze(0).to(self.device) + + right_monster_signs = torch.sign(torch.tensor(right_monsters, dtype=torch.int16)) + right_terrain_signs = torch.ones_like(torch.tensor(right_terrain, dtype=torch.int16)) # 地形特征符号为1 + right_signs = torch.cat([right_monster_signs, right_terrain_signs]).unsqueeze(0).to(self.device) + + right_monster_counts = torch.abs(torch.tensor(right_monsters, dtype=torch.int16)) + right_terrain_counts = torch.tensor(right_terrain, dtype=torch.int16) # 地形特征直接使用原值 + right_counts_tensor = torch.cat([right_monster_counts, right_terrain_counts]).unsqueeze(0).to(self.device) + + # 预测流程 + with torch.no_grad(): + # 使用修改后的模型前向传播流程,现在包含地形特征 + prediction = self.model( + left_signs, left_counts_tensor, right_signs, right_counts_tensor + ).item() + + # 确保预测值在有效范围内 + if np.isnan(prediction) or np.isinf(prediction): + logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") + prediction = 0.5 + + # 检查预测结果是否在[0,1]范围内 + if prediction < 0 or prediction > 1: + prediction = max(0, min(1, prediction)) + + return prediction diff --git a/core/predict_onnx.py b/core/predict_onnx.py new file mode 100644 index 0000000..31a527f --- /dev/null +++ b/core/predict_onnx.py @@ -0,0 +1,182 @@ +from pathlib import Path + +import onnxruntime as ort +import os +import numpy as np +import logging + +from config import MONSTER_COUNT +from config import FIELD_FEATURE_COUNT + +logger = logging.getLogger(__name__) + +class CannotModel: + def __init__(self, model_path="models"): + self.model_path = self._resolve_model_path(model_path) + self.is_model_loaded = False + try: + self.load_model() # 初始化时加载模型 + self.is_model_loaded = True + except Exception as e: + logger.error(f"模型加载失败: {e}") + self.session = None + + def _resolve_model_path(self, path): + """ + Resolves the model path. If a directory is given, finds the latest model file. + If a file is given, returns it directly. + """ + if Path(path).is_dir(): + logger.info(f"Searching for the latest model in directory: {path}") + model_dir = Path(path) + + # 尝试寻找默认的 best_model_full.onnx + default_path = model_dir / "best_model_full.onnx" + if default_path.exists(): + logger.info(f"Found default model: {default_path}") + return str(default_path) + + logger.error(f"No valid ONNX model files found in {path}") + return str(default_path) + + elif Path(path).is_file(): + logger.info(f"Using specified model file: {path}") + return path + else: + logger.error(f"Provided model path is invalid: {path}") + return "" + + def load_model(self): + """加载 ONNX 模型""" + try: + if not os.path.exists(self.model_path): + raise FileNotFoundError(f"未找到 ONNX 模型文件 {self.model_path}") + + # 配置会话选项 + sess_options = ort.SessionOptions() + sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + + # 创建会话(默认使用 CPU) + self.session = ort.InferenceSession( + self.model_path, + sess_options, + providers=['CPUExecutionProvider'] + ) + + except Exception as e: + raise RuntimeError(f"ONNX 模型加载失败: {str(e)}") + + def get_prediction(self, left_counts: np.ndarray, right_counts: np.ndarray): + if self.session is None: + raise RuntimeError("模型未正确初始化") + + def validate_input(arr): + """验证并转换输入数据""" + # 转换为 int64 类型 + arr = arr.astype(np.int64) + + # 添加批次维度(如果输入是单样本) + if arr.ndim == 1: + arr = arr[np.newaxis, :] # shape: (1, 56) + return arr + + # 处理符号和绝对值,以匹配导出的模型输入 + left_signs_arr = np.sign(left_counts).astype(np.int64) + left_counts_arr = np.abs(left_counts).astype(np.int64) + right_signs_arr = np.sign(right_counts).astype(np.int64) + right_counts_arr = np.abs(right_counts).astype(np.int64) + + inputs = { + "left_signs": validate_input(left_signs_arr), + "left_counts": validate_input(left_counts_arr), + "right_signs": validate_input(right_signs_arr), + "right_counts": validate_input(right_counts_arr) + } + + # 执行推理 + try: + output = self.session.run( + output_names=["output"], + input_feed=inputs + ) + # output 是一个列表,output[0] 是形状为 (batch_size, 1) 的数组 + prediction = output[0].flatten()[0] + except Exception as e: + raise RuntimeError(f"推理失败: {str(e)}") + + # 后处理(与原逻辑一致) + if np.isnan(prediction) or np.isinf(prediction): + logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") + prediction = 0.5 + + prediction = np.clip(prediction, 0.0, 1.0) + return float(prediction) + + def get_prediction_with_terrain(self, full_features: np.ndarray): + """使用包含地形特征的完整特征向量进行预测(ONNX版本)""" + if self.session is None: + raise RuntimeError("模型未正确初始化") + + # 检查特征向量长度 + expected_length = MONSTER_COUNT * 2 + FIELD_FEATURE_COUNT * 2 # 77L + 6L + 77R + 6R = 166 + if len(full_features) != expected_length: + logger.warning(f"特征向量长度不匹配: 期望{expected_length}, 实际{len(full_features)}") + # 如果长度不匹配,回退到原始方法 + left_counts = full_features[:MONSTER_COUNT] + right_counts = full_features[MONSTER_COUNT:MONSTER_COUNT*2] + return self.get_prediction(left_counts, right_counts) + + # 提取各个部分 + left_monsters = full_features[:MONSTER_COUNT] # 1L-77L + left_terrain = full_features[MONSTER_COUNT:MONSTER_COUNT+FIELD_FEATURE_COUNT] # 78L-83L + right_monsters = full_features[MONSTER_COUNT+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT] # 1R-77R + right_terrain = full_features[MONSTER_COUNT*2+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT*2] # 78R-83R + + # 处理左侧特征 + left_monster_signs = np.sign(left_monsters).astype(np.int64) + left_terrain_signs = np.ones_like(left_terrain).astype(np.int64) + left_signs = np.concatenate([left_monster_signs, left_terrain_signs]) + + left_monster_counts = np.abs(left_monsters).astype(np.int64) + left_counts = np.concatenate([left_monster_counts, left_terrain.astype(np.int64)]) + + # 处理右侧特征 + right_monster_signs = np.sign(right_monsters).astype(np.int64) + right_terrain_signs = np.ones_like(right_terrain).astype(np.int64) + right_signs = np.concatenate([right_monster_signs, right_terrain_signs]) + + right_monster_counts = np.abs(right_monsters).astype(np.int64) + right_counts = np.concatenate([right_monster_counts, right_terrain.astype(np.int64)]) + + def validate_input(arr): + """验证并转换输入数据""" + arr = arr.astype(np.int64) + if arr.ndim == 1: + arr = arr[np.newaxis, :] + return arr + + inputs = { + "left_signs": validate_input(left_signs), + "left_counts": validate_input(left_counts), + "right_signs": validate_input(right_signs), + "right_counts": validate_input(right_counts) + } + + # 执行推理 + try: + output = self.session.run( + output_names=["output"], + input_feed=inputs + ) + # output[0] 是形状为 (batch_size, 1) 的数组 + prediction = output[0].flatten()[0] + except Exception as e: + raise RuntimeError(f"推理失败: {str(e)}") + + # 后处理(与原逻辑一致) + if np.isnan(prediction) or np.isinf(prediction): + logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") + prediction = 0.5 + + prediction = np.clip(prediction, 0.0, 1.0) + return float(prediction) \ No newline at end of file From 27b961eb196870c0827c4cbf6a19be53f1f89659 Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:31:50 +0800 Subject: [PATCH 07/14] Create __init__.py --- gui/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 gui/__init__.py diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1 @@ + From e2e645f301bdd82fb8edd57771455a60e55d475b Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:33:47 +0800 Subject: [PATCH 08/14] Add files via upload --- gui/dark_mode_style_fix.py | 123 ++++++++++++ gui/similar_history_match.py | 336 ++++++++++++++++++++++++++++++++ gui/similar_history_match_ui.py | 302 ++++++++++++++++++++++++++++ 3 files changed, 761 insertions(+) create mode 100644 gui/dark_mode_style_fix.py create mode 100644 gui/similar_history_match.py create mode 100644 gui/similar_history_match_ui.py diff --git a/gui/dark_mode_style_fix.py b/gui/dark_mode_style_fix.py new file mode 100644 index 0000000..7565859 --- /dev/null +++ b/gui/dark_mode_style_fix.py @@ -0,0 +1,123 @@ +class DarkModeStyleFix: + DARK_TEXT_COLOR = "#313131" + COMBO_POPUP_BACKGROUND = "#FFFFFF" + COMBO_POPUP_BORDER = "#CCCCCC" + COMBO_SELECTION_BACKGROUND = "#F5EA2D" + COMBO_SELECTION_COLOR = "#313131" + PLACEHOLDER_COLOR = "#888888" + + @staticmethod + def get_global_qss() -> str: + return f""" + QDialog {{ + background-color: #FFFFFF; + }} + QMessageBox {{ + background-color: #FFFFFF; + }} + QMessageBox QLabel {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + background-color: transparent; + }} + QMessageBox QPushButton {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + background-color: #F2F2F2; + border: 1px solid #999999; + border-radius: 4px; + padding: 4px 10px; + min-width: 60px; + }} + QMessageBox QPushButton:hover {{ + background-color: #E6E6E6; + border: 1px solid #666666; + }} + QLabel {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QGroupBox {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QGroupBox::title {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QCheckBox {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QComboBox {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QComboBox QAbstractItemView {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + background-color: {DarkModeStyleFix.COMBO_POPUP_BACKGROUND}; + selection-background-color: {DarkModeStyleFix.COMBO_SELECTION_BACKGROUND}; + selection-color: {DarkModeStyleFix.COMBO_SELECTION_COLOR}; + border: 1px solid {DarkModeStyleFix.COMBO_POPUP_BORDER}; + outline: none; + }} + QComboBox QLineEdit {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QLineEdit {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QPushButton {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + border: 1px solid #999999; + border-radius: 4px; + padding: 4px 8px; + }} + QPushButton:hover {{ + border: 1px solid #666666; + }} + QPushButton:pressed {{ + border: 1px solid #333333; + }} + QScrollArea {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + """ + + @staticmethod + def get_combo_box_qss() -> str: + return f""" + QComboBox {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QComboBox QAbstractItemView {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + background-color: {DarkModeStyleFix.COMBO_POPUP_BACKGROUND}; + selection-background-color: {DarkModeStyleFix.COMBO_SELECTION_BACKGROUND}; + selection-color: {DarkModeStyleFix.COMBO_SELECTION_COLOR}; + border: 1px solid {DarkModeStyleFix.COMBO_POPUP_BORDER}; + outline: none; + }} + QComboBox QLineEdit {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + """ + + @staticmethod + def get_line_edit_qss() -> str: + return f""" + QLineEdit {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + """ + + @staticmethod + def get_group_box_title_qss() -> str: + return f""" + QGroupBox {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + QGroupBox::title {{ + color: {DarkModeStyleFix.DARK_TEXT_COLOR}; + }} + """ + + @staticmethod + def apply(app) -> None: + if app is None: + raise ValueError("QApplication instance cannot be None") + global_qss = DarkModeStyleFix.get_global_qss() + app.setStyleSheet(global_qss) diff --git a/gui/similar_history_match.py b/gui/similar_history_match.py new file mode 100644 index 0000000..7e4df25 --- /dev/null +++ b/gui/similar_history_match.py @@ -0,0 +1,336 @@ +import numpy as np +import pandas as pd +# 从父目录找config +import sys +from pathlib import Path +root_dir = Path(__file__).parent.parent +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) +from config import MONSTER_COUNT +from config import FIELD_FEATURE_COUNT + +def cosine_similarity_manual(a, b): + """手动实现余弦相似度,替代 sklearn 以减小打包体积""" + norm_a = np.linalg.norm(a, axis=1, keepdims=True) + norm_b = np.linalg.norm(b, axis=1, keepdims=True) + # 避免除以零 + norm_a[norm_a == 0] = 1e-10 + norm_b[norm_b == 0] = 1e-10 + dot = np.dot(a, b.T) + return dot / (norm_a * norm_b.T) + +class HistoryMatch: + """错题本数据集的读取和处理类""" + + def __init__(self, csv_path="arknights.csv"): + # 初始化时加载历史对局数据 + self.csv_path = csv_path + self.load_history_data() + + def __len__(self): + # 返回历史对局数量 + return self.N_history + + def load_history_data(self): + """读取 CSV 文件,加载历史对局的左右阵容、地形及胜负标签""" + try: + df = pd.read_csv(self.csv_path, header=None, skiprows=1) + + # 新数据格式: [怪物L(77), 场地L(6), 怪物R(77), 场地R(6), Result, ImgPath] + total_features = (MONSTER_COUNT + FIELD_FEATURE_COUNT) * 2 + + if df.shape[1] >= total_features + 1: # 至少包含特征和结果列 + # 提取各部分特征 + left_monster_end = MONSTER_COUNT + left_field_end = MONSTER_COUNT + FIELD_FEATURE_COUNT + right_monster_end = MONSTER_COUNT + FIELD_FEATURE_COUNT + MONSTER_COUNT + right_field_end = MONSTER_COUNT + FIELD_FEATURE_COUNT + MONSTER_COUNT + FIELD_FEATURE_COUNT + + # 分别提取怪物和地形特征 + left_monsters = df.iloc[:, 0:left_monster_end].values.astype(float) + left_terrain = df.iloc[:, left_monster_end:left_field_end].values.astype(float) + right_monsters = df.iloc[:, left_field_end:right_monster_end].values.astype(float) + right_terrain = df.iloc[:, right_monster_end:right_field_end].values.astype(float) + + # 合并怪物特征(只使用怪物部分进行相似度计算) + self.past_left = left_monsters + self.past_right = right_monsters + + # 保存地形特征用于显示 + self.past_left_terrain = left_terrain + self.past_right_terrain = right_terrain + + # 胜负标签 + self.labels = df.iloc[:, total_features].values + else: + # 兼容旧格式:只有怪物特征 + self.past_left = df.iloc[:, 0:MONSTER_COUNT].values.astype(float) + self.past_right = df.iloc[:, MONSTER_COUNT:MONSTER_COUNT*2].values.astype(float) + self.labels = df.iloc[:, MONSTER_COUNT*2].values + + # 地形特征为空 + self.past_left_terrain = np.zeros((len(self.past_left), FIELD_FEATURE_COUNT)) + self.past_right_terrain = np.zeros((len(self.past_right), FIELD_FEATURE_COUNT)) + + except Exception as e: + print(f"加载历史数据失败: {e}") + # 加载失败时,初始化为空数组 + self.past_left = np.zeros((0, MONSTER_COUNT), float) + self.past_right = np.zeros((0, MONSTER_COUNT), float) + self.past_left_terrain = np.zeros((0, FIELD_FEATURE_COUNT), float) + self.past_right_terrain = np.zeros((0, FIELD_FEATURE_COUNT), float) + self.labels = np.array([], dtype=str) + + # 构造历史对局特征: 左右数量之和与差的绝对值拼接(只使用怪物特征) + self.feat_past = np.hstack([ + self.past_left + self.past_right, + np.abs(self.past_left - self.past_right) + ]) + # 历史对局总数 + self.N_history = self.past_left.shape[0] + + def render_similar_matches(self, left_counts: np.ndarray, right_counts: np.ndarray): + """返回与当前对局最相似的历史对局索引及胜率统计""" + # 将输入转为浮点型数组 + cur_left = left_counts.astype(float) + cur_right = right_counts.astype(float) + + # 计算当前存在的兵种布尔向量 + pres_L = cur_left > 0 + pres_R = cur_right > 0 + need_L_idx = np.nonzero(pres_L)[0] # 当前左侧有兵的索引 + need_R_idx = np.nonzero(pres_R)[0] # 当前右侧有兵的索引 + + # 构造当前对局特征并计算与所有历史的余弦相似度 + feat_cur = np.hstack([cur_left + cur_right, np.abs(cur_left - cur_right)]).reshape(1, -1) + sims = cosine_similarity_manual(feat_cur, self.feat_past)[0] + + # 历史对局的存在布尔矩阵 + hist_pres_L = self.past_left > 0 # shape (N_history, MONSTER_COUNT) + hist_pres_R = self.past_right > 0 + + # 计算未镜像(missA, cntA)和镜像后(missB, cntB)的缺兵及数量差距 + missA = np.sum(np.logical_xor(pres_L, hist_pres_L), axis=1) + np.sum( + np.logical_xor(pres_R, hist_pres_R), axis=1) + cntA = np.sum(np.abs(self.past_left - cur_left), axis=1) + np.sum( + np.abs(self.past_right - cur_right), axis=1) + + missB = np.sum(np.logical_xor(pres_L, hist_pres_R), axis=1) + np.sum( + np.logical_xor(pres_R, hist_pres_L), axis=1) + cntB = np.sum(np.abs(self.past_right - cur_left), axis=1) + np.sum( + np.abs(self.past_left - cur_right), axis=1) + + # 根据(miss, cnt)比较,决定是否对历史数据做镜像处理 + swap = (missB < missA) | ((missB == missA) & (cntB < cntA)) + + # 生成镜像后的历史左右阵容 + Lh = np.where(swap[:, None], self.past_right, self.past_left) + Rh = np.where(swap[:, None], self.past_left, self.past_right) + + # 修复bug:3B对阵3A在csv文件中,但输入3A、3B不返回,输入3B、3A才返回 + # 使用镜像后的阵容构造存在布尔矩阵(后续匹配统计应基于镜像后的阵容) + hist_pres_Lh = Lh > 0 # shape (N_history, MONSTER_COUNT) + hist_pres_Rh = Rh > 0 + + # 判断在需求索引上是否完全匹配 + full_L = np.all(Lh[:, need_L_idx] == cur_left[need_L_idx], axis=1) + full_R = np.all(Rh[:, need_R_idx] == cur_right[need_R_idx], axis=1) + + # 计算需求索引处的数量差和 + diff_L = np.sum(np.abs(Lh[:, need_L_idx] - cur_left[need_L_idx]), axis=1) + diff_R = np.sum(np.abs(Rh[:, need_R_idx] - cur_right[need_R_idx]), axis=1) + + # 计算对手兵种在本方需求中的命中数,取最小值作为 match_other + hit_L = np.sum(hist_pres_Rh[:, need_L_idx] & pres_L[need_L_idx], axis=1) + hit_R = np.sum(hist_pres_Lh[:, need_R_idx] & pres_R[need_R_idx], axis=1) + match_other = np.minimum(hit_L, hit_R) + + # 根据命中侧及是否完全匹配,选择对应的 qdiff_other + qdiff_other = np.where( + (hit_R > 0) & (~full_R), diff_R, + np.where((hit_L > 0) & (~full_L), diff_L, 0) + ) + + # 批量计算分类所需的布尔向量 + # 注意:类型(presence)比较也应基于镜像后的阵容 + typeL_eq = np.all(hist_pres_Lh == pres_L, axis=1) + typeR_eq = np.all(hist_pres_Rh == pres_R, axis=1) + cntL_eq = np.all(Lh == cur_left, axis=1) + cntR_eq = np.all(Rh == cur_right, axis=1) + + # 初始化类别为最松散的 5(默认) + cats = np.full(self.N_history, 5, dtype=np.int8) + + # 重新定义分类优先级(数值越小优先级越高): + # 0: 双方种类与数量都完全相同 + # 1: 双方种类相同,数量均不同但成比例(如 1A1B vs 2A2B) + # 2: 双方种类相同,且至少一侧数量相同(但不是双方都相同) + # 3: 双方种类相同,但双方数量均不同(且不成比例) + # 5: 其它(默认) + same_species = typeL_eq & typeR_eq + + mask0 = same_species & cntL_eq & cntR_eq + mask1 = same_species & (cntL_eq | cntR_eq) & ~mask0 + + # 检测“成比例”: + # 在双方各自非零位置上,Lh/cur_left 与 Rh/cur_right 分别行内常数, + # 且左右两侧比例相同,且比例不为 1(确保“数量均不同”) + # 注意:当某侧不存在任意单位时,认为该侧比例为 1 且恒定 + if need_L_idx.size > 0: + ratios_L = Lh[:, need_L_idx] / np.maximum(cur_left[need_L_idx], 1e-12) + rL_min = ratios_L.min(axis=1) + rL_max = ratios_L.max(axis=1) + uniform_L = np.isclose(rL_min, rL_max, rtol=1e-3, atol=1e-6) + rL = 0.5 * (rL_min + rL_max) + else: + uniform_L = np.ones(self.N_history, dtype=bool) + rL = np.ones(self.N_history, dtype=float) + + if need_R_idx.size > 0: + ratios_R = Rh[:, need_R_idx] / np.maximum(cur_right[need_R_idx], 1e-12) + rR_min = ratios_R.min(axis=1) + rR_max = ratios_R.max(axis=1) + uniform_R = np.isclose(rR_min, rR_max, rtol=1e-3, atol=1e-6) + rR = 0.5 * (rR_min + rR_max) + else: + uniform_R = np.ones(self.N_history, dtype=bool) + rR = np.ones(self.N_history, dtype=float) + + same_ratio = np.isclose(rL, rR, rtol=1e-3, atol=1e-6) + ratio_not_one = ~np.isclose(rL, 1.0, rtol=1e-3, atol=1e-6) # rL==rR 时即可代表两侧都不为1 + proportional = uniform_L & uniform_R & same_ratio & ratio_not_one + + # 2类:同种类,数量均不同且成比例 + mask2 = same_species & (~cntL_eq) & (~cntR_eq) & proportional + # 3类:同种类,数量均不同但不成比例 + mask3 = same_species & (~cntL_eq) & (~cntR_eq) & (~proportional) + + cats[mask0] = 0 + cats[mask1] = 2 + cats[mask2] = 1 + cats[mask3] = 3 + + # 使用 lexsort 按 (-sims, qdiff_other, -match_other, cats) 排序 + order = np.lexsort((-sims, qdiff_other, -match_other, cats)) + good = order[match_other[order] > 0] + backup = order[match_other[order] == 0] + top20 = np.concatenate([good, backup])[:20] + # 移除最终“仅按相似度”的重排,保持 cats 优先级 + # top20 = top20[np.argsort(-sims[top20])] + self.top20_idx = top20 + + # 从前5条中计算左右胜率 + top5 = top20[:5] + labs = np.where(swap[top5], np.where(self.labels[top5]=="L", "R", "L"), self.labels[top5]) + tgtL = need_L_idx[np.argmax(cur_left[need_L_idx])] if need_L_idx.size else None + tgtR = need_R_idx[np.argmax(cur_right[need_R_idx])] if need_R_idx.size else None + + lw = np.sum([lab == ("L" if (Lh[i, tgtL] if tgtL is not None else 0) > 0 else "R") + for i, lab in zip(top5, labs)]) + rw = np.sum([lab == ("L" if (Lh[i, tgtR] if tgtR is not None else 0) > 0 else "R") + for i, lab in zip(top5, labs)]) + self.left_rate = lw / len(top5) if top5.size else 0 + self.right_rate = rw / len(top5) if top5.size else 0 + self.sims = sims + self.swap = swap + self.cur_left = cur_left + self.cur_right = cur_right + return self.top20_idx, self.left_rate, self.right_rate + + def get_terrain_names(self, idx, is_swapped=False): + """获取指定历史对局的地形名称""" + if idx >= len(self.past_left_terrain): + return "无地形" + + # 根据是否镜像选择地形特征 + terrain_features = self.past_right_terrain[idx] if is_swapped else self.past_left_terrain[idx] + + # 获取激活的地形特征索引 + active_indices = np.where(terrain_features > 0)[0] + + if len(active_indices) == 0: + return "无地形" + + # 尝试从FieldRecognizer获取实际的特征列名称 + try: + from field_recognition import FieldRecognizer + field_recognizer = FieldRecognizer() + if field_recognizer.is_ready(): + feature_columns = field_recognizer.get_feature_columns() + # 根据实际特征列名称生成简洁名称 + active_terrains = [] + for i in active_indices: + if i < len(feature_columns): + full_name = feature_columns[i] + # 简化名称映射(与main.py的terrain_display_mapping保持一致) + if "altar_vertical_altar" in full_name: + simple_name = "垂直祭坛" + elif "block_parallel_block" in full_name: + simple_name = "平行方块阻挡" + elif "block_vertical_altar_shape1" in full_name: + simple_name = "垂直祭坛形阻挡1" + elif "block_vertical_altar_shape2" in full_name: + simple_name = "垂直祭坛形阻挡2" + elif "block_vertical_block_shape1" in full_name: + simple_name = "垂直方块阻挡1" + elif "block_vertical_block_shape2" in full_name: + simple_name = "垂直方块阻挡2" + elif "coil_narrow_coil" in full_name: + simple_name = "窄型线圈装置" + elif "coil_wide_coil" in full_name: + simple_name = "宽型线圈装置" + elif "crossbow_top_crossbow" in full_name: + simple_name = "顶部弩炮" + elif "fire_side_crossbow" in full_name: + simple_name = "侧边弩炮" + elif "fire_side_fire" in full_name: + simple_name = "侧边火炮" + elif "fire_top_fire" in full_name: + simple_name = "顶部火炮" + # 保留旧的映射以兼容旧数据 + elif "middle_row_blocks" in full_name: + simple_name = "中路阻挡" + elif "side_fire_cannon_crossbow" in full_name: + simple_name = "侧边弩箭" + elif "side_fire_cannon_fire" in full_name: + simple_name = "侧边火炮" + elif "top_crossbow" in full_name: + simple_name = "顶部弩箭" + elif "top_fire_cannon" in full_name: + simple_name = "顶部火炮" + elif "two_row_blocks" in full_name: + simple_name = "双行阻挡" + else: + # 如果无法识别,使用原名称的简化版本 + simple_name = full_name.replace("_", "") + active_terrains.append(simple_name) + + return "+".join(active_terrains) if active_terrains else "无地形" + except Exception: + pass + + # 备用硬编码映射(如果无法获取FieldRecognizer) + # 与main.py的terrain_display_mapping保持一致 + terrain_names = { + 0: "垂直祭坛", + 1: "平行方块阻挡", + 2: "垂直祭坛形阻挡1", + 3: "垂直祭坛形阻挡2", + 4: "垂直方块阻挡1", + 5: "垂直方块阻挡2", + 6: "窄型线圈装置", + 7: "宽型线圈装置", + 8: "顶部弩炮", + 9: "侧边弩炮", + 10: "侧边火炮", + 11: "顶部火炮" + } + + # 获取所有激活地形的名称 + active_terrains = [] + for i in active_indices: + if i < len(terrain_names): + active_terrains.append(terrain_names[i]) + + # 如果有多个地形,用"+"连接 + return "+".join(active_terrains) if active_terrains else "无地形" diff --git a/gui/similar_history_match_ui.py b/gui/similar_history_match_ui.py new file mode 100644 index 0000000..2b6a02f --- /dev/null +++ b/gui/similar_history_match_ui.py @@ -0,0 +1,302 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QScrollArea, QGraphicsDropShadowEffect, QFrame +from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon, QPainter, QColor +import numpy as np +import logging + +from .similar_history_match import HistoryMatch +# 从父目录找config +import sys +from pathlib import Path +root_dir = Path(__file__).parent.parent +if str(root_dir) not in sys.path: + sys.path.insert(0, str(root_dir)) +from config import MONSTER_COUNT, MONSTER_DATA + +logger = logging.getLogger(__name__) + + +class HistoryMatchUI(QFrame): + def __init__(self, history_match: HistoryMatch): + super().__init__() + self.history_match = history_match + self.init_ui() + + def init_ui(self): + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + + # 创建滚动区域 + self.history_scroll_area = QScrollArea() + self.history_scroll_area.setFixedWidth(540) + self.history_scroll_area.setWidgetResizable(True) + self.history_scroll_area.setStyleSheet( + """ + QScrollBar:horizontal { + background: rgba(0, 0, 0, 0); + width: 12px; /* 宽度 */ + margin: 0px; /* 边距 */ + } + QScrollBar::handle:horizontal { + background: rgba(100, 100, 100, 150); + min-height: 20px; /* 滑块最小高度 */ + border-radius: 8px; /* 圆角 */ + } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + background: none; /* 隐藏箭头按钮 */ + } + QScrollBar:vertical { + background: rgba(0, 0, 0, 0); + width: 12px; /* 宽度 */ + margin: 0px; /* 边距 */ + } + QScrollBar::handle:vertical { + background: rgba(100, 100, 100, 150); + min-height: 20px; /* 滑块最小高度 */ + border-radius: 8px; /* 圆角 */ + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + background: none; /* 隐藏箭头按钮 */ + } + QScrollArea { + background-color: rgba(0, 0, 0, 40); + border-radius: 15px; + border: 5px solid #F5EA2D; + } + QScrollArea > QWidget > QWidget { + background: transparent; + } + QScrollBar:vertical { + background: rgba(50, 50, 50, 100); + width: 12px; + margin: 15px 0 15px 0; + } + QScrollBar::handle:vertical { + background: rgba(100, 100, 100, 150); + min-height: 20px; + border-radius: 6px; + } + """ + ) + + # 创建内容部件 + self.history_widget = QWidget() + self.history_layout = QVBoxLayout(self.history_widget) + self.history_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # 设置滚动区域内容 + self.history_scroll_area.setWidget(self.history_widget) + + # 添加到主布局 + self.main_layout.addWidget(self.history_scroll_area) + + def render_similar_matches(self, left_monsters, right_monsters): + try: + # 获取当前输入 + cur_left = np.zeros(MONSTER_COUNT, dtype=float) + cur_right = np.zeros(MONSTER_COUNT, dtype=float) + for name, entry in left_monsters.items(): + v = entry.text() + if v.isdigit(): + cur_left[int(name) - 1] = float(v) + for name, entry in right_monsters.items(): + v = entry.text() + if v.isdigit(): + cur_right[int(name) - 1] = float(v) + + self.history_match.render_similar_matches(cur_left, cur_right) + sims = self.history_match.sims + top_indices = self.history_match.top20_idx + + # 清空现有内容 + for i in reversed(range(self.history_layout.count())): + self.history_layout.itemAt(i).widget().setParent(None) + + # 添加标题 + title_label = QLabel(f"错题本") + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(0) # 模糊半径(控制发光范围) + shadow.setColor(QColor("#313131")) # 发光颜色 + shadow.setOffset(2) # 偏移量(0表示均匀四周发光) + title_label.setGraphicsEffect(shadow) + + title_label.setStyleSheet( + """ + QWidget { + border-radius: 0px; + font-size: 24px; + font-weight: bold; + color: white; + } + """ + ) + self.history_layout.addWidget(title_label) + + # 渲染每个历史对局 + for idx in top_indices: + self.add_history_match(idx, sims[idx], left_monsters, right_monsters) + + except Exception as e: + logger.error(f"渲染历史对局失败: {str(e)}") + + def add_history_match(self, idx, similarity, left_monsters, right_monsters): + """添加单个历史对局到面板""" + # 获取历史数据 + left = self.history_match.past_left[idx] + right = self.history_match.past_right[idx] + result = self.history_match.labels[idx] + + # 获取当前对局的左右单位 + cur_left = np.zeros(MONSTER_COUNT, dtype=float) + cur_right = np.zeros(MONSTER_COUNT, dtype=float) + for name, entry in left_monsters.items(): + v = entry.text() + if v.isdigit(): + cur_left[int(name) - 1] = float(v) + for name, entry in right_monsters.items(): + v = entry.text() + if v.isdigit(): + cur_right[int(name) - 1] = float(v) + + # 计算当前对局和历史对局的相似度(不镜像和镜像两种情况) + setL_cur = set(np.where(cur_left > 0)[0]) + setR_cur = set(np.where(cur_right > 0)[0]) + setL_past = set(np.where(left > 0)[0]) + setR_past = set(np.where(right > 0)[0]) + + # 判断是否需要镜像历史对局 + should_swap = (len(setL_cur ^ setR_past) + len(setR_cur ^ setL_past)) < ( + len(setL_cur ^ setL_past) + len(setR_cur ^ setR_past) + ) + + # 获取地形名称 + terrain_name = self.history_match.get_terrain_names(idx, should_swap) + + # 创建对局容器 + match_widget = QWidget() + match_widget.setStyleSheet( + """ + QWidget { + background-color: rgba(50, 50, 50, 150); + border-radius: 10px; + padding: 0px; + margin: 5px; + } + """ + ) + match_widget.setFixedSize(500, 170) # 增加高度以容纳地形信息 + match_layout = QVBoxLayout(match_widget) + + # 添加左右阵容 + teams_widget = QWidget() + teams_layout = QHBoxLayout(teams_widget) + + # 根据是否需要镜像决定显示方向 + if should_swap: + left_team = self.create_team_widget("右方", right, result == "R") + right_team = self.create_team_widget("左方", left, result == "L") + else: + left_team = self.create_team_widget("左方", left, result == "L") + right_team = self.create_team_widget("右方", right, result == "R") + + teams_layout.addWidget(left_team) + teams_layout.addWidget(right_team) + match_layout.addWidget(teams_widget) + + # 添加地形信息显示 + terrain_label = QLabel(f"地形: {terrain_name}") + terrain_label.setStyleSheet( + """ + QLabel { + color: #CCCCCC; + font: 10px Microsoft YaHei; + padding: 2px 5px; + background-color: rgba(0, 0, 0, 50); + border-radius: 3px; + margin: 2px; + } + """ + ) + terrain_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + match_layout.addWidget(terrain_label) + + self.history_layout.addWidget(match_widget) + + def create_team_widget(self, side, counts, is_winner): + """创建单个队伍显示部件""" + team_widget = QWidget() + team_widget.setStyleSheet( + f""" + QWidget {{ + background-color: {'rgba(250, 250, 50, 150)' if is_winner else 'rgba(50, 50, 50, 100)'}; + border-radius: 8px; + padding: 0px; + margin: 0px; + }} + """ + ) + + layout = QVBoxLayout(team_widget) + + # 显示区域 + ops_widget = QWidget() + shadow01 = QGraphicsDropShadowEffect() + shadow01.setBlurRadius(5) # 模糊半径(控制发光范围) + shadow01.setColor(QColor(0, 0, 0, 120)) # 发光颜色 + shadow01.setOffset(3) # 偏移量(0表示均匀四周发光) + ops_widget.setGraphicsEffect(shadow01) + + ops_widget.setStyleSheet( + """ + QWidget { + background-color: rgba(0, 0, 0, 0); + border-radius: 0px; + padding: 0px; + margin: 0px; + } + """ + ) + ops_layout = QHBoxLayout(ops_widget) + ops_layout.setSpacing(5) + ops_layout.setContentsMargins(0, 0, 0, 0) + + for i, count in enumerate(counts): + if count > 0: + # 创建干员显示 + op_widget = QWidget() + op_widget.setStyleSheet("background-color: rgba(0, 0, 0, 0); padding: 0px 0;margin: 0px;") + op_layout = QVBoxLayout(op_widget) + op_layout.setContentsMargins(0, 0, 0, 0) + op_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # 干员图片 + img_label = QLabel() + img_label.setFixedSize(60, 60) + img_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + try: + pixmap = QPixmap(f"images/{MONSTER_DATA['原始名称'][i+1]}.png") + if not pixmap.isNull(): + pixmap = pixmap.scaled( + 60, 60, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation + ) + img_label.setPixmap(pixmap) + except: + pass + + # 数量标签 + count_label = QLabel(str(int(count))) + count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + count_label.setStyleSheet( + """ + color: #EDEDED; + font: bold 20px SimHei; + min-width: 20px; + """ + ) + + op_layout.addWidget(img_label, stretch=3) + op_layout.addWidget(count_label, stretch=1) + ops_layout.addWidget(op_widget) + + layout.addWidget(ops_widget) + return team_widget From 8d125e1a9eed26e2e06a925590b1b6b2ae50780d Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:34:36 +0800 Subject: [PATCH 09/14] Update main.py --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index e2bad64..5dff549 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ from maa_adb_connector import MaaAdbConnector, MaaFrameworkDetector from gui.dark_mode_style_fix import DarkModeStyleFix from gui.similar_history_match import HistoryMatch -from gui.simular_history_match_ui import HistoryMatchUI +from gui.similar_history_match_ui import HistoryMatchUI import recognize from recognize import MONSTER_COUNT from specialmonster import SpecialMonsterHandler From 6f1c9b55d2577fd13b96f802f469e9ce2b729ba6 Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:35:01 +0800 Subject: [PATCH 10/14] Delete similar_history_match.py --- similar_history_match.py | 330 --------------------------------------- 1 file changed, 330 deletions(-) delete mode 100644 similar_history_match.py diff --git a/similar_history_match.py b/similar_history_match.py deleted file mode 100644 index ea9f1ff..0000000 --- a/similar_history_match.py +++ /dev/null @@ -1,330 +0,0 @@ -import numpy as np -import pandas as pd -from config import MONSTER_COUNT -from config import FIELD_FEATURE_COUNT - -def cosine_similarity_manual(a, b): - """手动实现余弦相似度,替代 sklearn 以减小打包体积""" - norm_a = np.linalg.norm(a, axis=1, keepdims=True) - norm_b = np.linalg.norm(b, axis=1, keepdims=True) - # 避免除以零 - norm_a[norm_a == 0] = 1e-10 - norm_b[norm_b == 0] = 1e-10 - dot = np.dot(a, b.T) - return dot / (norm_a * norm_b.T) - -class HistoryMatch: - """错题本数据集的读取和处理类""" - - def __init__(self, csv_path="arknights.csv"): - # 初始化时加载历史对局数据 - self.csv_path = csv_path - self.load_history_data() - - def __len__(self): - # 返回历史对局数量 - return self.N_history - - def load_history_data(self): - """读取 CSV 文件,加载历史对局的左右阵容、地形及胜负标签""" - try: - df = pd.read_csv(self.csv_path, header=None, skiprows=1) - - # 新数据格式: [怪物L(77), 场地L(6), 怪物R(77), 场地R(6), Result, ImgPath] - total_features = (MONSTER_COUNT + FIELD_FEATURE_COUNT) * 2 - - if df.shape[1] >= total_features + 1: # 至少包含特征和结果列 - # 提取各部分特征 - left_monster_end = MONSTER_COUNT - left_field_end = MONSTER_COUNT + FIELD_FEATURE_COUNT - right_monster_end = MONSTER_COUNT + FIELD_FEATURE_COUNT + MONSTER_COUNT - right_field_end = MONSTER_COUNT + FIELD_FEATURE_COUNT + MONSTER_COUNT + FIELD_FEATURE_COUNT - - # 分别提取怪物和地形特征 - left_monsters = df.iloc[:, 0:left_monster_end].values.astype(float) - left_terrain = df.iloc[:, left_monster_end:left_field_end].values.astype(float) - right_monsters = df.iloc[:, left_field_end:right_monster_end].values.astype(float) - right_terrain = df.iloc[:, right_monster_end:right_field_end].values.astype(float) - - # 合并怪物特征(只使用怪物部分进行相似度计算) - self.past_left = left_monsters - self.past_right = right_monsters - - # 保存地形特征用于显示 - self.past_left_terrain = left_terrain - self.past_right_terrain = right_terrain - - # 胜负标签 - self.labels = df.iloc[:, total_features].values - else: - # 兼容旧格式:只有怪物特征 - self.past_left = df.iloc[:, 0:MONSTER_COUNT].values.astype(float) - self.past_right = df.iloc[:, MONSTER_COUNT:MONSTER_COUNT*2].values.astype(float) - self.labels = df.iloc[:, MONSTER_COUNT*2].values - - # 地形特征为空 - self.past_left_terrain = np.zeros((len(self.past_left), FIELD_FEATURE_COUNT)) - self.past_right_terrain = np.zeros((len(self.past_right), FIELD_FEATURE_COUNT)) - - except Exception as e: - print(f"加载历史数据失败: {e}") - # 加载失败时,初始化为空数组 - self.past_left = np.zeros((0, MONSTER_COUNT), float) - self.past_right = np.zeros((0, MONSTER_COUNT), float) - self.past_left_terrain = np.zeros((0, FIELD_FEATURE_COUNT), float) - self.past_right_terrain = np.zeros((0, FIELD_FEATURE_COUNT), float) - self.labels = np.array([], dtype=str) - - # 构造历史对局特征: 左右数量之和与差的绝对值拼接(只使用怪物特征) - self.feat_past = np.hstack([ - self.past_left + self.past_right, - np.abs(self.past_left - self.past_right) - ]) - # 历史对局总数 - self.N_history = self.past_left.shape[0] - - def render_similar_matches(self, left_counts: np.ndarray, right_counts: np.ndarray): - """返回与当前对局最相似的历史对局索引及胜率统计""" - # 将输入转为浮点型数组 - cur_left = left_counts.astype(float) - cur_right = right_counts.astype(float) - - # 计算当前存在的兵种布尔向量 - pres_L = cur_left > 0 - pres_R = cur_right > 0 - need_L_idx = np.nonzero(pres_L)[0] # 当前左侧有兵的索引 - need_R_idx = np.nonzero(pres_R)[0] # 当前右侧有兵的索引 - - # 构造当前对局特征并计算与所有历史的余弦相似度 - feat_cur = np.hstack([cur_left + cur_right, np.abs(cur_left - cur_right)]).reshape(1, -1) - sims = cosine_similarity_manual(feat_cur, self.feat_past)[0] - - # 历史对局的存在布尔矩阵 - hist_pres_L = self.past_left > 0 # shape (N_history, MONSTER_COUNT) - hist_pres_R = self.past_right > 0 - - # 计算未镜像(missA, cntA)和镜像后(missB, cntB)的缺兵及数量差距 - missA = np.sum(np.logical_xor(pres_L, hist_pres_L), axis=1) + np.sum( - np.logical_xor(pres_R, hist_pres_R), axis=1) - cntA = np.sum(np.abs(self.past_left - cur_left), axis=1) + np.sum( - np.abs(self.past_right - cur_right), axis=1) - - missB = np.sum(np.logical_xor(pres_L, hist_pres_R), axis=1) + np.sum( - np.logical_xor(pres_R, hist_pres_L), axis=1) - cntB = np.sum(np.abs(self.past_right - cur_left), axis=1) + np.sum( - np.abs(self.past_left - cur_right), axis=1) - - # 根据(miss, cnt)比较,决定是否对历史数据做镜像处理 - swap = (missB < missA) | ((missB == missA) & (cntB < cntA)) - - # 生成镜像后的历史左右阵容 - Lh = np.where(swap[:, None], self.past_right, self.past_left) - Rh = np.where(swap[:, None], self.past_left, self.past_right) - - # 修复bug:3B对阵3A在csv文件中,但输入3A、3B不返回,输入3B、3A才返回 - # 使用镜像后的阵容构造存在布尔矩阵(后续匹配统计应基于镜像后的阵容) - hist_pres_Lh = Lh > 0 # shape (N_history, MONSTER_COUNT) - hist_pres_Rh = Rh > 0 - - # 判断在需求索引上是否完全匹配 - full_L = np.all(Lh[:, need_L_idx] == cur_left[need_L_idx], axis=1) - full_R = np.all(Rh[:, need_R_idx] == cur_right[need_R_idx], axis=1) - - # 计算需求索引处的数量差和 - diff_L = np.sum(np.abs(Lh[:, need_L_idx] - cur_left[need_L_idx]), axis=1) - diff_R = np.sum(np.abs(Rh[:, need_R_idx] - cur_right[need_R_idx]), axis=1) - - # 计算对手兵种在本方需求中的命中数,取最小值作为 match_other - hit_L = np.sum(hist_pres_Rh[:, need_L_idx] & pres_L[need_L_idx], axis=1) - hit_R = np.sum(hist_pres_Lh[:, need_R_idx] & pres_R[need_R_idx], axis=1) - match_other = np.minimum(hit_L, hit_R) - - # 根据命中侧及是否完全匹配,选择对应的 qdiff_other - qdiff_other = np.where( - (hit_R > 0) & (~full_R), diff_R, - np.where((hit_L > 0) & (~full_L), diff_L, 0) - ) - - # 批量计算分类所需的布尔向量 - # 注意:类型(presence)比较也应基于镜像后的阵容 - typeL_eq = np.all(hist_pres_Lh == pres_L, axis=1) - typeR_eq = np.all(hist_pres_Rh == pres_R, axis=1) - cntL_eq = np.all(Lh == cur_left, axis=1) - cntR_eq = np.all(Rh == cur_right, axis=1) - - # 初始化类别为最松散的 5(默认) - cats = np.full(self.N_history, 5, dtype=np.int8) - - # 重新定义分类优先级(数值越小优先级越高): - # 0: 双方种类与数量都完全相同 - # 1: 双方种类相同,数量均不同但成比例(如 1A1B vs 2A2B) - # 2: 双方种类相同,且至少一侧数量相同(但不是双方都相同) - # 3: 双方种类相同,但双方数量均不同(且不成比例) - # 5: 其它(默认) - same_species = typeL_eq & typeR_eq - - mask0 = same_species & cntL_eq & cntR_eq - mask1 = same_species & (cntL_eq | cntR_eq) & ~mask0 - - # 检测“成比例”: - # 在双方各自非零位置上,Lh/cur_left 与 Rh/cur_right 分别行内常数, - # 且左右两侧比例相同,且比例不为 1(确保“数量均不同”) - # 注意:当某侧不存在任意单位时,认为该侧比例为 1 且恒定 - if need_L_idx.size > 0: - ratios_L = Lh[:, need_L_idx] / np.maximum(cur_left[need_L_idx], 1e-12) - rL_min = ratios_L.min(axis=1) - rL_max = ratios_L.max(axis=1) - uniform_L = np.isclose(rL_min, rL_max, rtol=1e-3, atol=1e-6) - rL = 0.5 * (rL_min + rL_max) - else: - uniform_L = np.ones(self.N_history, dtype=bool) - rL = np.ones(self.N_history, dtype=float) - - if need_R_idx.size > 0: - ratios_R = Rh[:, need_R_idx] / np.maximum(cur_right[need_R_idx], 1e-12) - rR_min = ratios_R.min(axis=1) - rR_max = ratios_R.max(axis=1) - uniform_R = np.isclose(rR_min, rR_max, rtol=1e-3, atol=1e-6) - rR = 0.5 * (rR_min + rR_max) - else: - uniform_R = np.ones(self.N_history, dtype=bool) - rR = np.ones(self.N_history, dtype=float) - - same_ratio = np.isclose(rL, rR, rtol=1e-3, atol=1e-6) - ratio_not_one = ~np.isclose(rL, 1.0, rtol=1e-3, atol=1e-6) # rL==rR 时即可代表两侧都不为1 - proportional = uniform_L & uniform_R & same_ratio & ratio_not_one - - # 2类:同种类,数量均不同且成比例 - mask2 = same_species & (~cntL_eq) & (~cntR_eq) & proportional - # 3类:同种类,数量均不同但不成比例 - mask3 = same_species & (~cntL_eq) & (~cntR_eq) & (~proportional) - - cats[mask0] = 0 - cats[mask1] = 2 - cats[mask2] = 1 - cats[mask3] = 3 - - # 使用 lexsort 按 (-sims, qdiff_other, -match_other, cats) 排序 - order = np.lexsort((-sims, qdiff_other, -match_other, cats)) - good = order[match_other[order] > 0] - backup = order[match_other[order] == 0] - top20 = np.concatenate([good, backup])[:20] - # 移除最终“仅按相似度”的重排,保持 cats 优先级 - # top20 = top20[np.argsort(-sims[top20])] - self.top20_idx = top20 - - # 从前5条中计算左右胜率 - top5 = top20[:5] - labs = np.where(swap[top5], np.where(self.labels[top5]=="L", "R", "L"), self.labels[top5]) - tgtL = need_L_idx[np.argmax(cur_left[need_L_idx])] if need_L_idx.size else None - tgtR = need_R_idx[np.argmax(cur_right[need_R_idx])] if need_R_idx.size else None - - lw = np.sum([lab == ("L" if (Lh[i, tgtL] if tgtL is not None else 0) > 0 else "R") - for i, lab in zip(top5, labs)]) - rw = np.sum([lab == ("L" if (Lh[i, tgtR] if tgtR is not None else 0) > 0 else "R") - for i, lab in zip(top5, labs)]) - self.left_rate = lw / len(top5) if top5.size else 0 - self.right_rate = rw / len(top5) if top5.size else 0 - self.sims = sims - self.swap = swap - self.cur_left = cur_left - self.cur_right = cur_right - return self.top20_idx, self.left_rate, self.right_rate - - def get_terrain_names(self, idx, is_swapped=False): - """获取指定历史对局的地形名称""" - if idx >= len(self.past_left_terrain): - return "无地形" - - # 根据是否镜像选择地形特征 - terrain_features = self.past_right_terrain[idx] if is_swapped else self.past_left_terrain[idx] - - # 获取激活的地形特征索引 - active_indices = np.where(terrain_features > 0)[0] - - if len(active_indices) == 0: - return "无地形" - - # 尝试从FieldRecognizer获取实际的特征列名称 - try: - from field_recognition import FieldRecognizer - field_recognizer = FieldRecognizer() - if field_recognizer.is_ready(): - feature_columns = field_recognizer.get_feature_columns() - # 根据实际特征列名称生成简洁名称 - active_terrains = [] - for i in active_indices: - if i < len(feature_columns): - full_name = feature_columns[i] - # 简化名称映射(与main.py的terrain_display_mapping保持一致) - if "altar_vertical_altar" in full_name: - simple_name = "垂直祭坛" - elif "block_parallel_block" in full_name: - simple_name = "平行方块阻挡" - elif "block_vertical_altar_shape1" in full_name: - simple_name = "垂直祭坛形阻挡1" - elif "block_vertical_altar_shape2" in full_name: - simple_name = "垂直祭坛形阻挡2" - elif "block_vertical_block_shape1" in full_name: - simple_name = "垂直方块阻挡1" - elif "block_vertical_block_shape2" in full_name: - simple_name = "垂直方块阻挡2" - elif "coil_narrow_coil" in full_name: - simple_name = "窄型线圈装置" - elif "coil_wide_coil" in full_name: - simple_name = "宽型线圈装置" - elif "crossbow_top_crossbow" in full_name: - simple_name = "顶部弩炮" - elif "fire_side_crossbow" in full_name: - simple_name = "侧边弩炮" - elif "fire_side_fire" in full_name: - simple_name = "侧边火炮" - elif "fire_top_fire" in full_name: - simple_name = "顶部火炮" - # 保留旧的映射以兼容旧数据 - elif "middle_row_blocks" in full_name: - simple_name = "中路阻挡" - elif "side_fire_cannon_crossbow" in full_name: - simple_name = "侧边弩箭" - elif "side_fire_cannon_fire" in full_name: - simple_name = "侧边火炮" - elif "top_crossbow" in full_name: - simple_name = "顶部弩箭" - elif "top_fire_cannon" in full_name: - simple_name = "顶部火炮" - elif "two_row_blocks" in full_name: - simple_name = "双行阻挡" - else: - # 如果无法识别,使用原名称的简化版本 - simple_name = full_name.replace("_", "") - active_terrains.append(simple_name) - - return "+".join(active_terrains) if active_terrains else "无地形" - except Exception: - pass - - # 备用硬编码映射(如果无法获取FieldRecognizer) - # 与main.py的terrain_display_mapping保持一致 - terrain_names = { - 0: "垂直祭坛", - 1: "平行方块阻挡", - 2: "垂直祭坛形阻挡1", - 3: "垂直祭坛形阻挡2", - 4: "垂直方块阻挡1", - 5: "垂直方块阻挡2", - 6: "窄型线圈装置", - 7: "宽型线圈装置", - 8: "顶部弩炮", - 9: "侧边弩炮", - 10: "侧边火炮", - 11: "顶部火炮" - } - - # 获取所有激活地形的名称 - active_terrains = [] - for i in active_indices: - if i < len(terrain_names): - active_terrains.append(terrain_names[i]) - - # 如果有多个地形,用"+"连接 - return "+".join(active_terrains) if active_terrains else "无地形" From 3d247100f08a47d141a07c21aaeeb8ad0c0c332e Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:35:08 +0800 Subject: [PATCH 11/14] Delete simular_history_match_ui.py --- simular_history_match_ui.py | 296 ------------------------------------ 1 file changed, 296 deletions(-) delete mode 100644 simular_history_match_ui.py diff --git a/simular_history_match_ui.py b/simular_history_match_ui.py deleted file mode 100644 index 6ddf18c..0000000 --- a/simular_history_match_ui.py +++ /dev/null @@ -1,296 +0,0 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QScrollArea, QGraphicsDropShadowEffect, QFrame -from PyQt6.QtGui import QPixmap, QImage, QFont, QIcon, QPainter, QColor -import numpy as np -import logging - -from similar_history_match import HistoryMatch -from config import MONSTER_COUNT, MONSTER_DATA - -logger = logging.getLogger(__name__) - - -class HistoryMatchUI(QFrame): - def __init__(self, history_match: HistoryMatch): - super().__init__() - self.history_match = history_match - self.init_ui() - - def init_ui(self): - self.main_layout = QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 0, 0, 0) - - # 创建滚动区域 - self.history_scroll_area = QScrollArea() - self.history_scroll_area.setFixedWidth(540) - self.history_scroll_area.setWidgetResizable(True) - self.history_scroll_area.setStyleSheet( - """ - QScrollBar:horizontal { - background: rgba(0, 0, 0, 0); - width: 12px; /* 宽度 */ - margin: 0px; /* 边距 */ - } - QScrollBar::handle:horizontal { - background: rgba(100, 100, 100, 150); - min-height: 20px; /* 滑块最小高度 */ - border-radius: 8px; /* 圆角 */ - } - QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - background: none; /* 隐藏箭头按钮 */ - } - QScrollBar:vertical { - background: rgba(0, 0, 0, 0); - width: 12px; /* 宽度 */ - margin: 0px; /* 边距 */ - } - QScrollBar::handle:vertical { - background: rgba(100, 100, 100, 150); - min-height: 20px; /* 滑块最小高度 */ - border-radius: 8px; /* 圆角 */ - } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - background: none; /* 隐藏箭头按钮 */ - } - QScrollArea { - background-color: rgba(0, 0, 0, 40); - border-radius: 15px; - border: 5px solid #F5EA2D; - } - QScrollArea > QWidget > QWidget { - background: transparent; - } - QScrollBar:vertical { - background: rgba(50, 50, 50, 100); - width: 12px; - margin: 15px 0 15px 0; - } - QScrollBar::handle:vertical { - background: rgba(100, 100, 100, 150); - min-height: 20px; - border-radius: 6px; - } - """ - ) - - # 创建内容部件 - self.history_widget = QWidget() - self.history_layout = QVBoxLayout(self.history_widget) - self.history_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - - # 设置滚动区域内容 - self.history_scroll_area.setWidget(self.history_widget) - - # 添加到主布局 - self.main_layout.addWidget(self.history_scroll_area) - - def render_similar_matches(self, left_monsters, right_monsters): - try: - # 获取当前输入 - cur_left = np.zeros(MONSTER_COUNT, dtype=float) - cur_right = np.zeros(MONSTER_COUNT, dtype=float) - for name, entry in left_monsters.items(): - v = entry.text() - if v.isdigit(): - cur_left[int(name) - 1] = float(v) - for name, entry in right_monsters.items(): - v = entry.text() - if v.isdigit(): - cur_right[int(name) - 1] = float(v) - - self.history_match.render_similar_matches(cur_left, cur_right) - sims = self.history_match.sims - top_indices = self.history_match.top20_idx - - # 清空现有内容 - for i in reversed(range(self.history_layout.count())): - self.history_layout.itemAt(i).widget().setParent(None) - - # 添加标题 - title_label = QLabel(f"错题本") - shadow = QGraphicsDropShadowEffect() - shadow.setBlurRadius(0) # 模糊半径(控制发光范围) - shadow.setColor(QColor("#313131")) # 发光颜色 - shadow.setOffset(2) # 偏移量(0表示均匀四周发光) - title_label.setGraphicsEffect(shadow) - - title_label.setStyleSheet( - """ - QWidget { - border-radius: 0px; - font-size: 24px; - font-weight: bold; - color: white; - } - """ - ) - self.history_layout.addWidget(title_label) - - # 渲染每个历史对局 - for idx in top_indices: - self.add_history_match(idx, sims[idx], left_monsters, right_monsters) - - except Exception as e: - logger.error(f"渲染历史对局失败: {str(e)}") - - def add_history_match(self, idx, similarity, left_monsters, right_monsters): - """添加单个历史对局到面板""" - # 获取历史数据 - left = self.history_match.past_left[idx] - right = self.history_match.past_right[idx] - result = self.history_match.labels[idx] - - # 获取当前对局的左右单位 - cur_left = np.zeros(MONSTER_COUNT, dtype=float) - cur_right = np.zeros(MONSTER_COUNT, dtype=float) - for name, entry in left_monsters.items(): - v = entry.text() - if v.isdigit(): - cur_left[int(name) - 1] = float(v) - for name, entry in right_monsters.items(): - v = entry.text() - if v.isdigit(): - cur_right[int(name) - 1] = float(v) - - # 计算当前对局和历史对局的相似度(不镜像和镜像两种情况) - setL_cur = set(np.where(cur_left > 0)[0]) - setR_cur = set(np.where(cur_right > 0)[0]) - setL_past = set(np.where(left > 0)[0]) - setR_past = set(np.where(right > 0)[0]) - - # 判断是否需要镜像历史对局 - should_swap = (len(setL_cur ^ setR_past) + len(setR_cur ^ setL_past)) < ( - len(setL_cur ^ setL_past) + len(setR_cur ^ setR_past) - ) - - # 获取地形名称 - terrain_name = self.history_match.get_terrain_names(idx, should_swap) - - # 创建对局容器 - match_widget = QWidget() - match_widget.setStyleSheet( - """ - QWidget { - background-color: rgba(50, 50, 50, 150); - border-radius: 10px; - padding: 0px; - margin: 5px; - } - """ - ) - match_widget.setFixedSize(500, 170) # 增加高度以容纳地形信息 - match_layout = QVBoxLayout(match_widget) - - # 添加左右阵容 - teams_widget = QWidget() - teams_layout = QHBoxLayout(teams_widget) - - # 根据是否需要镜像决定显示方向 - if should_swap: - left_team = self.create_team_widget("右方", right, result == "R") - right_team = self.create_team_widget("左方", left, result == "L") - else: - left_team = self.create_team_widget("左方", left, result == "L") - right_team = self.create_team_widget("右方", right, result == "R") - - teams_layout.addWidget(left_team) - teams_layout.addWidget(right_team) - match_layout.addWidget(teams_widget) - - # 添加地形信息显示 - terrain_label = QLabel(f"地形: {terrain_name}") - terrain_label.setStyleSheet( - """ - QLabel { - color: #CCCCCC; - font: 10px Microsoft YaHei; - padding: 2px 5px; - background-color: rgba(0, 0, 0, 50); - border-radius: 3px; - margin: 2px; - } - """ - ) - terrain_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - match_layout.addWidget(terrain_label) - - self.history_layout.addWidget(match_widget) - - def create_team_widget(self, side, counts, is_winner): - """创建单个队伍显示部件""" - team_widget = QWidget() - team_widget.setStyleSheet( - f""" - QWidget {{ - background-color: {'rgba(250, 250, 50, 150)' if is_winner else 'rgba(50, 50, 50, 100)'}; - border-radius: 8px; - padding: 0px; - margin: 0px; - }} - """ - ) - - layout = QVBoxLayout(team_widget) - - # 显示区域 - ops_widget = QWidget() - shadow01 = QGraphicsDropShadowEffect() - shadow01.setBlurRadius(5) # 模糊半径(控制发光范围) - shadow01.setColor(QColor(0, 0, 0, 120)) # 发光颜色 - shadow01.setOffset(3) # 偏移量(0表示均匀四周发光) - ops_widget.setGraphicsEffect(shadow01) - - ops_widget.setStyleSheet( - """ - QWidget { - background-color: rgba(0, 0, 0, 0); - border-radius: 0px; - padding: 0px; - margin: 0px; - } - """ - ) - ops_layout = QHBoxLayout(ops_widget) - ops_layout.setSpacing(5) - ops_layout.setContentsMargins(0, 0, 0, 0) - - for i, count in enumerate(counts): - if count > 0: - # 创建干员显示 - op_widget = QWidget() - op_widget.setStyleSheet("background-color: rgba(0, 0, 0, 0); padding: 0px 0;margin: 0px;") - op_layout = QVBoxLayout(op_widget) - op_layout.setContentsMargins(0, 0, 0, 0) - op_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # 干员图片 - img_label = QLabel() - img_label.setFixedSize(60, 60) - img_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - try: - pixmap = QPixmap(f"images/{MONSTER_DATA['原始名称'][i+1]}.png") - if not pixmap.isNull(): - pixmap = pixmap.scaled( - 60, 60, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation - ) - img_label.setPixmap(pixmap) - except: - pass - - # 数量标签 - count_label = QLabel(str(int(count))) - count_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - count_label.setStyleSheet( - """ - color: #EDEDED; - font: bold 20px SimHei; - min-width: 20px; - """ - ) - - op_layout.addWidget(img_label, stretch=3) - op_layout.addWidget(count_label, stretch=1) - ops_layout.addWidget(op_widget) - - layout.addWidget(ops_widget) - return team_widget From 5f77676c175df17d42ed329f3a03a17baec14221 Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:35:17 +0800 Subject: [PATCH 12/14] Delete predict_onnx.py --- predict_onnx.py | 182 ------------------------------------------------ 1 file changed, 182 deletions(-) delete mode 100644 predict_onnx.py diff --git a/predict_onnx.py b/predict_onnx.py deleted file mode 100644 index 31a527f..0000000 --- a/predict_onnx.py +++ /dev/null @@ -1,182 +0,0 @@ -from pathlib import Path - -import onnxruntime as ort -import os -import numpy as np -import logging - -from config import MONSTER_COUNT -from config import FIELD_FEATURE_COUNT - -logger = logging.getLogger(__name__) - -class CannotModel: - def __init__(self, model_path="models"): - self.model_path = self._resolve_model_path(model_path) - self.is_model_loaded = False - try: - self.load_model() # 初始化时加载模型 - self.is_model_loaded = True - except Exception as e: - logger.error(f"模型加载失败: {e}") - self.session = None - - def _resolve_model_path(self, path): - """ - Resolves the model path. If a directory is given, finds the latest model file. - If a file is given, returns it directly. - """ - if Path(path).is_dir(): - logger.info(f"Searching for the latest model in directory: {path}") - model_dir = Path(path) - - # 尝试寻找默认的 best_model_full.onnx - default_path = model_dir / "best_model_full.onnx" - if default_path.exists(): - logger.info(f"Found default model: {default_path}") - return str(default_path) - - logger.error(f"No valid ONNX model files found in {path}") - return str(default_path) - - elif Path(path).is_file(): - logger.info(f"Using specified model file: {path}") - return path - else: - logger.error(f"Provided model path is invalid: {path}") - return "" - - def load_model(self): - """加载 ONNX 模型""" - try: - if not os.path.exists(self.model_path): - raise FileNotFoundError(f"未找到 ONNX 模型文件 {self.model_path}") - - # 配置会话选项 - sess_options = ort.SessionOptions() - sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL - - # 创建会话(默认使用 CPU) - self.session = ort.InferenceSession( - self.model_path, - sess_options, - providers=['CPUExecutionProvider'] - ) - - except Exception as e: - raise RuntimeError(f"ONNX 模型加载失败: {str(e)}") - - def get_prediction(self, left_counts: np.ndarray, right_counts: np.ndarray): - if self.session is None: - raise RuntimeError("模型未正确初始化") - - def validate_input(arr): - """验证并转换输入数据""" - # 转换为 int64 类型 - arr = arr.astype(np.int64) - - # 添加批次维度(如果输入是单样本) - if arr.ndim == 1: - arr = arr[np.newaxis, :] # shape: (1, 56) - return arr - - # 处理符号和绝对值,以匹配导出的模型输入 - left_signs_arr = np.sign(left_counts).astype(np.int64) - left_counts_arr = np.abs(left_counts).astype(np.int64) - right_signs_arr = np.sign(right_counts).astype(np.int64) - right_counts_arr = np.abs(right_counts).astype(np.int64) - - inputs = { - "left_signs": validate_input(left_signs_arr), - "left_counts": validate_input(left_counts_arr), - "right_signs": validate_input(right_signs_arr), - "right_counts": validate_input(right_counts_arr) - } - - # 执行推理 - try: - output = self.session.run( - output_names=["output"], - input_feed=inputs - ) - # output 是一个列表,output[0] 是形状为 (batch_size, 1) 的数组 - prediction = output[0].flatten()[0] - except Exception as e: - raise RuntimeError(f"推理失败: {str(e)}") - - # 后处理(与原逻辑一致) - if np.isnan(prediction) or np.isinf(prediction): - logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") - prediction = 0.5 - - prediction = np.clip(prediction, 0.0, 1.0) - return float(prediction) - - def get_prediction_with_terrain(self, full_features: np.ndarray): - """使用包含地形特征的完整特征向量进行预测(ONNX版本)""" - if self.session is None: - raise RuntimeError("模型未正确初始化") - - # 检查特征向量长度 - expected_length = MONSTER_COUNT * 2 + FIELD_FEATURE_COUNT * 2 # 77L + 6L + 77R + 6R = 166 - if len(full_features) != expected_length: - logger.warning(f"特征向量长度不匹配: 期望{expected_length}, 实际{len(full_features)}") - # 如果长度不匹配,回退到原始方法 - left_counts = full_features[:MONSTER_COUNT] - right_counts = full_features[MONSTER_COUNT:MONSTER_COUNT*2] - return self.get_prediction(left_counts, right_counts) - - # 提取各个部分 - left_monsters = full_features[:MONSTER_COUNT] # 1L-77L - left_terrain = full_features[MONSTER_COUNT:MONSTER_COUNT+FIELD_FEATURE_COUNT] # 78L-83L - right_monsters = full_features[MONSTER_COUNT+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT] # 1R-77R - right_terrain = full_features[MONSTER_COUNT*2+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT*2] # 78R-83R - - # 处理左侧特征 - left_monster_signs = np.sign(left_monsters).astype(np.int64) - left_terrain_signs = np.ones_like(left_terrain).astype(np.int64) - left_signs = np.concatenate([left_monster_signs, left_terrain_signs]) - - left_monster_counts = np.abs(left_monsters).astype(np.int64) - left_counts = np.concatenate([left_monster_counts, left_terrain.astype(np.int64)]) - - # 处理右侧特征 - right_monster_signs = np.sign(right_monsters).astype(np.int64) - right_terrain_signs = np.ones_like(right_terrain).astype(np.int64) - right_signs = np.concatenate([right_monster_signs, right_terrain_signs]) - - right_monster_counts = np.abs(right_monsters).astype(np.int64) - right_counts = np.concatenate([right_monster_counts, right_terrain.astype(np.int64)]) - - def validate_input(arr): - """验证并转换输入数据""" - arr = arr.astype(np.int64) - if arr.ndim == 1: - arr = arr[np.newaxis, :] - return arr - - inputs = { - "left_signs": validate_input(left_signs), - "left_counts": validate_input(left_counts), - "right_signs": validate_input(right_signs), - "right_counts": validate_input(right_counts) - } - - # 执行推理 - try: - output = self.session.run( - output_names=["output"], - input_feed=inputs - ) - # output[0] 是形状为 (batch_size, 1) 的数组 - prediction = output[0].flatten()[0] - except Exception as e: - raise RuntimeError(f"推理失败: {str(e)}") - - # 后处理(与原逻辑一致) - if np.isnan(prediction) or np.isinf(prediction): - logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") - prediction = 0.5 - - prediction = np.clip(prediction, 0.0, 1.0) - return float(prediction) \ No newline at end of file From ca3ebe5b8927101a5830d33c49be1a11a0dd4113 Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:35:24 +0800 Subject: [PATCH 13/14] Delete predict.py --- predict.py | 257 ----------------------------------------------------- 1 file changed, 257 deletions(-) delete mode 100644 predict.py diff --git a/predict.py b/predict.py deleted file mode 100644 index 59edb44..0000000 --- a/predict.py +++ /dev/null @@ -1,257 +0,0 @@ -import re -from datetime import datetime -from functools import cache -from pathlib import Path - -import numpy as np -import torch -import logging - -from config import MONSTER_COUNT -from config import FIELD_FEATURE_COUNT - -logger = logging.getLogger(__name__) - -def get_device(prefer_gpu=True): - """ - prefer_gpu (bool): 是否优先尝试使用GPU - """ - if prefer_gpu: - if torch.cuda.is_available(): - logger.info("Use torch with cuda") - return torch.device("cuda") - elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): - logger.info("Use torch with mps") - return torch.device("mps") # Apple Silicon GPU - elif hasattr(torch, "xpu") and torch.xpu.is_available(): # Intel GPU - logger.info("Use torch with xpu") - return torch.device("xpu") - logger.info("Use torch with cpu") - return torch.device("cpu") - -class CannotModel: - def __init__(self, model_path="models"): - self.device = get_device() - self.is_model_loaded = False - self.model_path = self._resolve_model_path(model_path) - try: - self.load_model() # 初始化时加载模型 - self.is_model_loaded = True - except Exception as e: - logger.error(f"模型加载失败: {e}") - self.model = None - - def _resolve_model_path(self, path): - """ - Resolves the model path. If a directory is given, finds the latest model file. - If a file is given, returns it directly. - """ - if Path(path).is_dir(): - logger.info(f"Searching for the latest model in directory: {path}") - model_dir = Path(path) - models = [f for f in model_dir.iterdir() if f.suffix == ".pth" and f.is_file()] - if not models: - logger.error(f"No model files (.pth) found in {path}") - - priority = {"loss": 0, "acc": 1, "full": 2} - valid_models = [] - - pattern = re.compile( - r"best_model_(acc|loss|full)_data\d+_acc\d+\.\d+_loss\d+\.\d+_(\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2})\.pth$" - ) - - for model_file_path in models: - match = pattern.match(model_file_path.name) - if match: - model_type = match.group(1) - timestamp_str = match.group(2) # Group 2 captures the timestamp - try: - model_time = datetime.strptime( - timestamp_str, "%Y_%m_%d_%H_%M_%S" - ) - valid_models.append((model_time, priority.get(model_type, 3), model_file_path)) - except ValueError: - continue # Ignore files with malformed timestamps - - if valid_models: - # Sort by time DESC, then priority ASC (loss=0, acc=1, full=2) - valid_models.sort(key=lambda x: (x[0], -x[1]), reverse=True) - latest_model_path = valid_models[0][2] - logger.info(f"Found latest model: {latest_model_path}") - return str(latest_model_path) - else: - logger.error( - f"No models with the expected name format found in {path}" - ) - - elif Path(path).is_file(): - logger.info(f"Using specified model file: {path}") - return path - else: - logger.error(f"Provided model path is invalid: {path}") - return "" - - def load_model(self): - """初始化时加载模型""" - try: - if not Path(self.model_path).exists(): - raise FileNotFoundError( - rf"未找到训练好的模型文件 {self.model_path},请先训练模型" - ) - - try: - model = torch.load( - self.model_path, - map_location=self.device, - weights_only=False, - ) - except TypeError: # 如果旧版本 PyTorch 不认识 weights_only - model = torch.load( - self.model_path, map_location=self.device - ) - model.eval() - self.model = model.to(self.device) - - except Exception as e: - error_msg = f"模型加载失败: {str(e)}" - if "missing keys" in str(e): - error_msg += "\n可能是模型结构不匹配,请重新训练模型" - raise e # 无法继续运行,退出程序 - - def export_onnx(self,outputpath, monster_count=MONSTER_COUNT): - # 确保模型在 CPU 上(避免设备不一致) - self.model = self.model.cpu() - self.model.eval() - - # 生成虚拟输入(与模型同设备) - device = next(self.model.parameters()).device - dummy_left_counts = torch.randint(0, 10, (1, monster_count), dtype=torch.int16, device=device) - dummy_right_counts = torch.randint(0, 10, (1, monster_count), dtype=torch.int16, device=device) - - # 获取符号和绝对值张量(确保在相同设备) - left_signs = torch.sign(dummy_left_counts.to(torch.int64)).to(device) - left_counts = torch.abs(dummy_left_counts.to(torch.int64)).to(device) - right_signs = torch.sign(dummy_right_counts.to(torch.int64)).to(device) - right_counts = torch.abs(dummy_right_counts.to(torch.int64)).to(device) - - # 导出参数 - input_names = ["left_signs", "left_counts", "right_signs", "right_counts"] - dynamic_axes = {name: {0: 'batch_size'} for name in input_names} - dynamic_axes["output"] = {0: 'batch_size'} - - # 导出 ONNX - torch.onnx.export( - self.model, - (left_signs, left_counts, right_signs, right_counts), - outputpath, - input_names=input_names, - output_names=["output"], - dynamic_axes=dynamic_axes, - opset_version=20, - verbose=True # 开启详细输出便于调试 - ) - - def get_prediction(self, left_counts: np.typing.ArrayLike, right_counts: np.typing.ArrayLike): - if self.model is None: - raise RuntimeError("模型未正确初始化") - - # 转换为张量并处理符号和绝对值 - left_signs = ( - torch.sign(torch.tensor(left_counts, dtype=torch.int16)) - .unsqueeze(0) - .to(self.device) - ) - left_counts = ( - torch.abs(torch.tensor(left_counts, dtype=torch.int16)) - .unsqueeze(0) - .to(self.device) - ) - right_signs = ( - torch.sign(torch.tensor(right_counts, dtype=torch.int16)) - .unsqueeze(0) - .to(self.device) - ) - right_counts = ( - torch.abs(torch.tensor(right_counts, dtype=torch.int16)) - .unsqueeze(0) - .to(self.device) - ) - - # 预测流程 - with torch.no_grad(): - # 使用修改后的模型前向传播流程 - prediction = self.model( - left_signs, left_counts, right_signs, right_counts - ).item() - - # 确保预测值在有效范围内 - if np.isnan(prediction) or np.isinf(prediction): - logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") - prediction = 0.5 - - # 检查预测结果是否在[0,1]范围内 - if prediction < 0 or prediction > 1: - prediction = max(0, min(1, prediction)) - - return prediction - - def get_prediction_with_terrain(self, full_features: np.typing.ArrayLike): - """使用包含地形特征的完整特征向量进行预测""" - if self.model is None: - raise RuntimeError("模型未正确初始化") - - # 检查特征向量长度 - expected_length = MONSTER_COUNT * 2 + FIELD_FEATURE_COUNT * 2 # 77L + 6L + 77R + 6R = 166 - if len(full_features) != expected_length: - logger.warning(f"特征向量长度不匹配: 期望{expected_length}, 实际{len(full_features)}") - # 如果长度不匹配,回退到原始方法 - left_counts = full_features[:MONSTER_COUNT] - right_counts = full_features[MONSTER_COUNT:MONSTER_COUNT*2] - return self.get_prediction(left_counts, right_counts) - - # 提取各个部分 - left_monsters = full_features[:MONSTER_COUNT] # 1L-77L - left_terrain = full_features[MONSTER_COUNT:MONSTER_COUNT+FIELD_FEATURE_COUNT] # 78L-83L - right_monsters = full_features[MONSTER_COUNT+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT] # 1R-77R - right_terrain = full_features[MONSTER_COUNT*2+FIELD_FEATURE_COUNT:MONSTER_COUNT*2+FIELD_FEATURE_COUNT*2] # 78R-83R - - # 合并怪物特征和地形特征(按照训练时的格式) - left_counts = np.concatenate([left_monsters, left_terrain]) - right_counts = np.concatenate([right_monsters, right_terrain]) - - # 转换为张量并处理符号和绝对值 - # 对于怪物特征,使用符号和绝对值 - # 对于地形特征,不需要符号处理(地形特征本身就是0/1值) - left_monster_signs = torch.sign(torch.tensor(left_monsters, dtype=torch.int16)) - left_terrain_signs = torch.ones_like(torch.tensor(left_terrain, dtype=torch.int16)) # 地形特征符号为1 - left_signs = torch.cat([left_monster_signs, left_terrain_signs]).unsqueeze(0).to(self.device) - - left_monster_counts = torch.abs(torch.tensor(left_monsters, dtype=torch.int16)) - left_terrain_counts = torch.tensor(left_terrain, dtype=torch.int16) # 地形特征直接使用原值 - left_counts_tensor = torch.cat([left_monster_counts, left_terrain_counts]).unsqueeze(0).to(self.device) - - right_monster_signs = torch.sign(torch.tensor(right_monsters, dtype=torch.int16)) - right_terrain_signs = torch.ones_like(torch.tensor(right_terrain, dtype=torch.int16)) # 地形特征符号为1 - right_signs = torch.cat([right_monster_signs, right_terrain_signs]).unsqueeze(0).to(self.device) - - right_monster_counts = torch.abs(torch.tensor(right_monsters, dtype=torch.int16)) - right_terrain_counts = torch.tensor(right_terrain, dtype=torch.int16) # 地形特征直接使用原值 - right_counts_tensor = torch.cat([right_monster_counts, right_terrain_counts]).unsqueeze(0).to(self.device) - - # 预测流程 - with torch.no_grad(): - # 使用修改后的模型前向传播流程,现在包含地形特征 - prediction = self.model( - left_signs, left_counts_tensor, right_signs, right_counts_tensor - ).item() - - # 确保预测值在有效范围内 - if np.isnan(prediction) or np.isinf(prediction): - logger.warning("警告: 预测结果包含NaN或Inf,返回默认值0.5") - prediction = 0.5 - - # 检查预测结果是否在[0,1]范围内 - if prediction < 0 or prediction > 1: - prediction = max(0, min(1, prediction)) - - return prediction From 48a4c33b4bfb1f81311f2a6f303d80e72ceb00fa Mon Sep 17 00:00:00 2001 From: liemark <115876720+liemark@users.noreply.github.com> Date: Mon, 4 May 2026 04:37:20 +0800 Subject: [PATCH 14/14] Delete dark_mode_style_fix.py --- dark_mode_style_fix.py | 123 ----------------------------------------- 1 file changed, 123 deletions(-) delete mode 100644 dark_mode_style_fix.py diff --git a/dark_mode_style_fix.py b/dark_mode_style_fix.py deleted file mode 100644 index 7565859..0000000 --- a/dark_mode_style_fix.py +++ /dev/null @@ -1,123 +0,0 @@ -class DarkModeStyleFix: - DARK_TEXT_COLOR = "#313131" - COMBO_POPUP_BACKGROUND = "#FFFFFF" - COMBO_POPUP_BORDER = "#CCCCCC" - COMBO_SELECTION_BACKGROUND = "#F5EA2D" - COMBO_SELECTION_COLOR = "#313131" - PLACEHOLDER_COLOR = "#888888" - - @staticmethod - def get_global_qss() -> str: - return f""" - QDialog {{ - background-color: #FFFFFF; - }} - QMessageBox {{ - background-color: #FFFFFF; - }} - QMessageBox QLabel {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - background-color: transparent; - }} - QMessageBox QPushButton {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - background-color: #F2F2F2; - border: 1px solid #999999; - border-radius: 4px; - padding: 4px 10px; - min-width: 60px; - }} - QMessageBox QPushButton:hover {{ - background-color: #E6E6E6; - border: 1px solid #666666; - }} - QLabel {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QGroupBox {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QGroupBox::title {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QCheckBox {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QComboBox {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QComboBox QAbstractItemView {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - background-color: {DarkModeStyleFix.COMBO_POPUP_BACKGROUND}; - selection-background-color: {DarkModeStyleFix.COMBO_SELECTION_BACKGROUND}; - selection-color: {DarkModeStyleFix.COMBO_SELECTION_COLOR}; - border: 1px solid {DarkModeStyleFix.COMBO_POPUP_BORDER}; - outline: none; - }} - QComboBox QLineEdit {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QLineEdit {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QPushButton {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - border: 1px solid #999999; - border-radius: 4px; - padding: 4px 8px; - }} - QPushButton:hover {{ - border: 1px solid #666666; - }} - QPushButton:pressed {{ - border: 1px solid #333333; - }} - QScrollArea {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - """ - - @staticmethod - def get_combo_box_qss() -> str: - return f""" - QComboBox {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QComboBox QAbstractItemView {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - background-color: {DarkModeStyleFix.COMBO_POPUP_BACKGROUND}; - selection-background-color: {DarkModeStyleFix.COMBO_SELECTION_BACKGROUND}; - selection-color: {DarkModeStyleFix.COMBO_SELECTION_COLOR}; - border: 1px solid {DarkModeStyleFix.COMBO_POPUP_BORDER}; - outline: none; - }} - QComboBox QLineEdit {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - """ - - @staticmethod - def get_line_edit_qss() -> str: - return f""" - QLineEdit {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - """ - - @staticmethod - def get_group_box_title_qss() -> str: - return f""" - QGroupBox {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - QGroupBox::title {{ - color: {DarkModeStyleFix.DARK_TEXT_COLOR}; - }} - """ - - @staticmethod - def apply(app) -> None: - if app is None: - raise ValueError("QApplication instance cannot be None") - global_qss = DarkModeStyleFix.get_global_qss() - app.setStyleSheet(global_qss)