From f05916bc3a01302771ad4f75c43c0a478bdf7efb Mon Sep 17 00:00:00 2001 From: duxin Date: Tue, 30 Jun 2026 10:45:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AD=A5=E9=AA=A4=E5=9B=9B=E7=9A=84=E9=87=87?= =?UTF-8?q?=E6=A0=B7=E7=82=B9=E5=92=8C=E5=AF=B9=E5=BA=94=E7=9A=84=E5=85=89?= =?UTF-8?q?=E8=B0=B1=E5=9B=BE=E5=83=8F=E5=B1=95=E7=A4=BA=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/panels/step4_sampling_panel.py | 258 ++++++++++++++++++------- 1 file changed, 191 insertions(+), 67 deletions(-) diff --git a/src/gui/panels/step4_sampling_panel.py b/src/gui/panels/step4_sampling_panel.py index fc55674..b69f179 100644 --- a/src/gui/panels/step4_sampling_panel.py +++ b/src/gui/panels/step4_sampling_panel.py @@ -7,7 +7,8 @@ Step4 面板 - 采样点布设(内嵌交互式光谱探针视图) - 左右分栏布局 (QSplitter):左侧控制区 + 右侧嵌入式 Matplotlib 视图 - 1×2 子图:ax1 散点图 + ax2 光谱曲线 - Hover 悬停显示坐标提示,Click 点击绘制该点光谱曲线 - - NavigationToolbar2QT 工具栏自带保存/缩放/平移 + - 完美复刻 Step12 风格的自定义独立顶部工具栏,隐藏原生丑陋图标 + - 修复 Matplotlib 3.3+ 版本兼容性导致的 _active 属性报错 """ import os @@ -48,6 +49,10 @@ from src.gui.styles import ModernStylesheet class Step4SamplingPanel(QWidget): def __init__(self, parent=None): super().__init__(parent) + # ── 全局中文字体强制设置(必须在创建 Figure 之前,与模块级 rcParams 形成双重保障)── + plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei', 'sans-serif'] + plt.rcParams['axes.unicode_minus'] = False + # 交互状态 self._df = None self._x_col = None @@ -59,6 +64,7 @@ class Step4SamplingPanel(QWidget): self._cid_hover = None self._cid_click = None self._last_render_path = None + self.init_ui() # ═══════════════════════════════════════════════════════════════ @@ -174,20 +180,65 @@ class Step4SamplingPanel(QWidget): left_widget.setMinimumWidth(360) # ═══════════════════════════════════════════════ - # 右侧:可视化区(参考 Step12 ImageViewer 的白底卡片风格) + # 右侧:可视化区(深度复刻 Step12 样式) # ═══════════════════════════════════════════════ right_widget = QWidget() right_layout = QVBoxLayout() right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(0) - # 给画板穿上卡片外衣,确保视觉上的绝对统一 viz_group = QGroupBox("📊 采样点交互式探索") viz_layout = QVBoxLayout() viz_layout.setContentsMargins(8, 20, 8, 8) - viz_layout.setSpacing(0) + viz_layout.setSpacing(8) - # Matplotlib 画布(1×2 子图:散点图 + 光谱曲线) + # ── 学习 Step 12:重构标准的 PyQt 工具栏 ── + custom_toolbar = QHBoxLayout() + + self.fit_btn = QPushButton("⬜ 适应窗口") + self.fit_btn.setToolTip("恢复默认全景视图") + custom_toolbar.addWidget(self.fit_btn) + + separator = QFrame() + separator.setFrameShape(QFrame.VLine) + separator.setFrameShadow(QFrame.Sunken) + custom_toolbar.addWidget(separator) + + # 专门为交互工具按钮定制的悬停与选中(蓝色)高亮样式 + tool_btn_style = """ + QPushButton { padding: 5px 10px; border-radius: 4px; border: 1px solid transparent; background: transparent; color: #475569; font-weight: bold; } + QPushButton:hover { background-color: #F1F5F9; border: 1px solid #CBD5E1; color: #0F172A; } + QPushButton:checked { background-color: #E0F2FE; color: #0369A1; border: 1px solid #BAE6FD; } + """ + + self.probe_btn = QPushButton("👆 点选探针") + self.probe_btn.setToolTip("点击散点查看光谱曲线(默认模式)") + self.probe_btn.setCheckable(True) + self.probe_btn.setChecked(True) # 默认激活 + self.probe_btn.setStyleSheet(tool_btn_style) + custom_toolbar.addWidget(self.probe_btn) + + self.pan_btn = QPushButton("✋ 拖拽漫游") + self.pan_btn.setToolTip("按住左键拖拽平移图表") + self.pan_btn.setCheckable(True) + self.pan_btn.setStyleSheet(tool_btn_style) + custom_toolbar.addWidget(self.pan_btn) + + self.zoom_rect_btn = QPushButton("🔍 框选放大") + self.zoom_rect_btn.setToolTip("框选局部区域进行放大") + self.zoom_rect_btn.setCheckable(True) + self.zoom_rect_btn.setStyleSheet(tool_btn_style) + custom_toolbar.addWidget(self.zoom_rect_btn) + + custom_toolbar.addStretch() + + self.save_btn = QPushButton("💾 保存图像") + self.save_btn.setToolTip("保存当前图表到本地") + custom_toolbar.addWidget(self.save_btn) + + viz_layout.addLayout(custom_toolbar) + + # ── 画布渲染区 ── self._fig = Figure(figsize=(10, 7), facecolor='white') self._ax_scatter = self._fig.add_subplot(121) self._ax_spectrum = self._fig.add_subplot(122) @@ -195,6 +246,16 @@ class Step4SamplingPanel(QWidget): self._canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._canvas.setStyleSheet("background-color: white;") + # ── 彻底隐藏原生引擎并绑定到前台按钮 ── + self._hidden_toolbar = NavigationToolbar(self._canvas, self) + self._hidden_toolbar.hide() + + self.fit_btn.clicked.connect(self._hidden_toolbar.home) + self.probe_btn.clicked.connect(self._toggle_probe) + self.pan_btn.clicked.connect(self._toggle_pan) + self.zoom_rect_btn.clicked.connect(self._toggle_zoom) + self.save_btn.clicked.connect(self._hidden_toolbar.save_figure) + # 设置子图初始状态 for ax, title, xlabel, ylabel in [ (self._ax_scatter, "采样点空间分布", "经度 / X", "纬度 / Y"), @@ -214,10 +275,6 @@ class Step4SamplingPanel(QWidget): fontsize=13, color='#AAAAAA') self._fig.tight_layout(pad=2.0) - - # 工具栏(自带保存/缩放/平移) - self._toolbar = NavigationToolbar(self._canvas, self) - viz_layout.addWidget(self._toolbar) viz_layout.addWidget(self._canvas) viz_group.setLayout(viz_layout) @@ -248,7 +305,50 @@ class Step4SamplingPanel(QWidget): self.output_file.line_edit.textChanged.connect(self._on_output_changed) # ═══════════════════════════════════════════════════════════════ - # 配置读写(保持不变) + # 原生交互工具代理 (兼容新旧版 Matplotlib) + # ═══════════════════════════════════════════════════════════════ + + def _toggle_probe(self): + """探针模式:解除原生引擎工具,恢复点击选点交互""" + # 取消 Matplotlib 原生工具的激活状态(回到无工具模式) + if hasattr(self._hidden_toolbar, 'mode'): + active = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper() + else: + active = str(getattr(self._hidden_toolbar, '_active', '')).upper() + # 如果当前有激活的工具,再点一次让它取消 + if 'PAN' in active: + self._hidden_toolbar.pan() + elif 'ZOOM' in active: + self._hidden_toolbar.zoom() + self._sync_button_states() + + def _toggle_pan(self): + """联动原生引擎的拖拽功能,并控制按钮激活状态""" + self._hidden_toolbar.pan() + self._sync_button_states() + + def _toggle_zoom(self): + """联动原生引擎的框选放大功能,并控制按钮激活状态""" + self._hidden_toolbar.zoom() + self._sync_button_states() + + def _sync_button_states(self): + """兼容新老版 Matplotlib 获取当前激活模式,实现三按钮互斥""" + if hasattr(self._hidden_toolbar, 'mode'): + mode_str = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper() + else: + mode_str = str(getattr(self._hidden_toolbar, '_active', '')).upper() + + is_pan = 'PAN' in mode_str + is_zoom = 'ZOOM' in mode_str + is_probe = not is_pan and not is_zoom # 探针 = 两者都不激活 + + self.probe_btn.setChecked(is_probe) + self.pan_btn.setChecked(is_pan) + self.zoom_rect_btn.setChecked(is_zoom) + + # ═══════════════════════════════════════════════════════════════ + # 配置读写 # ═══════════════════════════════════════════════════════════════ def get_config(self): @@ -288,17 +388,14 @@ class Step4SamplingPanel(QWidget): else: self.work_dir = None - # ── 去耀斑影像输入 ── - # 优先:pipeline context deglint_path = None if pipeline and hasattr(pipeline, 'step_outputs'): step3_out = pipeline.step_outputs.get('step3', {}) deglint_path = ( - step3_out.get('deglint_image') or step3_out.get('output_path') or - step3_out.get('output_file') or step3_out.get('deglint_img_path') + step3_out.get('deglint_image') or step3_out.get('output_path') or + step3_out.get('output_file') or step3_out.get('deglint_img_path') ) - # 回退:文件系统扫描 3_deglint/ if not deglint_path or not os.path.exists(deglint_path): deglint_path = scan_work_dir_for_input(self.work_dir, 'deglint_image') @@ -307,17 +404,14 @@ class Step4SamplingPanel(QWidget): deglint_path = os.path.join(self.work_dir or '', deglint_path).replace('\\', '/') self.deglint_img_file.set_path(deglint_path) - # ── 水域掩膜输入 ── - # 优先:pipeline context water_mask_path = None if pipeline and hasattr(pipeline, 'step_outputs'): step1_out = pipeline.step_outputs.get('step1', {}) water_mask_path = ( - step1_out.get('water_mask') or step1_out.get('output_path') or - step1_out.get('output_file') + step1_out.get('water_mask') or step1_out.get('output_path') or + step1_out.get('output_file') ) - # 回退:文件系统扫描 1_water_mask/ if not water_mask_path or not os.path.exists(water_mask_path): water_mask_path = scan_work_dir_for_input(self.work_dir, 'water_mask') @@ -326,13 +420,11 @@ class Step4SamplingPanel(QWidget): water_mask_path = os.path.join(self.work_dir or '', water_mask_path).replace('\\', '/') self.water_mask_file.set_path(water_mask_path) - # ── 输出路径 ── if self.work_dir and not self.output_file.get_path(): output_path = resolve_subdir(self.work_dir, 'sampling_csv_path') self.output_file.set_path(output_path.replace('\\', '/')) self._check_csv_exists() - # 若 CSV 已存在,尝试自动渲染 csv_path = self.output_file.get_path() if csv_path and os.path.isfile(csv_path): self._render_inline_plot() @@ -367,7 +459,6 @@ class Step4SamplingPanel(QWidget): self._check_csv_exists() def _check_csv_and_auto_render(self): - """定时器回调:检测到新 CSV 出现时自动渲染一次。""" csv_path = self.output_file.get_path() if csv_path and os.path.isfile(csv_path): if csv_path != self._last_render_path: @@ -375,7 +466,6 @@ class Step4SamplingPanel(QWidget): self._render_inline_plot() def _on_refresh_clicked(self): - """手动点击刷新按钮。""" csv_path = self.output_file.get_path() if not csv_path or not os.path.exists(csv_path): QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。") @@ -387,13 +477,7 @@ class Step4SamplingPanel(QWidget): # ═══════════════════════════════════════════════════════════════ def _detect_coordinate_columns(self, df: pd.DataFrame): - """检测坐标列,返回 (x_col, y_col) 或 (None, None)。 - - 优先级:pixel_x/pixel_y → longitude/latitude → lon/lat → - X/Y → UTM_X/UTM_Y → 任何含 'x'/'y' 关键字的列 - """ cols_lower = {c.lower(): c for c in df.columns} - priority_pairs = [ ('pixel_x', 'pixel_y'), ('longitude', 'latitude'), @@ -406,7 +490,6 @@ class Step4SamplingPanel(QWidget): if x_key in cols_lower and y_key in cols_lower: return cols_lower[x_key], cols_lower[y_key] - # 最后尝试:找名字中含 x / y 的数值列 x_candidates = [c for c in df.columns if 'x' in c.lower() and pd.api.types.is_numeric_dtype(df[c])] y_candidates = [c for c in df.columns if 'y' in c.lower() and pd.api.types.is_numeric_dtype(df[c])] if x_candidates and y_candidates: @@ -415,11 +498,6 @@ class Step4SamplingPanel(QWidget): return None, None def _detect_band_columns(self, df: pd.DataFrame): - """检测光谱波段列(纯数字列名,值域在 200–3000 nm 之间)。 - - 优先使用列名可解析为 float 且在波长范围内的列; - 否则回退到位置索引(跳过坐标列和已知元数据列)。 - """ band_cols = [] for col in df.columns: try: @@ -430,11 +508,9 @@ class Step4SamplingPanel(QWidget): continue if band_cols: - # 按波长数值排序 band_cols.sort(key=lambda c: float(str(c).strip())) return band_cols - # 回退:跳过坐标列和已知元数据列,取数值列 skip_keywords = ('x', 'y', 'lon', 'lat', 'utm', 'id', 'sample', 'index', 'pixel') numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist() if self._x_col: @@ -444,13 +520,11 @@ class Step4SamplingPanel(QWidget): return [c for c in numeric_cols if not any(k in c.lower() for k in skip_keywords)] def _render_inline_plot(self): - """读取 CSV → 检测坐标/波段列 → 绘制 1×2 子图。""" csv_path = self.output_file.get_path() if not csv_path or not os.path.isfile(csv_path): self._show_empty_state("等待采样数据生成...\n\n请先配置参数并运行步骤") return - # 读取数据 try: df = pd.read_csv(csv_path) except Exception as e: @@ -461,7 +535,6 @@ class Step4SamplingPanel(QWidget): self._show_empty_state("CSV 文件为空") return - # 检测坐标列 x_col, y_col = self._detect_coordinate_columns(df) if x_col is None or y_col is None: self._show_empty_state( @@ -471,13 +544,8 @@ class Step4SamplingPanel(QWidget): ) return - # 检测光谱列 band_cols = self._detect_band_columns(df) - if not band_cols: - # 仅显示散点图,光谱子图留空 - pass - # 缓存 self._df = df self._x_col = x_col self._y_col = y_col @@ -485,7 +553,6 @@ class Step4SamplingPanel(QWidget): self._highlight_idx = None self._last_render_path = csv_path - # 清除 Annotation if self._annot is not None: try: self._annot.remove() @@ -493,7 +560,6 @@ class Step4SamplingPanel(QWidget): pass self._annot = None - # ── 绘制 ax1:散点图 ── self._ax_scatter.clear() self._ax_scatter.set_facecolor('white') x = df[x_col].values @@ -510,7 +576,6 @@ class Step4SamplingPanel(QWidget): self._ax_scatter.grid(True, alpha=0.2, linestyle='-', linewidth=0.5) self._ax_scatter.tick_params(labelsize=9) - # ── 绘制 ax2:光谱曲线(初始状态)── self._ax_spectrum.clear() self._ax_spectrum.set_facecolor('white') if band_cols: @@ -528,10 +593,9 @@ class Step4SamplingPanel(QWidget): fontsize=13, color='#AAAAAA') self._fig.tight_layout(pad=2.0) - self._canvas.draw() + self._canvas.draw_idle() def _show_empty_state(self, message: str): - """在右侧画布显示提示信息。""" for ax in (self._ax_scatter, self._ax_spectrum): ax.clear() ax.set_facecolor('white') @@ -548,7 +612,7 @@ class Step4SamplingPanel(QWidget): fontsize=13, color='#AAAAAA') self._fig.tight_layout(pad=2.0) - self._canvas.draw() + self._canvas.draw_idle() self._df = None self._last_render_path = None @@ -557,9 +621,8 @@ class Step4SamplingPanel(QWidget): # ═══════════════════════════════════════════════════════════════ def _on_hover(self, event): - """悬停:在散点旁显示坐标 + ID 提示框。""" if event.inaxes != self._ax_scatter: - # 鼠标离开 ax1 → 隐藏 annotation + self._canvas.unsetCursor() # 离开绘图区,恢复默认鼠标 if self._annot is not None: try: self._annot.set_visible(False) @@ -571,9 +634,22 @@ class Step4SamplingPanel(QWidget): if self._df is None or self._scatter is None: return - # 检测是否悬停在散点上 + # ── 拦截:如果当前拿着拖拽或缩放工具,交出鼠标控制权给 Matplotlib ── + if hasattr(self._hidden_toolbar, 'mode'): + mode_str = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper() + else: + mode_str = str(getattr(self._hidden_toolbar, '_active', '')).upper() + + if 'PAN' in mode_str or 'ZOOM' in mode_str: + self._canvas.unsetCursor() # 解除我们的控制,让工具管鼠标 + if self._annot is not None and self._annot.get_visible(): + self._annot.set_visible(False) + self._canvas.draw_idle() + return + contains, info = self._scatter.contains(event) if not contains or info is None or 'ind' not in info or len(info['ind']) == 0: + self._canvas.setCursor(Qt.ArrowCursor) # 在空白处,变成普通箭头 if self._annot is not None: try: self._annot.set_visible(False) @@ -582,12 +658,14 @@ class Step4SamplingPanel(QWidget): pass return + # ── 【鼠标变小手】:悬停在散点上,变成可点击的"小手" ── + self._canvas.setCursor(Qt.PointingHandCursor) + idx = info['ind'][0] row = self._df.iloc[idx] x_val = row[self._x_col] y_val = row[self._y_col] - # 创建或更新 Annotation text = f"ID: {idx}\n经度: {x_val:.4f}\n纬度: {y_val:.4f}" if self._annot is None: self._annot = self._ax_scatter.annotate( @@ -608,23 +686,68 @@ class Step4SamplingPanel(QWidget): self._canvas.draw_idle() def _on_click(self, event): - """点击:高亮散点 + 绘制光谱曲线。""" if event.inaxes != self._ax_scatter: return if self._df is None or self._scatter is None: return - # 检测是否点击在散点上 + # ── 拦截:如果当前拿着拖拽或缩放工具,直接忽略选点 ── + if hasattr(self._hidden_toolbar, 'mode'): + mode_str = str(getattr(self._hidden_toolbar.mode, 'name', self._hidden_toolbar.mode)).upper() + else: + mode_str = str(getattr(self._hidden_toolbar, '_active', '')).upper() + + if 'PAN' in mode_str or 'ZOOM' in mode_str: + return + contains, info = self._scatter.contains(event) if not contains or info is None or 'ind' not in info or len(info['ind']) == 0: return idx = info['ind'][0] - row = self._df.iloc[idx] - self._highlight_idx = idx - # ── 高亮选中点 ── + # ── 【取消选中逻辑】:如果点击的是已经高亮的点,则取消高亮并重置 ── + if getattr(self, '_highlight_idx', None) == idx: + self._highlight_idx = None + current_xlim = self._ax_scatter.get_xlim() + current_ylim = self._ax_scatter.get_ylim() + + # 恢复左侧为全是蓝色散点的状态 + self._ax_scatter.clear() + self._ax_scatter.set_facecolor('white') + x = self._df[self._x_col].values + y = self._df[self._y_col].values + self._scatter = self._ax_scatter.scatter( + x, y, c='#0078D7', alpha=0.75, edgecolors='white', linewidth=0.6, s=45, picker=True, zorder=3 + ) + self._ax_scatter.set_xlabel("经度 / X", fontsize=11) + self._ax_scatter.set_ylabel("纬度 / Y", fontsize=11) + self._ax_scatter.set_title(f"采样点空间分布 (共 {len(self._df)} 个点)", fontsize=14, fontweight='bold', pad=12) + self._ax_scatter.grid(True, alpha=0.2, linestyle='-', linewidth=0.5) + self._ax_scatter.tick_params(labelsize=9) + self._ax_scatter.set_xlim(current_xlim) + self._ax_scatter.set_ylim(current_ylim) + + # 清空右侧光谱图 + self._ax_spectrum.clear() + self._ax_spectrum.set_facecolor('white') + self._ax_spectrum.set_title("光谱曲线(点击左侧散点查看)", fontsize=14, fontweight='bold', pad=12) + self._ax_spectrum.set_xlabel("波长 / 特征", fontsize=11) + self._ax_spectrum.set_ylabel("反射率", fontsize=11) + self._ax_spectrum.grid(True, alpha=0.2, linestyle='-', linewidth=0.5) + self._ax_spectrum.tick_params(labelsize=9) + self._ax_spectrum.text(0.5, 0.5, "点击左侧散点\n查看光谱曲线", + ha='center', va='center', transform=self._ax_spectrum.transAxes, + fontsize=13, color='#AAAAAA') + self._fig.tight_layout(pad=2.0) + self._canvas.draw_idle() + return + + # ── 正常选中并高亮逻辑 ── + self._highlight_idx = idx + row = self._df.iloc[idx] + x = self._df[self._x_col].values y = self._df[self._y_col].values @@ -633,6 +756,9 @@ class Step4SamplingPanel(QWidget): colors[idx] = '#E74C3C' sizes[idx] = 80 + current_xlim = self._ax_scatter.get_xlim() + current_ylim = self._ax_scatter.get_ylim() + self._ax_scatter.clear() self._ax_scatter.set_facecolor('white') self._scatter = self._ax_scatter.scatter( @@ -640,7 +766,6 @@ class Step4SamplingPanel(QWidget): alpha=0.75, edgecolors='white', linewidth=0.6, picker=True, zorder=3 ) - # 将选中点提升到顶层 self._ax_scatter.scatter( [x[idx]], [y[idx]], c='#E74C3C', s=110, @@ -652,8 +777,9 @@ class Step4SamplingPanel(QWidget): self._ax_scatter.set_title(f"采样点空间分布 (共 {len(self._df)} 个点)", fontsize=14, fontweight='bold', pad=12) self._ax_scatter.grid(True, alpha=0.2, linestyle='-', linewidth=0.5) self._ax_scatter.tick_params(labelsize=9) + self._ax_scatter.set_xlim(current_xlim) + self._ax_scatter.set_ylim(current_ylim) - # ── 绘制光谱曲线 ── self._ax_spectrum.clear() self._ax_spectrum.set_facecolor('white') if self._band_cols: @@ -670,7 +796,6 @@ class Step4SamplingPanel(QWidget): continue if wavelengths: - # 按波长排序 pairs = sorted(zip(wavelengths, reflectance), key=lambda p: p[0]) wavelengths, reflectance = zip(*pairs) if pairs else ([], []) self._ax_spectrum.plot( @@ -696,18 +821,17 @@ class Step4SamplingPanel(QWidget): fontsize=13, color='#AAAAAA') self._fig.tight_layout(pad=2.0) - self._canvas.draw() + self._canvas.draw_idle() # ═══════════════════════════════════════════════════════════════ # 旧版弹窗查看器(保留,供外部调用) # ═══════════════════════════════════════════════════════════════ def _open_sampling_viewer(self): - """打开独立的 SamplingViewerDialog 弹窗(保留兼容)。""" csv_path = self.output_file.get_path() if not csv_path or not os.path.exists(csv_path): QMessageBox.warning(self, "文件不存在", f"采样点 CSV 文件不存在:{csv_path}\n请先运行生成数据。") return dialog = SamplingViewerDialog(csv_path, self) dialog.exec_() - self._check_csv_exists() + self._check_csv_exists() \ No newline at end of file