2 Commits

Author SHA1 Message Date
7007478c3b update ignore 2026-01-28 10:10:26 -08:00
fb728d5033 added support updating optode positions from .xlsx 2026-01-28 10:09:06 -08:00
2 changed files with 67 additions and 20 deletions

1
.gitignore vendored
View File

@@ -175,3 +175,4 @@ cython_debug/
.pypirc .pypirc
/individual_images /individual_images
*.xlsx

84
main.py
View File

@@ -550,10 +550,10 @@ class UpdateOptodesWindow(QWidget):
self.btn_browse_a = QPushButton("Browse .snirf") self.btn_browse_a = QPushButton("Browse .snirf")
self.btn_browse_a.clicked.connect(self.browse_file_a) self.btn_browse_a.clicked.connect(self.browse_file_a)
self.label_file_b = QLabel("TXT file:") self.label_file_b = QLabel("Text file:")
self.line_edit_file_b = QLineEdit() self.line_edit_file_b = QLineEdit()
self.line_edit_file_b.setReadOnly(True) self.line_edit_file_b.setReadOnly(True)
self.btn_browse_b = QPushButton("Browse .txt") self.btn_browse_b = QPushButton("Browse .txt/.xlsx")
self.btn_browse_b.clicked.connect(self.browse_file_b) self.btn_browse_b.clicked.connect(self.browse_file_b)
self.label_suffix = QLabel("Suffix to append to filename:") self.label_suffix = QLabel("Suffix to append to filename:")
@@ -574,9 +574,10 @@ class UpdateOptodesWindow(QWidget):
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.<br>" 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.<br>"
"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.<br>" "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.<br>"
"Using a .txt file, we can update the positions in the snirf file to match those of a digitization system such as one from Polhemus or elsewhere.<br>" "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.<br>"
"The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>" "The .txt file should have the fiducials, detectors, and sources clearly labeled, followed by the x, y, and z coordinates seperated by a space.<br>"
"An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>.") "An example format of what a digitization text file should look like can be found <a href='custom_link'>by clicking here</a>. Currently only .xlsx files directly exported from a<br>"
"Polhemus system are supported.")
self.description.linkActivated.connect(self.handle_link_click) self.description.linkActivated.connect(self.handle_link_click)
layout.addWidget(self.description) layout.addWidget(self.description)
@@ -605,8 +606,7 @@ class UpdateOptodesWindow(QWidget):
file_a_layout.addWidget(file_a_container) file_a_layout.addWidget(file_a_container)
layout.addLayout(file_a_layout) 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."
help_text_b = "Provide a .txt file with labeled optodes (e.g., nz, rpa, lpa, d1, s1) and their x, y, z coordinates."
file_b_layout = QHBoxLayout() file_b_layout = QHBoxLayout()
@@ -689,7 +689,7 @@ class UpdateOptodesWindow(QWidget):
self.line_edit_file_a.setText(file_path) self.line_edit_file_a.setText(file_path)
def browse_file_b(self): def browse_file_b(self):
file_path, _ = QFileDialog.getOpenFileName(self, "Select TXT File", "", "Text Files (*.txt)") file_path, _ = QFileDialog.getOpenFileName(self, "Select File", "", "Text Files (*.txt), Excel Files (*.xlsx)")
if file_path: if file_path:
self.line_edit_file_b.setText(file_path) self.line_edit_file_b.setText(file_path)
@@ -743,21 +743,67 @@ class UpdateOptodesWindow(QWidget):
fiducials = {} fiducials = {}
ch_positions = {} ch_positions = {}
extension = Path(file_b).suffix
# Read the lines from the optode file # Read the lines from the optode file
with open(file_b, 'r') as f: if extension == '.txt':
for line in f: with open(file_b, 'r') as f:
if line.strip(): for line in f:
# Split by the semicolon and convert to meters if line.strip():
ch_name, coords_str = line.split(":") # Split by the semicolon and convert to meters
coords = np.array(list(map(float, coords_str.strip().split()))) * 0.001 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 # The key we have is a fiducial
if ch_name.lower() in ['lpa', 'nz', 'rpa']: if ch_name.lower() in ['lpa', 'nz', 'rpa']:
fiducials[ch_name.lower()] = coords fiducials[ch_name.lower()] = coords
# The key we have is a source or detector # The key we have is a source or detector
else: else:
ch_positions[ch_name.upper()] = coords ch_positions[ch_name.upper()] = coords
elif extension == '.xlsx':
df = pd.read_excel(file_b, sheet_name='Sheet1')
def _get_block_data(df, block_id, row_mapping, scale=0.001):
"""Isolates a block, cleans numeric data, and returns a scaled dictionary."""
# 1. Isolate and clean
block = df[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 = {}
# 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
elif isinstance(row_mapping, str):
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 # 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 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