diff --git a/.github/workflows/debug-trusted-publishing.yml b/.github/workflows/debug-trusted-publishing.yml index 6d234af..100f074 100644 --- a/.github/workflows/debug-trusted-publishing.yml +++ b/.github/workflows/debug-trusted-publishing.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Print workflow context run: | diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 1c49e55..09d643d 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Need full history to verify branch ancestry @@ -148,7 +148,7 @@ jobs: steps: - name: Checkout code with full history - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Full git history needed for setuptools-scm @@ -234,7 +234,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Download distribution packages uses: actions/download-artifact@v4 diff --git a/.github/workflows/publish-to-testpypi.yml b/.github/workflows/publish-to-testpypi.yml index 61bf00f..bee4c5d 100644 --- a/.github/workflows/publish-to-testpypi.yml +++ b/.github/workflows/publish-to-testpypi.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout code with full history - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Full history needed for setuptools-scm diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 4f9bed7..fe908a2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -37,7 +37,7 @@ jobs: name: Code Quality Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python 3.11 uses: actions/setup-python@v5 @@ -69,7 +69,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -249,7 +249,7 @@ jobs: # runs-on: ubuntu-latest # steps: # - name: Checkout code - # uses: actions/checkout@v4 + # uses: actions/checkout@v5 # - name: Set up Python 3.10 # uses: actions/setup-python@v3 # with: diff --git a/.github/workflows/sync-branches.yml b/.github/workflows/sync-branches.yml index d4570ff..823cd59 100644 --- a/.github/workflows/sync-branches.yml +++ b/.github/workflows/sync-branches.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 # Full history needed for merging token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index c3be3cc..af87ce9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,13 @@ pyASDReader is a robust Python library designed to read and parse all versions ( - Type-safe enum constants for file attributes - Robust error handling and validation +- **Graphical User Interface**: PyQt6-based GUI application + - Interactive spectral data visualization + - File browsing and recent files management + - Comprehensive metadata display + - Export data to CSV and plots to PNG/SVG/PDF + - See [GUI README](gui/README.md) for details + ## Requirements - Python >=3.8 @@ -55,10 +62,30 @@ cd pyASDReader # Install in editable mode with development dependencies pip install -e ".[dev]" -# Install with all dependencies (dev + docs + testing) +# Install with GUI support (adds PyQt6 and matplotlib) +pip install -e ".[gui]" + +# Install with all dependencies (dev + docs + gui + testing) pip install -e ".[all]" ``` +### GUI Application + +To use the graphical interface: + +```bash +# Install with GUI dependencies +pip install pyASDReader[gui] # or pip install -e ".[gui]" + +# Launch the GUI +python main.py + +# Or open a specific file +python main.py path/to/spectrum.asd +``` + +See the [GUI documentation](gui/README.md) for more details. + ## Documentation - **[CHANGELOG](CHANGELOG.md)** - Version history, feature updates, and bug fixes diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..f8b55ce --- /dev/null +++ b/gui/README.md @@ -0,0 +1,253 @@ +# pyASDReader GUI + +A graphical user interface for viewing and analyzing ASD spectral files. + +## Features + +- **File Management** + - Tree-based file browser with checkboxes for multi-file selection + - Open individual ASD files or browse folders + - Recent files list for quick access + - Tri-state checkbox selection for folders and files + - Expand/collapse folder tree + - Drag-and-drop support + +- **Multi-Plot Canvas (NEW)** + - 7 layout modes: 1×1, 1×2, 2×1, 2×2, 1×3, 3×1, 2×3 + - Compare files side-by-side automatically + - Independent data type selection per subplot + - Synchronized zoom and cursor across plots + - Export all subplots as a single image + +- **Data Visualization** + - Interactive spectral plots using matplotlib + - Multiple data types: + - Digital Number (DN) + - Reflectance (with 1st, 2nd, and 3rd derivatives) + - White Reference + - Absolute Reflectance + - Log(1/R) (with derivatives) + - Radiance (when available) + - Zoom, pan, and other matplotlib tools + - Customizable grid and legend + +- **Spectrum Comparison (NEW)** + - **Compare Mode**: View multiple files side-by-side + - **Overlay Mode**: Plot multiple spectra on one graph with: + - Different colors for each spectrum + - Mean and standard deviation overlay + - Statistics display + - Easy export functionality + +- **Metadata Display** + - 7 comprehensive tabs: + - File Information (name, path, size, timestamps) + - Metadata (GPS, acquisition parameters) + - Data Types (availability and quick load) + - Calibration (v7+ calibration data) + - History (v8+ audit log) + - System (instrument and spectral configuration) + - **Statistics (NEW)**: Real-time spectrum statistics + - Global statistics (min, max, mean, std, median, range) + - Per-band statistics (VNIR, SWIR1, SWIR2) + - Signal quality metrics (SNR, saturation) + +- **Export Capabilities** + - Export data to CSV (select specific data types) + - Export metadata to text file + - Export plots as PNG, SVG, or PDF + - Export multi-plot layouts as single image + - **Batch Export (NEW)**: Export multiple files at once + - CSV or TXT format + - Configurable data type selection + - Progress tracking + - Error reporting + +## Installation + +### Install GUI dependencies + +```bash +# Install with GUI dependencies +pip install -e ".[gui]" + +# Or install all dependencies +pip install -e ".[all]" +``` + +### Dependencies + +- PyQt6 >= 6.4.0 +- matplotlib >= 3.5.0 +- numpy >= 1.20.0 (already required by base package) + +## Usage + +### Launch the GUI + +```bash +# From the project root directory +python main.py + +# Or with a file to open +python main.py path/to/file.asd +``` + +### Using the Interface + +1. **Browse and Select Files** + - Click "Open Folder..." (Ctrl+Shift+O) to load a directory tree + - Check boxes next to files to select them + - Use "All", "Clear", "Expand", "Collapse" buttons for quick navigation + +2. **Compare Multiple Files** + - **Side-by-Side**: Check 2-6 files, they load automatically in multi-plot layout + - **Overlay**: Check 2+ files and click "Overlay" button to see all on one plot + - **Statistics**: Enable "Show Statistics" in overlay mode for mean/std dev + +3. **Single File Viewing** + - Double-click a file to view it in detail + - Select data type from dropdown menu + - View statistics in the Properties panel (Statistics tab) + +4. **Export Options** + - **Single Export**: Use Export menu to save current data or plots + - **Batch Export**: Check multiple files and click "Export" button + - Choose CSV (spectral data) or TXT (metadata) + - Select which data types to export + - Track progress with progress bar + +5. **Multi-Plot Controls** + - Use layout buttons to switch between different grid arrangements + - Enable/disable sync for zoom, cursor, or pan + - Export all plots as single image via Export → Export Plot + +### Keyboard Shortcuts + +- `Ctrl+O` - Open file +- `Ctrl+Shift+O` - Open folder +- `Ctrl+W` - Close current file +- `Ctrl+Q` - Quit application +- `F5` - Refresh plot + +### New Workflow Examples + +**Example 1: Comparing Field Measurements** +1. Open folder with multiple `.asd` files +2. Check 4 files you want to compare +3. They automatically load in 2×2 grid +4. Enable "Sync Zoom" to zoom all plots together +5. Export as single PNG via Export → Export Plot as PNG + +**Example 2: Analyzing Spectrum Statistics** +1. Double-click a file to load it +2. Click "Statistics" tab in Properties panel +3. View global and per-band statistics +4. Check SNR and saturation levels +5. Switch data types to compare statistics + +**Example 3: Batch Processing** +1. Open folder with 50+ files +2. Check all files (use "All" button) +3. Click "Export" button in file panel +4. Select CSV format and desired data types +5. Choose output directory +6. Click "Start Export" and monitor progress + +## Project Structure + +``` +gui/ +├── __init__.py # GUI module initialization +├── main_window.py # Main application window (3-column layout) +├── widgets/ # Custom widgets +│ ├── __init__.py +│ ├── plot_widget.py # Single spectral plot widget +│ ├── metadata_widget.py # Metadata display widget +│ ├── file_panel.py # File browser with tree and checkboxes +│ ├── multi_plot_canvas.py # Multi-plot layout canvas (NEW) +│ ├── properties_panel.py # Properties panel with 7 tabs (NEW) +│ ├── overlay_plot_widget.py # Overlay plot dialog (NEW) +│ ├── batch_export_dialog.py # Batch export dialog (NEW) +│ └── statistics_widget.py # Statistics display widget (NEW) +└── utils/ # Utility modules + ├── __init__.py + └── export_utils.py # Export functionality + +main.py # Application entry point +``` + +## Supported File Versions + +The GUI supports all ASD file versions (v1-v8) supported by the pyASDReader library. + +## Troubleshooting + +### GUI won't start + +- Ensure PyQt6 is installed: `pip install PyQt6` +- Check for Python version >= 3.8 + +### Plots not displaying + +- Ensure matplotlib is installed: `pip install matplotlib` +- Try refreshing the plot (F5) + +### Data type shows "Not Available" + +- Some data types require specific file versions +- Check the metadata to see file version +- Reflectance requires reference data (v2+) +- Absolute reflectance requires calibration data (v7+) + +## Development + +### Adding New Features + +The GUI is designed to be modular and extensible: + +- **New plot types**: Add to `PlotWidget.PLOT_TYPES` dictionary +- **New export formats**: Extend `ExportManager` class +- **New widgets**: Create in `gui/widgets/` directory +- **New properties tabs**: Add to `PropertiesPanel` class +- **New comparison modes**: Extend `OverlayPlotDialog` or create new dialog + +### Recent Improvements (Phase 4) + +**Priority 1: Internationalization** +- ✅ Replaced Chinese button text with English +- All UI elements now in English + +**Priority 2: Complete Features** +- ✅ Multi-plot export (export all subplots as single image) +- ✅ Compare mode (side-by-side viewing) +- ✅ Overlay mode (multiple spectra on one plot with statistics) +- ✅ Batch export dialog (CSV/TXT with progress tracking) + +**Priority 3: Enhanced Features** +- ✅ Statistics panel (global, per-band, and quality metrics) +- ⏳ Plot customization (colors, styles) - Partially implemented in overlay mode + +### Testing + +```bash +# Test imports +python -c "from gui.widgets import PlotWidget, MetadataWidget, FilePanel, OverlayPlotDialog, BatchExportDialog" + +# Syntax check +python -m py_compile gui/**/*.py + +# Test multi-plot functionality +python test_multiplot.py + +# Test GUI with sample data +python main.py tests/sample_data/v8sample/ +``` + +## License + +Same as pyASDReader library (MIT License) + +## Credits + +Built on top of the pyASDReader library by Kai Cao. diff --git a/gui/README_CN.md b/gui/README_CN.md new file mode 100644 index 0000000..5b41ba3 --- /dev/null +++ b/gui/README_CN.md @@ -0,0 +1,161 @@ +# pyASDReader GUI 图形界面 + +用于查看和分析 ASD 光谱文件的图形用户界面。 + +## 主要功能 + +- **文件管理** + - 打开单个 ASD 文件或浏览文件夹 + - 最近文件列表快速访问 + - 支持拖放(即将推出) + +- **数据可视化** + - 使用 matplotlib 的交互式光谱图 + - 支持多种数据类型: + - 数字量 (DN) + - 反射率(含一阶、二阶、三阶导数) + - 白参考 + - 绝对反射率 + - Log(1/R)(含导数) + - 辐射率(如果可用) + - 缩放、平移等 matplotlib 工具 + - 可自定义网格和图例 + +- **元数据显示** + - 完整的文件信息 + - 仪器详情 + - GPS 数据(如果可用) + - 光谱参数 + - 可用数据类型 + +- **导出功能** + - 导出数据为 CSV(可选择特定数据类型) + - 导出元数据为文本文件 + - 导出图表为 PNG、SVG 或 PDF + +## 安装 + +### 安装 GUI 依赖 + +```bash +# 安装 GUI 依赖 +pip install -e ".[gui]" + +# 或安装所有依赖 +pip install -e ".[all]" +``` + +### 依赖项 + +- PyQt6 >= 6.4.0 +- matplotlib >= 3.5.0 +- numpy >= 1.20.0(基础包已要求) + +## 使用方法 + +### 启动 GUI + +```bash +# 从项目根目录 +python main.py + +# 或者指定要打开的文件 +python main.py path/to/file.asd +``` + +### 使用界面 + +1. **打开文件** + - 点击 "Open File..." 按钮或使用文件菜单 (Ctrl+O) + - 或打开文件夹浏览多个文件 (Ctrl+Shift+O) + +2. **查看数据** + - 从下拉菜单选择数据类型 + - 图表自动更新 + - 使用 matplotlib 工具栏进行缩放/平移 + +3. **查看元数据** + - 文件信息显示在左侧面板 + - 根据需要展开/折叠各部分 + +4. **导出数据** + - 使用导出菜单保存数据或图表 + - 为 CSV 导出选择特定数据类型 + - 以各种格式导出图表 + +### 快捷键 + +- `Ctrl+O` - 打开文件 +- `Ctrl+Shift+O` - 打开文件夹 +- `Ctrl+W` - 关闭当前文件 +- `Ctrl+Q` - 退出应用程序 +- `F5` - 刷新图表 + +## 项目结构 + +``` +gui/ +├── __init__.py # GUI 模块初始化 +├── main_window.py # 主应用程序窗口 +├── widgets/ # 自定义组件 +│ ├── __init__.py +│ ├── plot_widget.py # 光谱图组件 +│ ├── metadata_widget.py # 元数据显示组件 +│ └── file_panel.py # 文件管理面板 +└── utils/ # 工具模块 + ├── __init__.py + └── export_utils.py # 导出功能 + +main.py # 应用程序入口点 +``` + +## 支持的文件版本 + +GUI 支持 pyASDReader 库支持的所有 ASD 文件版本(v1-v8)。 + +## 故障排除 + +### GUI 无法启动 + +- 确保已安装 PyQt6:`pip install PyQt6` +- 检查 Python 版本 >= 3.8 + +### 图表不显示 + +- 确保已安装 matplotlib:`pip install matplotlib` +- 尝试刷新图表 (F5) + +### 数据类型显示"不可用" + +- 某些数据类型需要特定文件版本 +- 查看元数据了解文件版本 +- 反射率需要参考数据 (v2+) +- 绝对反射率需要校准数据 (v7+) + +## 开发 + +### 添加新功能 + +GUI 设计为模块化和可扩展的: + +- **新图表类型**:添加到 `PlotWidget.PLOT_TYPES` 字典 +- **新导出格式**:扩展 `ExportManager` 类 +- **新组件**:在 `gui/widgets/` 目录创建 + +### 测试 + +```bash +# 测试导入 +python -c "from gui.widgets import PlotWidget, MetadataWidget, FilePanel" + +# 语法检查 +python -m py_compile gui/**/*.py +``` + +## 许可证 + +与 pyASDReader 库相同(MIT 许可证) + +## 致谢 + +基于 Kai Cao 的 pyASDReader 库构建。 diff --git a/gui/TOOLBAR_DESIGN_CN.md b/gui/TOOLBAR_DESIGN_CN.md new file mode 100644 index 0000000..ceb2ed6 --- /dev/null +++ b/gui/TOOLBAR_DESIGN_CN.md @@ -0,0 +1,485 @@ +# pyASDReader GUI - 工具栏设计文档 + +## 概述 + +本文档详细说明了 pyASDReader GUI 应用程序主工具栏的设计和实现方案。工具栏将提供常用操作的快速访问,提升整体用户工作流程效率。 + +## 当前状态 + +应用程序目前已有: +- **菜单栏**:文件、导出、视图、帮助菜单 +- **嵌入式控制栏**: + - `MultiPlotControlBar` - 布局和同步控制(位于画布区域内) + - `SubPlotControlBar` - 子图控制(单个图表控制) + - `FileTreeControlBar` - 树操作(全选、清除、刷新、展开、折叠) + - `SelectedFilesInfoBar` - 批量操作(对比、叠加、导出) +- **缺少主工具栏**,无法统一快速访问功能 + +## 设计目标 + +1. **可发现性**:通过可视化图标使功能易于发现 +2. **效率**:减少常用操作的点击次数 +3. **一致性**:遵循 Qt/PyQt6 工具栏最佳实践 +4. **灵活性**:允许自定义和状态持久化 +5. **可访问性**:支持键盘快捷键和工具提示 +6. **专业性**:现代、简洁的外观,适合科学软件 + +## 工具栏架构 + +### 位置 +- **位置**:MainWindow 顶部,菜单栏下方(默认) +- **可选位置**:底部、左侧、右侧(用户可配置) +- **可移动**:是,可以拖动到不同位置 +- **可浮动**:是,可以分离为浮动窗口 + +### 结构 + +工具栏将组织为 **8 个逻辑区域**,用视觉分隔符分隔: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ [文件] [视图] [绘图] [数据类型] [多文件] [同步] [显示] [设置] │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## 工具栏区域 + +### 1. 文件操作区域 + +**用途**:快速访问文件和文件夹操作 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 打开文件 | 📂 | Ctrl+O | 打开单个 ASD 文件 | +| 打开文件夹 | 📁 | Ctrl+Shift+O | 浏览文件夹树 | +| 最近文件 | 📋 | - | 最近文件下拉列表(最多 10 个)| +| 快速导出 | 💾 | Ctrl+E | 下拉菜单:CSV/PNG/SVG/PDF | +| 关闭文件 | ✕ | Ctrl+W | 关闭当前文件 | + +**实现说明**: +- 最近文件下拉列表使用 QMenu +- 快速导出显示格式选项子菜单 +- 未加载文件时,操作被禁用 + +### 2. 视图与布局区域 + +**用途**:控制面板可见性和绘图布局 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 布局选择器 | 🔲 | - | 下拉菜单:1×1, 1×2, 2×1, 2×2, 1×3, 3×1, 2×3 | +| 切换左侧面板 | ◀ | F9 | 显示/隐藏文件浏览器 | +| 切换右侧面板 | ▶ | F10 | 显示/隐藏属性面板 | +| 切换工具栏 | ⬍ | F11 | 显示/隐藏子图工具栏 | +| 全屏模式 | ⛶ | Alt+F11 | 最大化画布区域 | + +**实现说明**: +- 布局选择器直接控制 `MultiPlotCanvas.set_layout_mode()` +- 面板切换使用 QSplitter 的 widget 可见性 +- 状态保存在 QSettings 中 + +### 3. 绘图操作区域 + +**用途**:常用绘图操作 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 刷新全部 | 🔄 | F5 | 重绘所有图表 | +| 清除全部 | 🗑️ | Ctrl+Delete | 清除所有子图 | +| 自动缩放 | 🔍 | Ctrl+0 | 重置缩放以适应数据 | +| 重置视图 | 🏠 | Home | 重置为默认视图 | +| 截图 | 📷 | Ctrl+Shift+S | 快速捕获到剪贴板 | + +**实现说明**: +- 自动缩放对所有子图调用 `ax.autoscale()` +- 截图将当前视图复制到剪贴板 +- 仅当存在图表时操作才启用 + +### 4. 数据类型选择器区域 + +**用途**:全局和定向数据类型切换 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 数据类型下拉 | 📊 | - | 选择:DN、反射率、导数、Log(1/R) 等 | +| 应用到全部 | ⇒⇒ | Ctrl+Shift+A | 将选定类型应用到所有子图 | +| 应用到当前 | ⇒ | Ctrl+A | 应用到当前焦点子图 | + +**实现说明**: +- 数据类型列表与 `SubPlotControlBar` 组合框匹配 +- 应用到全部遍历所有子图 +- 应用到当前仅针对焦点子图 + +### 5. 多文件操作区域 + +**用途**:对多个选定文件的操作 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 对比 | ⚖️ | Ctrl+M | 并排加载选中的文件 | +| 叠加 | 📈 | Ctrl+Shift+M | 在一张图上绘制所有光谱 | +| 统计 | 📊 | Ctrl+T | 显示统计对比 | +| 批量导出 | 📦 | Ctrl+B | 导出多个文件 | + +**实现说明**: +- 仅当选中 2 个以上文件时启用 +- 对比触发自动布局选择 +- 叠加打开 `OverlayPlotDialog` +- 批量导出打开 `BatchExportDialog` + +### 6. 同步控制区域 + +**用途**:子图间的同步设置 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 同步缩放 | 🔗 | - | 切换缩放同步 | +| 同步光标 | ➕ | - | 切换光标同步 | +| 同步平移 | ✋ | - | 切换平移同步 | +| 全部锁定 | 🔒 | Ctrl+L | 锁定所有子图交互 | + +**实现说明**: +- 可选中的 QAction(切换按钮) +- 状态与 `MultiPlotControlBar` 同步 +- 全部锁定禁用所有子图交互 + +### 7. 显示选项区域 + +**用途**:视觉显示偏好设置 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 网格 | ⊞ | Ctrl+G | 切换所有图表上的网格 | +| 图例 | 📝 | Ctrl+Shift+L | 切换图例可见性 | +| 主题 | 🎨 | - | 下拉菜单:浅色/深色/系统 | +| 工具栏大小 | 📏 | - | 下拉菜单:小/中/大 | + +**实现说明**: +- 网格/图例应用于所有当前和未来的图表 +- 主题更改整个应用程序的外观 +- 工具栏大小调整图标尺寸(16/24/32px) + +### 8. 设置与帮助区域 + +**用途**:配置和帮助 + +| 操作 | 图标 | 快捷键 | 描述 | +|------|------|--------|------| +| 快速帮助 | ❓ | F1 | 显示键盘快捷键 | +| 设置 | ⚙️ | - | 打开首选项对话框 | +| 关于 | ℹ️ | - | 显示关于对话框 | + +**实现说明**: +- 快速帮助显示包含快捷键参考的弹出窗口 +- 设置对话框用于持久化首选项 +- 关于重用现有对话框 + +## 实现方案 + +### 阶段 1:核心基础设施 + +**需要创建的文件**: + +1. **`gui/widgets/main_toolbar.py`** + - `MainToolBar` 类(继承 QToolBar) + - 区域创建方法 + - 操作定义 + - 信号/槽连接 + +2. **`gui/utils/icon_manager.py`** + - 图标资源管理 + - 主题支持(浅色/深色) + - 图标不可用时的回退机制 + +3. **`gui/widgets/preferences_dialog.py`** + - 设置配置 UI + - 工具栏自定义选项 + +**需要修改的文件**: + +1. **`gui/main_window.py`** + - 在 `init_ui()` 中添加工具栏实例化 + - 连接工具栏信号到现有槽 + - 将工具栏添加到窗口布局 + +2. **`gui/widgets/multi_plot_canvas.py`** + - 暴露工具栏操作的方法 + - 添加状态变化的信号发射 + +### 阶段 2:操作实现 + +**核心操作**: +```python +class MainToolBar(QToolBar): + def __init__(self, parent=None): + super().__init__("主工具栏", parent) + self._create_file_section() + self._create_view_section() + self._create_plot_section() + self._create_datatype_section() + self._create_multifile_section() + self._create_sync_section() + self._create_display_section() + self._create_settings_section() + + def _create_file_section(self): + # 打开文件操作 + self.open_file_action = QAction( + QIcon("icons/open_file.png"), + "打开文件", + self + ) + self.open_file_action.setShortcut("Ctrl+O") + self.open_file_action.setToolTip("打开 ASD 文件 (Ctrl+O)") + self.open_file_action.setStatusTip("打开单个 ASD 文件") + self.addAction(self.open_file_action) + + # ... 更多操作 + self.addSeparator() # 区域分隔符 +``` + +### 阶段 3:状态管理 + +**持久化设置**: +```python +# 保存工具栏状态 +settings = QSettings("pyASDReader", "GUI") +settings.setValue("toolbar/position", self.toolbar.orientation()) +settings.setValue("toolbar/visible", self.toolbar.isVisible()) +settings.setValue("toolbar/icon_size", self.toolbar.iconSize()) +settings.setValue("toolbar/movable", self.toolbar.isMovable()) + +# 恢复工具栏状态 +orientation = settings.value("toolbar/position", Qt.Horizontal) +visible = settings.value("toolbar/visible", True, type=bool) +icon_size = settings.value("toolbar/icon_size", QSize(24, 24)) +``` + +### 阶段 4:图标资源 + +**图标策略**: +1. **首选**:尽可能使用 Qt 标准图标 +2. **次选**:包含自定义图标集(SVG 格式) +3. **回退**:使用 Unicode 字符/emoji 配合样式文本 + +**图标来源**: +- Qt 标准图标(`QStyle.StandardPixmap`) +- Material Design Icons +- Font Awesome(通过字体或 SVG) +- 专门操作的自定义 SVG 图标 + +**图标管理器示例**: +```python +class IconManager: + @staticmethod + def get_icon(name, theme="light"): + # 尝试 Qt 标准图标 + if hasattr(QStyle.StandardPixmap, f"SP_{name}"): + return qApp.style().standardIcon( + getattr(QStyle.StandardPixmap, f"SP_{name}") + ) + + # 尝试自定义图标文件 + icon_path = f"gui/resources/icons/{theme}/{name}.svg" + if os.path.exists(icon_path): + return QIcon(icon_path) + + # 回退到文本 + return QIcon() +``` + +## 与现有代码的集成 + +### 连接到主窗口 + +```python +# 在 main_window.py 中 +def init_ui(self): + # ... 现有代码 ... + + # 创建工具栏 + self.main_toolbar = MainToolBar(self) + self.addToolBar(Qt.TopToolBarArea, self.main_toolbar) + + # 连接信号 + self.main_toolbar.open_file_requested.connect( + self.file_panel.open_file_dialog + ) + self.main_toolbar.layout_changed.connect( + self.multi_plot_canvas.set_layout_mode + ) + self.main_toolbar.data_type_apply_all.connect( + self._apply_data_type_to_all + ) + # ... 更多连接 ... +``` + +### 与 MultiPlotCanvas 集成 + +```python +# MultiPlotCanvas 中的新方法 +def apply_data_type_to_all(self, data_type: str): + """将数据类型应用到所有子图""" + for subplot in self.subplots: + if subplot.current_file: + subplot.load_data(subplot.current_file, data_type) + +def toggle_grid_all(self, enabled: bool): + """切换所有子图的网格""" + for subplot in self.subplots: + subplot.ax.grid(enabled) + subplot.canvas.draw() +``` + +## 自定义功能 + +### 用户可配置选项 + +1. **工具栏可见性**:显示/隐藏整个工具栏 +2. **区域可见性**:显示/隐藏单个区域 +3. **图标大小**:小(16px)、中(24px)、大(32px) +4. **位置**:顶部、底部、左侧、右侧、浮动 +5. **样式**:仅图标、仅文本、图标+文本 +6. **主题**:浅色、深色、系统 + +### 首选项对话框 + +``` +┌─ 工具栏首选项 ─────────────────────────┐ +│ │ +│ ☑ 显示工具栏 │ +│ │ +│ 位置: [顶部 ▼] │ +│ 图标大小: [中等 (24px) ▼] │ +│ 样式: [图标+文本 ▼] │ +│ 主题: [系统 ▼] │ +│ │ +│ 可见区域: │ +│ ☑ 文件操作 │ +│ ☑ 视图与布局 │ +│ ☑ 绘图操作 │ +│ ☑ 数据类型选择器 │ +│ ☑ 多文件操作 │ +│ ☑ 同步控制 │ +│ ☑ 显示选项 │ +│ ☑ 设置与帮助 │ +│ │ +│ [恢复默认] [确定] [取消] │ +└────────────────────────────────────────┘ +``` + +## 测试策略 + +### 单元测试 +- 测试每个操作的启用/禁用逻辑 +- 测试信号发射 +- 测试状态持久化 + +### 集成测试 +- 测试工具栏与主窗口的集成 +- 测试实际文件操作的操作执行 +- 测试布局模式切换 + +### 用户测试 +- 验证键盘快捷键正确工作 +- 验证工具提示正确显示 +- 测试工具栏拖放/停靠行为 +- 验证浅色/深色主题中的图标可见性 + +## 可访问性 + +### 键盘导航 +- 所有操作都可通过键盘快捷键访问 +- Tab 键在工具栏项目间导航 +- Enter/空格键激活操作 + +### 屏幕阅读器 +- 为屏幕阅读器提供适当的工具提示文本 +- 状态栏集成的状态提示 +- 所有操作的可访问名称 + +### 高对比度 +- 图标设计在高对比度模式下工作 +- 文本标签保持可见 +- 焦点指示器清晰可见 + +## 未来增强 + +### 阶段 2 功能 +1. **自定义操作构建器**:允许用户创建自定义工具栏按钮 +2. **宏录制**:记录和重放操作序列 +3. **手势支持**:触控/触控板手势操作 +4. **上下文感知工具栏**:根据活动面板更改工具栏 + +### 高级功能 +1. **插件支持**:允许插件添加工具栏项目 +2. **工作区配置文件**:保存/加载工具栏配置 +3. **快速命令面板**:Ctrl+P 风格命令搜索 +4. **工具栏组**:为不同工作流创建多个工具栏 + +## 设计参考 + +### 类似应用程序的灵感 +- **QGIS**:具有清晰区域的模块化工具栏 +- **MATLAB**:数据类型选择器和绘图控制 +- **OriginPro**:科学绘图工具栏设计 +- **ImageJ**:多窗口协调工具 + +### Qt 最佳实践 +- 对所有工具栏项目使用 QAction +- 为互斥操作实现 QActionGroup +- 使用 QToolButton 进行下拉菜单 +- 遵循 Qt 样式指南进行图标设计 + +## 总结 + +此工具栏设计提供: +- **快速访问**:一键访问 40 多个常用操作 +- **视觉清晰**:带有工具提示和键盘快捷键的图标 +- **灵活性**:可自定义位置、大小和可见性 +- **集成**:与现有 UI 组件无缝集成 +- **可扩展性**:易于添加新操作和区域 + +该实现遵循 PyQt6 最佳实践,并与现有代码库架构保持一致。 + +## 附录:快捷键参考表 + +### 文件操作 +- `Ctrl+O` - 打开文件 +- `Ctrl+Shift+O` - 打开文件夹 +- `Ctrl+E` - 快速导出 +- `Ctrl+W` - 关闭文件 +- `Ctrl+Q` - 退出应用 + +### 视图控制 +- `F9` - 切换左侧面板 +- `F10` - 切换右侧面板 +- `F11` - 切换子图工具栏 +- `Alt+F11` - 全屏模式 + +### 绘图操作 +- `F5` - 刷新全部 +- `Ctrl+Delete` - 清除全部 +- `Ctrl+0` - 自动缩放 +- `Home` - 重置视图 +- `Ctrl+Shift+S` - 截图 + +### 数据类型 +- `Ctrl+A` - 应用到当前 +- `Ctrl+Shift+A` - 应用到全部 + +### 多文件操作 +- `Ctrl+M` - 对比模式 +- `Ctrl+Shift+M` - 叠加模式 +- `Ctrl+T` - 统计视图 +- `Ctrl+B` - 批量导出 + +### 同步控制 +- `Ctrl+L` - 全部锁定/解锁 + +### 显示选项 +- `Ctrl+G` - 切换网格 +- `Ctrl+Shift+L` - 切换图例 + +### 帮助 +- `F1` - 快速帮助 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..cd2d7dc --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,7 @@ +""" +GUI module for pyASDReader + +This module provides a PyQt6-based graphical user interface for viewing and analyzing ASD spectral files. +""" + +__version__ = "1.0.0" diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..cad74e6 --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,461 @@ +""" +Main window for pyASDReader GUI application +""" + +import sys +import os +from typing import List +from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QSplitter, QMenuBar, QMenu, QFileDialog, + QMessageBox, QStatusBar, QDialog, QDialogButtonBox, + QListWidget, QLabel, QCheckBox, QScrollArea) +from PyQt6.QtCore import Qt, QSettings +from PyQt6.QtGui import QAction + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from pyASDReader import ASDFile +from gui.widgets import PlotWidget, MetadataWidget, FilePanel +from gui.widgets.properties_panel import PropertiesPanel +from gui.widgets.multi_plot_canvas import MultiPlotCanvas, LayoutMode +from gui.utils import ExportManager + + +class DataTypeSelectionDialog(QDialog): + """Dialog for selecting data types to export""" + + def __init__(self, available_types, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Data Types to Export") + self.available_types = available_types + self.selected_types = [] + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + label = QLabel("Select the data types you want to export:") + layout.addWidget(label) + + # Create checkboxes for each data type + self.checkboxes = {} + for display_name, attr_name in self.available_types.items(): + checkbox = QCheckBox(display_name) + checkbox.setChecked(True) # Default to all selected + self.checkboxes[attr_name] = checkbox + layout.addWidget(checkbox) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def get_selected_types(self): + """Get list of selected data type attribute names""" + selected = [] + for attr_name, checkbox in self.checkboxes.items(): + if checkbox.isChecked(): + selected.append(attr_name) + return selected + + +class MainWindow(QMainWindow): + """ + Main application window for pyASDReader GUI + """ + + def __init__(self): + super().__init__() + self.current_asd_file = None + self.settings = QSettings("pyASDReader", "GUI") + self.init_ui() + self.load_settings() + + def init_ui(self): + """Initialize the user interface - Three-column layout""" + self.setWindowTitle("pyASDReader - ASD File Viewer") + self.setGeometry(100, 100, 1600, 900) + + # Create central widget with splitter layout + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QHBoxLayout(central_widget) + + # Create main splitter (horizontal) - Three columns + main_splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left panel: File browser + self.file_panel = FilePanel() + self.file_panel.file_selected.connect(self.load_asd_file) + self.file_panel.files_checked.connect(self._on_files_checked) + main_splitter.addWidget(self.file_panel) + + # Center panel: Multi-plot canvas (main work area) + self.multi_plot_canvas = MultiPlotCanvas() + main_splitter.addWidget(self.multi_plot_canvas) + + # Keep reference to plot_widget for compatibility + # Single plot mode uses first subplot + self.plot_widget = None + + # Right panel: Properties panel + self.properties_panel = PropertiesPanel() + main_splitter.addWidget(self.properties_panel) + + # Set initial sizes for three columns + # Left: 280px, Center: 800px (flexible), Right: 320px + main_splitter.setSizes([280, 800, 320]) + + # Allow left and right panels to collapse + main_splitter.setCollapsible(0, True) # Left panel can collapse + main_splitter.setCollapsible(1, False) # Center panel cannot collapse + main_splitter.setCollapsible(2, True) # Right panel can collapse + + main_layout.addWidget(main_splitter) + + # Create status bar + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Ready") + + # Create menu bar (after widgets are created) + self.create_menu_bar() + + def create_menu_bar(self): + """Create the application menu bar""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("&File") + + open_action = QAction("&Open File...", self) + open_action.setShortcut("Ctrl+O") + open_action.triggered.connect(self.file_panel.open_file_dialog) + file_menu.addAction(open_action) + + open_folder_action = QAction("Open &Folder...", self) + open_folder_action.setShortcut("Ctrl+Shift+O") + open_folder_action.triggered.connect(self.file_panel.open_folder_dialog) + file_menu.addAction(open_folder_action) + + file_menu.addSeparator() + + close_action = QAction("&Close File", self) + close_action.setShortcut("Ctrl+W") + close_action.triggered.connect(self.close_current_file) + file_menu.addAction(close_action) + + file_menu.addSeparator() + + exit_action = QAction("E&xit", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Export menu + export_menu = menubar.addMenu("&Export") + + export_csv_action = QAction("Export Data to &CSV...", self) + export_csv_action.triggered.connect(self.export_to_csv) + export_menu.addAction(export_csv_action) + + export_metadata_action = QAction("Export &Metadata to TXT...", self) + export_metadata_action.triggered.connect(self.export_metadata) + export_menu.addAction(export_metadata_action) + + export_menu.addSeparator() + + export_plot_png_action = QAction("Export Plot as &PNG...", self) + export_plot_png_action.triggered.connect(lambda: self.export_plot('png')) + export_menu.addAction(export_plot_png_action) + + export_plot_svg_action = QAction("Export Plot as &SVG...", self) + export_plot_svg_action.triggered.connect(lambda: self.export_plot('svg')) + export_menu.addAction(export_plot_svg_action) + + export_plot_pdf_action = QAction("Export Plot as P&DF...", self) + export_plot_pdf_action.triggered.connect(lambda: self.export_plot('pdf')) + export_menu.addAction(export_plot_pdf_action) + + # View menu + view_menu = menubar.addMenu("&View") + + refresh_action = QAction("&Refresh Plots", self) + refresh_action.setShortcut("F5") + refresh_action.triggered.connect(self._refresh_all_plots) + view_menu.addAction(refresh_action) + + clear_plot_action = QAction("&Clear All Plots", self) + clear_plot_action.triggered.connect(self._clear_all_plots) + view_menu.addAction(clear_plot_action) + + # Help menu + help_menu = menubar.addMenu("&Help") + + about_action = QAction("&About", self) + about_action.triggered.connect(self.show_about_dialog) + help_menu.addAction(about_action) + + def load_asd_file(self, filepath): + """ + Load an ASD file (single file mode) + + Args: + filepath: Path to the ASD file + """ + try: + self.status_bar.showMessage(f"Loading {os.path.basename(filepath)}...") + + # Load the file + asd_file = ASDFile(filepath) + + # Update current file + self.current_asd_file = asd_file + + # Load to first subplot + if self.multi_plot_canvas.subplots: + self.multi_plot_canvas.subplots[0].load_data(asd_file, 'reflectance') + + # Update properties panel + self.properties_panel.set_asd_file(asd_file) + + self.status_bar.showMessage(f"Loaded: {os.path.basename(filepath)}", 5000) + + except Exception as e: + QMessageBox.critical( + self, + "Error Loading File", + f"Failed to load ASD file:\n{filepath}\n\nError: {str(e)}" + ) + self.status_bar.showMessage("Error loading file", 5000) + + def _on_files_checked(self, files: List[str]): + """ + Handle multiple files checked in file browser + + Automatically switch to appropriate layout based on file count + """ + if not files: + return + + num_files = len(files) + + # Select appropriate layout + if num_files == 1: + layout_mode = LayoutMode.SINGLE + elif num_files == 2: + layout_mode = LayoutMode.HORIZONTAL_2 + elif num_files == 3: + layout_mode = LayoutMode.HORIZONTAL_3 + elif num_files == 4: + layout_mode = LayoutMode.GRID_2x2 + elif num_files <= 6: + layout_mode = LayoutMode.GRID_2x3 + else: + # Too many files, only load first 6 + files = files[:6] + layout_mode = LayoutMode.GRID_2x3 + QMessageBox.information( + self, + "Too Many Files", + f"Only first 6 files will be displayed.\nTotal selected: {num_files}" + ) + + # Switch layout and load files + self.multi_plot_canvas.set_layout_mode(layout_mode) + self.multi_plot_canvas.load_files_to_subplots(files, 'reflectance') + + self.status_bar.showMessage(f"Loaded {len(files)} files in {layout_mode.value} layout", 5000) + + def close_current_file(self): + """Close the currently loaded file""" + self.current_asd_file = None + self.multi_plot_canvas.clear_all() + self.properties_panel.clear() + self.status_bar.showMessage("File closed", 3000) + + def _refresh_all_plots(self): + """Refresh all plots""" + for subplot in self.multi_plot_canvas.subplots: + if subplot.ax: + subplot.canvas.draw() + self.status_bar.showMessage("Plots refreshed", 2000) + + def _clear_all_plots(self): + """Clear all plots""" + self.multi_plot_canvas.clear_all() + self.status_bar.showMessage("All plots cleared", 2000) + + def export_to_csv(self): + """Export current data to CSV""" + if self.current_asd_file is None: + QMessageBox.warning(self, "No File Loaded", + "Please load an ASD file first.") + return + + # Get available data types + available_types = ExportManager.get_available_export_data_types(self.current_asd_file) + + if not available_types: + QMessageBox.warning(self, "No Data Available", + "No exportable data available in the current file.") + return + + # Show data type selection dialog + dialog = DataTypeSelectionDialog(available_types, self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + selected_types = dialog.get_selected_types() + + if not selected_types: + QMessageBox.warning(self, "No Data Selected", + "Please select at least one data type to export.") + return + + # Get save file path + filepath, _ = QFileDialog.getSaveFileName( + self, + "Export to CSV", + f"{os.path.splitext(self.current_asd_file.filename)[0]}_export.csv", + "CSV Files (*.csv);;All Files (*.*)" + ) + + if not filepath: + return + + try: + ExportManager.export_to_csv(self.current_asd_file, filepath, selected_types) + QMessageBox.information(self, "Export Successful", + f"Data exported successfully to:\n{filepath}") + self.status_bar.showMessage(f"Exported to {os.path.basename(filepath)}", 5000) + + except Exception as e: + QMessageBox.critical(self, "Export Failed", + f"Failed to export data:\n{str(e)}") + + def export_metadata(self): + """Export metadata to text file""" + if self.current_asd_file is None: + QMessageBox.warning(self, "No File Loaded", + "Please load an ASD file first.") + return + + filepath, _ = QFileDialog.getSaveFileName( + self, + "Export Metadata", + f"{os.path.splitext(self.current_asd_file.filename)[0]}_metadata.txt", + "Text Files (*.txt);;All Files (*.*)" + ) + + if not filepath: + return + + try: + ExportManager.export_metadata_to_txt(self.current_asd_file, filepath) + QMessageBox.information(self, "Export Successful", + f"Metadata exported successfully to:\n{filepath}") + self.status_bar.showMessage(f"Exported metadata to {os.path.basename(filepath)}", 5000) + + except Exception as e: + QMessageBox.critical(self, "Export Failed", + f"Failed to export metadata:\n{str(e)}") + + def export_plot(self, format_type): + """ + Export current plots + + Args: + format_type: File format ('png', 'svg', 'pdf') + """ + if not self.multi_plot_canvas.subplots: + QMessageBox.warning(self, "No Plot", + "No plots to export.") + return + + format_upper = format_type.upper() + filter_str = f"{format_upper} Files (*.{format_type});;All Files (*.*)" + + # Determine if we have multiple subplots with data + active_subplots = [sp for sp in self.multi_plot_canvas.subplots if sp.current_file is not None] + default_name = "multiplot" if len(active_subplots) > 1 else "plot" + + filepath, _ = QFileDialog.getSaveFileName( + self, + f"Export Plots as {format_upper}", + f"{default_name}.{format_type}", + filter_str + ) + + if not filepath: + return + + try: + # Export all subplots if more than one has data + if len(active_subplots) > 1: + self.multi_plot_canvas.export_all_subplots(filepath, dpi=300) + elif len(active_subplots) == 1: + # Export single subplot + active_subplots[0].figure.savefig(filepath, dpi=300, bbox_inches='tight') + else: + QMessageBox.warning(self, "No Data", + "No plot data to export.") + return + + QMessageBox.information(self, "Export Successful", + f"Plot(s) exported successfully to:\n{filepath}") + self.status_bar.showMessage(f"Exported plot to {os.path.basename(filepath)}", 5000) + + except Exception as e: + QMessageBox.critical(self, "Export Failed", + f"Failed to export plot:\n{str(e)}") + + def show_about_dialog(self): + """Show about dialog""" + about_text = """ +

pyASDReader GUI

+

A graphical user interface for viewing and analyzing ASD spectral files.

+

Version: 1.0.0

+

Features:

+ +

Library: pyASDReader

+

Author: Kai Cao

+ """ + + QMessageBox.about(self, "About pyASDReader GUI", about_text) + + def load_settings(self): + """Load application settings""" + # Restore last opened directory + last_dir = self.settings.value("last_directory", "") + if last_dir and os.path.isdir(last_dir): + self.file_panel.load_directory(last_dir) + + # Restore window geometry + geometry = self.settings.value("geometry") + if geometry: + self.restoreGeometry(geometry) + + def save_settings(self): + """Save application settings""" + # Save last opened directory + if hasattr(self.file_panel.tree_widget, 'current_root') and self.file_panel.tree_widget.current_root: + self.settings.setValue("last_directory", self.file_panel.tree_widget.current_root) + + # Save window geometry + self.settings.setValue("geometry", self.saveGeometry()) + + def closeEvent(self, event): + """Handle window close event""" + self.save_settings() + event.accept() diff --git a/gui/utils/__init__.py b/gui/utils/__init__.py new file mode 100644 index 0000000..b17b7e5 --- /dev/null +++ b/gui/utils/__init__.py @@ -0,0 +1,7 @@ +""" +Utility functions for GUI operations +""" + +from .export_utils import ExportManager + +__all__ = ['ExportManager'] diff --git a/gui/utils/export_utils.py b/gui/utils/export_utils.py new file mode 100644 index 0000000..4856da1 --- /dev/null +++ b/gui/utils/export_utils.py @@ -0,0 +1,288 @@ +""" +Export utilities for ASD data and plots +""" + +import csv +import numpy as np +from datetime import datetime + + +class ExportManager: + """ + Manager class for exporting ASD data to various formats + """ + + @staticmethod + def export_to_csv(asd_file, filepath, data_types=None): + """ + Export ASD data to CSV file + + Args: + asd_file: ASDFile object + filepath: Output file path + data_types: List of data type names to export (e.g., ['reflectance', 'digitalNumber']) + If None, exports all available data types + """ + if asd_file is None or asd_file.wavelengths is None: + raise ValueError("No valid ASD file data to export") + + # Determine which data types to export + available_types = { + 'wavelengths': asd_file.wavelengths, + 'digitalNumber': getattr(asd_file, 'digitalNumber', None), + 'reflectance': getattr(asd_file, 'reflectance', None), + 'reflectance1stDeriv': getattr(asd_file, 'reflectance1stDeriv', None), + 'reflectance2ndDeriv': getattr(asd_file, 'reflectance2ndDeriv', None), + 'reflectance3rdDeriv': getattr(asd_file, 'reflectance3rdDeriv', None), + 'whiteReference': getattr(asd_file, 'whiteReference', None), + 'absoluteReflectance': getattr(asd_file, 'absoluteReflectance', None), + 'log1R': getattr(asd_file, 'log1R', None), + 'log1R1stDeriv': getattr(asd_file, 'log1R1stDeriv', None), + 'log1R2ndDeriv': getattr(asd_file, 'log1R2ndDeriv', None), + 'radiance': getattr(asd_file, 'radiance', None), + } + + # Filter out None values + available_types = {k: v for k, v in available_types.items() if v is not None} + + # If specific data types requested, filter + if data_types: + export_types = {k: v for k, v in available_types.items() + if k in data_types or k == 'wavelengths'} + else: + export_types = available_types + + if 'wavelengths' not in export_types: + raise ValueError("Wavelength data is required for export") + + # Write CSV + with open(filepath, 'w', newline='') as csvfile: + # Write header comment + csvfile.write(f"# ASD File Export\n") + csvfile.write(f"# Source: {getattr(asd_file, 'filename', 'Unknown')}\n") + csvfile.write(f"# Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + + if hasattr(asd_file, 'metadata') and asd_file.metadata: + metadata = asd_file.metadata + if hasattr(metadata, 'instrumentModel'): + csvfile.write(f"# Instrument: {metadata.instrumentModel}\n") + if hasattr(metadata, 'channels'): + csvfile.write(f"# Channels: {metadata.channels}\n") + + csvfile.write("#\n") + + # Write data + writer = csv.writer(csvfile) + + # Header row + header = list(export_types.keys()) + writer.writerow(header) + + # Data rows + num_rows = len(export_types['wavelengths']) + for i in range(num_rows): + row = [] + for col_name in header: + data = export_types[col_name] + if i < len(data): + value = data[i] + # Format numpy values + if isinstance(value, (np.integer, np.floating)): + value = float(value) + row.append(value) + else: + row.append('') + writer.writerow(row) + + @staticmethod + def export_metadata_to_txt(asd_file, filepath): + """ + Export ASD file metadata to text file + + Args: + asd_file: ASDFile object + filepath: Output file path + """ + if asd_file is None: + raise ValueError("No valid ASD file to export") + + with open(filepath, 'w') as f: + f.write("="*60 + "\n") + f.write("ASD File Metadata Report\n") + f.write("="*60 + "\n\n") + + f.write(f"Export Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") + + # File Attributes + f.write("-" * 60 + "\n") + f.write("File Attributes\n") + f.write("-" * 60 + "\n") + + if hasattr(asd_file, 'filename'): + f.write(f"File Name: {asd_file.filename}\n") + + if hasattr(asd_file, 'filepath'): + f.write(f"File Path: {asd_file.filepath}\n") + + if hasattr(asd_file, 'filesize'): + size_mb = asd_file.filesize / (1024 * 1024) + f.write(f"File Size: {asd_file.filesize:,} bytes ({size_mb:.2f} MB)\n") + + if hasattr(asd_file, 'creation_time'): + f.write(f"Creation Time: {asd_file.creation_time}\n") + + if hasattr(asd_file, 'modification_time'): + f.write(f"Modification Time: {asd_file.modification_time}\n") + + if hasattr(asd_file, 'hashMD5'): + f.write(f"MD5: {asd_file.hashMD5}\n") + + if hasattr(asd_file, 'hashSHA265'): + f.write(f"SHA256: {asd_file.hashSHA265}\n") + + # ASD Metadata + if hasattr(asd_file, 'metadata') and asd_file.metadata: + f.write("\n" + "-" * 60 + "\n") + f.write("ASD Metadata\n") + f.write("-" * 60 + "\n") + + metadata = asd_file.metadata + + if hasattr(asd_file, 'asdFileVersion'): + f.write(f"ASD File Version: {asd_file.asdFileVersion}\n") + + if hasattr(metadata, 'instrumentModel'): + f.write(f"Instrument Model: {metadata.instrumentModel}\n") + + if hasattr(metadata, 'instrumentType'): + f.write(f"Instrument Type: {metadata.instrumentType}\n") + + if hasattr(metadata, 'channels'): + f.write(f"Channels: {metadata.channels}\n") + + if hasattr(metadata, 'channel1Wavelength'): + f.write(f"Start Wavelength: {metadata.channel1Wavelength} nm\n") + + if hasattr(metadata, 'wavelengthStep'): + f.write(f"Wavelength Step: {metadata.wavelengthStep} nm\n") + + if hasattr(metadata, 'splice1_wavelength'): + f.write(f"Splice 1: {metadata.splice1_wavelength} nm\n") + + if hasattr(metadata, 'splice2_wavelength'): + f.write(f"Splice 2: {metadata.splice2_wavelength} nm\n") + + if hasattr(metadata, 'intergrationTime_ms'): + f.write(f"Integration Time: {metadata.intergrationTime_ms}\n") + + if hasattr(metadata, 'swir1Gain'): + f.write(f"SWIR1 Gain: {metadata.swir1Gain}\n") + + if hasattr(metadata, 'swir2Gain'): + f.write(f"SWIR2 Gain: {metadata.swir2Gain}\n") + + if hasattr(metadata, 'spectrumTime'): + f.write(f"Spectrum Time: {metadata.spectrumTime}\n") + + if hasattr(metadata, 'referenceTime'): + f.write(f"Reference Time: {metadata.referenceTime}\n") + + if hasattr(metadata, 'dataType'): + f.write(f"Data Type: {metadata.dataType}\n") + + # GPS Data + if hasattr(metadata, 'gpsData') and metadata.gpsData: + f.write("\n GPS Information:\n") + gps = metadata.gpsData + + if hasattr(gps, 'latitude'): + f.write(f" Latitude: {gps.latitude:.6f}\n") + + if hasattr(gps, 'longitude'): + f.write(f" Longitude: {gps.longitude:.6f}\n") + + if hasattr(gps, 'altitude'): + f.write(f" Altitude: {gps.altitude} m\n") + + # Spectral Information + if hasattr(asd_file, 'wavelengths') and asd_file.wavelengths is not None: + f.write("\n" + "-" * 60 + "\n") + f.write("Spectral Information\n") + f.write("-" * 60 + "\n") + + wavelengths = asd_file.wavelengths + f.write(f"Number of Bands: {len(wavelengths)}\n") + f.write(f"Wavelength Range: {wavelengths[0]:.2f} - {wavelengths[-1]:.2f} nm\n") + + if len(wavelengths) > 1: + f.write(f"Spectral Resolution: {wavelengths[1] - wavelengths[0]:.2f} nm\n") + + # Available Data Types + f.write("\n" + "-" * 60 + "\n") + f.write("Available Data Types\n") + f.write("-" * 60 + "\n") + + data_types = { + 'Digital Number': 'digitalNumber', + 'White Reference': 'whiteReference', + 'Reflectance': 'reflectance', + 'Reflectance (1st Deriv)': 'reflectance1stDeriv', + 'Reflectance (2nd Deriv)': 'reflectance2ndDeriv', + 'Reflectance (3rd Deriv)': 'reflectance3rdDeriv', + 'Absolute Reflectance': 'absoluteReflectance', + 'Log(1/R)': 'log1R', + 'Log(1/R) (1st Deriv)': 'log1R1stDeriv', + 'Log(1/R) (2nd Deriv)': 'log1R2ndDeriv', + 'Radiance': 'radiance', + } + + for display_name, attr_name in data_types.items(): + try: + data = getattr(asd_file, attr_name, None) + status = "Available" if data is not None else "Not Available" + f.write(f"{display_name:30} {status}\n") + except Exception: + f.write(f"{display_name:30} Error\n") + + f.write("\n" + "="*60 + "\n") + f.write("End of Report\n") + f.write("="*60 + "\n") + + @staticmethod + def get_available_export_data_types(asd_file): + """ + Get list of available data types for export + + Args: + asd_file: ASDFile object + + Returns: + dict: Dictionary of available data types {display_name: attr_name} + """ + if asd_file is None: + return {} + + data_types = { + 'Digital Number': 'digitalNumber', + 'White Reference': 'whiteReference', + 'Reflectance': 'reflectance', + 'Reflectance (1st Derivative)': 'reflectance1stDeriv', + 'Reflectance (2nd Derivative)': 'reflectance2ndDeriv', + 'Reflectance (3rd Derivative)': 'reflectance3rdDeriv', + 'Absolute Reflectance': 'absoluteReflectance', + 'Log(1/R)': 'log1R', + 'Log(1/R) (1st Derivative)': 'log1R1stDeriv', + 'Log(1/R) (2nd Derivative)': 'log1R2ndDeriv', + 'Radiance': 'radiance', + } + + available = {} + for display_name, attr_name in data_types.items(): + try: + data = getattr(asd_file, attr_name, None) + if data is not None: + available[display_name] = attr_name + except Exception: + pass + + return available diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py new file mode 100644 index 0000000..5ed0d54 --- /dev/null +++ b/gui/widgets/__init__.py @@ -0,0 +1,11 @@ +""" +GUI widgets for pyASDReader application +""" + +from .plot_widget import PlotWidget +from .metadata_widget import MetadataWidget +from .file_panel import FilePanel +from .overlay_plot_widget import OverlayPlotDialog +from .batch_export_dialog import BatchExportDialog + +__all__ = ['PlotWidget', 'MetadataWidget', 'FilePanel', 'OverlayPlotDialog', 'BatchExportDialog'] diff --git a/gui/widgets/batch_export_dialog.py b/gui/widgets/batch_export_dialog.py new file mode 100644 index 0000000..4a6f237 --- /dev/null +++ b/gui/widgets/batch_export_dialog.py @@ -0,0 +1,309 @@ +""" +Batch export dialog for exporting multiple ASD files +""" + +import os +import logging +from typing import List +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QGroupBox, QCheckBox, QRadioButton, QButtonGroup, + QPushButton, QFileDialog, QProgressBar, + QMessageBox, QScrollArea, QWidget, QLineEdit, + QDialogButtonBox) +from PyQt6.QtCore import Qt, QThread, pyqtSignal + +logger = logging.getLogger(__name__) + + +class BatchExportWorker(QThread): + """ + Worker thread for batch export operations + """ + + progress = pyqtSignal(int, str) # progress percentage, current file + finished = pyqtSignal(int, int) # success_count, total_count + error = pyqtSignal(str, str) # filename, error_message + + def __init__(self, files, output_dir, data_types, export_format, parent=None): + super().__init__(parent) + self.files = files + self.output_dir = output_dir + self.data_types = data_types + self.export_format = export_format + self._is_cancelled = False + + def run(self): + """Run batch export""" + from pyASDReader import ASDFile + from gui.utils import ExportManager + + success_count = 0 + total = len(self.files) + + for idx, filepath in enumerate(self.files): + if self._is_cancelled: + break + + try: + # Update progress + filename = os.path.basename(filepath) + progress_pct = int((idx / total) * 100) + self.progress.emit(progress_pct, filename) + + # Load file + asd_file = ASDFile(filepath) + + # Generate output filename + base_name = os.path.splitext(filename)[0] + output_file = os.path.join(self.output_dir, f"{base_name}_export.{self.export_format}") + + # Export based on format + if self.export_format == 'csv': + ExportManager.export_to_csv(asd_file, output_file, self.data_types) + elif self.export_format == 'txt': + ExportManager.export_metadata_to_txt(asd_file, output_file) + + success_count += 1 + + except Exception as e: + error_msg = str(e) + self.error.emit(os.path.basename(filepath), error_msg) + logger.error(f"Failed to export {filepath}: {e}") + + # Final progress + self.progress.emit(100, "Complete") + self.finished.emit(success_count, total) + + def cancel(self): + """Cancel the export operation""" + self._is_cancelled = True + + +class BatchExportDialog(QDialog): + """ + Dialog for batch exporting multiple ASD files + """ + + def __init__(self, files: List[str], parent=None): + super().__init__(parent) + self.files = files + self.output_dir = None + self.worker = None + self.setWindowTitle(f"Batch Export - {len(files)} Files") + self.setGeometry(100, 100, 600, 500) + self._init_ui() + + def _init_ui(self): + """Initialize UI""" + layout = QVBoxLayout(self) + + # File info + info_label = QLabel(f"Selected {len(self.files)} file(s) for export") + layout.addWidget(info_label) + + # Export format group + format_group = QGroupBox("Export Format") + format_layout = QVBoxLayout() + + self.format_group = QButtonGroup() + + self.csv_radio = QRadioButton("CSV (Spectral Data)") + self.csv_radio.setChecked(True) + self.format_group.addButton(self.csv_radio, 0) + format_layout.addWidget(self.csv_radio) + + self.txt_radio = QRadioButton("TXT (Metadata Only)") + self.format_group.addButton(self.txt_radio, 1) + format_layout.addWidget(self.txt_radio) + + format_group.setLayout(format_layout) + layout.addWidget(format_group) + + # Data types selection (for CSV export) + self.data_types_group = QGroupBox("Data Types to Export (CSV only)") + data_types_layout = QVBoxLayout() + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + + self.data_type_checkboxes = {} + data_types = [ + ('digitalNumber', 'Digital Number (DN)'), + ('whiteReference', 'White Reference'), + ('reflectance', 'Reflectance'), + ('reflectanceNoDeriv', 'Reflectance (No Derivative)'), + ('reflectance1stDeriv', 'Reflectance (1st Derivative)'), + ('reflectance2ndDeriv', 'Reflectance (2nd Derivative)'), + ('reflectance3rdDeriv', 'Reflectance (3rd Derivative)'), + ('absoluteReflectance', 'Absolute Reflectance'), + ('log1R', 'Log(1/R)'), + ('log1RNoDeriv', 'Log(1/R) No Derivative'), + ('log1R1stDeriv', 'Log(1/R) 1st Derivative'), + ('log1R2ndDeriv', 'Log(1/R) 2nd Derivative'), + ('radiance', 'Radiance'), + ] + + for attr_name, display_name in data_types: + checkbox = QCheckBox(display_name) + # Check common ones by default + if attr_name in ['reflectance', 'digitalNumber']: + checkbox.setChecked(True) + self.data_type_checkboxes[attr_name] = checkbox + scroll_layout.addWidget(checkbox) + + scroll.setWidget(scroll_content) + data_types_layout.addWidget(scroll) + + # Select all / Deselect all buttons + button_row = QHBoxLayout() + select_all_btn = QPushButton("Select All") + select_all_btn.clicked.connect(self._select_all_data_types) + button_row.addWidget(select_all_btn) + + deselect_all_btn = QPushButton("Deselect All") + deselect_all_btn.clicked.connect(self._deselect_all_data_types) + button_row.addWidget(deselect_all_btn) + + data_types_layout.addLayout(button_row) + self.data_types_group.setLayout(data_types_layout) + layout.addWidget(self.data_types_group) + + # Connect format change to enable/disable data types + self.csv_radio.toggled.connect(lambda checked: self.data_types_group.setEnabled(checked)) + + # Output directory selection + output_layout = QHBoxLayout() + output_layout.addWidget(QLabel("Output Directory:")) + + self.output_dir_edit = QLineEdit() + self.output_dir_edit.setReadOnly(True) + self.output_dir_edit.setPlaceholderText("Select output directory...") + output_layout.addWidget(self.output_dir_edit) + + browse_btn = QPushButton("Browse...") + browse_btn.clicked.connect(self._select_output_dir) + output_layout.addWidget(browse_btn) + + layout.addLayout(output_layout) + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + self.progress_label = QLabel("") + self.progress_label.setVisible(False) + layout.addWidget(self.progress_label) + + # Buttons + button_layout = QHBoxLayout() + button_layout.addStretch() + + self.export_btn = QPushButton("Start Export") + self.export_btn.clicked.connect(self._start_export) + button_layout.addWidget(self.export_btn) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.clicked.connect(self._cancel_export) + self.cancel_btn.setEnabled(False) + button_layout.addWidget(self.cancel_btn) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close) + button_layout.addWidget(close_btn) + + layout.addLayout(button_layout) + + def _select_all_data_types(self): + """Select all data types""" + for checkbox in self.data_type_checkboxes.values(): + checkbox.setChecked(True) + + def _deselect_all_data_types(self): + """Deselect all data types""" + for checkbox in self.data_type_checkboxes.values(): + checkbox.setChecked(False) + + def _select_output_dir(self): + """Select output directory""" + directory = QFileDialog.getExistingDirectory( + self, + "Select Output Directory", + "", + QFileDialog.Option.ShowDirsOnly + ) + + if directory: + self.output_dir = directory + self.output_dir_edit.setText(directory) + + def _start_export(self): + """Start batch export""" + # Validate output directory + if not self.output_dir: + QMessageBox.warning(self, "No Output Directory", + "Please select an output directory first.") + return + + # Get export format + export_format = 'csv' if self.csv_radio.isChecked() else 'txt' + + # Get selected data types (for CSV) + selected_types = [] + if export_format == 'csv': + for attr_name, checkbox in self.data_type_checkboxes.items(): + if checkbox.isChecked(): + selected_types.append(attr_name) + + if not selected_types: + QMessageBox.warning(self, "No Data Types Selected", + "Please select at least one data type to export.") + return + + # Show progress + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + self.progress_label.setVisible(True) + self.progress_label.setText("Starting export...") + + # Disable export button, enable cancel + self.export_btn.setEnabled(False) + self.cancel_btn.setEnabled(True) + + # Create and start worker thread + self.worker = BatchExportWorker( + self.files, + self.output_dir, + selected_types, + export_format + ) + self.worker.progress.connect(self._on_progress) + self.worker.finished.connect(self._on_finished) + self.worker.error.connect(self._on_error) + self.worker.start() + + def _cancel_export(self): + """Cancel export""" + if self.worker: + self.worker.cancel() + self.cancel_btn.setEnabled(False) + + def _on_progress(self, percentage: int, filename: str): + """Update progress""" + self.progress_bar.setValue(percentage) + self.progress_label.setText(f"Exporting: {filename}") + + def _on_finished(self, success_count: int, total_count: int): + """Handle export finished""" + self.progress_label.setText(f"Export complete: {success_count}/{total_count} files") + self.export_btn.setEnabled(True) + self.cancel_btn.setEnabled(False) + + QMessageBox.information(self, "Export Complete", + f"Successfully exported {success_count} out of {total_count} files.") + + def _on_error(self, filename: str, error_msg: str): + """Handle export error""" + logger.warning(f"Export error for {filename}: {error_msg}") diff --git a/gui/widgets/file_panel.py b/gui/widgets/file_panel.py new file mode 100644 index 0000000..dc3dbca --- /dev/null +++ b/gui/widgets/file_panel.py @@ -0,0 +1,749 @@ +""" +File browser panel with tree structure and checkboxes +""" + +import os +import logging +from typing import List +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTreeWidget, QTreeWidgetItem, + QStyle, QMenu, QMessageBox) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QDragEnterEvent, QDropEvent + +logger = logging.getLogger(__name__) + + +class FileTreeControlBar(QWidget): + """ + File tree control bar + + Provides quick actions: select all, clear all, refresh, expand, collapse + """ + + select_all_clicked = pyqtSignal() + clear_all_clicked = pyqtSignal() + refresh_clicked = pyqtSignal() + expand_all_clicked = pyqtSignal() + collapse_all_clicked = pyqtSignal() + + def __init__(self): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # Select all button + self.select_all_btn = QPushButton("All") + self.select_all_btn.setMaximumWidth(50) + self.select_all_btn.setToolTip("Select all files in tree") + self.select_all_btn.clicked.connect(self.select_all_clicked) + layout.addWidget(self.select_all_btn) + + # Clear all button + self.clear_all_btn = QPushButton("Clear") + self.clear_all_btn.setMaximumWidth(50) + self.clear_all_btn.setToolTip("Deselect all files") + self.clear_all_btn.clicked.connect(self.clear_all_clicked) + layout.addWidget(self.clear_all_btn) + + # Refresh button + self.refresh_btn = QPushButton("🔄") + self.refresh_btn.setMaximumWidth(30) + self.refresh_btn.setToolTip("Refresh file tree") + self.refresh_btn.clicked.connect(self.refresh_clicked) + layout.addWidget(self.refresh_btn) + + # Expand/collapse buttons + self.expand_btn = QPushButton("Expand") + self.expand_btn.setMaximumWidth(60) + self.expand_btn.setToolTip("Expand all folders") + self.expand_btn.clicked.connect(self.expand_all_clicked) + layout.addWidget(self.expand_btn) + + self.collapse_btn = QPushButton("Collapse") + self.collapse_btn.setMaximumWidth(65) + self.collapse_btn.setToolTip("Collapse all folders") + self.collapse_btn.clicked.connect(self.collapse_all_clicked) + layout.addWidget(self.collapse_btn) + + layout.addStretch() + + +class SelectedFilesInfoBar(QWidget): + """ + Selected files info bar + + Displays number of selected files and batch operation buttons + """ + + compare_clicked = pyqtSignal() + overlay_clicked = pyqtSignal() + batch_export_clicked = pyqtSignal() + + def __init__(self): + super().__init__() + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + # Info label + self.info_label = QLabel("Selected: 0 files") + self.info_label.setStyleSheet("font-weight: bold; color: #2196F3;") + layout.addWidget(self.info_label) + + # Button row + button_layout = QHBoxLayout() + + # Compare button + self.compare_btn = QPushButton("Compare") + self.compare_btn.setToolTip("Compare selected files side by side") + self.compare_btn.setEnabled(False) + self.compare_btn.clicked.connect(self.compare_clicked) + button_layout.addWidget(self.compare_btn) + + # Overlay button + self.overlay_btn = QPushButton("Overlay") + self.overlay_btn.setToolTip("Overlay selected spectra on one plot") + self.overlay_btn.setEnabled(False) + self.overlay_btn.clicked.connect(self.overlay_clicked) + button_layout.addWidget(self.overlay_btn) + + # Batch export button + self.export_btn = QPushButton("Export") + self.export_btn.setToolTip("Batch export selected files") + self.export_btn.setEnabled(False) + self.export_btn.clicked.connect(self.batch_export_clicked) + button_layout.addWidget(self.export_btn) + + layout.addLayout(button_layout) + + def update_info(self, count: int): + """ + Update selected files count display + + Args: + count: Number of selected files + """ + self.info_label.setText(f"Selected: {count} file{'s' if count != 1 else ''}") + + # Enable/disable buttons based on count + has_files = count > 0 + has_multiple = count >= 2 + + self.compare_btn.setEnabled(has_multiple) + self.overlay_btn.setEnabled(has_multiple) + self.export_btn.setEnabled(has_files) + + +class FileTreeWidget(QTreeWidget): + """ + Tree widget with checkboxes for file selection + + Implements tri-state checkbox logic: + - Checked: All children are checked + - Unchecked: All children are unchecked + - PartiallyChecked: Some children are checked + """ + + files_checked = pyqtSignal(list) # List of checked files changed + file_double_clicked = pyqtSignal(str) # File double-clicked + + def __init__(self): + super().__init__() + self.current_root = None + self._init_ui() + self._setup_interactions() + + def _init_ui(self): + """Initialize UI""" + # Column settings + self.setHeaderLabels(["Name", "Size", "Type"]) + self.setColumnWidth(0, 200) + self.setColumnWidth(1, 80) + self.setColumnWidth(2, 60) + + # Style + self.setAlternatingRowColors(True) + self.setAnimated(True) + self.setIndentation(20) + + # Enable drag & drop + self.setDragEnabled(False) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + + # Context menu + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) + + def _setup_interactions(self): + """Setup interactions""" + # Double click event + self.itemDoubleClicked.connect(self._on_item_double_clicked) + + # Checkbox state changed + self.itemChanged.connect(self._handle_check_state_changed) + + def load_directory(self, directory: str): + """ + Load directory + + Args: + directory: Directory path + """ + self.clear() + self.current_root = directory + + if not os.path.isdir(directory): + return + + # Create root node + root_item = QTreeWidgetItem(self) + root_item.setText(0, os.path.basename(directory)) + root_item.setIcon(0, self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)) + root_item.setData(0, Qt.ItemDataRole.UserRole, directory) + root_item.setData(0, Qt.ItemDataRole.UserRole + 1, "folder") + root_item.setCheckState(0, Qt.CheckState.Unchecked) + + # Recursively load subdirectories and files + self._load_directory_recursive(directory, root_item) + + # Expand root node + root_item.setExpanded(True) + + def _load_directory_recursive(self, directory: str, parent_item: QTreeWidgetItem): + """ + Recursively load directory + + Args: + directory: Directory path + parent_item: Parent tree item + """ + try: + # Get all files and folders + entries = os.listdir(directory) + + # Separate folders and files + folders = [] + files = [] + + for entry in entries: + full_path = os.path.join(directory, entry) + if os.path.isdir(full_path): + folders.append(entry) + elif entry.endswith('.asd'): + files.append(entry) + + # Add folders first + for folder in sorted(folders): + folder_path = os.path.join(directory, folder) + folder_item = QTreeWidgetItem(parent_item) + folder_item.setText(0, folder) + folder_item.setIcon(0, self.style().standardIcon( + QStyle.StandardPixmap.SP_DirIcon)) + folder_item.setData(0, Qt.ItemDataRole.UserRole, folder_path) + folder_item.setData(0, Qt.ItemDataRole.UserRole + 1, "folder") + folder_item.setCheckState(0, Qt.CheckState.Unchecked) + + # Recursively load subdirectory + self._load_directory_recursive(folder_path, folder_item) + + # Update folder display (show file count) + file_count = self._count_asd_files(folder_path) + if file_count > 0: + folder_item.setText(0, f"{folder} ({file_count})") + + # Add files + for filename in sorted(files): + filepath = os.path.join(directory, filename) + file_item = QTreeWidgetItem(parent_item) + file_item.setText(0, filename) + file_item.setIcon(0, self.style().standardIcon( + QStyle.StandardPixmap.SP_FileIcon)) + + # File size + try: + size = os.path.getsize(filepath) + size_str = self._format_size(size) + file_item.setText(1, size_str) + except: + file_item.setText(1, "--") + + file_item.setText(2, "ASD") + file_item.setData(0, Qt.ItemDataRole.UserRole, filepath) + file_item.setData(0, Qt.ItemDataRole.UserRole + 1, "file") + file_item.setCheckState(0, Qt.CheckState.Unchecked) + + except Exception as e: + logger.warning(f"Failed to load directory {directory}: {e}") + + def _count_asd_files(self, directory: str) -> int: + """Recursively count .asd files in directory""" + count = 0 + try: + for entry in os.listdir(directory): + full_path = os.path.join(directory, entry) + if os.path.isdir(full_path): + count += self._count_asd_files(full_path) + elif entry.endswith('.asd'): + count += 1 + except: + pass + return count + + def _format_size(self, size: int) -> str: + """Format file size""" + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size/1024:.1f} KB" + else: + return f"{size/1024/1024:.1f} MB" + + def _handle_check_state_changed(self, item: QTreeWidgetItem, column: int): + """ + Handle checkbox state change + + Implements tri-state logic: + 1. Child node changes -> update parent + 2. Parent node changes -> update all children + """ + if column != 0: + return + + # Temporarily disconnect signal to avoid recursion + self.itemChanged.disconnect(self._handle_check_state_changed) + + try: + item_type = item.data(0, Qt.ItemDataRole.UserRole + 1) + new_state = item.checkState(0) + + if item_type == "folder": + # Folder node changed -> update all children + self._set_children_check_state(item, new_state) + + # Update parent node state + self._update_parent_check_state(item) + + # Emit signal + checked_files = self.get_checked_files() + self.files_checked.emit(checked_files) + + finally: + # Reconnect signal + self.itemChanged.connect(self._handle_check_state_changed) + + def _set_children_check_state(self, parent: QTreeWidgetItem, state: Qt.CheckState): + """ + Recursively set check state for all children + + Args: + parent: Parent item + state: Target state + """ + for i in range(parent.childCount()): + child = parent.child(i) + child.setCheckState(0, state) + + # If child is a folder, recurse + if child.data(0, Qt.ItemDataRole.UserRole + 1) == "folder": + self._set_children_check_state(child, state) + + def _update_parent_check_state(self, item: QTreeWidgetItem): + """ + Update parent node check state upwards + + Tri-state logic: + - All children checked -> Checked + - All children unchecked -> Unchecked + - Some children checked -> PartiallyChecked + + Args: + item: Child item + """ + parent = item.parent() + if not parent: + return + + # Count children states + checked_count = 0 + unchecked_count = 0 + partial_count = 0 + total_count = parent.childCount() + + for i in range(total_count): + child = parent.child(i) + state = child.checkState(0) + if state == Qt.CheckState.Checked: + checked_count += 1 + elif state == Qt.CheckState.Unchecked: + unchecked_count += 1 + elif state == Qt.CheckState.PartiallyChecked: + partial_count += 1 + + # Set parent state based on children + if checked_count == total_count: + parent.setCheckState(0, Qt.CheckState.Checked) + elif unchecked_count == total_count: + parent.setCheckState(0, Qt.CheckState.Unchecked) + else: + parent.setCheckState(0, Qt.CheckState.PartiallyChecked) + + # Recursively update parent's parent + self._update_parent_check_state(parent) + + def get_checked_files(self) -> List[str]: + """ + Get all checked file paths + + Returns: + List of checked file paths + """ + files = [] + + def collect_checked_files(item: QTreeWidgetItem): + """Recursively collect checked files""" + item_type = item.data(0, Qt.ItemDataRole.UserRole + 1) + + if item_type == "file" and item.checkState(0) == Qt.CheckState.Checked: + filepath = item.data(0, Qt.ItemDataRole.UserRole) + files.append(filepath) + + # Recurse through children + for i in range(item.childCount()): + collect_checked_files(item.child(i)) + + # Start from root + root = self.invisibleRootItem() + for i in range(root.childCount()): + collect_checked_files(root.child(i)) + + return files + + def set_all_checked(self, checked: bool): + """ + Set check state for all files + + Args: + checked: True=check, False=uncheck + """ + state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked + + root = self.invisibleRootItem() + for i in range(root.childCount()): + item = root.child(i) + item.setCheckState(0, state) + + def refresh(self): + """Refresh current directory""" + if self.current_root: + self.load_directory(self.current_root) + + def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int): + """Double click event""" + item_type = item.data(0, Qt.ItemDataRole.UserRole + 1) + if item_type == "file": + filepath = item.data(0, Qt.ItemDataRole.UserRole) + self.file_double_clicked.emit(filepath) + + def _show_context_menu(self, position): + """Show context menu""" + item = self.itemAt(position) + if not item: + return + + menu = QMenu() + item_type = item.data(0, Qt.ItemDataRole.UserRole + 1) + + if item_type == "file": + # File context menu + open_action = menu.addAction("Open") + open_action.triggered.connect(lambda: self._open_file(item)) + + menu.addSeparator() + + load_action = menu.addAction("Load to Plot") + compare_action = menu.addAction("Compare with...") + + menu.addSeparator() + + export_action = menu.addAction("Export...") + properties_action = menu.addAction("Properties") + + menu.addSeparator() + + show_folder_action = menu.addAction("Show in Folder") + show_folder_action.triggered.connect(lambda: self._show_in_folder(item)) + + else: + # Folder context menu + select_all_action = menu.addAction("Select All Files") + select_all_action.triggered.connect(lambda: self._select_all_in_folder(item)) + + deselect_all_action = menu.addAction("Deselect All Files") + deselect_all_action.triggered.connect(lambda: self._deselect_all_in_folder(item)) + + menu.addSeparator() + + expand_action = menu.addAction("Expand All") + expand_action.triggered.connect(lambda: item.setExpanded(True)) + + collapse_action = menu.addAction("Collapse All") + collapse_action.triggered.connect(lambda: item.setExpanded(False)) + + menu.addSeparator() + + batch_export_action = menu.addAction("Batch Export...") + + menu.exec(self.viewport().mapToGlobal(position)) + + def _open_file(self, item: QTreeWidgetItem): + """Open file""" + filepath = item.data(0, Qt.ItemDataRole.UserRole) + self.file_double_clicked.emit(filepath) + + def _show_in_folder(self, item: QTreeWidgetItem): + """Show in system file manager""" + filepath = item.data(0, Qt.ItemDataRole.UserRole) + import subprocess + import platform + + if platform.system() == "Windows": + subprocess.run(['explorer', '/select,', filepath]) + elif platform.system() == "Darwin": # macOS + subprocess.run(['open', '-R', filepath]) + else: # Linux + subprocess.run(['xdg-open', os.path.dirname(filepath)]) + + def _select_all_in_folder(self, item: QTreeWidgetItem): + """Select all files in folder""" + item.setCheckState(0, Qt.CheckState.Checked) + + def _deselect_all_in_folder(self, item: QTreeWidgetItem): + """Deselect all files in folder""" + item.setCheckState(0, Qt.CheckState.Unchecked) + + # Drag & drop support + def dragEnterEvent(self, event: QDragEnterEvent): + """Drag enter event""" + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event: QDropEvent): + """Drop event""" + urls = event.mimeData().urls() + + for url in urls: + path = url.toLocalFile() + if os.path.isdir(path): + # If folder, load the folder + self.load_directory(path) + break + elif path.endswith('.asd'): + # If single file, emit signal + self.file_double_clicked.emit(path) + + +class FilePanel(QWidget): + """ + Tree-based file browser panel (with checkboxes) + + Features: + - Tree folder structure + - Tri-state checkbox selection + - Batch operations + - Drag & drop support + """ + + # Signals + file_checked = pyqtSignal(str, bool) # File check state changed + files_checked = pyqtSignal(list) # Checked files list changed + file_selected = pyqtSignal(str) # File double-clicked (for compatibility) + folder_changed = pyqtSignal(str) # Folder changed + + def __init__(self): + super().__init__() + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + # Title + title = QLabel("📁 File Browser") + layout.addWidget(title) + + # Control bar + self.control_bar = FileTreeControlBar() + self.control_bar.select_all_clicked.connect(self._select_all) + self.control_bar.clear_all_clicked.connect(self._clear_all) + self.control_bar.refresh_clicked.connect(self._refresh_tree) + self.control_bar.expand_all_clicked.connect(self._expand_all) + self.control_bar.collapse_all_clicked.connect(self._collapse_all) + layout.addWidget(self.control_bar) + + # Tree widget + self.tree_widget = FileTreeWidget() + self.tree_widget.files_checked.connect(self._on_files_checked) + self.tree_widget.file_double_clicked.connect(self._on_file_double_clicked) + layout.addWidget(self.tree_widget) + + # Selected files info bar + self.info_bar = SelectedFilesInfoBar() + self.info_bar.compare_clicked.connect(self._on_compare_clicked) + self.info_bar.overlay_clicked.connect(self._on_overlay_clicked) + self.info_bar.batch_export_clicked.connect(self._on_batch_export_clicked) + layout.addWidget(self.info_bar) + + def load_directory(self, directory: str): + """Load directory""" + self.tree_widget.load_directory(directory) + self.folder_changed.emit(directory) + + def open_file_dialog(self): + """Open file dialog""" + from PyQt6.QtWidgets import QFileDialog + filepath, _ = QFileDialog.getOpenFileName( + self, + "Open ASD File", + "", + "ASD Files (*.asd);;All Files (*.*)" + ) + if filepath: + self.file_selected.emit(filepath) + + def open_folder_dialog(self): + """Open folder dialog""" + from PyQt6.QtWidgets import QFileDialog + directory = QFileDialog.getExistingDirectory( + self, + "Select Folder", + "", + QFileDialog.Option.ShowDirsOnly + ) + if directory: + self.load_directory(directory) + + def get_checked_files(self) -> List[str]: + """Get all checked files""" + return self.tree_widget.get_checked_files() + + def _select_all(self): + """Select all""" + self.tree_widget.set_all_checked(True) + + def _clear_all(self): + """Clear selection""" + self.tree_widget.set_all_checked(False) + + def _refresh_tree(self): + """Refresh tree""" + self.tree_widget.refresh() + + def _expand_all(self): + """Expand all""" + self.tree_widget.expandAll() + + def _collapse_all(self): + """Collapse all""" + self.tree_widget.collapseAll() + + def _on_files_checked(self, files: List[str]): + """Checked files changed""" + self.info_bar.update_info(len(files)) + self.files_checked.emit(files) + + def _on_file_double_clicked(self, filepath: str): + """File double-clicked""" + self.file_selected.emit(filepath) + + def _on_compare_clicked(self): + """ + Compare button clicked + + Loads checked files in side-by-side layout for comparison + """ + files = self.get_checked_files() + if len(files) < 2: + QMessageBox.warning(self, "Warning", + "Please select at least 2 files to compare") + return + + # Emit signal to load files for comparison + # The main window will handle the actual comparison display + self.files_checked.emit(files) + logger.info(f"Compare {len(files)} files in side-by-side layout") + + def _on_overlay_clicked(self): + """ + Overlay button clicked + + Opens overlay plot dialog showing all checked files on one plot + """ + files = self.get_checked_files() + if len(files) < 2: + QMessageBox.warning(self, "Warning", + "Please select at least 2 files to overlay") + return + + try: + # Load all ASD files + from pyASDReader import ASDFile + from gui.widgets.overlay_plot_widget import OverlayPlotDialog + + asd_files = [] + failed_files = [] + + for filepath in files: + try: + asd_file = ASDFile(filepath) + asd_files.append(asd_file) + except Exception as e: + failed_files.append(os.path.basename(filepath)) + logger.error(f"Failed to load {filepath}: {e}") + + if not asd_files: + QMessageBox.critical(self, "Error", + "Failed to load any of the selected files") + return + + if failed_files: + QMessageBox.warning(self, "Partial Load", + f"Failed to load {len(failed_files)} file(s):\n" + + "\n".join(failed_files[:5]) + + ("\n..." if len(failed_files) > 5 else "")) + + # Open overlay dialog + dialog = OverlayPlotDialog(asd_files, self) + dialog.exec() + + except Exception as e: + QMessageBox.critical(self, "Error", + f"Failed to create overlay plot:\n{str(e)}") + logger.error(f"Overlay plot error: {e}") + + def _on_batch_export_clicked(self): + """ + Batch export button clicked + + Opens batch export dialog for exporting multiple files + """ + files = self.get_checked_files() + if not files: + QMessageBox.warning(self, "Warning", + "Please select files to export") + return + + try: + from gui.widgets.batch_export_dialog import BatchExportDialog + + dialog = BatchExportDialog(files, self) + dialog.exec() + + except Exception as e: + QMessageBox.critical(self, "Error", + f"Failed to open batch export dialog:\n{str(e)}") + logger.error(f"Batch export dialog error: {e}") diff --git a/gui/widgets/metadata_widget.py b/gui/widgets/metadata_widget.py new file mode 100644 index 0000000..1aaff50 --- /dev/null +++ b/gui/widgets/metadata_widget.py @@ -0,0 +1,239 @@ +""" +Metadata display widget for ASD files +""" + +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, + QLabel, QGroupBox, QPushButton, QHBoxLayout) +from PyQt6.QtCore import Qt + + +class MetadataWidget(QWidget): + """ + Widget for displaying ASD file metadata in a tree structure + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.asd_file = None + self.init_ui() + + def init_ui(self): + """Initialize the user interface""" + layout = QVBoxLayout(self) + + # Title + title_label = QLabel("File Information") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title_label) + + # Tree widget for metadata + self.tree = QTreeWidget() + self.tree.setHeaderLabels(["Property", "Value"]) + self.tree.setAlternatingRowColors(True) + self.tree.setColumnWidth(0, 200) + layout.addWidget(self.tree) + + # Button layout + btn_layout = QHBoxLayout() + + # Expand/Collapse buttons + expand_btn = QPushButton("Expand All") + expand_btn.clicked.connect(self.tree.expandAll) + btn_layout.addWidget(expand_btn) + + collapse_btn = QPushButton("Collapse All") + collapse_btn.clicked.connect(self.tree.collapseAll) + btn_layout.addWidget(collapse_btn) + + layout.addLayout(btn_layout) + + def set_asd_file(self, asd_file): + """ + Set the ASD file and display its metadata + + Args: + asd_file: ASDFile object + """ + self.asd_file = asd_file + self.update_metadata() + + def update_metadata(self): + """Update the metadata display""" + self.tree.clear() + + if self.asd_file is None: + return + + # File Attributes Section + file_attr = QTreeWidgetItem(self.tree, ["File Attributes", ""]) + self._add_file_attributes(file_attr) + + # Metadata Section + if hasattr(self.asd_file, 'metadata') and self.asd_file.metadata: + metadata_item = QTreeWidgetItem(self.tree, ["ASD Metadata", ""]) + self._add_metadata(metadata_item) + + # Spectral Data Info + if hasattr(self.asd_file, 'wavelengths') and self.asd_file.wavelengths is not None: + spectral_item = QTreeWidgetItem(self.tree, ["Spectral Information", ""]) + self._add_spectral_info(spectral_item) + + # Available Data Types + data_types_item = QTreeWidgetItem(self.tree, ["Available Data Types", ""]) + self._add_available_data_types(data_types_item) + + # Expand all by default + self.tree.expandAll() + + def _add_file_attributes(self, parent_item): + """Add file attribute information""" + if not hasattr(self.asd_file, 'filename'): + return + + self._add_tree_item(parent_item, "File Name", self.asd_file.filename) + + if hasattr(self.asd_file, 'filepath'): + self._add_tree_item(parent_item, "File Path", self.asd_file.filepath) + + if hasattr(self.asd_file, 'filesize'): + size_mb = self.asd_file.filesize / (1024 * 1024) + self._add_tree_item(parent_item, "File Size", f"{self.asd_file.filesize:,} bytes ({size_mb:.2f} MB)") + + if hasattr(self.asd_file, 'creation_time'): + self._add_tree_item(parent_item, "Creation Time", str(self.asd_file.creation_time)) + + if hasattr(self.asd_file, 'modification_time'): + self._add_tree_item(parent_item, "Modification Time", str(self.asd_file.modification_time)) + + if hasattr(self.asd_file, 'hashMD5'): + self._add_tree_item(parent_item, "MD5", self.asd_file.hashMD5) + + if hasattr(self.asd_file, 'hashSHA265'): + self._add_tree_item(parent_item, "SHA256", self.asd_file.hashSHA265) + + def _add_metadata(self, parent_item): + """Add ASD metadata information""" + metadata = self.asd_file.metadata + + # File version + if hasattr(metadata, 'fileVersion'): + self._add_tree_item(parent_item, "File Version", str(metadata.fileVersion)) + + if hasattr(self.asd_file, 'asdFileVersion'): + self._add_tree_item(parent_item, "ASD File Version", str(self.asd_file.asdFileVersion)) + + # Instrument information + instrument_item = QTreeWidgetItem(parent_item, ["Instrument", ""]) + + if hasattr(metadata, 'instrumentModel'): + self._add_tree_item(instrument_item, "Model", str(metadata.instrumentModel)) + + if hasattr(metadata, 'instrumentType'): + self._add_tree_item(instrument_item, "Type", str(metadata.instrumentType)) + + if hasattr(metadata, 'instrument'): + self._add_tree_item(instrument_item, "Instrument", str(metadata.instrument)) + + # Spectral parameters + spectral_params = QTreeWidgetItem(parent_item, ["Spectral Parameters", ""]) + + if hasattr(metadata, 'channels'): + self._add_tree_item(spectral_params, "Channels", str(metadata.channels)) + + if hasattr(metadata, 'channel1Wavelength'): + self._add_tree_item(spectral_params, "Start Wavelength", f"{metadata.channel1Wavelength} nm") + + if hasattr(metadata, 'wavelengthStep'): + self._add_tree_item(spectral_params, "Wavelength Step", f"{metadata.wavelengthStep} nm") + + if hasattr(metadata, 'splice1_wavelength'): + self._add_tree_item(spectral_params, "Splice 1", f"{metadata.splice1_wavelength} nm") + + if hasattr(metadata, 'splice2_wavelength'): + self._add_tree_item(spectral_params, "Splice 2", f"{metadata.splice2_wavelength} nm") + + # Acquisition parameters + if hasattr(metadata, 'intergrationTime_ms') or hasattr(metadata, 'swir1Gain') or hasattr(metadata, 'swir2Gain'): + acq_params = QTreeWidgetItem(parent_item, ["Acquisition Parameters", ""]) + + if hasattr(metadata, 'intergrationTime_ms'): + self._add_tree_item(acq_params, "Integration Time", str(metadata.intergrationTime_ms)) + + if hasattr(metadata, 'swir1Gain'): + self._add_tree_item(acq_params, "SWIR1 Gain", str(metadata.swir1Gain)) + + if hasattr(metadata, 'swir2Gain'): + self._add_tree_item(acq_params, "SWIR2 Gain", str(metadata.swir2Gain)) + + # GPS information + if hasattr(metadata, 'gpsData'): + gps = metadata.gpsData + if gps: + gps_item = QTreeWidgetItem(parent_item, ["GPS Data", ""]) + + if hasattr(gps, 'latitude'): + self._add_tree_item(gps_item, "Latitude", f"{gps.latitude:.6f}") + + if hasattr(gps, 'longitude'): + self._add_tree_item(gps_item, "Longitude", f"{gps.longitude:.6f}") + + if hasattr(gps, 'altitude'): + self._add_tree_item(gps_item, "Altitude", f"{gps.altitude} m") + + # Time information + if hasattr(metadata, 'spectrumTime'): + self._add_tree_item(parent_item, "Spectrum Time", str(metadata.spectrumTime)) + + if hasattr(metadata, 'referenceTime'): + self._add_tree_item(parent_item, "Reference Time", str(metadata.referenceTime)) + + # Data type + if hasattr(metadata, 'dataType'): + self._add_tree_item(parent_item, "Data Type", str(metadata.dataType)) + + def _add_spectral_info(self, parent_item): + """Add spectral data information""" + wavelengths = self.asd_file.wavelengths + + self._add_tree_item(parent_item, "Number of Bands", str(len(wavelengths))) + self._add_tree_item(parent_item, "Wavelength Range", + f"{wavelengths[0]:.2f} - {wavelengths[-1]:.2f} nm") + self._add_tree_item(parent_item, "Spectral Resolution", + f"{wavelengths[1] - wavelengths[0]:.2f} nm" if len(wavelengths) > 1 else "N/A") + + def _add_available_data_types(self, parent_item): + """Check and display available data types""" + data_types = { + 'Digital Number': 'digitalNumber', + 'White Reference': 'whiteReference', + 'Reflectance': 'reflectance', + 'Reflectance (1st Deriv)': 'reflectance1stDeriv', + 'Reflectance (2nd Deriv)': 'reflectance2ndDeriv', + 'Reflectance (3rd Deriv)': 'reflectance3rdDeriv', + 'Absolute Reflectance': 'absoluteReflectance', + 'Log(1/R)': 'log1R', + 'Log(1/R) (1st Deriv)': 'log1R1stDeriv', + 'Log(1/R) (2nd Deriv)': 'log1R2ndDeriv', + 'Radiance': 'radiance', + } + + for display_name, attr_name in data_types.items(): + try: + data = getattr(self.asd_file, attr_name, None) + status = "Available" if data is not None else "Not Available" + color = "green" if data is not None else "red" + + item = QTreeWidgetItem(parent_item, [display_name, status]) + item.setForeground(1, Qt.GlobalColor.darkGreen if data is not None else Qt.GlobalColor.darkRed) + except Exception: + item = QTreeWidgetItem(parent_item, [display_name, "Error"]) + item.setForeground(1, Qt.GlobalColor.red) + + def _add_tree_item(self, parent, name, value): + """Helper method to add a tree item""" + QTreeWidgetItem(parent, [name, str(value)]) + + def clear(self): + """Clear the metadata display""" + self.tree.clear() + self.asd_file = None diff --git a/gui/widgets/multi_plot_canvas.py b/gui/widgets/multi_plot_canvas.py new file mode 100644 index 0000000..24ca867 --- /dev/null +++ b/gui/widgets/multi_plot_canvas.py @@ -0,0 +1,719 @@ +""" +Multi-plot canvas for displaying multiple spectral plots + +Phase 2 - Supports 7 layout modes: +- 1×1 (Single) +- 1×2 (Horizontal 2) +- 2×1 (Vertical 2) +- 2×2 (Grid 2×2) +- 1×3 (Horizontal 3) +- 3×1 (Vertical 3) +- 2×3 (Grid 2×3) +""" + +import os +import logging +from enum import Enum +from typing import List, Tuple, Optional +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QPushButton, QComboBox, QCheckBox, QSplitter) +from PyQt6.QtCore import Qt, pyqtSignal +from matplotlib.figure import Figure +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +import matplotlib.pyplot as plt + +logger = logging.getLogger(__name__) + + +class LayoutMode(Enum): + """Layout mode enumeration""" + SINGLE = "1x1" # Single plot + HORIZONTAL_2 = "1x2" # Horizontal 2 + VERTICAL_2 = "2x1" # Vertical 2 + GRID_2x2 = "2x2" # 2×2 grid + HORIZONTAL_3 = "1x3" # Horizontal 3 + VERTICAL_3 = "3x1" # Vertical 3 + GRID_2x3 = "2x3" # 2×3 grid + + +class SyncManager: + """ + Subplot synchronization manager + + Handles synchronization of zoom, cursor, and pan across multiple subplots + """ + + def __init__(self): + self.subplots = [] + self.sync_zoom = True + self.sync_cursor = True + self.sync_pan = False + + def register_subplot(self, subplot): + """Register a subplot""" + self.subplots.append(subplot) + + def clear(self): + """Clear all registered subplots""" + self.subplots.clear() + + def sync_zoom_to_all(self, source_row: int, source_col: int, xlim, ylim): + """Synchronize zoom to all subplots""" + if not self.sync_zoom: + return + + for subplot in self.subplots: + # Skip source subplot + if subplot.row == source_row and subplot.col == source_col: + continue + + # Sync X-axis (wavelength is always the same) + if xlim and subplot.ax: + subplot.ax.set_xlim(xlim) + subplot.canvas.draw_idle() + + def sync_cursor_to_all(self, source_row: int, source_col: int, x: float, y: float): + """Synchronize cursor to all subplots""" + if not self.sync_cursor: + return + + for subplot in self.subplots: + # Skip source subplot + if subplot.row == source_row and subplot.col == source_col: + continue + + # Remove old cursor line + if hasattr(subplot, '_sync_cursor_line') and subplot.ax: + try: + subplot._sync_cursor_line.remove() + except: + pass + + # Draw new cursor line + if subplot.ax: + subplot._sync_cursor_line = subplot.ax.axvline( + x, color='red', linestyle='--', alpha=0.5, linewidth=1 + ) + subplot.canvas.draw_idle() + + +class SubPlotControlBar(QWidget): + """ + Subplot control bar + + Displayed at the top of each subplot + """ + + file_changed = pyqtSignal(str) + data_type_changed = pyqtSignal(str) + clear_requested = pyqtSignal() + + def __init__(self, row: int, col: int): + super().__init__() + self.row = row + self.col = col + + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + layout.setSpacing(5) + + # Position label + pos_label = QLabel(f"[{row},{col}]") + pos_label.setStyleSheet("color: gray; font-size: 9px; font-family: monospace;") + layout.addWidget(pos_label) + + # File selection + layout.addWidget(QLabel("File:")) + self.file_combo = QComboBox() + self.file_combo.setMaximumWidth(120) + self.file_combo.currentTextChanged.connect(self.file_changed) + layout.addWidget(self.file_combo) + + # Data type selection + layout.addWidget(QLabel("Type:")) + self.data_type_combo = QComboBox() + self.data_type_combo.addItems([ + 'digitalNumber', + 'reflectance', + 'reflectance1stDeriv', + 'reflectance2ndDeriv', + 'reflectance3rdDeriv', + 'whiteReference', + 'absoluteReflectance', + 'log1R', + 'log1R1stDeriv', + 'log1R2ndDeriv', + 'radiance', + ]) + self.data_type_combo.setCurrentText('reflectance') + self.data_type_combo.setMaximumWidth(100) + self.data_type_combo.currentTextChanged.connect(self.data_type_changed) + layout.addWidget(self.data_type_combo) + + layout.addStretch() + + # Cursor position display + self.cursor_label = QLabel("") + self.cursor_label.setStyleSheet("font-size: 9px; color: #555;") + layout.addWidget(self.cursor_label) + + # Clear button + self.clear_btn = QPushButton("✕") + self.clear_btn.setMaximumWidth(20) + self.clear_btn.setMaximumHeight(18) + self.clear_btn.setToolTip("Clear this subplot") + self.clear_btn.clicked.connect(self.clear_requested) + layout.addWidget(self.clear_btn) + + def update_file_list(self, files: List[str]): + """Update file list""" + self.file_combo.clear() + self.file_combo.addItems([os.path.basename(f) for f in files]) + + def set_current_file(self, filename: str): + """Set current file""" + index = self.file_combo.findText(os.path.basename(filename)) + if index >= 0: + self.file_combo.setCurrentIndex(index) + + def set_current_data_type(self, data_type: str): + """Set current data type""" + index = self.data_type_combo.findText(data_type) + if index >= 0: + self.data_type_combo.setCurrentIndex(index) + + def update_cursor_position(self, x: float, y: float): + """Update cursor position display""" + self.cursor_label.setText(f"λ={x:.1f}nm, y={y:.4f}") + + +class SubPlotWidget(QWidget): + """ + Single subplot widget + + Features: + - matplotlib plotting + - Independent control bar + - Data loading + - Event emission + """ + + zoom_changed = pyqtSignal(int, int, object, object) # row, col, xlim, ylim + cursor_moved = pyqtSignal(int, int, float, float) # row, col, x, y + selected = pyqtSignal() + + def __init__(self, row: int, col: int): + super().__init__() + self.row = row + self.col = col + self.current_file = None + self.current_data_type = None + + self._init_ui() + self._setup_interactions() + + def _init_ui(self): + """Initialize UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(2) + + # Subplot control bar + self.control_bar = SubPlotControlBar(self.row, self.col) + self.control_bar.file_changed.connect(self._on_file_changed) + self.control_bar.data_type_changed.connect(self._on_data_type_changed) + self.control_bar.clear_requested.connect(self.clear) + layout.addWidget(self.control_bar) + + # matplotlib figure + self.figure = Figure(figsize=(5, 4), dpi=100) + self.canvas = FigureCanvas(self.figure) + self.ax = self.figure.add_subplot(111) + + # Set style + self.ax.set_facecolor('#f8f8f8') + self.ax.grid(True, alpha=0.3, linestyle='--') + + layout.addWidget(self.canvas) + + # Toolbar (optional display) + self.toolbar = NavigationToolbar(self.canvas, self) + self.toolbar.setMaximumHeight(30) + layout.addWidget(self.toolbar) + + # Focus style + self.setAutoFillBackground(True) + self._update_focus_style(False) + + def _setup_interactions(self): + """Setup interactions""" + # matplotlib events + self.canvas.mpl_connect('button_press_event', self._on_mouse_press) + self.canvas.mpl_connect('motion_notify_event', self._on_mouse_move) + self.canvas.mpl_connect('scroll_event', self._on_mouse_scroll) + + # Focus events + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + def focusInEvent(self, event): + """Gain focus""" + super().focusInEvent(event) + self._update_focus_style(True) + self.selected.emit() + + def focusOutEvent(self, event): + """Lose focus""" + super().focusOutEvent(event) + self._update_focus_style(False) + + def _update_focus_style(self, focused: bool): + """Update focus style""" + if focused: + self.setStyleSheet("SubPlotWidget { border: 2px solid #2196F3; }") + else: + self.setStyleSheet("SubPlotWidget { border: 1px solid #ccc; }") + + def load_data(self, asd_file, data_type: str): + """ + Load data to subplot + + Args: + asd_file: ASD file object + data_type: Data type (e.g., 'reflectance') + """ + from pyASDReader import ASDFile + + self.current_file = asd_file + self.current_data_type = data_type + + # Update control bar + self.control_bar.set_current_file(asd_file.filepath) + self.control_bar.set_current_data_type(data_type) + + # Get data + wavelengths = asd_file.wavelengths + data = getattr(asd_file, data_type, None) + + if data is None or wavelengths is None: + self._show_no_data_message(data_type) + return + + # Clear and plot + self.ax.clear() + self.ax.plot(wavelengths, data, 'b-', linewidth=1.5, label=data_type) + + # Set labels + self.ax.set_xlabel('Wavelength (nm)', fontsize=10) + self.ax.set_ylabel(self._get_ylabel(data_type), fontsize=10) + self.ax.set_title(f"{os.path.basename(asd_file.filepath)}\n{data_type}", + fontsize=10, fontweight='bold') + + # Grid and legend + self.ax.grid(True, alpha=0.3, linestyle='--') + self.ax.legend(loc='best', fontsize=8) + + # Update canvas + self.figure.tight_layout() + self.canvas.draw() + + def _show_no_data_message(self, data_type: str): + """Show no data message""" + self.ax.clear() + self.ax.text(0.5, 0.5, f"No data available for\n{data_type}", + ha='center', va='center', transform=self.ax.transAxes, + fontsize=12, color='gray') + self.ax.axis('off') + self.canvas.draw() + + def _get_ylabel(self, data_type: str) -> str: + """Get Y-axis label""" + ylabel_map = { + 'digitalNumber': 'Digital Number', + 'reflectance': 'Reflectance', + 'reflectance1stDeriv': '1st Derivative', + 'reflectance2ndDeriv': '2nd Derivative', + 'reflectance3rdDeriv': '3rd Derivative', + 'whiteReference': 'White Reference', + 'absoluteReflectance': 'Absolute Reflectance', + 'log1R': 'Log(1/R)', + 'log1R1stDeriv': 'Log(1/R) 1st Derivative', + 'log1R2ndDeriv': 'Log(1/R) 2nd Derivative', + 'radiance': 'Radiance (W/m²/sr/nm)', + } + return ylabel_map.get(data_type, data_type) + + def _on_mouse_press(self, event): + """Mouse click""" + if event.inaxes == self.ax: + self.setFocus() + + def _on_mouse_move(self, event): + """Mouse move""" + if event.inaxes == self.ax and event.xdata and event.ydata: + # Emit cursor position + self.cursor_moved.emit(self.row, self.col, event.xdata, event.ydata) + + # Update control bar display + self.control_bar.update_cursor_position(event.xdata, event.ydata) + + def _on_mouse_scroll(self, event): + """Mouse scroll (zoom)""" + if event.inaxes == self.ax: + # Emit signal after zoom + xlim = self.ax.get_xlim() + ylim = self.ax.get_ylim() + self.zoom_changed.emit(self.row, self.col, xlim, ylim) + + def _on_file_changed(self, filename: str): + """File changed in combo box""" + # TODO: Implement file change handling + pass + + def _on_data_type_changed(self, data_type: str): + """Data type changed in combo box""" + if self.current_file: + self.load_data(self.current_file, data_type) + + def clear(self): + """Clear subplot""" + self.current_file = None + self.current_data_type = None + self.ax.clear() + self.ax.text(0.5, 0.5, 'Empty', + ha='center', va='center', transform=self.ax.transAxes, + fontsize=14, color='lightgray') + self.ax.axis('off') + self.canvas.draw() + + +class MultiPlotControlBar(QWidget): + """ + Multi-plot layout control bar + """ + + layout_changed = pyqtSignal(LayoutMode) + sync_zoom_changed = pyqtSignal(bool) + sync_cursor_changed = pyqtSignal(bool) + sync_pan_changed = pyqtSignal(bool) + reset_all_requested = pyqtSignal() + export_all_requested = pyqtSignal() + + def __init__(self): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + # Layout selection + layout.addWidget(QLabel("Layout:")) + + self.layout_buttons = {} + layouts = [ + ("1×1", LayoutMode.SINGLE), + ("1×2", LayoutMode.HORIZONTAL_2), + ("2×1", LayoutMode.VERTICAL_2), + ("2×2", LayoutMode.GRID_2x2), + ("1×3", LayoutMode.HORIZONTAL_3), + ("3×1", LayoutMode.VERTICAL_3), + ("2×3", LayoutMode.GRID_2x3), + ] + + for text, mode in layouts: + btn = QPushButton(text) + btn.setCheckable(True) + btn.setMaximumWidth(45) + btn.clicked.connect(lambda checked, m=mode: + self._on_layout_button_clicked(m)) + self.layout_buttons[mode] = btn + layout.addWidget(btn) + + # Default select single plot + self.layout_buttons[LayoutMode.SINGLE].setChecked(True) + + layout.addSpacing(20) + + # Sync controls + layout.addWidget(QLabel("Sync:")) + + self.sync_zoom_check = QCheckBox("Zoom") + self.sync_zoom_check.setChecked(True) + self.sync_zoom_check.stateChanged.connect( + lambda state: self.sync_zoom_changed.emit(state == Qt.CheckState.Checked) + ) + layout.addWidget(self.sync_zoom_check) + + self.sync_cursor_check = QCheckBox("Cursor") + self.sync_cursor_check.setChecked(True) + self.sync_cursor_check.stateChanged.connect( + lambda state: self.sync_cursor_changed.emit(state == Qt.CheckState.Checked) + ) + layout.addWidget(self.sync_cursor_check) + + self.sync_pan_check = QCheckBox("Pan") + self.sync_pan_check.stateChanged.connect( + lambda state: self.sync_pan_changed.emit(state == Qt.CheckState.Checked) + ) + layout.addWidget(self.sync_pan_check) + + layout.addSpacing(20) + + # Global operations + self.reset_all_btn = QPushButton("Reset All") + self.reset_all_btn.clicked.connect(self.reset_all_requested) + layout.addWidget(self.reset_all_btn) + + self.export_all_btn = QPushButton("Export All") + self.export_all_btn.clicked.connect(self.export_all_requested) + layout.addWidget(self.export_all_btn) + + layout.addStretch() + + def _on_layout_button_clicked(self, mode: LayoutMode): + """Layout button clicked""" + # Uncheck other buttons + for m, btn in self.layout_buttons.items(): + btn.setChecked(m == mode) + + self.layout_changed.emit(mode) + + +class MultiPlotCanvas(QWidget): + """ + Multi-plot layout canvas + + Core features: + - Manage multiple subplots + - Dynamic layout switching + - Sync control + - Data distribution + """ + + layout_changed = pyqtSignal(LayoutMode) + subplot_selected = pyqtSignal(int, int) # row, col + + def __init__(self): + super().__init__() + self.subplots = [] + self.current_layout = LayoutMode.SINGLE + self.sync_manager = SyncManager() + self.loaded_files = [] # Cache of loaded files + + self._init_ui() + + def _init_ui(self): + """Initialize UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Control bar + self.control_bar = MultiPlotControlBar() + self.control_bar.layout_changed.connect(self.set_layout_mode) + self.control_bar.sync_zoom_changed.connect(self._on_sync_zoom_changed) + self.control_bar.sync_cursor_changed.connect(self._on_sync_cursor_changed) + self.control_bar.sync_pan_changed.connect(self._on_sync_pan_changed) + self.control_bar.reset_all_requested.connect(self._on_reset_all) + self.control_bar.export_all_requested.connect(self._on_export_all) + layout.addWidget(self.control_bar) + + # Canvas container + self.canvas_container = QWidget() + self.canvas_layout = QGridLayout(self.canvas_container) + self.canvas_layout.setSpacing(5) + layout.addWidget(self.canvas_container) + + # Initialize with single plot + self.set_layout_mode(LayoutMode.SINGLE) + + def set_layout_mode(self, mode: LayoutMode): + """Switch layout mode""" + if mode == self.current_layout: + return + + self.current_layout = mode + self._rebuild_layout() + self.layout_changed.emit(mode) + + def _rebuild_layout(self): + """Rebuild layout""" + # Save current subplot states + old_data = self._save_subplot_states() + + # Clear existing subplots + self._clear_subplots() + + # Create new layout + rows, cols = self._get_grid_size(self.current_layout) + + for row in range(rows): + for col in range(cols): + subplot = SubPlotWidget(row, col) + + # Connect signals + subplot.zoom_changed.connect(self._on_subplot_zoom_changed) + subplot.cursor_moved.connect(self._on_subplot_cursor_moved) + subplot.selected.connect(lambda r=row, c=col: + self.subplot_selected.emit(r, c)) + + # Add to layout + self.canvas_layout.addWidget(subplot, row, col) + self.subplots.append(subplot) + + # Register to sync manager + self.sync_manager.register_subplot(subplot) + + # Restore data (if possible) + self._restore_subplot_states(old_data) + + def _get_grid_size(self, mode: LayoutMode) -> Tuple[int, int]: + """Get grid size""" + layout_grids = { + LayoutMode.SINGLE: (1, 1), + LayoutMode.HORIZONTAL_2: (1, 2), + LayoutMode.VERTICAL_2: (2, 1), + LayoutMode.GRID_2x2: (2, 2), + LayoutMode.HORIZONTAL_3: (1, 3), + LayoutMode.VERTICAL_3: (3, 1), + LayoutMode.GRID_2x3: (2, 3), + } + return layout_grids.get(mode, (1, 1)) + + def _clear_subplots(self): + """Clear all subplots""" + for subplot in self.subplots: + self.canvas_layout.removeWidget(subplot) + subplot.deleteLater() + self.subplots.clear() + self.sync_manager.clear() + + def _save_subplot_states(self) -> List[dict]: + """Save subplot states""" + states = [] + for subplot in self.subplots: + state = { + 'file': subplot.current_file, + 'data_type': subplot.current_data_type, + 'xlim': subplot.ax.get_xlim() if subplot.ax else None, + 'ylim': subplot.ax.get_ylim() if subplot.ax else None, + } + states.append(state) + return states + + def _restore_subplot_states(self, states: List[dict]): + """Restore subplot states""" + for i, subplot in enumerate(self.subplots): + if i < len(states): + state = states[i] + if state['file'] and state['data_type']: + subplot.load_data(state['file'], state['data_type']) + if state['xlim']: + subplot.ax.set_xlim(state['xlim']) + if state['ylim']: + subplot.ax.set_ylim(state['ylim']) + subplot.canvas.draw() + + def load_files_to_subplots(self, files: List[str], data_type: str = 'reflectance'): + """ + Batch load files to subplots + + Args: + files: List of file paths + data_type: Data type + """ + from pyASDReader import ASDFile + + for i, filepath in enumerate(files): + if i >= len(self.subplots): + break + + try: + asd_file = ASDFile(filepath) + subplot = self.subplots[i] + subplot.load_data(asd_file, data_type) + except Exception as e: + logger.error(f"Failed to load {filepath}: {e}") + + def _on_subplot_zoom_changed(self, row: int, col: int, xlim, ylim): + """Subplot zoom changed""" + if self.sync_manager.sync_zoom: + self.sync_manager.sync_zoom_to_all(row, col, xlim, ylim) + + def _on_subplot_cursor_moved(self, row: int, col: int, x: float, y: float): + """Subplot cursor moved""" + if self.sync_manager.sync_cursor: + self.sync_manager.sync_cursor_to_all(row, col, x, y) + + def _on_sync_zoom_changed(self, enabled: bool): + """Sync zoom changed""" + self.sync_manager.sync_zoom = enabled + + def _on_sync_cursor_changed(self, enabled: bool): + """Sync cursor changed""" + self.sync_manager.sync_cursor = enabled + + def _on_sync_pan_changed(self, enabled: bool): + """Sync pan changed""" + self.sync_manager.sync_pan = enabled + + def _on_reset_all(self): + """Reset all subplots""" + for subplot in self.subplots: + if subplot.ax: + subplot.ax.autoscale() + subplot.canvas.draw() + + def _on_export_all(self): + """Export all subplots""" + # TODO: Implement export all functionality + logger.info("Export all subplots") + + def export_all_subplots(self, filepath: str, dpi: int = 300): + """ + Export all subplots to a single file + + Args: + filepath: Path to save the file + dpi: Resolution in dots per inch + """ + if not self.subplots: + return + + # Get grid size + rows, cols = self._get_grid_size(self.current_layout) + + # Create a new figure with the same layout + fig = Figure(figsize=(cols * 6, rows * 4), dpi=100) + + # Copy each subplot to the new figure + for idx, subplot in enumerate(self.subplots): + if subplot.current_file is None: + continue + + # Calculate position in grid + row = idx // cols + col = idx % cols + + # Create subplot in new figure + ax = fig.add_subplot(rows, cols, idx + 1) + + # Get data from original subplot + wavelengths = subplot.current_file.wavelengths + data = getattr(subplot.current_file, subplot.current_data_type, None) + + if data is not None and wavelengths is not None: + # Plot data + ax.plot(wavelengths, data, 'b-', linewidth=1.5) + ax.set_xlabel('Wavelength (nm)', fontsize=10) + ax.set_ylabel(subplot._get_ylabel(subplot.current_data_type), fontsize=10) + ax.set_title(f"{os.path.basename(subplot.current_file.filepath)}\n{subplot.current_data_type}", + fontsize=10, fontweight='bold') + ax.grid(True, alpha=0.3, linestyle='--') + + # Tight layout and save + fig.tight_layout() + fig.savefig(filepath, dpi=dpi, bbox_inches='tight') + plt.close(fig) + + def clear_all(self): + """Clear all subplots""" + for subplot in self.subplots: + subplot.clear() diff --git a/gui/widgets/overlay_plot_widget.py b/gui/widgets/overlay_plot_widget.py new file mode 100644 index 0000000..07aebae --- /dev/null +++ b/gui/widgets/overlay_plot_widget.py @@ -0,0 +1,222 @@ +""" +Overlay plot widget for displaying multiple spectra on a single plot +""" + +import os +import logging +from typing import List +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QCheckBox, QPushButton, QDialog, + QDialogButtonBox) +from PyQt6.QtCore import Qt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.figure import Figure +import numpy as np + +logger = logging.getLogger(__name__) + + +class OverlayPlotDialog(QDialog): + """ + Dialog for displaying multiple spectra overlaid on a single plot + """ + + # Color cycle for different spectra + COLORS = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'] + + def __init__(self, asd_files: List, parent=None): + super().__init__(parent) + self.asd_files = asd_files + self.setWindowTitle(f"Overlay Plot - {len(asd_files)} Files") + self.setGeometry(100, 100, 1200, 800) + self._init_ui() + self.update_plot() + + def _init_ui(self): + """Initialize UI""" + layout = QVBoxLayout(self) + + # Control panel + control_layout = QHBoxLayout() + + # Data type selector + control_layout.addWidget(QLabel("Data Type:")) + self.data_type_combo = QComboBox() + self.data_type_combo.addItems([ + 'digitalNumber', + 'reflectance', + 'reflectance1stDeriv', + 'reflectance2ndDeriv', + 'reflectance3rdDeriv', + 'whiteReference', + 'absoluteReflectance', + 'log1R', + 'log1R1stDeriv', + 'log1R2ndDeriv', + 'radiance', + ]) + self.data_type_combo.setCurrentText('reflectance') + self.data_type_combo.currentTextChanged.connect(self.update_plot) + control_layout.addWidget(self.data_type_combo) + + # Show grid checkbox + self.grid_checkbox = QCheckBox("Show Grid") + self.grid_checkbox.setChecked(True) + self.grid_checkbox.stateChanged.connect(self.update_plot) + control_layout.addWidget(self.grid_checkbox) + + # Show legend checkbox + self.legend_checkbox = QCheckBox("Show Legend") + self.legend_checkbox.setChecked(True) + self.legend_checkbox.stateChanged.connect(self.update_plot) + control_layout.addWidget(self.legend_checkbox) + + control_layout.addStretch() + + # Statistics checkbox + self.stats_checkbox = QCheckBox("Show Statistics") + self.stats_checkbox.setChecked(False) + self.stats_checkbox.stateChanged.connect(self.update_plot) + control_layout.addWidget(self.stats_checkbox) + + # Export button + export_btn = QPushButton("Export Plot") + export_btn.clicked.connect(self._export_plot) + control_layout.addWidget(export_btn) + + layout.addLayout(control_layout) + + # Create matplotlib figure and canvas + self.figure = Figure(figsize=(12, 8)) + self.canvas = FigureCanvas(self.figure) + self.toolbar = NavigationToolbar(self.canvas, self) + + layout.addWidget(self.toolbar) + layout.addWidget(self.canvas) + + # Info label + self.info_label = QLabel("") + self.info_label.setStyleSheet("color: gray; font-size: 10px;") + layout.addWidget(self.info_label) + + # Close button + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + button_box.rejected.connect(self.close) + layout.addWidget(button_box) + + # Create plot axes + self.ax = self.figure.add_subplot(111) + + def update_plot(self): + """Update the overlay plot""" + self.ax.clear() + + data_type = self.data_type_combo.currentText() + plotted_count = 0 + all_data = [] + + # Plot each spectrum + for idx, asd_file in enumerate(self.asd_files): + wavelengths = asd_file.wavelengths + data = getattr(asd_file, data_type, None) + + if data is None or wavelengths is None: + continue + + # Get color from cycle + color = self.COLORS[idx % len(self.COLORS)] + + # Plot with label + filename = os.path.basename(asd_file.filepath) + self.ax.plot(wavelengths, data, color=color, linewidth=1.5, + label=filename, alpha=0.8) + + all_data.append(data) + plotted_count += 1 + + if plotted_count == 0: + self.ax.text(0.5, 0.5, f'No data available for {data_type}', + ha='center', va='center', transform=self.ax.transAxes, + fontsize=12, color='red') + self.canvas.draw() + return + + # Add statistics if requested + if self.stats_checkbox.isChecked() and all_data: + all_data_array = np.array(all_data) + mean_data = np.mean(all_data_array, axis=0) + std_data = np.std(all_data_array, axis=0) + + # Plot mean + self.ax.plot(wavelengths, mean_data, 'k--', linewidth=2, + label='Mean', alpha=0.7) + + # Plot std deviation band + self.ax.fill_between(wavelengths, + mean_data - std_data, + mean_data + std_data, + color='gray', alpha=0.2, + label='±1 Std Dev') + + # Set labels and title + self.ax.set_xlabel('Wavelength (nm)', fontsize=12) + self.ax.set_ylabel(self._get_ylabel(data_type), fontsize=12) + self.ax.set_title(f'Overlay Plot: {data_type}\n({plotted_count} spectra)', + fontsize=14, fontweight='bold') + + # Grid + if self.grid_checkbox.isChecked(): + self.ax.grid(True, alpha=0.3, linestyle='--') + + # Legend + if self.legend_checkbox.isChecked(): + # Place legend outside plot area for better visibility + self.ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), + fontsize=9) + + # Update info label + self.info_label.setText(f"Displaying {plotted_count} of {len(self.asd_files)} spectra") + + self.figure.tight_layout() + self.canvas.draw() + + def _get_ylabel(self, data_type: str) -> str: + """Get Y-axis label""" + ylabel_map = { + 'digitalNumber': 'Digital Number', + 'reflectance': 'Reflectance', + 'reflectance1stDeriv': '1st Derivative', + 'reflectance2ndDeriv': '2nd Derivative', + 'reflectance3rdDeriv': '3rd Derivative', + 'whiteReference': 'White Reference', + 'absoluteReflectance': 'Absolute Reflectance', + 'log1R': 'Log(1/R)', + 'log1R1stDeriv': 'Log(1/R) 1st Derivative', + 'log1R2ndDeriv': 'Log(1/R) 2nd Derivative', + 'radiance': 'Radiance (W/m²/sr/nm)', + } + return ylabel_map.get(data_type, data_type) + + def _export_plot(self): + """Export the overlay plot""" + from PyQt6.QtWidgets import QFileDialog + + filepath, _ = QFileDialog.getSaveFileName( + self, + "Export Overlay Plot", + "overlay_plot.png", + "PNG Files (*.png);;SVG Files (*.svg);;PDF Files (*.pdf);;All Files (*.*)" + ) + + if filepath: + try: + self.figure.savefig(filepath, dpi=300, bbox_inches='tight') + from PyQt6.QtWidgets import QMessageBox + QMessageBox.information(self, "Export Successful", + f"Plot exported to:\n{filepath}") + except Exception as e: + from PyQt6.QtWidgets import QMessageBox + QMessageBox.critical(self, "Export Failed", + f"Failed to export plot:\n{str(e)}") diff --git a/gui/widgets/plot_widget.py b/gui/widgets/plot_widget.py new file mode 100644 index 0000000..365a5fc --- /dev/null +++ b/gui/widgets/plot_widget.py @@ -0,0 +1,223 @@ +""" +Plot widget for spectral data visualization +""" + +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLabel, QCheckBox, QPushButton +from PyQt6.QtCore import pyqtSignal +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar +from matplotlib.figure import Figure +import numpy as np + + +class PlotWidget(QWidget): + """ + Widget for plotting spectral data from ASD files + """ + + PLOT_TYPES = { + 'Digital Number': 'digitalNumber', + 'Reflectance': 'reflectance', + 'Reflectance (1st Derivative)': 'reflectance1stDeriv', + 'Reflectance (2nd Derivative)': 'reflectance2ndDeriv', + 'Reflectance (3rd Derivative)': 'reflectance3rdDeriv', + 'White Reference': 'whiteReference', + 'Absolute Reflectance': 'absoluteReflectance', + 'Log(1/R)': 'log1R', + 'Log(1/R) (1st Derivative)': 'log1R1stDeriv', + 'Log(1/R) (2nd Derivative)': 'log1R2ndDeriv', + 'Radiance': 'radiance', + } + + def __init__(self, parent=None): + super().__init__(parent) + self.asd_file = None + self.init_ui() + + def init_ui(self): + """Initialize the user interface""" + layout = QVBoxLayout(self) + + # Control panel + control_layout = QHBoxLayout() + + # Plot type selector + control_layout.addWidget(QLabel("Data Type:")) + self.plot_type_combo = QComboBox() + self.plot_type_combo.addItems(self.PLOT_TYPES.keys()) + self.plot_type_combo.currentTextChanged.connect(self.update_plot) + control_layout.addWidget(self.plot_type_combo) + + # Show grid checkbox + self.grid_checkbox = QCheckBox("Show Grid") + self.grid_checkbox.setChecked(True) + self.grid_checkbox.stateChanged.connect(self.update_plot) + control_layout.addWidget(self.grid_checkbox) + + # Show legend checkbox + self.legend_checkbox = QCheckBox("Show Legend") + self.legend_checkbox.setChecked(True) + self.legend_checkbox.stateChanged.connect(self.update_plot) + control_layout.addWidget(self.legend_checkbox) + + control_layout.addStretch() + + # Clear plot button + clear_btn = QPushButton("Clear Plot") + clear_btn.clicked.connect(self.clear_plot) + control_layout.addWidget(clear_btn) + + layout.addLayout(control_layout) + + # Create matplotlib figure and canvas + self.figure = Figure(figsize=(10, 6)) + self.canvas = FigureCanvas(self.figure) + self.toolbar = NavigationToolbar(self.canvas, self) + + layout.addWidget(self.toolbar) + layout.addWidget(self.canvas) + + # Create initial plot + self.ax = self.figure.add_subplot(111) + self.ax.set_xlabel('Wavelength (nm)') + self.ax.set_ylabel('Value') + self.ax.set_title('Spectral Data') + self.ax.grid(True) + + def set_asd_file(self, asd_file): + """ + Set the ASD file to display + + Args: + asd_file: ASDFile object + """ + self.asd_file = asd_file + self.update_plot() + + def get_data_for_plot_type(self, plot_type_name): + """ + Get data array for the selected plot type + + Args: + plot_type_name: Display name of plot type + + Returns: + tuple: (data, ylabel) or (None, None) if data not available + """ + if self.asd_file is None: + return None, None + + attr_name = self.PLOT_TYPES[plot_type_name] + + try: + data = getattr(self.asd_file, attr_name, None) + + if data is None: + return None, None + + # Determine y-axis label + if 'derivative' in plot_type_name.lower(): + ylabel = f'{plot_type_name}' + elif 'digital' in plot_type_name.lower(): + ylabel = 'Digital Number' + elif 'log' in plot_type_name.lower(): + ylabel = 'Log(1/R)' + elif 'radiance' in plot_type_name.lower(): + ylabel = 'Radiance (W/m²/sr/nm)' + elif 'reflectance' in plot_type_name.lower(): + ylabel = 'Reflectance' + else: + ylabel = plot_type_name + + return data, ylabel + + except Exception as e: + print(f"Error getting data for {plot_type_name}: {e}") + return None, None + + def update_plot(self): + """Update the plot with current settings""" + if self.asd_file is None: + return + + plot_type = self.plot_type_combo.currentText() + data, ylabel = self.get_data_for_plot_type(plot_type) + + if data is None: + self.ax.clear() + self.ax.text(0.5, 0.5, f'Data not available for {plot_type}', + ha='center', va='center', transform=self.ax.transAxes, + fontsize=12, color='red') + self.canvas.draw() + return + + wavelengths = self.asd_file.wavelengths + + if wavelengths is None: + self.ax.clear() + self.ax.text(0.5, 0.5, 'Wavelength data not available', + ha='center', va='center', transform=self.ax.transAxes, + fontsize=12, color='red') + self.canvas.draw() + return + + # Clear and plot + self.ax.clear() + + # Plot data + self.ax.plot(wavelengths, data, 'b-', linewidth=1.5, label=plot_type) + + # Set labels and title + self.ax.set_xlabel('Wavelength (nm)', fontsize=11) + self.ax.set_ylabel(ylabel, fontsize=11) + + # Add file name to title + filename = self.asd_file.filename if hasattr(self.asd_file, 'filename') else 'Unknown' + self.ax.set_title(f'{plot_type} - {filename}', fontsize=12, fontweight='bold') + + # Grid + if self.grid_checkbox.isChecked(): + self.ax.grid(True, alpha=0.3) + + # Legend + if self.legend_checkbox.isChecked(): + self.ax.legend(loc='best') + + # Add metadata annotations if available + if hasattr(self.asd_file, 'metadata') and self.asd_file.metadata: + metadata = self.asd_file.metadata + info_text = [] + + if hasattr(metadata, 'instrumentModel'): + info_text.append(f"Instrument: {metadata.instrumentModel}") + if hasattr(metadata, 'channels'): + info_text.append(f"Channels: {metadata.channels}") + + if info_text: + self.ax.text(0.02, 0.98, '\n'.join(info_text), + transform=self.ax.transAxes, + fontsize=9, verticalalignment='top', + bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)) + + self.figure.tight_layout() + self.canvas.draw() + + def clear_plot(self): + """Clear the current plot""" + self.asd_file = None + self.ax.clear() + self.ax.set_xlabel('Wavelength (nm)') + self.ax.set_ylabel('Value') + self.ax.set_title('Spectral Data') + self.ax.grid(True) + self.canvas.draw() + + def export_figure(self, filepath, dpi=300): + """ + Export the current figure to a file + + Args: + filepath: Path to save the figure + dpi: Resolution in dots per inch + """ + self.figure.savefig(filepath, dpi=dpi, bbox_inches='tight') diff --git a/gui/widgets/properties_panel.py b/gui/widgets/properties_panel.py new file mode 100644 index 0000000..1b99b5f --- /dev/null +++ b/gui/widgets/properties_panel.py @@ -0,0 +1,641 @@ +""" +Properties panel for displaying file information, metadata, and data types + +Phase 1 - Simplified version with 3 tabs: +- Tab 1: File Information +- Tab 2: Metadata (reuse existing MetadataWidget) +- Tab 3: Available Data Types +""" + +import os +from typing import Optional +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QTabWidget, QScrollArea, + QLabel, QGroupBox, QFormLayout, QPushButton, + QHBoxLayout, QCheckBox) +from PyQt6.QtCore import Qt, pyqtSignal +from pyASDReader import ASDFile + + +class FileInfoTab(QScrollArea): + """ + File information tab (Phase 1 - Simplified) + + Displays basic file information: + - Name, path, size + - Creation/modification times + - ASD file version + """ + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + + content = QWidget() + layout = QVBoxLayout(content) + + # Basic information group + basic_group = QGroupBox("Basic Information") + basic_layout = QFormLayout() + + self.name_label = QLabel("--") + basic_layout.addRow("Name:", self.name_label) + + self.path_label = QLabel("--") + self.path_label.setWordWrap(True) + self.path_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + basic_layout.addRow("Path:", self.path_label) + + self.size_label = QLabel("--") + basic_layout.addRow("Size:", self.size_label) + + self.created_label = QLabel("--") + basic_layout.addRow("Created:", self.created_label) + + self.modified_label = QLabel("--") + basic_layout.addRow("Modified:", self.modified_label) + + basic_group.setLayout(basic_layout) + layout.addWidget(basic_group) + + # ASD format information + format_group = QGroupBox("ASD Format") + format_layout = QFormLayout() + + self.version_label = QLabel("--") + format_layout.addRow("File Version:", self.version_label) + + self.channels_label = QLabel("--") + format_layout.addRow("Channels:", self.channels_label) + + self.wavelength_range_label = QLabel("--") + format_layout.addRow("Wavelength Range:", self.wavelength_range_label) + + format_group.setLayout(format_layout) + layout.addWidget(format_group) + + # Action buttons + button_layout = QHBoxLayout() + + self.copy_btn = QPushButton("Copy Info") + self.copy_btn.clicked.connect(self._copy_info) + button_layout.addWidget(self.copy_btn) + + self.show_dir_btn = QPushButton("Show in Folder") + self.show_dir_btn.clicked.connect(self._show_in_folder) + button_layout.addWidget(self.show_dir_btn) + + layout.addLayout(button_layout) + + layout.addStretch() + self.setWidget(content) + + self.current_filepath = None + + def display(self, asd_file: ASDFile): + """Display file information""" + self.current_filepath = asd_file.filepath + + # Basic info + self.name_label.setText(os.path.basename(asd_file.filepath)) + self.path_label.setText(str(asd_file.filepath)) + + try: + size = os.path.getsize(asd_file.filepath) + size_str = self._format_size(size) + self.size_label.setText(size_str) + except: + self.size_label.setText("--") + + # Timestamps + try: + import time + ctime = os.path.getctime(asd_file.filepath) + mtime = os.path.getmtime(asd_file.filepath) + self.created_label.setText(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ctime))) + self.modified_label.setText(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(mtime))) + except: + self.created_label.setText("--") + self.modified_label.setText("--") + + # ASD format info + self.version_label.setText(f"v{asd_file.asdFileVersion.value}") + + if asd_file.wavelengths is not None: + self.channels_label.setText(str(len(asd_file.wavelengths))) + wl_range = f"{asd_file.wavelengths[0]:.1f} - {asd_file.wavelengths[-1]:.1f} nm" + self.wavelength_range_label.setText(wl_range) + else: + self.channels_label.setText("--") + self.wavelength_range_label.setText("--") + + def clear(self): + """Clear all labels""" + self.name_label.setText("--") + self.path_label.setText("--") + self.size_label.setText("--") + self.created_label.setText("--") + self.modified_label.setText("--") + self.version_label.setText("--") + self.channels_label.setText("--") + self.wavelength_range_label.setText("--") + self.current_filepath = None + + def _format_size(self, size: int) -> str: + """Format file size""" + if size < 1024: + return f"{size} B" + elif size < 1024 * 1024: + return f"{size/1024:.1f} KB" + else: + return f"{size/1024/1024:.1f} MB" + + def _copy_info(self): + """Copy file info to clipboard""" + if not self.current_filepath: + return + + from PyQt6.QtWidgets import QApplication + info = f"""File: {self.name_label.text()} +Path: {self.path_label.text()} +Size: {self.size_label.text()} +Created: {self.created_label.text()} +Modified: {self.modified_label.text()} +Version: {self.version_label.text()} +Channels: {self.channels_label.text()} +Wavelength Range: {self.wavelength_range_label.text()}""" + + QApplication.clipboard().setText(info) + + def _show_in_folder(self): + """Show file in system file manager""" + if not self.current_filepath: + return + + import subprocess + import platform + + if platform.system() == "Windows": + subprocess.run(['explorer', '/select,', self.current_filepath]) + elif platform.system() == "Darwin": # macOS + subprocess.run(['open', '-R', self.current_filepath]) + else: # Linux + subprocess.run(['xdg-open', os.path.dirname(self.current_filepath)]) + + +class DataTypesTab(QScrollArea): + """ + Data types availability tab (Phase 1 - Simplified) + + Shows which data types are available in the current file + """ + + load_requested = pyqtSignal(str) # data_type + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + + content = QWidget() + self.layout = QVBoxLayout(content) + + # Title + title = QLabel("Available Data Types") + self.layout.addWidget(title) + + # Data type checkboxes + self.data_type_widgets = {} + + data_types = [ + ('digitalNumber', 'Digital Number (DN)'), + ('whiteReference', 'White Reference'), + ('reflectance', 'Reflectance'), + ('reflectance1stDeriv', 'Reflectance (1st Derivative)'), + ('reflectance2ndDeriv', 'Reflectance (2nd Derivative)'), + ('reflectance3rdDeriv', 'Reflectance (3rd Derivative)'), + ('absoluteReflectance', 'Absolute Reflectance'), + ('log1R', 'Log(1/R)'), + ('log1R1stDeriv', 'Log(1/R) 1st Derivative'), + ('log1R2ndDeriv', 'Log(1/R) 2nd Derivative'), + ('radiance', 'Radiance'), + ] + + for data_type, display_name in data_types: + widget = DataTypeItemWidget(data_type, display_name) + widget.load_clicked.connect(lambda dt=data_type: self.load_requested.emit(dt)) + self.data_type_widgets[data_type] = widget + self.layout.addWidget(widget) + + self.layout.addStretch() + self.setWidget(content) + + def display(self, asd_file: ASDFile): + """Display data type availability""" + for data_type, widget in self.data_type_widgets.items(): + data = getattr(asd_file, data_type, None) + available = data is not None + widget.set_available(available) + + def clear(self): + """Clear all data type status""" + for widget in self.data_type_widgets.values(): + widget.set_available(False) + + +class DataTypeItemWidget(QWidget): + """Single data type item widget""" + + load_clicked = pyqtSignal() + + def __init__(self, data_type: str, display_name: str): + super().__init__() + self.data_type = data_type + + layout = QHBoxLayout(self) + layout.setContentsMargins(5, 2, 5, 2) + + # Status icon + self.status_icon = QLabel("❓") + layout.addWidget(self.status_icon) + + # Name + name_label = QLabel(display_name) + layout.addWidget(name_label) + + layout.addStretch() + + # Load button + self.load_btn = QPushButton("Load") + self.load_btn.setMaximumWidth(60) + self.load_btn.clicked.connect(self.load_clicked) + layout.addWidget(self.load_btn) + + def set_available(self, available: bool): + """Set availability status""" + if available: + self.status_icon.setText("✅") + self.load_btn.setEnabled(True) + else: + self.status_icon.setText("❌") + self.load_btn.setEnabled(False) + + +class CalibrationTab(QScrollArea): + """ + Calibration information tab (Phase 3) + + Displays calibration data from ASD file (v7+): + - Calibration header information + - Calibration series data (ABS, BSE, LMP, FO) + """ + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + + content = QWidget() + layout = QVBoxLayout(content) + + # Header group + header_group = QGroupBox("Calibration Header") + header_layout = QFormLayout() + + self.calib_count_label = QLabel("--") + header_layout.addRow("Calibration Count:", self.calib_count_label) + + header_group.setLayout(header_layout) + layout.addWidget(header_group) + + # Calibration series details + self.series_group = QGroupBox("Calibration Series") + self.series_layout = QVBoxLayout() + self.series_group.setLayout(self.series_layout) + layout.addWidget(self.series_group) + + # Data availability + data_group = QGroupBox("Calibration Data Availability") + data_layout = QFormLayout() + + self.abs_label = QLabel("--") + data_layout.addRow("Absolute (ABS):", self.abs_label) + + self.bse_label = QLabel("--") + data_layout.addRow("Base (BSE):", self.bse_label) + + self.lmp_label = QLabel("--") + data_layout.addRow("Lamp (LMP):", self.lmp_label) + + self.fo_label = QLabel("--") + data_layout.addRow("Fiber Optic (FO):", self.fo_label) + + data_group.setLayout(data_layout) + layout.addWidget(data_group) + + layout.addStretch() + self.setWidget(content) + + def display(self, asd_file: ASDFile): + """Display calibration information""" + # Clear previous series info + while self.series_layout.count(): + child = self.series_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + if asd_file.calibrationHeader is None: + self.calib_count_label.setText("Not available (file version < 7)") + self.abs_label.setText("--") + self.bse_label.setText("--") + self.lmp_label.setText("--") + self.fo_label.setText("--") + return + + # Header info + self.calib_count_label.setText(str(asd_file.calibrationHeader.calibrationNum)) + + # Series details + if asd_file.calibrationHeader.calibrationSeries: + for i, series in enumerate(asd_file.calibrationHeader.calibrationSeries): + cb_type, cb_name, cb_it, cb_s1gain, cb_s2gain = series + + series_widget = QGroupBox(f"Series {i+1}: {cb_type.name}") + series_layout = QFormLayout() + series_layout.addRow("Type:", QLabel(cb_type.name)) + series_layout.addRow("Name:", QLabel(cb_name)) + series_layout.addRow("Integration Time:", QLabel(f"{cb_it} ms")) + series_layout.addRow("SWIR1 Gain:", QLabel(str(cb_s1gain))) + series_layout.addRow("SWIR2 Gain:", QLabel(str(cb_s2gain))) + series_widget.setLayout(series_layout) + + self.series_layout.addWidget(series_widget) + + # Data availability + self.abs_label.setText("✅ Available" if asd_file.calibrationSeriesABS is not None else "❌ Not available") + self.bse_label.setText("✅ Available" if asd_file.calibrationSeriesBSE is not None else "❌ Not available") + self.lmp_label.setText("✅ Available" if asd_file.calibrationSeriesLMP is not None else "❌ Not available") + self.fo_label.setText("✅ Available" if asd_file.calibrationSeriesFO is not None else "❌ Not available") + + def clear(self): + """Clear all labels""" + self.calib_count_label.setText("--") + self.abs_label.setText("--") + self.bse_label.setText("--") + self.lmp_label.setText("--") + self.fo_label.setText("--") + + # Clear series widgets + while self.series_layout.count(): + child = self.series_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + +class HistoryTab(QScrollArea): + """ + Processing history tab (Phase 3) + + Displays audit log from ASD file (v8+) + """ + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + + content = QWidget() + layout = QVBoxLayout(content) + + # Header + header_label = QLabel("Audit Log") + layout.addWidget(header_label) + + # Event count + self.count_label = QLabel("Events: --") + layout.addWidget(self.count_label) + + # Event list + self.event_list = QLabel() + self.event_list.setWordWrap(True) + self.event_list.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.event_list.setStyleSheet("font-family: monospace; font-size: 9pt;") + layout.addWidget(self.event_list) + + layout.addStretch() + self.setWidget(content) + + def display(self, asd_file: ASDFile): + """Display audit log""" + if asd_file.auditLog is None: + self.count_label.setText("Audit log not available (file version < 8)") + self.event_list.setText("") + return + + event_count = asd_file.auditLog.auditCount + self.count_label.setText(f"Events: {event_count}") + + if event_count > 0 and asd_file.auditLog.auditEvents: + event_text = "" + for i, event in enumerate(asd_file.auditLog.auditEvents, 1): + event_text += f"{i}. {event}\n" + self.event_list.setText(event_text) + else: + self.event_list.setText("No audit events recorded") + + def clear(self): + """Clear display""" + self.count_label.setText("Events: --") + self.event_list.setText("") + + +class SystemTab(QScrollArea): + """ + System information tab (Phase 3) + + Displays system and instrument information + """ + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + + content = QWidget() + layout = QVBoxLayout(content) + + # Instrument group + instrument_group = QGroupBox("Instrument Information") + instrument_layout = QFormLayout() + + self.instrument_type_label = QLabel("--") + instrument_layout.addRow("Type:", self.instrument_type_label) + + self.instrument_model_label = QLabel("--") + instrument_layout.addRow("Model:", self.instrument_model_label) + + self.serial_label = QLabel("--") + instrument_layout.addRow("Serial Number:", self.serial_label) + + instrument_group.setLayout(instrument_layout) + layout.addWidget(instrument_group) + + # Spectra info group + spectra_group = QGroupBox("Spectral Configuration") + spectra_layout = QFormLayout() + + self.spectra_type_label = QLabel("--") + spectra_layout.addRow("Spectrum Type:", self.spectra_type_label) + + self.channels_label = QLabel("--") + spectra_layout.addRow("Number of Channels:", self.channels_label) + + self.wavelength_range_label = QLabel("--") + spectra_layout.addRow("Wavelength Range:", self.wavelength_range_label) + + self.splice1_label = QLabel("--") + spectra_layout.addRow("Splice Point 1:", self.splice1_label) + + self.splice2_label = QLabel("--") + spectra_layout.addRow("Splice Point 2:", self.splice2_label) + + spectra_group.setLayout(spectra_layout) + layout.addWidget(spectra_group) + + # Acquisition settings group + acquisition_group = QGroupBox("Acquisition Settings") + acquisition_layout = QFormLayout() + + self.integration_time_label = QLabel("--") + acquisition_layout.addRow("Integration Time:", self.integration_time_label) + + self.fo_label = QLabel("--") + acquisition_layout.addRow("Foreoptic:", self.fo_label) + + self.dark_current_label = QLabel("--") + acquisition_layout.addRow("Dark Current Correction:", self.dark_current_label) + + acquisition_group.setLayout(acquisition_layout) + layout.addWidget(acquisition_group) + + layout.addStretch() + self.setWidget(content) + + def display(self, asd_file: ASDFile): + """Display system information""" + metadata = asd_file.metadata + + # Instrument + self.instrument_type_label.setText(str(metadata.instrumentType.name)) + self.instrument_model_label.setText(str(metadata.instrumentModel.name)) + self.serial_label.setText(str(metadata.instrumentNum)) + + # Spectra config + self.spectra_type_label.setText(str(metadata.spectraType.name)) + self.channels_label.setText(str(metadata.channels)) + + if asd_file.wavelengths is not None: + wl_range = f"{asd_file.wavelengths[0]:.1f} - {asd_file.wavelengths[-1]:.1f} nm" + self.wavelength_range_label.setText(wl_range) + else: + self.wavelength_range_label.setText("--") + + self.splice1_label.setText(f"{metadata.splice1Wave} nm" if metadata.splice1Wave else "--") + self.splice2_label.setText(f"{metadata.splice2Wave} nm" if metadata.splice2Wave else "--") + + # Acquisition + self.integration_time_label.setText(str(metadata.intergrationTime.name)) + self.fo_label.setText(str(metadata.fo)) + self.dark_current_label.setText("✅ Enabled" if metadata.darkCurrentCorrention else "❌ Disabled") + + def clear(self): + """Clear all labels""" + self.instrument_type_label.setText("--") + self.instrument_model_label.setText("--") + self.serial_label.setText("--") + self.spectra_type_label.setText("--") + self.channels_label.setText("--") + self.wavelength_range_label.setText("--") + self.splice1_label.setText("--") + self.splice2_label.setText("--") + self.integration_time_label.setText("--") + self.fo_label.setText("--") + self.dark_current_label.setText("--") + + +class PropertiesPanel(QWidget): + """ + Properties panel (Phase 3 - Complete version) + + Contains 7 tabs: + - File Information + - Metadata (reuses MetadataWidget) + - Data Types + - Calibration (Phase 3) + - History (Phase 3) + - System (Phase 3) + - Statistics (Phase 4 - NEW) + """ + + def __init__(self): + super().__init__() + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + # Title + title = QLabel("Properties") + layout.addWidget(title) + + # Tab widget + self.tabs = QTabWidget() + self.tabs.setTabPosition(QTabWidget.TabPosition.North) + + # Tab 1: File Information + self.file_info_tab = FileInfoTab() + self.tabs.addTab(self.file_info_tab, "File") + + # Tab 2: Metadata (import existing widget) + from gui.widgets.metadata_widget import MetadataWidget + self.metadata_tab = MetadataWidget() + self.tabs.addTab(self.metadata_tab, "Metadata") + + # Tab 3: Data Types + self.data_types_tab = DataTypesTab() + self.tabs.addTab(self.data_types_tab, "Data Types") + + # Tab 4: Calibration (Phase 3 - NEW) + self.calibration_tab = CalibrationTab() + self.tabs.addTab(self.calibration_tab, "Calibration") + + # Tab 5: History (Phase 3 - NEW) + self.history_tab = HistoryTab() + self.tabs.addTab(self.history_tab, "History") + + # Tab 6: System (Phase 3 - NEW) + self.system_tab = SystemTab() + self.tabs.addTab(self.system_tab, "System") + + # Tab 7: Statistics (Phase 4 - NEW) + from gui.widgets.statistics_widget import StatisticsWidget + self.statistics_tab = StatisticsWidget() + self.tabs.addTab(self.statistics_tab, "Statistics") + + layout.addWidget(self.tabs) + + def set_asd_file(self, asd_file: ASDFile): + """Set current ASD file and update all tabs""" + self.file_info_tab.display(asd_file) + self.metadata_tab.set_asd_file(asd_file) + self.data_types_tab.display(asd_file) + self.calibration_tab.display(asd_file) + self.history_tab.display(asd_file) + self.system_tab.display(asd_file) + self.statistics_tab.update_statistics(asd_file, 'reflectance') + + def clear(self): + """Clear all tabs""" + self.file_info_tab.clear() + self.metadata_tab.clear() + self.data_types_tab.clear() + self.calibration_tab.clear() + self.history_tab.clear() + self.system_tab.clear() + self.statistics_tab.clear() diff --git a/gui/widgets/statistics_widget.py b/gui/widgets/statistics_widget.py new file mode 100644 index 0000000..2063e88 --- /dev/null +++ b/gui/widgets/statistics_widget.py @@ -0,0 +1,222 @@ +""" +Statistics widget for displaying spectral data statistics +""" + +import numpy as np +from typing import Optional +from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QGroupBox, QFormLayout, + QLabel, QScrollArea) +from PyQt6.QtCore import Qt +from pyASDReader import ASDFile + + +class StatisticsWidget(QScrollArea): + """ + Widget for displaying statistics of spectral data + + Shows: + - Global statistics (min, max, mean, std, median) + - Per-band statistics (VNIR, SWIR1, SWIR2) + - Signal-to-noise ratio estimates + """ + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + self._init_ui() + + def _init_ui(self): + """Initialize UI""" + content = QWidget() + layout = QVBoxLayout(content) + + # Global statistics group + global_group = QGroupBox("Global Statistics") + global_layout = QFormLayout() + + self.min_label = QLabel("--") + global_layout.addRow("Minimum:", self.min_label) + + self.max_label = QLabel("--") + global_layout.addRow("Maximum:", self.max_label) + + self.mean_label = QLabel("--") + global_layout.addRow("Mean:", self.mean_label) + + self.median_label = QLabel("--") + global_layout.addRow("Median:", self.median_label) + + self.std_label = QLabel("--") + global_layout.addRow("Std Deviation:", self.std_label) + + self.range_label = QLabel("--") + global_layout.addRow("Range:", self.range_label) + + global_group.setLayout(global_layout) + layout.addWidget(global_group) + + # Per-band statistics + band_group = QGroupBox("Per-Band Statistics") + band_layout = QVBoxLayout() + + # VNIR band + self.vnir_group = QGroupBox("VNIR (350-1000 nm)") + vnir_layout = QFormLayout() + self.vnir_mean_label = QLabel("--") + vnir_layout.addRow("Mean:", self.vnir_mean_label) + self.vnir_std_label = QLabel("--") + vnir_layout.addRow("Std Dev:", self.vnir_std_label) + self.vnir_group.setLayout(vnir_layout) + band_layout.addWidget(self.vnir_group) + + # SWIR1 band + self.swir1_group = QGroupBox("SWIR1 (1000-1800 nm)") + swir1_layout = QFormLayout() + self.swir1_mean_label = QLabel("--") + swir1_layout.addRow("Mean:", self.swir1_mean_label) + self.swir1_std_label = QLabel("--") + swir1_layout.addRow("Std Dev:", self.swir1_std_label) + self.swir1_group.setLayout(swir1_layout) + band_layout.addWidget(self.swir1_group) + + # SWIR2 band + self.swir2_group = QGroupBox("SWIR2 (1800-2500 nm)") + swir2_layout = QFormLayout() + self.swir2_mean_label = QLabel("--") + swir2_layout.addRow("Mean:", self.swir2_mean_label) + self.swir2_std_label = QLabel("--") + swir2_layout.addRow("Std Dev:", self.swir2_std_label) + self.swir2_group.setLayout(swir2_layout) + band_layout.addWidget(self.swir2_group) + + band_group.setLayout(band_layout) + layout.addWidget(band_group) + + # Signal quality + quality_group = QGroupBox("Signal Quality") + quality_layout = QFormLayout() + + self.snr_label = QLabel("--") + quality_layout.addRow("SNR Estimate:", self.snr_label) + + self.saturation_label = QLabel("--") + quality_layout.addRow("Saturation:", self.saturation_label) + + quality_group.setLayout(quality_layout) + layout.addWidget(quality_group) + + layout.addStretch() + self.setWidget(content) + + def update_statistics(self, asd_file: Optional[ASDFile], data_type: str = 'reflectance'): + """ + Update statistics for given ASD file and data type + + Args: + asd_file: ASD file object + data_type: Type of data to calculate statistics for + """ + if asd_file is None: + self.clear() + return + + wavelengths = asd_file.wavelengths + data = getattr(asd_file, data_type, None) + + if data is None or wavelengths is None: + self.clear() + return + + # Global statistics + self.min_label.setText(f"{np.min(data):.6f}") + self.max_label.setText(f"{np.max(data):.6f}") + self.mean_label.setText(f"{np.mean(data):.6f}") + self.median_label.setText(f"{np.median(data):.6f}") + self.std_label.setText(f"{np.std(data):.6f}") + self.range_label.setText(f"{np.max(data) - np.min(data):.6f}") + + # Per-band statistics + self._calculate_band_statistics(wavelengths, data) + + # Signal quality + self._calculate_signal_quality(data, data_type) + + def _calculate_band_statistics(self, wavelengths: np.ndarray, data: np.ndarray): + """Calculate per-band statistics""" + # VNIR (350-1000 nm) + vnir_mask = (wavelengths >= 350) & (wavelengths <= 1000) + if np.any(vnir_mask): + vnir_data = data[vnir_mask] + self.vnir_mean_label.setText(f"{np.mean(vnir_data):.6f}") + self.vnir_std_label.setText(f"{np.std(vnir_data):.6f}") + else: + self.vnir_mean_label.setText("N/A") + self.vnir_std_label.setText("N/A") + + # SWIR1 (1000-1800 nm) + swir1_mask = (wavelengths > 1000) & (wavelengths <= 1800) + if np.any(swir1_mask): + swir1_data = data[swir1_mask] + self.swir1_mean_label.setText(f"{np.mean(swir1_data):.6f}") + self.swir1_std_label.setText(f"{np.std(swir1_data):.6f}") + else: + self.swir1_mean_label.setText("N/A") + self.swir1_std_label.setText("N/A") + + # SWIR2 (1800-2500 nm) + swir2_mask = (wavelengths > 1800) & (wavelengths <= 2500) + if np.any(swir2_mask): + swir2_data = data[swir2_mask] + self.swir2_mean_label.setText(f"{np.mean(swir2_data):.6f}") + self.swir2_std_label.setText(f"{np.std(swir2_data):.6f}") + else: + self.swir2_mean_label.setText("N/A") + self.swir2_std_label.setText("N/A") + + def _calculate_signal_quality(self, data: np.ndarray, data_type: str): + """Calculate signal quality metrics""" + # SNR estimate (mean / std) + mean_val = np.mean(data) + std_val = np.std(data) + + if std_val > 0: + snr = mean_val / std_val + self.snr_label.setText(f"{snr:.2f}") + else: + self.snr_label.setText("N/A") + + # Check for saturation + # For reflectance, values should be between 0 and 1 + # For DN, check if values are at max (typically 65535 for 16-bit) + if data_type == 'reflectance' or 'reflectance' in data_type.lower(): + saturated = np.sum(data >= 1.0) + total = len(data) + saturation_pct = (saturated / total) * 100 + self.saturation_label.setText(f"{saturation_pct:.2f}% ({saturated}/{total} channels)") + elif data_type == 'digitalNumber': + # Check for 16-bit saturation + saturated = np.sum(data >= 65535) + total = len(data) + saturation_pct = (saturated / total) * 100 + self.saturation_label.setText(f"{saturation_pct:.2f}% ({saturated}/{total} channels)") + else: + self.saturation_label.setText("N/A") + + def clear(self): + """Clear all statistics""" + self.min_label.setText("--") + self.max_label.setText("--") + self.mean_label.setText("--") + self.median_label.setText("--") + self.std_label.setText("--") + self.range_label.setText("--") + + self.vnir_mean_label.setText("--") + self.vnir_std_label.setText("--") + self.swir1_mean_label.setText("--") + self.swir1_std_label.setText("--") + self.swir2_mean_label.setText("--") + self.swir2_std_label.setText("--") + + self.snr_label.setText("--") + self.saturation_label.setText("--") diff --git a/main.py b/main.py new file mode 100644 index 0000000..a59e81a --- /dev/null +++ b/main.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +""" +Main entry point for pyASDReader GUI application + +Usage: + python main.py [file.asd] + +Arguments: + file.asd: Optional ASD file to open on startup +""" + +import sys +from PyQt6.QtWidgets import QApplication +from gui.main_window import MainWindow + + +def main(): + """Main function to launch the GUI application""" + app = QApplication(sys.argv) + app.setApplicationName("pyASDReader") + app.setOrganizationName("pyASDReader") + + window = MainWindow() + window.show() + + # If a file path is provided as argument, load it + if len(sys.argv) > 1: + filepath = sys.argv[1] + window.load_asd_file(filepath) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index d8795c0..e2890d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,10 @@ docs = [ "sphinx>=4.0.0,<9.0.0", "sphinx-rtd-theme>=1.0.0,<4.0.0", ] +gui = [ + "PyQt6>=6.4.0,<7.0.0", + "matplotlib>=3.5.0,<4.0.0", +] all = [ # Include all optional dependencies (with version upper bounds) "pytest>=6.0.0,<9.0.0", @@ -73,6 +77,8 @@ all = [ "sphinx>=4.0.0,<9.0.0", "sphinx-rtd-theme>=1.0.0,<4.0.0", "keepachangelog>=2.0.0,<3.0.0", + "PyQt6>=6.4.0,<7.0.0", + "matplotlib>=3.5.0,<4.0.0", ] [project.urls]