diff --git a/scripts/rthook_add_dll_dirs.py b/scripts/rthook_add_dll_dirs.py index 2aba3a3..069096b 100644 --- a/scripts/rthook_add_dll_dirs.py +++ b/scripts/rthook_add_dll_dirs.py @@ -17,9 +17,24 @@ def _safe_add(path: str) -> None: pass -# PyInstaller onefile 解包目录 +# PyInstaller onedir 解包目录 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")) \ No newline at end of file + _safe_add(os.path.join(base, "DLLs")) + # osgeo 子目录包含 gdal.dll / geos.dll / proj_9.dll 等 GIS 核心库 + osgeo_dir = os.path.join(base, "osgeo") + _safe_add(osgeo_dir) + + # ★ 关键:在 PyQt5 之前预加载 osgeo 的所有 DLL + # 避免 Qt5 DLL 加载后 osgeo DLL 加载时发生符号冲突 + if os.path.isdir(osgeo_dir): + import ctypes as _ct + for _f in os.listdir(osgeo_dir): + if _f.lower().endswith('.dll'): + _dll_path = os.path.join(osgeo_dir, _f) + try: + _ct.CDLL(_dll_path) + except Exception: + pass \ No newline at end of file diff --git a/src/core/utils/preview_generator.py b/src/core/utils/preview_generator.py index 29dd826..eeb93c9 100644 --- a/src/core/utils/preview_generator.py +++ b/src/core/utils/preview_generator.py @@ -215,19 +215,29 @@ def _warp_mask_to_image(mask_path: str, minx, maxx = min(corners_x), max(corners_x) miny, maxy = min(corners_y), max(corners_y) - # ---- 读取掩膜的原始 NoData 并传递到 Warp ---- + # ---- 读取掩膜 ---- mask_ds = gdal.Open(mask_path, gdal.GA_ReadOnly) if mask_ds is None: raise ValueError(f"无法打开掩膜文件: {mask_path}") src_nodata = None - mask_gt = mask_ds.GetGeoTransform() - mask_proj = mask_ds.GetProjection() try: src_nodata = mask_ds.GetRasterBand(1).GetNoDataValue() except Exception: pass + # ── 快速通道:掩膜与底图同分辨率同范围时跳过 Warp ── + mask_gt = mask_ds.GetGeoTransform() + mask_w, mask_h = mask_ds.RasterXSize, mask_ds.RasterYSize + if (mask_w == base_w and mask_h == base_h + and abs(mask_gt[1] - base_gt[1]) < 1e-9 + and abs(mask_gt[5] - base_gt[5]) < 1e-9): + data = mask_ds.GetRasterBand(1).ReadAsArray().astype(np.float32) + mask_ds = None + print("[预览] 掩膜与底图分辨率一致,跳过 Warp 直接读取") + return _normalize_mask(data, nodata_value=src_nodata), nodata_output + mask_proj = mask_ds.GetProjection() + # ---- 构建 Warp 选项 ---- warp_kwargs = { 'format': 'MEM', diff --git a/src/gui/core/panel_registry.py b/src/gui/core/panel_registry.py index 90f97f8..1867590 100644 --- a/src/gui/core/panel_registry.py +++ b/src/gui/core/panel_registry.py @@ -74,16 +74,12 @@ PANEL_REGISTRY = [ }, 'constructor_kwargs': None, }, - - # ═══════════════════════════════════════════════════════════════ - # 模块二 特征工程与数据 - # ═══════════════════════════════════════════════════════════════ { 'step_id': 'step4_sampling', 'class_ref': Step4SamplingPanel, 'title': '采样点布设', 'icon': '4.png', - 'stage': '模块二 特征工程与数据', + 'stage': '模块一 影像预处理', 'display_name': '4. 采样点布设', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; # 第三元素 source_attr = 上游源控件真实属性名(panel_factory 回放端使用) @@ -95,12 +91,15 @@ PANEL_REGISTRY = [ }, 'constructor_kwargs': None, }, + # ═══════════════════════════════════════════════════════════════ + # 模块二 光谱建模与反演 + # ═══════════════════════════════════════════════════════════════ { 'step_id': 'step5_clean', 'class_ref': Step5CleanPanel, 'title': '数据清洗', 'icon': '5.png', - 'stage': '模块二 特征工程与数据', + 'stage': '模块二 光谱建模与反演', 'display_name': '5. 数据清洗', # 业务要求保持输入源独立,不自动抓取 step4_sampling 的输出 'dependencies': None, @@ -111,7 +110,7 @@ PANEL_REGISTRY = [ 'class_ref': Step6FeaturePanel, 'title': '光谱特征', 'icon': '6.png', - 'stage': '模块二 特征工程与数据', + 'stage': '模块二 光谱建模与反演', 'display_name': '6. 光谱特征提取', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名 'dependencies': { @@ -131,7 +130,7 @@ PANEL_REGISTRY = [ 'class_ref': Step7InversionPanel, 'title': '水质光谱指数计算', 'icon': '7.png', - 'stage': '模块二 特征工程与数据', + 'stage': '模块二 光谱建模与反演', 'display_name': '7. 水质指数计算', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; # 第三元素 source_attr = 上游源控件真实属性名 @@ -142,15 +141,12 @@ PANEL_REGISTRY = [ 'constructor_kwargs': None, }, - # ═══════════════════════════════════════════════════════════════ - # 模块三 模型训练与反演 - # ═══════════════════════════════════════════════════════════════ { 'step_id': 'step8_ml_train', 'class_ref': Step8MlTrainPanel, 'title': '机器学习建模', 'icon': '8.png', - 'stage': '模块三 模型训练与反演', + 'stage': '模块二 光谱建模与反演', 'display_name': '8. 机器学习建模', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名 'dependencies': { @@ -160,15 +156,12 @@ PANEL_REGISTRY = [ 'constructor_kwargs': None, }, - # ═══════════════════════════════════════════════════════════════ - # 模块三 模型训练与反演(续)→ 模块四 制图与成果汇编 - # ═══════════════════════════════════════════════════════════════ { 'step_id': 'step9_ml_predict', 'class_ref': Step9MlPredictPanel, 'title': '机器学习预测', 'icon': '9.png', - 'stage': '模块三 模型训练与反演', + 'stage': '模块二 光谱建模与反演', 'display_name': '9. 机器学习预测', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; # 第三元素 source_attr = 上游源控件真实属性名 @@ -178,12 +171,16 @@ PANEL_REGISTRY = [ }, 'constructor_kwargs': None, }, + + # ═══════════════════════════════════════════════════════════════ + # 模块三 物理公式反演 + # ═══════════════════════════════════════════════════════════════ { 'step_id': 'step10_watercolor', 'class_ref': Step10WatercolorPanel, 'title': '水色指数反演', 'icon': '10.png', - 'stage': '模块三 模型训练与反演', + 'stage': '模块三 物理公式反演', 'display_name': '10. 水色指数反演', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名; # 第三元素 source_attr = 上游源控件真实属性名 @@ -194,12 +191,16 @@ PANEL_REGISTRY = [ }, 'constructor_kwargs': None, }, + + # ═══════════════════════════════════════════════════════════════ + # 模块四 制图与成果输出 + # ═══════════════════════════════════════════════════════════════ { 'step_id': 'step11_map', 'class_ref': Step11MapPanel, 'title': '专题图生成', 'icon': '11.png', - 'stage': '模块四 制图与成果汇编', + 'stage': '模块四 制图与成果输出', 'display_name': '11. 分布图生成', # 架构解耦(2026-06-22):dict 键名 = 下游目标控件真实属性名 'dependencies': { @@ -217,7 +218,7 @@ PANEL_REGISTRY = [ 'class_ref': Step12VizPanel, 'title': '可视化', 'icon': '9.png', - 'stage': '模块四 制图与成果汇编', + 'stage': '模块四 制图与成果输出', 'display_name': '12. 可视化展示', 'dependencies': None, 'constructor_kwargs': None, @@ -227,7 +228,7 @@ PANEL_REGISTRY = [ 'class_ref': Step13ReportPanel, 'title': '报告生成', 'icon': '13.png', - 'stage': '模块四 制图与成果汇编', + 'stage': '模块四 制图与成果输出', 'display_name': '13. 分析报告生成', 'dependencies': None, 'constructor_kwargs': {'main_window'}, # 需要注入 main_window=self diff --git a/src/gui/water_quality_gui_v2.py b/src/gui/water_quality_gui_v2.py index e602d62..bb746e9 100644 --- a/src/gui/water_quality_gui_v2.py +++ b/src/gui/water_quality_gui_v2.py @@ -78,9 +78,12 @@ def _find_and_set_gdal_env(): return None def _find_gdal_in_prefix(prefix): - """在指定前缀下查找 Library/share/gdal""" + """在指定前缀下查找 GDAL data 目录(Library/share/gdal 或 osgeo/data/gdal)""" + # Conda 路径:Library/share/gdal + # pip wheel / PyInstaller 打包后:osgeo/data/gdal for sub in ("Library/share/gdal", "share/gdal", - "Library\\share\\gdal", "share\\gdal"): + "Library\\share\\gdal", "share\\gdal", + "osgeo/data/gdal", "osgeo\\data\\gdal"): path = os.path.join(prefix, sub) if os.path.isdir(path): for marker in ("gdalvrt.xsd", "pcs.csv", "gcs.csv"): @@ -110,6 +113,12 @@ def _find_and_set_gdal_env(): except Exception: pass + # 2b) PyInstaller 打包后的解包目录 (_MEIPASS) + # sys.prefix 在 PyInstaller 中已指向 _MEIPASS,但显式加入确保不被遗漏 + _meipass = getattr(sys, '_MEIPASS', None) + if _meipass and _meipass not in prefixes: + prefixes.insert(0, _meipass) + # 3) 从 sys.path 中 site-packages 位置回推所有可能的 conda/env 前缀 try: for sp in sys.path: @@ -236,10 +245,10 @@ def _find_and_set_gdal_env(): # 不设置的话 Fiona 会使用自带的旧版 proj.db (VERSION.MINOR=2), # 导致投影解析错误 → 矢量图层错位 → 水体被错误擦除 → 有效像元为 0 os.environ["PROJ_DATA"] = proj_lib - print(f"[ENV] PROJ_LIB → {proj_lib}") - print(f"[ENV] PROJ_DATA → {proj_lib}") + print(f"[ENV] PROJ_LIB -> {proj_lib}") + print(f"[ENV] PROJ_DATA -> {proj_lib}") else: - print("[ENV] ⚠ 未找到兼容的 proj.db (需要 PROJ v6+),PROJ_LIB/PROJ_DATA 保持系统默认") + print("[ENV] [WARN] 未找到兼容的 proj.db (需要 PROJ v6+),PROJ_LIB/PROJ_DATA 保持系统默认") print(f"[ENV] (已搜索 {len(prefixes)} 个前缀: {[os.path.basename(p) for p in prefixes[:5]]}...)") print("[ENV] 如果 step11 地图渲染报 PROJ 版本错误,") print("[ENV] 请手动设置: set PROJ_LIB=\\Library\\share\\proj") @@ -247,9 +256,9 @@ def _find_and_set_gdal_env(): if gdal_data: os.environ["GDAL_DATA"] = gdal_data - print(f"[ENV] GDAL_DATA → {gdal_data}") + print(f"[ENV] GDAL_DATA -> {gdal_data}") else: - print("[ENV] ⚠ 未找到 GDAL data 目录,GDAL_DATA 保持系统默认") + print("[ENV] [WARN] 未找到 GDAL data 目录,GDAL_DATA 保持系统默认") print(f"[ENV] (已搜索 {len(prefixes)} 个前缀)") # ── 屏蔽 Fiona 内部旧版 PROJ 路径 ── @@ -260,7 +269,7 @@ def _find_and_set_gdal_env(): if "PROJ_DATA" in os.environ: print("[ENV] 已屏蔽 Fiona 内部旧版 PROJ 数据库,全进程统一使用上述 proj.db") if _fiona_v2_path: - print(f"[ENV] ⚠ 检测到 Fiona 自带的旧版 proj.db (v2) 位于:") + print(f"[ENV] [WARN] 检测到 Fiona 自带的旧版 proj.db (v2) 位于:") print(f" {_fiona_v2_path}") print(f" PROJ_DATA 已指向 v6+ 版本,此旧版将被忽略") if proj_lib: diff --git a/src/postprocessing/map.py b/src/postprocessing/map.py index 522c3f3..cf34c2f 100644 --- a/src/postprocessing/map.py +++ b/src/postprocessing/map.py @@ -1082,8 +1082,10 @@ class ContentMapper: return np.array([]) try: - # 使用凸包识别边缘点 - hull = ConvexHull(points) + # 坐标中心化 + QJ joggle,解决 UTM 大坐标(60万+)精度退化问题 + center = points.mean(axis=0) + points_centered = points - center + hull = ConvexHull(points_centered, qhull_options='QJ') edge_indices = hull.vertices print(f"识别到 {len(edge_indices)} 个边缘采样点(共 {len(points)} 个点)") @@ -1274,6 +1276,63 @@ class ContentMapper: return result_gdf else: + # ── 兜底:中心射线法失败时,用水域边界均匀采样作为外扩点 ── + if boundary_gdf is not None: + print("中心射线法未生成外扩点,改用边界采样兜底...") + try: + # 提取水域边界线(所有多边形的 exterior + interiors) + boundary_lines = [] + for geom in boundary_gdf.geometry: + if hasattr(geom, 'exterior'): + boundary_lines.append(geom.exterior) + boundary_lines.extend(geom.interiors) + if boundary_lines: + # 沿边界采样:间距 = max(resolution*5, 水体宽度的1/20) + # 限制总数 ≤500,避免插值爆内存 + _sample_dist = max(resolution * 5, + min(line.length for line in boundary_lines) / 10) + boundary_pts = [] + _max_pts = 500 + _pts_per_line = max(2, _max_pts // max(1, len(boundary_lines))) + for line in boundary_lines: + length = line.length + n = min(_pts_per_line, max(2, int(length / _sample_dist) + 1)) + for i in range(n): + pt = line.interpolate(i * length / max(1, n - 1), normalized=True) + boundary_pts.append((pt.x, pt.y)) + if boundary_pts: + boundary_pts = np.unique(np.array(boundary_pts), axis=0) + # 去重:只保留离原始点 > 2×分辨率的边界点 + if len(points) > 0 and len(boundary_pts) > 0: + from scipy.spatial import cKDTree + tree = cKDTree(points) + dists, _ = tree.query(boundary_pts, distance_upper_bound=resolution*2) + far_mask = ~np.isfinite(dists) | (dists > resolution) + boundary_pts = boundary_pts[far_mask] + boundary_pts = boundary_pts[:_max_pts] # 硬截断 + if len(boundary_pts) > 0: + # 用最近邻采样点的真实值(而非均值),防止 IDW 被均值污染 + _tree = cKDTree(points) + _, _nn_idx = _tree.query(boundary_pts, k=1) + _nn_vals = points_gdf['content'].values[_nn_idx] + new_rows = [] + for i, bp in enumerate(boundary_pts): + new_rows.append({ + 'proj_x': float(bp[0]), 'proj_y': float(bp[1]), + 'longitude': float(bp[0]), 'latitude': float(bp[1]), + 'content': float(_nn_vals[i]), + 'geometry': Point(float(bp[0]), float(bp[1])) + }) + expanded_gdf = gpd.GeoDataFrame(new_rows, crs=points_gdf.crs) + result_gdf = gpd.GeoDataFrame( + pd.concat([points_gdf, expanded_gdf], ignore_index=True), + crs=points_gdf.crs + ) + print(f"边界采样兜底:新增 {len(boundary_pts)} 个边界点") + return result_gdf + except Exception as e: + print(f"边界采样兜底也失败: {e}") + print("未生成外扩点,返回原始点集") return points_gdf.copy() @@ -2566,11 +2625,14 @@ class ContentMapper: array ) - # ====== 新增:矢量掩膜物理擦除(必须在降采样之前,否则 array.shape 与 transform 错位)====== - # 把水域多边形外部的陆地像素物理擦除为 NaN,让下游 mean/std 统计 100% 干净(无陆地假数据污染) - # 同时保留 boundary_gdf_plotted 给末尾描边复用,避免重复读取 + 重复矢量化 + # ====== 矢量掩膜物理擦除 ====== + # 如果 TIFF 本身已有 NaN 掩膜(Step 11 栅格裁剪过),跳过重复擦除以避免黑点 boundary_gdf_plotted: Optional[Any] = None - if boundary_shp_path and os.path.isfile(boundary_shp_path) and transform is not None: + _tif_already_masked = bool(np.any(np.isnan(array))) + if _tif_already_masked: + print(f"[visualize_raster] TIFF 已含 NaN 掩膜,跳过矢量擦除 " + f"(有效像元: {int((~np.isnan(array)).sum())}/{array.size})") + if not _tif_already_masked and boundary_shp_path and os.path.isfile(boundary_shp_path) and transform is not None: try: boundary_ext = Path(boundary_shp_path).suffix.lower() if boundary_ext in ('.shp',): @@ -2642,6 +2704,9 @@ class ContentMapper: transform=transform, invert=True, ) + # 膨胀掩膜 1px:消除相邻多边形边界的 1-2px 缝隙 + from scipy.ndimage import binary_dilation + geom_mask = binary_dilation(geom_mask, iterations=1) array = np.where(geom_mask, array, np.nan) kept = int((~np.isnan(array)).sum()) print(f"[visualize_raster] 矢量掩膜物理擦除完成: 陆地背景 → NaN " @@ -3214,6 +3279,10 @@ class ContentMapper: grid_content = self._perform_interpolation(pts, vals, grid_xx, grid_yy) # ⑥ 复用共享掩膜裁剪 if mask is not None: + # 形态学闭运算填充掩膜小孔洞(NDWI 误判的孤立非水体像素) + from scipy.ndimage import binary_closing, generate_binary_structure + _se = generate_binary_structure(2, 1) # 3×3 十字结构 + mask = binary_closing(mask, structure=_se, iterations=2) grid_content[~mask] = np.nan # 边界内 NaN 填充 nan_mask = np.isnan(grid_content) diff --git a/src/utils/extract_water_area.py b/src/utils/extract_water_area.py index 1c93c5f..499fd2d 100644 --- a/src/utils/extract_water_area.py +++ b/src/utils/extract_water_area.py @@ -147,9 +147,9 @@ def calculate_NDWI(green_bandnumber, nir_bandnumber, filename): im_proj = dataset.GetProjection() # 地图投影信息 tmp = dataset.GetRasterBand(green_bandnumber + 1) # 波段计数从1开始 - band_green = tmp.ReadAsArray().astype(np.int16) + band_green = tmp.ReadAsArray().astype(np.float32) tmp = dataset.GetRasterBand(nir_bandnumber + 1) # 波段计数从1开始 - band_nir = tmp.ReadAsArray().astype(np.int16) + band_nir = tmp.ReadAsArray().astype(np.float32) ndwi = (band_green - band_nir) / (band_green + band_nir) diff --git a/water_quality_app.spec b/water_quality_app.spec index 91df30a..544c80a 100644 --- a/water_quality_app.spec +++ b/water_quality_app.spec @@ -60,6 +60,16 @@ def get_data_files(): if os.path.exists(rthook_script): datas.append((rthook_script, "scripts")) + # 1-6. osgeo Python 模块(__init__.py, gdal.py, ogr.py 等) + # ★ 关键:PyInstaller 会把 .py 文件打入 PYZ (嵌入 EXE),但 .pyd 文件 + # 作为 binaries 被放到 _internal/osgeo/ 物理目录下。当物理 osgeo/ + # 目录存在但没有 __init__.py 时,Python 无法将其识别为 package, + # 导致 "import osgeo" 失败 → "GDAL 未安装"。 + # 解决方案:将 osgeo 的所有 .py 文件显式复制到物理 osgeo/ 目录。 + if os.path.isdir(CONDA_OSGEO_DLLS): + for py_file in glob.glob(os.path.join(CONDA_OSGEO_DLLS, "*.py")): + datas.append((py_file, "osgeo")) + return datas datas = get_data_files() @@ -90,13 +100,26 @@ def get_binaries(): if os.path.exists(dll_path): binaries.append((dll_path, ".")) - # 2-2. 搜集底层 GDAL/GEOS/PROJ 核心框架与二进制扩展 - if os.path.isdir(CONDA_OSGEO_DLLS): - for dll_name in ["gdal.dll", "geos.dll", "geos_c.dll", "proj_9.dll"]: - dll_path = os.path.join(CONDA_OSGEO_DLLS, dll_name) + # 2-1b. Python C 扩展的运行时依赖 DLL(_ctypes → ffi.dll, _lzma → liblzma.dll 等) + # 这些 DLL 位于 Conda 环境的 Library/bin,PyInstaller 分析时无法自动发现 + CONDA_LIBRARY_BIN = os.path.join(CONDA_ENV_DLLS, "Library", "bin") + if os.path.isdir(CONDA_LIBRARY_BIN): + for dll_name in [ + "ffi.dll", # _ctypes.pyd 依赖 + "liblzma.dll", # _lzma.pyd 依赖 + "libbz2.dll", # _bz2.pyd 依赖 + "libexpat.dll", # pyexpat.pyd 依赖 + "sqlite3.dll", # _sqlite3.pyd 依赖 + ]: + dll_path = os.path.join(CONDA_LIBRARY_BIN, dll_name) if os.path.exists(dll_path): binaries.append((dll_path, ".")) + # 2-2. 搜集底层 GDAL/GEOS/PROJ 核心框架与二进制扩展 + # ★ 注意:gdal.dll / geos.dll / geos_c.dll / proj_9.dll 不再显式添加, + # PyInstaller 会自动将它们收集到 osgeo/ 目录(随 .pyd 依赖解析)。 + # 显式添加到根目录会导致 DLL 双副本冲突 → segfault。 + if os.path.isdir(CONDA_OSGEO_DLLS): # 映射 Python 3.12 对应的底层二进制接口 (C-Extension) for pyd_name in [ "_gdal.cp312-win_amd64.pyd", @@ -185,6 +208,10 @@ a.binaries = [ if not x[0].lower().startswith(('msvcp140', 'vcruntime140')) ] +# ★ 注意:.libs 目录中的 PROJ/GEOS/GDAL DLL 副本无需删除。 +# rthook_add_dll_dirs.py 运行时钩子会在 PyQt5 之前预加载 osgeo/ 的所有 DLL, +# 避免了 DLL 加载顺序导致的 segfault。各包的 .libs 副本可以安全共存。 + pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # 生成物理启动核心 @@ -198,7 +225,7 @@ exe = EXE( bootloader_ignore_signals=False, strip=False, upx=False, # 禁用 UPX 暴力压缩,保护二进制 C++ 区段完整性 - console=False, # ⚠️ 维持开启控制台,拒绝绝对静默崩溃 + console=False, # GUI 应用,不显示控制台 disable_windowed_traceback=False, argv_emulation=False, target_arch=None,