diff --git a/changelog.md b/changelog.md index 66811b2..ada2b5c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,11 +1,14 @@ # Version 1.7.1 - Added support for Plugins! Plugins can be downloaded from the official repository or from custom ones. Support for creating a plugin can be found on the wiki +- Files and folders can now be dragged and dropped into the loaded files area to be loaded instead of relying on the file menu +- Fixed an issue where shadow snirf files would attempt to be loaded by the application. Fixes [Issue 92](https://git.research.dezeeuw.ca/tyler/flares/issues/92) - Fixed an issue where the application could not automatically update between versions 1.5.1 to 1.7.1. Sorry! 1.7.1 upgrading onward should be fixed and more robust - Fixed an issue where some log files would not generate, or would generate in an incorrect location - Fixed an issue where updating events in a snirf file would not release memory properly - Fixed an issue where loading multiple files in quick succession could cause some of the files to never finish loading - Fixed an issue where loading a saved project, pushing clear, and re-opening snirf files would cause them to appear incorrectly in the loaded files area +- Fixed a crucial bug where short channels were still being sent to the donor pool to assist in interpolating long channels from. Fixes [Issue 80](https://git.research.dezeeuw.ca/tyler/flares/issues/80) # Version 1.7.0 diff --git a/flares.py b/flares.py index fe505e1..42ca517 100644 --- a/flares.py +++ b/flares.py @@ -1115,10 +1115,10 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, is_bad = (hbo_ch in raw.info['bads']) or (hbr_ch in raw.info['bads']) is_short = pair_distances[i] < short_channels_threshold - if is_bad: - bad_pairs.append(i) - elif is_short: + if is_short: n_short_excluded += 1 + elif is_bad: + bad_pairs.append(i) else: good_pairs.append(i) @@ -1182,7 +1182,7 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, constrained_layout=True) if n_bad == 1: axes = [axes] # Handle single subplot case - axes_flat = np.asarray(axes).get_data().flatten() if hasattr(axes, 'get_data') else np.asarray(axes).ravel() + axes_flat = np.asarray(axes).ravel() for j in range(n_bad, len(axes_flat)): if j >= n_bad: axes_flat[j].axis('off') @@ -1222,9 +1222,6 @@ def interpolate_fNIRS_bads_weighted_average(raw, max_dist=0.03, min_neighbors=2, print("Bads cleared:", raw.info['bads']) raw.info['bads'] = [] - for ch in raw.info['bads']: - print(f"Channel {ch} still marked as bad.") - fig_raw_after = raw.plot(duration=raw.times[-1], n_channels=raw.info['nchan'], title="After interpolation", show=False) return raw, fig_raw_after, fig_compare diff --git a/main.py b/main.py index 1de2d47..1433f91 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,10 @@ from datetime import datetime from functools import partial from multiprocessing import Process, current_process, freeze_support, Queue, set_start_method +# Fix for plotting on linux +if sys.platform.startswith("linux"): + os.environ["QT_QPA_PLATFORM"] = "xcb" + # External library imports import psutil @@ -535,6 +539,49 @@ class ThemeChangeWatcher(QObject): self.main_window.update_theme() +class DropScrollArea(QScrollArea): + """QScrollArea that accepts .snirf file and folder drops.""" + + def __init__(self, on_files_dropped_callback, parent=None): + super().__init__(parent) + self.on_files_dropped_callback = on_files_dropped_callback + self.setAcceptDrops(True) + # ScrollAreas require setting acceptDrops on their viewport as well + self.viewport().setAcceptDrops(True) + + def dragEnterEvent(self, event) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event) -> None: + if not event.mimeData().hasUrls(): + return + + dropped_paths = [ + url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile() + ] + + files_to_load = [] + for path_str in dropped_paths: + p = Path(path_str) + if p.is_file() and p.suffix.lower() == ".snirf": + files_to_load.append(os.path.normpath(str(p))) + elif p.is_dir(): + files_to_load.extend( + os.path.normpath(str(f)) for f in p.rglob("*.snirf") + ) + + if files_to_load: + self.on_files_dropped_callback(files_to_load) + else: + QMessageBox.information( + self, + "No Valid Files", + "None of the dropped items were .snirf files or directories containing .snirf files.", + ) + class MainApplication(QMainWindow): """ Main application window that creates and sets up the UI. @@ -715,7 +762,9 @@ class MainApplication(QMainWindow): self.bubble_layout = QGridLayout(self.bubble_container) self.bubble_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.scroll_area = QScrollArea() + self.scroll_area = DropScrollArea( + on_files_dropped_callback=self.project_manager.load_dropped_files + ) self.scroll_area.setWidgetResizable(True) self.scroll_area.setWidget(self.bubble_container) self.scroll_area.setMinimumHeight(200) diff --git a/project_manager.py b/project_manager.py index dc7ac51..9e021fb 100644 --- a/project_manager.py +++ b/project_manager.py @@ -108,21 +108,116 @@ class ProjectManager: # ========================================================================= # File & Folder Opening Dialogs # ========================================================================= + + def is_valid_snirf(self, path: str) -> bool: + """Fast header check to verify HDF5/SNIRF signature and ignore corrupt/shadow files.""" + try: + if os.path.getsize(path) < 8: + return False + with open(path, "rb") as f: + return f.read(8) == b"\x89HDF\r\n\x1a\n" + except Exception: + return False + def open_file_dialog(self) -> None: """Opens dialog to pick a single .snirf file.""" file_path, _ = QFileDialog.getOpenFileName( self.app, "Open File", "", "SNIRF Files (*.snirf);;All Files (*)" ) - if file_path: - self._load_files_into_pipeline([os.path.normpath(file_path)]) + if not file_path: + return + + norm_path = os.path.normpath(file_path) + filename = os.path.basename(norm_path) + + # 1. Check specifically for macOS shadow files + if filename.startswith("._"): + QMessageBox.warning( + self.app, + "Invalid SNIRF File", + f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not a actual data file." + ) + return + + # 2. Check for general header validity / corruption + if not self.is_valid_snirf(norm_path): + QMessageBox.warning( + self.app, + "Invalid SNIRF File", + f"'{filename}' is not a valid SNIRF file or has a corrupted header." + ) + return + + self._load_files_into_pipeline([norm_path]) def open_folder_dialog(self)-> None: """Recursively finds all .snirf files in a selected directory.""" folder_path = QFileDialog.getExistingDirectory(self.app, "Select Folder", "") - if folder_path: - snirf_files = [os.path.normpath(str(f)) for f in Path(folder_path).rglob("*.snirf")] - self._load_files_into_pipeline(snirf_files) + if not folder_path: + return + # Automatically filter out shadow files and invalid headers in batch mode + snirf_files = [ + os.path.normpath(str(f)) + for f in Path(folder_path).rglob("*.snirf") + if self.is_valid_snirf(str(f)) + ] + + if not snirf_files: + QMessageBox.information( + self.app, + "No Valid Files", + "No valid .snirf files were found in the selected directory." + ) + return + + def load_dropped_files(self, file_paths: List[str]) -> None: + """Public entry point for handling files or folders dropped onto the UI.""" + if not file_paths: + return + + # 1. Single file drop: Give explicit feedback like open_file_dialog + if len(file_paths) == 1: + norm_path = os.path.normpath(file_paths[0]) + filename = os.path.basename(norm_path) + + if filename.startswith("._"): + QMessageBox.warning( + self.app, + "Invalid SNIRF File", + f"'{filename}' is a macOS system shadow file (Apple Double resource fork), not an actual data file." + ) + return + + if not self.is_valid_snirf(norm_path): + QMessageBox.warning( + self.app, + "Invalid SNIRF File", + f"'{filename}' is not a valid SNIRF file or has a corrupted header." + ) + return + + valid_files = [norm_path] + + # 2. Batch drop: Silently filter out shadow/corrupt files like open_folder_dialog + else: + valid_files = [ + os.path.normpath(p) + for p in file_paths + if self.is_valid_snirf(os.path.normpath(p)) + ] + + if not valid_files: + QMessageBox.information( + self.app, + "No Valid Files", + "None of the dropped items were valid .snirf files." + ) + return + + # Delegate to internal pipeline + self._load_files_into_pipeline(valid_files) + def _load_files_into_pipeline(self, file_paths: List[str]) -> None: """Loads .snirf files into UI using chunked batches and background workers.""" app = self.app @@ -146,8 +241,11 @@ class ProjectManager: if not hasattr(app, "metadata_cache"): app.metadata_cache = {} - # Filter out files already in the UI to avoid duplicates - new_files = [p for p in file_paths if p not in app.selected_paths] + # Filter out duplicates AND non-SNIRF files (including macOS ._ shadow files) + new_files = [ + p for p in file_paths + if p not in app.selected_paths and self.is_valid_snirf(p) + ] if not new_files: return