diff --git a/.gitignore b/.gitignore
index e587af6..4f4926f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,41 @@
+# Python
__pycache__/
-*.pyc
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual Environment
.venv/
-env/
venv/
-.env
+env/
+
+# IDEs
.idea/
.vscode/
-*.log
-dist/
-build/
-*.egg-info/
-.DS_Store
-model_output/
+
+# Project Specific
*.pth
-*.pt
-*.h5
-*.ckpt
-*.onnx
+model_output/
+.gemini/
+.agent/
+brain/
+scratch/
+
+# Logs
+*.log
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7044efd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# NeuroSeg-Pro: MRI Segmentation & Visualization Tool
+
+NeuroSeg-Pro is a PyQt5-based application designed for visualizing and segmenting brain MRI scans using deep learning models. It provides a rich interface for comparing model outputs, ground truth, and difference maps.
+
+## π Key Features
+
+### Visualization
+- **Multi-Planar Reconstruction (MPR):** View Axial, Sagittal, and Coronal planes simultaneously.
+- **3D Viewport:** Interactive 3D visualization of segmentation masks (if enabled).
+- **Advanced Tools:**
+ - **Grid Overlay:** Helper grid for alignment.
+ - **Crosshair:** Navigate specific coordinates across all views.
+ - **MRI Visibility Toggle:** Focus on the mask by hiding the background MRI.
+
+### AI Analysis
+- **Model Selection:** Choose between different pre-trained models (e.g., Teacher/Student variants).
+- **Comparison Modes:**
+ - **Model A vs Model B:** Side-by-side comparison.
+ - **Model A vs Ground Truth:** Validate predictions against expert annotations.
+ - **Overlay vs Raw:** Compare active segmentation with the raw scan.
+- **Metrics:** Real-time calculation of Dice Coefficient, Sensitivity, Specificity, and Hausdorff Distance.
+
+### User Interface
+- **Modern Dark Theme:** "Deep Space Medical" theme for comfortable viewing.
+- **Dynamic Legend:** Legend updates automatically based on the classes present in the current view.
+- **One-Click Export:** Save segmentation masks as `.nii.gz` files.
+
+---
+
+## π οΈ Installation & Setup
+
+### Prerequisites
+- Python 3.8+
+- [Git](https://git-scm.com/)
+
+### 1. Clone the Repository
+```bash
+git clone https://github.com/yourusername/NeuroSeg-Pro.git
+cd NeuroSeg-Pro
+```
+
+### 2. Create a Virtual Environment (Recommended)
+```bash
+# Windows
+python -m venv .venv
+.venv\Scripts\activate
+
+# Linux/Mac
+python3 -m venv .venv
+source .venv/bin/activate
+```
+
+### 3. Install Dependencies
+```bash
+pip install -r requirements.txt
+```
+
+### 4. Setup Models
+> **Note:** Due to file size limits, pre-trained model weights (`.pth` files) are NOT included in this repository.
+> Please place your model files in the `models/` directory.
+
+Example structure:
+```
+NeuroSeg-Pro/
+βββ app/
+βββ models/
+β βββ Teacher_model_50.pth
+β βββ ...
+βββ assets/
+βββ ...
+```
+
+---
+
+## βΆοΈ Running the Application
+
+To launch the main application interface:
+
+```bash
+python -m app.main
+```
+
+---
+
+## π Project Structure
+
+- `app/` - Source code.
+ - `main.py` - Entry point.
+ - `ui/` - Interface widgets and themes.
+ - `core/` - Logic for inference and image processing.
+- `assets/` - Icons and resources.
+- `models/` - Directory for model weights (ignored by git).
+- `model_output/` - Saved segmentation masks.
+
+---
+
+## π€ Contributing
+Contributions are welcome! Please open an issue or submit a pull request for any improvements.
+
+## π License
+MIT License
diff --git a/app/core/inference.py b/app/core/inference.py
index 90bfe14..6b622fb 100644
--- a/app/core/inference.py
+++ b/app/core/inference.py
@@ -7,6 +7,7 @@ class InferenceEngine:
def __init__(self, model_path: str = None, device: str = None):
self.device = device if device else ("cuda" if torch.cuda.is_available() else "cpu")
self.model = None
+ self.current_model_path = None
if model_path:
self.load_model(model_path)
@@ -62,6 +63,7 @@ def load_model(self, model_path: str):
self.model.load_state_dict(new_state_dict, strict=False)
self.model.eval()
+ self.current_model_path = model_path
print(f"Model loaded from {model_path}")
except Exception as e:
print(f"Error loading model: {e}")
@@ -104,3 +106,12 @@ def predict(self, input_data: np.ndarray):
outputs = torch.nn.functional.interpolate(outputs, size=original_shape, mode='trilinear', align_corners=False)
return torch.argmax(outputs, dim=1).detach().cpu().numpy()[0] # Remove batch dim
+
+ def run_inference(self, volume: np.ndarray, model_path: str):
+ """
+ Loads the model if necessary and runs inference.
+ """
+ if self.current_model_path != model_path:
+ self.load_model(model_path)
+
+ return self.predict(volume)
diff --git a/app/ui/main_window.py b/app/ui/main_window.py
index 8470ce4..7438d34 100644
--- a/app/ui/main_window.py
+++ b/app/ui/main_window.py
@@ -14,6 +14,9 @@ class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.settings = Settings()
+ self.sidebar_expanded_width = 260
+ self.sidebar_collapsed_width = 76
+ self.sidebar_collapsed = bool(self.settings.get("sidebar_collapsed") or False)
self.setWindowTitle("Brain Tumor Segmentation Pro")
self.resize(1280, 850) # Slightly larger default
@@ -96,13 +99,30 @@ def setup_header(self):
self.right_layout.addWidget(self.header_frame)
def toggle_sidebar(self):
- if self.sidebar.isVisible():
- self.sidebar.hide()
+ self.sidebar_collapsed = not self.sidebar_collapsed
+ self.settings.set("sidebar_collapsed", self.sidebar_collapsed)
+ self.apply_sidebar_state()
+
+ def apply_sidebar_state(self):
+ if self.sidebar_collapsed:
+ self.sidebar.setFixedWidth(self.sidebar_collapsed_width)
+ self.title_label.setVisible(False)
+ self.btn_toggle_sidebar.setText("β·")
+ for btn in [self.btn_dashboard, self.btn_viewer, self.btn_help, self.btn_settings]:
+ btn.setText("")
+ btn.setToolTip(btn.property("full_text"))
else:
- self.sidebar.show()
+ self.sidebar.setFixedWidth(self.sidebar_expanded_width)
+ self.title_label.setVisible(True)
+ self.btn_toggle_sidebar.setText("β°")
+ for btn in [self.btn_dashboard, self.btn_viewer, self.btn_help, self.btn_settings]:
+ btn.setText(btn.property("full_text"))
+ btn.setToolTip("")
def on_recent_file_clicked(self, file_path):
- self.on_file_loaded(file_path, 'mri') # Default to MRI
+ import os
+ file_type = 'folder' if os.path.isdir(file_path) else 'mri'
+ self.on_file_loaded(file_path, file_type)
def on_file_loaded(self, file_path, file_type):
print(f"Loading {file_type}: {file_path}")
@@ -152,7 +172,7 @@ def refresh_theme(self):
def setup_sidebar(self):
self.sidebar = QFrame()
self.sidebar.setObjectName("Sidebar")
- self.sidebar.setFixedWidth(260)
+ self.sidebar.setFixedWidth(self.sidebar_expanded_width)
self.sidebar_layout = QVBoxLayout(self.sidebar)
self.sidebar_layout.setContentsMargins(20, 40, 20, 20)
@@ -190,6 +210,8 @@ def setup_sidebar(self):
self.btn_settings.clicked.connect(lambda: self.switch_page(2))
self.btn_help.clicked.connect(self.show_tutorial)
+ self.apply_sidebar_state()
+
def show_tutorial(self):
from app.ui.tutorial_dialog import TutorialDialog
dlg = TutorialDialog(self)
@@ -197,6 +219,7 @@ def show_tutorial(self):
def create_nav_button(self, text, icon_name=None):
btn = QPushButton(text)
+ btn.setProperty("full_text", text)
btn.setCheckable(True)
btn.setFixedHeight(50)
btn.setCursor(Qt.PointingHandCursor)
diff --git a/app/ui/theme.py b/app/ui/theme.py
index bca9bce..74937b2 100644
--- a/app/ui/theme.py
+++ b/app/ui/theme.py
@@ -119,22 +119,28 @@ def apply_theme(app: QApplication):
border-radius: 8px;
}}
- /* GroupBox - Modern & Minimal */
+ /* GroupBox - Modern & Block-like */
+ /* GroupBox - Modern & Block-like */
QGroupBox {{
background-color: {c["SURFACE"]};
border: 1px solid {c["BORDER"]};
- border-radius: 12px;
- margin-top: 24px;
- padding-top: 10px;
+ border-radius: 10px;
+ margin-top: 20px; /* Leave space for title */
+ padding-top: 15px;
+ padding-bottom: 10px;
+ padding-left: 10px;
+ padding-right: 10px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 0 5px;
left: 10px;
+ bottom: 0px; /* Overlap border */
color: {c["ACCENT"]};
font-weight: bold;
- font-size: {base_font_size + 1}px;
+ font-size: {base_font_size + 2}px;
+ background-color: {c["BACKGROUND"]}; /* Mask border behind title */
}}
/* Buttons */
@@ -327,8 +333,26 @@ def apply_theme(app: QApplication):
border: 1px solid {c["PRIMARY"]};
image: url(assets/icons/check.svg);
}}
- /* Fallback check */
- QCheckBox::indicator:checked {{
- background-color: {c["PRIMARY"]};
+ /* Table Widget */
+ QTableWidget {{
+ background-color: {c["SURFACE"]};
+ border: 1px solid {c["BORDER"]};
+ border-radius: 8px;
+ gridline-color: {c["BORDER"]};
+ }}
+ QHeaderView::section {{
+ background-color: {c["SURFACE_LIGHT"]};
+ padding: 4px;
+ border: none;
+ border-bottom: 2px solid {c["BORDER"]};
+ color: {c["TEXT_SECONDARY"]};
+ font-weight: bold;
+ }}
+ QTableWidget::item {{
+ padding: 5px;
+ }}
+ QTableWidget::item:selected {{
+ background-color: {c["PRIMARY"]}30;
+ color: {c["TEXT_PRIMARY"]};
}}
""")
diff --git a/app/ui/viewer_widget.py b/app/ui/viewer_widget.py
index a063391..f7afe8b 100644
--- a/app/ui/viewer_widget.py
+++ b/app/ui/viewer_widget.py
@@ -4,9 +4,9 @@
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton,
QSlider, QCheckBox, QGridLayout, QFrame, QSplitter, QGroupBox, QSizePolicy,
- QToolBox, QTableWidget, QTableWidgetItem, QHeaderView, QScrollArea
+ QToolBox, QTableWidget, QTableWidgetItem, QHeaderView, QScrollArea, QProgressBar, QMessageBox, QToolButton
)
-from PyQt5.QtCore import Qt, pyqtSignal, QSize
+from PyQt5.QtCore import Qt, pyqtSignal, QSize, QThread
from PyQt5.QtGui import QColor, QFont, QIcon
from app.ui.settings import Settings
@@ -14,6 +14,40 @@
from app.core.image_processor import ImageProcessor
from app.ui.theme import get_theme_palette, apply_theme
+class InferenceWorker(QThread):
+ finished = pyqtSignal(dict) # {model_name: prediction_array}
+ error = pyqtSignal(str)
+
+ def __init__(self, engine, input_vol, model_a_config, model_b_config=None):
+ super().__init__()
+ self.engine = engine
+ self.input_vol = input_vol
+ self.model_a_config = model_a_config
+ self.model_b_config = model_b_config
+
+ def run(self):
+ try:
+ results = {}
+
+ # Run Model A
+ if self.model_a_config and "path" in self.model_a_config:
+ print(f"Worker: Running Model A ({self.model_a_config['name']})...")
+ pred_a = self.engine.run_inference(self.input_vol, self.model_a_config["path"])
+ results['A'] = pred_a
+
+ # Run Model B
+ if self.model_b_config and "path" in self.model_b_config:
+ print(f"Worker: Running Model B ({self.model_b_config['name']})...")
+ pred_b = self.engine.run_inference(self.input_vol, self.model_b_config["path"])
+ results['B'] = pred_b
+
+ self.finished.emit(results)
+
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ self.error.emit(str(e))
+
class ViewerWidget(QWidget):
def __init__(self):
super().__init__()
@@ -90,14 +124,16 @@ def __init__(self):
# Controls Panel
self.controls = QFrame()
self.controls.setObjectName("Sidebar")
- # self.controls.setFixedWidth(350) # Remove fixed width to allow splitter resizing
- self.controls.setMinimumWidth(300) # Set minimum constraint
+ self.controls.setMinimumWidth(280)
+ self.controls.setMaximumWidth(460)
self.control_layout = QVBoxLayout(self.controls)
- self.control_layout.setContentsMargins(25, 25, 25, 25)
- self.control_layout.setSpacing(20)
+ self.control_layout.setContentsMargins(16, 16, 16, 16)
+ self.control_layout.setSpacing(14)
self.setup_controls()
+
+
# Scroll Area for Controls
from PyQt5.QtWidgets import QScrollArea
self.scroll_controls = QScrollArea()
@@ -114,9 +150,10 @@ def __init__(self):
# Set stretch factors: Viewport (index 0) gets all extra space (1), Sidebar (index 1) gets 0
self.splitter.setStretchFactor(0, 1)
self.splitter.setStretchFactor(1, 0)
+ self.splitter.setChildrenCollapsible(False)
# Set initial sizes [large width, sidebar width]
- self.splitter.setSizes([800, 350])
+ self.splitter.setSizes([900, 340])
self.layout.addWidget(self.splitter)
@@ -247,20 +284,8 @@ def create_3d_viewport(self):
view.opts['azimuth'] = 45
view.opts['elevation'] = 30
- # Create Grids
- gx = gl.GLGridItem()
- gx.rotate(90, 0, 1, 0)
- gx.translate(-100, 0, 0)
- view.addItem(gx)
-
- gy = gl.GLGridItem()
- gy.rotate(90, 1, 0, 0)
- gy.translate(0, -100, 0)
- view.addItem(gy)
-
- gz = gl.GLGridItem()
- gz.translate(0, 0, -100)
- view.addItem(gz)
+ # REMOVED: GLAxisItem and GLGridItem causing shader errors on some systems.
+ # Starting with clean empty view.
return view
@@ -282,63 +307,76 @@ def refresh_theme(self):
# v.title.setStyleSheet(f"color: {'#000' if is_light else '#fff'}; ...") # Optional text update
def setup_controls(self):
- # 1. AI & Segmentation Section
- self.tools_box = QToolBox()
- self.tools_box.setStyleSheet("background: transparent;") # Allow theme to propagate
- self.tools_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) # Ensure it expands
-
-
- # --- Page 1: Inference & Segmentation ---
- page_inference = QWidget()
- pi_layout = QVBoxLayout(page_inference)
- pi_layout.setSpacing(15)
+ # --- Section 1: AI Analysis ---
+ ai_group = QGroupBox("AI Analysis")
+ ai_layout = QVBoxLayout()
+ ai_layout.setSpacing(10)
# Modality Selection
- self.add_control_row(pi_layout, "Modality:", self.create_info_button("Select the MRI sequence to view."))
+ self.add_control_row(ai_layout, "Modality:", self.create_info_button("Select the MRI sequence to view."))
self.combo_modality = QComboBox()
self.combo_modality.addItem("T1")
self.combo_modality.currentTextChanged.connect(self.change_modality)
- pi_layout.addWidget(self.combo_modality)
+ ai_layout.addWidget(self.combo_modality)
# Model A Selection
- self.add_control_row(pi_layout, "Primary Model (A):", self.create_info_button("Main model for segmentation."))
+ self.add_control_row(ai_layout, "Primary Model (A):", self.create_info_button("Main model for segmentation."))
self.combo_model_a = QComboBox()
self.combo_model_a.currentIndexChanged.connect(lambda i: self.on_model_changed(i, 'A'))
- pi_layout.addWidget(self.combo_model_a)
+ ai_layout.addWidget(self.combo_model_a)
- # Model B Selection (For Comparison)
- self.add_control_row(pi_layout, "Secondary Model (B):", self.create_info_button("Optional model for comparison."))
+ # Model B Selection
+ self.add_control_row(ai_layout, "Secondary Model (B):", self.create_info_button("Optional model for comparison."))
self.combo_model_b = QComboBox()
- self.combo_model_b.addItem("None (Single Model Mode)")
+ self.combo_model_b.addItem("None (Single Model)")
self.combo_model_b.currentIndexChanged.connect(lambda i: self.on_model_changed(i, 'B'))
- pi_layout.addWidget(self.combo_model_b)
+ ai_layout.addWidget(self.combo_model_b)
+
+ # Run Button & Progress
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setVisible(False)
+ self.progress_bar.setStyleSheet("QProgressBar { height: 5px; text-align: center; }")
+ ai_layout.addWidget(self.progress_bar)
- # Run Button
self.btn_run = QPushButton("Run Segmentation")
- self.btn_run.setObjectName("AccentButton")
- self.btn_run.setFixedHeight(40)
+ self.btn_run.setObjectName("AccentButton")
+ self.btn_run.setFixedHeight(35)
self.btn_run.clicked.connect(self.run_segmentation)
- pi_layout.addWidget(self.btn_run)
+ ai_layout.addWidget(self.btn_run)
- pi_layout.addStretch()
+ ai_group.setLayout(ai_layout)
+ self.control_layout.addWidget(ai_group)
- # Fix: Add a wrapper QScrollArea to ensure layout respects size hints in Toolbox
- page_inference_scroll = QScrollArea()
- page_inference_scroll.setWidget(page_inference)
- page_inference_scroll.setWidgetResizable(True)
- page_inference_scroll.setStyleSheet("background: transparent; border: none;")
+ # --- Section 2: Visualization Control ---
+ viz_group = QGroupBox("Visualization Control")
+ viz_layout = QVBoxLayout()
+ viz_layout.setSpacing(8)
- self.tools_box.addItem(page_inference_scroll, "AI Analysis")
-
- self.control_layout.addWidget(self.tools_box)
-
- # 2. Visualization Section
- # --- Page 2: Visualization Options ---
- page_viz = QWidget()
- viz_layout = QVBoxLayout(page_viz)
- viz_layout.setSpacing(12)
-
- # Comparison Toggle
+ # Mask Opacity
+ viz_layout.addWidget(QLabel("Mask Transparency"))
+ self.slider_opacity = QSlider(Qt.Horizontal)
+ self.slider_opacity.setRange(0, 100)
+ self.slider_opacity.setValue(50)
+ self.slider_opacity.valueChanged.connect(self.update_opacity)
+ viz_layout.addWidget(self.slider_opacity)
+
+ # View Toggles
+ toggles_layout = QHBoxLayout()
+ self.chk_grid = QCheckBox("Grid")
+ self.chk_grid.toggled.connect(self.toggle_grid)
+ toggles_layout.addWidget(self.chk_grid)
+
+ self.chk_crosshair = QCheckBox("Crosshair")
+ self.chk_crosshair.toggled.connect(self.toggle_crosshair)
+ toggles_layout.addWidget(self.chk_crosshair)
+
+ self.chk_mri = QCheckBox("MRI")
+ self.chk_mri.setChecked(True)
+ self.chk_mri.toggled.connect(self.toggle_mri)
+ toggles_layout.addWidget(self.chk_mri)
+ viz_layout.addLayout(toggles_layout)
+
+ # Comparison Controls
self.btn_compare = QPushButton("Enable Split Comparison")
self.btn_compare.setCheckable(True)
self.btn_compare.toggled.connect(self.toggle_comparison)
@@ -347,74 +385,70 @@ def setup_controls(self):
self.compare_options = QWidget()
co_layout = QVBoxLayout(self.compare_options)
co_layout.setContentsMargins(0,0,0,0)
- co_layout.addWidget(QLabel("Compare Mode:"))
self.combo_compare_mode = QComboBox()
self.combo_compare_mode.addItems(["Model A vs Model B", "Model A vs Ground Truth", "Overlay vs Raw"])
self.combo_compare_mode.currentIndexChanged.connect(lambda: self.update_all_2d_views())
co_layout.addWidget(self.combo_compare_mode)
self.compare_options.setVisible(False)
viz_layout.addWidget(self.compare_options)
-
- # Opacity
- viz_layout.addWidget(QLabel("Mask Transparency"))
- self.slider_opacity = QSlider(Qt.Horizontal)
- self.slider_opacity.setRange(0, 100)
- self.slider_opacity.setValue(50)
- self.slider_opacity.valueChanged.connect(self.update_opacity)
- viz_layout.addWidget(self.slider_opacity)
-
- self.chk_mask = QCheckBox("Show Overlay")
- self.chk_mask.setChecked(True)
- self.chk_mask.toggled.connect(self.toggle_mask)
- viz_layout.addWidget(self.chk_mask)
-
- # Active Overlay Selection (Single View)
- viz_layout.addWidget(QLabel("Active Overlay (Single View):"))
+
+ # Active Overlay (Single Mode)
+ hbox_overlay = QHBoxLayout()
+ hbox_overlay.addWidget(QLabel("Overlay:"))
self.combo_overlay_mode = QComboBox()
self.combo_overlay_mode.addItems(["Model A", "Model B", "Ground Truth", "Difference (A vs GT)", "Difference (A vs B)"])
self.combo_overlay_mode.currentIndexChanged.connect(self.on_overlay_mode_changed)
- viz_layout.addWidget(self.combo_overlay_mode)
-
- viz_layout.addStretch()
- self.tools_box.addItem(page_viz, "Visualization Control")
+ hbox_overlay.addWidget(self.combo_overlay_mode)
+ viz_layout.addLayout(hbox_overlay)
- # 3. Legend & Metrics
- # 3. Legend & Metrics (Combined)
- results_group = QGroupBox("Results & Metrics")
- self.results_layout = QVBoxLayout()
+ viz_group.setLayout(viz_layout)
+ self.control_layout.addWidget(viz_group)
+
+ # --- Section 3: Results & Metrics ---
+ results_group = QGroupBox()
+ results_layout = QVBoxLayout()
+ results_layout.setSpacing(5)
+
+ # Custom Header with Info Button
+ r_header = QHBoxLayout()
+ r_label = QLabel("Results & Metrics")
+ r_label.setStyleSheet("font-weight: bold;")
+ r_header.addWidget(r_label)
+ r_header.addStretch()
+ self.btn_info = QToolButton()
+ self.btn_info.setText("?")
+ self.btn_info.setObjectName("InfoButton")
+ self.btn_info.setFixedSize(20, 20)
+ self.btn_info.clicked.connect(self.show_metrics_info)
+ r_header.addWidget(self.btn_info)
+ results_layout.addLayout(r_header)
- # Comparison Table
self.metrics_table = QTableWidget()
- self.metrics_table.setColumnCount(3) # Metric, Model A, Model B
+ self.metrics_table.setColumnCount(3)
self.metrics_table.setHorizontalHeaderLabels(["Metric", "Model A", "Model B"])
self.metrics_table.verticalHeader().setVisible(False)
self.metrics_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
- self.metrics_table.setFixedHeight(120)
- self.results_layout.addWidget(self.metrics_table)
+ self.metrics_table.setFixedHeight(100) # Slightly shorter
+ results_layout.addWidget(self.metrics_table)
- # Legend (Simplified)
+ # Legend container
self.legend_container = QWidget()
self.legend_layout = QVBoxLayout(self.legend_container)
- self.legend_layout.setContentsMargins(0, 0, 0, 0)
- self.legend_layout.setSpacing(5)
- self.results_layout.addWidget(self.legend_container)
+ self.legend_layout.setContentsMargins(0, 5, 0, 0)
+ self.legend_layout.setSpacing(2)
+ results_layout.addWidget(self.legend_container)
- results_group.setLayout(self.results_layout)
+ results_group.setLayout(results_layout)
self.control_layout.addWidget(results_group)
- self.control_layout.addStretch()
-
- # 4. Navigation & Export
- nav_group = QGroupBox("Navigation & Export")
+ # --- Section 4: Navigation & Export ---
+ nav_group = QGroupBox("Navigation")
nav_layout = QVBoxLayout()
- # Sliders
- # create_slice_slider now returns the container (QWidget) to prevent GC issues
self.sl_control_axial = self.create_slice_slider("Axial")
self.sl_control_sagittal = self.create_slice_slider("Sagittal")
self.sl_control_coronal = self.create_slice_slider("Coronal")
- # Store direct slider references for logic
self.sl_axial = self.sl_control_axial.slider
self.sl_sagittal = self.sl_control_sagittal.slider
self.sl_coronal = self.sl_control_coronal.slider
@@ -423,21 +457,26 @@ def setup_controls(self):
nav_layout.addWidget(self.sl_control_sagittal)
nav_layout.addWidget(self.sl_control_coronal)
- # Export Buttons
- export_layout = QHBoxLayout()
+ btns_layout = QHBoxLayout()
self.btn_export = QPushButton("Save .nii")
+ self.btn_export.setObjectName("AccentButton")
self.btn_export.clicked.connect(self.export_mask)
self.btn_export.setEnabled(False)
+ btns_layout.addWidget(self.btn_export)
self.btn_screenshot = QPushButton("Screenshot")
self.btn_screenshot.clicked.connect(self.save_screenshot)
+ btns_layout.addWidget(self.btn_screenshot)
- export_layout.addWidget(self.btn_export)
- export_layout.addWidget(self.btn_screenshot)
- nav_layout.addLayout(export_layout)
-
+ nav_layout.addLayout(btns_layout)
nav_group.setLayout(nav_layout)
self.control_layout.addWidget(nav_group)
+ self.control_layout.addStretch()
+
+ # Initialize Toggles
+ self.show_grid = False
+ self.show_crosshair = False
+ self.show_mri = True
# Initial Legend Update
self.update_legend()
@@ -446,14 +485,44 @@ def on_overlay_mode_changed(self):
self.update_legend()
self.update_all_2d_views()
- def update_legend(self):
+ # --- New Visualization Methods ---
+ def toggle_grid(self, checked):
+ self.show_grid = checked
+ for v in [self.axial_view, self.sagittal_view, self.coronal_view,
+ self.compare_view_axial, self.compare_view_sagittal, self.compare_view_coronal]:
+ # ViewBox doesn't support showGrid directly, use GridItem
+ if not hasattr(v, 'grid_item'):
+ v.grid_item = pg.GridItem()
+ v.view.addItem(v.grid_item)
+
+ v.grid_item.setVisible(checked)
+
+ def toggle_crosshair(self, checked):
+ self.show_crosshair = checked
+ self.update_all_2d_views() # Repaint to add/remove lines
+
+ def toggle_mri(self, checked):
+ self.show_mri = checked
+ self.update_all_2d_views()
+
+ def show_metrics_info(self):
+ info_text = """
+ Dice Coefficient: Measures overlap between prediction and ground truth (1.0 is perfect).
+ Sensitivity (Recall): Percentage of actual tumor correctly detected.
+ Specificity: Percentage of healthy tissue correctly identified.
+ Hausdorff Distance: Maximum distance between prediction boundary and ground truth boundary (lower is better).
+ """
+ QMessageBox.information(self, "Metrics Explanation", info_text)
+
+ def update_legend(self, mode=None):
# Clear existing
for i in reversed(range(self.legend_layout.count())):
item = self.legend_layout.itemAt(i)
if item.widget():
item.widget().setParent(None)
-
- overlay_text = self.combo_overlay_mode.currentText()
+
+ current_combo = self.combo_overlay_mode.currentText()
+ target_mode = mode if mode else current_combo
def add_item(color, label):
row = QWidget()
@@ -461,7 +530,6 @@ def add_item(color, label):
l.setContentsMargins(0,0,0,0)
box = QLabel()
box.setFixedSize(14, 14)
- # Use border-radius for cleaner look
box.setStyleSheet(f"background-color: {color}; border-radius: 3px; border: 1px solid #333;")
lbl = QLabel(label)
lbl.setStyleSheet("font-size: 10pt; padding-left: 5px;")
@@ -470,20 +538,40 @@ def add_item(color, label):
l.addStretch()
self.legend_layout.addWidget(row)
- # Add Header
- header = QLabel("Legend:")
- header.setStyleSheet("font-weight: bold; color: #8B5CF6; margin-bottom: 5px;")
- self.legend_layout.addWidget(header)
+ # Define Known Classes
+ # 1: NCR (Red), 2: ED (Green), 4/3: ET (Yellow)
- if overlay_text == "Difference Map":
- add_item("#FF3B30", "False Positive (Red)")
- add_item("#007AFF", "False Negative (Blue)")
- add_item("#34C759", "True Positive (Green)")
+ if target_mode == "Difference Map":
+ add_item("#FF3B30", "False Positive (Red)")
+ add_item("#007AFF", "False Negative (Blue)")
+ add_item("#34C759", "True Positive (Green)")
else:
- # Standard / GT / Intersection
- add_item("#FF3B30", "Necrosis (NCR) - Red")
- add_item("#34C759", "Edema (ED) - Green")
- add_item("#FFCC00", "Enhancing (ET) - Yellow")
+ # DYNAMIC LEGEND for Standard Mode
+ # If we have a mask, check its values
+ active_mask = self.prediction_a if self.prediction_a is not None else self.mask
+
+ present_classes = set()
+ if active_mask is not None:
+ present_classes = set(np.unique(active_mask).astype(int))
+
+ # Keep legend simplified if no mask loaded yet, otherwise show only present
+ if not present_classes and active_mask is None:
+ # Show all by default if nothing loaded
+ add_item("#FF3B30", "Necrosis (NCR)")
+ add_item("#34C759", "Edema (ED)")
+ add_item("#FFCC00", "Enhancing (ET)")
+ else:
+ if 1 in present_classes: add_item("#FF3B30", "Necrosis (NCR)")
+ if 2 in present_classes: add_item("#34C759", "Edema (ED)")
+ if 4 in present_classes or 3 in present_classes: add_item("#FFCC00", "Enhancing (ET)")
+
+ if len(present_classes) <= 1 and 0 in present_classes:
+ lbl = QLabel("No tumor detected")
+ lbl.setStyleSheet("color: #777; font-style: italic;")
+ self.legend_layout.addWidget(lbl)
+
+
+
def create_slice_slider(self, label):
container = QWidget()
@@ -512,8 +600,8 @@ def toggle_comparison(self, checked):
def update_slice(self, plane, value, label_widget):
self.current_slice[plane] = value
label_widget.setText(f"{plane.capitalize()}: {value}")
- # If in comparison mode, we update both views if axial
- if self.comparison_mode and plane == 'axial':
+ # In comparison mode every slice affects both panes.
+ if self.comparison_mode:
self.update_all_2d_views()
else:
self.update_view(plane)
@@ -572,31 +660,63 @@ def on_model_changed(self, index, which='A'):
def run_segmentation(self):
if self.volume is None: return
- model_a_name = self.combo_model_a.currentText()
- model_b_name = self.combo_model_b.currentText()
- is_dual = self.combo_model_b.itemData(self.combo_model_b.currentIndex()) is not None
+ model_a_data = self.combo_model_a.currentData()
+ model_b_data = self.combo_model_b.currentData()
self.btn_run.setText("Inference Running...")
self.btn_run.setEnabled(False)
+ self.progress_bar.setVisible(True)
+ self.progress_bar.setRange(0, 0) # Indeterminate
QFrame.repaint(self)
- raw_vol = self.patient_data.get(self.active_modality, self.volume)
+ # --- Prepare Multimodal Input (4 Channels) ---
+ # BraTS models expect [T1, T1ce, T2, FLAIR]
+ required_keys = ['t1', 't1ce', 't2', 'flair']
+ channels = []
- # Run Model A
- print(f"Running Model A: {model_a_name}")
- self.prediction_a = self.inference_engine.run_inference(raw_vol, model_a_name)
+ # Determine base shape/volume to use for zeros
+ base_vol = self.volume if self.volume is not None else list(self.patient_data.values())[0]
+ base_shape = base_vol.shape
+
+ # Case-insensitive lookup
+ patient_data_lower = {k.lower(): v for k, v in self.patient_data.items()}
+
+ for key in required_keys:
+ if key in patient_data_lower:
+ vol = patient_data_lower[key]
+ channels.append(ImageProcessor.normalize(vol))
+ else:
+ # Missing modality -> Zero channel
+ channels.append(np.zeros(base_shape, dtype=np.float32))
+
+ input_vol = np.stack(channels, axis=0) # (4, D, H, W)
+
+ # Start Worker
+ self.worker = InferenceWorker(self.inference_engine, input_vol, model_a_data, model_b_data)
+ self.worker.finished.connect(self.on_inference_finished)
+ self.worker.error.connect(self.on_inference_error)
+ self.worker.start()
+
+ def on_inference_finished(self, results):
+ self.progress_bar.setVisible(False)
+ self.btn_run.setText("Run Segmentation")
+ self.btn_run.setEnabled(True)
+
+ self.prediction_a = results.get('A')
+ self.prediction_b = results.get('B')
+
+ # Debug Output
+ if self.prediction_a is not None:
+ unique = np.unique(self.prediction_a)
+ print(f"Prediction A Stats: Shape={self.prediction_a.shape}, Unique Values={unique}")
+ if len(unique) == 1 and unique[0] == 0:
+ QMessageBox.warning(self, "Inference Result", "Model returned empty segmentation (all zeros). Check input data orientation or normalization.")
- # Run Model B (if selected)
- if is_dual:
- print(f"Running Model B: {model_b_name}")
- self.prediction_b = self.inference_engine.run_inference(raw_vol, model_b_name)
- else:
- self.prediction_b = None
-
# Metrics
if 'seg' in self.patient_data:
self.ground_truth = self.patient_data['seg']
- self.metrics_a = ImageProcessor.calculate_metrics(self.prediction_a, self.ground_truth)
+ if self.prediction_a is not None:
+ self.metrics_a = ImageProcessor.calculate_metrics(self.prediction_a, self.ground_truth)
if self.prediction_b is not None:
self.metrics_b = ImageProcessor.calculate_metrics(self.prediction_b, self.ground_truth)
@@ -606,11 +726,17 @@ def run_segmentation(self):
self.prediction = self.prediction_a # Default "Prediction" is A
self.mask = self.prediction_a
- self.btn_run.setText("Run Segmentation")
- self.btn_run.setEnabled(True)
self.btn_export.setEnabled(True)
self.setup_sliders_and_views()
+ QMessageBox.information(self, "Success", "Segmentation completed successfully.")
+
+ def on_inference_error(self, error_msg):
+ self.progress_bar.setVisible(False)
+ self.btn_run.setText("Run Segmentation")
+ self.btn_run.setEnabled(True)
+ QMessageBox.critical(self, "Inference Error", f"An error occurred during inference:\n{error_msg}")
+ print(f"Worker Error: {error_msg}")
def update_metrics_table(self):
self.metrics_table.clearContents()
@@ -775,55 +901,98 @@ def update_all_2d_views(self):
left_mask = self.prediction_a
right_mask = None
+ left_title_suffix = " (Prediction A)"
+ right_title_suffix = ""
# --- Logic for Comparison Mode (Split Screen) ---
if self.comparison_mode:
if mode == "Model A vs Model B":
left_mask = self.prediction_a
right_mask = self.prediction_b
+ left_title_suffix = " (Model A)"
+ right_title_suffix = " (Model B)"
+ if self.prediction_b is None: right_title_suffix += " [Not Run]"
+
elif mode == "Model A vs Ground Truth":
left_mask = self.prediction_a
right_mask = self.ground_truth
+ left_title_suffix = " (Model A)"
+ right_title_suffix = " (Ground Truth)"
+ if self.ground_truth is None: right_title_suffix += " [Missing]"
+
elif mode == "Overlay vs Raw":
left_mask = self.prediction_a
right_mask = None # Raw only
+ left_title_suffix = " (Overlay)"
+ right_title_suffix = " (Raw MRI)"
+ # Update Titles
+ self.axial_view.title.setText(f"Axial{left_title_suffix}")
+ self.sagittal_view.title.setText(f"Sagittal{left_title_suffix}")
+ self.coronal_view.title.setText(f"Coronal{left_title_suffix}")
+
+ self.compare_view_axial.title.setText(f"Axial{right_title_suffix}")
+ self.compare_view_sagittal.title.setText(f"Sagittal{right_title_suffix}")
+ self.compare_view_coronal.title.setText(f"Coronal{right_title_suffix}")
+
# Update Left (Main) Views
self.update_view('axial', dest_viewport=self.axial_view, override_mask=left_mask)
self.update_view('sagittal', dest_viewport=self.sagittal_view, override_mask=left_mask)
self.update_view('coronal', dest_viewport=self.coronal_view, override_mask=left_mask)
# Update Right (Compare) Views
- self.update_view('axial', dest_viewport=self.compare_view_axial, override_mask=right_mask, force_no_mask=(right_mask is None))
- self.update_view('sagittal', dest_viewport=self.compare_view_sagittal, override_mask=right_mask, force_no_mask=(right_mask is None))
- self.update_view('coronal', dest_viewport=self.compare_view_coronal, override_mask=right_mask, force_no_mask=(right_mask is None))
+ force_no_right = (right_mask is None)
+ self.update_view('axial', dest_viewport=self.compare_view_axial, override_mask=right_mask, force_no_mask=force_no_right)
+ self.update_view('sagittal', dest_viewport=self.compare_view_sagittal, override_mask=right_mask, force_no_mask=force_no_right)
+ self.update_view('coronal', dest_viewport=self.compare_view_coronal, override_mask=right_mask, force_no_mask=force_no_right)
else:
# --- Logic for Single Mode ---
- # Use 'active_overlay_mode' dropdown
active_mask = None
is_diff = False
+ title_suffix = ""
if overlay_mode == "Model A":
active_mask = self.prediction_a
+ title_suffix = " (Model A)"
elif overlay_mode == "Model B":
active_mask = self.prediction_b
+ title_suffix = " (Model B)"
elif overlay_mode == "Ground Truth":
active_mask = self.ground_truth
+ title_suffix = " (Ground Truth)"
elif overlay_mode == "Difference (A vs GT)":
if self.prediction_a is not None and self.ground_truth is not None:
active_mask = ImageProcessor.calculate_difference_map(self.prediction_a, self.ground_truth)
is_diff = True
+ title_suffix = " (Diff A vs GT)"
+ else:
+ title_suffix = " (Diff - Missing Data)"
elif overlay_mode == "Difference (A vs B)":
if self.prediction_a is not None and self.prediction_b is not None:
active_mask = ImageProcessor.calculate_difference_map(self.prediction_a, self.prediction_b)
is_diff = True
+ title_suffix = " (Diff A vs B)"
+ else:
+ title_suffix = " (Diff - Missing Data)"
else: # Fallback Standard
active_mask = self.prediction_a if self.prediction_a is not None else self.mask
+ title_suffix = " (Prediction)"
+ # Update Titles
+ self.axial_view.title.setText(f"Axial{title_suffix}")
+ self.sagittal_view.title.setText(f"Sagittal{title_suffix}")
+ self.coronal_view.title.setText(f"Coronal{title_suffix}")
+
self.update_view('axial', override_mask=active_mask, is_diff_map=is_diff)
self.update_view('sagittal', override_mask=active_mask, is_diff_map=is_diff)
self.update_view('coronal', override_mask=active_mask, is_diff_map=is_diff)
+
+ # Update Legend
+ # Assuming update_legend exists (it's called elsewhere potentially)
+ if hasattr(self, 'update_legend'):
+ legend_mode = "Difference Map" if is_diff else "Standard"
+ self.update_legend(legend_mode)
def update_view(self, plane, dest_viewport=None, override_mask=None, force_no_mask=False, is_diff_map=False):
if self.volume is None: return
@@ -840,7 +1009,68 @@ def update_view(self, plane, dest_viewport=None, override_mask=None, force_no_ma
# Update Main Image Use Float32 and levels=(0,1)
img_data = slice_img.T.astype(np.float32)
- target.img.setImage(img_data, autoLevels=False, levels=(0, 1))
+
+ if self.show_mri:
+ target.img.setImage(img_data, autoLevels=False, levels=(0, 1))
+ target.img.setVisible(True)
+ else:
+ target.img.setVisible(False)
+
+ # Draw Crosshair if enabled
+ # Remove old crosshair lines if any
+ if hasattr(target, 'crosshair_v'):
+ target.view.removeItem(target.crosshair_v)
+ target.view.removeItem(target.crosshair_h)
+ del target.crosshair_v
+ del target.crosshair_h
+
+ if self.show_crosshair:
+ # Add new lines
+ v_line = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen('y', width=1, style=Qt.DashLine))
+ h_line = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen('y', width=1, style=Qt.DashLine))
+
+ # Position based on current slices in OTHER planes.
+ # Axial View (X=Sagittal Slice, Y=Coronal Slice) - Approximate mapping
+ # This is complex without affine. For now, center on current slice or middle?
+ # Ideally crosshair should point to where the other views are slicing.
+ # But here we just want a visual center or cursor?
+ # User likely wants to see WHERE the other 2 views are intersecting.
+
+ # Simple implementation: Center of view (D/2, W/2 etc) or just middle marker?
+ # Better: Crosshair tracks the cursor? Or shows the intersection of the other 2 planes.
+ # Let's assume standard Ortho:
+ # Axial View shows X/Y. The "Z" is the slice index.
+ # The intersection point is (Sag_Slice, Cor_Slice).
+
+ # Need strict dimension mapping:
+ # Dim Order: (Sag, Cor, Axial) -> (0, 1, 2)
+ # Axial View (Plane 'axial', index 2): Shows dims (0, 1) -> (Sag, Cor)
+ # Correct? default behavior of slice(volume, 'axial', z) depends on get_slice
+ # ImageProcessor.get_slice:
+ # if plane == 'axial': return vol[:, :, idx] -> (Sag, Cor)
+
+ x_pos = 0
+ y_pos = 0
+
+ if plane == 'axial':
+ x_pos = self.current_slice['sagittal']
+ y_pos = self.current_slice['coronal']
+ elif plane == 'sagittal': # (Cor, Axial)
+ # get_slice 'sagittal' -> vol[idx, :, :] -> (Cor, Axial)
+ x_pos = self.current_slice['coronal']
+ y_pos = self.current_slice['axial']
+ elif plane == 'coronal': # (Sag, Axial)
+ # get_slice 'coronal' -> vol[:, idx, :] -> (Sag, Axial)
+ x_pos = self.current_slice['sagittal']
+ y_pos = self.current_slice['axial']
+
+ v_line.setPos(x_pos)
+ h_line.setPos(y_pos)
+
+ target.view.addItem(v_line)
+ target.view.addItem(h_line)
+ target.crosshair_v = v_line
+ target.crosshair_h = h_line
# Update Mask Overlay
mask_to_use = override_mask if override_mask is not None else self.mask
@@ -848,6 +1078,10 @@ def update_view(self, plane, dest_viewport=None, override_mask=None, force_no_ma
if mask_to_use is not None and (self.show_mask and not force_no_mask):
mask_slice = ImageProcessor.get_slice(mask_to_use, plane, idx)
+ # DEBUG: Print unique values ONLY for axial center slice to avoid spam
+ if plane == 'axial' and idx == self.current_slice['axial']:
+ pass # print(f"DEBUG: Mask Slice Unique Values: {np.unique(mask_slice)}")
+
h, w = mask_slice.shape
rgba = np.zeros((h, w, 4), dtype=np.uint8)
@@ -867,21 +1101,22 @@ def update_view(self, plane, dest_viewport=None, override_mask=None, force_no_ma
# Label 1 (NCR/NET) -> Red
idx1 = mask_slice == 1
rgba[idx1, 0] = 255 # R
- rgba[idx1, 3] = 200 # A
+ rgba[idx1, 3] = 200
# Label 2 (ED) -> Green
idx2 = mask_slice == 2
rgba[idx2, 1] = 255 # G
- rgba[idx2, 3] = 200 # A
+ rgba[idx2, 3] = 200
- # Label 4 (ET) -> Yellow
- idx4 = mask_slice == 4
+ # Label 4 (ET) OR Label 3 -> Yellow
+ # Some models output 3, some 4 for Enhancing Tumor
+ idx4 = (mask_slice == 4) | (mask_slice == 3)
rgba[idx4, 0] = 255 # R
rgba[idx4, 1] = 255 # G
- rgba[idx4, 3] = 200 # A
+ rgba[idx4, 3] = 200
- # Fallback for any other positive value (e.g. 3 if present)
- idx_other = (mask_slice > 0) & (mask_slice != 1) & (mask_slice != 2) & (mask_slice != 4)
+ # Fallback for any unexpected positive value
+ idx_other = (mask_slice > 0) & (mask_slice != 1) & (mask_slice != 2) & (mask_slice != 4) & (mask_slice != 3)
rgba[idx_other, 0] = 255
rgba[idx_other, 2] = 255 # Magenta
rgba[idx_other, 3] = 200
diff --git a/docs/ui_ux_upgrade_blueprint.md b/docs/ui_ux_upgrade_blueprint.md
new file mode 100644
index 0000000..963406a
--- /dev/null
+++ b/docs/ui_ux_upgrade_blueprint.md
@@ -0,0 +1,231 @@
+# NeuroSeg Pro β UI/UX & Viewer Modernization Blueprint
+
+## 1) High-priority issues observed in current app
+
+### A. Layout and drawer behavior
+1. **Main left navigation is a hard hide/show instead of a responsive/docked drawer**, which can cause unstable content reflow and poor small-screen behavior. The app currently hides the sidebar widget directly with no animated width policy, breakpoint behavior, or persisted collapsed state. (`toggle_sidebar()` with `hide()/show()`).
+2. **Left navigation width is fixed at 260 px**, which reduces usable viewport area and does not adapt to window size (`setFixedWidth(260)`).
+3. **Right control panel has a minimum width of 300 px with large interior margins**, increasing crowding risk in narrower windows and causing apparent overlap/truncation in dense states (`setMinimumWidth(300)`, margins `25,25,25,25`).
+4. **Control stack order is awkward for long panels** because a stretch is inserted before the Navigation section, making scrolling and visual grouping less predictable (`self.control_layout.addStretch()` before Section 4).
+
+### B. Comparison workflow / slice sync correctness
+5. **In comparison mode, only axial slider updates both panes; sagittal/coronal slider updates can go stale** because `update_slice()` refreshes all views only when plane is axial.
+6. **Recent-file loading always assumes MRI type**, which can mis-handle previously loaded masks/folders from history (`on_recent_file_clicked(..., 'mri')`).
+7. **Model B placeholder labels are inconsistent**, increasing UX ambiguity (`"None (Single Model Mode)"` vs `"None (Single Model)"`).
+
+### C. 3D viewer capability gap
+8. **3D volume rendering is effectively a placeholder** (`pass` for volume data), so users do not get real volumetric MRI context.
+9. **3D segmentation rendering is a single-color downsampled scatter cloud**, with no class-aware coloring, no mesh, no ground-truth/model toggles, no clipping planes, and no quantitative overlays.
+
+### D. Metrics and explainability gaps
+10. **Metrics table displays Dice only** although helper text references sensitivity/specificity/Hausdorff; this mismatch can confuse users and reduces trust.
+11. **No per-class confidence/uncertainty visualization, no threshold sweep, no calibration plot, and no model-vs-model delta table**, despite comparison mode intent.
+
+### E. Interaction and clarity gaps
+12. **Legend behavior is mode-dependent and not fully explicit for all states**, especially when masks are missing or model B is absent.
+13. **Crosshair mapping is manually inferred and brittle**, increasing risk of orientation confusion (especially with non-canonical affine orientations).
+14. **Screenshot export saves only axial viewport by default**, not full multi-pane comparative layouts.
+
+---
+
+## 2) Feature expansion roadmap (professional-grade)
+
+### Foundation (must-have)
+- Responsive app shell with:
+ - Collapsible left nav (expanded, icon-only, hidden)
+ - Resizable right inspector with min/max constraints
+ - Breakpoints for laptop/desktop/ultrawide
+ - Persisted layout state (Qt settings)
+- Deterministic viewport layout manager:
+ - Grid presets (2x2, 3x2 compare, 1x3 strips, focus mode)
+ - No overlap under any window width >= 1200
+- Canonical orientation engine:
+ - RAS/LPS handling
+ - explicit orientation badges (A/P, R/L, S/I markers)
+
+### Clinical comparison & explainability
+- True **multi-model workspace**:
+ - Compare N models (not only A/B)
+ - Baseline pinning
+ - Pairwise and aggregate ranking
+- Comparison modes:
+ - Checkerboard, swipe, split, blink, XOR/difference heatmap
+ - TP/FP/FN class-specific overlay controls
+- Metrics:
+ - Dice, IoU, Precision, Recall, Specificity, HD95, ASSD, Volume Error
+ - per-class + whole tumor + confidence intervals
+ - table + sparkline trend + export CSV/JSON
+
+### 3D viewer next level
+- Real 3D rendering stack:
+ - MRI volume ray casting
+ - Segmentation isosurface extraction (marching cubes)
+ - class-specific opacity and color LUT
+ - synchronized 2D/3D crosshair and linked camera presets
+- Advanced controls:
+ - clipping planes (axial/sagittal/coronal)
+ - ROI box tool
+ - class visibility matrix
+ - lighting/shading presets (clinical, contrast, print)
+
+### Productivity and professionalism
+- Workspace presets (Radiologist, ML Engineer, Reviewer)
+- Session timeline (all runs, model versions, metrics snapshots)
+- Reproducibility metadata panel (model hash, preprocessing hash, spacing, orientation)
+- Report generator (PDF/HTML): images + metrics + method card + signoff
+
+---
+
+## 3) Detailed prompt to give your coding agent
+
+Use the prompt below as-is.
+
+```text
+You are a senior medical-imaging product engineer. Implement a production-grade UI/UX overhaul and 3D visualization upgrade for NeuroSeg Pro (PyQt5 + pyqtgraph stack) while preserving existing inference flows.
+
+## Product objective
+Transform the app into a robust, professional, clinically legible segmentation analysis workstation.
+Primary outcomes:
+1) Zero layout overlap/truncation in normal desktop ranges.
+2) High-confidence model-vs-ground-truth and model-vs-model comparison workflows.
+3) Advanced, performant 3D viewing with clear class semantics.
+4) Measurable usability and reliability improvements with tests.
+
+## Non-functional constraints
+- Keep existing inference interfaces stable unless absolutely necessary.
+- Maintain backward compatibility for current settings and model registration where possible.
+- Avoid blocking UI thread during heavy rendering/inference.
+- Ensure memory-safe behavior with large NIfTI volumes.
+- Add feature flags for experimental modules.
+
+## Architecture tasks
+1. Introduce a responsive layout manager layer:
+ - Replace ad-hoc hide/show sidebar with stateful drawer controller:
+ states = {expanded, collapsed-icons, hidden}
+ - Persist state in settings.
+ - Add window-resize breakpoints with deterministic panel min/max widths.
+ - Enforce splitter constraints so no panel overlaps another.
+
+2. Refactor viewer into modular components:
+ - ViewportManager (2D panes, sync, orientation markers)
+ - OverlayManager (mask composition, LUT, opacity, blending mode)
+ - ComparisonEngine (A/B/N comparisons, diff maps, TP/FP/FN)
+ - MetricsEngine UI adapter (table + chart model)
+ - Viewer3DController (volume + surface + scatter fallback)
+
+3. Standardize data contracts:
+ - Create typed structures for:
+ VolumeData, SegmentationMask, ModelRunResult, MetricsBundle.
+ - Explicitly carry spacing, affine, orientation labels.
+ - Add validation utilities and user-facing error messages.
+
+## UI/UX implementation details
+1. Shell and drawers
+ - Left nav:
+ - animated width transitions
+ - compact icon mode with tooltips
+ - Right inspector:
+ - tabbed groups: Analysis, Overlays, Metrics, Export
+ - sticky run controls at top
+ - sticky slice controls at bottom
+ - Add command palette (Ctrl/Cmd+K) for actions.
+
+2. 2D viewports
+ - Add orientation tags (R/L/A/P/S/I) in each pane corner.
+ - Add scale bar and voxel coordinate readout.
+ - Fully synchronized crosshair for axial/sagittal/coronal in all modes.
+ - Add viewport presets and one-click reset camera/zoom.
+
+3. Overlay system
+ - Implement class LUT editor (with reset defaults).
+ - Add blending modes: alpha, additive, multiply, outline-only.
+ - Independent opacity sliders per class and per source (A, B, GT).
+ - Missing-data states must show clear neutral UI badges.
+
+4. Comparison modes
+ - Support:
+ - Model A vs GT
+ - Model B vs GT
+ - A vs B
+ - A vs B vs GT (tri-compare)
+ - Visual methods:
+ - split slider
+ - checkerboard
+ - blink
+ - TP/FP/FN map with legend and counts
+ - Keep titles and legends always synchronized with active mode.
+
+5. Metrics & analytics
+ - Extend metrics table columns:
+ Dice, IoU, Precision, Recall, Specificity, HD95, ASSD, Volume(ml), ΞVolume.
+ - Add class rows: NCR, ED, ET, WT.
+ - Add mini charts:
+ - per-class bar chart for A/B/GT deltas
+ - trend chart across runs
+ - Add CSV/JSON export for metrics and overlay counts.
+
+## 3D viewer overhaul
+1. Rendering
+ - Replace placeholder 3D volume code with actual rendering pipeline.
+ - Implement:
+ - MRI volume rendering (ray-march or slice stack fallback)
+ - Segmentation surfaces via marching cubes
+ - class-wise mesh coloring
+ - Keep a fallback low-memory mode.
+
+2. Controls
+ - Camera presets: axial, sagittal, coronal, iso-left, iso-right.
+ - Lighting controls: ambient/diffuse/specular sliders.
+ - Clip planes linked to 2D slice sliders.
+ - Toggle visibility for MRI volume / A / B / GT / diff surface.
+
+3. Performance
+ - Cache generated meshes by (mask_hash, class, threshold).
+ - Debounce high-frequency UI events.
+ - Background thread for mesh generation and metric-heavy ops.
+
+## Reliability & QA
+1. Add automated checks:
+ - layout integrity tests (no overlapping widgets at key widths)
+ - slice-sync tests across all 3 planes in compare mode
+ - legend-title consistency tests
+ - metrics-calculation smoke tests for A-only, A+B, A+GT, A+B+GT
+2. Add manual QA script:
+ - scenario-based checklist for loading modalities, inference, compare, export.
+3. Add telemetry hooks (local logs only):
+ - inference duration
+ - rendering FPS snapshots
+ - memory warnings
+
+## Migration + rollout
+- Phase 1: Fix critical layout and sync bugs first.
+- Phase 2: Metrics and comparison expansion.
+- Phase 3: Advanced 3D rendering and performance optimization.
+- Provide changelog and user-facing help updates per phase.
+
+## Acceptance criteria
+- No visual overlap in left/right drawers and no clipped controls at 1280x800 and above.
+- Comparison mode updates all panes correctly for axial/sagittal/coronal slider moves.
+- 3D panel supports class-aware rendering and synchronized clip planes.
+- Metrics panel shows expanded metrics with export.
+- User can clearly interpret colors/legends in every mode.
+- App remains responsive during inference and 3D updates.
+
+Deliverables:
+- Refactored code modules
+- Updated settings schema + migration code
+- Tests and QA checklist
+- Demo screenshots for each major mode
+- Technical notes on performance tradeoffs and fallback behavior
+```
+
+---
+
+## 4) Implementation order recommendation
+
+1. **Stability first**: layout/splitter/drawer + compare sync bug fixes.
+2. **Trust layer**: legend, metrics consistency, explicit states for missing data.
+3. **Clinical comparison layer**: richer A/B/GT workflows and exports.
+4. **3D advancement**: rendering pipeline and performance tuning.
+5. **Polish**: presets, command palette, report generation.
+
diff --git a/settings.json b/settings.json
index 5191483..87aaeed 100644
--- a/settings.json
+++ b/settings.json
@@ -1,6 +1,6 @@
{
"theme": "Dark",
- "font_size": 32,
+ "font_size": 28,
"visual_quality": "High",
"default_opacity": 0.6,
"show_grid": true,