""" Filename: updateoptodes.py Description: Methods to update optode locations for FLARES Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports import os from pathlib import Path from typing import Dict, Optional, Union # External library imports import pandas as pd import numpy as np import numpy.typing as npt from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QHBoxLayout, QMessageBox, QLineEdit, QPushButton, QFileDialog from PySide6.QtCore import Qt from mne.io import read_raw_snirf #type: ignore from mne_nirs.io import write_raw_snirf #type: ignore from mne.channels import make_dig_montage #type: ignore from src.shared.shareddata import APP_NAME class UpdateOptodesWindow(QWidget): def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent, Qt.WindowType.Window) self.setWindowTitle(f"Update optode positions - {APP_NAME.upper()}") self.resize(760, 200) self.selected_snirf_files: list[str] = [] self.label_file_a = QLabel("SNIRF files:") self.line_edit_file_a = QLineEdit() self.line_edit_file_a.setReadOnly(True) self.btn_browse_a = QPushButton("Browse .snirf") self.btn_browse_a.clicked.connect(self.browse_file_a) self.label_file_b = QLabel("Text file:") self.line_edit_file_b = QLineEdit() self.line_edit_file_b.setReadOnly(True) self.btn_browse_b = QPushButton("Browse .txt/.xlsx") self.btn_browse_b.clicked.connect(self.browse_file_b) self.label_suffix = QLabel("Suffix to append to filename:") self.line_edit_suffix = QLineEdit() self.line_edit_suffix.setText("flare") self.btn_clear = QPushButton("Clear") self.btn_go = QPushButton("Go") self.btn_clear.clicked.connect(self.clear_files) self.btn_go.clicked.connect(self.go_action) layout = QVBoxLayout() self.description = QLabel() self.description.setTextFormat(Qt.TextFormat.RichText) self.description.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) self.description.setOpenExternalLinks(False) # Handle the click internally self.description.setText("Some software when creating snirf files will insert a template of optode positions as the correct position of the optodes for the participant.
" "This is rarely correct as each head differs slightly in shape or size, and a lot of calculations require the optodes to be in the correct location.
" "Using a .txt or .xlsx file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.
" "The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.
" "An example format of what a digitization text file should look like can be found by clicking here. Currently only .xlsx files directly exported from a
" "Polhemus system are supported.") self.description.linkActivated.connect(self.handle_link_click) layout.addWidget(self.description) help_text_a = "Select the SNIRF (.snirf) file to update with new optode positions." file_a_layout = QHBoxLayout() # Help button on the left help_btn_a = QPushButton("?") help_btn_a.setFixedWidth(25) help_btn_a.setToolTip(help_text_a) help_btn_a.clicked.connect(lambda: self.show_help_popup(help_text_a)) file_a_layout.addWidget(help_btn_a) # Container for label + line_edit + browse button with tooltip file_a_container = QWidget() file_a_container_layout = QHBoxLayout() file_a_container_layout.setContentsMargins(0, 0, 0, 0) file_a_container_layout.addWidget(self.label_file_a) file_a_container_layout.addWidget(self.line_edit_file_a) file_a_container_layout.addWidget(self.btn_browse_a) file_a_container.setLayout(file_a_container_layout) file_a_container.setToolTip(help_text_a) file_a_layout.addWidget(file_a_container) layout.addLayout(file_a_layout) help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates, or a .xlsx file from a Polhemius system." file_b_layout = QHBoxLayout() help_btn_b = QPushButton("?") help_btn_b.setFixedWidth(25) help_btn_b.setToolTip(help_text_b) help_btn_b.clicked.connect(lambda: self.show_help_popup(help_text_b)) file_b_layout.addWidget(help_btn_b) file_b_container = QWidget() file_b_container_layout = QHBoxLayout() file_b_container_layout.setContentsMargins(0, 0, 0, 0) file_b_container_layout.addWidget(self.label_file_b) file_b_container_layout.addWidget(self.line_edit_file_b) file_b_container_layout.addWidget(self.btn_browse_b) file_b_container.setLayout(file_b_container_layout) file_b_container.setToolTip(help_text_b) file_b_layout.addWidget(file_b_container) layout.addLayout(file_b_layout) help_text_suffix = "This text will be appended to the original filename when saving. Default is 'flare'." suffix_layout = QHBoxLayout() help_btn_suffix = QPushButton("?") help_btn_suffix.setFixedWidth(25) help_btn_suffix.setToolTip(help_text_suffix) help_btn_suffix.clicked.connect(lambda: self.show_help_popup(help_text_suffix)) suffix_layout.addWidget(help_btn_suffix) suffix_container = QWidget() suffix_container_layout = QHBoxLayout() suffix_container_layout.setContentsMargins(0, 0, 0, 0) suffix_container_layout.addWidget(self.label_suffix) suffix_container_layout.addWidget(self.line_edit_suffix) suffix_container.setLayout(suffix_container_layout) suffix_container.setToolTip(help_text_suffix) suffix_layout.addWidget(suffix_container) layout.addLayout(suffix_layout) buttons_layout = QHBoxLayout() buttons_layout.addStretch() buttons_layout.addWidget(self.btn_clear) buttons_layout.addWidget(self.btn_go) layout.addLayout(buttons_layout) self.setLayout(layout) def show_help_popup(self, text: str) -> None: msg = QMessageBox(self) msg.setWindowTitle(f"Parameter Info - {APP_NAME.upper()}") msg.setText(text) msg.exec() def handle_link_click(self, link: str) -> None: if link == "custom_link": msg = QMessageBox(self) msg.setWindowTitle("Example Digitization File") text = "nz: -1.91 85.175 -31.1525\n" \ "rpa: 80.3825 -17.1925 -57.2775\n" \ "lpa: -81.815 -17.1925 -57.965\n" \ "d1: 0.01 -97.5175 62.5875\n" \ "d2: 25.125 -103.415 45.045\n" \ "d3: 49.095 -97.9025 30.2075\n" \ "s1: 0.01 -112.43 32.595\n" \ "s2: 30.325 -84.3125 71.8975\n" \ "s3: 0.01 -70.6875 89.0925\n" msg.setText(text) msg.exec() def browse_file_a(self) -> None: file_paths, _ = QFileDialog.getOpenFileNames( self, "Select SNIRF Files", "", "SNIRF Files (*.snirf)" ) if file_paths: self.selected_snirf_files = file_paths self.line_edit_file_a.setText("; ".join(Path(p).name for p in file_paths)) def browse_file_b(self) -> None: file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Supported Files (*.txt *.xlsx)") if file_path: self.line_edit_file_b.setText(file_path) def clear_files(self) -> None: self.selected_snirf_files.clear() self.line_edit_file_a.clear() self.line_edit_file_b.clear() def go_action(self) -> None: file_a = self.line_edit_file_a.text() file_b = self.line_edit_file_b.text() suffix = self.line_edit_suffix.text().strip() or "flare" if not self.selected_snirf_files: QMessageBox.critical(self, "Missing File", "Please select at least one SNIRF file.") return if not file_b: QMessageBox.critical(self, "Missing File", "Please select a TXT or XLSX digitization file.") return output_dir = QFileDialog.getExistingDirectory( self, "Select Output Directory" ) if not output_dir: print("Save cancelled.") return output_path = Path(output_dir) successful_files: list[str] = [] failed_files: list[str] = [] for file_a in self.selected_snirf_files: input_path = Path(file_a) # Keep original filename and independently add suffix save_path = output_path / f"{input_path.stem}_{suffix}.snirf" try: self.update_optode_positions( file_a=file_a, file_b=file_b, save_path=save_path ) successful_files.append(save_path.name) except Exception as e: failed_files.append( f"{input_path.name}: {e}" ) # Build summary message_parts: list[str] = [] if successful_files: message_parts.append( f"Successfully processed {len(successful_files)} " f"SNIRF file(s):\n\n" + "\n".join(successful_files) ) if failed_files: message_parts.append( f"Failed to process {len(failed_files)} " f"SNIRF file(s):\n\n" + "\n".join(failed_files) ) if failed_files: QMessageBox.warning( self, "Processing Complete", "\n\n".join(message_parts) ) else: QMessageBox.information( self, "Files Saved", "\n\n".join(message_parts) ) def update_optode_positions( self, file_a: Union[str, Path], file_b: Union[str, Path], save_path: Union[str, Path] ) -> None: fiducials = {} ch_positions = {} extension = Path(file_b).suffix # Read the lines from the optode file if extension == '.txt': with open(file_b, 'r') as f: for line in f: if line.strip(): # Split by the semicolon and convert to meters ch_name, coords_str = line.split(":") coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001 # The key we have is a fiducial if ch_name.lower() in ['lpa', 'nz', 'rpa']: fiducials[ch_name.lower()] = coords # The key we have is a source or detector else: ch_positions[ch_name.upper()] = coords elif extension == '.xlsx': # TODO: Bad! Why assume sheet1 has the contents? df = pd.read_excel(file_b, sheet_name='Sheet1') # type: ignore def _get_block_data( target_df: pd.DataFrame, block_id: int, row_mapping: Union[Dict[int, str], str], scale: float = 0.001 ) -> Dict[str, npt.NDArray[np.float64]]: """Isolates a block, cleans numeric data, and returns a scaled dictionary.""" # 1. Isolate and clean block = target_df[target_df['block_id'] == block_id].iloc[:, [1, 2, 3]].copy() block = block.apply(pd.to_numeric, errors='coerce') # 2. Extract into dictionary based on mapping result: Dict[str, npt.NDArray[np.float64]] = {} # If row_mapping is a dict (like {0: 'nz'}), use it directly if isinstance(row_mapping, dict): for row_idx, key in row_mapping.items(): if row_idx < len(block): result[key] = block.iloc[row_idx].to_numpy(dtype=float) * scale # If row_mapping is a string prefix (like 'D' or 'S'), auto-generate keys else: for i in range(len(block)): result[f"{row_mapping}{i+1}"] = block.iloc[i].to_numpy(dtype=float) * scale return result # Identify blocks is_empty = df.isnull().all(axis=1) df['block_id'] = is_empty.cumsum() clean_df = df[~is_empty].copy() # Process Block 2: Landmarks fiducials = _get_block_data(clean_df, 2, {0: 'nz', 2: 'rpa', 3: 'lpa'}) # Process Block 3: D-Points d_points = _get_block_data(clean_df, 3, 'D') # Process Block 4: S-Points s_points = _get_block_data(clean_df, 4, 'S') ch_positions = {**d_points, **s_points} # Create montage with updated coords in head space initial_montage = make_dig_montage(ch_pos=ch_positions, nasion=fiducials.get('nz'), lpa=fiducials.get('lpa'), rpa=fiducials.get('rpa'), coord_frame='head') # type: ignore # Read the SNIRF file, set the montage, and write it back raw = read_raw_snirf(str(file_a), preload=True) raw.set_montage(initial_montage) # type: ignore write_raw_snirf(raw, save_path)