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,