fix: 多项稳定性修复与打包优化

- PyInstaller: runtime hook 预加载 osgeo DLL 避免 Qt5 符号冲突;
  spec 纳入 osgeo .py 文件 + 运行时 DLL 依赖 (ffi/lzma/bz2/expat/sqlite3);
  移除 DLL 双副本防止 segfault
- GDAL 环境: 新增 _MEIPASS/osgeo/data/gdal 路径搜索,改善打包后 GDAL_DATA 检测
- 预览生成器: 掩膜与底图同分辨率时跳过 Warp 加速读取
- 专题图: ConvexHull 坐标中心化修复 UTM 大坐标精度退化;
  边界采样兜底防止外扩点为空;
  TIFF 已含 NaN 掩膜时跳过矢量擦除;
  形态学闭运算填充掩膜小孔洞
- NDWI: int16→float32 防止减法溢出
- 面板注册表: 步骤模块分组重构 (模块一/二/三/四重划分)
This commit is contained in:
duxin
2026-07-26 20:35:50 +08:00
parent 0bd54d5fdd
commit b609a64811
7 changed files with 177 additions and 46 deletions

View File

@ -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"))
_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

View File

@ -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',

View File

@ -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-22dict 键名 = 下游目标控件真实属性名;
# 第三元素 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-22dict 键名 = 下游目标控件真实属性名
'dependencies': {
@ -131,7 +130,7 @@ PANEL_REGISTRY = [
'class_ref': Step7InversionPanel,
'title': '水质光谱指数计算',
'icon': '7.png',
'stage': '模块二 特征工程与数据',
'stage': '模块二 光谱建模与反演',
'display_name': '7. 水质指数计算',
# 架构解耦2026-06-22dict 键名 = 下游目标控件真实属性名;
# 第三元素 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-22dict 键名 = 下游目标控件真实属性名
'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-22dict 键名 = 下游目标控件真实属性名;
# 第三元素 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-22dict 键名 = 下游目标控件真实属性名;
# 第三元素 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-22dict 键名 = 下游目标控件真实属性名
'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

View File

@ -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=<conda环境>\\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:

View File

@ -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)

View File

@ -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)

View File

@ -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/binPyInstaller 分析时无法自动发现
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,