Appearance
GridVarEditor 重构笔记:把 PyQt4 时代的网格编辑器搬到 PyQt5 + Cartopy
上一篇里,cesmGUITools 还停留在 Python 2.7 + PyQt4 + Basemap 的老环境上——为了跑起 KMTEditor.py,得专门装一套 2013 年前后的依赖版本,交互方式也是"固定窗口 + 键盘翻页"。这次做的事情是把它重新实现成 GridVarEditor.py:运行在 Python 3 + PyQt5 + Cartopy 之上,交互改成鼠标驱动的地图查看器风格,能同时编辑 KMT 有效层数、水深/地形 elevation、区域掩码 REGION_MASK 等任意与网格坐标形状一致的二维字段。
这篇文档记录的是重写过程中的架构设计,以及几个排查起来不那么直观的坑——多数是"看起来应该能用,实际会崩"的那种。
一、总体架构
text
GridVarEditor/
├── GridVarEditor.py # 主程序:GUI + 交互 + 数据模型
├── gridio.py # 与 NetCDF/POP 网格相关的无状态工具函数
└── pyproject.toml, uv.lock # uv 管理的依赖声明与锁定版本设计上把"读写 NetCDF、发现网格坐标、计算纵横比、保存变更记录"这类与 GUI 无关的逻辑放进 gridio.py,GridVarEditor.py 只负责数据模型(DataContainer)和界面/交互(GridVarEditor 主窗口)。这样划分是为了让 gridio.py 里的函数可以脱离 PyQt 单独测试,也方便以后被其他脚本复用——整个 GridVarEditor/ 文件夹甚至可以完整拷贝到别处独立使用,不依赖仓库中的其他代码。
gridio.py:无状态的网格工具函数
| 函数 | 作用 |
|---|---|
discover_coordinates(ncfile) | 按候选名单(TLAT/ULAT、TLONG/ULONG/ULON)找到网格的 2D 经纬度坐标变量 |
discover_editable_variables(ncfile, grid_shape) | 找出文件中所有与网格形状一致的 2D 变量,作为"可编辑变量"候选列表 |
compute_physical_aspect(...) | 计算网格的南北/东西物理比例(优先用 HTN/HTE 等格点尺寸变量,否则用经纬度大圆距离估算),用于让地图按真实比例显示 |
load_level_bottom_depths(fname) | 从 gridinfo 文件的 z_w/dz 读出各层底部深度(米),用于把 KMT 层数索引换算成水深 |
clone_netcdf(fname, ofile, clobber) | 把整个 NetCDF 文件原样复制一份(维度、变量、属性、数据),作为保存修改的基础 |
append_change_log(...) | 在克隆出的文件里写入 original_<var>(编辑前的原始数据)和 changes_<var>((i, j, new_value) 修改记录),延续旧版 KMTEditor 的存档约定 |
这些函数都是纯函数、无 GUI 依赖,测试时直接用 netCDF4.Dataset 构造输入调用即可,不需要启动 Qt。
GridVarEditor.py:三个部分
DataContainer:某一个(文件, 变量)组合的数据模型——持有完整二维数组、经纬度坐标、是否为整型/是否需要"陆地掩膜"、撤销/重做栈、保存目标文件名等状态。不依赖 Qt,可独立实例化和单元测试。GridVarEditor(QMainWindow):主窗口,负责画布渲染、鼠标/键盘事件处理、侧栏各控件、菜单、多文件管理。main():命令行入口,解析参数后创建窗口。
二、核心数据模型:DataContainer
网格与变量发现
初始化时用 gridio.discover_coordinates 找到经纬度坐标,再校验目标变量的 shape 与坐标一致;同时尝试读取 HTN/HTE(或 HUW/HUS)计算显示纵横比。数值类型(整型/浮点)直接取自 NetCDF 变量的 dtype,决定了后续输入校验、平均值取整、显示格式等行为。
"陆地掩膜"启发式
判断一个变量是否是"KMT 式的层数索引"(从而需要把 0 值当作陆地掩膜掉、且可以换算水深)用的是一个简单的名字启发式:
python
def is_kmt_like(varname):
name = varname.lower()
return "kmt" in name or "kmu" in name对这类变量:
displayArray()返回np.ma.masked_equal(data, 0),配合cmap.set_bad(LAND_COLOR)让陆地格显示为统一的深灰色;- 如果能找到层深表(
level_depths,来自同文件或--levels指定的文件),可以在光标信息面板里实时显示该格对应的实际水深。
撤销/重做:按"组"而不是逐格记录
python
self.undo_stack = [] # list[list[Edit]],一组 = 一次单格编辑或一次连续拖拽涂抹
self.redo_stack = []
self._group = None # 正在进行中的一组(画笔拖拽时打开)- 单格编辑(在侧栏输入框回车提交):
beginGroup()/setValue()/endGroup()包住一次调用,形成一个只含一条Edit的组。 - 画笔连续涂抹:鼠标按下时
beginGroup(),拖拽过程中每经过一个新格子调用一次setValue(),鼠标松开时endGroup(),整次拖拽合并成一组。 undo()/redo()按组整体回滚/重放,reversed(group)保证组内如果同一格被改了多次也能正确还原成组前的状态。
这样撤销一次画笔涂抹是"整体撤销",符合常见图像编辑软件的直觉,而不是逐格撤销几十次。
多文件/多变量缓存
主窗口维护:
python
self.containers = {} # (fname, varname) -> DataContainer切换文件或变量时优先查缓存,没有才新建 DataContainer(进而触发一次 NetCDF 读取)。这带来两个效果:
- 切换来回不会丢失编辑内容和撤销历史;
- 不需要在切换前用对话框询问"是否放弃未保存的修改"——这是早期版本的做法,后来发现缓存方案体验更好,直接去掉了确认对话框。
保存目标文件名(ofile)和"是否已确认覆盖"标记也放在 DataContainer 实例上而不是主窗口上,因为它们是"每个 (文件, 变量) 各自独立"的状态。
三、几个不那么直观的坑
这一节是实现过程中真正花时间排查的部分,记录问题现象、根因和最终方案。
1. uv sync 在 Windows 上解析到无 wheel 的 PyQt5
uv sync 默认解析到的 pyqt5-qt5 最新版本(5.15.19)在 Windows 上没有预编译 wheel,会直接解析失败;同时最新 Python(当时环境默认的 3.14)与该 PyQt5 版本的 Windows wheel 也不兼容。解决方式:
pyproject.toml显式锁定PyQt5==5.15.11+PyQt5-Qt5==5.15.2(这一组合有现成的 Windows wheel);.python-version固定为3.11,避免 uv 选用过新的解释器版本导致依赖解析失败。
2. Cartopy 在古地理网格上因换日线折返崩溃
最初尝试用 GeoAxes.pcolormesh(..., transform=ccrs.PlateCarree()) 画总览小地图背景,在真实世界坐标下没问题,但这批网格文件是古地理重建数据(如 03Ma_BASclosed),经度覆盖 0–360 且跨越了 Cartopy 判定"是否需要在换日线处切分多边形"的阈值,触发了 Cartopy/Shapely 内部的一个已知脆弱路径,最终以
text
shapely.errors.GEOSException: UnsupportedOperationException: getX called on empty Point崩溃。
由于小地图的坐标轴本身就是纯 PlateCarree(经纬度角度),根本不需要重新投影,绕过的办法是直接调用基类方法、跳过 GeoAxes 对 pcolormesh 的折返切分包装:
python
mpl.axes.Axes.pcolormesh(
self.mini_axes, lons[::step, ::step], lats[::step, ::step],
background[::step, ::step], cmap='Greys', shading='auto')gridlines()(画经纬网格线)不涉及这条崩溃路径,仍然正常使用 GeoAxes 的实现,因此小地图依然是一个真正的 projection=ccrs.PlateCarree() 坐标轴,只是背景图的绘制方式做了规避。
3. 主视图故意不用地理投影
主编辑视图不使用 Cartopy 投影,而是直接在数组下标 (i, j) 空间里用 imshow 绘制——因为编辑操作(选中格子、方向键移动、画笔涂抹)都是按下标寻址的,下标空间里事件坐标到 (i, j) 的换算最简单可靠(floor(event.xdata + 0.5)),换成地理投影反而会让点选逻辑复杂,还会在网格畸变处失真。
但纯下标空间会把每个格子画成正方形,而 POP 曲线网格在不同纬度、不同区域的物理宽高比差异很大(越靠近极点,经度方向的物理宽度被压缩得越厉害)。为了让整体观感"像一张真实地图"而不是被拉伸/压扁的方格,用 axes.set_aspect(aspect, adjustable='box') 设置一个全局代表性纵横比,取自 gridio.compute_physical_aspect:优先用格点物理尺寸变量(HTN/HTE),否则退化为用经纬度的大圆距离估算。这只是"看起来大致正确"的近似(曲线网格在跨越很大纬度范围时不存在唯一的全局纵横比),但对这批以海盆为单位的区域网格已经足够。
4. Matplotlib 工具栏的 Pan/Zoom 按钮会吞掉自定义点选
早期版本保留了 NavigationToolbar2QT 默认的全部按钮(含 Pan/Zoom 切换工具)。问题在于:一旦用户点击了 Pan 或 Zoom 按钮(这是很自然的操作,图标就在画布上方),工具栏会进入"平移/缩放模式",把左键点击全部截获用于框选缩放/拖拽平移,导致自定义的选格/画笔点击事件完全收不到——表现为"光标无法选定某个格子进行修改"。
修复方式是从根源上消除这个陷阱:
python
class MapToolbar(NavigationToolbar):
toolitems = [t for t in NavigationToolbar.toolitems if t[0] in ("Home", "Save")]只保留 Home/Save 按钮,平移功能改为自己实现的中键拖动(on_press/on_motion/on_release 中通过 event.button == 2 判断,用按下时刻的 axes.transData.inverted() 做像素坐标到数据坐标的转换),不会和左键选格/画笔产生任何按钮冲突。
同时把"跟随鼠标的浅灰色悬停标记"和"停留在最后一次点击格子上的红色选中标记"拆成了两个独立的 Line2D 对象——早期版本只有一个跟随鼠标的标记,鼠标移开后就看不出选中了哪一格,容易被误认为"没选中"。
5. PyQt4 → PyQt5 迁移的具体坑
QFrame.setLineWidth(0.5)在 PyQt5 下会因为参数类型检查报TypeError(PyQt4 更宽松),改成整数setLineWidth(1);- 旧式信号槽
self.connect(action, SIGNAL("triggered()"), slot)在 PyQt5 中不再支持,统一改成action.triggered.connect(slot); mpl.cm.get_cmap(name)在较新版本 Matplotlib 中已废弃,改用mpl.colormaps[name](需要.copy()后再set_bad(),避免污染全局色表实例)。
6. 无头自动化测试中,模态对话框会段错误
开发过程中大量使用 QT_QPA_PLATFORM=offscreen 做无 GUI 的自动化验证(构造 QApplication,直接调用事件处理函数模拟鼠标/键盘输入,检查内部状态而不实际弹出窗口)。测试中发现:在这个离屏平台插件下,QMessageBox.warning/question 等模态对话框的 exec_() 会导致进程段错误(segfault)——这是 Windows 上 offscreen 插件对模态事件循环支持不完整的环境限制,不是程序本身的缺陷,在正常桌面会话(app.exec_() 跑在真实事件循环里)中弹窗一切正常。为了绕开这个限制继续做自动化验证,测试脚本里会临时把 QMessageBox.warning/question monkeypatch 成直接返回值的桩函数。
四、保存与变更记录格式
保存时不直接覆盖原文件,而是:
gridio.clone_netcdf(原文件, 目标文件):原样克隆整个文件(含所有其他变量、维度、全局属性);- 把当前变量的完整数组写回目标文件对应变量;
gridio.append_change_log(...)追加两个辅助变量:original_<var>:编辑前的原始数据,保留对照;changes_<var>:形状(N, 3)的(i, j, new_value)记录数组,N为发生变化的格子数。保存时通过data != orig_data现算一次差异,而不是依赖撤销栈的原始编辑序列,这样无论中间经历过多少次撤销/重做,保存的记录始终反映"最终结果相对原始数据的差异"。
默认目标文件名是 原文件名_变量名_edited.nc;也可以通过 Save As 另存为任意路径。目标文件已存在时会询问是否覆盖,同一个 (文件, 变量) 组合在一次会话中只会询问一次。
五、快速上手
用 uv 管理依赖,在 GridVarEditor/ 目录下执行一次即可:
bash
cd GridVarEditor
uv sync会自动创建 .venv 并安装 netCDF4、numpy、matplotlib、锁定版本的 PyQt5、cartopy。
bash
# 打开单个文件
uv run GridVarEditor.py ../work/gridkmt_PIdefault_260804.nc
# 一次打开多个文件
uv run GridVarEditor.py ../work/*.nc
# 打开整个目录下的所有 .nc 文件,并指定层深表用于水深换算
uv run GridVarEditor.py --dir ../work --levels ../origin/PIdefault.pop.gx3v7_gridinfo.nc常用操作:
| 操作 | 效果 |
|---|---|
| 左键点击格子 | 选中该格(红框标记),侧栏"选中格新值"框显示当前值 |
| 输入新值后回车 | 提交对选中格的修改 |
| 勾选"画笔模式"后左键拖动 | 用侧栏"笔刷值"连续涂抹多个格子(一次拖拽算一个撤销单元) |
| 右键点击格子 | 取色:把该格的值读入笔刷值 |
| 方向键 | 在相邻格间移动选中位置 |
| 鼠标滚轮 / 中键拖动 | 缩放 / 平移 |
Ctrl+Z / Ctrl+Shift+Z | 撤销 / 重做 |
Ctrl+S | 保存为 原文件名_变量名_edited.nc,不覆盖原文件 |
六、已知限制
- 纵横比是整张网格的单一近似值,不是逐格精确的地图投影;跨越很大纬度范围的全球网格在高纬区域会有可见的形变。
is_kmt_like的陆地掩膜判断是基于变量名的启发式(含kmt/kmu子串),不是读取变量的语义属性;如果某个非层数索引变量恰好叫这个名字,会被误判为需要掩膜。- 多文件缓存没有内存上限,长时间打开大量大网格文件会持续占用内存(对本仓库中百级网格规模的文件不成问题)。
- 目前没有自动化的 GUI 回归测试套件,验证方式是通过
QT_QPA_PLATFORM=offscreen直接调用内部方法模拟交互(见上文第 6 条),尚未接入 CI。