phasor-handler 2.2.3__tar.gz → 2.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {phasor_handler-2.2.3/phasor_handler.egg-info → phasor_handler-2.2.4}/PKG-INFO +1 -1
  2. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/app.py +1 -1
  3. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/scripts/meta_reader.py +158 -10
  4. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/image_view.py +38 -4
  5. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/meta_info.py +56 -33
  6. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/roi_list.py +9 -25
  7. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/trace_plot.py +178 -36
  8. {phasor_handler-2.2.3 → phasor_handler-2.2.4/phasor_handler.egg-info}/PKG-INFO +1 -1
  9. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/pyproject.toml +1 -1
  10. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/CONTRIBUTING.md +0 -0
  11. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/LICENSE.md +0 -0
  12. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/MANIFEST.in +0 -0
  13. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/README.md +0 -0
  14. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/environment.yml +0 -0
  15. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/__init__.py +0 -0
  16. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/img/icons/chevron-down.svg +0 -0
  17. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/img/icons/chevron-up.svg +0 -0
  18. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/img/logo.ico +0 -0
  19. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/models/dir_manager.py +0 -0
  20. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/scripts/contrast.py +0 -0
  21. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/scripts/convert.py +0 -0
  22. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/scripts/plot.py +0 -0
  23. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/scripts/register.py +0 -0
  24. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/themes/__init__.py +0 -0
  25. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/themes/dark_theme.py +0 -0
  26. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/tools/__init__.py +0 -0
  27. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/tools/check_stylesheet.py +0 -0
  28. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/tools/misc.py +0 -0
  29. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/__init__.py +0 -0
  30. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/__init__.py +0 -0
  31. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/bnc.py +0 -0
  32. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/components/circle_roi.py +0 -0
  33. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/analysis/view.py +0 -0
  34. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/conversion/view.py +0 -0
  35. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/widgets/registration/view.py +0 -0
  36. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/workers/__init__.py +0 -0
  37. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/workers/analysis_worker.py +0 -0
  38. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/workers/conversion_worker.py +0 -0
  39. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/workers/histogram_worker.py +0 -0
  40. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler/workers/registration_worker.py +0 -0
  41. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler.egg-info/SOURCES.txt +0 -0
  42. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler.egg-info/dependency_links.txt +0 -0
  43. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler.egg-info/entry_points.txt +0 -0
  44. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler.egg-info/requires.txt +0 -0
  45. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/phasor_handler.egg-info/top_level.txt +0 -0
  46. {phasor_handler-2.2.3 → phasor_handler-2.2.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phasor-handler
3
- Version: 2.2.3
3
+ Version: 2.2.4
4
4
  Summary: A PyQt6 GUI toolbox for processing two-photon phasor imaging data
5
5
  Author-email: Josia Shemuel <joshemuel@users.noreply.github.com>
6
6
  License: MIT License
@@ -244,7 +244,7 @@ class MainWindow(QMainWindow):
244
244
  def main():
245
245
  app = QApplication(sys.argv)
246
246
  try:
247
- qdarktheme.setup_theme("auto") # or your own apply_dark_theme()
247
+ qdarktheme.setup_theme() # or your own apply_dark_theme()
248
248
  except Exception:
249
249
  pass # fall back to default if theme package missing
250
250
  window = MainWindow()
@@ -1,5 +1,3 @@
1
- # TODO read from .txt files of CHA and CHB channels if available
2
-
3
1
  #!/usr/bin/env python3
4
2
  """
5
3
  meta_reader.py
@@ -32,6 +30,42 @@ class DataFrameDict(dict):
32
30
  self[key] = value
33
31
 
34
32
  # ------------- FUNCTIONS -------------
33
+
34
+ # Mini2P text file parser
35
+ _SPLIT = re.compile(r"\s{4,}") # split key/value on 4+ spaces
36
+
37
+ def read_mini2p_meta(path):
38
+ """
39
+ Read Mini2P metadata from Information-CHA.txt or Information-CHB.txt files.
40
+
41
+ Args:
42
+ path: Path to the text file
43
+
44
+ Returns:
45
+ dict: Nested dictionary with sections as keys
46
+ """
47
+ from pathlib import Path
48
+ from typing import Dict, Any
49
+
50
+ path = Path(path)
51
+ data: Dict[str, Dict[str, Any]] = {}
52
+ section = None
53
+ with path.open(encoding="utf-8-sig") as f: # utf-8 with BOM safe; handles 'μ'
54
+ for raw in f:
55
+ line = raw.strip()
56
+ if not line or line.startswith(("#", ";")):
57
+ continue
58
+ if line.startswith("[") and line.endswith("]"):
59
+ section = line[1:-1].strip()
60
+ data.setdefault(section, {})
61
+ continue
62
+ if section is None:
63
+ section = "_root_"
64
+ data.setdefault(section, {})
65
+ parts = _SPLIT.split(line, maxsplit=1)
66
+ key, val = (parts[0].strip(), parts[1].strip()) if len(parts) == 2 else (parts[0], "")
67
+ data[section][key] = val
68
+ return data
35
69
  def open_overwrite(path, *args, **kwargs):
36
70
  path = Path(path)
37
71
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -358,7 +392,7 @@ def process_i3_folder(folder_path: str):
358
392
 
359
393
  # Output a json file
360
394
  with open_overwrite(Path(folder_path) / 'experiment_summary.json', 'w', encoding='utf-8') as f:
361
- json.dump(variables, f, indent=4)
395
+ json.dump(variables, f, indent=4, ensure_ascii=False)
362
396
 
363
397
  print(f"JSON file and Pickle file saved to {folder_path}")
364
398
 
@@ -399,6 +433,8 @@ def process_mini2p_folder(folder_path: str):
399
433
  # Collect all subdirectories and TDMS files
400
434
  folders = {}
401
435
  path_df = {}
436
+ txt_meta = {} # Store parsed txt metadata
437
+
402
438
  for subdir in os.listdir(folder_path):
403
439
  subdir_path = os.path.join(folder_path, subdir)
404
440
  if os.path.isdir(subdir_path):
@@ -406,6 +442,16 @@ def process_mini2p_folder(folder_path: str):
406
442
  tdms_files = [os.path.join(subdir_path, f) for f in os.listdir(subdir_path) if f.endswith('.tdms')]
407
443
  txt_files = [os.path.join(subdir_path, f) for f in os.listdir(subdir_path) if f.endswith('.txt')]
408
444
 
445
+ # Parse txt files for channel information
446
+ for txt_file in txt_files:
447
+ txt_filename = os.path.basename(txt_file)
448
+ if 'Information-CHA.txt' in txt_filename or 'Information-CHB.txt' in txt_filename:
449
+ try:
450
+ txt_meta[txt_filename] = read_mini2p_meta(txt_file)
451
+ print(f"[INFO] Parsed metadata from {txt_filename}")
452
+ except Exception as e:
453
+ print(f"[WARN] Failed to parse {txt_filename}: {e}")
454
+
409
455
  if txt_files:
410
456
  path_df[subdir] = (tdms_files, txt_files)
411
457
  elif tdms_files:
@@ -449,20 +495,41 @@ def process_mini2p_folder(folder_path: str):
449
495
  for _, row in info_df.iterrows():
450
496
  image_info[row['Item']] = row['Value']
451
497
 
452
- # Parse timestamp from image info
498
+ # Override/supplement with txt file metadata if available
499
+ txt_cha = next((txt_meta[k] for k in txt_meta if 'CHA' in k), None)
500
+ txt_chb = next((txt_meta[k] for k in txt_meta if 'CHB' in k), None)
501
+
502
+ # Use the first available txt metadata (prefer CHA)
503
+ primary_txt = txt_cha if txt_cha else txt_chb
504
+
505
+ # Parse timestamp from txt file or image info
453
506
  import datetime
454
- timestamp_str = safe_extract(lambda: image_info.get('Image time', 'NA'))
507
+
508
+ timestamp_str = "NA"
509
+ if primary_txt and 'Basic Information' in primary_txt:
510
+ timestamp_str = safe_extract(lambda: primary_txt['Basic Information'].get('Time', 'NA'))
511
+ if timestamp_str == "NA":
512
+ timestamp_str = safe_extract(lambda: image_info.get('Image time', 'NA'))
513
+
455
514
  if timestamp_str != 'NA':
456
515
  try:
457
- # Parse format like "10/16/2025 11:19:54 PM"
458
- dt = datetime.datetime.strptime(timestamp_str, "%m/%d/%Y %I:%M:%S %p")
516
+ # Try Mini2P format first: "2025-10-16_23-14-26.286"
517
+ if '_' in timestamp_str and '-' in timestamp_str:
518
+ # Format: YYYY-MM-DD_HH-MM-SS.mmm
519
+ timestamp_str = timestamp_str.split('.')[0] # Remove milliseconds
520
+ dt = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d_%H-%M-%S")
521
+ else:
522
+ # Try format like "10/16/2025 11:19:54 PM"
523
+ dt = datetime.datetime.strptime(timestamp_str, "%m/%d/%Y %I:%M:%S %p")
524
+
459
525
  day = dt.day
460
526
  month = dt.month
461
527
  year = dt.year
462
528
  hour = dt.hour
463
529
  minute = dt.minute
464
530
  second = dt.second
465
- except:
531
+ except Exception as e:
532
+ print(f"[WARN] Could not parse timestamp '{timestamp_str}': {e}")
466
533
  day = month = year = hour = minute = second = "NA"
467
534
  else:
468
535
  day = month = year = hour = minute = second = "NA"
@@ -519,7 +586,7 @@ def process_mini2p_folder(folder_path: str):
519
586
  "pixel_size": safe_extract(lambda: image_info.get('Image size_Pixel', 'NA')),
520
587
  "height": safe_extract(lambda: int(image_info.get('Image size_Line', 'NA'))),
521
588
  "width": safe_extract(lambda: int(image_info.get('Image size_Pixel', 'NA'))),
522
- "FOV_size": "NA", # Mini2P doesn't provide micron measurements in TDMS
589
+ "FOV_size": "NA", # Will be updated from txt if available
523
590
  "zoom": safe_extract(lambda: image_info.get('Zoom', 'NA')),
524
591
  "laser_voltage": safe_extract(lambda: image_info.get('Laser(V)', 'NA')),
525
592
  "pmt_voltage": safe_extract(lambda: image_info.get('PMT(V)', 'NA')),
@@ -553,12 +620,93 @@ def process_mini2p_folder(folder_path: str):
553
620
  "data_folders": list(tdms_data.keys())
554
621
  }
555
622
 
623
+ # Enhance with txt file metadata if available
624
+ if primary_txt:
625
+ # Basic Information
626
+ if 'Basic Information' in primary_txt:
627
+ basic = primary_txt['Basic Information']
628
+ variables["system_config"] = safe_extract(lambda: basic.get('SystemConfig', 'NA'))
629
+ variables["probe"] = safe_extract(lambda: basic.get('Probe', 'NA'))
630
+ variables["imaging_mode"] = safe_extract(lambda: basic.get('ImagingMode', 'NA'))
631
+ variables["supergin_version"] = safe_extract(lambda: basic.get('SUPERGIN_Version', 'NA'))
632
+ variables["probe_type"] = safe_extract(lambda: basic.get('Probe_Type', 'NA'))
633
+ variables["pmt_gain"] = safe_extract(lambda: basic.get('PMT_Gain', 'NA'))
634
+
635
+ # Power Regulation
636
+ if 'PowerRegulation' in primary_txt:
637
+ power = primary_txt['PowerRegulation']
638
+ variables["power_regulation_mode"] = safe_extract(lambda: power.get('PowerRegulationMode', 'NA'))
639
+ variables["power_voltage"] = safe_extract(lambda: power.get('Power', 'NA'))
640
+ variables["power_percentage"] = safe_extract(lambda: power.get('PowerPercentage', 'NA'))
641
+
642
+ # Scan Information
643
+ if 'Scan' in primary_txt:
644
+ scan = primary_txt['Scan']
645
+ variables["scan_direction"] = safe_extract(lambda: scan.get('Scan_Direction', 'NA'))
646
+ variables["pixel_dwell"] = safe_extract(lambda: scan.get('Pixel_Dwell', 'NA'))
647
+ variables["frame_rate"] = safe_extract(lambda: scan.get('Frame_Rate', 'NA'))
648
+ variables["scan_frequency"] = safe_extract(lambda: scan.get('Frequency', 'NA'))
649
+ variables["fps_division"] = safe_extract(lambda: scan.get('FPS_Division', 'NA'))
650
+
651
+ # Update dimensions from txt if available
652
+ pixel_x = safe_extract(lambda: int(scan.get('Pixel_X', 'NA')))
653
+ pixel_y = safe_extract(lambda: int(scan.get('Pixel_Y', 'NA')))
654
+ if pixel_x != "NA":
655
+ variables["width"] = pixel_x
656
+ if pixel_y != "NA":
657
+ variables["height"] = pixel_y
658
+
659
+ # Zoom Information
660
+ if 'Zoom' in primary_txt:
661
+ zoom_info = primary_txt['Zoom']
662
+ variables["zoom"] = safe_extract(lambda: zoom_info.get('Zoom', 'NA'))
663
+ variables["amplitude_x"] = safe_extract(lambda: zoom_info.get('Amplitude_X', 'NA'))
664
+ variables["amplitude_y"] = safe_extract(lambda: zoom_info.get('Amplitude_Y', 'NA'))
665
+ variables["pixel_size"] = safe_extract(lambda: zoom_info.get('Pixel_Size', 'NA'))
666
+ variables["fov_x"] = safe_extract(lambda: zoom_info.get('Fov_X', 'NA'))
667
+ variables["fov_y"] = safe_extract(lambda: zoom_info.get('Fov_Y', 'NA'))
668
+ variables["FOV_size"] = safe_extract(lambda: f"{zoom_info.get('Fov_X', 'NA')} x {zoom_info.get('Fov_Y', 'NA')}")
669
+ variables["save_frames"] = safe_extract(lambda: zoom_info.get('Save Frames', 'NA'))
670
+
671
+ # Stage Position
672
+ if 'Stage' in primary_txt:
673
+ stage = primary_txt['Stage']
674
+ variables["X_start_position"] = safe_extract(lambda: stage.get('Displacement_X', 'NA'))
675
+ variables["Y_start_position"] = safe_extract(lambda: stage.get('Displacement_Y', 'NA'))
676
+ variables["Z_start_position"] = safe_extract(lambda: stage.get('Displacement_Z', 'NA'))
677
+
678
+ # ETL Information
679
+ if 'ETL' in primary_txt:
680
+ etl = primary_txt['ETL']
681
+ variables["etl_voltage"] = safe_extract(lambda: etl.get('Voltage', 'NA'))
682
+ variables["etl_distance"] = safe_extract(lambda: etl.get('Distance', 'NA'))
683
+
684
+ # Behavioral Setting
685
+ if 'Behavioral Setting' in primary_txt:
686
+ behavioral = primary_txt['Behavioral Setting']
687
+ variables["camera_framerate"] = safe_extract(lambda: behavioral.get('Camera FrameRate', 'NA'))
688
+
689
+ # Time Division Mode
690
+ if 'TimeDivisionMode' in primary_txt:
691
+ tdm = primary_txt['TimeDivisionMode']
692
+ variables["time_division_power"] = safe_extract(lambda: tdm.get('TimeDivisionModePower', 'NA'))
693
+ variables["channel_framerate"] = safe_extract(lambda: tdm.get('Channel FrameRate', 'NA'))
694
+
695
+ # Add channel-specific metadata if both channels are available
696
+ if txt_cha and txt_chb:
697
+ variables["cha_metadata"] = txt_cha
698
+ variables["chb_metadata"] = txt_chb
699
+ elif txt_cha:
700
+ variables["cha_metadata"] = txt_cha
701
+ elif txt_chb:
702
+ variables["chb_metadata"] = txt_chb
703
+
556
704
  # Save metadata
557
705
  with open(Path(folder_path) / 'experiment_summary.pkl', 'wb') as f:
558
706
  pickle.dump(variables, f)
559
707
 
560
708
  with open_overwrite(Path(folder_path) / 'experiment_summary.json', 'w', encoding='utf-8') as f:
561
- json.dump(variables, f, indent=4)
709
+ json.dump(variables, f, indent=4, ensure_ascii=False)
562
710
 
563
711
  print(f"[OK] Mini2P metadata saved to {folder_path}")
564
712
  print("\n[OK] Files saved successfully.")
@@ -817,9 +817,10 @@ class ImageViewWidget(QWidget):
817
817
  if isinstance(metadata, dict):
818
818
  # Check direct key
819
819
  if 'pixel_size' in metadata:
820
- return float(metadata['pixel_size'])
820
+ pixel_size_str = str(metadata['pixel_size'])
821
+ return self._parse_pixel_size_string(pixel_size_str)
821
822
 
822
- # Check nested structure from ImageRecord.yaml
823
+ # Check nested structure from ImageRecord.yaml (3i format)
823
824
  if ('ImageRecord.yaml' in metadata and
824
825
  'CLensDef70' in metadata['ImageRecord.yaml'] and
825
826
  'mMicronPerPixel' in metadata['ImageRecord.yaml']['CLensDef70']):
@@ -827,11 +828,44 @@ class ImageViewWidget(QWidget):
827
828
 
828
829
  # Try as object with attributes
829
830
  elif hasattr(metadata, 'pixel_size'):
830
- return float(metadata.pixel_size)
831
+ pixel_size_str = str(metadata.pixel_size)
832
+ return self._parse_pixel_size_string(pixel_size_str)
831
833
  elif hasattr(metadata, 'mMicronPerPixel'):
832
834
  return float(metadata.mMicronPerPixel)
833
835
 
834
836
  except (KeyError, TypeError, ValueError, AttributeError) as e:
835
837
  print(f"DEBUG: Could not extract pixel size from metadata: {e}")
836
838
 
837
- return None
839
+ return None
840
+
841
+ def _parse_pixel_size_string(self, pixel_size_str):
842
+ """
843
+ Parse pixel size string to extract numeric value in microns.
844
+ Handles formats like:
845
+ - '0.201μm/pixel'
846
+ - '0.201'
847
+ - 0.201
848
+
849
+ Args:
850
+ pixel_size_str: String or number containing pixel size
851
+
852
+ Returns:
853
+ float: Pixel size in microns per pixel
854
+ """
855
+ import re
856
+
857
+ # If already a number, return it
858
+ if isinstance(pixel_size_str, (int, float)):
859
+ return float(pixel_size_str)
860
+
861
+ # Convert to string and extract numeric part
862
+ pixel_size_str = str(pixel_size_str)
863
+
864
+ # Try to extract the first number from the string
865
+ # Handles formats like "0.201μm/pixel" or "0.598μm/pixel"
866
+ match = re.search(r'(\d+\.?\d*)', pixel_size_str)
867
+ if match:
868
+ return float(match.group(1))
869
+
870
+ # If no match, try direct float conversion as last resort
871
+ return float(pixel_size_str)
@@ -1,3 +1,5 @@
1
+ # TODO Implement behavioural and sync information to metadata.
2
+
1
3
  """
2
4
  Metadata Information Viewer Component
3
5
 
@@ -13,6 +15,7 @@ from PyQt6.QtWidgets import (
13
15
  from PyQt6.QtCore import Qt, QSize
14
16
  from PyQt6.QtGui import QFont
15
17
  import json
18
+ import re
16
19
 
17
20
 
18
21
  class MetadataViewer(QDialog):
@@ -104,7 +107,7 @@ class MetadataViewer(QDialog):
104
107
  self.timing_group.setLayout(self.timing_layout)
105
108
  layout.addWidget(self.timing_group)
106
109
 
107
- # Stimulation Information Group
110
+ # Stimulation/Behavioral Information Group (will be renamed based on device)
108
111
  self.stim_group = QGroupBox("Stimulation Information")
109
112
  self.stim_layout = QGridLayout()
110
113
  self.stim_group.setLayout(self.stim_layout)
@@ -216,6 +219,16 @@ class MetadataViewer(QDialog):
216
219
 
217
220
  def update_overview_from_dict(self):
218
221
  """Update overview from dictionary metadata."""
222
+ # Determine device type
223
+ device_name = self.metadata.get('device_name', '').lower() if isinstance(self.metadata.get('device_name'), str) else ''
224
+ is_mini2p = 'mini' in device_name
225
+
226
+ # Update group title based on device
227
+ if is_mini2p:
228
+ self.stim_group.setTitle("Behavioral Information")
229
+ else:
230
+ self.stim_group.setTitle("Stimulation Information")
231
+
219
232
  # Experiment Summary
220
233
  row = 0
221
234
  for key in ['device_name', 'n_frames']:
@@ -270,37 +283,43 @@ class MetadataViewer(QDialog):
270
283
  display_value) if key != "Date (DDMMYYYY)" else self.add_info_row(self.timing_layout, row, key, display_value)
271
284
  row += 1
272
285
 
273
- # Stimulation Information
286
+ # Stimulation/Behavioral Information (device-specific)
274
287
  row = 0
275
- stim_keys = ['stimulation_timeframes', 'stimulation_ms', 'duty_cycle', 'stimulated_roi_location', 'stimulated_rois']
276
- for key in stim_keys:
277
- if key in self.metadata:
278
- value = self.metadata[key]
279
- if isinstance(value, list):
280
- if key == 'stimulation_timeframes':
281
- display_value = ', '.join(map(str, value))
282
- key = "Stimulation Timeframes"
283
- elif key == 'stimulation_ms':
284
- display_value = ', '.join(map(str, [int(v/1000) for v in value]))
285
- key = "Stimulation Time (s)"
286
- elif key == 'duty_cycle':
287
- duty_cycle_counts = {val: value.count(val) for val in set(value)}
288
- display_value = ' | '.join([f'{val}: {count}X' for val, count in duty_cycle_counts.items()])
289
- key = "Duty Cycle"
290
- elif key == 'stimulated_roi_location':
291
- display_value = ', '.join(map(str, [len(x) for x in value]))
292
- key = "Number of stimulated ROIs"
293
- elif key == 'stimulated_rois':
294
- if not value or all(not sublist for sublist in value):
295
- display_value = "NA"
296
- else:
297
- display_value = ', '.join([', '.join(map(str, x)) for x in value])
298
- key = "Stimulated ROIs"
299
- else:
300
- display_value = str(value)
301
- self.add_info_row(self.stim_layout, row, key.replace('_', ' '),
302
- display_value)
303
- row += 1
288
+ if is_mini2p:
289
+ # Mini2P: Show only camera frame rate for behavioral information
290
+ camera_framerate = self.metadata.get('camera_framerate', 'NA')
291
+ self.add_info_row(self.stim_layout, row, "Camera Frame Rate (Hz)", str(camera_framerate))
292
+ else:
293
+ # 3i: Show stimulation information
294
+ stim_keys = ['stimulation_timeframes', 'stimulation_ms', 'duty_cycle', 'stimulated_roi_location', 'stimulated_rois']
295
+ for key in stim_keys:
296
+ if key in self.metadata:
297
+ value = self.metadata[key]
298
+ if isinstance(value, list):
299
+ if key == 'stimulation_timeframes':
300
+ display_value = ', '.join(map(str, value))
301
+ key = "Stimulation Timeframes"
302
+ elif key == 'stimulation_ms':
303
+ display_value = ', '.join(map(str, [int(v/1000) for v in value]))
304
+ key = "Stimulation Time (s)"
305
+ elif key == 'duty_cycle':
306
+ duty_cycle_counts = {val: value.count(val) for val in set(value)}
307
+ display_value = ' | '.join([f'{val}: {count}X' for val, count in duty_cycle_counts.items()])
308
+ key = "Duty Cycle"
309
+ elif key == 'stimulated_roi_location':
310
+ display_value = ', '.join(map(str, [len(x) for x in value]))
311
+ key = "Number of stimulated ROIs"
312
+ elif key == 'stimulated_rois':
313
+ if not value or all(not sublist for sublist in value):
314
+ display_value = "NA"
315
+ else:
316
+ display_value = ', '.join([', '.join(map(str, x)) for x in value])
317
+ key = "Stimulated ROIs"
318
+ else:
319
+ display_value = str(value)
320
+ self.add_info_row(self.stim_layout, row, key.replace('_', ' '),
321
+ display_value)
322
+ row += 1
304
323
 
305
324
  # Image Information
306
325
  row = 0
@@ -308,10 +327,14 @@ class MetadataViewer(QDialog):
308
327
  for key in image_keys:
309
328
  if key in self.metadata:
310
329
  value = self.metadata[key]
311
- if key == "pixel_size": key = "Pixel Size (µm)"
330
+ if key == "pixel_size":
331
+ key = "Pixel Size (µm)"
332
+ value = re.sub(r'[^0-9.]+', '', str(value))
312
333
  elif key == "FOV_size":
313
334
  key = "FOV Size (µm)"
314
- value = value[:-7]
335
+ # Parse FOV_size to format as "206 x 176" (without μm on individual values)
336
+ # Example input: "206μm x 176μm"
337
+ value = str(value).replace('μm', '').replace('um', '').replace('microns', '').strip()
315
338
 
316
339
  self.add_info_row(self.image_layout, row, key.replace('_', ' '),
317
340
  str(value))
@@ -74,20 +74,15 @@ class RoiListWidget(QWidget):
74
74
  roi_grid_layout.addWidget(self.save_roi_btn, 1, 0)
75
75
  roi_grid_layout.addWidget(self.load_roi_btn, 1, 1)
76
76
  roi_grid_layout.addWidget(self.export_trace_btn, 2, 0, 1, 2)
77
+
78
+ from PyQt6.QtWidgets import QCheckBox
79
+ self.hide_rois_checkbox = QCheckBox("Hide ROIs")
80
+ self.hide_rois_checkbox.stateChanged.connect(self._on_hide_rois_toggled)
81
+ roi_grid_layout.addWidget(self.hide_rois_checkbox, 3, 0)
77
82
 
78
- # Create checkboxes for ROI display options
79
- try:
80
- from PyQt6.QtWidgets import QCheckBox
81
- self.hide_rois_checkbox = QCheckBox("Hide ROIs")
82
- self.hide_rois_checkbox.stateChanged.connect(self._on_hide_rois_toggled)
83
- roi_grid_layout.addWidget(self.hide_rois_checkbox, 3, 0, 1, 2)
84
-
85
- self.display_labels_checkbox = QCheckBox("Hide Labels")
86
- self.display_labels_checkbox.stateChanged.connect(self._on_hide_labels_toggled)
87
- roi_grid_layout.addWidget(self.display_labels_checkbox, 3, 1, 1, 2)
88
- except Exception:
89
- self.hide_rois_checkbox = None
90
- self.display_labels_checkbox = None
83
+ self.display_labels_checkbox = QCheckBox("Hide Labels")
84
+ self.display_labels_checkbox.stateChanged.connect(self._on_hide_labels_toggled)
85
+ roi_grid_layout.addWidget(self.display_labels_checkbox, 3, 1)
91
86
 
92
87
  roi_vbox.addLayout(roi_grid_layout)
93
88
  roi_group.setLayout(roi_vbox)
@@ -202,18 +197,7 @@ class RoiListWidget(QWidget):
202
197
  except Exception:
203
198
  pass
204
199
  else:
205
- # Create new ROI - calculate next available ROI number
206
- existing_numbers = []
207
- for roi in self.main_window._saved_rois:
208
- roi_name = roi.get('name', '')
209
- if roi_name.startswith('ROI '):
210
- try:
211
- number = int(roi_name.split('ROI ')[1])
212
- existing_numbers.append(number)
213
- except (IndexError, ValueError):
214
- pass
215
-
216
- next_num = max(existing_numbers) + 1 if existing_numbers else 1
200
+ next_num = len(self.main_window._saved_rois) + 1
217
201
  name = f"ROI {next_num}"
218
202
 
219
203
  color = (
@@ -303,25 +303,58 @@ class TraceplotWidget(QWidget):
303
303
  self.trace_ax.cla()
304
304
 
305
305
  # Compute metric depending on available channels and selected formula
306
- # If red channel missing, switch to (Fg - Fo)/Fo (index 1) and disable other choices
306
+ # If red channel missing, show only single-channel formulas
307
307
  if sig2 is None:
308
- # Single-channel data: use (Fg - Fo)/Fo
309
- try:
310
- # set dropdown to index 1 (Fg - Fo / Fo) but do not allow changing it
311
- if self.formula_dropdown.count() > 1:
312
- # If index argument was provided override, respect it, otherwise set selection
313
- if index is None:
314
- self.formula_dropdown.setCurrentIndex(1)
315
- self.formula_dropdown.setEnabled(False)
316
- except Exception:
317
- pass
308
+ # Single-channel data: limit dropdown to (Fg - Fo)/Fo and raw Fg only
309
+ # Only modify dropdown if it currently has more than 2 items (switching from dual to single channel)
310
+ if self.formula_dropdown.count() > 2:
311
+ self.formula_dropdown.blockSignals(True)
312
+ current_index = self.formula_dropdown.currentIndex()
313
+
314
+ # Clear and repopulate with single-channel options only
315
+ self.formula_dropdown.clear()
316
+ self.formula_dropdown.addItem("(Fg - Fog) / Fog") # Index 0 for single channel
317
+ self.formula_dropdown.addItem("Fg only") # Index 1 for single channel
318
+
319
+ # Set appropriate default selection based on previous selection
320
+ if current_index == 2: # Was "Fg only"
321
+ self.formula_dropdown.setCurrentIndex(1)
322
+ else: # Default to (Fg - Fog) / Fog
323
+ self.formula_dropdown.setCurrentIndex(0)
324
+
325
+ self.formula_dropdown.blockSignals(False)
326
+ print("DEBUG: Switched to single-channel formula dropdown")
318
327
 
319
- # Safe denom: avoid division by zero
320
- denom_val = Fog if (Fog is not None and Fog != 0) else 1e-6
321
- metric = (sig1 - Fog) / denom_val
328
+ # Calculate metric based on selected formula
329
+ formula_index = self.formula_dropdown.currentIndex() if index is None else index
330
+ if formula_index == 1: # Fg only (raw)
331
+ metric = sig1
332
+ else: # (Fg - Fog) / Fog (default, index 0)
333
+ denom_val = Fog if (Fog is not None and Fog != 0) else 1e-6
334
+ metric = (sig1 - Fog) / denom_val
322
335
  else:
323
- # Two-channel data: enable formula selection
324
- self.formula_dropdown.setEnabled(True)
336
+ # Two-channel data: restore all formula options
337
+ # Only modify dropdown if it currently has 2 items (switching from single to dual channel)
338
+ if self.formula_dropdown.count() == 2:
339
+ self.formula_dropdown.blockSignals(True)
340
+ current_index = self.formula_dropdown.currentIndex()
341
+
342
+ # Clear and repopulate with all formula options
343
+ self.formula_dropdown.clear()
344
+ self.formula_dropdown.addItem("Fg - Fog / Fr") # Index 0
345
+ self.formula_dropdown.addItem("Fg - Fog / Fog") # Index 1
346
+ self.formula_dropdown.addItem("Fg only") # Index 2
347
+ self.formula_dropdown.addItem("Fr only") # Index 3
348
+
349
+ # Restore appropriate selection based on previous selection
350
+ if current_index == 1: # Was "Fg only" in single-channel mode
351
+ self.formula_dropdown.setCurrentIndex(2) # Set to "Fg only" in two-channel mode
352
+ else: # Was "(Fg - Fog) / Fog"
353
+ self.formula_dropdown.setCurrentIndex(1) # Keep as "(Fg - Fog) / Fog"
354
+
355
+ self.formula_dropdown.blockSignals(False)
356
+ print("DEBUG: Switched to dual-channel formula dropdown")
357
+
325
358
  formula_index = self.formula_dropdown.currentIndex() if index is None else index
326
359
  if formula_index == 0:
327
360
  denom = sig2.copy().astype(np.float32)
@@ -377,16 +410,61 @@ class TraceplotWidget(QWidget):
377
410
  print(f"DEBUG: First few time stamps: {time_stamps[:min(5, len(time_stamps))]}")
378
411
  break
379
412
 
380
- if time_stamps is not None and len(time_stamps) >= len(metric):
381
- # Use time stamps as x-axis (convert from ms to seconds)
382
- x_values = np.array(time_stamps[:len(metric)]) / 1000.0
383
- x_label = "Time (s)"
384
- # Convert current frame position to time
385
- if current_frame < len(time_stamps):
386
- current_x_pos = time_stamps[current_frame] / 1000.0
413
+ # Check if we have valid time stamps (not empty and has enough data)
414
+ has_valid_timestamps = (time_stamps is not None and
415
+ hasattr(time_stamps, '__len__') and
416
+ len(time_stamps) > 0 and
417
+ len(time_stamps) >= len(metric))
418
+
419
+ if has_valid_timestamps:
420
+ # Parse time stamps - they could be numbers (ms or seconds) or datetime strings
421
+ time_array = np.array(time_stamps[:len(metric)])
422
+
423
+ # Check if timestamps are strings (datetime format) or numbers
424
+ if isinstance(time_stamps[0], str):
425
+ # Parse datetime strings and convert to relative seconds
426
+ from datetime import datetime
427
+ try:
428
+ # Parse the datetime strings
429
+ dt_objects = []
430
+ for ts in time_stamps[:len(metric)]:
431
+ # Handle format like '2025-10-26 19:08:38.626'
432
+ dt = datetime.strptime(ts, '%Y-%m-%d %H:%M:%S.%f')
433
+ dt_objects.append(dt)
434
+
435
+ # Convert to seconds relative to first timestamp
436
+ first_dt = dt_objects[0]
437
+ x_values = np.array([(dt - first_dt).total_seconds() for dt in dt_objects])
438
+ print(f"DEBUG: Parsed datetime timestamps, duration: {x_values[-1]:.2f}s")
439
+ except Exception as e:
440
+ print(f"DEBUG: Error parsing datetime strings: {e}")
441
+ # Fall back to frame numbers
442
+ show_time = False
443
+ x_values = None
387
444
  else:
388
- current_x_pos = time_stamps[-1] / 1000.0 if len(time_stamps) > 0 else current_frame
389
- print(f"DEBUG: Using time stamps for x-axis (converted from ms), current position: {current_x_pos}s")
445
+ # Numeric timestamps - detect if milliseconds or seconds
446
+ max_time = np.max(time_array) if len(time_array) > 0 else 0
447
+
448
+ # Heuristic: if max time > 10000, assume milliseconds; otherwise seconds
449
+ if max_time > 10000:
450
+ # Data is in milliseconds, convert to seconds
451
+ x_values = time_array / 1000.0
452
+ print(f"DEBUG: Time stamps appear to be in milliseconds (max={max_time:.2f}), converting to seconds")
453
+ else:
454
+ # Data is already in seconds (or very short recording in ms)
455
+ x_values = time_array
456
+ print(f"DEBUG: Time stamps appear to be in seconds (max={max_time:.2f})")
457
+
458
+ if x_values is not None:
459
+ x_label = "Time (s)"
460
+
461
+ # Convert current frame position to time
462
+ if current_frame < len(x_values):
463
+ current_x_pos = x_values[current_frame]
464
+ else:
465
+ current_x_pos = x_values[-1] if len(x_values) > 0 else current_frame
466
+
467
+ print(f"DEBUG: Using time stamps for x-axis, current position: {current_x_pos}s")
390
468
  else:
391
469
  # Fallback: estimate time based on frame rate (if available)
392
470
  frame_rate = getattr(ed, 'frame_rate', None) if ed else None
@@ -432,14 +510,38 @@ class TraceplotWidget(QWidget):
432
510
  print(f"DEBUG: Found {len(stims)} stimulation timeframes: {stims}")
433
511
 
434
512
  # Convert stimulation timeframes to appropriate x-axis units
435
- if show_time and x_values is not None:
436
- # Convert stim frames to time positions (from ms to seconds)
437
- for stim in stims:
438
- stim_frame = int(stim)
439
- if stim_frame < len(time_stamps):
440
- stim_x_pos = time_stamps[stim_frame] / 1000.0
441
- print(f"DEBUG: Adding stimulation vline at time {stim_x_pos:.2f}s (frame {stim_frame})")
442
- self.trace_ax.axvline(stim_x_pos, color='red', linestyle='--', zorder=15, linewidth=2)
513
+ if show_time and x_values is not None and time_stamps is not None:
514
+ # Handle both datetime strings and numeric timestamps
515
+ if isinstance(time_stamps[0], str):
516
+ # Parse datetime strings for stimulation times
517
+ from datetime import datetime
518
+ try:
519
+ first_dt = datetime.strptime(time_stamps[0], '%Y-%m-%d %H:%M:%S.%f')
520
+ for stim in stims:
521
+ stim_frame = int(stim)
522
+ if stim_frame < len(time_stamps):
523
+ stim_dt = datetime.strptime(time_stamps[stim_frame], '%Y-%m-%d %H:%M:%S.%f')
524
+ stim_x_pos = (stim_dt - first_dt).total_seconds()
525
+ print(f"DEBUG: Adding stimulation vline at time {stim_x_pos:.2f}s (frame {stim_frame})")
526
+ self.trace_ax.axvline(stim_x_pos, color='red', linestyle='--', zorder=15, linewidth=2)
527
+ except Exception as e:
528
+ print(f"DEBUG: Error parsing datetime for stimulation: {e}")
529
+ else:
530
+ # Numeric timestamps - detect format
531
+ time_array = np.array(time_stamps)
532
+ max_time = np.max(time_array) if len(time_array) > 0 else 0
533
+ is_milliseconds = max_time > 10000
534
+
535
+ # Convert stim frames to time positions
536
+ for stim in stims:
537
+ stim_frame = int(stim)
538
+ if stim_frame < len(time_stamps):
539
+ if is_milliseconds:
540
+ stim_x_pos = time_stamps[stim_frame] / 1000.0
541
+ else:
542
+ stim_x_pos = time_stamps[stim_frame]
543
+ print(f"DEBUG: Adding stimulation vline at time {stim_x_pos:.2f}s (frame {stim_frame})")
544
+ self.trace_ax.axvline(stim_x_pos, color='red', linestyle='--', zorder=15, linewidth=2)
443
545
  else:
444
546
  # Use frame numbers
445
547
  for stim in stims:
@@ -538,8 +640,27 @@ class TraceplotWidget(QWidget):
538
640
  time_stamps = getattr(ed, attr_name)
539
641
  break
540
642
 
541
- if time_stamps is not None and current_frame < len(time_stamps):
542
- current_x_pos = time_stamps[current_frame] / 1000.0
643
+ if time_stamps is not None and len(time_stamps) > 0 and current_frame < len(time_stamps):
644
+ # Handle both datetime strings and numeric timestamps
645
+ if isinstance(time_stamps[0], str):
646
+ # Parse datetime strings
647
+ from datetime import datetime
648
+ try:
649
+ first_dt = datetime.strptime(time_stamps[0], '%Y-%m-%d %H:%M:%S.%f')
650
+ current_dt = datetime.strptime(time_stamps[current_frame], '%Y-%m-%d %H:%M:%S.%f')
651
+ current_x_pos = (current_dt - first_dt).total_seconds()
652
+ except Exception:
653
+ # Fall back to frame rate if parsing fails
654
+ pass
655
+ else:
656
+ # Numeric timestamps - detect if milliseconds or seconds
657
+ max_time = np.max(time_stamps) if len(time_stamps) > 0 else 0
658
+ if max_time > 10000:
659
+ # Data is in milliseconds, convert to seconds
660
+ current_x_pos = time_stamps[current_frame] / 1000.0
661
+ else:
662
+ # Data is already in seconds
663
+ current_x_pos = time_stamps[current_frame]
543
664
  elif ed is not None:
544
665
  # Fallback: estimate time based on frame rate
545
666
  if isinstance(ed, dict):
@@ -575,7 +696,28 @@ class TraceplotWidget(QWidget):
575
696
  break
576
697
 
577
698
  if time_stamps is not None and len(time_stamps) > 0:
578
- xmax = max(np.array(time_stamps[:min(nframes, len(time_stamps))]) / 1000.0)
699
+ # Handle both datetime strings and numeric timestamps
700
+ if isinstance(time_stamps[0], str):
701
+ # Parse datetime strings
702
+ from datetime import datetime
703
+ try:
704
+ first_dt = datetime.strptime(time_stamps[0], '%Y-%m-%d %H:%M:%S.%f')
705
+ last_idx = min(nframes, len(time_stamps)) - 1
706
+ last_dt = datetime.strptime(time_stamps[last_idx], '%Y-%m-%d %H:%M:%S.%f')
707
+ xmax = (last_dt - first_dt).total_seconds()
708
+ except Exception:
709
+ xmax = max(1, nframes - 1)
710
+ else:
711
+ # Numeric timestamps - detect if milliseconds or seconds
712
+ time_array = np.array(time_stamps[:min(nframes, len(time_stamps))])
713
+ max_time = np.max(time_array) if len(time_array) > 0 else 0
714
+
715
+ if max_time > 10000:
716
+ # Data is in milliseconds, convert to seconds
717
+ xmax = max(time_array / 1000.0)
718
+ else:
719
+ # Data is already in seconds
720
+ xmax = max(time_array)
579
721
  elif ed is not None:
580
722
  frame_rate = getattr(ed, 'frame_rate', None)
581
723
  if frame_rate and frame_rate > 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phasor-handler
3
- Version: 2.2.3
3
+ Version: 2.2.4
4
4
  Summary: A PyQt6 GUI toolbox for processing two-photon phasor imaging data
5
5
  Author-email: Josia Shemuel <joshemuel@users.noreply.github.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phasor-handler"
7
- version = "2.2.3"
7
+ version = "2.2.4"
8
8
  authors = [{ name = "Josia Shemuel", email = "joshemuel@users.noreply.github.com" }]
9
9
  description = "A PyQt6 GUI toolbox for processing two-photon phasor imaging data"
10
10
  readme = "README.md"
File without changes
File without changes