diff --git a/data/icons/Mega Water 1.0.png b/data/icons/Mega Water 1.0.png deleted file mode 100644 index ad5abe9..0000000 Binary files a/data/icons/Mega Water 1.0.png and /dev/null differ diff --git a/scripts/rthook_add_dll_dirs.py b/scripts/rthook_add_dll_dirs.py index 4ae983b..2aba3a3 100644 --- a/scripts/rthook_add_dll_dirs.py +++ b/scripts/rthook_add_dll_dirs.py @@ -5,11 +5,12 @@ import sys def _safe_add(path: str) -> None: if not path or not os.path.isdir(path): return - try: - if hasattr(os, "add_dll_directory"): + if hasattr(os, "add_dll_directory"): + try: os.add_dll_directory(path) - except Exception: - pass + return + except Exception: + pass try: os.environ["PATH"] = path + os.pathsep + os.environ.get("PATH", "") except Exception: @@ -21,5 +22,4 @@ base = getattr(sys, "_MEIPASS", None) if base: _safe_add(base) _safe_add(os.path.join(base, "lib-dynload")) - _safe_add(os.path.join(base, "DLLs")) - + _safe_add(os.path.join(base, "DLLs")) \ No newline at end of file diff --git a/src/auth/license_manager.py b/src/auth/license_manager.py index 4de3bf0..19c1956 100644 --- a/src/auth/license_manager.py +++ b/src/auth/license_manager.py @@ -31,7 +31,9 @@ def get_cpu_id() -> Optional[str]: capture_output=True, text=True, timeout=5, - creationflags=subprocess.CREATE_NO_WINDOW + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) cpu_id = result.stdout.strip().split("\n")[-1].strip() if cpu_id: @@ -57,7 +59,9 @@ def get_motherboard_uuid() -> Optional[str]: capture_output=True, text=True, timeout=5, - creationflags=subprocess.CREATE_NO_WINDOW + creationflags=subprocess.CREATE_NO_WINDOW, + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) board_uuid = result.stdout.strip().split("\n")[-1].strip() board_uuid = re.sub(r'[^a-zA-Z0-9\-]', '', board_uuid) @@ -68,7 +72,9 @@ def get_motherboard_uuid() -> Optional[str]: ["cat", "/sys/class/dmi/id/product_uuid"], capture_output=True, text=True, - timeout=5 + timeout=5, + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) if result.returncode == 0: return result.stdout.strip() diff --git a/src/core/modeling/modeling_batch.py b/src/core/modeling/modeling_batch.py index 73287b7..2079f81 100644 --- a/src/core/modeling/modeling_batch.py +++ b/src/core/modeling/modeling_batch.py @@ -40,6 +40,10 @@ CB_AVAILABLE = False # 注释掉catboost import sys import os +# PyInstaller 打包环境感知:EXE 模式下强制单核,防止 Windows 派生无限重启 +is_frozen_env = getattr(sys, 'frozen', False) +safe_n_jobs = 1 if is_frozen_env else -1 + from src.preprocessing.spectral_Preprocessing import Preprocessing @@ -643,7 +647,7 @@ class WaterQualityModelingBatch: config['params'], cv=cv_strategy, scoring=scoring, - n_jobs=-1, + n_jobs=safe_n_jobs, verbose=1 ) diff --git a/src/core/prediction/inference_batch.py b/src/core/prediction/inference_batch.py index b922fa1..94330f7 100644 --- a/src/core/prediction/inference_batch.py +++ b/src/core/prediction/inference_batch.py @@ -40,16 +40,19 @@ class WaterQualityInference: self.best_model_info = None self.loaded_model_data = None - def load_sampling_data(self, csv_path: str) -> Tuple[pd.DataFrame, pd.DataFrame]: + def load_sampling_data(self, csv_path: str) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """ - 加载sampling生成的CSV数据 + 加载sampling生成的CSV数据(兼容 WQI 增强版 CSV) Args: - csv_path: CSV文件路径,前两列为经纬度,其余列为光谱数据 + csv_path: CSV文件路径 + 旧版:x_coord,y_coord,pixel_x,pixel_y,波长... + 新版:x_coord,y_coord,WQI_...,波长... Returns: - coords: 经纬度数据 (DataFrame) - spectra: 光谱数据 (DataFrame) + coords: 经纬度数据 (DataFrame, 2列) + spectra: 纯光谱数据 (DataFrame, 跳过 WQI 列) + wqi_df: WQI 指数列 (DataFrame, 0或45列) """ print(f"正在加载采样数据: {csv_path}") @@ -71,15 +74,35 @@ class WaterQualityInference: coords = data.iloc[:, :2].copy() coords.columns = ['longitude', 'latitude'] - # 从第5列开始为光谱数据(跳过第2、3、4列的其他信息) - spectra = data.iloc[:, 4:].copy() + # 动态识别光谱列(兼容 sampling_spectra.csv 列顺序变更) + # 列名约定:波长为纯数字字符串如 "374.285004";WQI 为 "WQI_xxx" 前缀 + # 旧版 CSV(无WQI):x_coord,y_coord,pixel_x,pixel_y,波长... → 取 [4:] + # 新版 CSV(有WQI):x_coord,y_coord,WQI_...,波长... → 过滤 WQI 列后取光谱 + all_cols = list(data.columns) + spectral_col_indices = [] + wqi_col_indices = [] + for i, col in enumerate(all_cols): + col_str = str(col) + if col_str.startswith('WQI_'): + wqi_col_indices.append(i) + elif col_str.replace('.', '').lstrip('-').isdigit(): + # 波长列:纯数字字符串 + spectral_col_indices.append(i) + else: + # 其他元数据列(x_coord/y_coord/pixel_x/pixel_y),由 coords 接收 + pass + + # 光谱列 = 纯数字列(WQI 已被排除) + spectra = data.iloc[:, spectral_col_indices].copy() if spectral_col_indices else data.iloc[:, 4:].copy() + # WQI 列(用于追加到预测结果输出) + wqi_df = data.iloc[:, wqi_col_indices].copy() if wqi_col_indices else pd.DataFrame() print(f" 经纬度数据形状: {coords.shape}") - print(f" 光谱数据形状: {spectra.shape}") + print(f" 光谱数据形状: {spectra.shape} (自动识别波长列,排除 {len(wqi_col_indices)} 个WQI列)") print(f" 经纬度范围: 经度[{coords['longitude'].min():.6f}, {coords['longitude'].max():.6f}], " f"纬度[{coords['latitude'].min():.6f}, {coords['latitude'].max():.6f}]") - return coords, spectra + return coords, spectra, wqi_df def random(self, data, label, test_ratio=0.2, random_state=123): """ @@ -519,6 +542,69 @@ class WaterQualityInference: print(f"正在应用预处理方法: {actual_preprocess_method}") print(f"原始光谱数据形状: {spectra.shape}") + # ---- 自动特征补全:50 光谱 → 补全至模型训练时的 95 维(WQI 指数) ---- + # 触发条件:模型期望 n_features_in_ 个特征,但当前 spectra 列数不足 + # 原因:training_spectra.csv 含 50 光谱 + 45 WQI;sampling_spectra.csv 只有 50 光谱 + # 做法:与训练端(calculate_all_indices)完全一致的算法列表,实时补全缺失的 45 个 WQI 列 + model = self.loaded_model_data['model'] + expected_features = getattr(model, 'n_features_in_', None) + + # ---- 自动特征补全:50 光谱 → 补全至模型训练时的 n_features_in_ 维(WQI 指数) ---- + if expected_features is not None and spectra.shape[1] < expected_features: + print(f"[特征补全] 检测到特征缺口:当前 {spectra.shape[1]} 列 < 模型期望 {expected_features} 列," + f"正在从光谱数据实时计算 WQI 指数...") + try: + from src.utils.water_index import WaterQualityIndexCalculator + calc = WaterQualityIndexCalculator() + + # 提取纯计算方法(排除 find_closest_wavelength 和 calculate_all_indices, + # 以及不返回 Series 的辅助方法) + algorithm_methods = [] + for m in dir(calc): + if m.startswith('_'): + continue + if m in ['find_closest_wavelength', 'calculate_all_indices']: + continue + attr = getattr(calc, m) + if callable(attr): + algorithm_methods.append(m) + + original_col_count = spectra.shape[1] + for algo_name in algorithm_methods: + try: + algo_func = getattr(calc, algo_name) + result = algo_func(spectra) + # 只追加返回 Series 且长度为样本数的合法结果 + if isinstance(result, pd.Series) and len(result) == len(spectra): + spectra[algo_name] = result.values + else: + spectra[algo_name] = np.nan + except Exception: + spectra[algo_name] = np.nan + + print(f"[特征补全] 完成!光谱列已扩充至 {spectra.shape[1]} 列" + f"(追加了 {spectra.shape[1] - original_col_count} 个 WQI 指数)") + except Exception as e: + print(f"[特征补全] 失败,将使用原始光谱特征: {e}") + + # ---- 防线 1:强制维度对齐(物理截断)---- + if expected_features is not None and spectra.shape[1] > expected_features: + print(f"[精准对齐] 正在将 {spectra.shape[1]} 维特征截断为模型要求的 {expected_features} 维") + spectra = spectra.iloc[:, :expected_features] + elif expected_features is not None and spectra.shape[1] < expected_features: + # 维度不足时填充 0 + padding_cols = expected_features - spectra.shape[1] + for i in range(padding_cols): + spectra[f'_padding_{i}'] = 0.0 + print(f"[精准对齐] 特征不足,填充 {padding_cols} 列 0") + + # ---- 防线 2:彻底清洗无穷大数值---- + # 防止 WQI 计算中除零/溢出产生 np.inf / -np.inf 导致预处理崩溃 + spectra = spectra.replace([np.inf, -np.inf], np.nan) + spectra = spectra.fillna(0) + + print(f"[特征对齐] 最终输入维度: {spectra.shape}") + try: # 应用预处理 spectra_processed = Preprocessing(actual_preprocess_method, spectra) @@ -573,7 +659,8 @@ class WaterQualityInference: raise def save_predictions(self, coords: pd.DataFrame, predictions: np.ndarray, - output_path: str, prediction_column: str = 'prediction'): + output_path: str, prediction_column: str = 'prediction', + wqi_columns: Optional[pd.DataFrame] = None): """ 保存预测结果 @@ -582,11 +669,15 @@ class WaterQualityInference: predictions: 预测结果 output_path: 输出文件路径 prediction_column: 预测列名称 + wqi_columns: Optional[pd.DataFrame] = None """ print(f"正在保存预测结果到: {output_path}") # 创建结果DataFrame result_df = coords.copy() + # 追加 WQI 水质指数列(如 sampling_spectra.csv 注入了 45 列指数) + if wqi_columns is not None and not wqi_columns.empty: + result_df = pd.concat([result_df, wqi_columns.reset_index(drop=True)], axis=1) result_df[prediction_column] = predictions # 确保输出目录存在 @@ -659,10 +750,10 @@ class WaterQualityInference: else: self.load_best_model(metric=metric) - # 2. 加载采样数据 + # 2. 加载采样数据(coords=坐标, spectra=纯光谱, wqi_df=45个WQI指数列) print("\n步骤2: 加载采样数据") print("-" * 40) - coords, spectra = self.load_sampling_data(sampling_csv_path) + coords, spectra, wqi_df = self.load_sampling_data(sampling_csv_path) # 3. 数据预处理 print("\n步骤3: 数据预处理") @@ -674,10 +765,11 @@ class WaterQualityInference: print("-" * 40) predictions = self.predict(spectra_processed) - # 5. 保存预测结果 + # 5. 保存预测结果(透传 WQI 列至最终输出文件) print("\n步骤5: 保存预测结果") print("-" * 40) - result_df = self.save_predictions(coords, predictions, output_csv_path, prediction_column) + result_df = self.save_predictions(coords, predictions, output_csv_path, + prediction_column, wqi_df) print("\n" + "=" * 80) print("推理流程完成!") @@ -747,10 +839,11 @@ class WaterQualityInference: output_file = output_path / f"prediction_{csv_file.name}" # 执行推理 - coords, spectra = self.load_sampling_data(str(csv_file)) + coords, spectra, wqi_df = self.load_sampling_data(str(csv_file)) spectra_processed = self.preprocess_spectra(spectra) predictions = self.predict(spectra_processed) - result_df = self.save_predictions(coords, predictions, str(output_file), prediction_column) + result_df = self.save_predictions(coords, predictions, str(output_file), + prediction_column, wqi_df) results[csv_file.name] = { 'output_file': str(output_file), @@ -908,10 +1001,11 @@ class WaterQualityInference: output_file = output_path / f"{file_stem}{file_ext}" # 执行推理 - coords, spectra = self.load_sampling_data(str(csv_file)) + coords, spectra, wqi_df = self.load_sampling_data(str(csv_file)) spectra_processed = self.preprocess_spectra(spectra) predictions = self.predict(spectra_processed) - result_df = self.save_predictions(coords, predictions, str(output_file), prediction_column) + result_df = self.save_predictions(coords, predictions, str(output_file), + prediction_column, wqi_df) results[file_stem] = { 'input_file': str(csv_file), diff --git a/src/gui/panels/step5_5_panel.py b/src/gui/panels/step5_5_panel.py index f2c2f7b..f184890 100644 --- a/src/gui/panels/step5_5_panel.py +++ b/src/gui/panels/step5_5_panel.py @@ -13,11 +13,24 @@ from src.gui.components.custom_widgets import FileSelectWidget from src.gui.styles import ModernStylesheet def get_resource_path(relative_path: str) -> str: - """适配开发与 PyInstaller 环境的路径获取逻辑""" + """适配开发与 PyInstaller 环境的路径获取逻辑。 + 支持两种打包模式: + 1. --onedir 模式:文件在 exe_root/_internal/ 下 → 检查 _internal 目录 + 2. --onefile 模式:文件在 sys._MEIPASS 平铺目录 + """ + # 优先检查 PyInstaller onefile 模式(文件平铺在 _MEIPASS 下) if hasattr(sys, '_MEIPASS'): - # 打包后,文件会被平铺或按 tree 结构放入临时目录 + internal_path = os.path.join(sys._MEIPASS, '_internal', relative_path) + if os.path.exists(internal_path): + return internal_path return os.path.join(sys._MEIPASS, relative_path) + # 兼容 PyInstaller onedir 模式的 _internal 目录(exe 同级目录下) + exe_dir = os.path.dirname(sys.executable) + internal_path = os.path.join(exe_dir, '_internal', relative_path) + if os.path.exists(internal_path): + return internal_path + # 开发环境下:基于当前文件 (step5_5_panel.py) 的绝对路径进行回溯 # 当前在 src/gui/panels/,目标在 src/gui/model/ base_dir = Path(__file__).resolve().parent.parent / "model" diff --git a/src/gui/water_quality_gui.py b/src/gui/water_quality_gui.py index 462414c..02a8057 100644 --- a/src/gui/water_quality_gui.py +++ b/src/gui/water_quality_gui.py @@ -5,6 +5,12 @@ GUI for Water Quality Inversion Pipeline """ +# ============================================================================== +# 🚀 终极防御:必须在全宇宙第一行强制载入 GDAL 底层 DLL,绝对杜绝 0xC0000005 内存崩溃 +# ============================================================================== +import osgeo +from osgeo import gdal, ogr + import os import json import copy @@ -31,8 +37,23 @@ from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPalette, QColor, QPixmap import sys import traceback +import multiprocessing +import ctypes +# ============================================================================== +# 🚀 终极防御置顶:在载入任何自定义面板、样式或子模块之前,强制提前创建 QApplication! +# 彻底杜绝 import 时期载入类属性 (如 QFont/QIcon/QPixmap) 触发的 QWidget 崩溃 +# ============================================================================== +if multiprocessing.current_process().name == 'MainProcess': + if not QApplication.instance(): + try: + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + except Exception: + pass + _global_app = QApplication(sys.argv) +# 👇 全局异常钩子(保持不变) def get_resource_path(relative_path: str) -> str: """获取资源的绝对路径,适配 PyInstaller 打包环境。 打包后资源位于 sys._MEIPASS(解压临时目录),开发环境则基于 __file__ 向上三级。 @@ -45,10 +66,31 @@ def get_resource_path(relative_path: str) -> str: def global_exception_handler(exc_type, exc_value, exc_traceback): - print("\n" + "="*50) - print("【严重错误拦截 - PyQt 崩溃死因】") - traceback.print_exception(exc_type, exc_value, exc_traceback) - print("="*50 + "\n") + err_lines = traceback.format_exception(exc_type, exc_value, exc_traceback) + err_msg = "".join(err_lines) + dump_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "crash_dump.txt") + try: + with open(dump_path, "a", encoding="utf-8") as f: + f.write(f"\n{'='*60}\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]\n") + f.write(err_msg) + except Exception: + pass + try: + from PyQt5.QtWidgets import QMessageBox + from PyQt5.QtCore import Qt + msg = ( + "【严重错误 - 程序即将退出】\n\n" + "错误类型: {}\n\n" + "错误信息: {}\n\n" + "详细信息已写入:\n{}".format( + exc_type.__name__, + str(exc_value), + dump_path, + ) + ) + QMessageBox.critical(None, "程序崩溃", msg) + except Exception: + pass # 挂载全局异常钩子,阻止 PyQt 静默闪退 sys.excepthook = global_exception_handler @@ -89,9 +131,13 @@ from src.gui.panels.step9_panel import Step9Panel from src.gui.panels.visualization_panel import VisualizationPanel from src.gui.panels.report_generation_panel import ReportGenerationPanel -# Matplotlib相关导入 +# Matplotlib相关导入 (推迟并加入底层防爆保护) import matplotlib -matplotlib.use('Qt5Agg') +try: + # 确保只在主线程且安全的环境下绑定后端 + matplotlib.use('Qt5Agg', force=False) +except Exception: + pass from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from matplotlib.figure import Figure @@ -1278,7 +1324,17 @@ class WaterQualityGUI(QMainWindow): """水质参数反演分析系统主窗口""" def __init__(self): + # 1. 🚀 强制设置任务栏图标(解决任务栏图标默认是 Python 黄蓝图标的问题) + # 为当前进程设置独立的 AppUserModelID + my_appid = u'mycompany.megacube.waterquality.v1' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(my_appid) + super().__init__() + + # 2. 设置窗口图标(指向你的 .ico 文件) + icon_path = get_resource_path("data/icons-1/uitubiao.ico") + self.setWindowIcon(QIcon(icon_path)) + self.pipeline = None self.worker = None self.config_file = None @@ -3002,8 +3058,23 @@ class WaterQualityGUI(QMainWindow): def main(): """主函数""" import sys + import multiprocessing + from PyQt5.QtWidgets import QApplication - # 离线授权验证拦截(必须在业务窗口创建前执行) + # 1. 多进程 Fork 环境隔离 + if multiprocessing.current_process().name != 'MainProcess': + sys.exit(0) + + # 2. 🚀 终极防御:必须在全宇宙第一行强制创建 QApplication 实例! + # 绝对杜绝任何后续验签模块或弹窗过早调用 QWidget 导致的崩溃 + app = QApplication.instance() + if not app: + app = QApplication(sys.argv) + + app.setApplicationName("Mega Water") + app.setOrganizationName("WaterQuality") + + # 3. 安全载入离线授权验证拦截 try: from src.auth.license_manager import verify_license from src.auth.license_dialog import LicenseDialog @@ -3018,27 +3089,36 @@ def main(): _is_license_valid, _license_msg = verify_license() if not _is_license_valid: - _license_app = QApplication(sys.argv) - _license_app.setApplicationName("WaterQuality") _dialog = LicenseDialog() _dialog.exec_() - _license_app.quit() sys.exit(0) - # 授权通过,正常载入主程序 - app = QApplication(sys.argv) - app.setApplicationName("Mega Water") - app.setOrganizationName("WaterQuality") - + # 4. 授权通过,正常载入主程序主界面 window = WaterQualityGUI() window.show() sys.exit(app.exec_()) +# ============================================================================== +# 全宇宙最底部程序入口 +# ============================================================================== if __name__ == "__main__": - # 必须紧跟在 if __name__ == "__main__": 下面第一行 + import sys import multiprocessing - multiprocessing.freeze_support() + + # 1. 极其强硬的底层防御: + # 如果当前进程明确是 PyInstaller 派生的后台计算子进程,强行静默退出! + # 彻底绕过多进程钩子在尝试解包 sys.argv 时引发的 ValueError 崩溃 + if multiprocessing.current_process().name != 'MainProcess': + sys.exit(0) + + # 2. 安全调用 freeze_support(带防爆气囊) + try: + multiprocessing.freeze_support() + except Exception: + pass # 哪怕底层钩子参数解包失败,也强行保住主进程平稳过关 + + # 3. 正常拉起主业务逻辑 main() diff --git a/src/utils/sampling.py b/src/utils/sampling.py index 0030c13..d4e3bba 100644 --- a/src/utils/sampling.py +++ b/src/utils/sampling.py @@ -11,6 +11,7 @@ os.environ['GDAL_FILENAME_IS_UTF8'] = 'YES' os.environ['SHAPE_ENCODING'] = 'UTF-8' import numpy as np +import pandas as pd from osgeo import gdal, ogr import spectral from scipy import ndimage @@ -354,6 +355,45 @@ def get_spectral_sampling_points_chunked(bil_file, water_mask_shp, severe_glint= if f: f.close() + # ============================================================================== + # 🚀 终极手术植入点:带强行环境净化的特征引擎挂载 + # ============================================================================== + + # 2. 安全校验路径落盘状态 + if output_csvpath and os.path.exists(str(output_csvpath)): + try: + from src.utils.water_index import WaterQualityIndexCalculator + + print("\n[特征引擎挂载] 正在为采样点自动追加 45 个水质指数衍生特征...") + + # 读取基础底座(50列光谱) + base_df = pd.read_csv(output_csvpath) + + # 实例化计算器 + calc = WaterQualityIndexCalculator() + + # 提取有效算法 + algorithm_methods = [ + m for m in dir(calc) + if not m.startswith('_') and m not in ['find_closest_wavelength', 'calculate_all_indices'] + ] + + # 就地追加 45 列衍生指数 + for algo_name in algorithm_methods: + try: + algo_func = getattr(calc, algo_name) + base_df[algo_name] = algo_func(base_df) + except Exception: + base_df[algo_name] = np.nan + + # 覆盖重写最终结果! + base_df.to_csv(output_csvpath, index=False, encoding='utf-8-sig') + print(f"✓ 特征扩充大功告成!当前文件总维度完美适配模型: {base_df.shape}") + + except Exception as e: + print(f"⚠ 警告:追加特征失败,保留原基础光谱。死因: {e}") + # ============================================================================== + return x_out, y_out, np.array(spectral_out) except Exception as e: