diff --git a/.gitignore b/.gitignore index 8dfb1973..57fa7e62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ +*.egg-info +.coverage* +.vscode/ +data/ +pyinstaller/macos/_secrets.py +sandbox/ + pyinstaller/monterey/tmp_env build/ @@ -8,6 +15,10 @@ sanpy_env/* sanpy.log sanpy/sanpy.log +# Log files +logs/ +*.log + .AppleDouble .DS_Store *.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index 748e5260..06df12e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ SanPy Documentation is available at [https://cudmore.github.io/SanPy/](https://cudmore.github.io/SanPy/) +## 202403 + +### New features + + - Re-added the File - Save menu. This will save the current analysis for one file or a folder. + - Added a "bin time" pooling option to summary plugin. Makes a table with stats (count, mean, std, se, median) in evenly spaced windows of time. For example pooles between [0,2) then [2,4) then [4,6), etc, seconds. + - Added a colorbar to scatter plot plugin when selecting hue as "Time". This allows users to know where a point in the scatter occured in the recording time. + +### Bug fixes + + - Fixed bug in plugin - summarize results - sweep summary + - Fixed bug in plugin - plot scatter when selecting hue of Time or Sweep + ## 20240126 - Added a file folder opening window. This is show at first run and allows users to open new and previous opened files and folder. diff --git a/dev-notes/colin-dev-log.md b/dev-notes/colin-dev-log.md new file mode 100644 index 00000000..2eb5bcc6 --- /dev/null +++ b/dev-notes/colin-dev-log.md @@ -0,0 +1,36 @@ +# Colin Development Log + +This file tracks ongoing development, refactoring, and testing for colin-related code in the SanPy project. + +**Note:** This development log was mostly auto-generated by Cursor AI, an AI coding assistant, to help track changes and maintain context during development. + +--- + +## 2024-06-13: Refactor, Utility Improvements, and Robust Testing + +### Major Changes +- **Refactored** `colin_global.py` and `colin_stats.py` for clarity, maintainability, and best practices. +- **Introduced** the `FileInfo` dataclass for robust parsing of file paths (e.g., extracting cellID, epoch, date, region, condition). +- **Added** a utility iterator function to return unique rows for a given cellID from a DataFrame. +- **Replaced** ad-hoc file parsing logic in `colin_stats.py` with the new `FileInfo` dataclass. +- **Fixed** pandas `SettingWithCopyWarning` by using `.copy()` when needed. + +### Testing +- **Created** `sanpy/kym/tests/test_colin_global.py` and `test_colin_stats.py`: + - Each test prints results and uses `assert` statements to verify expected values. + - Tests cover normal cases, edge cases, and error conditions. + - Tests for DataFrame operations, iterator logic, and `.copy()` best practices. +- **Added** `run_all_tests.py` to run all colin-related tests and provide a summary. +- **Added** a `README.md` in the tests folder to document test structure and usage. + +### Benefits +- **Regression-proof**: Tests will catch breaking changes or regressions. +- **Documentation**: Test cases and this log serve as living documentation. +- **Maintainability**: Refactored code is easier to extend and debug. +- **Professional structure**: Follows best practices for code and test organization. + +--- + +**Next Steps:** +- Continue to log all major refactors, new utilities, and test improvements here. +- Use this file for context when returning to the project or onboarding collaborators. \ No newline at end of file diff --git a/pyinstaller/macos/build_arm.sh b/pyinstaller/macos/build_arm.sh index 8009cfb9..f27e935a 100755 --- a/pyinstaller/macos/build_arm.sh +++ b/pyinstaller/macos/build_arm.sh @@ -64,7 +64,14 @@ conda install -y numpy \ conda install -y -c conda-forge mplcursors pip install pyabf + +# 20250627, abb building sanpykym, we might need, pyqtdarktheme-fork pip install pyqtdarktheme +pip install qtawesome +pip install roifile +pip install statannotations +# pip install tifffile +# pip install tables # install sanpy with no packages pip install -e '../../.' @@ -76,4 +83,5 @@ pip install pyinstaller # build the app with pyinstaller python macos_build.py -python notarizeSanpy.py dist_arm \ No newline at end of file +# abb 202506 turn back on to notarize, getting password error +# python notarizeSanpy.py dist_arm \ No newline at end of file diff --git a/pyinstaller/macos/readme.md b/pyinstaller/macos/readme.md index a7b6c99a..c367da07 100644 --- a/pyinstaller/macos/readme.md +++ b/pyinstaller/macos/readme.md @@ -1,5 +1,7 @@ This folder contains scripts to build macOS app(s) from Python source code using pyinstaller. +My apple developer account is at https://developer.apple.com/account + Building a Python app on macOS is by no means simple. The app needs to be properly codesigned, notarized on an Apple server (requires uploading a zip of the app to Apple), waiting for the ok, and then staple(ing) the app with the notarization. This all requires an Apple Developer Subscription which is $99/year. diff --git a/pyproject.toml b/pyproject.toml index a423ec03..9a11a253 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,67 @@ requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2", "wheel"] build-backend = "setuptools.build_meta" -#[project] +[project] +name = "sanpy" # version = "0.0.1" # Remove any existing version parameter. -#dynamic = ["version"] +dynamic = ["version"] +requires-python = ">=3.11" +dependencies = [] # Base install has no requirements + +[project.optional-dependencies] +gui = [ + "h5py", + "matplotlib", + "mplcursors", + "numpy>=1.20.0", + "pandas>=2.0.0", + "pyabf", + "PyQt5>=5.15.0", + "pyqtdarktheme-fork", + "pyqtgraph", + "qtawesome", + "qtpy", + "requests", + "roifile", # For reading Fiji ROI files + "scikit-image", + "scipy>=1.8.0", + "seaborn", # For statistical visualizations + "statannotations", # For statistical annotations + "tables", + "tifffile" +] + +dev = [ + "flake8", + "ipython", + "jupyter", + "mkdocs", + "mkdocs-material", + "mkdocs-jupyter", + "mkdocstrings", + "mkdocstrings-python", + "pyinstaller", + "pytest", + "pytest-cov", + "pytest-qt", + "tornado", + "tox" +] + +test = [ + "flake8", + "pytest", + "pytest-cov", + "pytest-qt", + "tox" +] + +[project.scripts] +sanpy = "sanpy.interface.sanpy_app:main" +sanpykym = "sanpy.kym.interface.kym_file_list.tif_tree_window:main" + +[tool.setuptools] +packages = ["sanpy"] [tool.setuptools_scm] write_to = "sanpy/_version.py" @@ -15,4 +73,7 @@ local_scheme = "no-local-version" [tool.ruff] ignore = [ "E501", # Supress line-too-long warnings: trust black's judgement on this one. -] \ No newline at end of file +] + +[tool.pytest.ini_options] +addopts = "--ignore=napari" \ No newline at end of file diff --git a/readme-dev.md b/readme-dev.md index bcf4cfba..1403590b 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -30,6 +30,32 @@ git tag -d v0.1.25 git push --delete origin v0.1.25 ``` +202402 + +list remote tags + git ls-remote --tags origin +commit with a tag + git tag -a v1.0.36 -m 'release 1.0.36' +push the tag + git push --follow-tags + +get version from setup + python setup.py --version + +I AM SO SICK OF VERSIONING !!!!!! + +Logic is (this is the way) + +``` +update version in setup.py +update version in _myVersion.py +git commit -am 'v1.0.42' +git push + +git tag -a v1.0.42 -m 'release v1.0.42' +git push --follow-tags +``` + ## Lock down a version to accompany SanPy manuscript Working on publishing v0.1.8 diff --git a/sanpy/__init__.py b/sanpy/__init__.py index b5337f4e..2f2cb934 100644 --- a/sanpy/__init__.py +++ b/sanpy/__init__.py @@ -1,6 +1,6 @@ # using this to turn off for 1st sanpy publication -D_BJ_MANUSCRIPT = True -DO_KYMOGRAPH_ANALYSIS = False +D_BJ_MANUSCRIPT = False +DO_KYMOGRAPH_ANALYSIS = True from .sanpyLogger import * diff --git a/sanpy/_util.py b/sanpy/_util.py index 0ba9d6c7..44065ef2 100644 --- a/sanpy/_util.py +++ b/sanpy/_util.py @@ -200,6 +200,26 @@ def _loadLineScanHeader(path): txtFile = os.path.splitext(path)[0] + ".txt" + if not os.path.isfile(txtFile): + # find "ISAN Linescan 6 Metadata.txt" + logger.info(f'did not find {txtFile}') + logger.info(' -->> searching ...') + + _folder, _file = os.path.split(path) + _file, _ = os.path.splitext(_file) + _file += ' Metadata.txt' + logger.info(f' -->> looking for Olympus "{_file}"') + txtFile = os.path.join(_folder, _file) + if not os.path.isfile(txtFile): + _folder, _file = os.path.split(path) + _file, _ = os.path.splitext(_file) + _file = _file.split('_')[0] # may fail + _file += '.txt' + logger.info(f' -->> looking for Olympus "{_file}"') + txtFile = os.path.join(_folder, _file) + if not os.path.isfile(txtFile): + logger.warning(f'DID NOT FIND CORRESPONDING OLYMPUS FILE: {os.path.split(path)[1]}') + if not os.path.isfile(txtFile): # logger.error(f"did not find file:{txtFile}") @@ -283,6 +303,17 @@ def _loadLineScanHeader(path): # elif line.startswith('"Image Size(Unit Converted)"'): # print('loadLineScanHeader:', line) + elif line.startswith('"Date"'): + # "Date" "09/12/2024 01:33:26.239 PM" + # logger.info(f'date line is: {line}') + _date, _datetime = line.split('\t') + _datetime = _datetime.replace('"', '') # remove "" + _date, _time, _ampm = _datetime.split(' ') + # logger.info(f' _date:{_date} _time:{_time} _ampm:{_ampm}') + + theRet ['date'] = _date + theRet ['time'] = _time + ' ' + _ampm + # tif shape is (lines, pixels) if gotNumPixels and gotImageSize: shape = (theRet["numLines"], theRet["numPixels"]) @@ -341,9 +372,5 @@ def getFileList(path, depth=1): return fileList if __name__ == '__main__': - path = '/Users/cudmore/Dropbox/data/cell-shortening/fig1' - fileList = getFileList(path, 4) - for file in fileList: - print(file) - + pass diff --git a/sanpy/analysisDir.py b/sanpy/analysisDir.py index 2425e071..e88f677d 100644 --- a/sanpy/analysisDir.py +++ b/sanpy/analysisDir.py @@ -354,9 +354,6 @@ def _old_santana_file_finder(files): retDict = {} # list of full path to tif files in all subfolders - # files = [y for x in os.walk(path) for y in glob(os.path.join(x[0], '*.tif'))] - #files = sanpy._util.getFileList(path) - for file in files: if not file.endswith('.tif'): continue @@ -420,6 +417,14 @@ def _walk(path, theseFileTypes, depth=None): else: top_pathlen = len(path) + len(os.path.sep) for dirpath, dirnames, filenames in os.walk(path): + + # abb 202505 colin + # this does not work !!! + # logger.info(f'dirnames before sort: {dirnames}') + # dirnames.sort() + # logger.info(f'dirnames after sort: {dirnames}') + # filenames.sort() + dirlevel = dirpath[top_pathlen:].count(os.path.sep) if depth and dirlevel >= depth: dirnames[:] = [] @@ -433,7 +438,19 @@ def _walk(path, theseFileTypes, depth=None): yield os.path.join(dirpath, filename) def getFileList(path, theseFileTypes, depth=1): + logger.info(f'{path}') + logger.info(f' theseFileTypes:{theseFileTypes}') + logger.info(f' depth:{depth}') + fileList = [filePath for filePath in _walk(path, theseFileTypes, depth)] + + # ignore path with folder 'kym-roi-img-clips' + fileList = [filePath for filePath in fileList \ + if 'kym-roi-img-clips' not in filePath + and 'sanpy-kym-roi-analysis' not in filePath + and 'sanpy-reports-pdf' not in filePath # all pdf reports AND roi tif clips + ] + return fileList def stripSantanaTif(fileList : List[str]) -> List[str]: @@ -462,7 +479,8 @@ class analysisDir: # Dict of dict of column names and bookkeeping info. # 20231230, get this from sanpyapp fileloader keys - # theseFileTypes = [".abf", ".atf", ".sanpy", ".tif"] # .dat .czi + # baltimore april put back in ??? + #theseFileTypes = [".abf", ".atf", ".sanpy", ".tif"] # .dat .czi # File types to load. def __init__( @@ -487,6 +505,7 @@ def __init__( PyQt, used to signal progress on loading fileLoaderDict (dict): Dict with file extension keys (no dot) + See: sanpy.fileloaders.getFileLoaders(verbose=True) autoLoad (bool): If True then folderDepth (int): @@ -506,7 +525,7 @@ def __init__( else: folderPath = path - logger.info(f'{path}') + logger.info(f'folderDepth:{folderDepth} {path}') self.path: str = folderPath # path to folder @@ -514,7 +533,7 @@ def __init__( # used to signal on building initial db self._fileLoaderDict = fileLoaderDict - # dist with file extension keys + # dict with file extension keys self.autoLoad = autoLoad # not used @@ -545,15 +564,6 @@ def __init__( logger.info(f'sync existing df with filePath: {self._filePath}') self.syncDfWithPath() - # if we have a filePath and not in df then add it - # if self._filePath is not None: - - # logger.info('self._df') - # print(self._df) - - # self._df = self.loadFolder(loadData=autoLoad) - - # self._checkColumns() self._updateLoadedAnalyzed() @@ -611,7 +621,8 @@ def __len__(self): return len(self._df) @property - def numFiles(self): + def numFiles(self) -> int: + """Get the number of files. same as len().""" return len(self._df) @property @@ -679,7 +690,8 @@ def copy(self): return self._df.copy() def sort_values(self, Ncol, order): - logger.info(f"sorting by column {self.columns[Ncol]} with order:{order}") + logger.info(f'sorting by column "{self.columns[Ncol]}" with ascending order:{order}') + print(self.columns[Ncol]) self._df = self._df.sort_values(self.columns[Ncol], ascending=not order) # print(self._df) @@ -701,15 +713,10 @@ def columnIsCheckBox(self, colName): # logger.info(f'{colName} {type(type)}, type:{type} {isBool}') return isBool - def getDataFrame(self): + def getDataFrame(self) -> pd.DataFrame: """Get the underlying pandas DataFrame.""" return self._df - @property - def numFiles(self): - """Get the number of files. same as len().""" - return len(self._df) - def copyToClipboard(self): """ TODO: Is this used or is copy to clipboard in pandas model? @@ -1156,7 +1163,7 @@ def getAnalysis(self, rowIdx, allowAutoLoad=True, verbose=False) -> sanpy.bAnaly Return: bAnalysis """ - file = self._df.loc[rowIdx, "File"] + # file = self._df.loc[rowIdx, "File"] ba = self._df.loc[rowIdx, "_ba"] uuid = self._df.loc[rowIdx, "uuid"] # if we have a uuid bAnalysis is saved in h5f # filePath = os.path.join(self.path, file) @@ -1164,7 +1171,7 @@ def getAnalysis(self, rowIdx, allowAutoLoad=True, verbose=False) -> sanpy.bAnaly # logger.info(f'rowIdx: {rowIdx} ba:{ba}') if ba is None or ba == "": - # logger.info('did not find _ba ... loading from abf file ...') + logger.info('did not find _ba ... loading from abf file ...') # working on kymograph # relPath = self.getPathFromRelPath(ba._path) relPath = self._df.loc[rowIdx, "relPath"] @@ -1383,11 +1390,6 @@ def getFileList(self, """ - # to open just one file - # if forceFolder: - # # we are forcing reload of an entire folder - # self._filePath = None - if self._filePath is not None: logger.info(f'returning one file {self._filePath}') return [self._filePath] @@ -1400,67 +1402,6 @@ def getFileList(self, fileList = stripSantanaTif(fileList) return fileList - logger.warning("Remember: MODIFIED TO LOAD TIF FILES IN SUBFOLDERS") - count = 1 - tmpFileList = [] - folderDepth = self.folderDepth # if none then all depths - excludeFolders = ["analysis", "hide"] - for root, subdirs, files in os.walk(path): - subdirs[:] = [d for d in subdirs if d not in excludeFolders] - - print(f'count:{count} folderDepth:{folderDepth}') - print(' root:', root) - print(' subdirs:', subdirs) - print(' files:', files) - - # strip out folders that start with __ - # _parentFolder = os.path.split(root)[1] - # print('root:', root) - # print(' parentFolder:', _parentFolder) - # if _parentFolder.startswith('__'): - if "__" in root: - logger.info(f"SKIPPING based on path root:{root}") - continue - - if os.path.split(root)[1] == "analysis": - # don't load from analysis/ folder, we save analysis there - continue - - # if os.path.split(root)[1] == 'hide': - # # special case/convention, don't load from 'hide' folders - # continue - - for file in files: - # TODO (cudmore) parse all our fileLoader(s) for a list - _, _ext = os.path.splitext(file) - if _ext in self.theseFileTypes: - oneFile = os.path.join(root, file) - tmpFileList.append(oneFile) - - count += 1 - if folderDepth is not None and count > folderDepth: - break - - fileList = [] - for file in sorted(tmpFileList): - if file.startswith("."): - continue - # ignore our database file - if file == self.dbFile: - continue - - # tmpExt is like .abf, .csv, etc - tmpFileName, tmpExt = os.path.splitext(file) - if tmpExt in self.theseFileTypes: - # if getFullPath: - # #file = os.path.join(path, file) - # file = pathlib.Path(path) / file - # file = str(file) # return List[str] NOT List[PosixPath] - fileList.append(file) - # - logger.info(f"found {len(fileList)} files ...") - return fileList - def getRowDict(self, rowIdx): """ Return a dict with selected row as dict (includes detection parameters). diff --git a/sanpy/analysisPlot.py b/sanpy/analysisPlot.py index 9c6f2515..8a9cfe0d 100644 --- a/sanpy/analysisPlot.py +++ b/sanpy/analysisPlot.py @@ -160,8 +160,7 @@ def _makeFig(self, plotStyle=None): return fig, ax def plotRaw(self, plotStyle=None, ax=None): - """ - Plot raw recording + """Plot sweepx vs sweepY of raw recording. Args: plotStye (float): @@ -387,6 +386,7 @@ def plotClips( """ if self.ba.numSpikes == 0: + logger.warning('no spikes to plot !!!') return None, None if ax is None: @@ -415,7 +415,11 @@ def plotClips( sweepNumber=sweepNumber, ) numClips = len(theseClips) - + logger.warning(f'got num clips {numClips}') + if numClips == 0: + logger.error('did not find any clips -->> abort') + return + # convert clips to 2d ndarray ??? xTmp = np.array(theseClips_x) # xTmp /= self.ba.dataPointsPerMs * 1000 # pnt to seconds @@ -464,7 +468,7 @@ def plotClips( # ax.plot(xPlot, yPlot, '-g', linewidth=0.5, color='g') ax.plot(xPlot, yPlot, "-", label=f"{i}", color=color, linewidth=0.5) - yLabel = self.ba._sweepLabelY + yLabel = self.ba.fileLoader._sweepLabelY ax.set_ylabel(yLabel) ax.set_xlabel("Time (sec)") diff --git a/sanpy/analysisUtil.py b/sanpy/analysisUtil.py index 5961e6bc..efda1519 100644 --- a/sanpy/analysisUtil.py +++ b/sanpy/analysisUtil.py @@ -22,6 +22,8 @@ def throwOutAboveBelow( spikeTimes (list): list of spike times spikeErrors (list): list of error """ + logger.warning('') + newSpikeTimes = [] newSpikeErrorList = [] newSpikePeakPnt = [] diff --git a/sanpy/bAbfText.py b/sanpy/bAbfText.py index bd1a0a47..91ab57bf 100644 --- a/sanpy/bAbfText.py +++ b/sanpy/bAbfText.py @@ -295,11 +295,12 @@ def _abfFromLineScanTif(self, path, theRect=None): # we want our tiffs to be short and wide # shape[0] is space, shape[1] is time - if tif.shape[0] < tif.shape[1]: - # correct shape - pass - else: - tif = np.rot90(tif) # rotates 90 degrees counter-clockwise + logger.warning('2 removed np.rot90()') + # if tif.shape[0] < tif.shape[1]: + # # correct shape + # pass + # else: + # tif = np.rot90(tif) # rotates 90 degrees counter-clockwise f0 = tif.mean() tifNorm = (tif - f0) / f0 diff --git a/sanpy/bAnalysis_.py b/sanpy/bAnalysis_.py index 5312fb00..38c70161 100644 --- a/sanpy/bAnalysis_.py +++ b/sanpy/bAnalysis_.py @@ -176,6 +176,7 @@ def __init__( logger.info(f' self.fileLoader.filepath:{self.fileLoader.filepath}') logger.info(f' self.fileLoader.tifData:{self.fileLoader.tifData.shape}') logger.info(f' self.fileLoader.tifHeader:{self.fileLoader.tifHeader}') + # logger.warning('expand fileLoader.tifData to multiple channels') self._kymAnalysis = sanpy.kymAnalysis(self.fileLoader.filepath, self.fileLoader.tifData, self.fileLoader.tifHeader) diff --git a/sanpy/bDetection.py b/sanpy/bDetection.py index c3ca874c..ca9e0c29 100644 --- a/sanpy/bDetection.py +++ b/sanpy/bDetection.py @@ -36,7 +36,7 @@ import copy from collections import OrderedDict -from matplotlib.font_manager import json_load +# from matplotlib.font_manager import json_load # from colin.stochAnalysis import load diff --git a/sanpy/bExport.py b/sanpy/bExport.py index 6f5eed01..f1bb8628 100644 --- a/sanpy/bExport.py +++ b/sanpy/bExport.py @@ -1,7 +1,7 @@ import os +import math from collections import OrderedDict -from pprint import pprint -from typing import Union, Dict, List, Tuple, Optional +from typing import List, Optional import numpy as np import pandas as pd @@ -99,6 +99,89 @@ def old_report(self, theMin, theMax): return df + # abb 03/2024 + def report4(self, + sweep = 'All', + epoch = 'All', + minMaxList : List[float] = None, + ) -> pd.DataFrame: + """Generate a report for a number of time intervals + + Parameters + ---------- + minMaxList : List[float] + List of time to make bins, read each pair as start/stop + + Notes + ----- + Implemented 03/2024 for xxx(yyy lab, UCD) + """ + + theseColumns = ['thresholdVal', + 'thresholdVal_dvdt', + 'peakVal', + 'peakHeight', + 'timeToPeak_ms', + 'fastAhpSec', + 'fastAhpValue', + 'preSpike_dvdt_max_val', + 'preSpike_dvdt_max_val2', + 'postSpike_dvdt_min_val', + 'postSpike_dvdt_min_val2', + 'isi_ms', + 'spikeFreq_hz', + 'widths_20', + 'widths_50', + 'widths_80' + ] + + listOfDict = [] + + # step through parwise from ... to + for theMin,theMax in zip(minMaxList, minMaxList[1:]): + # print(theMin, theMax) + df = self.report3(sweep=sweep, epoch=epoch, + theMin=theMin, + theMax=theMax) + + n = len(df) + # print(theMin, theMax, n) + + if n == 0: + # logger.warning(f'did not get spikes in interval {theMin}:{theMax}') + continue + + # now for a subset of columns, calculate stats (count, mean, std, se) + oneDict = { + 'start (sec)': theMin, + 'stop (sec)': theMax, + } + + # aggList = ["count", "mean", "std", "sem", "median"] + + for colStat in theseColumns: + _count = n # number of spikes + _mean = np.nanmean(df[colStat]) + _std = np.nanstd(df[colStat]) + _se = _std / math.sqrt(_count) + _min = np.nanmin(df[colStat]) + _max = np.nanmax(df[colStat]) + + oneDict[colStat+'_count'] = _count + oneDict[colStat+'_mean'] = _mean + oneDict[colStat+'_std'] = _std + oneDict[colStat+'_se'] = _se + oneDict[colStat+'_min'] = _min + oneDict[colStat+'_max'] = _max + + listOfDict.append(oneDict) + + # make a dataframe from our list of dict + dfFinal = pd.DataFrame(listOfDict) + + # print(dfFinal.head()) + return dfFinal + def report3(self, sweep='All', epoch='All', theMin : Optional[float] = None, @@ -110,7 +193,7 @@ def report3(self, sweep='All', """ if self.ba.numSpikes == 0: - logger.warning(f"did not find and spikes for summary") + logger.warning("did not find and spikes for summary") return None df = self.ba.asDataFrame() # full df with all spikes @@ -122,7 +205,7 @@ def report3(self, sweep='All', if theMin is not None and theMax is not None: df = df[ (df['thresholdSec']>=theMin) & (df['thresholdSec'] {_img.shape}') + for _channelIdx in range(self._numChannels): + _img = loadedTif[:, :, _channelIdx] + self._tif.append(_img) # olympus like (1000, 1023, 3) + + # logger.warning(f' for colin kym, swapping 2nd channel into 1s {self.filepath}') + # self._tif[0] = self._tif[1] + # self._tif[1] = np.flip(self._tif[1], axis=1) + + else: + self._tif.append(loadedTif[:, 0, :]) # czi elif numLoadedDims == 4: + # (3756, 2, 1, 1024) # multi channel czi line scan with frames (frames, channels, height, width) _channelDimension = 1 self._numChannels = loadedTif.shape[_channelDimension] for _channel in range(self._numChannels): - self._tif.append(loadedTif[:, _channel, 0, :]) + _appendThisImgData = loadedTif[:, _channel, 0, :] + logger.info(f' _appendThisImgData:{_appendThisImgData.shape}') + self._tif.append(_appendThisImgData) else: - logger.error(f'did not understand image with sahpe {loadedTif.shape}') + logger.error(f' did not understand image with shape {loadedTif.shape}') self._loadError = True return # image must be shape[0] is time/big, shape[1] is space/small for _channel, img in enumerate(self._tif): - if img.shape[1] < img.shape[0]: - logger.info(f"rot90 image with shape: {img.shape}") - self._tif[_channel] = np.rot90( - img, 1 - ) # ROSIE, so lines are not backward + # logger.info('3 removed np.rot90()') + # if 1 or img.shape[1] < img.shape[0]: + # logger.info(f"rot90 image with shape: {img.shape}") + + img = np.rot90(img) # we want shape (pixels, lines) + + # new 20250529 + img = np.flip(img, axis=0) - if self._tif[0].dtype == np.uint8: + # img = np.rot90(img) # ROSIE, so lines are not backward + # img = np.flip(img) + + # img = img.astype(np.int8) # to all pos and negative + if img.dtype == np.uint16: + # logger.warning(f' converting _channel:{_channel} {img.dtype} to np.int16') + # logger.info(f' orig min:{np.min(img)} max:{np.max(img)}') + img = img.astype(np.int16) # to all pos and negative + # logger.info(f' orig min:{np.min(img)} max:{np.max(img)}') + elif img.dtype == np.uint8: + # logger.warning(f' converting _channel:{_channel} {img.dtype} to np.int8') + # logger.info(f' orig min:{np.min(img)} max:{np.max(img)}') + # img = img.astype(np.int8) # + img = img.astype(int) + img = img.astype(np.uint8) + # logger.info(f' after min:{np.min(img)} max:{np.max(img)}') + + # logger.info(f'tif _channel:{_channel} shape:{img.shape} mean:{np.mean(img)}') + + # add image to our list of channels + self._tif[_channel] = img + + debugZeissTiffExport = False + if debugZeissTiffExport: + logger.warning(f' debugZeissTiffExport:{debugZeissTiffExport}') + if _channel == 1: + self._tif[0] = self._tif[1] + self._tif[1] = np.flip(self._tif[0], axis=1) + + # check the dtype of the first tif (all are the same) + if self._tif[0].dtype in [np.uint8, np.int8]: _bitDepth = 8 - elif self._tif[0].dtype == np.uint16: + elif self._tif[0].dtype in [np.uint16, np.int16]: _bitDepth = 16 else: - logger.warning(f'Did not undertand dtype {self._tif[0].dtype} defaulting to bit depth 16') + logger.error(f'Did not understand dtype {self._tif[0].dtype} defaulting to bit depth 16') _bitDepth = 16 # we need a header to mimic one in original bAnalysis @@ -267,18 +362,27 @@ def loadFile(self): } # load olympus txt file if it exists - _olympusHeader = _loadLineScanHeader(self.filepath) + # _olympusHeader = _loadLineScanHeader(self.filepath) if _olympusHeader is not None: # logger.info('loaded olympus header for {self.filepath}') # for k,v in _olympusHeader.items(): # logger.info(f' {k}: {v}') + try: self._tifHeader['umPerPixel'] = _olympusHeader["umPerPixel"] self._tifHeader['secondsPerLine'] = _olympusHeader["secondsPerLine"] except (KeyError) as e: - pass + logger.error(f'did not understand olympus header "umPerPixel" or "secondsPerLine"') - self._setLoadedData() + _loadOlympusHeader = True + if _olympusHeader is None: + self._fakeScale + + for _channelIdx, _tif in enumerate(self._tif): + if np.sum(_tif) == 0: + logger.warning(f' not adding _channelIdx:{_channelIdx} -->> sum is 0') + continue + self._setLoadedData(channel=_channelIdx+1) # # need to pull/merge code from xxx @@ -287,14 +391,29 @@ def loadFile(self): def _setLoadedData(self, channel=1): channelIdx = channel - 1 + # logger.warning(f'constructing sweepX/sweepY tif channelIdx:{channelIdx}') + + # 20241001 was this # using 'reshape(-1,1)' to convert shape from (n,) to (n,1) - sweepX = np.arange(0, self._tif[channelIdx].shape[1]).reshape(-1, 1) + # sweepX = np.arange(0, self._tif[channelIdx].shape[1]).reshape(-1, 1) + # sweepX = sweepX.astype(np.float64) + # sweepX *= self._tifHeader['secondsPerLine'] + + # sweepY = np.sum(self._tif[channelIdx], axis=0).reshape(-1, 1) + # sweepY = np.divide(sweepY, np.max(sweepY)) + + _timeDim = 1 + _spaceDim = 0 + sweepX = np.arange(0, self._tif[channelIdx].shape[_timeDim]).reshape(-1, 1) sweepX = sweepX.astype(np.float64) sweepX *= self._tifHeader['secondsPerLine'] - sweepY = np.sum(self._tif[channelIdx], axis=0).reshape(-1, 1) + sweepY = np.sum(self._tif[channelIdx], axis=_spaceDim).reshape(-1, 1) + # logger.info(f' normalizing to max(sweepY):{np.max(sweepY)}') sweepY = np.divide(sweepY, np.max(sweepY)) + # logger.info(f'loaded tif:{self._tif[channelIdx].shape} sweepX:{sweepX.shape} sweepY:{sweepY.shape}') + self.setLoadedData( sweepX=sweepX, sweepY=sweepY, @@ -329,9 +448,16 @@ def setScale(self, secondsPerLine, umPerPixel, channel=1): ) @property - def tifData(self, channel=1) -> np.ndarray: - channelIdx = channel - 1 - return self._tif[channelIdx] + def tifData(self) -> np.ndarray: + """Get the first tif color channel. NEEDS TO BE REFACTORED. + """ + # logger.warning('TODO: refactor tifData, need to handle multiple color channels.') + return self._tif[0] + + def getTifData(self, channel : int = 1): + """Get one color channel from tif, channel is 1 based. + """ + return self._tif[channel-1] @property def tifHeader(self) -> dict: diff --git a/sanpy/interface/bDetectionWidget.py b/sanpy/interface/bDetectionWidget.py index 0ae0274f..aa4d9c47 100644 --- a/sanpy/interface/bDetectionWidget.py +++ b/sanpy/interface/bDetectionWidget.py @@ -124,7 +124,7 @@ def __init__( "styleColor": "color: yellow", "symbol": "o", "plotOn": "vm", - "plotIsOn": True, + "plotIsOn": False, }, { @@ -209,7 +209,7 @@ def __init__( self._buildUI() windowOptions = self.getMainWindowOptions() - showDvDt = True + # showDvDt = True # showClips = False # showScatter = True if windowOptions is not None: @@ -682,7 +682,8 @@ def setAxisFull(self): # self.vmPlot.enableAutoRange() logger.info('!!!!! setting vmPlot_ auto range !!!!!') - self.vmPlot.autoRange(items=[self.vmPlot_]) # 20221003 + self.vmPlot.autoRange() # 20221003 + # self.vmPlot.autoRange(items=[self.vmPlot_]) # 20221003 # these are linked to vmPlot # self.derivPlot.autoRange() @@ -699,7 +700,7 @@ def setAxisFull(self): # kymograph # self.myKymWidget.kymographPlot.setXRange(start, stop, padding=padding) # row major is different #self.myKymWidget.kymographPlot.autoRange() # row major is different - if sanpy.DO_KYMOGRAPH_ANALYSIS: + if 0 and sanpy.DO_KYMOGRAPH_ANALYSIS: if self.ba.fileLoader.isKymograph(): self.myKymWidget.kymographPlot.autoRange() @@ -1584,11 +1585,12 @@ def slot_kymographChanged(self): self.signalDetect.emit(self.ba) # underlying _abf has new rect def _buildUI(self): - self.mySetTheme(doReplot=False) + # self.mySetTheme(doReplot=False) # left is toolbar, right is PYQtGraph (self.view) - self.myHBoxLayout_detect = QtWidgets.QHBoxLayout(self) - self.myHBoxLayout_detect.setAlignment(QtCore.Qt.AlignTop) + self.myHBoxLayout_detect = QtWidgets.QHBoxLayout() + self.myHBoxLayout_detect.setAlignment(QtCore.Qt.AlignLeft) + self.setLayout(self.myHBoxLayout_detect) # hSplitter gets added to h layout # then we add left/right widgets to the splitter @@ -1603,13 +1605,17 @@ def _buildUI(self): ) # v1 - self.myHBoxLayout_detect.addWidget(self.detectToolbarWidget) + _tmpVLayout = QtWidgets.QVBoxLayout() + _tmpVLayout. addWidget(self.detectToolbarWidget) + # self.myHBoxLayout_detect.addWidget(self.detectToolbarWidget, alignment=QtCore.Qt.AlignLeft) + self.myHBoxLayout_detect.addLayout(_tmpVLayout) # v2 # _hSplitter.addWidget(self.detectToolbarWidget) # self.myHBoxLayout_detect.addWidget(_hSplitter) # kymograph, we need a vboxlayout to hold (kym widget, self.view) - vBoxLayoutForPlot = QtWidgets.QVBoxLayout(self) + vBoxLayoutForPlot = QtWidgets.QVBoxLayout() + self.myHBoxLayout_detect.addLayout(vBoxLayoutForPlot) # for publication, don't do kymographs # make a branch and get this working @@ -1626,26 +1632,32 @@ def _buildUI(self): # addPlot return a plotItem self.vmPlotGlobal = pg.PlotWidget() + self.vmPlotGlobal.setDefaultPadding() + self.vmPlotGlobal.enableAutoRange() self.vmPlotGlobal_ = self.vmPlotGlobal.plot(name="vmPlotGlobal") self.vmPlotGlobal_.setData(xPlotEmpty, yPlotEmpty, connect="finite") vBoxLayoutForPlot.addWidget(self.vmPlotGlobal) - self.vmPlotGlobal.enableAutoRange() self.derivPlot = pg.PlotWidget(name='derivPlot') + self.derivPlot.setDefaultPadding() + self.derivPlot.enableAutoRange() self.derivPlot_ = self.derivPlot.plot(name="derivPlot") self.derivPlot_.setData(xPlotEmpty, yPlotEmpty, connect="finite") vBoxLayoutForPlot.addWidget(self.derivPlot) - self.derivPlot.enableAutoRange() self.derivPlot.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.derivPlot.customContextMenuRequested.connect(partial(self.slot_contextMenu,'derivPlot', self.derivPlot)) self.dacPlot = pg.PlotWidget() + self.dacPlot.setDefaultPadding() + self.dacPlot.enableAutoRange() self.dacPlot_ = self.dacPlot.plot(name="dacPlot") self.dacPlot_.setData(xPlotEmpty, yPlotEmpty, connect="finite") vBoxLayoutForPlot.addWidget(self.dacPlot) self.dacPlot.enableAutoRange() self.vmPlot = pg.PlotWidget(name='vmPlot') + self.vmPlot.setDefaultPadding() + self.vmPlot.enableAutoRange() # vmPlot_ is pyqtgraph.graphicsItems.PlotDataItem.PlotDataItem self.vmPlot_ = self.vmPlot.plot(name="vmPlot") self.vmPlot_.setData(xPlotEmpty, yPlotEmpty, connect="finite") @@ -1678,10 +1690,12 @@ def _buildUI(self): # July 15, 2023 # cursors, start by adding to vmPlot self._sanpyCursors = sanpy.interface.sanpyCursors(self.vmPlot) + self._sanpyCursors.toggleCursors(False) self._sanpyCursors.signalCursorDragged.connect(self.updateStatusBar) self._sanpyCursors.signalSetDetectionParam.connect(self._setDetectionParam) self._sanpyCursors_dvdt = sanpy.interface.sanpyCursors(self.derivPlot) + self._sanpyCursors_dvdt.toggleCursors(False) self._sanpyCursors_dvdt.signalCursorDragged.connect(self.updateStatusBar) self._sanpyCursors_dvdt.signalSetDetectionParam.connect(self._setDetectionParam) @@ -1854,7 +1868,7 @@ def _buildUI(self): # vBoxLayoutForPlot.addWidget(self.view) # v1 - self.myHBoxLayout_detect.addLayout(vBoxLayoutForPlot) + # self.myHBoxLayout_detect.addLayout(vBoxLayoutForPlot) # v2 # _tmpSplitterWidget = QtWidgets.QWidget() # _tmpSplitterWidget.setLayout(vBoxLayoutForPlot) @@ -2276,7 +2290,9 @@ def slot_switchFile(self, ba: "sanpy.bAnalysis" = None, tableRowDict: dict = Non if self.ba.fileLoader.isKymograph(): self.myKymWidget.setVisible(True) # self.myKymWidget.slot_switchFile(ba, startSec, stopSec) + self.vmPlot.setXLink(self.myKymWidget.kymographPlot) + # self.myKymWidget.kymographPlot.setXLink(self.vmPlot) # row major is different self.myKymWidget.slot_switchFile(ba) else: @@ -2313,7 +2329,8 @@ def slot_updateAnalysis(self, sDict : dict): self.selectSpikeList(self._selectedSpikeList) def _slot_y_range_changed(self, viewBox): - logger.info('') + return + logger.info('DOES NOTING') print(' viewBox.viewRange():', viewBox.viewRange()) def _slot_x_range_changed(self, viewBox): @@ -2334,13 +2351,13 @@ def _slot_x_range_changed(self, viewBox): self.vmPlot.sigXRangeChanged.connect(self._slot_x_range_changed) """ - logger.info('') - print(' viewBox.viewRange():', viewBox.viewRange()) - - viewRange = viewBox.viewRange() - xMin = viewRange[0][0] - xMax = viewRange[0][1] - self.setAxis(xMin, xMax) + + # logger.info(f' viewBox.viewRange():{viewBox.viewRange()}') + # 20241003 removed + # viewRange = viewBox.viewRange() + # xMin = viewRange[0][0] + # xMax = viewRange[0][1] + # self.setAxis(xMin, xMax) # 20240120 removed enableAutoRange x 3 @@ -2454,11 +2471,12 @@ def _replot(self, startSec : Optional[float] = None, # vmPlot_ is PlotDataItem # logger.info(f'vmPlot.viewRange {self.vmPlot.viewRange()}') + # 20240930 moved down # april 30, 2023 # was this jun 4 # 20240118 - self.vmPlot.sigXRangeChanged.connect(self._slot_x_range_changed) - self.vmPlot.sigYRangeChanged.connect(self._slot_y_range_changed) + # self.vmPlot.sigXRangeChanged.connect(self._slot_x_range_changed) + # self.vmPlot.sigYRangeChanged.connect(self._slot_y_range_changed) # pg.setConfigOption('leftButtonPan', False) @@ -2530,35 +2548,14 @@ def _replot(self, startSec : Optional[float] = None, # was this june 4 # self._setAxis(start=startSec, stop=stopSec) + # useful but were doing nothing + # self.vmPlot.sigXRangeChanged.connect(self._slot_x_range_changed) + # self.vmPlot.sigYRangeChanged.connect(self._slot_y_range_changed) + # # critical, replot() is inherited self.replotOverlays() - -class kymographImage(pg.ImageItem): - def mouseClickEvent(self, event): - # print("Click", event.pos()) - x = event.pos().x() - y = event.pos().y() - - def mouseDragEvent(self, event): - return - - if event.isStart(): - print("Start drag", event.pos()) - elif event.isFinish(): - print("Stop drag", event.pos()) - else: - print("Drag", event.pos()) - - def hoverEvent(self, event): - if not event.isExit(): - # the mouse is hovering over the image; make sure no other items - # will receive left click/drag events from here. - event.acceptDrags(pg.QtCore.Qt.LeftButton) - event.acceptClicks(pg.QtCore.Qt.LeftButton) - - class myImageExporter(ImageExporter): def __init__(self, item): pg.exporters.ImageExporter.__init__(self, item) @@ -2882,7 +2879,7 @@ class myDetectToolbarWidget2(QtWidgets.QWidget): # signalSelectSpike = QtCore.Signal(object, object) # spike number, doZoom def __init__(self, myPlots, detectionWidget: bDetectionWidget, parent=None): - super(myDetectToolbarWidget2, self).__init__(parent) + super().__init__(None) self.myPlots = myPlots self.detectionWidget = detectionWidget # parent detection widget @@ -3186,26 +3183,26 @@ def _buildUI(self): windowOptions = self.detectionWidget.getMainWindowOptions() detectDvDt = 20 detectMv = -20 - showGlobalVm = True - showDvDt = True - showDAC = True + # showGlobalVm = True + # showDvDt = True + # showDAC = True if windowOptions is not None: detectDvDt = windowOptions["detect"]["detectDvDt"] detectMv = windowOptions["detect"]["detectMv"] - showDvDt = windowOptions["rawDataPanels"]["Derivative"] - showDAC = windowOptions["rawDataPanels"]["DAC"] - showGlobalVm = windowOptions["rawDataPanels"]["Full Recording"] + # showDvDt = windowOptions["rawDataPanels"]["Derivative"] + # showDAC = windowOptions["rawDataPanels"]["DAC"] + # showGlobalVm = windowOptions["rawDataPanels"]["Full Recording"] # April 15, 2023, removed when adding horizontal splitter #self.setFixedWidth(280) - self.setFixedWidth(280) + # self.setFixedWidth(280) - # why do I need self here? self.mainLayout = QtWidgets.QVBoxLayout() self.mainLayout.setAlignment(QtCore.Qt.AlignTop) - self.mainLayout.setContentsMargins(0,0,0,0) + # 20241004 removed + # self.mainLayout.setContentsMargins(0,0,0,0) # Show selected file # self.mySelectedFileLabel = QtWidgets.QLabel("None") diff --git a/sanpy/interface/bKymographWidget.py b/sanpy/interface/bKymographWidget.py index 6b26ee01..0280e0a8 100644 --- a/sanpy/interface/bKymographWidget.py +++ b/sanpy/interface/bKymographWidget.py @@ -10,10 +10,8 @@ import sanpy from sanpy.sanpyLogger import get_logger - logger = get_logger(__name__) - class myScaleDialog(QtWidgets.QDialog): """Dialog to set x/y kymograph scale. @@ -190,6 +188,14 @@ def _resetZoom(self, doEmit=True): if self.ba is None: return + self.kymographPlot.autoRange(item=self.myImageItem) + # self.kymographPlot.autoRange() + + if doEmit: + self.signalResetZoom.emit() + + return + imageBoundingRect = self.myImageItem.boundingRect() # QtCore.QRectF _rect = self.ba.kymAnalysis.getImageRect() @@ -296,6 +302,7 @@ def _old__buildMolarLayout(self): return molarLayout def _refreshControlBarWidget(self): + logger.warning('expand fileLoader.tifData to multiple channels') tifData = self.ba.fileLoader.tifData # x and y pixels @@ -311,6 +318,7 @@ def _refreshControlBarWidget(self): self.yScaleLabel.setText(umPerPixel) # update min/max labels + logger.warning('expand fileLoader.tifData to multiple channels') tifData = self.ba.fileLoader.tifData minTif = np.nanmin(tifData) maxTif = np.nanmax(tifData) @@ -318,12 +326,14 @@ def _refreshControlBarWidget(self): self.tifMinLabel.setText(f"Min:{minTif}") self.tifMaxLabel.setText(f"Max:{maxTif}") + # 20241002 don't use 2**bitDepth for max, isumage image max * 2 # update contrast slider controls - bitDepth = self.ba.fileLoader.tifHeader['bitDepth'] - self.minContrastSpinBox.setMaximum(2**bitDepth) - self.minContrastSlider.setMaximum(2**bitDepth) - self.maxContrastSpinBox.setMaximum(2**bitDepth) - self.maxContrastSlider.setMaximum(2**bitDepth) + # bitDepth = self.ba.fileLoader.tifHeader['bitDepth'] + _maxImg = np.max(tifData) * 2 + self.minContrastSpinBox.setMaximum(_maxImg) + self.minContrastSlider.setMaximum(_maxImg) + self.maxContrastSpinBox.setMaximum(_maxImg) + self.maxContrastSlider.setMaximum(_maxImg) self.minContrastSpinBox.setValue(self._minContrast) self.minContrastSlider.setValue(self._minContrast) @@ -394,6 +404,20 @@ def _buildControlBarWidget(self) -> QtWidgets.QWidget: ) controlBarLayout.addWidget(self.tifChannelCombobox, alignment=QtCore.Qt.AlignLeft) + # color + colorList = ['Greens', 'Reds', 'Blues', 'Greys', 'viridis', 'plasma', 'inferno'] + self.colorComboBox = QtWidgets.QComboBox() + self.colorComboBox.addItems(colorList) + self.colorComboBox.currentTextChanged.connect( + self.on_select_color + ) + controlBarLayout.addWidget(self.colorComboBox, alignment=QtCore.Qt.AlignLeft) + + showRoiCheckbox = QtWidgets.QCheckBox('Show ROI') + showRoiCheckbox.setChecked(True) + showRoiCheckbox.stateChanged.connect(self.on_show_roi) + controlBarLayout.addWidget(showRoiCheckbox, alignment=QtCore.Qt.AlignLeft) + # align left controlBarLayout.addStretch() @@ -470,6 +494,23 @@ def _buildControlBarWidget(self) -> QtWidgets.QWidget: #return controlBarLayout return _aWidget + def on_show_roi(self, value): + """Toggle ROI on/off. + + Added 20241002 + """ + if value > 0 : + value = 1 + self.myLineRoi.setVisible(value) + + def on_select_color(self, text): + """Set color lut. + + Added 20241002 + """ + self.aColorBar.setColorMap(pg.colormap.getFromMatplotlib(text)) + self._replot() + def on_select_channel(self, text): logger.info(text) @@ -479,8 +520,9 @@ def showTopToolbar(self, visible : bool = True): def _buildUI(self): - self.myVBoxLayout = QtWidgets.QVBoxLayout(self) - # self.myVBoxLayout.setAlignment(QtCore.Qt.AlignTop) + self.myVBoxLayout = QtWidgets.QVBoxLayout() + self.myVBoxLayout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(self.myVBoxLayout) if 0: molarLayout = self._buildMolarLayout() @@ -501,6 +543,7 @@ def _buildUI(self): row=row, col=0, rowSpan=rowSpan, colSpan=colSpan ) + self.kymographPlot.setDefaultPadding() self.kymographPlot.enableAutoRange() # turn off x/y dragging of deriv and vm @@ -526,11 +569,20 @@ def _buildUI(self): # now using transpose .T # axisOrder = "row-major" - self.myImageItem = kymographImage(_fakeTif.T, - #axisOrder=axisOrder, - #rect=imageRect - ) - + # self.myImageItem = kymographImage(_fakeTif.T, + # #axisOrder=axisOrder, + # #rect=imageRect + # ) + + self.myImageItem = pg.ImageItem(_fakeTif, axisOrder = "row-major") # need transpose for row-major + + # logger.warning('--->>> tryin ColorBarItem') + # self.aColorBar = pg.ColorBarItem(colorMap='inferno') + # self.aColorBar = pg.ColorBarItem(colorMap=pg.colormap.getFromMatplotlib('Greens')) + self.aColorBar = pg.ColorBarItem() + self.aColorBar.setColorMap(pg.colormap.getFromMatplotlib('inferno')) + self.aColorBar.setImageItem(self.myImageItem) + # redirect hover to self (to display intensity self.myImageItem.hoverEvent = self.hoverEvent @@ -732,7 +784,7 @@ def _getContrastedImage(self, image, display_min, display_max, bitDepth): else: image = np.array(image, dtype=np.uint16, copy=True) image.clip(display_min, display_max, out=image) - image -= display_min + image = image - display_min np.floor_divide( image, (display_max - display_min + 1) / (2**bitDepth), @@ -750,6 +802,7 @@ def _replot(self, startSec=None, stopSec=None): if self.ba is None: return + # myTif = self.ba.fileLoader.tifData myTif = self.getContrastEnhance() logger.info( @@ -757,7 +810,6 @@ def _replot(self, startSec=None, stopSec=None): ) # like (519, 10000) imageRect = self.ba.kymAnalysis.getImageRect(asList=True) - # imageRect[2] = myTif.shape[1] logger.info(f' imageRect:{imageRect}') # [0,0,5,113] axisOrder = "row-major" @@ -776,45 +828,14 @@ def _replot(self, startSec=None, stopSec=None): # # tr.translate(0,0) # self.myImageItem.setTransform(tr) - if startSec is not None and stopSec is not None: - padding = 0 - self.kymographPlot.setXRange( - startSec, stopSec, padding=padding - ) # row major is different - - # color bar with contrast !!! - # if myTif.dtype == np.dtype("uint8"): - # bitDepth = 8 - # elif myTif.dtype == np.dtype("uint16"): - # bitDepth = 16 - # else: - # bitDepth = 16 - # logger.error(f"Did not recognize tif dtype: {myTif.dtype}") - - # cm = pg.colormap.get( - # "Greens_r", source="matplotlib" - # ) # prepare a linear color map - # # values = (0, 2**bitDepth) - # # values = (0, maxTif) - # values = (0, 2**12) - # limits = (0, 2**12) - # # logger.info(f'color bar bit depth is {bitDepth} with values in {values}') - # doColorBar = False - # if doColorBar: - # if self.myColorBarItem == None: - # self.myColorBarItem = pg.ColorBarItem( - # values=values, - # limits=limits, - # interactive=True, - # label="", - # cmap=cm, - # orientation="horizontal", - # ) - # # Have ColorBarItem control colors of img and appear in 'plot': - # self.myColorBarItem.setImageItem( - # self.myImageItem, insert_in=self.kymographPlot - # ) - # self.myColorBarItem.setLevels(values=values) + # + # removed 20241003 + # + # if startSec is not None and stopSec is not None: + # padding = 0 + # self.kymographPlot.setXRange( + # startSec, stopSec, padding=padding + # ) # row major is different # kymographRect is in scaled units, we need plot units kymographRect = self.ba.kymAnalysis.getRoiRect() @@ -1053,7 +1074,7 @@ def slot_switchFile(self, ba=None, startSec=None, stopSec=None): self._replot(startSec=startSec, stopSec=stopSec) - self._resetZoom() + self._resetZoom(doEmit=False) # update line scan slider _numLineScans = len(self.ba.fileLoader.sweepX) - 1 diff --git a/sanpy/interface/bTableView.py b/sanpy/interface/bTableView.py index 06012c52..740c4943 100644 --- a/sanpy/interface/bTableView.py +++ b/sanpy/interface/bTableView.py @@ -33,8 +33,8 @@ class bTableView(QtWidgets.QTableView): """ signalSelectRow = QtCore.pyqtSignal( - object, object, object - ) # (row, rowDict, selectingAgain) + int, dict, bool, bool + ) # (row, rowDict, selectingAgain, isDoubleClick) signalUpdateStatus = QtCore.pyqtSignal(object) """Update status in main SanPy app.""" @@ -56,9 +56,12 @@ def __init__(self, model, parent=None): self.lastSeletedRow = None self.clicked.connect(self.onLeftClick) + self.doubleClicked.connect(self._onDoubleClick) self.mySetModel(model) + self._lastClick = None + # frozen table was my attempt to keep a few columns always on the left # this led to huge problems and is not worth it @@ -165,6 +168,23 @@ def __init__(self, model, parent=None): # def dropEvent(self, event): # logger.info('') + def mousePressEvent(self, event): + logger.info('') + super().mousePressEvent(event) + self._lastClick = "Click" + + # def mouseReleaseEvent(self, event): + # logger.info('') + # super().mouseReleaseEvent(event) + # if self.last == "Click": + # QtCore.QTimer.singleShot(QtWidgets.QApplication.instance().doubleClickInterval(), + # self._onLeftClick(event)) + + def mouseDoubleClickEvent(self, event): + logger.info('') + super().mouseDoubleClickEvent(event) + self._lastClick = "DoubleClick" + # # frozen def _old_initFrozenColumn(self): @@ -230,16 +250,42 @@ def getSelectedRowDict(self): rowDict = self.model().myGetRowDict(selectedRow) return rowDict + def _onDoubleClick(self, item): + """Handle user double-click on a row. + + This is used to open the file in the default application. + """ + self._lastClick = "DoubleClick" + + row = item.row() + realRow = self.model()._data.index[row] + + rowDict = self.model().myGetRowDict(realRow) + + # if tif/kym clicked, open kymRoiPlugin + logger.info('-->> emit signalSelectRow') + selectedAgain = False + doubleClick = True + self.signalSelectRow.emit(realRow, rowDict, selectedAgain, doubleClick) + def onLeftClick(self, item): """Hanlde user left-click on a row. Keep track of lastSelected row to differentiate between switch-file and click again. """ - row = item.row() - realRow = self.model()._data.index[row] # sort order - # logger.info(f'User clicked row:{row} realRow:{realRow}') - self._onLeftClick(realRow) + # self._lastClick = "Click" + + try: + if self.last == "Click": + row = item.row() + realRow = self.model()._data.index[row] # sort order + # logger.info(f'User clicked row:{row} realRow:{realRow}') + # self._onLeftClick(realRow) + QtCore.QTimer.singleShot(QtWidgets.QApplication.instance().doubleClickInterval(), + self._onLeftClick(realRow)) + except (AttributeError) as e: + logger.error(e) def selectRowByFile(self, filename : str): # fileList = self.model()._data['File'].tolist() @@ -252,6 +298,7 @@ def selectRowByFile(self, filename : str): logger.warning(f"Did not find file {filename} in {df['File'].tolist()}") def _onLeftClick(self, realRow): + rowDict = self.model().myGetRowDict(realRow) logger.info(f"=== User click row:{realRow} relPath:{rowDict['relPath']}") @@ -264,7 +311,8 @@ def _onLeftClick(self, realRow): # print(' new row selection') # logger.info(f'realRow:{realRow} rowDict:{rowDict}') logger.info('-->> emit signalSelectRow') - self.signalSelectRow.emit(realRow, rowDict, selectedAgain) + doubleClick = False + self.signalSelectRow.emit(realRow, rowDict, selectedAgain,doubleClick) else: # print(' handle another click on already selected row') pass diff --git a/sanpy/interface/fileListWidget.py b/sanpy/interface/fileListWidget.py index 247233b0..941dff63 100644 --- a/sanpy/interface/fileListWidget.py +++ b/sanpy/interface/fileListWidget.py @@ -26,7 +26,7 @@ class fileListWidget(QtWidgets.QWidget): """Update statu s in main SanPy app.""" # signalSetDefaultDetection = QtCore.pyqtSignal(object, object) # selected row, detection type - def __init__(self, myModel: pandasModel, folderDepth:int=1,parent=None): + def __init__(self, myModel: pandasModel, folderDepth:int=2,parent=None): """ Parameters ---------- diff --git a/sanpy/interface/kymographPlugin2.py b/sanpy/interface/kymographPlugin2.py index 8bf0cef9..5cc1e01c 100644 --- a/sanpy/interface/kymographPlugin2.py +++ b/sanpy/interface/kymographPlugin2.py @@ -997,6 +997,8 @@ def refreshSumLinePlot(self, autoRange='all'): if self._kymographAnalysis.hasDiamAnalysis(): yPlot = self._kymographAnalysis.getResults('rangeInt') + logger.info(f'xPlot:{len(xPlot)} yPlot:{len(yPlot)}') + if yPlot is not None: self.sumIntensityPlot.setData(xPlot, yPlot, connect="finite") @@ -1024,6 +1026,7 @@ def refreshDiameterPlot(self, autoRange='all'): xPlot = self._ba.fileLoader.sweepX + # yDiamPlot is a list [] if self._plotDiamType == 'Diameter (um)': leftLabel = 'Diameter (um)' yDiamPlot = self._kymographAnalysis.getResults("diameter_um") @@ -1048,6 +1051,8 @@ def refreshDiameterPlot(self, autoRange='all'): # yDiam_um = self._kymographAnalysis.getResults("diameter_um") if yDiamPlot is not None: + logger.info(f'xPlot from sweepX:{xPlot.shape} yDiamPlot:{len(yDiamPlot)}') + self.diameterPlot.setData(xPlot, yDiamPlot, connect="finite") self.diameterPlotItem.setLabel("left", leftLabel, units="") else: @@ -1085,9 +1090,9 @@ def refreshDiameterPlot(self, autoRange='all'): if autoRange == 'all': self.diameterPlotItem.autoRange() elif autoRange == 'y': - self.diameterPlotItem.setRange(yRange=[min(yDiamPlot), max(yDiamPlot)]) + self.diameterPlotItem.setRange(yRange=[np.nanmin(yDiamPlot), np.nanmax(yDiamPlot)]) elif autoRange == 'x': - self.diameterPlotItem.setRange(xRange=[min(xPlot), max(xPlot)]) + self.diameterPlotItem.setRange(xRange=[np.nanmin(xPlot), np.nanmax(xPlot)]) def exportDiameter(): import sanpy.interface.bExportWidget diff --git a/sanpy/interface/openFirstWidget.py b/sanpy/interface/openFirstWidget.py index a2f37eea..a9e54dd8 100644 --- a/sanpy/interface/openFirstWidget.py +++ b/sanpy/interface/openFirstWidget.py @@ -159,6 +159,15 @@ def _refreshWindowsMenu(self): self.getSanPyApp().getWindowsMenu(self.windowsMenu) + def _on_radio_toggled(self): + """Handle radio button toggle between SanPy and Kymograph modes.""" + if self.sanpyRadio.isChecked(): + logger.info('Switched to SanPy mode') + self._sanpyApp.configDict['interface_mode'] = 'sanpy' + elif self.kymographRadio.isChecked(): + logger.info('Switched to Kymograph mode') + self._sanpyApp.configDict['interface_mode'] = 'kymograph' + def _buildUI(self): # typical wrapper for PyQt, we can't use setLayout(), we need to use setCentralWidget() _mainWidget = QtWidgets.QWidget() @@ -194,6 +203,35 @@ def _buildUI(self): aButton.clicked.connect(partial(self._on_open_button_click, name)) hBoxLayout.addWidget(aButton, alignment=QtCore.Qt.AlignLeft) + # abb 202506 sanpy-kym + # radio buttons for selecting interface mode + + radioLayout = QtWidgets.QHBoxLayout() + radioLayout.setAlignment(QtCore.Qt.AlignLeft) + hBoxLayout.addLayout(radioLayout) + + radioGroup = QtWidgets.QButtonGroup() + + self.sanpyRadio = QtWidgets.QRadioButton("SanPy") + # sanpyRadio.setChecked(True) + radioGroup.addButton(self.sanpyRadio) + radioLayout.addWidget(self.sanpyRadio) + + self.kymographRadio = QtWidgets.QRadioButton("SanPy Kymograph") + radioGroup.addButton(self.kymographRadio) + radioLayout.addWidget(self.kymographRadio) + + interface_mode = self._sanpyApp.configDict['interface_mode'] + if interface_mode == 'kymograph': + self.kymographRadio.setChecked(True) + else: + self.sanpyRadio.setChecked(True) + + # Connect radio button signals + self.sanpyRadio.toggled.connect(self._on_radio_toggled) + self.kymographRadio.toggled.connect(self._on_radio_toggled) + + # # recent files and tables recent_vBoxLayout = QtWidgets.QVBoxLayout() diff --git a/sanpy/interface/plugins/__init__.py b/sanpy/interface/plugins/__init__.py index cd769798..597b2d18 100644 --- a/sanpy/interface/plugins/__init__.py +++ b/sanpy/interface/plugins/__init__.py @@ -31,6 +31,7 @@ # remove for publication from .kymographPlugin import kymographPlugin +from .kymographRoiPlugin import kymographRoiPlugin from .setSpikeStat import SetSpikeStat from .setMetaData import SetMetaData diff --git a/sanpy/interface/plugins/_fftPlugin.py b/sanpy/interface/plugins/_fftPlugin.py new file mode 100644 index 00000000..dc57f216 --- /dev/null +++ b/sanpy/interface/plugins/_fftPlugin.py @@ -0,0 +1,1679 @@ +""" +To be complete, I need: + - PSD + - FFT + - Auto Corelation (on detected spikes?) + +See: + https://stackoverflow.com/questions/59265603/how-to-find-period-of-signal-autocorrelation-vs-fast-fourier-transform-vs-power +""" + +import sys, math, time +from math import exp +from functools import partial +from typing import Union, Dict, List, Tuple, Optional, Optional + +import numpy as np +from scipy.signal import lfilter, freqz +import scipy.signal + +from PyQt5 import QtCore, QtWidgets, QtGui +import matplotlib as mpl +import matplotlib.pyplot as plt + +import sanpy +from sanpy.interface.plugins import sanpyPlugin + +from sanpy.sanpyLogger import get_logger + +logger = get_logger(__name__) + + +def butter_sos(cutOff, fs, order=50, passType="lowpass"): + """ + passType (str): ('lowpass', 'highpass') + """ + + """ + nyq = fs * 0.5 + normal_cutoff = cutOff / nyq + """ + + logger.info( + f"Making butter file with order:{order} cutOff:{cutOff} passType:{passType} fs:{fs}" + ) + + # sos = butter(order, normal_cutoff, btype='low', analog=False, output='sos') + # sos = scipy.signal.butter(N=order, Wn=normal_cutoff, btype=passType, analog=False, fs=fs, output='sos') + sos = scipy.signal.butter( + N=order, Wn=cutOff, btype=passType, analog=False, fs=fs, output="sos" + ) + return sos + + +def spikeDetect(t, dataFiltered, dataThreshold2, startPoint=None, stopPoint=None): + verbose = False + # dataThreshold2 = -59.81 + + g_backupFraction = 0.3 # 0.5 or 0.3 + + logger.info( + f"dataThreshold2:{dataThreshold2} startPoint:{startPoint} stopPoint:{stopPoint}" + ) + + # + # detect spikes, dataThreshold2 changes as we zoom + Is = np.where(dataFiltered > dataThreshold2)[0] + Is = np.concatenate(([0], Is)) + Ds = Is[:-1] - Is[1:] + 1 + spikePoints = Is[np.where(Ds)[0] + 1] + spikeSeconds = t[spikePoints] + + # + # Throw out spikes that are within refractory (ms) of previous + spikePoints = _throwOutRefractory(spikePoints, refractory_ms=100) + spikeSeconds = t[spikePoints] + + # + # remove spikes outside of start/stop + goodSpikes = [] + if startPoint is not None and stopPoint is not None: + if verbose: + print(f" Removing spikes outside point range {startPoint} ... {stopPoint}") + # print(f' {t[startPoint]} ... {t[stopPoint]} (s)') + for idx, spikePoint in enumerate(spikePoints): + if spikePoint < startPoint or spikePoint > stopPoint: + pass + else: + goodSpikes.append(spikePoint) + if verbose: + print(f" appending spike {idx} {t[spikePoint]}(s)") + # + spikePoints = goodSpikes + spikeSeconds = t[spikePoints] + + # + # only accept rising phase + windowPoints = 40 # assuming 10 kHz -->> 4 ms + goodSpikes = [] + for idx, spikePoint in enumerate(spikePoints): + preClip = dataFiltered[spikePoint - windowPoints : spikePoint - 1] + postClip = dataFiltered[spikePoint + 1 : spikePoint + windowPoints] + preMean = np.nanmean(preClip) + postMean = np.nanmean(postClip) + if preMean < postMean: + goodSpikes.append(spikePoint) + else: + pass + # print(f' rejected spike {idx}, at point {spikePoint}') + # + spikePoints = goodSpikes + spikeSeconds = t[spikePoints] + + # + # throw out above take-off-potential of APs (-30 mV) + apTakeOff = -30 + goodSpikes = [] + for idx, spikePoint in enumerate(spikePoints): + if dataFiltered[spikePoint] < apTakeOff: + goodSpikes.append(spikePoint) + # + spikePoints = goodSpikes + spikeSeconds = t[spikePoints] + + # + # find peak for eack spike + # at 500 pnts, assuming spikes are slower than 20 Hz + peakWindow_pnts = 500 + peakPoints = [] + peakVals = [] + for idx, spikePoint in enumerate(spikePoints): + peakPnt = np.argmax(dataFiltered[spikePoint : spikePoint + peakWindow_pnts]) + peakPnt += spikePoint + peakVal = np.max(dataFiltered[spikePoint : spikePoint + peakWindow_pnts]) + # + peakPoints.append(peakPnt) + peakVals.append(peakVal) + + # + # backup each spike until pre/post is not changing + # uses g_backupFraction to get percent of dv/dt at spike veruus each backup window + backupWindow_pnts = 30 + maxSteps = 30 # 30 steps at 20 pnts (2ms) per step gives 60 ms + backupSpikes = [] + for idx, spikePoint in enumerate(spikePoints): + # print(f'=== backup spike:{idx} at {t[spikePoint]}(s)') + foundBackupPoint = None + for step in range(maxSteps): + tmpPnt = spikePoint - (step * backupWindow_pnts) + tmpSeconds = t[tmpPnt] + preClip = dataFiltered[ + tmpPnt - 1 - backupWindow_pnts : tmpPnt - 1 + ] # reversed + postClip = dataFiltered[tmpPnt + 1 : tmpPnt + 1 + backupWindow_pnts] + preMean = np.nanmean(preClip) + postMean = np.nanmean(postClip) + diffMean = postMean - preMean + if step == 0: + initialDiff = diffMean + # print(f' spike {idx} step:{step} tmpSeconds:{round(tmpSeconds,4)} initialDiff:{round(initialDiff,4)} diff:{round(diffMean,3)}') + # if diffMean is 1/2 initial AP slope then accept + if diffMean < (initialDiff * g_backupFraction): + # stop + if foundBackupPoint is None: + foundBackupPoint = tmpPnt + # break + # + if foundBackupPoint is not None: + backupSpikes.append(foundBackupPoint) + else: + # needed to keep spike parity + logger.warning( + f"Did not find backupSpike for spike {idx} at {t[spikePoint]}(s)" + ) + backupSpikes.append(spikePoint) + + # + # use backupSpikes (points) to get each spike amplitude + spikeAmps = [] + for idx, backupSpike in enumerate(backupSpikes): + if backupSpikes == np.nan or math.isnan(backupSpike): + continue + # print('backupSpike:', backupSpike) + footVal = dataFiltered[backupSpike] + peakVal = peakVals[idx] + spikeAmp = peakVal - footVal + spikeAmps.append(spikeAmp) + + # TODO: use foot and peak to get real half/width + theseWidths = [10, 20, 50, 80, 90] + window_ms = 100 + spikeDictList = _getHalfWidths( + t, + dataFiltered, + backupSpikes, + peakPoints, + theseWidths=theseWidths, + window_ms=window_ms, + ) + # for idx, spikeDict in enumerate(spikeDictList): + # print(idx, spikeDict) + + # + # get estimate of duration + # for each spike, find next downward crossing (starting at peak) + """ + minDurationPoints = 10 + windowPoints = 1000 # 100 ms + fallingPoints = [] + for idx, spikePoint in enumerate(spikePoints): + peakPoint = peakPoints[idx] + backupPoint = backupSpikes[idx] + if backupPoint == np.nan or math.isnan(backupPoint): + continue + spikeSecond = spikeSeconds[idx] + # Not informative because we are getting spikes from absolute threshold + thisThreshold = dataFiltered[backupPoint] + thisPeak = dataFiltered[peakPoint] + halfHeight = thisThreshold + (thisPeak - thisThreshold) / 2 + startPoint = peakPoint #+ 50 + postClip = dataFiltered[startPoint:startPoint+windowPoints] + tmpFallingPoints = np.where(postClip 0: + fallingPoint = startPoint + tmpFallingPoints[0] + # TODO: check if falling point is AFTER next spike + duration = fallingPoint - spikePoint + if duration > minDurationPoints: + fallingPoints.append(fallingPoint) + else: + print(f' reject spike {idx} at {spikeSecond} (s), duration is {duration} points, minDurationPoints:{minDurationPoints}') + #fallingPoints.append(fallingPoint) + else: + print(f' did not find falling pnt for spike {idx} at {spikeSecond}(s) point {spikePoint}, assume it is longer than windowPoints') + pass + #fallingPoints.append(np.nan) + """ + + # TODO: Package all results into a dictionary + spikeDictList = [{}] * len(spikePoints) + for idx, spikePoint in enumerate(spikePoints): + """ + spikeSecond = spikeSeconds[idx] + peakPoint = peakPoints[idx] + peakVal = peakVals[idx] + fallingPoint = fallingPoints[idx] # get rid of this + backupSpike = backupSpikes[idx] # get rid of this + spikeAmp = spikeAmps[idx] # get rid of this + """ + spikeDictList[idx]["spikePoint"] = spikePoint + spikeDictList[idx]["spikeSecond"] = spikeSeconds[idx] + spikeDictList[idx]["peakPoint"] = peakPoints[idx] + spikeDictList[idx]["peakVal"] = peakVals[idx] + # spikeDictList[idx]['fallingPoint'] = fallingPoints[idx] + spikeDictList[idx]["backupSpike"] = backupSpikes[idx] + spikeDictList[idx]["spikeAmp"] = spikeAmps[idx] + # + spikeSeconds = t[spikePoints] + # return spikeSeconds, spikePoints, peakPoints, peakVals, fallingPoints, backupSpikes, spikeAmps + # removved fallingPoints + return ( + spikeDictList, + spikeSeconds, + spikePoints, + peakPoints, + peakVals, + backupSpikes, + spikeAmps, + ) + + +def _getHalfWidths( + t, v, spikePoints, peakPoints, theseWidths=[10, 20, 50, 80, 90], window_ms=50 +): + """Get half widths. + + Args: + t (ndarray): Time + v (ndaray): Recording (usually curent clamp Vm) + spikePoints (list of int): List of spike threshold crossings. + Usually the back-up version + peakPoints (list of int): + theseWidths (list of int): Specifies full-width-half maximal to calculate + window_ms (int): Number of ms to look after the peak for downward 1/2 height crossing + + Returns: + List of dict, one elemennt per spike + """ + logger.info( + f"theseWidths:{theseWidths} window_ms:{window_ms} spikePoints:{len(spikePoints)} peakPoints:{len(peakPoints)}" + ) + + pointsPerMs = 10 # ToDo: generalize this + window_pnts = window_ms * pointsPerMs + + spikeDictList = [{}] * len(spikePoints) # an empy list of dict with proper size + for idx, spikePoint in enumerate(spikePoints): + # print(f'Spike {idx} pnt:{spikePoint}') + + # each spike has a list of dict for each width result + spikeDictList[idx]["widths"] = [] + + # check each pre/post pnt is before next spike + nextSpikePnt = None + if idx < len(spikePoints) - 1: + nextSpikePnt = spikePoints[idx + 1] + + peakPoint = peakPoints[idx] + try: + vSpike = v[spikePoint] + except IndexError as e: + logger.error(f"spikePoint:{spikePoint} {type(spikePoint)}") + vPeak = v[peakPoint] + height = vPeak - vSpike + preStartPnt = spikePoint + 1 + preClip = v[preStartPnt : peakPoint - 1] + postStartPnt = peakPoint + 1 + postClip = v[postStartPnt : peakPoint + window_pnts] + for width in theseWidths: + thisHeight = vSpike + (height / width) # search for a percent of height + prePnt = np.where(preClip >= thisHeight)[0] + if len(prePnt) > 0: + prePnt = preStartPnt + prePnt[0] + else: + # print(f' Error: Spike {idx} "prePnt" width:{width} vSpike:{vSpike} height:{height} thisHeight:{thisHeight}') + prePnt = None + postPnt = np.where(postClip < thisHeight)[0] + if len(postPnt) > 0: + postPnt = postStartPnt + postPnt[0] + else: + # print(f' Error: Spike {idx} "postPnt" width:{width} vSpike:{vSpike} height:{height} thisHeight:{thisHeight}') + postPnt = None + widthMs = None + if prePnt is not None and postPnt is not None: + widthPnts = postPnt - prePnt + widthMs = widthPnts / pointsPerMs + # print(f' width:{width} tPre:{t[prePnt]} tPost:{t[postPnt]} widthMs:{widthMs}') + if nextSpikePnt is not None and prePnt >= nextSpikePnt: + print( + f" Error: Spike {idx} widthMs:{widthMs} prePnt:{prePnt} is after nextSpikePnt:{nextSpikePnt}" + ) + if nextSpikePnt is not None and postPnt >= nextSpikePnt: + print( + f" Error: Spike {idx} widthMs:{widthMs} postPnt:{postPnt} is after nextSpikePnt:{nextSpikePnt}" + ) + + # put into dict + widthDict = { + "halfHeight": width, + "risingPnt": prePnt, + #'risingVal': defaultVal, + "fallingPnt": postPnt, + #'fallingVal': defaultVal, + #'widthPnts': None, + "widthMs": widthMs, + } + spikeDictList[idx]["widths_" + str(width)] = widthMs + spikeDictList[idx]["widths"].append(widthDict) + # + return spikeDictList + + +def _throwOutRefractory(spikePoints, refractory_ms=100): + """ + spikePoints: spike times to consider + refractory_ms: + """ + dataPointsPerMs = 10 + + before = len(spikePoints) + + # if there are doubles, throw-out the second one + # refractory_ms = 20 #10 # remove spike [i] if it occurs within refractory_ms of spike [i-1] + lastGood = 0 # first spike [0] will always be good, there is no spike [i-1] + for i in range(len(spikePoints)): + if i == 0: + # first spike is always good + continue + dPoints = spikePoints[i] - spikePoints[lastGood] + if dPoints < dataPointsPerMs * refractory_ms: + # remove spike time [i] + spikePoints[i] = 0 + else: + # spike time [i] was good + lastGood = i + # regenerate spikeTimes0 by throwing out any spike time that does not pass 'if spikeTime' + # spikeTimes[i] that were set to 0 above (they were too close to the previous spike) + # will not pass 'if spikeTime', as 'if 0' evaluates to False + # if goodSpikeErrors is not None: + # goodSpikeErrors = [goodSpikeErrors[idx] for idx, spikeTime in enumerate(spikeTimes0) if spikeTime] + spikePoints = [spikePoint for spikePoint in spikePoints if spikePoint] + + # TODO: put back in and log if detection ['verbose'] + after = len(spikePoints) + logger.info(f"From {before} to {after} spikes with refractory_ms:{refractory_ms}") + + return spikePoints + + +def getKernel(type="sumExp", amp=5, tau1=30, tau2=70): + """Get a kernel for convolution with a spike train.""" + N = 500 # pnts + t = [x for x in range(N)] + y = t + + if type == "sumExp": + for i in t: + y[i] = -amp * (exp(-t[i] / tau1) - (exp(-t[i] / tau2))) + # + return y + + +def getSpikeTrain(numSeconds=1, fs=10000, spikeFreq=3, amp=10, noiseAmp=10): + """Get a spike train at given frequency. + + Arguments: + numSeconds (int): Total number of seconds + fs (int): Sampling frequency, 10000 for 10 kH + spikeFreq (int): Frequency of events in spike train, e.g. simulated EPSPs + amp (float): Amplitude of sum exponential kernel (getKernel) + """ + n = int(numSeconds * fs) # total number of samples + numSpikes = int(numSeconds * spikeFreq) + spikeTrain = np.zeros(n) + start = fs / spikeFreq + spikeTimes = np.linspace(start, n, numSpikes, endpoint=False) + for idx, spike in enumerate(spikeTimes): + # print(idx, spike) + spike = int(spike) + spikeTrain[spike] = 1 + + expKernel = getKernel(amp=amp) + epspTrain = scipy.signal.convolve(spikeTrain, expKernel, mode="same") + # shift to -60 mV + epspTrain -= 60 + + # add noise + if noiseAmp == 0: + pass + else: + # noise_power = 0.001 * fs / noiseAmp + # epspTrain += np.random.normal(scale=np.sqrt(noise_power), size=epspTrain.shape) + epspTrain += np.random.normal(scale=noiseAmp, size=epspTrain.shape) + + # + t = np.linspace(0, numSeconds, n, endpoint=True) + # + return t, spikeTrain, epspTrain + + +class fftPlugin(sanpyPlugin): + myHumanName = "FFT" + + # def __init__(self, myAnalysisDir=None, **kwargs): + def __init__(self, plotRawAxis=False, ba=None, **kwargs): + """ + Args: + ba (bAnalysis): Not required + """ + super(fftPlugin, self).__init__(ba=ba, **kwargs) + + self._isInited = False + + self.plotRawAxis = plotRawAxis + + self.doButterFilter = True + self.butterOrder = 70 + self.butterCutoff = [0.7, 10] # [low freq, high freq] for bandpass + + self._resultsDictList = [] + self._resultStr = "" + + self.isModel = False + + # only defined when running without SanPy app + # self._analysisDir = myAnalysisDir + + self._store_ba = None # allow switching between model and self.ba + + if self.ba is not None: + self.fs = self.ba.fileLoader.recordingFrequency * 1000 + else: + self.fs = None + self.psdWindowStr = "Hanning" # mpl.mlab.window_hanning + + # fft Freq (Hz) resolution is fs/nfft --> resolution 0.2 Hz = 10000/50000 + self.nfft = 50000 # 512 * 100 + + self.signalHz = None # assign when using fake data + self.maxPlotHz = 10 # limit x-axis frequenccy + + # self.sos = None + + self.medianFilterPnts = 0 + + # points + self.lastLeft = None # 0 + self.lastRight = None # len(self.ba.sweepX()) + + # if self._analysisDir is not None: + # # running lpugin without sanpy + # self.loadData(2) # load the 3rd file in analysis dir + + self._buildInterface() + + # self._getPsd() + + self.dataLine = None + self.dataFilteredLine = None + self.spikesLine = None + self.peaksLine = None + self.dataMeanLine = None + self.thresholdLine2 = None + self.thresholdLine3 = None + + # self.getMean() + self.plot() # first plot of data + self.replot2(switchFile=True) + # self.replotPsd() + # self.replot_fft() + + self._isInited = True + + @property + def ba(self): + return self._ba + + def _buildInterface(self): + # self.pyqtWindow() + + # main layout + vLayout = QtWidgets.QVBoxLayout() + + self.controlLayout = QtWidgets.QHBoxLayout() + # + # aLabel = QtWidgets.QLabel('fft') + # self.controlLayout.addWidget(aLabel) + + """ + buttonName = 'Detect' + aButton = QtWidgets.QPushButton(buttonName) + aButton.clicked.connect(partial(self.on_button_click,buttonName)) + self.controlLayout.addWidget(aButton) + """ + + """ + aLabel = QtWidgets.QLabel('mV Threshold') + self.controlLayout.addWidget(aLabel) + + self.mvThresholdSpinBox = QtWidgets.QDoubleSpinBox() + self.mvThresholdSpinBox.setRange(-1e9, 1e9) + self.controlLayout.addWidget(self.mvThresholdSpinBox) + """ + + """ + checkboxName = 'PSD' + aCheckBox = QtWidgets.QCheckBox(checkboxName) + aCheckBox.setChecked(True) + aCheckBox.stateChanged.connect(partial(self.on_checkbox_clicked, checkboxName)) + self.controlLayout.addWidget(aCheckBox) + """ + + """ + checkboxName = 'Auto-Correlation' + aCheckBox = QtWidgets.QCheckBox(checkboxName) + aCheckBox.setChecked(True) + aCheckBox.stateChanged.connect(partial(self.on_checkbox_clicked, checkboxName)) + self.controlLayout.addWidget(aCheckBox) + """ + + buttonName = "Replot" + aButton = QtWidgets.QPushButton(buttonName) + aButton.clicked.connect(partial(self.on_button_click, buttonName)) + self.controlLayout.addWidget(aButton) + + self.resultsLabel = QtWidgets.QLabel("Results: Peak Hz=??? Ampplitude=???") + self.controlLayout.addWidget(self.resultsLabel) + + checkboxName = "Butter Filter" + butterCheckBox = QtWidgets.QCheckBox(checkboxName) + butterCheckBox.setChecked(self.doButterFilter) + butterCheckBox.stateChanged.connect( + partial(self.on_checkbox_clicked, checkboxName) + ) + self.controlLayout.addWidget(butterCheckBox) + + aLabel = QtWidgets.QLabel("Order") + self.controlLayout.addWidget(aLabel) + self.butterOrderSpinBox = QtWidgets.QSpinBox() + self.butterOrderSpinBox.setRange(0, 2**16) + self.butterOrderSpinBox.setValue(self.butterOrder) + self.butterOrderSpinBox.editingFinished.connect( + partial(self.on_cutoff_spinbox, aLabel) + ) + self.controlLayout.addWidget(self.butterOrderSpinBox) + + aLabel = QtWidgets.QLabel("Low (Hz)") + self.controlLayout.addWidget(aLabel) + self.lowCutoffSpinBox = QtWidgets.QDoubleSpinBox() + self.lowCutoffSpinBox.setRange(0, 2**16) + self.lowCutoffSpinBox.setValue(self.butterCutoff[0]) + self.lowCutoffSpinBox.editingFinished.connect( + partial(self.on_cutoff_spinbox, aLabel) + ) + self.controlLayout.addWidget(self.lowCutoffSpinBox) + + aLabel = QtWidgets.QLabel("High") + self.controlLayout.addWidget(aLabel) + self.highCutoffSpinBox = QtWidgets.QDoubleSpinBox() + self.highCutoffSpinBox.setRange(0, 2**16) + self.highCutoffSpinBox.setValue(self.butterCutoff[1]) + self.highCutoffSpinBox.editingFinished.connect( + partial(self.on_cutoff_spinbox, aLabel) + ) + self.controlLayout.addWidget(self.highCutoffSpinBox) + + psdWindowComboBox = QtWidgets.QComboBox() + psdWindowComboBox.addItem("Hanning") + psdWindowComboBox.addItem("Blackman") + psdWindowComboBox.currentTextChanged.connect(self.on_psd_window_changed) + self.controlLayout.addWidget(psdWindowComboBox) + + # + vLayout.addLayout(self.controlLayout) # add mpl canvas + + self.controlLayout1_5 = QtWidgets.QHBoxLayout() + + self.fsLabel = QtWidgets.QLabel(f"fs={self.fs}") + self.controlLayout1_5.addWidget(self.fsLabel) + + aLabel = QtWidgets.QLabel("NFFT") + self.controlLayout1_5.addWidget(aLabel) + self.nfftSpinBox = QtWidgets.QSpinBox() + self.nfftSpinBox.setRange(0, 2**16) + self.nfftSpinBox.setValue(self.nfft) + self.nfftSpinBox.editingFinished.connect( + partial(self.on_cutoff_spinbox, aLabel) + ) + self.controlLayout1_5.addWidget(self.nfftSpinBox) + + # self.freqResLabel = QtWidgets.QLabel(f'Freq Resolution (Hz) {round(self.fs/self.nfft,3)}') + self.freqResLabel = QtWidgets.QLabel(f"Freq Resolution (Hz) Unknown") + self.controlLayout1_5.addWidget(self.freqResLabel) + + aLabel = QtWidgets.QLabel("Median Filter (Pnts)") + self.controlLayout1_5.addWidget(aLabel) + self.medianFilterPntsSpinBox = QtWidgets.QSpinBox() + self.medianFilterPntsSpinBox.setRange(0, 2**16) + self.medianFilterPntsSpinBox.setValue(self.medianFilterPnts) + self.medianFilterPntsSpinBox.editingFinished.connect( + partial(self.on_cutoff_spinbox, aLabel) + ) + self.controlLayout1_5.addWidget(self.medianFilterPntsSpinBox) + + aLabel = QtWidgets.QLabel("Max Plot (Hz)") + self.controlLayout1_5.addWidget(aLabel) + self.maxPlotHzSpinBox = QtWidgets.QDoubleSpinBox() + self.maxPlotHzSpinBox.setRange(0, 2**16) + self.maxPlotHzSpinBox.setValue(self.maxPlotHz) + self.maxPlotHzSpinBox.editingFinished.connect( + partial(self.on_cutoff_spinbox, aLabel) + ) + self.controlLayout1_5.addWidget(self.maxPlotHzSpinBox) + + """ + aLabel = QtWidgets.QLabel('Order') + self.controlLayout1_5.addWidget(aLabel) + self.orderSpinBox = QtWidgets.QDoubleSpinBox() + self.orderSpinBox.setRange(-1e9, 1e9) + self.orderSpinBox.setValue(self.order) + self.orderSpinBox.editingFinished.connect(partial(self.on_cutoff_spinbox, aLabel)) + self.controlLayout1_5.addWidget(self.orderSpinBox) + """ + + buttonName = "Filter Response" + aButton = QtWidgets.QPushButton(buttonName) + aButton.clicked.connect(partial(self.on_button_click, buttonName)) + self.controlLayout1_5.addWidget(aButton) + + """ + buttonName = 'Rebuild Auto-Corr' + aButton = QtWidgets.QPushButton(buttonName) + aButton.clicked.connect(partial(self.on_button_click,buttonName)) + self.controlLayout1_5.addWidget(aButton) + """ + + # + vLayout.addLayout(self.controlLayout1_5) # add mpl canvas + + # + # second row of controls (for model) + self.controlLayout_row2 = QtWidgets.QHBoxLayout() + + checkboxName = "Model Data" + self.modelDataCheckBox = QtWidgets.QCheckBox(checkboxName) + self.modelDataCheckBox.setChecked(False) + self.modelDataCheckBox.stateChanged.connect( + partial(self.on_checkbox_clicked, checkboxName) + ) + self.controlLayout_row2.addWidget(self.modelDataCheckBox) + + """ + buttonName = 'Detect' + aButton = QtWidgets.QPushButton(buttonName) + aButton.clicked.connect(partial(self.on_button_click,buttonName)) + self.controlLayout_row2.addWidget(aButton) + """ + + """ + aLabel = QtWidgets.QLabel('mvThreshold') + self.controlLayout_row2.addWidget(aLabel) + + self.mvThresholdSpinBox = QtWidgets.QDoubleSpinBox() + self.mvThresholdSpinBox.setValue(-52) + self.mvThresholdSpinBox.setRange(-1000, 1000) + self.controlLayout_row2.addWidget(self.mvThresholdSpinBox) + """ + + # numSeconds, spikeFreq, amp, noise + aLabel = QtWidgets.QLabel("Seconds") + self.controlLayout_row2.addWidget(aLabel) + + self.modelSecondsSpinBox = QtWidgets.QDoubleSpinBox() + self.modelSecondsSpinBox.setValue(20) + self.modelSecondsSpinBox.setRange(0, 1000) + self.controlLayout_row2.addWidget(self.modelSecondsSpinBox) + + # finalize row 2 + vLayout.addLayout(self.controlLayout_row2) # add mpl canvas + + # 3rd row + # second row of controls (for model) + # self.controlLayout_row3 = QtWidgets.QHBoxLayout() + + aLabel = QtWidgets.QLabel("Spike Frequency") + self.controlLayout_row2.addWidget(aLabel) + + self.modelFrequencySpinBox = QtWidgets.QDoubleSpinBox() + self.modelFrequencySpinBox.setValue(1) + self.modelFrequencySpinBox.setRange(0, 100) + self.controlLayout_row2.addWidget(self.modelFrequencySpinBox) + + aLabel = QtWidgets.QLabel("Amplitude") + self.controlLayout_row2.addWidget(aLabel) + + self.modelAmpSpinBox = QtWidgets.QDoubleSpinBox() + self.modelAmpSpinBox.setValue(100) + self.modelAmpSpinBox.setRange(-100, 100) + self.controlLayout_row2.addWidget(self.modelAmpSpinBox) + + aLabel = QtWidgets.QLabel("Noise Amp") + self.controlLayout_row2.addWidget(aLabel) + + self.modelNoiseAmpSpinBox = QtWidgets.QDoubleSpinBox() + self.modelNoiseAmpSpinBox.setValue(50) + self.modelNoiseAmpSpinBox.setRange(0, 1000) + self.controlLayout_row2.addWidget(self.modelNoiseAmpSpinBox) + + # finalize row 3 + # vLayout.addLayout(self.controlLayout_row3) # add mpl canvas + + vSplitter = QtWidgets.QSplitter(QtCore.Qt.Vertical) + vLayout.addWidget(vSplitter) + + # don't use inherited, want 3x3 with rows: 1, 1, 3 subplits + """ + self.mplWindow2(numRow=3) # makes self.axs[] and self.static_canvas + #self.vmAxes = self.axs[0] + self.rawAxes = self.axs[0] + self.rawZoomAxes = self.axs[1] + self.fftAxes = self.axs[2] + #self.autoCorrAxes = self.axs[3] # uses spike detection + """ + + # mirror self.mplWindow2() + from matplotlib.backends import backend_qt5agg + + self.fig = mpl.figure.Figure(constrained_layout=True) + self.static_canvas = backend_qt5agg.FigureCanvas(self.fig) + # this is really tricky and annoying + self.static_canvas.setFocusPolicy(QtCore.Qt.ClickFocus) + self.static_canvas.setFocus() + # self.fig.canvas.mpl_connect('key_press_event', self.keyPressEvent) + + self.mplToolbar = mpl.backends.backend_qt5agg.NavigationToolbar2QT( + self.static_canvas, self.static_canvas + ) + + if self.plotRawAxis: + numRow = 3 + else: + numRow = 2 + + gs = self.fig.add_gridspec(numRow, 3) + # self.rawAxes = self.fig.addsubplot(gs[0,:]) + if self.plotRawAxis: + self.rawAxes = self.static_canvas.figure.add_subplot(gs[0, :]) + self.rawZoomAxes = self.static_canvas.figure.add_subplot(gs[1, :]) + # 3rd row has 3 subplots + self.psdAxes = self.static_canvas.figure.add_subplot(gs[2, 0]) + self.spectrogramAxes = self.static_canvas.figure.add_subplot(gs[2, -2]) + else: + self.rawAxes = None + self.rawZoomAxes = self.static_canvas.figure.add_subplot(gs[0, :]) + # 3rd row has 3 subplots + self.psdAxes = self.static_canvas.figure.add_subplot(gs[1, 0]) + self.spectrogramAxes = self.static_canvas.figure.add_subplot(gs[1, -2]) + + vSplitter.addWidget(self.static_canvas) # add mpl canvas + vSplitter.addWidget(self.mplToolbar) # add mpl canvas + + # this works + # self.mplToolbar.hide() + + # set the layout of the main window + # self.setLayout(vLayout) + self.getVBoxLayout().addLayout(vLayout) + + def on_psd_window_changed(self, item): + """User selected window dropdown + + Args: + item (str): + """ + logger.info(f'item:"{item}" {type(item)}') + self.psdWindowStr = item + """ + if item == 'Hanning': + self.psdWindow = mpl.mlab.window_hanning + if item == 'Blackman': + self.psdWindow = np.blackman(51) + else: + logger.warning(f'Item not understood {item}') + """ + # + self.replot2(switchFile=False) + + def setAxis(self): + self.replot() + + def replot(self): + logger.info("") + + # self.getMean() + self.replot2(switchFile=True) + + def replot2(self, switchFile=False): + logger.info(f"switchFile:{switchFile}") + + # self.replotData(switchFile=switchFile) + # self.replotPsd() + # self.replot_fft() + # self.replotAutoCorr() + self.replot_fft2() + + if switchFile: + self._mySetWindowTitle() + + self.static_canvas.draw() + # plt.draw() + + def replot_fft2(self): + def myDetrend(x): + # print('myDetrend() x:', x.shape) + y = plt.mlab.detrend_linear(x) + # y = plt.mlab.detrend_mean(y) + return y + + start = time.time() + + if self.ba is None: + return + + if self.sweepNumber == "All": + logger.warning( + f"fft plugin can only show one sweep, received sweepNumber:{self.sweepNumber}" + ) + logger.warning('Defaulting to sweep 0') + self._sweepNumber = 0 + + # logger.info(f'using ba: {self.ba}') + startSec, stopSec = self.getStartStop() + if ( + startSec is None + or stopSec is None + or math.isnan(startSec) + or math.isnan(stopSec) + ): + logger.info(f"Resetting start/stop seconds to max") + startSec = 0 + stopSec = self.ba.fileLoader.recordingDur # self.ba.sweepX()[-1] + logger.info(f"Using start(s):{startSec} stop(s):{stopSec}") + self.lastLeft = round(startSec * 1000 * self.ba.fileLoader.dataPointsPerMs) + self.lastRight = round(stopSec * 1000 * self.ba.fileLoader.dataPointsPerMs) + + leftPoint = self.lastLeft + rightPoint = self.lastRight + + sweepY = self.getSweep("y") + if leftPoint < 0: + leftPoint = 0 + if rightPoint > sweepY.shape[0]: + rightPoint = sweepY.shape[0] + + y = sweepY[leftPoint:rightPoint] + + medianPnts = self.medianFilterPnts # 50 + if medianPnts > 0: + logger.info(f" Median filter with pnts={medianPnts}") + yFiltered = scipy.ndimage.median_filter(y, medianPnts) + else: + logger.info(" No median filter") + yFiltered = y + + # logger.info(f'Fetching sweepX with sweepNumber: {self.sweepNumber}') + + sweepX = self.getSweep("x") + + # logger.info(f' sweepX: {sweepX.shape}') + # logger.info(f' leftSec:{sweepX[leftPoint]} rightSec:{sweepX[rightPoint-1]}') + t = sweepX[leftPoint:rightPoint] + + dt = t[1] - t[0] # 0.0001 + fs = round(1 / dt) # samples per second + nfft = self.nfft # The number of data points used in each block for the FFT + # print(f' fs:{fs} nfft:{nfft}') + + if self.plotRawAxis: + self.rawAxes.clear() + self.rawAxes.plot(sweepX, sweepY, "-", linewidth=1) + # draw a rectangle to show left/right time selecction + yMin = np.nanmin(sweepY) + yMax = np.nanmax(sweepY) + xRect = startSec + yRect = yMin + wRect = stopSec - startSec + hRect = yMax - yMin + rect1 = mpl.patches.Rectangle((xRect, yRect), wRect, hRect, color="gray") + self.rawAxes.add_patch(rect1) + self.rawAxes.set_xlim([sweepX[0], sweepX[-1]]) + + # + # replot the clip +/- std we analyzed, after detrend + yDetrend = myDetrend(yFiltered) + + # + if self.doButterFilter: + # self.butterCutoff = 10 # Hz + # self.butterOrder = 70 + # self.sos = butter_sos(self.butterCutoff, self.fs, order=self.butterOrder, passType='lowpass') + # self.butterCutoff = [0.7, 10] + # logger.info(f' butterOrder:{self.butterOrder} butterCutoff:{self.butterCutoff}') + self.sos = butter_sos( + self.butterCutoff, self.fs, order=self.butterOrder, passType="bandpass" + ) + # filtfilt should remove phase delay in time. A forward-backward digital filter using cascaded second-order sections. + yFiltered_Butter = scipy.signal.sosfiltfilt(self.sos, yDetrend, axis=0) + # print('yDetrend:', yDetrend.shape, np.nanmin(yDetrend), np.nanmax(yDetrend)) + # print('yFiltered_Butter:', yFiltered_Butter.shape, np.nanmin(yFiltered_Butter), np.nanmax(yFiltered_Butter)) + + # we use this for remaining + yFiltered = yFiltered_Butter + + dataMean = np.nanmean(yDetrend) + dataStd = np.nanstd(yDetrend) + dataThreshold2 = dataMean + (2 * dataStd) + dataThreshold3 = dataMean + (3 * dataStd) + + self.rawZoomAxes.clear() + self.rawZoomAxes.plot(t, yDetrend, "-", linewidth=1) + self.rawZoomAxes.axhline( + dataMean, marker="", color="r", linestyle="--", linewidth=0.5 + ) + self.rawZoomAxes.axhline( + dataThreshold2, marker="", color="r", linestyle="--", linewidth=0.5 + ) + self.rawZoomAxes.axhline( + dataThreshold3, marker="", color="r", linestyle="--", linewidth=0.5 + ) + self.rawZoomAxes.set_xlim([t[0], t[-1]]) + # + if self.doButterFilter: + self.rawZoomAxes.plot(t, yFiltered, "-r", linewidth=1) + + # save yDetrend, yFiltered as csv + """ + print('=== FOR FERNANDO') + import pandas as pd + print(' t:', t.shape) + print(' yDetrend:', yDetrend.shape) + print(' yFiltered:', yFiltered.shape) + tmpDf = pd.DataFrame(columns=['s', 'yDetrend', 'yFiltered']) + tmpDf['s'] = t + tmpDf['yDetrend'] = yDetrend + tmpDf['yFiltered'] = yFiltered + print(tmpDf.head()) + dfFile = 'fft-20200707-0000-16sec-31sec.csv' + print('saving dfFile:', dfFile) + tmpDf.to_csv(dfFile, index=False) + print('=== END') + """ + + # we will still scale a freq plots to [0, self.maxPlotHz] + if self.doButterFilter: + minPlotFreq = self.butterCutoff[0] + maxPlotFreq = self.butterCutoff[1] + else: + minPlotFreq = 0 + maxPlotFreq = self.maxPlotHz # 15 + + # + # spectrogram + # self.fftAxes.clear() + nfft2 = int(nfft / 4) + specSpectrum, specFreqs, spec_t, specIm = self.spectrogramAxes.specgram( + yFiltered, NFFT=nfft2, Fs=fs, detrend=myDetrend + ) + """ + print('=== specgram') + print(' yFiltered:', yFiltered.shape) + print(' specSpectrum (freq, t):', specSpectrum.shape) # (25601, 20) is (freq, t) + print(' ', np.nanmin(specSpectrum), np.nanmax(specSpectrum)) + print(' specFreqs:', specFreqs.shape) + print(' spec_t:', spec_t.shape) + print(' specIm:', type(specIm)) + """ + mask = (specFreqs >= minPlotFreq) & (specFreqs <= maxPlotFreq) + specSpectrum = specSpectrum[mask, :] # TRANSPOSE + """ + print(' 2 Transpose specSpectrum (freq, t):', specSpectrum.shape) # (25601, 20) is (freq, t) + print(' ', np.nanmin(specSpectrum), np.nanmax(specSpectrum)) + """ + + # careful: I want to eventually scale y-axis to [0, self.maxPlotHz] + # for now use [minPlotFreq, maxPlotFreq] + x_min = startSec + x_max = stopSec + y_min = maxPlotFreq # minPlotFreq + y_max = minPlotFreq # maxPlotFreq + extent = [x_min, x_max, y_min, y_max] + + self.spectrogramAxes.clear() + self.spectrogramAxes.imshow( + specSpectrum, extent=extent, aspect="auto", cmap="viridis" + ) + self.spectrogramAxes.invert_yaxis() + self.spectrogramAxes.set_xlabel("Time (s)") + self.spectrogramAxes.set_ylabel("Freq (Hz)") + + # + # matplotlib psd + if self.psdWindowStr == "Hanning": + psdWindow = mpl.mlab.window_hanning + elif self.psdWindowStr == "Blackman": + psdWindow = np.blackman(nfft) + else: + logger.warning(f"psdWindowStr not understood {self.psdWindowStr}") + """ + print('=== calling mpl psd()') + print(' nfft:', nfft) + print(' fs:', fs) + print(' myDetrend:', myDetrend) + print(' psdWindowStr:', self.psdWindowStr) + print(' psdWindow:', psdWindow) + """ + # default scale_by_freq=True + scale_by_freq = True + Pxx, freqs = self.psdAxes.psd( + yFiltered, + marker="", + linestyle="-", + NFFT=nfft, + Fs=fs, + scale_by_freq=scale_by_freq, + detrend=myDetrend, + window=psdWindow, + ) + + # + # replot matplotlib psd + pxxLog10 = 10 * np.log10(Pxx) + mask = (freqs >= minPlotFreq) & (freqs <= maxPlotFreq) + pxxLog10 = pxxLog10[mask] # order matters + freqsLog10 = freqs[mask] # order matters + self.psdAxes.clear() + self.psdAxes.plot(freqsLog10, pxxLog10, "-", linewidth=1) + self.psdAxes.set_xlim([0, self.maxPlotHz]) # x-axes is frequency + self.psdAxes.grid(True) + # self.psdAxes.set_ylabel('10*log10(Pxx)') + self.psdAxes.set_ylabel("PSD (dB)") + self.psdAxes.set_xlabel("Freq (Hz)") + + # + # get peak frequency from psd, finding max peak with width + inflection = np.diff(np.sign(np.diff(pxxLog10))) + peaks = (inflection < 0).nonzero()[0] + 1 + # excception ValueError + try: + peak = peaks[pxxLog10[peaks].argmax()] + maxFreq = round(freqsLog10[peak], 3) # Gives 0.05 + maxPsd = round(pxxLog10[peak], 3) # Gives 0.05 + except ValueError as e: + logger.error("BAD PEAK in pxx") + maxFreq = [] + maxPsd = [] + + # add to plot + self.psdAxes.plot(maxFreq, maxPsd, ".r") + + maxPsd = np.nanmax(pxxLog10) + maxPnt = np.argmax(pxxLog10) + maxFreq = freqsLog10[maxPnt] + self.resultsLabel.setText( + f"Results: Peak Hz={round(maxFreq,3)} Amplitude={round(maxPsd,3)}" + ) + + # + # print results + # (file, startSec, stopSec, max psd amp, max psd freq) + pStart = round(startSec, 3) + pStop = round(stopSec, 3) + pMaxFreq = round(maxFreq, 3) + pMaxPsd = round(maxPsd, 3) + + printStr = f"Type\tFile\tstartSec\tstopSec\tmaxFreqPsd\tmaxPsd" # \tmaxFreqFft\tmaxFft' + # self.appendResultsStr(printStr) + + # print("=== FFT results are:") + # print(printStr) + + printStr = f"fftPlugin\t{self.ba.fileLoader.filename}\t{pStart}\t{pStop}\t{pMaxFreq}\t{pMaxPsd}" + + self.appendResultsStr( + printStr, maxFreq=pMaxFreq, maxPsd=pMaxPsd, freqs=freqsLog10, psd=pxxLog10 + ) + # print(printStr) + + # + # plot np fft + # see: https://www.gw-openscience.org/tutorial05/ + # blackman is supposed to correct for low freq signal ??? + """ + doBlackman = False + if doBlackman: + window = np.blackman(yFiltered.size) + windowed_yFiltered = yFiltered*window + else: + windowed_yFiltered = yFiltered + + ft = np.fft.rfft(windowed_yFiltered) + # not sure to use diff b/w [1]-[0] or fs=10000 ??? + tmpFs = t[1]-t[0] + fftFreqs = np.fft.rfftfreq(len(windowed_yFiltered), tmpFs) # Get frequency axis from the time axis + fftMags = abs(ft) # We don't care about the phase information here + + # find max peak with width + inflection = np.diff(np.sign(np.diff(fftMags))) + peaks = (inflection < 0).nonzero()[0] + 1 + peak = peaks[fftMags[peaks].argmax()] + signal_freq = round(fftFreqs[peak],3) # Gives 0.05 + signal_mag = round(fftMags[peak],3) # Gives 0.05 + #printStr = f'FFT\t{self.ba.getFileName()}\t{pStart}\t{pStop}\t{signal_freq}\t{signal_mag}' + #print(printStr) + printStr += f'\t{signal_freq}\t{signal_mag}' + print(printStr) + self.appendResultsStr(printStr) + #print(f' fft signal frequency is:{signal_freq} with mag:{signal_mag}') + + # strip down to min/ax x-plot of freq + mask = (fftFreqs>=minPlotFreq) & (fftFreqs<=maxPlotFreq) + fftMags = fftMags[mask] # order matters + fftFreqs = fftFreqs[mask] # order matters + + fftMags = fftMags[2:-1] + fftFreqs = fftFreqs[2:-1] + + self.fftAxes.clear() + self.fftAxes.semilogy(fftFreqs, fftMags, '-', linewidth=1) + self.fftAxes.set_xlim([minPlotFreq, maxPlotFreq]) # x-axes is frequency + self.fftAxes.set_xlabel('Freq (Hz)') + self.fftAxes.set_ylabel('FFT Mag') + self.fftAxes.grid(True) + self.fftAxes.semilogy(signal_freq, signal_mag, '.r') + """ + + stop = time.time() + # logger.info(f'Took {stop-start} seconds.') + + # + # self.static_canvas.draw() + + def appendResultsStr(self, str, maxFreq="", maxPsd="", freqs="", psd=""): + self._resultStr += str + "\n" + resultDict = { + "file": self.ba.fileLoader.filename, + "startSec": self.getStartStop()[0], + "stopSec": self.getStartStop()[1], + "butterFilter": self.doButterFilter, + "butterOrder": self.butterOrder, + "lowFreqCutoff": self.butterCutoff[0], + "highFreqCutoff": self.butterCutoff[1], + "maxFreq": maxFreq, + "maxPSD": maxPsd, + "freqs": freqs, + "psd": psd, + } + self._resultsDictList.append(resultDict) + + def getResultsDictList(self): + return self._resultsDictList + + def getResultStr(self): + return self._resultStr + + def old_replot_fft(self): + self.replot_fft2() + return + + def replotFilter(self): + """Plot frequency response of butter sos filter.""" + + logger.info("") + + # self.sos = butter_sos(self.butterCutoff, self.fs, self.butterOrder, passType='lowpass') + + fs = self.fs + + if self.sos is None: + # filter has not been created + self.sos = butter_sos( + self.butterCutoff, self.fs, order=self.butterOrder, passType="bandpass" + ) + + w, h = scipy.signal.sosfreqz(self.sos, worN=fs * 5, fs=fs) + + print("w:", w) + print("h:", np.abs(h)) + + fig = plt.figure(figsize=(3, 3)) + ax1 = fig.add_subplot(1, 1, 1) # + + # plt.plot(0.5*fs*w/np.pi, np.abs(H), 'b') + db = 20 * np.log10(np.maximum(np.abs(h), 1e-5)) + # y = np.abs(H) # 10*np.log10(np.maximum(1e-10, np.abs(H))) + ax1.plot(w, db, "-") + ax1.axvline(self.butterCutoff[0], color="r", linestyle="--", linewidth=0.5) + ax1.axvline(self.butterCutoff[1], color="r", linestyle="--", linewidth=0.5) + # y_sos = signal.sosfilt(sos, x) + ax1.set_xlim(0, self.maxPlotHz * 2) + + ax1.set_xlabel("Frequency (Hz)") + ax1.set_ylabel("Decibles (dB)") + + plt.show() + + def old_getPsd(self): + """Get psd from selected x-axes range.""" + # logger.info(f'self.lastLeft:{self.lastLeft} self.lastRight:{self.lastRight}') + dataFiltered = self.getSweep("filteredVm") + leftPoint = self.lastLeft + rightPoint = self.lastRight + y_sos = scipy.signal.sosfilt(self.sos, dataFiltered[leftPoint:rightPoint]) + self.f, self.Pxx_den = scipy.signal.periodogram(y_sos, self.fs) + + def rebuildModel(self): + numSeconds = self.modelSecondsSpinBox.value() + spikeFreq = self.modelFrequencySpinBox.value() + amp = self.modelAmpSpinBox.value() + noiseAmp = self.modelNoiseAmpSpinBox.value() + fs = 10000 # 10 kHz + t, spikeTrain, data = getSpikeTrain( + numSeconds=numSeconds, + spikeFreq=spikeFreq, + fs=fs, + amp=amp, + noiseAmp=noiseAmp, + ) + # (t, data) need to be one column (for bAnalysis) + t = t.reshape(t.shape[0], -1) + data = data.reshape(data.shape[0], -1) + + self._startSec = 0 + self._stopSec = numSeconds + + # t = t[:,0] + # data = data[:,0] + + print(" model t:", t.shape) + print(" model data:", data.shape) + + modelDict = {"sweepX": t, "sweepY": data, "mode": "I-Clamp"} + self._ba = sanpy.bAnalysis(fromDict=modelDict) + + # self.modelDetect() + + def loadData(self, fileIdx=0, modelData=False): + """Load from an analysis directory. Only used when no SanPyApp.""" + if modelData: + self.isModel = True + + # store bAnalysis so we can bring it back + self._store_ba = self._ba + self._store_startSec = self._startSec + self._store_stopSec = self._stopSec + + # + # load from a model + # numSeconds = 50 + # spikeFreq = 1 + # amp = 20 + # TODO: ensure we separate interface from backend !!! + self.rebuildModel() + else: + self.isModel = False + if self._analysisDir is not None: + self._ba = self._analysisDir.getAnalysis(fileIdx) + + if self._store_ba is not None: + self._ba = self._store_ba + self._startSec = self._store_startSec + self._stopSec = self._store_stopSec + + # either real ba or model + print(self.ba) + + self.fs = self.ba.fileLoader.recordingFrequency * 1000 + + self.replot2(switchFile=True) + + return + + def not_used_modelDetect(self): + # mvThreshold = self.mvThresholdSpinBox.value() # for detection + mvThreshold = -20 + + dDict = sanpy.bDetection().getDetectionDict("SA Node") + dDict["dvdtThreshold"] = np.nan + dDict["mvThreshold"] = mvThreshold + dDict["onlyPeaksAbove_mV"] = None + dDict["doBackupSpikeVm"] = False + self._ba.spikeDetect(dDict) + + def on_cutoff_spinbox(self, name): + """When user sets values, rebuild low-pass filter.""" + self.nfft = self.nfftSpinBox.value() + self.maxPlotHz = self.maxPlotHzSpinBox.value() + self.medianFilterPnts = self.medianFilterPntsSpinBox.value() # int + + self.butterOrder = self.butterOrderSpinBox.value() + + lowCutoff = self.lowCutoffSpinBox.value() # int + highCutoff = self.highCutoffSpinBox.value() # int + self.butterCutoff = [lowCutoff, highCutoff] + + self.freqResLabel.setText(f"Freq Resolution (Hz) {round(self.fs/self.nfft, 3)}") + + # self.order = self.orderSpinBox.value() + # self.sos = butter_lowpass_sos(self.cutOff, self.fs, self.order) + + def on_checkbox_clicked(self, name, value): + # print('on_crosshair_clicked() value:', value) + logger.info(f'name:"{name}" value:{value}') + isOn = value == 2 + if name == "PSD": + self.psdAxes.set_visible(isOn) + if isOn: + self.vmAxes.change_.geometry(2, 1, 1) + self.replotPsd() + else: + self.vmAxes.change_geometry(1, 1, 1) + self.static_canvas.draw() + elif name == "Auto-Correlation": + pass + elif name == "Model Data": + self.loadData(fileIdx=0, modelData=isOn) + elif name == "Butter Filter": + self.doButterFilter = value == 2 + self.replot2(switchFile=False) + else: + logger.warning(f'name:"{name}" not understood') + + def on_button_click(self, name): + if name == "Replot": + if self.isModel: + self.rebuildModel() + # self.replot2(switchFile=False) + # + self.replot2(switchFile=False) + elif name == "Filter Response": + # popup new window + self.replotFilter() + else: + logger.warning(f'name:"{name}" not understood') + """ + elif name == 'Detect': + #mvThreshold = self.mvThresholdSpinBox.value() + #logger.info(f'{name} mvThreshold:{mvThreshold}') + self.modelDetect() + self.replot2() + elif name == 'Rebuild Auto-Corr': + self.replotAutoCorr() + """ + + def on_xlims_change(self, event_ax): + return + + if not self._isInited: + return + + # slogger.info(f'event_ax:{event_ax}') + left, right = event_ax.get_xlim() # seconds + logger.info(f"left:{left} right:{right}") + + # find start/stop point from seconds in 't' + t = self.getSweep("x") + leftPoint = np.where(t >= left)[0] + leftPoint = leftPoint[0] + rightPoint = np.where(t >= right)[0] + if len(rightPoint) == 0: + rightPoint = len(t) # assuming we use [left:right] + else: + rightPoint = rightPoint[0] + # print('leftPoint:', leftPoint, 'rightPoint:', rightPoint, 'len(t):', len(self.t)) + + # keep track of last setting, if it does not change then do nothing + if self.lastLeft == leftPoint and self.lastRight == rightPoint: + logger.info("left/right point same doing nothng -- RETURNING") + return + else: + self.lastLeft = leftPoint + self.lastRight = rightPoint + + # + # get threshold fromm selection + self.getMean() + + self.replotPsd() + self.replot_fft() + # self.replotAutoCorr() # slow, requires button push + + def keyPressEvent(self, event): + logger.info(event) + text = super().keyPressEvent(event) + + # isMpl = isinstance(event, mpl.backend_bases.KeyEvent) + # text = event.key + # logger.info(f'xxx mpl key: "{text}"') + + if text == "h": + # hide controls (leave plots) + self.controlLayout.hide() + + if text in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]: + # self.loadData yyy + fileIdx = int(text) + self.loadData(fileIdx) + self.replot2(switchFile=True) + + elif text == "f": + self.replotFilter() + + def slot_switchFile( + self, ba: sanpy.bAnalysis, rowDict: Optional[dict] = None, replot: bool = True + ): + super().slot_switchFile(ba, rowDict, replot=False) + + self.fs = self.ba.fileLoader.recordingFrequency * 1000 + + self.freqResLabel.setText(f"Freq Resolution (Hz) {round(self.fs/self.nfft, 3)}") + + self.replot() + + +def testPlugin(): + from PyQt5 import QtWidgets + + app = QtWidgets.QApplication([]) + + path = "/Users/cudmore/Sites/Sanpy/data/fft" + ad = sanpy.analysisDir(path, autoLoad=True) + + fft = fftPlugin(ad) + # plt.show() + + sys.exit(app.exec_()) + + +def test_fft(): + if 0: + # abf data + path = "/Users/cudmore/Sites/SanPy/data/fft/2020_07_07_0000.abf" + ba = sanpy.bAnalysis(path) + + x = ba.fileLoader.sweepX + y = ba.fileLoader.sweepY + + # 20-30 sec + startSec = 16.968 # good signal + stopSec = 31.313 + + # reduce to get fft with N=1024 in excel + dataPointsPerMs = ba.fileLoader.dataPointsPerMs # 10 for 10 kHz + startPnt = round(startSec * 1000 * dataPointsPerMs) + stopPnt = round(stopSec * 1000 * dataPointsPerMs) + + numPnts = stopPnt - startPnt + print(f"N={numPnts}") + + t = x[startPnt:stopPnt] + y = y[startPnt:stopPnt] + + # y -= np.nanmean(y) + + dt = 0.0001 + fs = 1 / dt + NFFT = 512 # 2**16 #512 * 100 # The number of data points used in each block for the FFT + medianPnts = 50 # 5 ms + + fileName = ba.fileLoader.filename + + if 1: + # sin wave data + # Fixing random state for reproducibility + np.random.seed(19680801) + + durSec = 10.24 # to get 1024 points at dt=0.01 (power of two) + dt = 0.01 + fs = 1 / dt + nfft = 512 # The number of data points used in each block for the FFT + medianPnts = 5 # + + t = np.arange(0, durSec, dt) + nse = np.random.randn(len(t)) + r = np.exp(-t / 0.05) + + cnse = np.convolve(nse, r) * dt + cnse = cnse[: len(t)] + + secondFre = 7 + y = 0.1 * np.sin(2 * np.pi * t) + cnse + y += 0.1 * np.sin(secondFre * 2 * np.pi * t) + cnse + + fileName = "fakeSin" + + # def replot_fft(): + + # + # filter + yFiltered = scipy.ndimage.median_filter(y, medianPnts) + + # subsample down to 1024 + """ + from scipy.interpolate import interp1d + t2 = interp1d(np.arange(1024), t, 'linear') + yFiltered2 = interp1d(t, yFiltered, 'linear') + print(f'N2={len(t2)}') + plt.plot(t2, yFiltered2) + """ + + # save [t,y] to csv + """ + import pandas as pd + tmpDf = pd.DataFrame(columns=['t', 'y']) + tmpDf['t'] = t + tmpDf['y'] = y + csvPath = fileName + '.csv' + print('saving csv:', csvPath) + tmpDf.to_csv(csvPath, index=False) + """ + + """ + cutOff = 10 #20 # 20, cutOff of filter (Hz) + order = 50 # 40 # order of filter + sos = butter_lowpass_sos(cutOff, fs, order) + """ + + # yFiltered = scipy.signal.sosfilt(sos, yFiltered) + + # + # Fs = 1/dt # The sampling frequency (samples per time unit) + # NFFT = 512 # The number of data points used in each block for the FFT + + # plot + fig, (ax0, ax1, ax2) = plt.subplots(3, 1) + + ax0.plot(t, y, "k") + ax0.plot(t, yFiltered, "r") + + def myDetrend(x): + y = plt.mlab.detrend_linear(x) + y = plt.mlab.detrend_mean(y) + return y + + # The power spectral density 𝑃𝑥𝑥 by Welch's average periodogram method + print(" fs:", fs, "nfft:", nfft) + ax1.clear() + Pxx, freqs = ax1.psd(yFiltered, NFFT=nfft, Fs=fs, detrend=myDetrend) + ax1.set_xlim([0, 20]) + ax1.set_ylabel("PSD (dB/Hz)") + # ax1.callbacks.connect('xlim_changed', self.on_xlims_change) + + """ + ax1.clear() + ax1.plot(freqs, Pxx) + ax1.set_xlim([0, 20]) + """ + + """ + # recompute the ax.dataLim + ax1.relim() + # update ax.viewLim using the new dataLim + ax1.autoscale_view() + plt.draw() + """ + + """ + ax2.plot(freqs, Pxx) + ax2.set_xlim([0, 20]) + """ + + maxPsd = np.nanmax(Pxx) + maxPnt = np.argmax(Pxx) + print(f"Max PSD freq is {freqs[maxPnt]} with power {maxPsd}") + + scipy_f, scipy_Pxx = scipy.signal.periodogram(yFiltered, fs) + ax2.plot(scipy_f[1:-1], scipy_Pxx[1:-1]) # drop freq 0 + ax2.set_xlim([0, 20]) + + ax2.set_xlabel("Frequency (Hz)") + ax2.set_ylabel("scipy_Pxx") + + # + plt.show() + + +def testFilter(): + butterOrder = 50 + butterCutoff = [0.7, 10] + fs = 10000 + # nyq = fs * 0.5 + # normal_cutoff = butterCutoff / nyq + # normal_cutoff = butterCutoff + passType = "bandpass" + + # sos = scipy.signal.butter(N=butterOrder, Wn=butterCutoff, btype=passType, analog=False, fs=fs, output='sos') + sos = butter_sos(butterCutoff, fs, order=butterOrder, passType="bandpass") + + w, h = scipy.signal.sosfreqz(sos, worN=fs * 5, fs=fs) + + db = 20 * np.log10(np.maximum(np.abs(h), 1e-5)) + + print("w:", w) + print("db:", db) + + fig = plt.figure(figsize=(3, 3)) + ax1 = fig.add_subplot(1, 1, 1) # + + ax1.plot(w, db, "-") + ax1.set_xlim([0, 15]) + + plt.show() + + +def main(): + path = "/home/cudmore/Sites/SanPy/data/19114001.abf" + ba = sanpy.bAnalysis(path) + ba.spikeDetect() + print(ba.numSpikes) + + import sys + + app = QtWidgets.QApplication([]) + fft = fftPlugin(ba=ba) + fft.show() + sys.exit(app.exec_()) + + +if __name__ == "__main__": + # test_fft() + # testFilter() + main() diff --git a/sanpy/interface/plugins/kymographRoiPlugin.py b/sanpy/interface/plugins/kymographRoiPlugin.py new file mode 100644 index 00000000..f4bd83cf --- /dev/null +++ b/sanpy/interface/plugins/kymographRoiPlugin.py @@ -0,0 +1,74 @@ +from typing import Union, Dict, List, Tuple, Optional + +from sanpy.sanpyLogger import get_logger + +logger = get_logger(__name__) + +import sanpy +from sanpy.interface.plugins import sanpyPlugin + +# from sanpy.interface import kymographWidget + +class kymographRoiPlugin(sanpyPlugin): + myHumanName = "Kymograph ROI" + + # def __init__(self, myAnalysisDir=None, **kwargs): + def __init__(self, ba=None, **kwargs): + """ + Args: + ba (bAnalysis): Not required + """ + + logger.info("") + + super().__init__(ba=ba, **kwargs) + + # self._kymWidget = sanpy.interface.kymographPlugin2(ba) + + # kym roi will not respond to main sanpy gui (in particlar switch file) + self._turnOffAllSignalSlot() + + if ba is None or not ba.fileLoader.isKymograph(): + logger.error('ba file is not a kymograph') + from PyQt5 import QtWidgets + self._kymWidget = QtWidgets.QLabel('NOT A KYMOGRAPH FILE!') + self.getVBoxLayout().addWidget(self._kymWidget) + return + + path = self.ba.fileLoader.filepath + + if self.darkTheme: + # updated 20230914 + import matplotlib.pyplot as plt + plt.style.use("dark_background") + else: + import matplotlib.pyplot as plt + plt.rcParams.update(plt.rcParamsDefault) + + import numpy as np + from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis + + + imgData = self.ba.fileLoader._tif # list of color channel images + + logger.warning(f'removing sum 0 color channels from imgData:{len(imgData)}') + + finalImgData = [] + for _imgData in imgData: + if np.sum(_imgData) == 0: + continue + else: + finalImgData.append(_imgData) + + logger.info(f' final number of channels is {len(finalImgData)}') + # + kra = KymRoiAnalysis(path, imgData=finalImgData) + + from sanpy.kym.interface.kymRoiWidget import KymRoiWidget + # self._kymWidget = KymRoiWidget(imgData=imgData, header=headerDict) + self._kymWidget = KymRoiWidget(kra) + + self.getVBoxLayout().addWidget(self._kymWidget) + + def closeEvent(self, event): + self._kymWidget.closeEvent(event) diff --git a/sanpy/interface/plugins/plotScatter.py b/sanpy/interface/plugins/plotScatter.py index 8039447c..e0802e04 100644 --- a/sanpy/interface/plugins/plotScatter.py +++ b/sanpy/interface/plugins/plotScatter.py @@ -11,6 +11,7 @@ import matplotlib as mpl import matplotlib.pyplot as plt from matplotlib.widgets import RectangleSelector # To click+drag rectangular selection +from matplotlib.cm import ScalarMappable import matplotlib.markers as mmarkers # To define different markers for scatter from sanpy.sanpyLogger import get_logger @@ -40,7 +41,7 @@ def getPlotMarkersAndColors(ba : sanpy.bAnalysis, cMap.set_under("white") # only works for dark theme # do not specify 'c' argument, we set colors using set_facecolor, set_color - cMap = None + # cMap = None colorMapArray = None faceColors = None pathList = None @@ -140,7 +141,7 @@ def getPlotMarkersAndColors(ba : sanpy.bAnalysis, retDict = { 'cMap': cMap, 'colorMapArray': colorMapArray, - 'faceColors': faceColors, + 'faceColors': faceColors, # 032024 not always assigned? 'pathList': pathList, 'markerList_pg': markerList_pg, } @@ -187,6 +188,8 @@ def __init__(self, **kwargs): self._markerSize = 20 + self._colorbar = None # colorbar when showing hue + # when we plot, we plot a subset of spikes # this list tells us the spikes we are plotting and [ind_1] gives us the real spike number self._plotSpikeNumber = [] @@ -405,6 +408,7 @@ def _switchScatter(self): 1, 1, left=0.1, right=0.9, bottom=0.1, top=0.9, wspace=0.05, hspace=0.05 ) + # redraw everything self.static_canvas.figure.clear() if self.plotHistograms: @@ -444,6 +448,8 @@ def _switchScatter(self): self.lines = self.axScatter.scatter([], [], picker=5) + logger.info(f'self.lines:{self.lines}') + # make initial empty spike selection plot # self.spikeSel, = self.axScatter.plot([], [], 'x', markerfacecolor='none', color='y', markersize=10) # no picker for selection @@ -674,99 +680,39 @@ def replot(self): _tmpDict = getPlotMarkersAndColors(self.ba, plotSpikeNumberList, self._hue) cMap = _tmpDict['cMap'] - colorMapArray = _tmpDict['colorMapArray'] + colorMapArray = _tmpDict['colorMapArray'] # used by Time hue faceColors = _tmpDict['faceColors'] pathList = _tmpDict['pathList'] # logger.info(' setting lots of xxx') self.lines.set_array(colorMapArray) # set_array is for a color map - self.lines.set_cmap(cMap) # mpl.pyplot.cm.coolwarm - self.lines.set_color(faceColors) - self.lines.set_color(faceColors) # sets the outline - self.lines.set_paths(pathList) + if cMap is not None: + self.lines.set_cmap(cMap) # mpl.pyplot.cm.coolwarm + if 1 or faceColors is not None: + self.lines.set_color(faceColors) + # if faceColors is not None: + # self.lines.set_color(faceColors) # sets the outline + if pathList is not None: + self.lines.set_paths(pathList) # logger.info(' done setting lots of xxx') - # - # color - # if self._hue == "Time": - # tmpColor = np.array(range(len(xData))) - # self.lines.set_array(tmpColor) # set_array is for a color map - # self.lines.set_cmap(self.cmap) # mpl.pyplot.cm.coolwarm - # self.lines.set_color(None) - # elif self._hue == "Sweep": - # # color sweeps - # _sweeps = self.getStat("sweep") - # tmpColor = np.array(range(len(_sweeps))) - # self.lines.set_array(tmpColor) # set_array is for a color map - # self.lines.set_cmap(self.cmap) # mpl.pyplot.cm.coolwarm - # self.lines.set_color(None) - - # else: - # # tmpColor = np.array(range(len(xData))) - # # assuming self.cmap.set_under("white") - - # # from matplotlib.colors import ListedColormap - # color_dict = {1: "blue", 2: "red", 13: "orange", 7: "green"} - # color_dict = { - # "good": (0, 1, 1, 1), # cyan - # "bad": (1, 0, 0, 1), # red - # #'userType1':(0,0,1,1), # blue - # #'userType2':(0,1,1,1), # cyan - # #'userType3':(1,0,1,1), # magenta - # } - # marker_dict = { - # "userType1": mmarkers.MarkerStyle("*"), # star - # "userType2": mmarkers.MarkerStyle("v"), # triangle_down - # "userType3": mmarkers.MarkerStyle("<"), # triangle_left - # } - - # # no need for a cmap ??? - # # cm = ListedColormap([color_dict[x] for x in color_dict.keys()]) - # # self.lines.set_cmap(cm) - - # goodSpikes = self.getStat("include") - # userTypeList = self.getStat("userType") - - # logger.warning(" debug set spike stat, user types need to be int") - # logger.warning(" we ARE NOT GETTING THE CORRECT SPIKE INDEX") - # # print(userTypeList) - - # if self.plotChasePlot: - # # xData = xData[1:-1] # x is the reference spike for marking (bad, type) - # # goodSpikes = goodSpikes[1:-1] - # # userTypeList = userTypeList[1:-1] - # goodSpikes = goodSpikes[0:-2] - # userTypeList = userTypeList[0:-2] - - # tmpColors = [color_dict["bad"]] * len(xData) # start as all good - - # # user types will use symbols - # # tmpColors = [color_dict['type'+num2str(x)] if x>0 else tmpColors[idx] for idx,x in enumerate(userTypeList)] - # # bad needs to trump user type !!! - # tmpColors = [ - # color_dict["good"] if x else tmpColors[idx] - # for idx, x in enumerate(goodSpikes) - # ] - # tmpColors = np.array(tmpColors) - # # print('tmpColors', type(tmpColors), tmpColors.shape, tmpColors) - - # self.lines.set_array(None) # used to map [0,1] to color map - # self.lines.set_facecolor(tmpColors) - # self.lines.set_color(tmpColors) # sets the outline - - # # set user type 2 to 'star' - # # see: https://stackoverflow.com/questions/52303660/iterating-markers-in-plots/52303895#52303895 - # # import matplotlib.markers as mmarkers - # myMarkerList = [ - # marker_dict["userType" + str(x)] if x > 0 else mmarkers.MarkerStyle("o") - # for x in userTypeList - # ] - # myPathList = [] - # for myMarker in myMarkerList: - # path = myMarker.get_path().transformed(myMarker.get_transform()) - # myPathList.append(path) - # self.lines.set_paths(myPathList) - + # show colorbar + if colorMapArray is not None: + norm = plt.Normalize(colorMapArray.min(), colorMapArray.max()) + sm = ScalarMappable(norm=norm, cmap=cMap) + sm.set_array([]) + if self._colorbar is None: + self._colorbar = self.fig.colorbar(sm, ax=self.axScatter) + else: + self._colorbar.update_normal(sm) + # self._colorbar.show() + else: + if self._colorbar is not None: + try: + self._colorbar.remove() + except (KeyError): + pass + self._colorbar = None # # update highlighter, needs coordinates of x/y to highlight self.myHighlighter.setData(xData, yData, self._plotSpikeNumber) diff --git a/sanpy/interface/plugins/plotToolPool.py b/sanpy/interface/plugins/plotToolPool.py index 22cb9266..e65e2b62 100644 --- a/sanpy/interface/plugins/plotToolPool.py +++ b/sanpy/interface/plugins/plotToolPool.py @@ -22,8 +22,8 @@ def __init__(self, tmpMasterDf=None, **kwargs): if _analysisDir is not None: logger.info('using sanpy app analysis dir') self.masterDf = _analysisDir.pool_build(uniqueColumn=uniqueColumn, allowAutoLoad=False) - logger.info('masterDf is') - logger.info(f'\n{self.masterDf}') + # logger.info('masterDf is') + # logger.info(f'\n{self.masterDf}') else: logger.error('main SanPY app does not have an analysis dir') diff --git a/sanpy/interface/plugins/sanpyPlugin.py b/sanpy/interface/plugins/sanpyPlugin.py index 50debf0f..855ec8c0 100644 --- a/sanpy/interface/plugins/sanpyPlugin.py +++ b/sanpy/interface/plugins/sanpyPlugin.py @@ -1,3 +1,4 @@ +import os import math import enum @@ -5,7 +6,7 @@ # Error shows up in sanpy.bPlugin when it tries to grab .myHumanName ??? import functools -from typing import Union, Dict, List, Tuple, Optional, Optional +from typing import Union, List, Optional from matplotlib.backends import backend_qt5agg import matplotlib as mpl @@ -610,7 +611,7 @@ def copyToClipboard(self, df=None): logger.info(f'Saving: "{fileName}"') df.to_csv(fileName, index=False) - def saveResultsFigure(self, pgPlot=None): + def saveResultsFigure(self, pgPlot=None, appendToFileName = ''): """In derived, add code to save main figure to file. In derived, pass in a pg plot from a view and we will save it. @@ -630,6 +631,9 @@ def saveResultsFigure(self, pgPlot=None): # ask user for file fileName = self.ba.fileLoader.filename + filename = os.path.splitext(appendToFileName)[0] + if appendToFileName: + filename += '-' + appendToFileName fileName += ".png" savePath = fileName options = QtWidgets.QFileDialog.Options() @@ -786,7 +790,7 @@ def spike_pick_event(self, event): } self.signalSelectSpikeList.emit(sDict) - def closeEvent(self, event): + def closeEvent(self, event=None): """Called when window is closed. Signal close event back to parent bPlugin object. @@ -956,6 +960,9 @@ def slot_set_x_axis(self, startStopList: List[float]): startStopList : list(float) Two element list with [start, stop] in seconds """ + + return + if not self._getResponseOption(self.responseTypes.setAxis): return @@ -966,6 +973,7 @@ def slot_set_x_axis(self, startStopList: List[float]): if self._ba != ba: return + if startStopList is None: self._startSec = None self._stopSec = None @@ -1154,7 +1162,8 @@ def _updateTopToolbar(self): self._blockComboBox = False else: # no epochs defined - self._epochComboBox.setEnabled(False) + logger.warning('turned on 20250313 EPOCHS') + # self._epochComboBox.setEnabled(False) # filename = self.ba.getFileName() # self._fileLabel.setText(filename) diff --git a/sanpy/interface/plugins/spikeClips.py b/sanpy/interface/plugins/spikeClips.py index 695b4eac..b1fff059 100644 --- a/sanpy/interface/plugins/spikeClips.py +++ b/sanpy/interface/plugins/spikeClips.py @@ -291,7 +291,7 @@ def _myReplotClips(self): elif self.respondTo == "Spike Selection": selectedSpikeList = self.getSelectedSpikes() - logger.info(f' startSec:{startSec} stopSec:{stopSec}') + logger.info(f' startSec:{startSec} stopSec:{stopSec} self.epochNumber:{self.epochNumber}') # print('=== selectedSpikeList:', selectedSpikeList) diff --git a/sanpy/interface/plugins/summarizeResults.py b/sanpy/interface/plugins/summarizeResults.py index cea25d07..de90abfa 100644 --- a/sanpy/interface/plugins/summarizeResults.py +++ b/sanpy/interface/plugins/summarizeResults.py @@ -1,6 +1,6 @@ # 20210619 -from PyQt5 import QtCore, QtWidgets, QtGui +from PyQt5 import QtWidgets from sanpy.sanpyLogger import get_logger @@ -65,6 +65,14 @@ def _buildUI(self): radio3 = QtWidgets.QRadioButton("Sweep Summary") radio3.toggled.connect(lambda:self._on_radio_clicked(radio3)) + self._binTimeSpineBox = QtWidgets.QSpinBox() + self._binTimeSpineBox.setValue(2) + self._binTimeSpineBox.setMinimum(1) + self._binTimeSpineBox.setMaximum(2**16) + + radio3_1 = QtWidgets.QRadioButton("Bin Time (s)") + radio3_1.toggled.connect(lambda:self._on_radio_clicked(radio3_1)) + radio4 = QtWidgets.QRadioButton("Detection Errors") radio4.toggled.connect(lambda:self._on_radio_clicked(radio4)) @@ -73,6 +81,8 @@ def _buildUI(self): controlsLayout.addWidget(radio1) controlsLayout.addWidget(radio2) controlsLayout.addWidget(radio3) + controlsLayout.addWidget(radio3_1) + controlsLayout.addWidget(self._binTimeSpineBox) controlsLayout.addWidget(radio4) # the number of spikes in the report @@ -164,6 +174,16 @@ def replot(self): theMin=startSec, theMax=stopSec) + elif self._reportType == 'Bin Time (s)': + stepSec = self._binTimeSpineBox.value() + minMaxList = range(0, int(self.ba.fileLoader.recordingDur), stepSec) + logger.info(f'minMaxList:{minMaxList}') + self.cardiacDf = exportObject.report4( + sweep = self.sweepNumber, + epoch = self.epochNumber, + minMaxList = minMaxList + ) # report2 is more 'cardiac' + elif self._reportType == 'Detection Errors': self.cardiacDf = self.ba.dfError diff --git a/sanpy/interface/preferences.py b/sanpy/interface/preferences.py index b031f9b8..c58b8a55 100644 --- a/sanpy/interface/preferences.py +++ b/sanpy/interface/preferences.py @@ -32,6 +32,8 @@ def __init__(self, sanpyApp: "sanpy.interface.SanPyApp"): self._version = 1.8 # 20231226, implementing single file and folder windows self._version = 1.9 # 20231229, get rid of saving open plugins and single window position + self._version = 2.0 # 20250627, adding interface_mode for (SanPy, Kymograph) + self._maxRecent = 7 # a lucky number self._configDict = self.load() @@ -249,9 +251,11 @@ def getDefaults(self) -> dict: configDict["rawDataPanels"]["DAC"] = False # configDict['fileList'] = {} - # 20240124 changed from 1 to 3 (don't use for load file !!!) - configDict['fileList']['Folder Depth'] = 3 + configDict['fileList']['Folder Depth'] = 5 + + # abb 202506 + configDict['interface_mode'] = 'sanpy' # from ('sanpy', 'kymograph') return configDict diff --git a/sanpy/interface/sanpy_app.py b/sanpy/interface/sanpy_app.py index 044f4e01..c27f96a6 100644 --- a/sanpy/interface/sanpy_app.py +++ b/sanpy/interface/sanpy_app.py @@ -21,6 +21,7 @@ import pyqtgraph as pg +# enable_hi_dpi() must be called before the instantiation of QApplication. import qdarktheme qdarktheme.enable_hi_dpi() @@ -72,7 +73,7 @@ def __init__(self, argv): if firstTimeRunning: logger.info(" We created /Documents/Sanpy and need to restart") - self._fileLoaderDict = sanpy.fileloaders.getFileLoaders(verbose=True) + self._fileLoaderDict = sanpy.fileloaders.getFileLoaders(verbose=False) self._detectionClass : sanpy.bDetection = sanpy.bDetection() @@ -136,8 +137,12 @@ def _buildMenus(self, mainMenu): self.openRecentMenu.aboutToShow.connect(self._refreshOpenRecent) fileMenu.addMenu(self.openRecentMenu) - ## fileMenu.addSeparator() - # fileMenu.addAction(saveDatabaseAction) + fileMenu.addSeparator() + + # save frontmost window + saveAction = QtWidgets.QAction("Save", self) + saveAction.triggered.connect(self.saveFrontmost) + fileMenu.addAction(saveAction) fileMenu.addSeparator() @@ -301,6 +306,16 @@ def loadFile(self, filePath : str = None): logger.info(' spawning new window') self.openSanPyWindow(filePath) + def saveFrontmost(self): + """Save analysis in frontmost window. + """ + logger.info('') + # sanpy.interface.sanpy_window.SanPyWindow + activeWindow = self.activeWindow() + + if isinstance(activeWindow, sanpy.interface.sanpy_window.SanPyWindow): + activeWindow.saveFilesTable() + def loadFolder(self, path : str = None, folderDepth=None): """Load a folder of raw data files. @@ -313,7 +328,8 @@ def loadFolder(self, path : str = None, folderDepth=None): if folderDepth is None: # get the depth from file list widget #folderDepth = self._fileListWidget.getDepth() - folderDepth = 1 + folderDepth = 4 + logger.warning(f'balt april 2025, hard coding folderDepth to {folderDepth}') logger.info(f"Loading depth:{folderDepth} path: {path}") @@ -436,10 +452,12 @@ def openSanPyWindow(self, path=None, sweep=None, spikeNumber=None): sweep : int Only works for file path """ + interface_mode = self.configDict['interface_mode'] logger.info(f'path:{path}') logger.info(f' sweep:{sweep}') logger.info(f' spikeNumber:{spikeNumber}') + logger.info(f' interface_mode:{interface_mode}') # check if it is open foundWindow = None @@ -453,7 +471,13 @@ def openSanPyWindow(self, path=None, sweep=None, spikeNumber=None): # open new window if foundWindow is None: logger.info(' opening new window') - foundWindow = SanPyWindow(self, path) + + if interface_mode == 'sanpy': + foundWindow = SanPyWindow(self, path) + elif interface_mode == 'kymograph': + from sanpy.kym.interface.kym_file_list.tif_tree_window import TifTreeWindow + foundWindow = TifTreeWindow(self, path) + foundWindow.show() foundWindow.raise_() # bring to front, raise is a python keyword foundWindow.activateWindow() # bring to front @@ -487,10 +511,10 @@ def openSanPyWindow(self, path=None, sweep=None, spikeNumber=None): def closeSanPyWindow(self, theWindow : SanPyWindow): """Remove theWindow from self._windowList. """ - logger.info('todo: implement this') - logger.info(' remove sanpy window from app list of windows') + for idx, aWindow in enumerate(self._windowList): if aWindow == theWindow: + logger.info(f'remove/pop sanpy window from app, idx:{idx}') _removedValue = self._windowList.pop(idx) def _onHelpMenuAction(self, name: str): diff --git a/sanpy/interface/sanpy_window.py b/sanpy/interface/sanpy_window.py index 16bb108b..828b8b9f 100644 --- a/sanpy/interface/sanpy_window.py +++ b/sanpy/interface/sanpy_window.py @@ -34,7 +34,8 @@ class SanPyWindow(QtWidgets.QMainWindow): signalSelectSpikeList = QtCore.Signal(object) """Emit spike list selection.""" - def __init__(self, sanPyApp : "sanpy.interface.SanPyApp", path, parent=None): + def __init__(self, sanPyApp : "sanpy.interface.SanPyApp", + path, parent=None): """Main SanPy window to show one file or a file of files. Parameters @@ -47,7 +48,8 @@ def __init__(self, sanPyApp : "sanpy.interface.SanPyApp", path, parent=None): super().__init__(parent) - logger.info(f'Initializing SanPyWindow with path: {path}') + logger.info(f'Initializing SanPyWindow with path:') + logger.info(f' {path}') # logger.info("Constructing SanPyWindow") # date_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") @@ -326,7 +328,7 @@ def closeEvent(self, event): event : PyQt5.QtGui.QCloseEvent """ - logger.info(event) + logger.info('ask user to save changes') # check if our table view has been edited by user and warn doQuit = True @@ -518,7 +520,9 @@ def _old_getSelectedFileDict(self): return rowDict """ - def slot_fileTableClicked(self, row, rowDict, selectingAgain): + def slot_fileTableClicked(self, row, rowDict, + selectingAgain, + isDoubleClick): """Respond to selections in file table. Parameters @@ -541,6 +545,8 @@ def slot_fileTableClicked(self, row, rowDict, selectingAgain): if self.myAnalysisDir is not None: ba = self.myAnalysisDir.getAnalysis(row) # if None then problem loading + # logger.info(f'got row:{row} rowDict:{rowDict} ba: {ba}') + if ba is not None: self.signalSwitchFile.emit(ba, rowDict) if selectingAgain: @@ -555,6 +561,10 @@ def slot_fileTableClicked(self, row, rowDict, selectingAgain): f'Loaded file {rowDict["parent1"]}/{ba.fileLoader.filename} {fileNote}' ) # this will load ba if necc + if isDoubleClick and ba.kymAnalysis is not None: + logger.info('if ba is kym, open "Kymograph ROI" plugin') + self.sanpyPlugin_action('Kymograph ROI') + def _buildMenus(self): mainMenu = self.menuBar() @@ -1145,6 +1155,9 @@ def _load(self): folderDepth = 1 elif os.path.isdir(path): folderDepth = self.configDict["fileList"]["Folder Depth"] + logger.warning(f'baltimore april, need to allow uset to configure folderDepth:{folderDepth}') + logger.warning('20250510 colin folder depth is 5') + folderDepth = 5 else: logger.error(f'did not load path:{path}') return diff --git a/sanpy/interface/util.py b/sanpy/interface/util.py index b041ecc9..0c914486 100644 --- a/sanpy/interface/util.py +++ b/sanpy/interface/util.py @@ -3,24 +3,41 @@ from PyQt5 import QtCore, QtWidgets, QtGui import pyqtgraph as pg -from sanpy.sanpyLogger import get_logger +from sanpy.kym.logger import get_logger logger = get_logger(__name__) class sanpyCursors(QtCore.QObject): signalCursorDragged = QtCore.pyqtSignal(str) # dx signalSetDetectionParam = QtCore.pyqtSignal(str, float) - def __init__(self, plotWidget : pg.PlotWidget, showInView=True): + def __init__(self, + plotWidget : pg.PlotWidget, + showInView=True, + showCursorD=False, + cursorC_label=''): """Add cursors to a PlotWidget. Normally vmPlot. + + Parameters + ---------- + showCursorD : bool + Added when working on KymRoi """ super().__init__(None) + self._showCursorA = True + self._showCursorB = True + self._showCursorC = True + self._showCursorD = showCursorD + self._showCursors = False self._delx : float = float('nan') + self._dely : float = float('nan') self._aCursorVal : float = float('nan') self._bCursorVal : float = float('nan') self._cCursorVal : float = float('nan') + if self._showCursorD: + self._dCursorVal : float = float('nan') self._showCursorsY = False self._delx : float = float('nan') @@ -28,7 +45,7 @@ def __init__(self, plotWidget : pg.PlotWidget, showInView=True): self._plotWidget = plotWidget _rect = self._plotWidget.viewRect() # get xaxis - logger.info(f'_rect:{_rect}') + # logger.info(f'_rect:{_rect}') _left = _rect.left() _top = _rect.top() _right = _rect.right() @@ -43,19 +60,28 @@ def __init__(self, plotWidget : pg.PlotWidget, showInView=True): self._cursorB.sigDragged.connect(partial(self._cursorDragged, 'cursorB')) self._cursorB.setVisible(self._showCursors) - yLabelOpts = {'position':0.05} - self._cursorC = pg.InfiniteLine(pos=_top, angle=0, label='C', labelOpts=yLabelOpts, movable=True) - self._cursorC.sigDragged.connect(partial(self._cursorDragged, 'cursorA')) + yLabelOpts = {'position':0.1} + # label='f0%:{value:.2f}' + self._cursorC = pg.InfiniteLine(pos=_top, + angle=0, + # label='C', + label=cursorC_label+'{value:.2f}', # value is hidden, current pos of line + labelOpts=yLabelOpts, + movable=True) + self._cursorC.sigDragged.connect(partial(self._cursorDragged, 'cursorC')) self._cursorC.setVisible(self._showCursors) - # self._cursorD = pg.InfiniteLine(pos=10, angle=0, label='D', labelOpts=yLabelOpts, movable=True) - # self._cursorD.sigDragged.connect(partial(self._cursorDragged, 'cursorB')) - # self._cursorD.setVisible(self._showCursors) + + if showCursorD: + self._cursorD = pg.InfiniteLine(pos=10, angle=0, label='D', labelOpts=yLabelOpts, movable=True) + self._cursorD.sigDragged.connect(partial(self._cursorDragged, 'cursorD')) + self._cursorD.setVisible(self._showCursors) self._plotWidget.addItem(self._cursorA) self._plotWidget.addItem(self._cursorB) self._plotWidget.addItem(self._cursorC) - # self._plotWidget.addItem(self._cursorD) + if showCursorD: + self._plotWidget.addItem(self._cursorD) # logger.info(self._getName()) #self._showInView() @@ -70,11 +96,26 @@ def cursorsAreShowing(self): def toggleCursors(self, visible): self._showCursors = visible - self._cursorA.setVisible(visible) - self._cursorB.setVisible(visible) - self._cursorC.setVisible(visible) - # self._cursorD.setVisible(visible) + if self._showCursorA: + self._cursorA.setVisible(visible) + else: + self._cursorA.setVisible(False) + + if self._showCursorB: + self._cursorB.setVisible(visible) + else: + self._cursorB.setVisible(False) + + if self._showCursorC: + self._cursorC.setVisible(visible) + else: + self._cursorC.setVisible(False) + + if self._showCursorD: + self._cursorD.setVisible(visible) + # else: + # self._cursorD.setVisible(False) if visible: # set position to start/stop of current view @@ -150,13 +191,15 @@ def _showInView(self): bottom = rect.top() + yPercentOfView # y is flipped top = rect.bottom() - yPercentOfView - logger.info(f'left:{left} right:{right} bottom:{bottom} top:{top}') + # logger.info(f'left:{left} right:{right} bottom:{bottom} top:{top}') self._cursorA.setValue(left) self._cursorB.setValue(right) self._cursorC.setValue(bottom) - # self._cursorD.setValue(top) + + if self._showCursorD: + self._cursorD.setValue(top) self._cursorDragged('cursorA', self._cursorA) @@ -181,11 +224,26 @@ def _cursorDragged(self, name, infLine): self._cCursorVal = round(yCursorC,3) yCursorC = round(yCursorC,4) + # yCursorD = round(yCursorD,4) + if self._showCursorD: + yCursorD = self._cursorD.pos().y() + self._dCursorVal = round(yCursorD,3) + yCursorD = round(yCursorD,4) + + dely = yCursorD - yCursorC + dely = round(dely, 4) + + self._dely = dely # self._cursorB.label.setFormat(f'B\ndelx={delx}') delStr = f'A:{xCursorA} B:{xCursorB} Delta:{delx}' - delStr += f' | C:{yCursorC}' + if self._showCursorD: + delStr += f' | C:{yCursorC} D:{yCursorD} Delta:{dely}' + else: + # 20240916 original behavior + delStr += f' | C:{yCursorC}' + # delStr += f' | C:{yCursorC} D:{yCursorD} Delta:{dely}' self.signalCursorDragged.emit(delStr) diff --git a/sanpy/kym/__init__.py b/sanpy/kym/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanpy/kym/interface/__init__.py b/sanpy/kym/interface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanpy/kym/interface/imageViewer.py b/sanpy/kym/interface/imageViewer.py new file mode 100644 index 00000000..70d6ebb4 --- /dev/null +++ b/sanpy/kym/interface/imageViewer.py @@ -0,0 +1,495 @@ +from functools import partial + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSlider, QCheckBox +from PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtGui import QFont + +from PyQt5 import QtWidgets, QtCore, QtGui +import pyqtgraph as pg +import pyqtgraph.exporters + +from sanpy.kym.kymUtils import getAutoContrast + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +from enum import Enum, auto + +class ImageDisplayMode(Enum): + """Display modes for image data.""" + RAW = 'Raw' # Raw fluorescence values + F_F0 = 'f/f0' # F/F0 normalized values + DF_F0 = 'df/f0' # (F-F0)/F0 normalized values + +class ImageViewer(QtWidgets.QWidget): + + """General perpose 2D image viewer. + """ + def __init__(self, + imgData : np.ndarray, + secondsPerLine:float = 1, + umPerPixel:float = 1, + f0=1 + ): + super().__init__(None) + + self._imgData = imgData + self._secondsPerLine = secondsPerLine + self._umPerPixel = umPerPixel + self._f0 = f0 + + self._imgDataDisplay = imgData + # the image data displayed on the kymograph + + self._minContrast = 0 + self._maxContrast = self.imgDisplayMax + + self._buildUI() + + @property + def imgData(self) -> np.ndarray: + """Get image data for one image channel. + """ + return self._imgData + + @property + def imgDisplayMax(self): + return np.max(self._imgDataDisplay) + + def getImageRect(self): + """Get image rect with (x,y) scale. + + Used to display kym ImageItem + """ + left = 0 + top = self._imgDataDisplay.shape[0] * self._umPerPixel + right = self._imgDataDisplay.shape[1] * self._secondsPerLine + bottom = 0 + + width = right - left + height = top - bottom + + return left, bottom, width, height # x, y, w, h + + def _on_button_click(self, name : str): + logger.info(f'name:{name}') + + if name == 'Auto': + # auto contrast + self.setAutoContrast() + + # elif name == 'Set Scale': + # self._setScaleDialog() + + else: + logger.warning(f'did not understand button "{name}"') + + def setAutoContrast(self): + _min, _max = getAutoContrast(self._imgDataDisplay) # new 20240925, should mimic ImageJ + + logger.info(f'_min:{_min} _max:{_max}') + + self._minContrast = _min + self._maxContrast = _max + + # update gui + self.minContrastSlider.setValue(self._minContrast) + self.maxContrastSlider.setValue(self._maxContrast) + + def setColorMap(self, colorMap : str): + """ + _colorList = ['Green', 'Red', 'Blue', 'Grey', 'Grey Invert', 'viridis', 'plasma', 'inferno'] + """ + + # cm is type pg.ColorMap + if colorMap == 'Green': + cm = pg.colormap.get('Greens_r', source='matplotlib') + elif colorMap == 'Red': + cm = pg.colormap.get('Reds_r', source='matplotlib') + elif colorMap == 'Blue': + cm = pg.colormap.get('Blues_r', source='matplotlib') + elif colorMap == 'Grey': + cm = pg.colormap.get('Greys_r', source='matplotlib') + elif colorMap == 'Grey Invert': + cm = pg.colormap.get('Greys', source='matplotlib') + elif colorMap == 'viridis': + cm = pg.colormap.get('viridis', source='matplotlib') + elif colorMap == 'plasma': + cm = pg.colormap.get('plasma', source='matplotlib') + elif colorMap == 'inferno': + cm = pg.colormap.get('inferno', source='matplotlib') + else: + logger.error(f'did not understand color map: {colorMap}') + return + + # logger.info(f'{colorMap} cm:{cm}') + + # self.aColorBar.setColorMap(cm) + self.myImageItem.setColorMap(cm) + + def _hoverEvent(self, event): + """Hover on image -> update status in QMainWindow + """ + if event.isExit(): + return + + xPos = event.pos().x() + yPos = event.pos().y() + + xPos = int(xPos) + yPos = int(yPos) + + try: + intensity = self._imgDataDisplay[yPos, xPos] # flipped + except (IndexError) as e: + intensity = 'None' + + intensity = f'{xPos} {yPos} intensity:{intensity}' + + # logger.warning(f'todo: set on hover "{intensity}"') + # self.mySetStatusBar(intensity) + + def _toggleGui(self, item:str, visible:bool): + if item == 'contrastSliders': + self._contrastSliders.setVisible(visible) + else: + logger.warning(f'did not understand item: {item}') + + def _buildContrastSliders(self) -> QtWidgets.QWidget: + # Main container with horizontal layout + mainLayout = QtWidgets.QHBoxLayout() + mainLayout.setSpacing(10) + mainLayout.setContentsMargins(5, 5, 5, 5) + + # Left column for color and auto controls + leftColumn = QtWidgets.QVBoxLayout() + leftColumn.setAlignment(QtCore.Qt.AlignTop) + leftColumn.setSpacing(5) + + # Color selection combo + _colorList = ['Red', 'Green', 'Blue', 'Grey', 'Grey Invert', 'viridis', 'plasma', 'inferno'] + colorComboBox = QtWidgets.QComboBox() + colorComboBox.addItems(_colorList) + colorComboBox.setCurrentIndex(0) + colorComboBox.currentTextChanged.connect(partial(self.setColorMap)) + leftColumn.addWidget(colorComboBox) + + # Auto contrast button + autoButton = QtWidgets.QPushButton('Auto') + autoButton.clicked.connect(partial(self._on_button_click, 'Auto')) + leftColumn.addWidget(autoButton) + + # Right column for contrast controls and scale info + rightColumn = QtWidgets.QVBoxLayout() + rightColumn.setAlignment(QtCore.Qt.AlignTop) + rightColumn.setSpacing(5) + + # Min contrast controls + minContrastLayout = QtWidgets.QHBoxLayout() + minContrastLayout.setSpacing(5) + minContrastLayout.addWidget(QtWidgets.QLabel("Min")) + + self.minContrastSpinBox = QtWidgets.QSpinBox() + self.minContrastSpinBox.setEnabled(False) + self.minContrastSpinBox.setMinimum(0) + self.minContrastSpinBox.setMaximum(self.imgDisplayMax) + minContrastLayout.addWidget(self.minContrastSpinBox) + + self.minContrastSlider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.minContrastSlider.setMinimum(0) + self.minContrastSlider.setMaximum(self.imgDisplayMax) + self.minContrastSlider.setValue(0) + self.minContrastSlider.valueChanged.connect( + lambda val, name="minSlider": self._onContrastSliderChanged(val, name) + ) + minContrastLayout.addWidget(self.minContrastSlider) + + self.tifMinLabel = QtWidgets.QLabel(f'Img Min:{np.min(self._imgDataDisplay)}') + minContrastLayout.addWidget(self.tifMinLabel) + rightColumn.addLayout(minContrastLayout) + + # Max contrast controls + maxContrastLayout = QtWidgets.QHBoxLayout() + maxContrastLayout.setSpacing(5) + maxContrastLayout.addWidget(QtWidgets.QLabel("Max")) + + self.maxContrastSpinBox = QtWidgets.QSpinBox() + self.maxContrastSpinBox.setEnabled(False) + self.maxContrastSpinBox.setMinimum(0) + self.maxContrastSpinBox.setMaximum(self.imgDisplayMax) + maxContrastLayout.addWidget(self.maxContrastSpinBox) + + self.maxContrastSlider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.maxContrastSlider.setMinimum(0) + self.maxContrastSlider.setMaximum(self.imgDisplayMax) + self.maxContrastSlider.setValue(self.imgDisplayMax) + self.maxContrastSlider.valueChanged.connect( + lambda val, name="maxSlider": self._onContrastSliderChanged(val, name) + ) + maxContrastLayout.addWidget(self.maxContrastSlider) + + self.tifMaxLabel = QtWidgets.QLabel(f'Max:{np.max(self._imgDataDisplay)}') + maxContrastLayout.addWidget(self.tifMaxLabel) + rightColumn.addLayout(maxContrastLayout) + + # Scale info row + scaleLayout = QtWidgets.QHBoxLayout() + scaleLayout.setAlignment(QtCore.Qt.AlignRight) + scaleLayout.setSpacing(10) + + self._imgBitDepth = QtWidgets.QLabel(f'dtype:{self._imgDataDisplay.dtype}') + self._xScaleLabel = QtWidgets.QLabel(f'ms/line:{self._secondsPerLine*1000}') + self._yScaleLabel = QtWidgets.QLabel(f'um/pixel:{self._umPerPixel}') + + scaleLayout.addWidget(self._imgBitDepth) + scaleLayout.addWidget(self._xScaleLabel) + scaleLayout.addWidget(self._yScaleLabel) + rightColumn.addLayout(scaleLayout) + + # Add columns to main layout + # mainLayout.addLayout(leftColumn, stretch=1) + mainLayout.addLayout(leftColumn) + mainLayout.addLayout(rightColumn, stretch=4) + + # Create and return widget + widget = QtWidgets.QWidget() + widget.setLayout(mainLayout) + return widget + + def _buildUI(self): + vLayout = QtWidgets.QVBoxLayout() + self.setLayout(vLayout) + + self._contrastSliders = self._buildContrastSliders() + self._contrastSliders.setVisible(False) # Set initial visibility + vLayout.addWidget(self._contrastSliders) + + + # Display mode selection and f0 control + displayModeLayout = QtWidgets.QHBoxLayout() + displayModeLayout.setAlignment(QtCore.Qt.AlignLeft) + displayModeLayout.setSpacing(10) + + # Add f0 spinbox + f0Label = QtWidgets.QLabel("f0:") + self.f0SpinBox = QtWidgets.QDoubleSpinBox() + self.f0SpinBox.setMinimum(0.0001) # Avoid division by zero + self.f0SpinBox.setMaximum(99999) + self.f0SpinBox.setValue(self._f0) + self.f0SpinBox.setKeyboardTracking(False) + self.f0SpinBox.valueChanged.connect(self._onF0Changed) + displayModeLayout.addWidget(f0Label) + displayModeLayout.addWidget(self.f0SpinBox) + + displayModeLabel = QtWidgets.QLabel("Display Mode:") + self.displayModeCombo = QtWidgets.QComboBox() + for mode in ImageDisplayMode: + self.displayModeCombo.addItem(mode.value) + self.displayModeCombo.setCurrentText(ImageDisplayMode.RAW.name) + self.displayModeCombo.currentTextChanged.connect(self._onDisplayModeChanged) + + displayModeLayout.addWidget(displayModeLabel) + displayModeLayout.addWidget(self.displayModeCombo) + vLayout.addLayout(displayModeLayout) + + # kymograph + self.view = pg.GraphicsLayoutWidget() + self.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) # Enable context menu + vLayout.addWidget(self.view) + + # pyqtgraph.graphicsItems.PlotItem + row = 0 + colSpan = 1 + rowSpan = 1 + self.kymographPlot = self.view.addPlot( + row=row, col=0, rowSpan=rowSpan, colSpan=colSpan + ) + """A PlotItem""" + + # self.kymographPlot.setLabel("left", "Pixels (um)", units="") + # self.kymographPlot.setLabel("bottom", "Time (s)", units="") + + self.toggleAxis(False) + # self.kymographPlot.hideAxis('bottom') + # self.kymographPlot.hideAxis('left') + + self.kymographPlot.setDefaultPadding(0) + self.kymographPlot.enableAutoRange() + self.kymographPlot.setMouseEnabled(x=True, y=False) + self.kymographPlot.hideButtons() # hide the little 'A' button to rescale axis + # self.kymographPlot.setMenuEnabled(False) # turn off right-click menu + + # switching to PyMapManager style contrast with setLevels() + imageRect = self.getImageRect() # l,b,h,w + self.myImageItem = pg.ImageItem(self._imgDataDisplay, + axisOrder = "row-major", + rect=imageRect + ) # need transpose for row-major + + self.setColorMap('Green') # sets self.aColorBar which sets children (self.myImageItem) + + self.kymographPlot.addItem(self.myImageItem, ignorBounds=True) + + # redirect hover to self (to display intensity + self.myImageItem.hoverEvent = self._hoverEvent + + def _onF0Changed(self, value): + """Handle changes to f0 spinbox value. + + Parameters + ---------- + value : float + New f0 value + """ + self._f0 = value + + logger.info(f'f0:{self._f0}') + + # refresh displayed image based on current display mode + currentMode = self.displayModeCombo.currentText() + if currentMode == ImageDisplayMode.F_F0.value: + self._imgDataDisplay = self._imgData / self._f0 + elif currentMode == ImageDisplayMode.DF_F0.value: + self._imgDataDisplay = (self._imgData - self._f0) / self._f0 + else: + # Raw mode - no f0 normalization needed + self._imgDataDisplay = self._imgData + + # update image + self.myImageItem.setImage(self._imgDataDisplay, autoRange=True) + + def exportImage(self): + """Export the current image to a file. + """ + filter = ["*."+str(f) for f in QtGui.QImageWriter.supportedImageFormats()] + preferred = ['*.png', '*.tif', '*.jpg'] + + logger.info(f'filter:{filter}') + logger.info(f'preferred:{preferred}') + + exporter = pg.exporters.ImageExporter(self.myImageItem) + # exporter.parameters()['width'] = 100 + # exporter.export('image.png') + saveFile = '/Users/cudmore/Desktop/export.png' + logger.info(saveFile) + exporter.export(saveFile) + + def toggleAxis(self, visible): + if visible: + self.kymographPlot.showAxis('bottom') + self.kymographPlot.showAxis('left') + else: + self.kymographPlot.hideAxis('bottom') + self.kymographPlot.hideAxis('left') + + def _resetZoom(self, doEmit=True): + self.kymographPlot.autoRange(item=self.myImageItem) + + def keyReleaseEvent(self, event): + key = event.key() + if key == QtCore.Qt.Key_Shift: + self.kymographPlot.setMouseEnabled(x=True, y=False) + + def keyPressEvent(self, event): + """Respond to user key press. + + Parameters + ---------- + event : PyQt5.QtGui.QKeyEvent + """ + logger.info('') + key = event.key() + text = event.text() + + isShift = event.modifiers() == QtCore.Qt.ShiftModifier + isAlt = event.modifiers() == QtCore.Qt.AltModifier + isCtrl = event.modifiers() == QtCore.Qt.ControlModifier + + logger.info(f'key:{key} text:{text} isCtrl:{isCtrl} isAlt:{isAlt} isShift:{isShift}') + + if key == QtCore.Qt.Key_Return or key == QtCore.Qt.Key_Enter: + self._resetZoom() + elif isShift: + # switch it to y-zoom + self.kymographPlot.setMouseEnabled(x=False, y=True) + + def _onDisplayModeChanged(self, mode): + """Handle changes to display mode. + + Parameters + ---------- + mode : ImageDisplayMode + The new display mode to use + """ + + if mode == ImageDisplayMode.RAW.value: + displayData = self.imgData + elif mode == ImageDisplayMode.F_F0.value: + displayData = self.imgData / self._f0 + elif mode == ImageDisplayMode.DF_F0.value: + displayData = (self.imgData - self._f0) / self._f0 + else: + logger.warning(f'did not understand display mode: {mode}') + return + + displayData = displayData.astype(np.int16) + self._imgDataDisplay = displayData + + imageRect = self.getImageRect() + self.myImageItem.setImage(displayData, rect=imageRect) + + # Reset contrast to auto levels for new display mode + minVal, maxVal = getAutoContrast(displayData) + self._minContrast = minVal + self._maxContrast = maxVal + self.myImageItem.setLevels([minVal, maxVal]) + + # Update UI controls + self.minContrastSpinBox.setValue(minVal) + self.maxContrastSpinBox.setValue(maxVal) + self.minContrastSlider.setValue(minVal) + self.maxContrastSlider.setValue(maxVal) + + def _onContrastSliderChanged(self, value, name): + # logger.info(f'name:{name} value:{value}') + + if name == "minSlider": + self._minContrast = value + self.minContrastSpinBox.setValue(value) + elif name == "maxSlider": + self._maxContrast = value + self.maxContrastSpinBox.setValue(value) + + _levels = [self._minContrast, self._maxContrast] + self.myImageItem.setLevels(_levels, update=True) + # self.aColorBar.setLevels(_levels) + + def contextMenuEvent(self, event): + """Handle right-click context menu events.""" + menu = QtWidgets.QMenu(self) + + # Create toggle action for contrast sliders + showContrastAction = menu.addAction("Show Contrast Controls") + showContrastAction.setCheckable(True) + showContrastAction.setChecked(self._contrastSliders.isVisible()) + showContrastAction.triggered.connect( + lambda checked: self._contrastSliders.setVisible(checked) + ) + + # Create toggle action for axis visibility + showAxisAction = menu.addAction("Show Axis") + showAxisAction.setCheckable(True) + showAxisAction.setChecked(self.kymographPlot.getAxis("bottom").isVisible()) + showAxisAction.triggered.connect( + lambda checked: self.toggleAxis(checked) + ) + # Add export image action + exportAction = menu.addAction("Export Image...") + exportAction.triggered.connect(self.exportImage) + + # Show the menu at cursor position + menu.exec_(event.globalPos()) diff --git a/sanpy/kym/interface/kymDetectionToolbar.py b/sanpy/kym/interface/kymDetectionToolbar.py new file mode 100644 index 00000000..2e27bca7 --- /dev/null +++ b/sanpy/kym/interface/kymDetectionToolbar.py @@ -0,0 +1,821 @@ +from functools import partial +from typing import Optional + +from PyQt5 import QtCore, QtWidgets + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes +from sanpy.kym.kymRoiDetection import KymRoiDetection + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymDetectionGroupBox(QtWidgets.QGroupBox): + """A group box to show detection parameters. + + Either for detecting in (sum f0 or diameter). + """ + + signalDetectionParamChanged = QtCore.pyqtSignal(str, object) # (group name, KymRoiDetection) + signalDetection = QtCore.pyqtSignal(object) + signalSetWidgetVisible = QtCore.pyqtSignal(object, object) # (widget name, visible) + + def __init__(self, + kymRoiAnalysis : KymRoiAnalysis, + peakDetectionType : PeakDetectionTypes, + kymRoiDetection : KymRoiDetection, + groupName, + detectThisTraceList, + ): + + super().__init__(title=groupName) # title will be updated when roi is selected + + # only used to get roi detection on slot_selectroi + self._kymRoiAnalysis = kymRoiAnalysis + """KymRoiAnalysis object to get the roi detection dict. + """ + self._peakDetectionType : PeakDetectionTypes = peakDetectionType + + self._kymRoiDetection = kymRoiDetection + """KymRoiDetection object to get the roi detection dict. + """ + self._groupName = groupName + self._detectThisTraceList = detectThisTraceList + + self._blockSlots = False + + self._contentMarginLeft = 5 + self._contentMarginTop = 5 + + # baltimore april + self._selectedChannel = None + self._selectedRoiLabel = None + + self._buildUI(groupName=groupName) + + def _old_setDetectionDict(self, kymRoiDetection : KymRoiDetection): + """Update with new dict. + + Used when selecting an roi. + """ + self._kymRoiDetection = kymRoiDetection + self._updateDetectionParamGui() + + def setWidgetEnabled(self, widgetName, enabled : bool): + """Enable/disable a widget. + """ + if widgetName not in self._detectionControls.keys(): + logger.error(f'did not find widget "{widgetName}" available widgets are {self._detectionControls.keys()}') + return + self._detectionControls[widgetName].setEnabled(enabled) + + def setWidgetVisible(self, widgetName, visible : bool): + """Enable/disable a widget. + """ + if widgetName not in self._detectionControls.keys(): + logger.error(f'did not find widget "{widgetName}" available widgets are {self._detectionControls.keys()}') + return + self._detectionControls[widgetName].setVisible(visible) + + if widgetName == 'f0 Type': + # logger.error('layout has no set visible !!!!!!!!!!!!!!!!!!!!') + # self.hLayout_f0.setVisible(visible) + self._widget_f0.setVisible(visible) + + def _buildTopToolbar(self) -> QtWidgets.QVBoxLayout: + """Derived classes define this. + """ + pass + + def _buildUI(self, + groupName : str, + ) -> QtWidgets.QGroupBox: + """A detection toolbar, either for detection in int f0 or diameter. + """ + + # logger.info(f'building groupName:{groupName}') + + detectionDict = self._kymRoiDetection + """dict to pull values from""" + + # a dict of detection controls so we can update them on roi selection in _updateDetectionParamGui + self._detectionControls = {} + + # content margins are inherited, when we add a widget or layout to QGroupBox (e.g. self) + # all containing layout will inherit! + self.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + + # add the groupbox as the main layout (always confusing) + mainLayout = QtWidgets.QVBoxLayout() + mainLayout.setAlignment(QtCore.Qt.AlignTop) + # mainLayout.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + self.setLayout(mainLayout) + + # abb 202505 colin, removing nested QGroupBox + # self.detectionGroupBox = QtWidgets.QGroupBox(groupName) + # self.detectionGroupBox.setEnabled(False) # only enable when an roi is selected + # mainLayout.addWidget(self.detectionGroupBox) + + # abb 202505 colin, removing nested QGroupBox + # v layout for groupbox + # vLayout = QtWidgets.QVBoxLayout() + # vLayout.setAlignment(QtCore.Qt.AlignTop) + # self.detectionGroupBox.setLayout(vLayout) + + _topToolbar = self._buildTopToolbar() + if _topToolbar is not None: + # vLayout.addLayout(_topToolbar) + mainLayout.addLayout(_topToolbar) + + # + hLayoutAnalyze = QtWidgets.QHBoxLayout() + hLayoutAnalyze.setAlignment(QtCore.Qt.AlignLeft) + mainLayout.addLayout(hLayoutAnalyze) + + aCheckBoxName = 'Auto' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip(detectionDict.getDescription(aCheckBoxName)) + aCheckBox.setChecked(detectionDict[aCheckBoxName]) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayoutAnalyze.addWidget(aCheckBox) + self._detectionControls[aCheckBoxName] = aCheckBox + + buttonName = 'Detect Peaks' + aButton = QtWidgets.QPushButton(buttonName) + aButton.setStyleSheet("background-color: green") + aButton.setToolTip('Perform the analysis') + aButton.clicked.connect( + partial(self._on_button_click, buttonName) + ) + hLayoutAnalyze.addWidget(aButton) + self._detectionControls[buttonName] = aButton # so we can set the color + + aName = 'detectThisTrace' + aComboBox = QtWidgets.QComboBox() + aComboBox.setToolTip(detectionDict.getDescription(aName)) + aComboBox.addItems(self._detectThisTraceList) + aComboBox.currentTextChanged.connect( + partial(self._on_combobox, aName) + ) + hLayoutAnalyze.addWidget(aComboBox) + self._detectionControls[aName] = aComboBox + + buttonName = 'Reset' + aButton = QtWidgets.QPushButton(buttonName) + aButton.setToolTip('Reset detection parameters to default.') + aButton.clicked.connect( + partial(self._on_button_click, buttonName) + ) + hLayoutAnalyze.addWidget(aButton) + + # + hLayout00 = QtWidgets.QHBoxLayout() + hLayout00.setAlignment(QtCore.Qt.AlignLeft) + # hLayout00.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + mainLayout.addLayout(hLayout00) + + # + aLabel = QtWidgets.QLabel('Background Subtract') + hLayout00.addWidget(aLabel) + + aName = 'Background Subtract' + aComboBox = QtWidgets.QComboBox() + aComboBox.setToolTip(detectionDict.getDescription(aName)) + _items = KymRoiDetection.backgroundSubtractTypes # ['Off', 'Rolling-Ball', 'Median', 'Mean'] + aComboBox.addItems(_items) + aComboBox.currentTextChanged.connect( + partial(self._on_combobox, aName) + ) + hLayout00.addWidget(aComboBox) + self._detectionControls[aName] = aComboBox + + hLayout000 = QtWidgets.QHBoxLayout() + hLayout000.setAlignment(QtCore.Qt.AlignLeft) + # hLayout000.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + mainLayout.addLayout(hLayout000) + + aCheckBoxName = 'Exponential Detrend' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip(detectionDict.getDescription(aCheckBoxName)) + aCheckBox.setChecked(detectionDict[aCheckBoxName]) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout000.addWidget(aCheckBox) + self._detectionControls[aCheckBoxName] = aCheckBox + + # need self.hLayout_f0 so we can hide entire row + # abb 202505 colin, put in a widget so we can hide + self._widget_f0 = QtWidgets.QWidget() + + hLayout_f0 = QtWidgets.QHBoxLayout() + hLayout_f0.setAlignment(QtCore.Qt.AlignLeft) + self._widget_f0.setLayout(hLayout_f0) + + # mainLayout.addLayout(hLayout_f0) + mainLayout.addWidget(self._widget_f0) + + # + aName = 'f0 Type' + _displayName = 'f0' + # aLabel = QtWidgets.QLabel(aName) + aLabel = QtWidgets.QLabel(_displayName) + hLayout_f0.addWidget(aLabel) + + aComboBox = QtWidgets.QComboBox() + aComboBox.setToolTip(detectionDict.getDescription(aName)) + aComboBox.addItems(['Manual', 'Percentile']) + aComboBox.currentTextChanged.connect( + partial(self._on_combobox, aName) + ) + # self.hLayout_f0.addWidget(aComboBox) + hLayout_f0.addWidget(aComboBox) + self._detectionControls[aName] = aComboBox + + # + spinBoxName = 'f0 Percentile' + _displayName = 'Percentile' + # aLabel = QtWidgets.QLabel(spinBoxName) + # hLayout_f0.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0.001,1000) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{_displayName}: ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout_f0.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # + spinBoxName = 'f0 Value Manual' + _displayName = 'Manual' + # aLabel = QtWidgets.QLabel(spinBoxName) + # hLayout_f0.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0.001,1000) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{_displayName}: ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout_f0.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # 2nd row (filtering) + _showFiltering = False + + hLayout000 = QtWidgets.QHBoxLayout() + hLayout000.setAlignment(QtCore.Qt.AlignLeft) + # hLayout000.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + mainLayout.addLayout(hLayout000) + + aCheckBoxName = 'Median Filter' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip(detectionDict.getDescription(aCheckBoxName)) + aCheckBox.setChecked(detectionDict[aCheckBoxName]) + aCheckBox.setVisible(_showFiltering) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout000.addWidget(aCheckBox) + self._detectionControls[aCheckBoxName] = aCheckBox + + spinBoxName = 'Median Filter Kernel' + aSpinBox = QtWidgets.QSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(1,100) + aSpinBox.setSingleStep(2) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setKeyboardTracking(False) + aSpinBox.setVisible(_showFiltering) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout000.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # + aCheckBoxName = 'Savitzky-Golay' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip(detectionDict.getDescription(aCheckBoxName)) + aCheckBox.setChecked(detectionDict[aCheckBoxName]) + aCheckBox.setVisible(_showFiltering) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout000.addWidget(aCheckBox) + self._detectionControls[aCheckBoxName] = aCheckBox + + # + # third row + hLayout0 = QtWidgets.QHBoxLayout() + hLayout0.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + # hLayout0.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + mainLayout.addLayout(hLayout0) + + # + aName = 'Polarity' + aLabel = QtWidgets.QLabel(aName) + hLayout0.addWidget(aLabel) + + aComboBox = QtWidgets.QComboBox() + aComboBox.setToolTip(detectionDict.getDescription(aName)) + aComboBox.addItems(['Pos', 'Neg']) + aComboBox.currentTextChanged.connect( + partial(self._on_combobox, aName) + ) + hLayout0.addWidget(aComboBox) + self._detectionControls[aName] = aComboBox + + # + spinBoxName = 'Bin Line Scans' + aLabel = QtWidgets.QLabel(spinBoxName) + hLayout0.addWidget(aLabel) + + aSpinBox = QtWidgets.QSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,100) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout0.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # + # second row + # hLayout1 = QtWidgets.QHBoxLayout() + # hLayout1.setAlignment(QtCore.Qt.AlignLeft) + # # hLayout1.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + # vLayout.addLayout(hLayout1) + + vLayoutProminence = QtWidgets.QVBoxLayout() + vLayoutProminence.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop) + # vLayoutProminence.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + mainLayout.addLayout(vLayoutProminence) + + spinBoxName = 'Prominence' + # aLabel = QtWidgets.QLabel(spinBoxName) + # hLayout1.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(-100,100) + aSpinBox.setSingleStep(0.1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName}:') + # aSpinBox.setSuffix(" (pixels)") + + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + vLayoutProminence.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + spinBoxName = 'Width (ms)' + # aLabel = QtWidgets.QLabel(spinBoxName) + # hLayout1.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,1000) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName} ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + vLayoutProminence.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # + # hLayout11 = QtWidgets.QHBoxLayout() + # hLayout11.setAlignment(QtCore.Qt.AlignLeft) + # # hLayout11.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + # vLayout.addLayout(hLayout11) + + spinBoxName = 'Distance (ms)' + # aLabel = QtWidgets.QLabel(spinBoxName) + # vLayoutProminence.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,10000) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName} ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + vLayoutProminence.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + spinBoxName = 'Decay (ms)' + # aLabel = QtWidgets.QLabel(spinBoxName) + # hLayout11.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,1000) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName} ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + vLayoutProminence.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # FINISH VLayout + + # + # third row + # hLayout2 = QtWidgets.QHBoxLayout() + # hLayout2.setAlignment(QtCore.Qt.AlignLeft) + # # hLayout2.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + # vLayout.addLayout(hLayout2) + + # + # 2.5 row + hLayout2_5 = QtWidgets.QHBoxLayout() + hLayout2_5.setAlignment(QtCore.Qt.AlignLeft) + # hLayout2_5.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + mainLayout.addLayout(hLayout2_5) + # + spinBoxName = 'thresh_rel_height' + # aLabel = QtWidgets.QLabel(spinBoxName) + # hLayout2_5.addWidget(aLabel) + + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,1000) + aSpinBox.setSingleStep(.1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName} ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout2_5.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # + # third row + hLayout3 = QtWidgets.QHBoxLayout() + hLayout3.setAlignment(QtCore.Qt.AlignLeft) + mainLayout.addLayout(hLayout3) + # + spinBoxName = 'newOnsetOffsetFraction' + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,1000) + aSpinBox.setSingleStep(.1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName} ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout3.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + # + # fourth row + hLayout4 = QtWidgets.QHBoxLayout() + hLayout4.setAlignment(QtCore.Qt.AlignLeft) + mainLayout.addLayout(hLayout4) + + spinBoxName = 'Post Median Filter Kernel' + aSpinBox = QtWidgets.QSpinBox() + aSpinBox.setToolTip(detectionDict.getDescription(spinBoxName)) + aSpinBox.setRange(0,51) + aSpinBox.setSingleStep(1) + aSpinBox.setValue(detectionDict[spinBoxName]) + aSpinBox.setPrefix(f'{spinBoxName} ') + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hLayout4.addWidget(aSpinBox) + self._detectionControls[spinBoxName] = aSpinBox + + def _on_spin_box(self, name, value): + if self._blockSlots: + # logger.warning(f'self._blockSlots:{self._blockSlots} -->> no update for {name} {value}') + return + + logger.info(f'name:{name} value:{value}') + + detectionDict = self._kymRoiDetection + + if name not in detectionDict.keys(): + logger.error(f'did not understand "{name}" available keys are {detectionDict.keys()}') + return + else: + detectionDict[name] = value + logger.info(f'-->> emit signalDetectionParamChanged group:{self._groupName} + KymRoiDetection') + self.signalDetectionParamChanged.emit(self._groupName, detectionDict) + + def _on_combobox(self, name, value): + """ + Parameters + ---------- + detectionDict : dict + Switches between multiple detection group boxes like (detect int, detect diam) + """ + if self._blockSlots: + # logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + logger.info(f'"{name}" value:{value}') + + detectionDict = self._kymRoiDetection + + if name not in detectionDict.keys(): + logger.error(f'did not understand "{name}" available keys are {detectionDict.keys()}') + return + else: + detectionDict[name] = value + logger.info(f'-->> emit signalDetectionParamChanged group:{self._groupName} + KymRoiDetection') + self.signalDetectionParamChanged.emit(self._groupName, detectionDict) + + return + + def _on_checkbox_clicked(self, name, value = None): + if self._blockSlots: + # logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + if value > 0: + value = 1 + + # logger.info(f'name:{name} value:{value}') + + detectionDict = self._kymRoiDetection + + if name not in detectionDict.keys(): + logger.error(f'did not understand "{name}" available keys are {detectionDict.keys()}') + return + else: + detectionDict[name] = value + logger.info(f'-->> emit signalDetectionParamChanged group:{self._groupName} + KymRoiDetection') + self.signalDetectionParamChanged.emit(self._groupName, detectionDict) + + return + + def _on_button_click(self, name : str): + if self._blockSlots: + # logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + logger.info(f'name:{name}') + + if name == 'Detect Peaks': + self.signalDetection.emit(self._groupName) + + elif name == 'Reset': + # reset detection params to default + self._kymRoiDetection.setDefaults() + self._updateDetectionParamGui() + + elif name == 'Plot Quality': + from sanpy.kym.kymRoiAnalysis import plotDetectionResults + # plotDetectionResults(self._kymRoiAnalysis.getRoi(self._selectedRoiLabel), + # self._selectedChannel) + fig, ax = plotDetectionResults(self._kymRoiAnalysis, + self._selectedRoiLabel, + self._selectedChannel) + from matplotlib import pyplot as plt + plt.show() + + def _updateDetectionParamGui(self): + + logger.info('') + + self._blockSlots = True + + detectionDict = self._kymRoiDetection + if detectionDict is None: + # no roi selection + logger.warning('todo: disable gui on None roi') + return + + if detectionDict['Polarity'] == 'Pos': + self._detectionControls['Polarity'].setCurrentIndex(0) + elif detectionDict['Polarity'] == 'Neg': + self._detectionControls['Polarity'].setCurrentIndex(1) + else: + logger.error(f"did not understand polarity: {detectionDict['Polarity']}") + + + self._detectionControls['Auto'].setChecked(detectionDict['Auto']) # boolean + self._detectionControls['Median Filter'].setChecked(detectionDict['Median Filter']) # boolean + self._detectionControls['Median Filter Kernel'].setValue(detectionDict['Median Filter Kernel']) # must be odd + self._detectionControls['Savitzky-Golay'].setChecked(detectionDict['Savitzky-Golay']) + self._detectionControls['Bin Line Scans'].setValue(detectionDict['Bin Line Scans']) + self._detectionControls['Prominence'].setValue(detectionDict['Prominence']) + self._detectionControls['Width (ms)'].setValue(detectionDict['Width (ms)']) + self._detectionControls['Distance (ms)'].setValue(detectionDict['Distance (ms)']) + + self._detectionControls['f0 Percentile'].setValue(detectionDict['f0 Percentile']) + + if detectionDict['detectThisTrace'] == 'Diameter (um)': + pass + else: + if detectionDict['f0 Type'] == 'Manual': + self._detectionControls['f0 Type'].setCurrentIndex(0) + self._detectionControls['f0 Percentile'].setEnabled(False) + elif detectionDict['f0 Type'] == 'Percentile': + self._detectionControls['f0 Type'].setCurrentIndex(1) + self._detectionControls['f0 Percentile'].setEnabled(True) + else: + logger.error(f"did not understand 'f0 Type' {detectionDict['f0 Type']}") + + backgroundSubtractTypes = KymRoiDetection.backgroundSubtractTypes + backgroundsubtract = detectionDict['Background Subtract'] + _idx = backgroundSubtractTypes.index(backgroundsubtract) + self._detectionControls['Background Subtract'].setCurrentIndex(_idx) + + self._detectionControls['Exponential Detrend'].setChecked(detectionDict['Exponential Detrend']) # boolean + + # abb 202505, need to refactor to auto fill in detection params + self._detectionControls['thresh_rel_height'].setValue(detectionDict['thresh_rel_height']) + self._detectionControls['newOnsetOffsetFraction'].setValue(detectionDict['newOnsetOffsetFraction']) + + # set detectThisTrace combobox + detectThisTrace = detectionDict['detectThisTrace'] + _idx = self._detectThisTraceList.index(detectThisTrace) + self._detectionControls['detectThisTrace'].setCurrentIndex(_idx) + + self._blockSlots = False + + def slot_selectRoi(self, channel : int, roiLabel : Optional[str]): + + # always setTitle() + _title = f'{self._groupName} ch {channel+1} roi {roiLabel}' + # self.detectionGroupBox.setTitle(_title) + self.setTitle(_title) + + if roiLabel is not None: + # self.detectionGroupBox.setEnabled(True) + self.setEnabled(True) + self._kymRoiDetection = self._kymRoiAnalysis.getDetectionParams(roiLabel, self._peakDetectionType, channel) + self._updateDetectionParamGui() + + # detect button follow channel color + # from sanpy.kym.interface.kymRoiWidget import getChannelColor + detectButtonColor = self._kymRoiAnalysis.getChannelColor(channel) + self._detectionControls['Detect Peaks'].setStyleSheet(f'background-color: {detectButtonColor}') + + self._selectedRoiLabel = roiLabel + self._selectedChannel = channel + + else: + # self.detectionGroupBox.setEnabled(False) + self.setEnabled(False) + self._kymRoiDetection = None + self._selectedRoiLabel = None + self._selectedChannel = None + +class KymDetectionGroupBox_Intensity(KymDetectionGroupBox): + def __init__(self, + kymRoiAnalysis : KymRoiAnalysis, + kymRoiDetection : KymRoiDetection, + groupName, + detectThisTraceList, + ): + super().__init__(kymRoiAnalysis, + PeakDetectionTypes.intensity, + kymRoiDetection, + groupName, + detectThisTraceList) + + def _buildTopToolbar(self) -> QtWidgets.QVBoxLayout: + vLayout = QtWidgets.QVBoxLayout() + # vLayout.setContentsMargins(self._contentMarginLeft, self._contentMarginTop, 0, 0) + + # buttons to toggle sum and intensity (f0) + hLayoutButtons = QtWidgets.QHBoxLayout() + hLayoutButtons.setAlignment(QtCore.Qt.AlignLeft) + # hLayoutButtons.setContentsMargins(self._contentMarginLeft, self._contentMarginTop, 0, 0) + + vLayout.addLayout(hLayoutButtons) + + aLabel = QtWidgets.QLabel('Plots:') + hLayoutButtons.addWidget(aLabel) + + # # visual control of interface (not part of detection parameters) + # aCheckBoxName = 'Intensity' + # aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + # aCheckBox.setChecked(True) + # aCheckBox.stateChanged.connect( + # partial(self._on_checkbox_clicked, aCheckBoxName) + # ) + # hLayoutButtons.addWidget(aCheckBox) + + # # visual control of interface (not part of detection parameters) + # aCheckBoxName = 'f0' + # aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + # aCheckBox.setChecked(False) + # aCheckBox.stateChanged.connect( + # partial(self._on_checkbox_clicked, aCheckBoxName) + # ) + # hLayoutButtons.addWidget(aCheckBox) + + # + buttonName = 'Plot Quality' + aButton = QtWidgets.QPushButton(buttonName) + aButton.setToolTip('Matplotlib plot of steps in forming dF/F0.') + aButton.clicked.connect( + partial(self._on_button_click, buttonName) + ) + hLayoutButtons.addWidget(aButton) + + return vLayout + + def _on_checkbox_clicked(self, name, value = None): + if self._blockSlots: + # logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + if value > 0: + value = 1 + + # if name == 'Intensity': + # logger.info('TODO: hide show intensity plot (sum)') + # self.signalSetWidgetVisible.emit(name, value) + # elif name == 'f0': + # logger.info('TODO: hide show f0 intensity plot') + # self.signalSetWidgetVisible.emit(name, value) + # else: + # super()._on_checkbox_clicked(name, value) + + super()._on_checkbox_clicked(name, value) + +class KymDetectionGroupBox_Diameter(KymDetectionGroupBox): + def __init__(self, + kymRoiAnalysis : KymRoiAnalysis, + kymRoiDetection : KymRoiDetection, + groupName, + detectThisTraceList, + ): + super().__init__(kymRoiAnalysis, + PeakDetectionTypes.diameter, + kymRoiDetection, + groupName, + detectThisTraceList) + + def _buildTopToolbar(self) -> QtWidgets.QVBoxLayout: + vLayout = QtWidgets.QVBoxLayout() + + # buttons to toggle sum and intensity (f0) + hLayoutButtons = QtWidgets.QHBoxLayout() + hLayoutButtons.setAlignment(QtCore.Qt.AlignLeft) + vLayout.addLayout(hLayoutButtons) + + aLabel = QtWidgets.QLabel('Plots:') + hLayoutButtons.addWidget(aLabel) + + # visual control of interface (not part of detection parameters) + aCheckBoxName = 'Diameter' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + # aCheckBox.setToolTip(detectionDict.getDescription(aCheckBoxName)) + # aCheckBox.setChecked(detectionDict[aCheckBoxName]) + aCheckBox.setChecked(False) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayoutButtons.addWidget(aCheckBox) + + return vLayout + + def _on_checkbox_clicked(self, name, value = None): + if self._blockSlots: + # logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + if value > 0: + value = 1 + + if name == 'Diameter': + logger.info('TODO: hide show intensity plot (sum)') + self.signalSetWidgetVisible.emit(name, value) + else: + super()._on_checkbox_clicked(name, value) + diff --git a/sanpy/kym/interface/kymDiamToolbar.py b/sanpy/kym/interface/kymDiamToolbar.py new file mode 100644 index 00000000..dfeb9658 --- /dev/null +++ b/sanpy/kym/interface/kymDiamToolbar.py @@ -0,0 +1,215 @@ +from functools import partial + +from PyQt5 import QtCore, QtWidgets + +from sanpy.kym.kymRoiDetection import KymRoiDetection +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymDiameterToolbar(QtWidgets.QGroupBox): + """A groupbox to detect diameter in kym image. + """ + + signalDetectionParamChanged = QtCore.pyqtSignal(str, object) # (group name, KymRoiDetection) + signalDetection = QtCore.pyqtSignal(object) + + def __init__(self, + kymRoiAnalysis : KymRoiAnalysis, + kymRoiDetection : KymRoiDetection, + groupName : str): + super().__init__() + + self._kymRoiAnalysis : KymRoiAnalysis = kymRoiAnalysis + self._kymRoiDetection = kymRoiDetection #KymRoiDetection(PeakDetectionTypes.diameter) # on init is defaults + self._groupName = groupName + self._blockSlots = False + + self._buildUI() + + def setDetectionDict(self, detectionDict : KymRoiDetection): + """Update with new dict. + + Used when selecting an roi. + """ + self._kymRoiDetection = detectionDict + self._updateDetectionParamGui() + + def _updateDetectionParamGui(self): + """Update GUI with current detection parameters. + """ + logger.warning(' this is triggering KeyError on "Detect Diameter"') + self._blockSlots = True + detectionDict = self._kymRoiDetection + + for name, widget in self._detectionControls.items(): + value = detectionDict[name] + if isinstance(widget, QtWidgets.QCheckBox): + widget.setChecked(value) + elif isinstance(widget, QtWidgets.QAbstractSpinBox): + widget.setValue(value) + + self._blockSlots = False + + def _buildUI(self): + self._detectionControls = {} + + detectionDict = self._kymRoiDetection + + # self.detectionGroupBox = QtWidgets.QGroupBox(self._groupName) + + # add the groupbox as the main layout (always confusing) + mainLayout = QtWidgets.QVBoxLayout() + # mainLayout.addWidget(self.detectionGroupBox) + self.setLayout(mainLayout) + + # vLayout = QtWidgets.QVBoxLayout() + # vLayout.setAlignment(QtCore.Qt.AlignTop) + # self.detectionGroupBox.setLayout(vLayout) + + # + aButtonName = 'Detect Diameter' + aButton = QtWidgets.QPushButton(aButtonName) + aButton.setToolTip('Perform the analysis') + aButton.clicked.connect( + partial(self._on_button_click, aButtonName) + ) + self._detectionControls[aButtonName] = aButton + self._addHLayout(aButton, mainLayout, labelStr=None) + + aName = 'do_background_subtract_diam' + aCheckBox = QtWidgets.QCheckBox(aName) + aCheckBox.setChecked(detectionDict[aName]) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aName) + ) + self._detectionControls[aName] = aCheckBox + self._addHLayout(aCheckBox, mainLayout, labelStr=None) + + aName = 'line_width_diam' + aSpinBox = self._aSpinBox(aName) + self._detectionControls[aName] = aSpinBox + self._addHLayout(aSpinBox, mainLayout, labelStr=aName) + + aName = 'line_median_kernel_diam' + aSpinBox = self._aSpinBox(aName) + aSpinBox.setSingleStep(2) + self._detectionControls[aName] = aSpinBox + self._addHLayout(aSpinBox, mainLayout, labelStr=aName) + + # main detection param + aName = 'std_threshold_mult_diam' + aSpinBox = self._aSpinBox(aName) + aSpinBox.setMinimum(-4) + self._detectionControls[aName] = aSpinBox + self._addHLayout(aSpinBox, mainLayout, labelStr=aName) + + aName = 'line_scan_fraction_diam' + aSpinBox = self._aSpinBox(aName) + self._detectionControls[aName] = aSpinBox + self._addHLayout(aSpinBox, mainLayout, labelStr=aName) + + aName = 'line_interp_mult_diam' + aSpinBox = self._aSpinBox(aName) + self._detectionControls[aName] = aSpinBox + self._addHLayout(aSpinBox, mainLayout, labelStr=aName) + + def _aSpinBox(self, name : str): + detectionDict = self._kymRoiDetection + + _type = detectionDict.getType(name) + if _type == 'int': + aSpinBox = QtWidgets.QSpinBox() + elif _type == 'float': + aSpinBox = QtWidgets.QDoubleSpinBox() + aSpinBox.setSingleStep(0.1) + else: + logger.error(f'did not understand type for spinbox "{_type}"') + return + + aSpinBox.setToolTip(detectionDict.getDescription(name)) + # aSpinBox.setRange(1,100) + # aSpinBox.setSingleStep(2) + aSpinBox.setValue(detectionDict[name]) + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, name) + ) + return aSpinBox + + def _addHLayout(self, widget : QtWidgets.QWidget, parentLayout, labelStr : str = None): + """Add a new hLayout to parentLayout. + """ + hLayout = QtWidgets.QHBoxLayout() + hLayout.setAlignment(QtCore.Qt.AlignLeft) + + if labelStr is not None: + aLabel = QtWidgets.QLabel(labelStr) + hLayout.addWidget(aLabel) + + hLayout.addWidget(widget) + parentLayout.addLayout(hLayout) + + def _on_button_click(self, name): + logger.info(f'{name}') + if name == 'Detect Diameter': + self.signalDetection.emit(self._groupName) + + def _on_checkbox_clicked(self, name, value): + if self._blockSlots: + logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + if value > 0: + value = 1 + logger.info(f'{name} {value}') + detectionDict = self._kymRoiDetection + if name not in detectionDict.keys(): + logger.error(f'did not understand "{name}" available keys are {detectionDict.keys()}') + return + else: + detectionDict[name] = value + logger.info(f'-->> emit signalDetectionParamChanged group:{self._groupName} + KymRoiDetection') + self.signalDetectionParamChanged.emit(self._groupName, detectionDict) + + def _on_spin_box(self, name, value): + if self._blockSlots: + logger.warning(f'_blockSlots -->> no update for {name} {value}') + return + + logger.info(f'{name} {value}') + detectionDict = self._kymRoiDetection + if name not in detectionDict.keys(): + logger.error(f'did not understand "{name}" available keys are {detectionDict.keys()}') + return + else: + detectionDict[name] = value + logger.info(f'-->> emit signalDetectionParamChanged group:{self._groupName} + KymRoiDetection') + self.signalDetectionParamChanged.emit(self._groupName, detectionDict) + + def slot_selectRoi(self, channel : int, roiLabel : str): + # logger.info(f'channel:{channel} roi:{roi}') + # logger.info(' TODO: implemet this') + + # always setTitle() + _title = f'{self._groupName} ch {channel+1} roi {roiLabel}' + # self.detectionGroupBox.setTitle(_title) + self.setTitle(_title) + + if roiLabel is not None: + # self.detectionGroupBox.setEnabled(True) + self.setEnabled(True) + + self._kymRoiDetection = self._kymRoiAnalysis.getRoi(roiLabel).getDetectionParams(channel, PeakDetectionTypes.diameter) + self._updateDetectionParamGui() + + # detect button follow channel color + # from sanpy.kym.interface.kymRoiWidget import getChannelColor + detectButtonColor = self._kymRoiAnalysis.getChannelColor(channel) + self._detectionControls['Detect Diameter'].setStyleSheet(f'background-color: {detectButtonColor}') + + else: + # self.detectionGroupBox.setEnabled(False) + self.setEnabled(False) + self._kymRoiDetection = None diff --git a/sanpy/kym/interface/kymPlotWidget.py b/sanpy/kym/interface/kymPlotWidget.py new file mode 100644 index 00000000..a05a5189 --- /dev/null +++ b/sanpy/kym/interface/kymPlotWidget.py @@ -0,0 +1,613 @@ +from functools import partial +from typing import Optional + +import numpy as np +import pandas as pd +from PyQt5 import QtWidgets, QtCore +import pyqtgraph as pg + +from sanpy.interface.util import sanpyCursors + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes +from sanpy.kym.kymRoiResults import KymRoiResults + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymPlotWidget(QtWidgets.QWidget): + """Plot a trace like sum intensity or diameter and overlay KymAnalysis results. + """ + + signalCursorMove = QtCore.pyqtSignal(object) # roi + + def __init__(self, kymRoiAnalysis : KymRoiAnalysis, + xTrace : str, + yTrace : str, # we get seeded with one (e.g. f/f0) but can switch to other + peakDetectionType : PeakDetectionTypes): + super().__init__() + + self._kymRoiAnalysis = kymRoiAnalysis + + self.peakDetectionType = peakDetectionType + + self._additionalKeys = ['Half-Width', 'Exp Decay'] + # need to be defined in self._overlayPlotDict + + # sloppy + self._rightAxisPlot = None + + self._buildUI() + + for _channel in range(self._kymRoiAnalysis.numChannels): + self._showHideOverlays(_channel, True) + + # re-wire right-click (for entire widget) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + + def slot_selectRoi(self, channelIdx : int, roiLabel : Optional[str]): + """Select an roi in one channel. + """ + # logger.info(f'channelIdx:{channelIdx} roiLabel:{roiLabel} self.peakDetectionType:{self.peakDetectionType}') + + if not self.isVisible(): + return + + if roiLabel is None: + # clear all plots + self.sumIntensityPlot.setData(np.array([]), np.array([])) + + if self._rightAxisPlot is not None: + self._rightAxisPlot.setData(np.array([]), np.array([])) + + for _channel in range(self._kymRoiAnalysis.numChannels): + self.replotOverlays(_channel, roiLabel) + + self.setRoiLabelText('ROI: None') + self.setNumPeaksLabelText('Peaks: None') + return + + # backend KymRoi + kymRoi = self._kymRoiAnalysis.getRoi(roiLabel) + + # replot one channel + # _channel = channelIdx + # if 1: + for _channel in range(self._kymRoiAnalysis.numChannels): + if _channel == 0: + thisPlot = self.sumIntensityPlot + else: + thisPlot = self._rightAxisPlot + + # get what was detected, may change yPlot + _detectionParams = kymRoi.getDetectionParams(_channel, self.peakDetectionType) + # the trace that was detected + detectThisTrace = _detectionParams.getParam('detectThisTrace') + + xPlot = kymRoi.getTrace(_channel, 'Time (s)') + yPlot = kymRoi.getTrace(_channel, detectThisTrace) + + # xPlot (time s) will always have values, yPlot might all be nan + yAllNan = np.isnan(yPlot).all() + if yAllNan: + logger.info(f'detectThisTrace:{detectThisTrace} is all nan, setting x to nan as well.') + # xPlot[:] = np.nan + xPlot = np.array([]) + yPlot = np.array([]) + + # logger.info(f' _channel:{_channel} detectThisTrace:"{detectThisTrace}"') + # logger.info(f' xPlot:{xPlot}') + # logger.info(f' yPlot:{yPlot}') + + thisPlot.setData(xPlot, yPlot) + if _channel == 0: + self.sumIntensityPlotItem.setLabel("left", detectThisTrace, units="") + else: + self.sumIntensityPlotItem.setLabel("right", detectThisTrace, units="") + + # overlay for given channel + self.replotOverlays(_channel, roiLabel) + + _txt = f'ROI: {roiLabel}' + self.setRoiLabelText(_txt) + + # logger.warning('extend number o fpeaks to both ch1 and ch2') + # _txt = f'Peaks: {len(dfPlot)}' + # self.setNumPeaksLabelText(_txt) + + def _contextMenu(self, pos): + """Context menu for entire widget. + + See also myRawContextMenu. + """ + logger.info('') + + # build menu + contextMenu = QtWidgets.QMenu() + contextMenu.addAction('Full Zoom') + contextMenu.addSeparator() + + cursorAction = QtWidgets.QAction('Cursors') + cursorAction.setCheckable(True) + cursorAction.setChecked(self._sanpyCursors.cursorsAreShowing()) + contextMenu.addAction(cursorAction) + contextMenu.addSeparator() + + toolbarAction = QtWidgets.QAction('Overlay Toolbar') + toolbarAction.setCheckable(True) + toolbarAction.setChecked(self.sumOverlayToolbar.isVisible()) + contextMenu.addAction(toolbarAction) + # contextMenu.addSeparator() + + # show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + # respond to menu selection + actionText = action.text() + + if actionText == 'Full Zoom': + self.autoRange() + + elif actionText == 'Cursors': + self._sanpyCursors.toggleCursors(action.isChecked()) + + elif actionText == 'Overlay Toolbar': + self.sumOverlayToolbar.setVisible(action.isChecked()) + + def autoRange(self): + logger.info('') + self.sumIntensityPlotItem.autoRange() + + def setMouseEnabled(self, x : bool, y : bool): + logger.info(f'{self.peakDetectionType}') + # the left axis + self.sumIntensityPlotItem.setMouseEnabled(x=x, y=y) + + # the right axis + if self._rightAxisPlotItem is not None: + self._rightAxisPlotItem.setMouseEnabled(x=x, y=y) + + # AttributeError: 'PlotCurveItem' object has no attribute 'setMouseEnabled' + # self._rightAxisPlot.setMouseEnabled(x=x, y=y) + + # def plotItem(self): + # return self.sumIntensityPlotItem.plotItem + + def setXLink(self, plotItem): + self.sumIntensityPlotItem.setXLink(plotItem) + + def _buildUI(self): + + # self.setContentsMargins(0, 0, 0, 0) + + vBoxPlot = QtWidgets.QVBoxLayout() + vBoxPlot.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(vBoxPlot) + + self.sumIntensityPlotItem = pg.PlotWidget() + self.sumIntensityPlotItem.setDefaultPadding() + self.sumIntensityPlotItem.enableAutoRange() + self.sumIntensityPlotItem.setMouseEnabled(x=True, y=False) + self.sumIntensityPlotItem.hideButtons() # hide the little 'A' button to rescale axis + + _channelColor = self._kymRoiAnalysis.getChannelColor(0) + + self.sumIntensityPlotItem.setLabel("left", '', color=_channelColor, units="") + self.sumIntensityPlotItem.setLabel("bottom", 'Time (s)', units="") + # self.sumIntensityPlotItem.setXLink(self.kymographPlot) + + # get the original font andmake it bigger + _origFont = self.sumIntensityPlotItem.getAxis("bottom").label.font() + from sanpy.kym.interface.kymRoiWidget import KymRoiWidget + _origFont.setPointSize(KymRoiWidget._pgAxisLabelFontSize) + self.sumIntensityPlotItem.getAxis("bottom").label.setFont(_origFont) + self.sumIntensityPlotItem.getAxis("left").label.setFont(_origFont) + + # self._leftAxisPlotItem = pg.ViewBox() + # self.sumIntensityPlotItem.scene().addItem(self._leftAxisPlotItem) + + # self.sumIntensityPlotItem.getAxis('left').setLabel('axis2', color='#00ff00') + + # this is left axis plot (channel 0) - no longer gfp channel 2 + # the actual plot, pg PlotDataItem + # self.sumIntensityPlot = self.sumIntensityPlotItem.plotItem + # self.sumIntensityPlot = self.sumIntensityPlotItem.plot(name="sumIntensityPlot", + # pen=pg.mkPen('Red', width=2), + # # symbol='o', + # # brush=pg.mkBrush(100, 255, 100, 220), + # ) + + + self.sumIntensityPlot = pg.PlotCurveItem(pen=pg.mkPen(_channelColor, width=2)) + self.sumIntensityPlotItem.addItem(self.sumIntensityPlot) + # self._leftAxisPlotItem.addItem(self.sumIntensityPlot) + + # logger.info(f'self.sumIntensityPlotItem:{type(self.sumIntensityPlotItem)} {self.sumIntensityPlotItem}') + # logger.info(f'self.sumIntensityPlot:{type(self.sumIntensityPlot)} {self.sumIntensityPlot}') + + # if more than one channel, add right axis + # if self._kymRoiAnalysis.numChannels > 1: + self._addRightAxisPlot() + + # + # a vertical line to show selected line scan + self._kymLineScanLine = pg.InfiniteLine(pen=pg.mkPen(color='c', width=2)) + self.sumIntensityPlotItem.addItem(self._kymLineScanLine) + + self._sanpyCursors = sanpyCursors(self.sumIntensityPlotItem, showCursorD=True) + self._sanpyCursors.toggleCursors(False) # initially hidden + self._sanpyCursors.signalCursorDragged.connect(self.mySetStatusbar) + + self._initSumPlotOverlays() # requires + self.sumOverlayToolbar = self._buildPlotOverlayToolbar() + + # order matters + vBoxPlot.addWidget(self.sumOverlayToolbar) + vBoxPlot.addWidget(self.sumIntensityPlotItem) + + def _updateViews(self): + ## view has resized; update auxiliary views to match + pw = self.sumIntensityPlotItem # plot widget that contains a plotItem + + if self._rightAxisPlotItem is not None: + p2 = self._rightAxisPlotItem + p2.setGeometry(pw.plotItem.vb.sceneBoundingRect()) + + ## need to re-update linked axes since this was called + ## incorrectly while views had different shapes. + ## (probably this should be handled in ViewBox.resizeEvent) + # p2.linkedViewChanged(p1.vb, p2.XAxis) + + p2.linkedViewChanged(pw.plotItem.vb, p2.XAxis) + #p2.linkedViewChanged(pw.plotItem.vb, p2.YAxis) + + def _addRightAxisPlot(self): + """Add a right axis like matplotlib twinx. + + This adds right axis to self.sumIntensityPlotItem + + see: https://github.com/pyqtgraph/pyqtgraph/blob/master/pyqtgraph/examples/MultiplePlotAxes.py + """ + + if self._kymRoiAnalysis.numChannels == 1: + self._rightAxisPlotItem = None + return + + p1 = self.sumIntensityPlotItem # original left axis + + self._rightAxisPlotItem = pg.ViewBox() + p2 = self._rightAxisPlotItem + p1.showAxis('right') + p1.scene().addItem(p2) + p1.getAxis('right').linkToView(p2) + p2.setXLink(p1) + #p2.setYLink(p1) # this does link but left/right have different scale (does not visially work) + p1.getAxis('right').setLabel('Channel 2', color='#00ff00') + + self._rightAxisPlot = pg.PlotCurveItem(pen=pg.mkPen('Green', width=2)) + p2.addItem(self._rightAxisPlot) + + self._updateViews() + # p1.vb.sigResized.connect(self.updateViews) + pw = self.sumIntensityPlotItem # plot widget that contains a plotItem + pw.plotItem.vb.sigResized.connect(self._updateViews) + + # + # we can setdata on self._rightAxisPlot + # self._rightAxisPlot.setData(np.array([10, 30, 20])) + + def slot_updateLineProfile(self, lineScanIdx : int): + """Update vertical line showing current selected line scan. + """ + # logger.info(f'lineScanIdx:{lineScanIdx}') + lineScanSec = lineScanIdx * self._kymRoiAnalysis.secondsPerLine + self._kymLineScanLine.setPos(lineScanSec) + + def setRoiLabelText(self, text): + self._selectedRoiLabel.setText(text) + + def setNumPeaksLabelText(self, text): + self._numPeaksLabel.setText(text) + + def mySetStatusbar(self, event): + # logger.info(event) + self.signalCursorMove.emit(event) + + def _on_user_scatter_click(self, scatterPlotItem, spotItems): + """ + scatterPlotItem : scatterPlotItem + spotItems : [SpotItem] + """ + for spotItem in spotItems: + logger.info(f'spotItem.index():{spotItem.index()}') + + def _initSumPlotOverlays(self): + """"Initialize a number of overlay plots on top of self.sumIntensityPlotItem. + + e.g. (peak, threshold, half-width, etc) taken from KymAnalysis + """ + + # scatter plots that go over peak detection + self._overlayPlotDict = {} + + for _channel in range(self._kymRoiAnalysis.numChannels): + self._overlayPlotDict[_channel] = {} + + for analysisKey in KymRoiResults.overlayKeys: + symbol = KymRoiResults.getMarker(analysisKey) + if symbol is None: + symbol = 'o' + color = KymRoiResults.getColor(analysisKey) + if color is None: + color = (200, 200, 200, 220) + aPlot = pg.ScatterPlotItem( + name = analysisKey, + pen=None, # draws outline + symbol=symbol, + size=10, + brush=pg.mkBrush(color) + ) + aPlot.sigClicked.connect(self._on_user_scatter_click) + if _channel == 0: + self.sumIntensityPlotItem.addItem(aPlot) + # _tmpItem = self.sumIntensityPlotItem.getPlotItem() + # logger.info(f'"{analysisKey}" _tmpItem:{_tmpItem}') + else: + self._rightAxisPlotItem.addItem(aPlot) + self._overlayPlotDict[_channel][analysisKey] = aPlot + + # half-width and exp decay are special cases + for _additionalKey in self._additionalKeys: + # connect='finite' will not draw lines between np.nan + aLinePlot = pg.PlotCurveItem(pen=pg.mkPen('Yellow', width=3), connect='finite', name=_additionalKey) + aLinePlot.setVisible(False) + # + if _channel == 0: + self.sumIntensityPlotItem.addItem(aLinePlot) + else: + self._rightAxisPlotItem.addItem(aLinePlot) + self._overlayPlotDict[_channel][_additionalKey] = aLinePlot + + def clearPlotOverlays(self, channelIdx : int = None): + + if channelIdx is None: + theseChannels = range(self._kymRoiAnalysis.numChannels) + else: + theseChannels = [channelIdx] + + for _channel in theseChannels: + for _key, _item in self._overlayPlotDict[_channel].items(): + # _item.setData(np.array([]), np.array([])) + _item.clear() + + def _buildPlotOverlayToolbar(self): + """Dynamically build a number of check boxes from keys in _overlayPlotDict. + + A VBox with two HBox rows. + """ + + # checkboxes only have one copy, shared between channel 0/1 + self._overlayCheckboxDict = {} + + vBox = QtWidgets.QVBoxLayout() + vBox.setContentsMargins(0, 0, 0, 0) + + hBox = QtWidgets.QHBoxLayout() + hBox.setAlignment(QtCore.Qt.AlignLeft) + vBox.addLayout(hBox) + + # gui will control channel 1 (green, iATP) + # plots have two channels, here just pull from first + _channel = 0 + + _initThisList = ['Peak Int', 'Onset Int'] + + thisHBox = hBox + _controlsPerRow = 5 + numberCreated = 0 + for analysisResultKey in self._overlayPlotDict[_channel].keys(): + if ' Int' not in analysisResultKey: + # logger.warning(f'we are only showing analysis results with " Int" in their key, got key:{analysisResultKey}') + continue + _isChecked = analysisResultKey in _initThisList + aCheckBoxName = analysisResultKey.replace(' Int', '') # strip off ' Int' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setChecked(_isChecked) + # aCheckBox.setChecked(self._overlayPlotDict[_channel][analysisResultKey].isVisible()) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName, _channel) + ) + self._overlayCheckboxDict[analysisResultKey] = aCheckBox + # switch to second row + if numberCreated == _controlsPerRow: # hard coding 4 checkboxes per row (only two rows) + thisHBox = QtWidgets.QHBoxLayout() + thisHBox.setAlignment(QtCore.Qt.AlignLeft) + vBox.addLayout(thisHBox) + numberCreated = -1 + thisHBox.addWidget(aCheckBox) + + numberCreated += 1 + + # add in additional key like Half-Width and Exp Decay (not explicitly in analysis results) + for _additionalKey in self._additionalKeys: + _isChecked = False + aCheckBox = QtWidgets.QCheckBox(_additionalKey) + aCheckBox.setChecked(_isChecked) + # aCheckBox.setChecked(self._overlayPlotDict[_channel][_additionalKey].isVisible()) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, _additionalKey, _channel) + ) + self._overlayCheckboxDict[_additionalKey] = aCheckBox + thisHBox.addWidget(aCheckBox) + + # + hBox_final = QtWidgets.QHBoxLayout() + hBox_final.setAlignment(QtCore.Qt.AlignLeft) + vBox.addLayout(hBox_final) + + # add checkboxes to toggle entire channel on/off + for _channel in range(self._kymRoiAnalysis.numChannels): + aCheckBoxName = f'Channel {_channel+1}' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setChecked(True) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName, _channel) + ) + self._overlayCheckboxDict[aCheckBoxName] = aCheckBox + hBox_final.addWidget(aCheckBox) + + # label to show selected roi + self._selectedRoiLabel = QtWidgets.QLabel('ROI: None') + hBox_final.addWidget(self._selectedRoiLabel) + # label to show number of peaks + self._numPeaksLabel = QtWidgets.QLabel('Peaks: None') + hBox_final.addWidget(self._numPeaksLabel) + + _widget = QtWidgets.QWidget() + _widget.setContentsMargins(0, 0, 0, 0) + _widget.setLayout(vBox) + + # initially hidden + _widget.setVisible(False) + + return _widget + + def showingChannel(self, channelIdx): + """If 'channel 1' and/or channel 2 checkbox is clicked. + """ + thisCheckbox = f'Channel {channelIdx+1}' + return self._overlayCheckboxDict[thisCheckbox].isChecked() + + def _on_checkbox_clicked(self, name, channelIdx, value): + """ + name : str + Name of an analysis result key (or _additionalKeys) + channel : int + 0 based channel index + """ + if value > 0: + value = 1 + + logger.info(f'name:"{name}" channelIdx:{channelIdx} value:{value}') + + # name might originate from an analysis result key like 'Peak Int' + intKey = name + ' Int' + + if 'Channel ' in name: + # toggle one channel on/off + if channelIdx == 0: + self.sumIntensityPlot.setVisible(value) + # this would turn them all on/off (not what we want + # for _key, _item in self._overlayPlotDict[channelIdx].items(): + # _item.setVisible(value) + + # turn overlays on/off + # for on, will follow check box is chacked + self._showHideOverlays(channelIdx, value) + + # if value == 0: + # self.clearPlotOverlays(channelIdx=0) + # else: + # self.replotOverlays(channelIdx=0) + + elif channelIdx == 1: + # for the right-axis plot, this turns of trace and all overlay + self._rightAxisPlotItem.setVisible(value) + self._showHideOverlays(channelIdx, value) + + elif intKey in self._overlayPlotDict[channelIdx].keys(): + for _channel in range(self._kymRoiAnalysis.numChannels): + logger.info(f' -->> refreshing _channel:{_channel} intKey:{intKey}') + if self.showingChannel(_channel): + self._overlayPlotDict[_channel][intKey].setVisible(value) + + elif name in self._additionalKeys: + for _channel in range(self._kymRoiAnalysis.numChannels): + logger.info(f' -->> refreshing _channel:{_channel} _additionalKeys name:{name}') + if self.showingChannel(_channel): + self._overlayPlotDict[_channel][name].setVisible(value) + + else: + logger.info(f'did not understand name:"{name}"') + + def _showHideOverlays(self, channelIdx : int, value : bool): + """Set Visible of each plot overlay (scatter) based on its checkbox values. + + We have 2x channels of plots but only one set of checkboxes. + """ + _turningOn = value > 0 + for _key, _item in self._overlayPlotDict[channelIdx].items(): + if _turningOn: + # don't turn on all overlays, follow check box dict + _turnOn = self._overlayCheckboxDict[_key].isChecked() + else: + _turnOn = value + _item.setVisible(_turnOn) + + def replotOverlays(self, channelIdx, roiLabel : str): + """Replot all scatter/line overlay plots. + + Parameters + ========== + channel : int + roiLabel : str + """ + + # logger.info(f'channelIdx:{channelIdx} roiLabel:{roiLabel} self.peakDetectionType:{self.peakDetectionType}-->> replotOverlay with dfPlot:') + + if roiLabel is None: + logger.warning(' -->> got roiLabel None.') + self.clearPlotOverlays(channelIdx=channelIdx) + return + + kymRoi = self._kymRoiAnalysis.getRoi(roiLabel) + dfPlot = kymRoi.getAnalysisResults(channelIdx, self.peakDetectionType).df + + if len(dfPlot) == 0: + logger.warning(' -->> got empty analysis dataframe.') + self.clearPlotOverlays(channelIdx=channelIdx) + return + + for analysisKey in KymRoiResults.overlayKeys: + # analysisKey should contain "Int" and is used as y-axis in scatter + # if 'Int' not in analysisKey: + # continue + + if ' Int' in analysisKey: + xKey = analysisKey.replace(' Int', ' (s)') + xPlot = dfPlot[xKey].to_numpy() + yPlot = dfPlot[analysisKey].to_numpy() + + # logger.info(f'channel:{channelIdx} analysisKey:"{analysisKey}"') + # logger.info(f' xPlot:{xPlot}') + # logger.info(f' yPlot:{yPlot}') + + self._overlayPlotDict[channelIdx][analysisKey].setData(xPlot, yPlot) + # + # _isVisible = self._overlayCheckboxDict[analysisKey].isVisible() + # self._overlayPlotDict[channelIdx][analysisKey].setVisible(_isVisible) + + # refresh additional plots like half-width and exp decay + for analysisKey in self._additionalKeys: + if analysisKey == 'Exp Decay': + xExp, yExp = kymRoi.getExpDecayPlot(channel=channelIdx, peakDetectionType=self.peakDetectionType) + # logger.info(f'channel:{channelIdx} analysisKey:"{analysisKey}"') + # logger.info(f' xExp:{xExp}') + # logger.info(f' yExp:{yExp}') + self._overlayPlotDict[channelIdx][analysisKey].setData(xExp, yExp) + + elif analysisKey =='Half-Width': + xHalWidth, yHalfWidth = kymRoi.getHalfWidthPlot(channel=channelIdx, peakDetectionType=self.peakDetectionType) + # logger.info(f'channel:{channelIdx} analysisKey:"{analysisKey}"') + # logger.info(f' xHalWidth:{xHalWidth}') + # logger.info(f' yHalfWidth:{yHalfWidth}') + self._overlayPlotDict[channelIdx][analysisKey].setData(xHalWidth, yHalfWidth) + + # + # _isVisible = self._overlayCheckboxDict[analysisKey].isVisible() + # self._overlayPlotDict[channelIdx][analysisKey].setVisible(_isVisible) + diff --git a/sanpy/kym/interface/kymRoiClipsWidget.py b/sanpy/kym/interface/kymRoiClipsWidget.py new file mode 100644 index 00000000..902d43ec --- /dev/null +++ b/sanpy/kym/interface/kymRoiClipsWidget.py @@ -0,0 +1,352 @@ +from functools import partial +from typing import Optional + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from PyQt5 import QtGui, QtCore, QtWidgets +import pyqtgraph as pg + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymRoiClipsWidget(QtWidgets.QWidget): + """A widget to show peak clips. + """ + def __init__(self, kymRoiAnalysis : KymRoiAnalysis): + super().__init__(None) + + self._kymRoiAnalysis : KymRoiAnalysis= kymRoiAnalysis + + # so we can replot on gui change + self._channel = None + self._roiLabel = None + + # gui state + self._plusMinusMs = 100 + self._plotRaw = True + self._plotMean = True + self._plotPercent = True + + self._buildUI() + + def slot_selectRoi(self, channel : int, roiLabel : Optional[str]): + # logger.info(f'channel:{channel} roi:{roiLabel}') + + self._clearClips() + + # so we can _replot + self._channel = channel + self._roiLabel = roiLabel + + # plotPeakClips(self, yPlot, xPeakBins, secondsPerLine, plusMinusBins, doDiameter : bool = False) + + if roiLabel is None: + self._clearClips() + return + + self.plotPeakClips(channel, roiLabel) + + def _buildUI(self): + vBox = QtWidgets.QVBoxLayout() + + _topToolbar = self._buildTopToolbar() + vBox.addLayout(_topToolbar) + + # tabs will be peak clips from (f/f0, diameter, phase plots) + self._tabwidget = QtWidgets.QTabWidget() + vBox.addWidget(self._tabwidget) + + self.peakClipPlotItem = pg.PlotWidget() + self.peakClipPlotItem.setLabel("left", "Intensity (%)", units="") + self.peakClipPlotItem.setLabel("bottom", "Time (s)", units="") + + self.peakClipPlotItem.enableAutoRange() + self.peakClipPlotItem.setMouseEnabled(x=True, y=False) + self.peakClipPlotItem.hideButtons() # hide the little 'A' button to rescale axis + + # raw plot item to update + self.clipPlot = self.peakClipPlotItem.plot(name="clipPlot", + pen=pg.mkPen('#AAAAAA', width=2), + # symbol='o', + # size=4, + # brush=None, #pg.mkBrush(100, 255, 100, 220) + ) + + # plot mean + self.meanClipPlot = self.peakClipPlotItem.plot(name="meanClipPlot", + pen=pg.mkPen('r', width=2), + # symbol='o', + # size=4, + # brush=None, #pg.mkBrush(100, 255, 100, 220) + ) + + self._tabwidget.addTab(self.peakClipPlotItem, "Intensity") + # vBox.addWidget(self.peakClipPlotItem) + + self._buildPeakDiamPlot() + self._tabwidget.addTab(self.diameterClipPlotItem, "Diameter") + self._tabwidget.setCurrentIndex(0) + + # TODO: ADD + self._buildPhasePlot() + self._tabwidget.addTab(self.phaseClipPlotItem, "Phase") + + # + self.setLayout(vBox) + + def _buildPeakDiamPlot(self): + """Peak clips for diameter. + """ + self.diameterClipPlotItem = pg.PlotWidget() + self.diameterClipPlotItem.setLabel("left", "Diameter (%)", units="") + self.diameterClipPlotItem.setLabel("bottom", "Time (s)", units="") + + self.diameterClipPlotItem.enableAutoRange() + self.diameterClipPlotItem.setMouseEnabled(x=True, y=False) + self.diameterClipPlotItem.hideButtons() # hide the little 'A' button to rescale axis + + # raw plot item to update + self.diameterClipPlot = self.diameterClipPlotItem.plot(name="diameterClipPlot", + pen=pg.mkPen('#AAAAAA', width=2), + # symbol='o', + # size=4, + # brush=None, #pg.mkBrush(100, 255, 100, 220) + ) + + # plot mean + self.diameterMeanClipPlot = self.diameterClipPlotItem.plot(name="diameterMeanClipPlot", + pen=pg.mkPen('r', width=2), + # symbol='o', + # size=4, + # brush=None, #pg.mkBrush(100, 255, 100, 220) + ) + + return self.diameterClipPlotItem + + def _buildPhasePlot(self): + """Phase plot of (peak clip vs diam clip). + """ + self.phaseClipPlotItem = pg.PlotWidget() + self.phaseClipPlotItem.setLabel("left", "Diameter (%)", units="") + self.phaseClipPlotItem.setLabel("bottom", "Intensity", units="") + + self.phaseClipPlotItem.enableAutoRange() + self.phaseClipPlotItem.setMouseEnabled(x=True, y=False) + self.phaseClipPlotItem.hideButtons() # hide the little 'A' button to rescale axis + + # raw plot item to update + self.phaseClipPlot = self.phaseClipPlotItem.plot(name="phaseClipPlot", + pen=pg.mkPen('#AAAAAA', width=2), + # symbol='o', + # size=4, + # brush=None, #pg.mkBrush(100, 255, 100, 220) + ) + self.phaseClipPlot.setData([10, 20, 5], [20, 40, 30]) + + # plot mean + self.phaseMeanClipPlot = self.phaseClipPlotItem.plot(name="phaseMeanClipPlot", + pen=pg.mkPen('r', width=2), + # symbol='o', + # size=4, + # brush=None, #pg.mkBrush(100, 255, 100, 220) + ) + + return self.phaseClipPlotItem + + def _buildTopToolbar(self): + hBox = QtWidgets.QHBoxLayout() + hBox.setAlignment(QtCore.Qt.AlignLeft) + + aCheckBoxName = 'Raw' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setChecked(self._plotMean) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hBox.addWidget(aCheckBox) + + aCheckBoxName = 'Mean' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setChecked(self._plotMean) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hBox.addWidget(aCheckBox) + + aCheckBoxName = 'Y Percent' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setChecked(self._plotPercent) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hBox.addWidget(aCheckBox) + + # spin box for clip width (ms) + spinBoxName = 'Clip (ms)' + + aLabel = QtWidgets.QLabel(spinBoxName) + hBox.addWidget(aLabel) + + aSpinBox = QtWidgets.QSpinBox() + aSpinBox.setToolTip('Length of the clip (ms)') + aSpinBox.setRange(1,2000) + aSpinBox.setSingleStep(5) + aSpinBox.setValue(self._plusMinusMs) + aSpinBox.setKeyboardTracking(False) + aSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + hBox.addWidget(aSpinBox) + + return hBox + + def _on_spin_box(self, name, value): + if name == 'Clip (ms)': + self._plusMinusMs = value + self._replot() + + else: + logger.warning(f'did not understand name "{name}"') + + def _on_checkbox_clicked(self, name, value): + if value>0: + value = 1 + # logger.info(f'name:{name} value:{value}') + + if name == 'Raw': + self._plotRaw = value + self.clipPlot.setVisible(value) + elif name == 'Mean': + self._plotRaw = value + self.meanClipPlot.setVisible(value) + + elif name == 'Y Percent': + self._plotPercent = value + self._replot() + + def _replot(self): + self.plotPeakClips(self._channel, self._roiLabel) + + def plotPeakClips(self, channel, roiLabel): + """ + """ + if roiLabel is None: + self._clearClips() + return + + # from sanpy.kym.interface.kymRoiWidget import getChannelColor + _color = self._kymRoiAnalysis.getChannelColor(self._channel) + + kymRoi = self._kymRoiAnalysis.getRoi(roiLabel) # KymRoi + + clipDict = {} + numIntClips = None + numDiamClips = None + + for peakDetectionType in PeakDetectionTypes: + + xPlotClips, yPlotClips = kymRoi.getPeakClips(peakDetectionType, + channel=channel, + asPercent = self._plotPercent, + plusMinusMs=self._plusMinusMs) + + if xPlotClips is None or yPlotClips is None: + # no clips to plot + # self._clearClips() + continue + + # if xPlotClips is None or yPlotClips is None: + # logger.warning(f'xPlotClips is: {xPlotClips.shape}') + # logger.warning(f'xPlotClips is: {xPlotClips}') + if np.isnan(xPlotClips).all() or np.isnan(yPlotClips).all(): + # no clips to plot + self._clearClips() + continue + + detectThisTrace = kymRoi.getDetectionParams(channel, peakDetectionType)['detectThisTrace'] + + if peakDetectionType == PeakDetectionTypes.intensity: + + _xFlatten = xPlotClips.flatten() + _xFlatten = _xFlatten[~np.isnan(_xFlatten)] + + _yFlatten = yPlotClips.flatten() + _yFlatten = _yFlatten[~np.isnan(_yFlatten)] + + if len(_yFlatten) != len(_xFlatten): + logger.warning(f' x and y are not the same length: {len(_xFlatten)} != {len(_yFlatten)}') + # self.clipPlot.setData(xPlotClips.flatten(), yPlotClips.flatten(), pen=pg.mkPen(color=_color)) + + else: + # self.clipPlot.setData(xPlotClips.flatten(), yPlotClips.flatten(), pen=pg.mkPen(color=_color)) + self.clipPlot.setData(_xFlatten, _yFlatten, pen=pg.mkPen(color=_color)) + + yMean = np.nanmean(yPlotClips, axis=0) + self.meanClipPlot.setData(xPlotClips[0], yMean) # assuming xPlotClips[0] is not NaN + self.peakClipPlotItem.autoRange() + self.peakClipPlotItem.setLabel("left", detectThisTrace, units="") + numIntClips = len(yPlotClips) + + elif peakDetectionType == PeakDetectionTypes.diameter: + self.diameterClipPlot.setData(xPlotClips.flatten(), yPlotClips.flatten(), pen=pg.mkPen(color=_color)) + yMean = np.nanmean(yPlotClips, axis=0) + self.diameterMeanClipPlot.setData(xPlotClips[0], yMean) + self.diameterClipPlotItem.setLabel("left", detectThisTrace, units="") + self.diameterClipPlotItem.autoRange() + numDiamClips = len(yPlotClips) + + clipDict[peakDetectionType.value] = { + # 'xPlotClips' : xPlotClips, + 'yPlotClips' : yPlotClips, + } + + if numIntClips is not None and numDiamClips is not None and (numIntClips == numDiamClips): + xPhase = clipDict[PeakDetectionTypes.intensity.value]['yPlotClips'] + yPhase = clipDict[PeakDetectionTypes.diameter.value]['yPlotClips'] + + # same number of points + if len(xPhase[0]) == len(yPhase[0]): + + logger.warning(' plotting phase clips -->> -->>') + print(xPhase.flatten()) + print(yPhase.flatten()) + + self.phaseClipPlot.setData(xPhase.flatten(), yPhase.flatten()) + + detectThisTrace = kymRoi.getDetectionParams(channel, PeakDetectionTypes.intensity)['detectThisTrace'] + self.phaseClipPlotItem.setLabel("bottom", detectThisTrace, units="") + detectThisTrace = kymRoi.getDetectionParams(channel, PeakDetectionTypes.diameter)['detectThisTrace'] + self.phaseClipPlotItem.setLabel("left", detectThisTrace, units="") + + def _clearClips(self): + # intensity + self.clipPlot.setData([], []) + self.meanClipPlot.setData([], []) + + # diameter + self.diameterClipPlot.setData([], []) + self.diameterMeanClipPlot.setData([], []) + + # phase plot + self.phaseClipPlot.setData([], []) + + def keyPressEvent(self, event): + key = event.key() + + if key in [QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return]: + self._resetZoom() + + def _resetZoom(self, doEmit=True): + # self._kymRoiImageWidget.kymographPlot.autoRange(item=self._kymRoiImageWidget.myImageItem) + _currentTabIndex = self._tabwidget.currentIndex() + if _currentTabIndex == 0: + self.peakClipPlotItem.autoRange() + elif _currentTabIndex == 1: + self.diameterClipPlotItem.autoRange() + else: + logger.info(f'did not understand tab index {_currentTabIndex}') + \ No newline at end of file diff --git a/sanpy/kym/interface/kymRoiImageViewer.py b/sanpy/kym/interface/kymRoiImageViewer.py new file mode 100644 index 00000000..ee428045 --- /dev/null +++ b/sanpy/kym/interface/kymRoiImageViewer.py @@ -0,0 +1,94 @@ +from functools import partial + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSlider, QCheckBox +from PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtGui import QFont + +from PyQt5 import QtWidgets, QtCore +import pyqtgraph as pg + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, KymRoi, PeakDetectionTypes +from sanpy.kym.interface.kymRoiImageWidget import KymRoiImageWidget +from sanpy.kym.interface.imageViewer import ImageViewer + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymRoiImageViewer(QtWidgets.QWidget): + """A widget to show a kymRoiImage. + """ + def __init__(self, kymRoiAnalysis : KymRoiAnalysis): + super().__init__(None) + + self._kymRoiAnalysis = kymRoiAnalysis + + self._buildUI() + + # self.loadAnalysis() # load roi from analysis and display on image + + def _buildUI(self): + vLayout = QtWidgets.QVBoxLayout() + self.setLayout(vLayout) + + # the whole kym + # self._kymRoiImageWidget = KymRoiImageWidget(self._kymRoiAnalysis) + # vLayout.addWidget(self._kymRoiImageWidget) + channel = 0 + + # one img per roi + kymRoiImageDict = {} + for roiLabel, kymRoi in self._kymRoiAnalysis._roiDict.items(): + # add a pg.ROI + kymRoi:KymRoi = kymRoi + logger.info(f'adding OneKymRoi roiLabel:{roiLabel}') + detectionParameters = kymRoi.getDetectionParams(channel=channel, detectionType=PeakDetectionTypes.intensity) + imgData = kymRoi.getRoiImg(channel=0) + f0 = detectionParameters['f0 Value Percentile'] + oneKymRoi = ImageViewer(imgData, + secondsPerLine=self._kymRoiAnalysis.secondsPerLine, + umPerPixel=self._kymRoiAnalysis.umPerPixel, + f0=f0) + vLayout.addWidget(oneKymRoi) + kymRoiImageDict[roiLabel] = oneKymRoi + + # if roiLabel == '2': + # break + + # taken from kymRoiWidget + def loadAnalysis(self): + """Load and add each roi in _kymRoiAnalysis + """ + # logger.info(self._kymRoiAnalysis._roiDict.items()) + for _idx, (roiLabel, kymRoi) in enumerate(self._kymRoiAnalysis._roiDict.items()): + ltrb = kymRoi.getRect() + logger.info(f'adding roi {roiLabel} with rect {ltrb}') + + # add a pg.ROI + _pgRoi = self._kymRoiImageWidget._addRoi(kymRoi) # add roi to gui + + # doSelect = _idx == 0 + # if doSelect: + # # select the first roi + # self.updateRoiIntensityPlot(roiLabel, doAnalysis=False) + +if __name__ == '__main__': + path = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/sanpy-20250608/250225/ISAN/ISAN R1 LS1.tif.frames/250225 ISAN R1 LS1 c1.tif' + from sanpy.bAnalysis_ import bAnalysis + ba = bAnalysis(path) + imgData = ba.fileLoader.getTifData(channel=0) + logger.info(f'imgData:{imgData.shape}') + + kymRoiAnalysis = KymRoiAnalysis(path, imgData, loadAnalysis=True) + logger.info(f'kymRoiAnalysis:{kymRoiAnalysis.numRoi}') + + # run qt app + app = QtWidgets.QApplication([]) + + kymRoiImageViewer = KymRoiImageViewer(kymRoiAnalysis) + kymRoiImageViewer.show() + + app.exec_() diff --git a/sanpy/kym/interface/kymRoiImageWidget.py b/sanpy/kym/interface/kymRoiImageWidget.py new file mode 100644 index 00000000..248d8bb3 --- /dev/null +++ b/sanpy/kym/interface/kymRoiImageWidget.py @@ -0,0 +1,1189 @@ +from functools import partial +from typing import Optional + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QSlider, QCheckBox +from PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtGui import QPixmap, QImage + +from PyQt5 import QtGui, QtCore, QtWidgets +import pyqtgraph as pg + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, KymRoi +from sanpy.kym.kymRoiDetection import KymRoiDetection + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class SetScaleDialog(QtWidgets.QDialog): + """Dialog to set x/y kymograph scale. + + If set, analysis has to be redone. + """ + def __init__(self, secondsPerLine :float, umPerPixel : float, parent=None): + # logger.info(f'secondsPerLine:{secondsPerLine} umPerPixel:{umPerPixel}') + + super().__init__(parent) + + self.setWindowTitle("Set Kymograph Scale") + + QBtn = QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + + self.buttonBox = QtWidgets.QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + hLayout = QtWidgets.QHBoxLayout() + + xScaleLabel = QtWidgets.QLabel("ms/Line") + self.xScale = QtWidgets.QDoubleSpinBox() + self.xScale.setDecimals(5) + self.xScale.setSingleStep(0.01) + msPerLine = secondsPerLine * 1000 + self.xScale.setValue(msPerLine) + + yScaleLabel = QtWidgets.QLabel("Microns/Pixel") + self.yScale = QtWidgets.QDoubleSpinBox() + self.yScale.setDecimals(3) + self.yScale.setSingleStep(0.01) + self.yScale.setValue(umPerPixel) + + hLayout.addWidget(xScaleLabel) + hLayout.addWidget(self.xScale) + + hLayout.addWidget(yScaleLabel) + hLayout.addWidget(self.yScale) + + self.layout = QtWidgets.QVBoxLayout() + # message = QtWidgets.QLabel("Set Kymograph Scale") + # self.layout.addWidget(message) + + self.layout.addLayout(hLayout) + + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + def getResults(self) -> dict: + """Get values from user input.""" + xScale_ms_per_Line = self.xScale.value() # units are ms/line + yScale = self.yScale.value() + retDict = { + "secondsPerLine": xScale_ms_per_Line / 1000, + "umPerPixel": yScale, + } + return retDict + +class KymRoiImageWidget(QtWidgets.QWidget): + """Display an image kymograph for one kymRoiAnalysis. + """ + # signalSetChannel = QtCore.pyqtSignal(object) # (channel) + signalSelectRoi = QtCore.pyqtSignal(object, object) # (channel, roi label), can be None + signalRoiChanged = QtCore.pyqtSignal(object) # (roi label) + signalSetLineProfile = QtCore.pyqtSignal(object) # (int), line scan index + + def __init__(self, kymRoiAnalysis : KymRoiAnalysis, + detectThisTrace:str, + kymRoiWidget = None): + super().__init__(None) + + self._kymRoiAnalysis = kymRoiAnalysis + self._detectThisTrace = detectThisTrace + self._kymRoiWidget = kymRoiWidget + + self._currentChannel = 0 + + self.selectedRoi : Optional[str] = None + """The selected roi label str.""" + + self._minContrast = 0 + self._maxContrast = 0 + + self.roiList = {} + """Keys are str label, values are pg.ROI""" + + self.roiLabelList = {} + """Keys are str label, value are QLabel.""" + + self.imgData = self._kymRoiAnalysis.getImageChannel(self._currentChannel) + # this display the entire kym image, there is no f0, each roi has an f0 + # if self._detectThisTrace == 'f_f0': + # self.imgData = self.imgData / self.imgData.mean() + # elif self._detectThisTrace == 'df_f0': + # self.imgData = (self.imgData - self.imgData.mean()) / self.imgData.mean() + + self._buildUI() + + self.switchChannel(self._currentChannel + 1) + + # @property + # def imgData(self) -> np.ndarray: + # """Get image data for one image channel. + # """ + # return self._kymRoiAnalysis.getImageChannel(self._currentChannel) + + def onUserAddRoi(self, ltrbRoi = None): + """Add to backend with default ltrb + """ + + selectedRoiLabel = self.getSelectedRoiLabel() + + # get the current view to make roi visible + #ltrbRoi = [100, 200, 500, 0] + + # _rect = self.myImageItem.viewRect() + # logger.warning(f'nope _rect:{_rect}') + # _rect = self.kymographPlot.viewRect() + # logger.warning(f'nope kymographPlot (PlotItem) _rect:{_rect}') + + # _range is in PHYSICAL UNITS [ [xMin, xMax], [yMin, yMax]] + _range = self.kymographPlot.viewRange() + logger.warning(f'kymographPlot (PlotItem) _range:{_range}') + + if ltrbRoi is None: + _left = _range[0][0] / self._kymRoiAnalysis.secondsPerLine + _right = _range[0][1] / self._kymRoiAnalysis.secondsPerLine + _bottom = _range[1][0] / self._kymRoiAnalysis.umPerPixel + _top = _range[1][1] / self._kymRoiAnalysis.umPerPixel + _view_ltrb = [_left, _top, _right, _bottom] + _view_ltrb = [int(x) for x in _view_ltrb] + ltrbRoi = _view_ltrb + + logger.info(f'adding roi ltrbRoi:{ltrbRoi}') + + # add to backend + newKymRoi = self._kymRoiAnalysis.addROI(ltrbRoi, reuseRoiLabel=selectedRoiLabel) + + # add pg.ROI to GUI + roi = self._addRoi(newKymRoi) + + # select the new roi + self._selectRoi(roi) + + self.mySetStatusBar(f'Added ROI {newKymRoi}') + + return roi + + def onUserDeleteRoi(self, roiLabel:Optional[str] = None): + """Delete the selected pg.ROI from backend and gui. + """ + if roiLabel is None and self.selectedRoi is None: + logger.warning('no selected roi') + return + + if roiLabel is not None: + # delete the selected roi + self.selectedRoi = roiLabel + + # delete from backend + self._kymRoiAnalysis.deleteRoi(self.selectedRoi) + + # delete from gui + self._deleteRoi(self.selectedRoi) + + # after delete, no selection + self.selectedRoi = None + + self.signalSelectRoi.emit(self._currentChannel, None) + + def _deleteRoi(self, roiLabelStr : str): + + pgRoi = self.roiList[roiLabelStr] + + _roi = self.roiList.pop(roiLabelStr, None) + _ = self.roiLabelList.pop(roiLabelStr, None) + + self.refreshDiameterPlot([], [], []) + + self.view.scene().removeItem(pgRoi) + + return _roi + + def _addRoi(self, kymRoi : KymRoi) -> pg.ROI: + """Add an ROI to self.imageItem + + Add a kymRoi to the GUI (NOT THE BACKEND) + """ + + pos, size = kymRoi.getPosSize() + + rectRoi = pg.ROI( + pos=pos, + pen=pg.mkPen('m', width=2), + hoverPen=pg.mkPen('m', width=4), + handlePen=pg.mkPen('m', width=2), + handleHoverPen=pg.mkPen('m', width=4), + size=size, + parent=self.myImageItem, + # remaining params turn of things like click+drag -> movable and shift+drag -> resizable + movable=False, # do not allow drag (makes gui better) + rotatable=False, + resizable=False, + removable=False # we have gui to remove + ) + # self.myLineRoi.addScaleHandle((0,0), (1,1), name='topleft') # at origin + rectRoi.addScaleHandle( + (0.5, 0), (0.5, 1), name="bottom center" + ) # top center + rectRoi.addScaleHandle( + (0.5, 1), (0.5, 0), name="top center" + ) # bottom center + + # abb 20250624 removed + # rectRoi.addScaleHandle( + # (0, .5), (1, 0.5), name="left center" + # ) # bottom center + # rectRoi.addScaleHandle( + # (1, .5), (0, 0.5), name="right center" + # ) # bottom center + + rectRoi.setAcceptedMouseButtons(QtCore.Qt.MouseButton.LeftButton) + rectRoi.sigClicked.connect(self._on_roi_clicked) + rectRoi.sigRegionChangeFinished.connect(self._on_roi_changed) + # rectRoi.sigRemoveRequested.connect(self._on_remove_roi) + + # each roi has a label + roiLabelName = kymRoi.getLabel() + + # color does not match seaborn scatter + _color = 'c' # just use cyan + roiLabel = pg.TextItem(roiLabelName, color=_color) + roiLabel.setParentItem(rectRoi) + font = QtGui.QFont() + # font.setBold(True) + from sanpy.kym.interface.kymRoiWidget import KymRoiWidget + font.setPointSize(KymRoiWidget._pgAxisLabelFontSize) + roiLabel.setFont(font) + self.roiLabelList[roiLabelName] = roiLabel # actual pyqtgraph TextItem() + + self.roiList[roiLabelName] = rectRoi # actual pyqtgraph ROI() + + # 20241024, do not need this + # self.kymRoiList[rectRoi] = kymRoi # v2 backend roi + + return rectRoi + + def getKymRoi(self, roiLabel : str) -> KymRoi: + return self._kymRoiAnalysis.getRoi(roiLabel) + + def getRoiLabel(self, pgRoi : pg.ROI) -> str: + """Get the string label of a pg.ROI (for backend). + + Reverse lookup from pgRoi to dictionary key (roi label str) + """ + roiLabelStr = None + for _roiLabelStr, _pgRoi in self.roiList.items(): + if _pgRoi == pgRoi: + roiLabelStr = _roiLabelStr + break + + # roiLabel = self.roiLabelList[roi].toPlainText() + return roiLabelStr + + def getSelectedRoiLabel(self) -> Optional[str]: + return self.selectedRoi + + def getSelectedKymRoi(self) -> Optional[KymRoi]: + roiLabel = self.getSelectedRoiLabel() + if roiLabel is None: + return + return self.getKymRoi(roiLabel) + + # abb 202505 + def slot_roi_changed(self, roiLabelStr : str): + """roi has changed update pg roi. + + comming from KymRoiToolbar. + + abb 202505 + """ + pgRoi = self.roiList[roiLabelStr] + if pgRoi is None: + logger.warning(f'roiLabelStr:{roiLabelStr} not found in roiList') + return + + # update the pgRoi + kymRoi = self._kymRoiAnalysis.getRoi(roiLabelStr) # KymRoi + pos, size = kymRoi.getPosSize() + + update = False + finish = False + pgRoi.setPos(pos, update=update, finish=finish) + pgRoi.setSize(size, update=update, finish=finish) + pgRoi.stateChanged(finish=False) # update handles + + + logger.info(f' -->> signalRoiChanged roiLabelStr:"{roiLabelStr}"') + self.signalRoiChanged.emit(roiLabelStr) + + def _on_roi_changed(self, pgRoi : pg.ROI): + """User finished dragging the ROI + + Args: + event :pyqtgraph.graphicsItems.ROI.ROI + + Info: + theRect2:[0, 383, 5000, 17] + """ + + pos = pgRoi.pos() + size = pgRoi.size() + # logger.info(f'pg.ROI pos:{pos}') + # logger.info(f'pg.ROI size:{size}') + + # update the backend + roiLabel = self.getRoiLabel(pgRoi) + kymRoi = self._kymRoiAnalysis.getRoi(roiLabel) # KymRoi + + # abb 20250624, was commented? + # set the updated rect in backend + newRect = kymRoi.setRectPosSize(pos, size) + + # get the actual constrained roi + pos, size = kymRoi.getPosSize() + + + update = False + finish = False + pgRoi.setPos(pos, update=update, finish=finish) + pgRoi.setSize(size, update=update, finish=finish) + pgRoi.stateChanged(finish=False) # update handles + + logger.info(f'sanpy roi pos:{pos} size:{size}') + + roiLabelStr = self.getRoiLabel(pgRoi) + logger.info(f' -->> signalRoiChanged with event:"{roiLabelStr}"') + self.signalRoiChanged.emit(roiLabelStr) + + def _on_roi_clicked(self, event : pg.ROI): + """Respond to user selecting (clicking) an ROI. + + Parameters + ========== + event : pg.ROI + """ + self._selectRoi(event) + + def selectRoiFromLabel(self, roiLabelStr : str): + """Select an roi based on its text label (key). + + Used by parent. + """ + try: + pgRoi = self.roiList[roiLabelStr] + except (KeyError): + logger.warning(f'did not find roi label "{roiLabelStr}"') + return + self._selectRoi(pgRoi) + + def _selectRoi(self, pgRoi : Optional[pg.ROI] = None): + """ + Parameter + --------- + roi : ??? + The roi to select, None will deselect all roi. + """ + # if pgRoi is not None and pgRoi == self.selectedRoi: + # # do not re-select + # return + + # deselect all + pen = pg.mkPen('c', width=2) + for v in self.roiList.values(): + v.setPen(pen) + self.selectedRoi = None + + if pgRoi is not None: + # selected roi is yellow + roiLabel = self.getRoiLabel(pgRoi) + pen = pg.mkPen('yellow', width=2) + self.roiList[roiLabel].setPen(pen) + + self.selectedRoi = roiLabel + else: + roiLabel = None + + # refresh diameter + if roiLabel is None: + self.refreshDiameterPlot([], [], []) + else: + # timeSec = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'timeSec', self._currentChannel) + timeSec = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'Time (s)', self._currentChannel) + leftDiameterUm = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'Left Diameter (um)', self._currentChannel) + rightDiameterUm = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'Right Diameter (um)', self._currentChannel) + self.refreshDiameterPlot(timeSec, leftDiameterUm, rightDiameterUm) + + logger.warning(f' -->> signalSelectRoi.emit with roiLabel:"{roiLabel}"') + self.signalSelectRoi.emit(self._currentChannel, roiLabel) + + def _toggleROI(self, visible : bool): + """Toggle all roi on/off. + """ + # toggle labels + for label in self.roiLabelList.values(): + label.setVisible(visible) + + # toggle roi + for roi in self.roiList.values(): + roi.setVisible(visible) + + def setAutoContrast(self): + from sanpy.kym.kymUtils import getAutoContrast + _min, _max = getAutoContrast(self.imgData) # new 20240925, should mimic ImageJ + + logger.info(f'_min:{_min} _max:{_max}') + + self._minContrast = _min #np.min(self.imgData) + self._maxContrast = _max # int(np.max(self.imgData) / 2) + + # update gui + self.minContrastSlider.setValue(self._minContrast) + self.maxContrastSlider.setValue(self._maxContrast) + + def getImageRect(self): + """Get image rect with (x,y) scale. + + Used to display kym ImageItem + """ + left = 0 + top = self.imgData.shape[0] * self._kymRoiAnalysis.umPerPixel + right = self.imgData.shape[1] * self._kymRoiAnalysis.secondsPerLine + bottom = 0 + + width = right - left + height = top - bottom + + return left, bottom, width, height # x, y, w, h + + def _hoverEvent(self, event): + """Hover on image -> update status in QMainWindow + """ + if event.isExit(): + return + + xPos = event.pos().x() + yPos = event.pos().y() + + xPos = int(xPos) + yPos = int(yPos) + + try: + intensity = self.imgData[yPos, xPos] # flipped + except (IndexError) as e: + intensity = 'None' + + intensity = f'{xPos} {yPos} intensity:{intensity}' + + # logger.warning(f'todo: set on hover "{intensity}"') + # self.mySetStatusBar(intensity) + + def _buildTopToolbar(self) -> QtWidgets.QVBoxLayout: + vBoxLayout = QtWidgets.QVBoxLayout() + vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + + hLayout = QtWidgets.QHBoxLayout() + hLayout.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hLayout) + + # move to detection widget + # buttonName = 'Save Analysis' + # aButton = QtWidgets.QPushButton(buttonName) + # aButton.setToolTip('Save analysis for all roi(s)') + # aButton.clicked.connect( + # self.saveAnalysis + # ) + # hLayout.addWidget(aButton) + + # aCheckBoxName = 'ROIs' + # aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + # aCheckBox.setToolTip('Toggle ROIs on/off') + # aCheckBox.setChecked(True) # show by default + # aCheckBox.stateChanged.connect( + # partial(self._on_checkbox_clicked, aCheckBoxName) + # ) + # hLayout.addWidget(aCheckBox) + + buttonName = 'Add ROI' + aButton = QtWidgets.QPushButton(buttonName) + aButton.setToolTip('Add an ROI') + aButton.clicked.connect( + partial(self._on_button_click, buttonName) + ) + hLayout.addWidget(aButton) + + buttonName = 'Delete ROI' + aButton = QtWidgets.QPushButton(buttonName) + aButton.setToolTip('Delete selected ROI') + aButton.clicked.connect( + partial(self._on_button_click, buttonName) + ) + hLayout.addWidget(aButton) + + aCheckBoxName = 'ROI' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle roi plot on/off') + aCheckBox.setChecked(True) # show by default + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + aCheckBoxName = 'Contrast' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle contrast sliders') + aCheckBox.setChecked(False) # hidden by default + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + aCheckBoxName = 'Line Profile' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle contrast sliders') + aCheckBox.setChecked(False) # hidden by default + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + if self._kymRoiAnalysis._fakeScale: + aLabel = QtWidgets.QLabel('Fake Scale') + hLayout.addWidget(aLabel) + + # set channel + aName = 'Channel' + # aLabel = QtWidgets.QLabel(aName) + # hLayout.addWidget(aLabel) + + channelComboBox = QtWidgets.QComboBox() + _channelList = [f'Channel {str(_channel+1)}' for _channel in range(self._kymRoiAnalysis.numChannels)] + channelComboBox.addItems(_channelList) + channelComboBox.setCurrentIndex(0) # default to 0 + channelComboBox.currentTextChanged.connect( + partial(self._on_combobox, aName) + ) + hLayout.addWidget(channelComboBox) + + # second row + # hLayout1 = QtWidgets.QHBoxLayout() + # hLayout1.setAlignment(QtCore.Qt.AlignLeft) + # vBoxLayout.addLayout(hLayout1) + + # show intensity under cursor + # TODO: put this somewhere better + # self.hoverLabel = QtWidgets.QLabel(None) + # hLayout1.addWidget(self.hoverLabel, alignment=QtCore.Qt.AlignRight) + + return vBoxLayout + + def _buildContrastSliders(self) -> QtWidgets.QWidget: + + hBox = QtWidgets.QHBoxLayout() + + vBoxLeft = QtWidgets.QVBoxLayout() + vBoxLeft.setAlignment(QtCore.Qt.AlignTop) + vBoxRight = QtWidgets.QVBoxLayout() + vBoxRight.setAlignment(QtCore.Qt.AlignTop) + + hBox.addLayout(vBoxLeft) + hBox.addLayout(vBoxRight) + + # color popup + _colorList = ['Red', 'Green', 'Blue', 'Grey', 'Grey Invert', 'viridis', 'plasma', 'inferno'] + colorComboBox = QtWidgets.QComboBox() + colorComboBox.addItems(_colorList) + colorComboBox.setCurrentIndex(0) # default to Red + colorComboBox.currentTextChanged.connect( + partial(self.setColorMap) + ) + vBoxLeft.addWidget(colorComboBox) + + buttonName = 'Auto' + aButton = QtWidgets.QPushButton(buttonName) + aButton.clicked.connect( + partial(self._on_button_click, buttonName) + ) + vBoxLeft.addWidget(aButton) + + # image min/max + hBoxImgMinMax = QtWidgets.QHBoxLayout() + vBoxLeft.addLayout(hBoxImgMinMax) + + # + # contrast sliders + bitDepth = 8 + + # min contrast row + minContrastLayout = QtWidgets.QHBoxLayout() + + minLabel = QtWidgets.QLabel("Min") + minContrastLayout.addWidget(minLabel) + + self.minContrastSpinBox = QtWidgets.QSpinBox() + self.minContrastSpinBox.setEnabled(False) + self.minContrastSpinBox.setMinimum(0) + self.minContrastSpinBox.setMaximum(2**bitDepth) + minContrastLayout.addWidget(self.minContrastSpinBox) + + self.minContrastSlider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.minContrastSlider.setMinimum(0) + self.minContrastSlider.setMaximum(2**bitDepth) + self.minContrastSlider.setValue(0) + self.minContrastSlider.valueChanged.connect( + lambda val, name="minSlider": self._onContrastSliderChanged(val, name) + ) + minContrastLayout.addWidget(self.minContrastSlider) + + # image min + self.tifMinLabel = QtWidgets.QLabel(f'Img Min:{np.min(self.imgData)}') + minContrastLayout.addWidget(self.tifMinLabel) + + vBoxRight.addLayout(minContrastLayout) + + # max contrast row + maxContrastLayout = QtWidgets.QHBoxLayout() + + maxLabel = QtWidgets.QLabel("Max") + maxContrastLayout.addWidget(maxLabel) + + self.maxContrastSpinBox = QtWidgets.QSpinBox() + self.maxContrastSpinBox.setEnabled(False) + self.maxContrastSpinBox.setMinimum(0) + self.maxContrastSpinBox.setMaximum(2**bitDepth) + maxContrastLayout.addWidget(self.maxContrastSpinBox) + + self.maxContrastSlider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.maxContrastSlider.setMinimum(0) + self.maxContrastSlider.setMaximum(2**bitDepth) + self.maxContrastSlider.setValue(2**bitDepth) + self.maxContrastSlider.valueChanged.connect( + lambda val, name="maxSlider": self._onContrastSliderChanged(val, name) + ) + maxContrastLayout.addWidget(self.maxContrastSlider) + + # image max + self.tifMaxLabel = QtWidgets.QLabel(f'Max:{np.max(self.imgData)}') + maxContrastLayout.addWidget(self.tifMaxLabel) + + vBoxRight.addLayout(maxContrastLayout) + + # row for x/y scale + hScaleLayout = QtWidgets.QHBoxLayout() + hScaleLayout.setAlignment(QtCore.Qt.AlignRight) + vBoxRight.addLayout(hScaleLayout) + + self._imgBitDepth = QtWidgets.QLabel(f'dtype:{self.imgData.dtype}') + self._xScaleLabel = QtWidgets.QLabel(f'ms/line:{self._kymRoiAnalysis.secondsPerLine*1000}') + self._yScaleLabel = QtWidgets.QLabel(f'um/pixel:{self._kymRoiAnalysis.umPerPixel}') + + aName = 'Set Scale' + aButton = QtWidgets.QPushButton(aName) + aButton.setToolTip('Add an ROI') + aButton.clicked.connect( + partial(self._on_button_click, aName) + ) + hScaleLayout.addWidget(self._imgBitDepth) + hScaleLayout.addWidget(self._xScaleLabel) + hScaleLayout.addWidget(self._yScaleLabel) + hScaleLayout.addWidget(aButton) + + + # return as widget so we can call setVisible() + aWidget = QtWidgets.QWidget() + aWidget.setLayout(hBox) + + return aWidget + + def _setScaleDialog(self): + """Show a set scale dialog. + """ + secondsPerLine = self._kymRoiAnalysis.secondsPerLine + umPerPixel = self._kymRoiAnalysis.umPerPixel + mcd = SetScaleDialog(secondsPerLine, umPerPixel) + if mcd.exec(): + scaleDict = mcd.getResults() + logger.info(f'got from dialog scaleDict:{scaleDict}') + secondsPerLine = scaleDict['secondsPerLine'] + umPerPixel = scaleDict['umPerPixel'] + + # set new scale in backend + self._kymRoiAnalysis.header['secondsPerLine'] = secondsPerLine + self._kymRoiAnalysis.header['umPerPixel'] = umPerPixel + + # update labels + self._xScaleLabel.setText(str(f'ms/line:{self._kymRoiAnalysis.secondsPerLine*1000}')) + self._yScaleLabel.setText(str(f'um/pixel:{self._kymRoiAnalysis.umPerPixel}')) + + # update image + imageRect = self.getImageRect() # l,b,h,w + axisOrder = "row-major" + self.myImageItem.setImage(self.imgData, + axisOrder=axisOrder, + rect=imageRect + ) + + def _buildUI(self): + self.setContentsMargins(0, 0, 0, 0) + + # toolbar, contrast slider, then image + mainVBoxLayout = QtWidgets.QVBoxLayout() + mainVBoxLayout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(mainVBoxLayout) + + self._topToolbar = self._buildTopToolbar() # one row with file name, image params + mainVBoxLayout.addLayout(self._topToolbar) + + self._contrastSliders = self._buildContrastSliders() + self._contrastSliders.setVisible(False) + mainVBoxLayout.addWidget(self._contrastSliders) + + # kymograph + self.view = pg.GraphicsLayoutWidget() + mainVBoxLayout.addWidget(self.view) + + # pyqtgraph.graphicsItems.PlotItem + row = 0 + colSpan = 1 + rowSpan = 1 + self.kymographPlot = self.view.addPlot( + row=row, col=0, rowSpan=rowSpan, colSpan=colSpan + ) + """A PlotItem""" + + self.kymographPlot.setLabel("left", "Pixels (um)", units="") + self.kymographPlot.setLabel("bottom", "Time (s)", units="") + + # make the font a bit bigger + # _origFont = self.kymographPlot.getAxis("bottom").label.font() + # _origFont.setPointSize(18) + # self.kymographPlot.getAxis("bottom").label.setFont(_origFont) + # self.kymographPlot.getAxis("left").label.setFont(_origFont) + + self.kymographPlot.setDefaultPadding() + self.kymographPlot.enableAutoRange() + self.kymographPlot.setMouseEnabled(x=True, y=False) + self.kymographPlot.hideButtons() # hide the little 'A' button to rescale axis + self.kymographPlot.setMenuEnabled(False) # turn off right-click menu + + # switching to PyMapManager style contrast with setLevels() + imageRect = self.getImageRect() # l,b,h,w + self.myImageItem = pg.ImageItem(self.imgData, + axisOrder = "row-major", + rect=imageRect + ) # need transpose for row-major + + # logger.warning('--->>> tryin ColorBarItem') + # self.aColorBar = pg.ColorBarItem() + # self.aColorBar.setImageItem(self.myImageItem) + self.setColorMap('Green') # sets self.aColorBar which sets children (self.myImageItem) + + self.kymographPlot.addItem(self.myImageItem, ignorBounds=True) + + # redirect hover to self (to display intensity + self.myImageItem.hoverEvent = self._hoverEvent + + # left/right scatter on top of kymograph + self._overlayKymDict = {} + self._overlayKymDict['leftDiamOverlay'] = self.kymographPlot.plot(name="leftDiamOverlay", + pen=None, + symbol='o', + symbolPen=None, + symbolSize=5, + symbolBrush=pg.mkBrush(250, 0, 250, 200) # green + ) + self._overlayKymDict['rightDiamOverlay'] = self.kymographPlot.plot(name="rightDiamOverlay", + pen=None, + symbol='o', + symbolPen=None, + symbolSize=5, + symbolBrush=pg.mkBrush(0, 250, 250, 200) # red + ) + + # vertical line in kymographPlot to show selected line scan + self._kymLineScanLine = pg.InfiniteLine(pen=pg.mkPen(color='c', width=2)) + self.kymographPlot.addItem(self._kymLineScanLine) + + # line scan slider + self._lineScanSlider = QtWidgets.QSlider(orientation=QtCore.Qt.Orientation.Horizontal) + self._lineScanSlider.setMinimum(0) + self._lineScanSlider.setMaximum(self.imgData.shape[1]) + self._lineScanSlider.setValue(0) + self._lineScanSlider.valueChanged.connect( + partial(self._on_line_scan_slider, 'Line Scan Slider') + ) + mainVBoxLayout.addWidget(self._lineScanSlider) + + self._lineProfileWidget = LineProfileWidget(self) + self.signalSelectRoi.connect(self._lineProfileWidget.selectRoi) + self.signalRoiChanged.connect(self._lineProfileWidget.selectRoi) + self.signalSetLineProfile.connect(self._lineProfileWidget.slot_updateLineProfile) + self._lineProfileWidget.setVisible(False) + mainVBoxLayout.addWidget(self._lineProfileWidget) + + # update to line scan 0 + self._on_line_scan_slider('', 0) + + def _onContrastSliderChanged(self, value, name): + # logger.info(f'name:{name} value:{value}') + + if name == "minSlider": + self._minContrast = value + self.minContrastSpinBox.setValue(value) + elif name == "maxSlider": + self._maxContrast = value + self.maxContrastSpinBox.setValue(value) + + _levels = [self._minContrast, self._maxContrast] + self.myImageItem.setLevels(_levels, update=True) + # self.aColorBar.setLevels(_levels) + + def setLineScanSlider(self, lineScanNumber : int): + """Programatically set the selected line scan. + """ + + # line scan plot + secondsValue = lineScanNumber * self._kymRoiAnalysis.secondsPerLine + self._kymLineScanLine.setPos(secondsValue) + + # slider + self._lineScanSlider.setValue(lineScanNumber) + + # line profile + # self.signalSetLineProfile.emit(lineScanNumber) + + def _on_line_scan_slider(self, name, lineScanIdx): + # logger.info(f'{name} {lineScanIdx}') + + # the vertical line on the kym image + secondsValue = lineScanIdx * self._kymRoiAnalysis.secondsPerLine + self._kymLineScanLine.setPos(secondsValue) + + self.signalSetLineProfile.emit(lineScanIdx) + + # from sanpy.kym.kymRoiAnalysis import PeakDetectionTypes + _selectedRoi = self.getSelectedRoiLabel() + if _selectedRoi is None: + logger.warning(' -->> about') + return + logger.info(f'setting kymRoiAnalysis "Divide Line Scan": {lineScanIdx}') + # not the roi + # _detection = self._kymRoiAnalysis.getRoi(_selectedRoi).getDetectionParams(self._currentChannel, PeakDetectionTypes.intensity) + # _detection.setParam('Divide Line Scan', lineScanIdx) + + # the kymAnalysis + self._kymRoiAnalysis.setKymDetectionParam('Divide Line Scan', lineScanIdx) + + def _on_combobox(self, name, value): + """ + Parameters + ---------- + detectionDict : dict + Switches between multiple detection group boxes like (detect int, detect diam) + """ + + logger.info(f'"{name}" value:{value} {type(value)}') + + if name == 'Channel': + value = value.replace('Channel ', '') + value = int(value) + self.switchChannel(value) + + def _on_checkbox_clicked(self, name, value = None): + # if self._blockSlots: + # # logger.warning(f'_blockSlots -->> no update for {name} {value}') + # return + + if value > 0: + value = 1 + + # show/hide widgets + if name == 'Contrast': + self._contrastSliders.setVisible(value) + + elif name == 'ROI': + # toggle all roi + self._toggleROI(value) + + elif name == 'Line Profile': + self._lineProfileWidget.setVisible(value) + + else: + logger.warning(f'did not understand name "{name}"') + + def _on_button_click(self, name : str): + logger.info(f'name:{name}') + + if name == 'Add ROI': + self.onUserAddRoi() + + elif name =='Delete ROI': + self.onUserDeleteRoi() + + elif name == 'Auto': + # auto contrast + self.setAutoContrast() + + elif name == 'Set Scale': + self._setScaleDialog() + + else: + logger.warning(f'did not understand button "{name}"') + + def mySetStatusBar(self, statusStr : str): + """Set the status bar of a parent kymRoiWidget. + + Only exists during PyQt runtime (not in scripts). + """ + logger.info(statusStr) + if self._kymRoiWidget is not None: + self._kymRoiWidget.mySetStatusbar(statusStr) + + def refreshDiameterPlot(self, timeSec, leftDiameterUm, rightDiameterUm): + # left/right on kym image + self._overlayKymDict['leftDiamOverlay'].setData(timeSec, leftDiameterUm) + self._overlayKymDict['rightDiamOverlay'].setData(timeSec, rightDiameterUm) + + def setColorMap(self, colorMap : str): + """ + _colorList = ['Green', 'Red', 'Blue', 'Grey', 'Grey Invert', 'viridis', 'plasma', 'inferno'] + """ + + # cm is type pg.ColorMap + if colorMap == 'Green': + cm = pg.colormap.get('Greens_r', source='matplotlib') + elif colorMap == 'Red': + cm = pg.colormap.get('Reds_r', source='matplotlib') + elif colorMap == 'Blue': + cm = pg.colormap.get('Blues_r', source='matplotlib') + elif colorMap == 'Grey': + cm = pg.colormap.get('Greys_r', source='matplotlib') + elif colorMap == 'Grey Invert': + cm = pg.colormap.get('Greys', source='matplotlib') + elif colorMap == 'viridis': + cm = pg.colormap.get('viridis', source='matplotlib') + elif colorMap == 'plasma': + cm = pg.colormap.get('plasma', source='matplotlib') + elif colorMap == 'inferno': + cm = pg.colormap.get('inferno', source='matplotlib') + else: + logger.error(f'did not understand color map: {colorMap}') + return + + # logger.info(f'{colorMap} cm:{cm}') + + # self.aColorBar.setColorMap(cm) + self.myImageItem.setColorMap(cm) + + def switchChannel(self, channel : int): + """ + Parameters + ========== + channel : int + Image channel (user GUI is 1 based) + """ + channelIdx = channel-1 + + if channelIdx<0 or channelIdx>self._kymRoiAnalysis.numChannels: + logger.error(f'got channel:{channel} but channel has to be in range {1}..{self._kymRoiAnalysis.numChannels}') + return + + logger.info(f'channel:{channel} channelIdx:{channelIdx}') + + self._currentChannel = channelIdx + + # + # re-emit roi selection in new channel + # + selectedRoiLabel = self.getSelectedRoiLabel() + # logger.info(f're-selecting roi "{selectedRoi}"') + self.selectRoiFromLabel(selectedRoiLabel) + + imageRect = self.getImageRect() # l,b,h,w + axisOrder = "row-major" + self.myImageItem.setImage(self.imgData, + axisOrder=axisOrder, + rect=imageRect + ) + + # min/max in contrast slider + imgMax = np.max(self.imgData) + self.tifMinLabel.setText(f'Img Min:{np.min(self.imgData)}') + self.tifMaxLabel.setText(f'Img Max:{imgMax}') + # + self.minContrastSlider.setMaximum(imgMax) + self.minContrastSpinBox.setMaximum(imgMax) + # + self.maxContrastSlider.setMaximum(imgMax) + self.maxContrastSpinBox.setMaximum(imgMax) + + self.setAutoContrast() + + _levels = [self._minContrast, self._maxContrast] + self.myImageItem.setLevels(_levels, update=True) + + # detect button follow channel color + # from sanpy.kym.interface.kymRoiWidget import getChannelColor + detectButtonColor = self._kymRoiAnalysis.getChannelColor(self._currentChannel) + self.setColorMap(detectButtonColor) + + # self.signalSetChannel.emit(self._currentChannel) + + def slot_detectionChanged(self, detectionType : str, detectionDict : KymRoiDetection): + """Pass changes in diameter detection to child lineProfileWidget. + """ + self._lineProfileWidget.slot_detectionChanged(detectionType, detectionDict) + +class LineProfileWidget(QtWidgets.QWidget): + def __init__(self, kymRoiImageWidget : KymRoiImageWidget): + super().__init__(None) + + self._kymRoiImageWidget = kymRoiImageWidget + self._lineIndex = 0 + self._buildGui() + + @property + def currentChannel(self): + return self._kymRoiImageWidget._currentChannel + + def slot_detectionChanged(self, detectionType : str, detectionDict : KymRoiDetection): + # logger.info(f'detectionType:"{detectionType}"') + # kymRoi = self._kymRoiImageWidget.getSelectedKymRoi() + self.slot_updateLineProfile() + + def selectRoi(self, roiLabel : str): + # if roiLabel is None: + # self.lineProfilePlot.setData([], []) + # return + # kymRoi = self._kymRoiImageWidget.getKymRoi(roiLabel) + self.slot_updateLineProfile() + + def slot_updateLineProfile(self, lineIdx : int = None): + + kymRoi = self._kymRoiImageWidget.getSelectedKymRoi() + + if kymRoi is None: + self.lineProfilePlot.setData([], []) + return + + if lineIdx is not None: + self._lineIndex = lineIdx + + lineIdx = self._lineIndex + + channel = self._kymRoiImageWidget._currentChannel + + # pen for mean/std/2*std + _channelColor = self._kymRoiImageWidget._kymRoiAnalysis.getChannelColor(channel) + _penForChannel = pg.mkPen(color=_channelColor, width=2, style=QtCore.Qt.DashLine) + + from sanpy.kym.kymRoiAnalysis import PeakDetectionTypes + detectionParams = kymRoi.getDetectionParams(channel, PeakDetectionTypes.diameter) + stdThreshold = detectionParams['std_threshold_mult_diam'] + lineScanFraction = detectionParams['line_scan_fraction_diam'] # fraction of line for lef/right, 4 is 25% and 2 is 50% + lineInterptMult = detectionParams['line_interp_mult_diam'] # interpolate each line scan by this multiplyer + + xUm, lineProfile = kymRoi.getLineProfile(channel, lineIdx) + self.lineProfilePlot.setData(xUm, lineProfile) + + # left + leftStopBin = int(len(lineProfile)/lineScanFraction) + leftStopUm = leftStopBin / lineInterptMult * kymRoi.umPerPixel + # logger.info(f' leftStopUm:{leftStopUm} leftStopBins:{leftStopBin}') + leftMean = np.mean(lineProfile[0:leftStopBin]) + leftStd = np.std(lineProfile[0:leftStopBin]) + leftThreshold = leftMean + (leftStd * stdThreshold) + self.leftThreshold.setData([0, leftStopUm], [leftThreshold, leftThreshold]) + self.leftMean.setData([0, leftStopUm], [leftMean, leftMean]) + self.leftStd1.setData([0, leftStopUm], [leftMean+leftStd, leftMean+leftStd]) + self.leftStd2.setData([0, leftStopUm], [leftMean+2*leftStd, leftMean+2*leftStd]) + # color based on channel + self.leftMean.setPen(_penForChannel) + self.leftStd1.setPen(_penForChannel) + self.leftStd2.setPen(_penForChannel) + + # right + rightStartBin = len(lineProfile) - int(len(lineProfile)/lineScanFraction) + rightStartUm = rightStartBin / lineInterptMult * kymRoi.umPerPixel + # logger.info(f' rightStartUm:{rightStartUm} rightStartBins:{rightStartBin}') + rightMean = np.mean(lineProfile[rightStartBin:-1]) + rightStd = np.std(lineProfile[rightStartBin:-1]) + rightThreshold = rightMean + (rightStd * stdThreshold) + self.rightThreshold.setData([rightStartUm, len(lineProfile)/lineInterptMult*kymRoi.umPerPixel], [rightThreshold, rightThreshold]) + self.rightMean.setData([rightStartUm, len(lineProfile)/lineInterptMult*kymRoi.umPerPixel], [rightMean, rightMean]) + self.rightStd1.setData([rightStartUm, len(lineProfile)/lineInterptMult*kymRoi.umPerPixel], [rightMean+rightStd, rightMean+rightStd]) + self.rightStd2.setData([rightStartUm, len(lineProfile)/lineInterptMult*kymRoi.umPerPixel], [rightMean+2*rightStd, rightMean+2*rightStd]) + # color based on channel + self.rightMean.setPen(_penForChannel) + self.rightStd1.setPen(_penForChannel) + self.rightStd2.setPen(_penForChannel) + + def _buildGui(self): + vBoxPlot = QtWidgets.QVBoxLayout() + self.setLayout(vBoxPlot) + + self.lineProfilePlotWidget = pg.PlotWidget() + vBoxPlot.addWidget(self.lineProfilePlotWidget) + + self.lineProfilePlotWidget.setDefaultPadding() + self.lineProfilePlotWidget.enableAutoRange() + self.lineProfilePlotWidget.setMouseEnabled(x=True, y=False) + self.lineProfilePlotWidget.hideButtons() # hide the little 'A' button to rescale axis + + self.lineProfilePlotWidget.setLabel("left", "Intensity", units="") + self.lineProfilePlotWidget.setLabel("bottom", 'Distance (um)', units="") + + # the actual line profileplot + + # left + self.lineProfilePlot = self.lineProfilePlotWidget.plot(name="lineProfilePlot", + pen=pg.mkPen(color='c', width=3), + # symbol='o', + # brush=pg.mkBrush(100, 255, 100, 220), + ) + + self.leftMean = self.lineProfilePlotWidget.plot(name="leftMean", + pen=pg.mkPen(color='r', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + self.leftStd1 = self.lineProfilePlotWidget.plot(name="leftStd1", + pen=pg.mkPen(color='r', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + self.leftStd2 = self.lineProfilePlotWidget.plot(name="leftStd1", + pen=pg.mkPen(color='r', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + # build last so it is on top + self.leftThreshold = self.lineProfilePlotWidget.plot(name="leftThreshold", + pen=pg.mkPen(color='y', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + + # right + self.rightMean = self.lineProfilePlotWidget.plot(name="leftMean", + pen=pg.mkPen(color='r', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + self.rightStd1 = self.lineProfilePlotWidget.plot(name="leftStd1", + pen=pg.mkPen(color='r', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + self.rightStd2 = self.lineProfilePlotWidget.plot(name="leftStd1", + pen=pg.mkPen(color='r', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) + # build last so it is on top + self.rightThreshold = self.lineProfilePlotWidget.plot(name="leftThreshold", + pen=pg.mkPen(color='y', width=2, style=QtCore.Qt.DashLine), + # symbol='o', + # brush=pg.mkBrush(200, 0, 0, 220), + ) diff --git a/sanpy/kym/interface/kymRoiMetaDataWidget.py b/sanpy/kym/interface/kymRoiMetaDataWidget.py new file mode 100644 index 00000000..d983aba4 --- /dev/null +++ b/sanpy/kym/interface/kymRoiMetaDataWidget.py @@ -0,0 +1,53 @@ +from functools import partial + +from PyQt5 import QtGui, QtCore, QtWidgets +import numpy as np +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem +from PyQt5.QtCore import pyqtSignal + +from sanpy.kym.kymRoiMetaData import KymRoiMetaData +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class MetaDataWidget(QtWidgets.QWidget): + def __init__(self, kymRoiMetaData : KymRoiMetaData): + super().__init__() + + self._kymRoiMetaData = kymRoiMetaData + + self._buildUI() + + def _buildUI(self): + vBox = QtWidgets.QVBoxLayout() + vBox.setContentsMargins(0, 0, 0, 0) + vBox.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(vBox) + + for key, v in self._kymRoiMetaData.items(): + if not self._kymRoiMetaData.showInGui(key): + continue + + hBox = QtWidgets.QHBoxLayout() + hBox.setAlignment(QtCore.Qt.AlignLeft) + vBox.addLayout(hBox) + + aLabel = QtWidgets.QLabel(key) + hBox.addWidget(aLabel) + + _allowEdit = self._kymRoiMetaData.allowTextEdit(key) + if _allowEdit: + aLineEdit = QtWidgets.QLineEdit(v) + aLineEdit.editingFinished.connect( + partial(self.on_text_edit, aLineEdit, key) + ) + hBox.addWidget(aLineEdit) + + else: + vStr = str(v) + aLabel = QtWidgets.QLabel(vStr) + hBox.addWidget(aLabel) + + def on_text_edit(self, aWidget, key): + strValue = aWidget.text() + logger.info(f'key:"{key}" strValue:{strValue}') + self._kymRoiMetaData.setParam(key, strValue) \ No newline at end of file diff --git a/sanpy/kym/interface/kymRoiScatter.py b/sanpy/kym/interface/kymRoiScatter.py new file mode 100644 index 00000000..480e4b84 --- /dev/null +++ b/sanpy/kym/interface/kymRoiScatter.py @@ -0,0 +1,531 @@ +from functools import partial +from typing import Optional + +import pandas as pd + +from matplotlib.backends import backend_qt5agg +import matplotlib as mpl +import matplotlib.pyplot as plt + +import seaborn as sns +# sns.set_palette("colorblind") + +from PyQt5 import QtGui, QtCore, QtWidgets + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes +from sanpy.kym.kymRoiResults import KymRoiResults + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class simpleTableWidget(QtWidgets.QTableWidget): + def __init__(self, df : Optional[pd.DataFrame] = None): + super().__init__(None) + self._df = df + if self._df is not None: + self.setDf(self._df) + + def setDf(self, df : pd.DataFrame): + self._df = df + + self.setRowCount(df.shape[0]) + self.setColumnCount(df.shape[1]) + self.setHorizontalHeaderLabels(df.columns) + + for row in range(df.shape[0]): + for col in range(df.shape[1]): + item = QtWidgets.QTableWidgetItem(str(df.iloc[row, col])) + self.setItem(row, col, item) + +class SimpleRoiScatter(QtWidgets.QWidget): + """Plot a scatter plot from peak/diameter detection df. + """ + def __init__(self, kymRoiAnalysis : KymRoiAnalysis): + super().__init__(None) + + self._kymRoiAnalysis = kymRoiAnalysis + + # need two df for f/f0 and another for diameter + self._df = None # set this to context of (df/f0 or diameter) + # + self._dfIntensity = None + self._dfDiameter = None + + # columns in analysis df + self._columns = KymRoiResults.userAnalysisKeys # reduces analysis keys, just for the user + + # ignore some bookkeeping columns + + self._plotTypes = ['Scatter', 'Swarm', 'Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM', 'Histogram', 'Cumulative Histogram'] + + self._state = { + 'xStat': 'Peak Number', + 'yStat': 'Peak Height', + 'hue': 'ROI Number', + 'plotType': 'Swarm + Mean', + 'analysisType': 'Intensity', # either f/f0 or diameter (um) + 'makeSquare': False, + } + + # re-wire right-click (for entire widget) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + self._buildUI() + + # enable/disable combobox(es) based on plot type + self._updateGuiOnPlotType(self._state['plotType']) + + def slot_analysisChanged(self, channel, roi): + if not self.isVisible(): + return + logger.info(f'channel:{channel} roi:{roi}') + + # get 2x df, one for intensity and other for diameter + # we do not know that actual 'analyzethisTrace' within each, e.g. could be f/f0 or df/f0 + self._dfIntensity = self._kymRoiAnalysis.getDataFrame(channel, PeakDetectionTypes.intensity) # get full dataframe across all 'roi number' + self._dfDiameter = self._kymRoiAnalysis.getDataFrame(channel, PeakDetectionTypes.diameter) # get full dataframe across all 'roi number' + + # df = self._kymRoiAnalysis.getCombindedDataFrame(channel) # get full dataframe across all 'roi number' + + if self._state['analysisType'] == 'Intensity': + dfReplot = self._dfIntensity + elif self._state['analysisType'] == 'Diameter': + dfReplot = self._dfDiameter + + self.replot(dfReplot) + + def _refreshAnalysisType(self): + """Swap in correct df when user chooses an 'analysisType'. + """ + if self._state['analysisType'] == 'Intensity': + self._df = self._dfIntensity + elif self._state['analysisType'] == 'Diameter': + self._df = self._dfDiameter + else: + logger.error(f'did not understand analysisType ???') + + # logger.info('self._df is now:') + # print() + + def _buildUI(self): + + # this is dangerous, collides with self.mplWindow() + self.fig = mpl.figure.Figure() + # self.static_canvas = backend_qt5agg.FigureCanvas(self.fig) + self.static_canvas = backend_qt5agg.FigureCanvasQTAgg(self.fig) + self.static_canvas.setFocusPolicy( + QtCore.Qt.ClickFocus + ) # this is really tricky and annoying + self.static_canvas.setFocus() + # self.axs[idx] = self.static_canvas.figure.add_subplot(numRow,1,plotNum) + + self.gs = self.fig.add_gridspec( + 1, 1, left=0.1, right=0.9, bottom=0.1, top=0.9, wspace=0.05, hspace=0.05 + ) + + # redraw everything + self.static_canvas.figure.clear() + + self.axScatter = self.static_canvas.figure.add_subplot(self.gs[0, 0]) + + # despine top/right + self.axScatter.spines["right"].set_visible(False) + self.axScatter.spines["top"].set_visible(False) + + self.fig.canvas.mpl_connect("key_press_event", self.keyPressEvent) + + self.mplToolbar = mpl.backends.backend_qt5agg.NavigationToolbar2QT( + self.static_canvas, self.static_canvas + ) + + # put toolbar and static_canvas in a V layout + # plotWidget = QtWidgets.QWidget() + vLayoutPlot = QtWidgets.QVBoxLayout(self) + + self._topToolbar = self._buildTopToobar() + vLayoutPlot.addLayout(self._topToolbar) + + vLayoutPlot.addWidget(self.static_canvas) + vLayoutPlot.addWidget(self.mplToolbar) + #plotWidget.setLayout(vLayoutPlot) + + # + # raw and y-stat summary in tabs + self._tabwidget = QtWidgets.QTabWidget() + + self.rawTableWidget = simpleTableWidget(self._df) + self._tabwidget.addTab(self.rawTableWidget, "Raw") + + self.yStatSummaryTableWidget = simpleTableWidget() + self._tabwidget.addTab(self.yStatSummaryTableWidget, "Y-Stat Summary") + + self._tabwidget.setCurrentIndex(0) + + vLayoutPlot.addWidget(self._tabwidget) + + # + self.static_canvas.draw() + + @property + def state(self): + return self._state + + def copyTableToClipboard(self): + _tabIndex = self._tabwidget.currentIndex() + if _tabIndex == 0: + if self._df is None: + return '' + logger.info('=== copy raw to clipboard ===') + print(self._df) + self._df.to_clipboard(sep="\t", index=False) + _ret = 'Copied raw table to clipboard' + elif _tabIndex == 1: + logger.info('=== copy summary to clipboard ===') + print(self._dfYStatSummary) + self._dfYStatSummary.to_clipboard(sep="\t", index=False) + _ret = 'Copied y-stat-summary table to clipboard' + else: + logger.warning(f'did not understand tab: {_tabIndex}') + _ret = 'Did not copy, please select a table' + return _ret + + def _buildTopToobar(self): + vBoxLayout = QtWidgets.QVBoxLayout() + vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + + # row 1 + hBoxLayout = QtWidgets.QHBoxLayout() + hBoxLayout.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hBoxLayout) + + # plot type + aName = 'Plot Type' + aLabel = QtWidgets.QLabel(aName) + hBoxLayout.addWidget(aLabel) + + plotTypeComboBox = QtWidgets.QComboBox() + plotTypeComboBox.addItems(self._plotTypes) + _index = self._plotTypes.index('Swarm') + plotTypeComboBox.setCurrentIndex(_index) + plotTypeComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, aName) + ) + hBoxLayout.addWidget(plotTypeComboBox) + + # either f_f0 or diameter + aName = 'Analysis Type' + aComboBox = QtWidgets.QComboBox() + aComboBox.addItems(['Intensity', 'Diameter']) + _index = self._plotTypes.index('Swarm') + aComboBox.setCurrentIndex(0) # default to f/f0 + aComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, aName) + ) + hBoxLayout.addWidget(aComboBox) + + # + # hueName = 'Hue' + # hueLabel = QtWidgets.QLabel(hueName) + # hBoxLayout.addWidget(hueLabel) + + # _hueList = ['None', 'ROI Number', 'Peak Number'] + # self.hueComboBox = QtWidgets.QComboBox() + # self.hueComboBox.addItems(_hueList) + # self.hueComboBox.setCurrentIndex(1) # default to 'ROI Number' + # self.hueComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, hueName) + # ) + # hBoxLayout.addWidget(self.hueComboBox) + + # second row + hBoxLayout2 = QtWidgets.QHBoxLayout() + hBoxLayout2.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hBoxLayout2) + + # + xName = 'X-Stat' + xLabel = QtWidgets.QLabel(xName) + hBoxLayout2.addWidget(xLabel) + + self.xComboBox = QtWidgets.QComboBox() + self.xComboBox.addItems(self._columns) + _index = self._columns.index(self.state['xStat']) + self.xComboBox.setCurrentIndex(_index) + self.xComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, xName) + ) + hBoxLayout2.addWidget(self.xComboBox) + + # + yName = 'Y-Stat' + yLabel = QtWidgets.QLabel(yName) + hBoxLayout2.addWidget(yLabel) + + self.yComboBox = QtWidgets.QComboBox() + self.yComboBox.addItems(self._columns) + _index = self._columns.index(self.state['yStat']) + self.yComboBox.setCurrentIndex(_index) + self.yComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, yName) + ) + hBoxLayout2.addWidget(self.yComboBox) + + # + return vBoxLayout + + def _updateGuiOnPlotType(self, plotType): + """Enable/disable giu on plot type. + """ + # plot types turn on/off X-Stat + enableXStat = plotType == 'Scatter' + self.xComboBox.setEnabled(enableXStat) + + def _on_stat_combobox(self, name : str, value : str): + logger.info(f'name:{name} value:{value}') + + if name == 'Plot Type': + self.state['plotType'] = value + self._updateGuiOnPlotType(self._state['plotType']) + + elif name == 'Analysis Type': + self.state['analysisType'] = value + self._refreshAnalysisType() + + elif name == 'X-Stat': + self.state['xStat'] = value + + elif name == 'Y-Stat': + self.state['yStat'] = value + + # elif name == 'Hue': + # if value == 'None': + # value = None + # self.state['hue'] = value + + else: + logger.warning(f'did not understand "{name}"') + + self.replot() + + def _on_user_pick(self, event): + """ + event : matplotlib.backend_bases.PickEvent + """ + logger.info(event) + logger.info(f' event.artist:{event.artist}') # matplotlib.collections.PathCollection + logger.info(f' event.ind:{event.ind}') + + ind = event.ind + dfRow = self._df.loc[ind] + print(dfRow) + + def replot(self, df=None): + + if df is not None: + # logger.info('df is') + # print(df[self._columns]) + self._df = df + + df = self._df + xStat = self.state['xStat'] + yStat = self.state['yStat'] + hue = self.state['hue'] + plotType = self.state['plotType'] + # analysisType = self.state['analysisType'] # either f/f0 or diameter (um) + + # logger.info(f'plotType:{plotType}') + + # reduce to just one analysis type + # df = df[ df['Analysis Type'] == analysisType] + + dfGrouped = self.getGroupedDataframe(yStat) + self._dfYStatSummary = dfGrouped + + self.rawTableWidget.setDf(self._df) + self.yStatSummaryTableWidget.setDf(dfGrouped) + + # sns.set_palette() + numRoiNum = len(df['ROI Number'].unique()) + sns.set_palette("colorblind") + palette = sns.color_palette(n_colors=numRoiNum) + + # try: + if 1: + + # returns "matplotlib.axes._axes.Axes" + self.axScatter.cla() + + if plotType == 'Scatter': + self.axScatter = sns.scatterplot(data=df, + x=xStat, + y=yStat, + hue=hue, + palette=palette, + ax=self.axScatter, + picker=5) # return matplotlib.axes.Axes + + if self._state['makeSquare']: + # logger.info('making scatter plot square') + _xLim = self.axScatter.get_xlim() + _yLim = self.axScatter.get_ylim() + _min = min(_xLim[0], _yLim[0]) + _max = max(_xLim[1], _yLim[1]) + + # logger.info(f'_min:{_min} _max:{_max}') + self.axScatter.set_xlim([_min, _max]) + self.axScatter.set_ylim([_min, _max]) + + # draw a diagonal line + # Using transform=self.axScatter.transAxes, the supplied x and y coordinates are interpreted as axes coordinates instead of data coordinates. + self.axScatter.plot([0, 1], [0, 1], '--', transform=self.axScatter.transAxes) + + elif plotType in ['Swarm', 'Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + # picker does not work for stripplot + # fernando's favorite + + # reduce the brightness of raw data + if plotType in ['Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + alpha = 0.6 + # ['ci', 'pi', 'se', 'sd'] + if plotType == 'Swarm + Mean': + errorBar = None + elif plotType == 'Swarm + Mean + STD': + errorBar = 'sd' + elif plotType == 'Swarm + Mean + SEM': + errorBar = 'se' + else: + alpha = 1 + errorBar = None + + self.axScatter = sns.stripplot(data=df, + x='ROI Number', + y=yStat, + hue=hue, + alpha=alpha, + palette=palette, + legend=False, + ax=self.axScatter) + + # if errorBar is not None: + if plotType in ['Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + # overlay mean +- sem + errorbar = errorBar + markersize = 30 + sns.pointplot(data=df, x='ROI Number', y=yStat, hue=hue, + errorbar=errorbar, # can be 'se', 'sem', etc + # capsize=capsize, + linestyle='none', # do not connect (with line) between categorical x + marker="_", + markersize=markersize, + # markeredgewidth=3, + legend=False, + palette=palette, + ax=self.axScatter) + + elif plotType == 'Histogram': + self.axScatter = sns.histplot(data=df, + x=yStat, + hue=hue, + palette=palette, + ax=self.axScatter) + + elif plotType == 'Cumulative Histogram': + self.axScatter = sns.histplot(data=df, x=yStat, hue=hue, + element="step", + fill=False, + cumulative=True, + stat="density", + common_norm=False, + palette=palette, + ax=self.axScatter) + + else: + logger.warning(f'did not understand plot type: {plotType}') + + self.axScatter.figure.canvas.mpl_connect("pick_event", self._on_user_pick) + + self.static_canvas.draw() + + # except (ValueError) as e: + # logger.error(e) + + def keyPressEvent(self, event): + _handled = False + isMpl = isinstance(event, mpl.backend_bases.KeyEvent) + if isMpl: + text = event.key + logger.info(f'mpl key: "{text}"') + + doCopy = text in ["ctrl+c", "cmd+c"] + if doCopy: + self.copyTableToClipboard() + + def _contextMenu(self, pos): + logger.info('') + + contextMenu = QtWidgets.QMenu() + + makeSquareAction = QtWidgets.QAction('Make Square') + makeSquareAction.setCheckable(True) + makeSquareAction.setChecked(self._state['makeSquare']) + makeSquareAction.setEnabled(self._state['plotType'] == 'Scatter') + contextMenu.addAction(makeSquareAction) + + contextMenu.addSeparator() + contextMenu.addAction('Copy Stats Table ...') + + # show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + _ret = '' + actionText = action.text() + if action == makeSquareAction: + self._state['makeSquare'] = makeSquareAction.isChecked() + self.replot() + + elif actionText == 'Copy Stats Table ...': + _ret = self.copyTableToClipboard() + + def getGroupedDataframe(self, statColumn, groupByColumn = 'ROI Number'): + import numpy as np + from scipy.stats import variation + + aggList = ["count", "mean", "std", "sem", variation, "median", "min", "max"] + + if len(self._df)>0: + # get first row value + detectedTrace = self._df['Detected Trace'].iloc[0] + else: + detectedTrace = 'N/A' + + # aggDf = self._df.groupby(groupByColumn, as_index=False)[statColumn].agg(aggList) + dfDropNan = self._df.dropna(subset=[statColumn]) # drop rows where statColumn is nan + + try: + aggDf = dfDropNan.groupby(groupByColumn).agg({statColumn : aggList}) + except (TypeError) as e: + logger.error(f'groupByColumn "{groupByColumn}" failed e:{e}') + aggDf = dfDropNan + + aggDf.columns = aggDf.columns.droplevel(0) # get rid of statColumn multiindex + aggDf.insert(0, 'Stat', statColumn) # add column 0, in place + aggDf.insert(0, 'Detected Trace', detectedTrace) # + aggDf = aggDf.reset_index() # move groupByColum (e.g. 'ROI Number') from row index label to column + + # rename column 'variation' as 'CV' + aggDf = aggDf.rename(columns={'variation': 'CV'}) + + # round some columns + aggList = ["mean", "std", "sem", "CV", "median", "min", "max"] + for agg in aggList: + if agg == 'count': + continue + # logger.info(f'rounding agg:{agg}') + aggDf[agg] =round(aggDf[agg], 2) + + return aggDf + \ No newline at end of file diff --git a/sanpy/kym/interface/kymRoiSetF0Widget.py b/sanpy/kym/interface/kymRoiSetF0Widget.py new file mode 100644 index 00000000..329dfc82 --- /dev/null +++ b/sanpy/kym/interface/kymRoiSetF0Widget.py @@ -0,0 +1,237 @@ +from typing import Optional + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QSlider, QCheckBox, QSpinBox +from PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtGui import QFont + +from PyQt5 import QtGui, QtCore, QtWidgets +import pyqtgraph as pg + +from sanpy.interface.util import sanpyCursors + +from sanpy.kym.kymRoiDetection import KymRoiDetection +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class SetF0Widget(QtWidgets.QWidget): + """Class to display sum intensity to show and manually set f0. + """ + + signalUpdateF0 = QtCore.pyqtSignal(object, object, object) # (channel, roiLabel, new_f0_value) + + def __init__(self, kymRoiAnalysis : KymRoiAnalysis): + super().__init__() + + self._kymRoiAnalysis = kymRoiAnalysis + + self.xTrace = 'Time (s)' + self.yTrace = 'intDetrend' + + self._kymRoiDetection : KymRoiDetection = None + """Switches to one channel in slot_selectRoi(). + """ + + self._channelIdx : Optional[int] = None + self._roiLabel : Optional[str] = None + + self._buildUI() + + def slot_selectRoi(self, channelIdx : int, roiLabel : Optional[str]): + # logger.info(f'channelIdx:{channelIdx} roiLabel:{roiLabel}') + + if roiLabel is None: + # clear the plot + # yEmpty = [np.nan] * self._kymRoiAnalysis.numLineScans + logger.info('clearing f0 plot') + self.intensityPlotItem.setData(np.array([]), np.array([])) + + self._channelIdx = None + self._roiLabel = None + self._kymRoiDetection = None + + return + + self._channelIdx = channelIdx + self._roiLabel = roiLabel + + # backend KymRoi + kymRoi = self._kymRoiAnalysis.getRoi(roiLabel) + self._kymRoiDetection = kymRoi.getDetectionParams(channelIdx, PeakDetectionTypes.intensity) + + xPlot = kymRoi.getTrace(channelIdx, self.xTrace) + yPlot = kymRoi.getTrace(channelIdx, self.yTrace) + + self.intensityPlotItem.setData(xPlot, yPlot) + + # if channelIdx == 0: + # _color = '#DD1111' + # else: + # _color = 'Green' + _color = self._kymRoiAnalysis.getChannelColor(channelIdx) + + self.intensityPlotItem.setPen(pg.mkPen(color=_color)) + + self.intensityPlotWidget.setLabel("left", 'Sum Intensity', color=_color, units="") + + # set horizontal line for f0 percentile (pg.InfiniteLine) + f0_percentile = self._kymRoiDetection['f0 Value Percentile'] + if f0_percentile is not None: + # f0Value = kymRoi.getTrace(channelIdx, 'f0 Value Percentile') + self.rawIntensity_f0_line.setPos(f0_percentile) + # logger.error(f'infinite line has no set label, want to set to f0_percentile:{f0_percentile:.2f}') + # self.rawIntensity_f0_line.setLabel(f'f0={f0_percentile:.2f}') + self.rawIntensity_f0_line.setVisible(True) + else: + self.rawIntensity_f0_line.setVisible(False) + + # set cursor c to manual f0 + f0_manual = self._kymRoiDetection['f0 Value Manual'] + self._sanpyCursors._cursorC.setPos(f0_manual) + + def myContextMenu(self, event): + """Context menu for raw plot item. + + Used to set f0 from cursors. + See also _contextMenu() for global widget context menus. + """ + if self._kymRoiDetection is None: + return + + contextMenu = QtWidgets.QMenu() + + contextMenu.addAction('Full Zoom') + contextMenu.addSeparator() + + _cursorsShowing = self._sanpyCursors.cursorsAreShowing() + + cCursorValue = self._sanpyCursors._cCursorVal + cCursorValue = round(cCursorValue, 2) # round + + cursorAction = QtWidgets.QAction('Cursors') + cursorAction.setCheckable(True) + cursorAction.setChecked(_cursorsShowing) + contextMenu.addAction(cursorAction) + contextMenu.addSeparator() + + # + f0ManualPercentile = self._kymRoiDetection['f0 Type'] # in (Manual, Percentile) + logger.info(f'f0ManualPercentile:"{f0ManualPercentile}"') + _do_f0_Manual = f0ManualPercentile == 'Manual' + + f0Action = QtWidgets.QAction(f'Set f0 to {cCursorValue}') + f0Action.setEnabled(_cursorsShowing and _do_f0_Manual) + contextMenu.addAction(f0Action) + + action = contextMenu.exec_(event.globalPos()) + if action is None: + return + + # respond to menu selection + _ret = '' + actionText = action.text() + if actionText == 'Full Zoom': + self._resetZoom() + _ret = 'Reset zoom' + + elif actionText == 'Cursors': + _checked = cursorAction.isChecked() + # self._sanpyCursors.toggleCursors(_checked) + self._sanpyCursors.toggleCursors(_checked) + + elif action == f0Action: + _ret = f'User set f0 to {cCursorValue}' + logger.info(_ret) + + self._kymRoiDetection['f0 Value Manual'] = cCursorValue + logger.info(f' --->>> emit _channelIdx:{self._channelIdx} _roiLabel:{self._roiLabel} cCursorValue:{cCursorValue}') + self.signalUpdateF0.emit(self._channelIdx, self._roiLabel, cCursorValue) + + self.mySetStatusbar(_ret) + + def _resetZoom(self): + self.intensityPlotItem.autoRange() + + def _setXLink(self, widget): + self.intensityPlotWidget.setXLink(widget) + + def _buildUI(self): + vBoxPlot = QtWidgets.QVBoxLayout() + vBoxPlot.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(vBoxPlot) + + self.intensityPlotWidget = pg.PlotWidget() + vBoxPlot.addWidget(self.intensityPlotWidget) + + self.intensityPlotWidget.setDefaultPadding() + self.intensityPlotWidget.enableAutoRange() + self.intensityPlotWidget.setMouseEnabled(x=True, y=False) + self.intensityPlotWidget.hideButtons() # hide the little 'A' button to rescale axis + + # abb 20250501 + logger.warning('hard coding getChannelColor to 0') + _channelColor = self._kymRoiAnalysis.getChannelColor(0) + + self.intensityPlotWidget.setLabel("left", 'Sum Intensity', color=_channelColor, units="") + self.intensityPlotWidget.setLabel("bottom", 'Time (s)', units="") + + # get the original font and make it bigger + _origFont = self.intensityPlotWidget.getAxis("bottom").label.font() + from sanpy.kym.interface.kymRoiWidget import KymRoiWidget # just for font size + _origFont.setPointSize(KymRoiWidget._pgAxisLabelFontSize) + self.intensityPlotWidget.getAxis("bottom").label.setFont(_origFont) + self.intensityPlotWidget.getAxis("left").label.setFont(_origFont) + + # self.intensityPlotWidget.setXLink(self._kymRoiImageWidget.kymographPlot) + + # re-wire right-click (for entire widget) + # self.intensityPlotWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + # self.intensityPlotWidget.customContextMenuRequested.connect(self.myContextMenu) + self.intensityPlotWidget.contextMenuEvent = self.myContextMenu # rewire right-click to custom function + + self.intensityPlotItem = pg.PlotCurveItem(pen=pg.mkPen(_channelColor, width=2)) + self.intensityPlotItem.setData(x=self._kymRoiAnalysis.getXAxis(), y=[np.nan]*self._kymRoiAnalysis.numLineScans) + self.intensityPlotWidget.addItem(self.intensityPlotItem) + + # horizontal line to show f0 + self.rawIntensity_f0_line = pg.InfiniteLine(angle=0, + movable=False, + pen = pg.mkPen('c', width=2), + # label=f'f0={f0Value}', + label='f0 %:{value:.2f}', # hidden variable value is value of line + # labelText='f0={value:.2f}', + # labelOpts={'position':0.05}, + # labelOpts={'position':0.85, 'color':'c', 'font-size':'10pt'}, + labelOpts={'position':0.85, 'color':'c'}, + ) + self.intensityPlotWidget.addItem(self.rawIntensity_f0_line) + + # a vertical line to show selected line scan + self._kymLineScanLine = pg.InfiniteLine(pen=pg.mkPen(color='c', width=2)) + self.intensityPlotWidget.addItem(self._kymLineScanLine) + + # cursors for user to set f0 manual + # self._sanpyCursors._cursorC is a pg iinfinite line + self._sanpyCursors = sanpyCursors(self.intensityPlotWidget, + showCursorD=False, + cursorC_label='f0 Manual:',) + self._sanpyCursors._showCursorA = False + self._sanpyCursors._showCursorB = False + self._sanpyCursors.toggleCursors(True) # initially visible + self._sanpyCursors.signalCursorDragged.connect(self.mySetStatusbar) + + def slot_updateLineProfile(self, lineScanIdx : int): + """Update vertical line showing current selected line scan. + """ + # logger.info(f'lineScanIdx:{lineScanIdx}') + lineScanSec = lineScanIdx * self._kymRoiAnalysis.secondsPerLine + self._kymLineScanLine.setPos(lineScanSec) + + def mySetStatusbar(self, text : str): + # logger.info('') + pass \ No newline at end of file diff --git a/sanpy/kym/interface/kymRoiToolbar.py b/sanpy/kym/interface/kymRoiToolbar.py new file mode 100644 index 00000000..a06fb858 --- /dev/null +++ b/sanpy/kym/interface/kymRoiToolbar.py @@ -0,0 +1,192 @@ +from typing import Optional +from functools import partial + +from PyQt5 import QtCore, QtWidgets + +import qtawesome as qta +import numpy as np +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QPushButton, QLabel, QComboBox, QSpinBox, QDoubleSpinBox, QCheckBox, QGroupBox +from PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtGui import QFont + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis +from sanpy.kym.kymRoiDetection import KymRoiDetection + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymRoiGroupBox(QtWidgets.QGroupBox): + """ + A widget to display and edit an roi. + """ + signalRoiChanged = QtCore.pyqtSignal(object) # (roi label) + + def __init__(self, + kymRoiAnalysis : KymRoiAnalysis, + kymRoiDetection : KymRoiDetection, + groupName : str, + ): + + super().__init__(title=groupName) + + self._kymRoiAnalysis = kymRoiAnalysis + self._kymRoiDetection = kymRoiDetection + self._groupName = groupName + + self._roiLabel = None # set in slot_selectRoi() + + self._blockSlots = False + + self._buildUI() + + def slot_rio_changed(self, roiLabel : str): + logger.warning('hard coding channel=0 ???') + self.slot_selectRoi(channel=0, roiLabel=roiLabel) + + def slot_selectRoi(self, channel : int, roiLabel : Optional[str]): + logger.info(f'channel:{channel} roiLabel:{roiLabel}') + + # always setTitle() + _title = f'{self._groupName} ch {channel+1} roi {roiLabel}' + self.setTitle(_title) + + self._roiLabel = roiLabel + + if roiLabel is not None: + self.setEnabled(True) + + roi = self._kymRoiAnalysis.getRoi(roiLabel) + _rectDict = roi.getRectDict() + logger.info(f' roi _rectDict:{_rectDict} ') # pixels like [0, 512, 2000, 0] + + self._widgetDict['leftLabel'].setText(f"Left: {_rectDict['left']}") + self._widgetDict['topLabel'].setText(f"Top: {_rectDict['top']}") + self._widgetDict['rightLabel'].setText(f"Right: {_rectDict['right']}") + self._widgetDict['bottomLabel'].setText(f"Bottom: {_rectDict['bottom']}") + + # set value of spinbox + self._blockSlots = True # TODO: use context manager + _height = _rectDict['top'] - _rectDict['bottom'] + self._widgetDict['setHeight'].setValue(_height) + self._blockSlots = False + + else: + self.setEnabled(False) + + def _on_spin_box(self, name, value): + """ + Slot to handle spin box value changes. + """ + if self._blockSlots: + logger.warning('blocked') + + logger.info(f'spin box "{name}" value:{value}') + + if name == 'Height': + # set backend height of roi in pixels + self._kymRoiAnalysis.getRoi(roiLabel=self._roiLabel).setRoiHeightPixels(value) + + logger.info(f'-->> emit signalRoiChanged roi label:{self._roiLabel}') + self.signalRoiChanged.emit(self._roiLabel) + + def _buildUI(self): + """ + Build the UI for the group box. + """ + self.setEnabled(False) + + self._widgetDict = {} + + # self.setTitle(self._groupName) + # self.setCheckable(True) + # self.setChecked(True) + + # self.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + + # Create a layout for the group box + mainLayout = QtWidgets.QVBoxLayout() + mainLayout.setAlignment(QtCore.Qt.AlignTop) + self.setLayout(mainLayout) # main layout for self groupbox (inherited) + + + _hBox1 = QtWidgets.QHBoxLayout() + _hBox1.setAlignment(QtCore.Qt.AlignLeft) + mainLayout.addLayout(_hBox1) + + _leftLabel = QtWidgets.QLabel("Left") + _topLabel = QtWidgets.QLabel("Top") + _rightLabel = QtWidgets.QLabel("Right") + _bottomLabel = QtWidgets.QLabel("Bottom") + + _hBox1.addWidget(_leftLabel) + _hBox1.addWidget(_topLabel) + _hBox1.addWidget(_rightLabel) + _hBox1.addWidget(_bottomLabel) + + # leftLabel.setAlignment(QtCore.Qt.AlignLeft) + # leftLabel.setContentsMargins(self._contentMarginLeft, 0, 0, 0) + self._widgetDict['leftLabel'] = _leftLabel + self._widgetDict['topLabel'] = _topLabel + self._widgetDict['rightLabel'] = _rightLabel + self._widgetDict['bottomLabel'] = _bottomLabel + + # set height of roi in pixels + _hBox2 = QtWidgets.QHBoxLayout() + _hBox2.setAlignment(QtCore.Qt.AlignLeft) + mainLayout.addLayout(_hBox2) + + spinBoxName= 'Height' + _heightSpinBox = QtWidgets.QSpinBox() + _heightSpinBox.setRange(0, 10000) + _heightSpinBox.setValue(0) + _heightSpinBox.setSingleStep(1) + _heightSpinBox.setPrefix(f'{spinBoxName} ') + _heightSpinBox.setSuffix(" (pixels)") + _heightSpinBox.setAlignment(QtCore.Qt.AlignLeft) + # + _heightSpinBox.setKeyboardTracking(False) + _heightSpinBox.valueChanged.connect( + partial(self._on_spin_box, spinBoxName) + ) + # + self._widgetDict['setHeight'] = _heightSpinBox + _hBox2.addWidget(_heightSpinBox) + + # buttons for nudge roi left/right/up/down + _nudgeLeftButton = QtWidgets.QPushButton(qta.icon('mdi6.arrow-left'), '') + _nudgeLeftButton.setToolTip("Nudge Left") + _nudgeRightButton = QtWidgets.QPushButton(qta.icon('mdi6.arrow-right'), '') + _nudgeRightButton.setToolTip("Nudge Right") + _nudgeUpButton = QtWidgets.QPushButton(qta.icon('mdi6.arrow-up'), '') + _nudgeUpButton.setToolTip("Nudge Up") + _nudgeDownButton = QtWidgets.QPushButton(qta.icon('mdi6.arrow-down'), '') + _nudgeDownButton.setToolTip("Nudge Down") + _hBox2.addWidget(_nudgeLeftButton) + _hBox2.addWidget(_nudgeRightButton) + _hBox2.addWidget(_nudgeUpButton) + _hBox2.addWidget(_nudgeDownButton) + _hBox2.addStretch(1) + # connect buttons to slots + _nudgeLeftButton.clicked.connect( + partial(self.nudgeRoi, direction='left') + ) + _nudgeRightButton.clicked.connect( + partial(self.nudgeRoi, direction='right') + ) + _nudgeUpButton.clicked.connect( + partial(self.nudgeRoi, direction='up') + ) + _nudgeDownButton.clicked.connect( + partial(self.nudgeRoi, direction='down') + ) + + def nudgeRoi(self, direction : str): + """ + Nudge the roi in the specified direction. + """ + logger.info(f'nudgeRoi {self._roiLabel} {direction}') + # self._kymRoiDetection.nudgeRoi(roiLabel=roiLabel, direction=direction) + self._kymRoiAnalysis.getRoi(roiLabel=self._roiLabel).nudge(direction) + + logger.info(f'-->> emit signalRoiChanged roi label:{self._roiLabel}') + self.signalRoiChanged.emit(self._roiLabel) diff --git a/sanpy/kym/interface/kymRoiWidget.py b/sanpy/kym/interface/kymRoiWidget.py new file mode 100644 index 00000000..9dd44e64 --- /dev/null +++ b/sanpy/kym/interface/kymRoiWidget.py @@ -0,0 +1,987 @@ +import os +from functools import partial +import json +import subprocess + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.figure import Figure + +from PyQt5 import QtGui, QtCore, QtWidgets +import pyqtgraph as pg + +# import sanpy + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes +from sanpy.kym.kymRoiDetection import KymRoiDetection + +from sanpy.kym.interface.kymRoiImageWidget import KymRoiImageWidget +from sanpy.kym.interface.kymDetectionToolbar import KymDetectionGroupBox_Intensity, KymDetectionGroupBox_Diameter +from sanpy.kym.interface.kymRoiToolbar import KymRoiGroupBox # abb 202505 +from sanpy.kym.interface.kymDiamToolbar import KymDiameterToolbar +from sanpy.kym.interface.kymPlotWidget import KymPlotWidget # new 20241014 +from sanpy.kym.interface.kymRoiSetF0Widget import SetF0Widget +from sanpy.kym.interface.kymRoiMetaDataWidget import MetaDataWidget +from sanpy.kym.interface.kymRoiScatter import SimpleRoiScatter +from sanpy.kym.interface.kymRoiClipsWidget import KymRoiClipsWidget + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymRoiWidget(QtWidgets.QMainWindow): + signalRoiSumChanged = QtCore.pyqtSignal(int, object) # (channel, roiLabel) + signalRoiDiameterChanged = QtCore.pyqtSignal(int, object) # (channel, roiLabel) + # signalRoiSelected = QtCore.pyqtSignal(int, object) # (channel, roiLabel) + signalSwitchChannel = QtCore.pyqtSignal(object) # xxx + + _pgAxisLabelFontSize = 12 + """Specify font size for all pg plots. + """ + + def __init__(self, kymRoiAnalysis : KymRoiAnalysis): + super().__init__(None) + + self._kymRoiAnalysis = kymRoiAnalysis + + # this switches for each selected ROI + self._detectionParams : KymRoiDetection = KymRoiDetection(PeakDetectionTypes.intensity) + + self._detectionParamsDiameter : KymRoiDetection = KymRoiDetection(PeakDetectionTypes.diameter) + self._detectionParamsDiameter.setParam('Exponential Detrend', False) + + # self._blockSlots = False + + self._buildUI() + + # re-wire right-click (for entire widget) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + self.statusBar = QtWidgets.QStatusBar() + self.setStatusBar(self.statusBar) + + self.setWindowTitle(self.path) + + self.loadAnalysis() # load from folder with text file analysis (if it exists) + + # self.font_change() + + # def font_change(self): + # font, ok = QtWidgets.QFontDialog.getFont() + # if ok: + # for name, obj in self.getmembers(self): + # if isinstance(obj, QtWidgets.QLabel): + # obj.setFont(font) + + def getSelectedRoiLabel(self) -> str: + return self._kymRoiImageWidget.getSelectedRoiLabel() + + def slot_detectionChanged(self, detectionType : str, detectionDict : KymRoiDetection): + """Put this here so we can turn off detection when value changes. + """ + auto = detectionDict.getParam('Auto') + logger.info(f'detectionType:{detectionType} auto:{auto}') + if auto: + self.slot_doAnalysis(detectionType) + + def slot_doAnalysis(self, detectionType : str): + """Perform analysis based on which detection gui emitted the signal. + + Either detect in intensity or detect in diam. + """ + logger.info(f'detectionType: {detectionType}') + + if detectionType == 'Detect Peaks (Intensity)': + roiLabel = self._kymRoiImageWidget.getSelectedRoiLabel() + ok = self.analyzeRoi(roiLabel, PeakDetectionTypes.intensity) # analyze the selected ROI + if ok is not None: + self.updateRoiIntensityPlot(roiLabel, doAnalysis=False) + + elif detectionType == 'Detect Diameter': + # detect diameter from kym image + roiLabel = self._kymRoiImageWidget.getSelectedRoiLabel() + self.updateRoiDiameterPlot(roiLabel, doAnalysis=True) + logger.warning(f' -->> signalRoiDiameterChanged.emit with self.currentChannel:{self.currentChannel} roiLabel:{roiLabel}') + self.signalRoiDiameterChanged.emit(self.currentChannel, roiLabel) + + elif detectionType == 'Detect Peaks (Diameter)': + # detect peaks in 'Diameter (um)' + roiLabel = self._kymRoiImageWidget.getSelectedRoiLabel() + ok = self.analyzeRoi(roiLabel, PeakDetectionTypes.diameter) # analyze the selected ROI + if ok is not None: + # update overlay of diameter plot (e.g. peak, threshold, etc) + # roi = self.roiList.selectedRoi + # self.updateRoiDiameterPlot2(roiLabel) + + # roi = self.roiList.selectedRoi + # roiLabel = self.getRoiLabel(roi) + logger.warning(f' -->> signalRoiDiameterChanged.emit with self.currentChannel:{self.currentChannel} roiLabel:{roiLabel}') + self.signalRoiDiameterChanged.emit(self.currentChannel, roiLabel) + + else: + logger.error(f'did not understand detectionType:{detectionType}') + + @property + def currentChannel(self): + return self._kymRoiImageWidget._currentChannel + + def switchChannel(self, channel): + logger.info(f'channel:{channel} {type(channel)}') + + # not used + # self.signalSwitchChannel.emit(channel) + + # cancel selection + # self.selectRoi(roi = None) + + def isDirty(self): + # return self._kymRoiAnalysis._isDirty + return self._kymRoiAnalysis.isDirty() + + def closeEvent(self, event): + logger.info('veto close if peak analysis is dirty') + acceptAndContinue = True + if self.isDirty(): + logger.info(' kym peak analysis is dirty, prompt to save') + + from sanpy.interface.bDialog import yesNoCancelDialog + # saveDialog = yesNoCancelDialog('xxx is dirty', 'yyy save?') + + userResp = yesNoCancelDialog( + "There is analysis that is not saved.\nDo you want to save?" + ) + if userResp == QtWidgets.QMessageBox.Yes: + # self.saveFilesTable() + logger.warning('TODO: actually save kym roi peaks') + self.saveAnalysis() + acceptAndContinue = True + elif userResp == QtWidgets.QMessageBox.No: + acceptAndContinue = True + else: # userResp == QtWidgets.QMessageBox.Cancel: + acceptAndContinue = False + # + # return acceptAndContinue + if acceptAndContinue: + event.accept() + else: + event.ignore() + + def loadAnalysis(self): + """Load and add each roi in _kymRoiAnalysis + """ + # logger.info(self._kymRoiAnalysis._roiDict.items()) + for _idx, (roiLabel, kymRoi) in enumerate(self._kymRoiAnalysis._roiDict.items()): + ltrb = kymRoi.getRect() + logger.info(f'adding roi {roiLabel} with rect {ltrb}') + # self.addRoi(ltrb, doAnalysis=False, doSelect=False) + + # add a pg.ROI + _pgRoi = self._kymRoiImageWidget._addRoi(kymRoi) # add roi to gui + + doSelect = _idx == 0 + if doSelect: + # select the first roi + self.updateRoiIntensityPlot(roiLabel, doAnalysis=False) + + @property + def path(self): + return self._kymRoiAnalysis.path + + @property + def imgData(self) -> np.ndarray: + """Get image data for one image channel. + """ + return self._kymRoiAnalysis.getImageChannel(self.currentChannel) + + def mySetStatusbar(self, text : str): + """Update the status bar with some text. + """ + self.statusBar.showMessage(text) # ,2000) + + def slot_selectRoi(self, channel : int, roiLabel : str): + """On selection of an ROI, we set the f_f0 and diameter detection members. + + If roi is None then deselect all. + """ + logger.info(f'channel:{channel} roi:"{roiLabel}"') + + # 20250609 set image line scan slider + # roi = self._kymRoiAnalysis.getRoi(roiLabel) + # _detectParams = roi.getDetectionParams(channel, PeakDetectionTypes.intensity) + # _divideLinesScan = _detectParams['Divide Line Scan'] + _divideLinesScan = self._kymRoiAnalysis.getKymDetectionParam('Divide Line Scan') + logger.info(f' _divideLinesScan:{_divideLinesScan}') + if _divideLinesScan is not None: + self._kymRoiImageWidget.setLineScanSlider(_divideLinesScan) + + # if roiLabel is not None: + # # set our detection params to the selected roi + # self._detectionParams = self._kymRoiAnalysis.getDetectionParams(roiLabel, PeakDetectionTypes.intensity, self.currentChannel) + # self._detectionParamsDiameter = self._kymRoiAnalysis.getDetectionParams(roiLabel, PeakDetectionTypes.diameter, self.currentChannel) + + # self._updateDetectionParamGui() + + # self.updateRoiDiameterPlot(roiLabel, doAnalysis=False) + + # logger.warning(f' -->> signalRoiSelected.emit with self.currentChannel:{self.currentChannel} {type(roiLabel)}') + # self.signalRoiSelected.emit(self.currentChannel, roiLabel) + + def _updateDetectionParamGui(self): + """Update gui for KymDetectionToolbar. + """ + logger.info('') + # self._detectionToolbar.setDetectionDict(self._detectionParams) + # self._diamDetectionToolbar.setDetectionDict(self._detectionParamsDiameter) + # self._kymDiamDetectToolbar.setDetectionDict(self._detectionParamsDiameter) + + def _buildTopToolbar(self) -> QtWidgets.QVBoxLayout: + vBoxLayout = QtWidgets.QVBoxLayout() + vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + + hLayout = QtWidgets.QHBoxLayout() + hLayout.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hLayout) + + buttonName = 'Save Analysis' + aButton = QtWidgets.QPushButton(buttonName) + aButton.setToolTip('Save analysis for all roi(s)') + aButton.clicked.connect( + self.saveAnalysis + ) + hLayout.addWidget(aButton) + + # buttonName = 'Load' + # aButton = QtWidgets.QPushButton(buttonName) + # aButton.setToolTip('Load analysis for all roi(s)') + # aButton.clicked.connect( + # self.saveAnalysis + # ) + # hLayout.addWidget(aButton) + + # second row + # hLayout1 = QtWidgets.QHBoxLayout() + # hLayout1.setAlignment(QtCore.Qt.AlignLeft) + # vBoxLayout.addLayout(hLayout1) + + aCheckBoxName = 'Detection' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle detection panel on/off') + aCheckBox.setChecked(True) # show by default + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + # visual control of interface (not part of detection parameters) + aCheckBoxName = 'Intensity' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle Intensity plot on/off') + aCheckBox.setChecked(True) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + # visual control of interface (not part of detection parameters) + aCheckBoxName = 'f0' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle f0 plot on/off') + aCheckBox.setChecked(False) + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + aCheckBoxName = 'Clips' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle peak clips on/off') + aCheckBox.setChecked(False) # show by default + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + aCheckBoxName = 'Scatter' + aCheckBox = QtWidgets.QCheckBox(aCheckBoxName) + aCheckBox.setToolTip('Toggle scatter plot on/off') + aCheckBox.setChecked(False) # show by default + aCheckBox.stateChanged.connect( + partial(self._on_checkbox_clicked, aCheckBoxName) + ) + hLayout.addWidget(aCheckBox) + + # show intensity under cursor + # TODO: put this somewhere better + # self.hoverLabel = QtWidgets.QLabel(None) + # hLayout1.addWidget(self.hoverLabel, alignment=QtCore.Qt.AlignRight) + + return vBoxLayout + + def _on_combobox(self, name, value): + """ + Parameters + ---------- + detectionDict : dict + Switches between multiple detection group boxes like (detect int, detect diam) + """ + # if self._blockSlots: + # # logger.warning(f'_blockSlots -->> no update for {name} {value}') + # return + + logger.info(f'"{name}" value:{value}') + + if name == 'Channel': + self.switchChannel(value) + + def setWidgetVisible(self, name : str, visible : bool): + """Slot responding to child requesting to hid/show a widget. + """ + if name == 'f0': + # self.rawIntensityPlotItem.setVisible(visible) + self._setf0Widget.setVisible(visible) + + elif name == 'Intensity': + self.sumIntensityPlotItem.setVisible(visible) + + elif name == 'Diameter': + self.diameterPlotItem.setVisible(visible) + + else: + logger.warning(f'did not understand name:"{name}"') + + def _on_checkbox_clicked(self, name, value = None): + # if self._blockSlots: + # # logger.warning(f'_blockSlots -->> no update for {name} {value}') + # return + + if value > 0: + value = 1 + logger.info(f'"{name}" {value}') + + # show/hide widgets + # if name == 'Contrast': + # self._contrastSliders.setVisible(value) + + # if name == 'Sum Intensity (f0)': + # self.rawIntensityPlotItem.setVisible(value) + + # elif name == 'Diameter': + # self.diameterPlotItem.setVisible(value) + if name == 'Intensity': + self.sumIntensityPlotItem.setVisible(value) + + elif name == 'f0': + self._setf0Widget.setVisible(value) + + elif name == 'Clips': + self.peakClipsWidget.setVisible(value) + + elif name == 'Scatter': + self.simpleScatter.setVisible(value) + + # elif name == 'ROI': + # # toggle all roi + # self._kymRoiImageWidget._toggleROI(value) + + elif name == 'Detection': + self._tabwidget.setVisible(value) + + else: + logger.info(f'did not understand name:"{name}"') + + def _buildUI(self): + # self.setContentsMargins(0, 0, 0, 0) + + self.myVBoxLayout = QtWidgets.QVBoxLayout() + self.myVBoxLayout.setAlignment(QtCore.Qt.AlignTop) + + # needed for QMainWindow + mainWidget = QtWidgets.QWidget() + mainWidget.setLayout(self.myVBoxLayout) + self.setCentralWidget(mainWidget) + + mainHBox = QtWidgets.QHBoxLayout() # left is detection, right is plots + self.myVBoxLayout.addLayout(mainHBox) + + # vBoxPlot is buttons, then all the plots + vBoxPlot = QtWidgets.QVBoxLayout() + vBoxPlot.setAlignment(QtCore.Qt.AlignTop) + mainHBox.addLayout(vBoxPlot) + + _topToolbar = self._buildTopToolbar() + vBoxPlot.addLayout(_topToolbar) + + # 20241024 using KymRoiImageWidget + self._kymRoiImageWidget = KymRoiImageWidget(self._kymRoiAnalysis, self) + self._kymRoiImageWidget.signalSelectRoi.connect(self.slot_selectRoi) + self._kymRoiImageWidget.signalRoiChanged.connect(self.slot_roiChanged) + vBoxPlot.addWidget(self._kymRoiImageWidget) + + # new 20241109 (replaces rawIntensityPlotItem) + self._setf0Widget = SetF0Widget(self._kymRoiAnalysis) + self._setf0Widget.setVisible(False) # hidden by default + self._setf0Widget._setXLink(self._kymRoiImageWidget.kymographPlot) + self._setf0Widget.signalUpdateF0.connect(self.slot_f0_changed) # !!! + self._kymRoiImageWidget.signalSelectRoi.connect(self._setf0Widget.slot_selectRoi) + self.signalRoiSumChanged.connect(self._setf0Widget.slot_selectRoi) + self._kymRoiImageWidget.signalSetLineProfile.connect(self._setf0Widget.slot_updateLineProfile) + vBoxPlot.addWidget(self._setf0Widget) + + # + # 4) sum intensity of each line scan (actually our int/f0 plot) + self.sumIntensityPlotItem = KymPlotWidget(self._kymRoiAnalysis, + xTrace='Time (s)', + yTrace='f/f0', + peakDetectionType=PeakDetectionTypes.intensity) + # self.sumIntensityPlotItem.sumIntensityPlotItem.setLabel("left", 'Santana Intensity (f/f0)', units="") + self.sumIntensityPlotItem.setXLink(self._kymRoiImageWidget.kymographPlot) + self.sumIntensityPlotItem.signalCursorMove.connect(self.mySetStatusbar) + # + self.signalRoiSumChanged.connect(self.sumIntensityPlotItem.slot_selectRoi) + # self.signalRoiSelected.connect(self.sumIntensityPlotItem.slot_selectRoi) + self._kymRoiImageWidget.signalSetLineProfile.connect(self.sumIntensityPlotItem.slot_updateLineProfile) + self._kymRoiImageWidget.signalSelectRoi.connect(self.sumIntensityPlotItem.slot_selectRoi) + vBoxPlot.addWidget(self.sumIntensityPlotItem) + + # diameter plot + self.diameterPlotItem = KymPlotWidget(self._kymRoiAnalysis, + xTrace='Time (s)', + yTrace='Diameter (um)', + peakDetectionType=PeakDetectionTypes.diameter) + self.diameterPlotItem.setVisible(False) # off by default + # self.diameterPlotItem.sumIntensityPlotItem.setLabel("left", 'Dimaeter (um)', units="") + self.diameterPlotItem.setXLink(self._kymRoiImageWidget.kymographPlot) + self.diameterPlotItem.signalCursorMove.connect(self.mySetStatusbar) + # + self.signalRoiDiameterChanged.connect(self.diameterPlotItem.slot_selectRoi) + # self.signalRoiSelected.connect(self.diameterPlotItem.slot_selectRoi) + self._kymRoiImageWidget.signalSetLineProfile.connect(self.diameterPlotItem.slot_updateLineProfile) + self._kymRoiImageWidget.signalSelectRoi.connect(self.diameterPlotItem.slot_selectRoi) + vBoxPlot.addWidget(self.diameterPlotItem) + + # TODO: make 3-tabs (intensity, diameter, velocity) + self._tabwidget = QtWidgets.QTabWidget() + mainHBox.addWidget(self._tabwidget) + + # vbox to hold kymRoi and intensity detection + _tmpVBox = QtWidgets.QVBoxLayout() + _tmpVBox.setAlignment(QtCore.Qt.AlignTop) + _tmpWidget = QtWidgets.QWidget() + _tmpWidget.setLayout(_tmpVBox) + self._tabwidget.addTab(_tmpWidget, "Intensity") + + # abb 202505 + # toolbar to show/set roi manually (not with dragging) + # this will emit KymRoiGroupBox.signalRoiChanged + groupName = 'Edit ROI' + self._kymRoiToolbar = KymRoiGroupBox(self._kymRoiAnalysis, + self._detectionParams, + groupName=groupName, + ) + self._kymRoiImageWidget.signalSelectRoi.connect(self._kymRoiToolbar.slot_selectRoi) + self._kymRoiImageWidget.signalRoiChanged.connect(self._kymRoiToolbar.slot_rio_changed) + # abb 202505 bidirectional change of roi + self._kymRoiToolbar.signalRoiChanged.connect(self._kymRoiImageWidget.slot_roi_changed) + _tmpVBox.addWidget(self._kymRoiToolbar) + + # KymDetectionGroupBox_Intensity + groupName = 'Detect Peaks (Intensity)' + self._detectionToolbar = KymDetectionGroupBox_Intensity(self._kymRoiAnalysis, + self._detectionParams, + groupName=groupName, + detectThisTraceList=['f/f0', 'df/f0', 'Divided'] + ) + self._detectionToolbar.signalDetectionParamChanged.connect(self.slot_detectionChanged) + self._detectionToolbar.signalDetection.connect(self.slot_doAnalysis) + self._detectionToolbar.signalSetWidgetVisible.connect(self.setWidgetVisible) + self._kymRoiImageWidget.signalSelectRoi.connect(self._detectionToolbar.slot_selectRoi) + # self._tabwidget.addTab(self._detectionToolbar, "Intensity") + _tmpVBox.addWidget(self._detectionToolbar) + + # + # a toolbar (group) to detect diameter from kym image + + # hold 2x detection , diameter and diameter peaks + _diameter_widget = QtWidgets.QWidget() + _diameter_vbox = QtWidgets.QVBoxLayout() + _diameter_vbox.setAlignment(QtCore.Qt.AlignTop) + + _diameter_widget.setLayout(_diameter_vbox) + self._tabwidget.addTab(_diameter_widget, "Diameter") + + groupName = 'Detect Diameter' + kymRoiDetection = self._detectionParamsDiameter + self._kymDiamDetectToolbar = KymDiameterToolbar(self._kymRoiAnalysis, + kymRoiDetection, + groupName=groupName, + ) + self._kymDiamDetectToolbar.signalDetectionParamChanged.connect(self.slot_detectionChanged) + self._kymDiamDetectToolbar.signalDetection.connect(self.slot_doAnalysis) + self._kymDiamDetectToolbar.signalDetectionParamChanged.connect(self._kymRoiImageWidget.slot_detectionChanged) + self._kymRoiImageWidget.signalSelectRoi.connect(self._kymDiamDetectToolbar.slot_selectRoi) + _diameter_vbox.addWidget(self._kymDiamDetectToolbar) + + groupName = 'Detect Peaks (Diameter)' + self._diamDetectionToolbar = KymDetectionGroupBox_Diameter(self._kymRoiAnalysis, + self._detectionParamsDiameter, + groupName=groupName, + detectThisTraceList=['Diameter (um)', 'Left Diameter (um)', 'Right Diameter (um)']) + + # abb 202505 colin was using set enabled, start using set visible (vertical hight is too big on laptop) + # self._diamDetectionToolbar.setWidgetEnabled('Background Subtract', False) + # self._diamDetectionToolbar.setWidgetEnabled('Exponential Detrend', False) + # self._diamDetectionToolbar.setWidgetEnabled('f0 Type', False) + # self._diamDetectionToolbar.setWidgetEnabled('f0 Percentile', False) + self._diamDetectionToolbar.setWidgetVisible('Background Subtract', False) + self._diamDetectionToolbar.setWidgetVisible('Exponential Detrend', False) + self._diamDetectionToolbar.setWidgetVisible('f0 Type', False) + self._diamDetectionToolbar.setWidgetVisible('f0 Percentile', False) + + self._diamDetectionToolbar.signalDetectionParamChanged.connect(self.slot_detectionChanged) + self._diamDetectionToolbar.signalDetection.connect(self.slot_doAnalysis) + self._diamDetectionToolbar.signalSetWidgetVisible.connect(self.setWidgetVisible) + self._kymRoiImageWidget.signalSelectRoi.connect(self._diamDetectionToolbar.slot_selectRoi) + + # vBoxDetectionLayout_left.addWidget(self._diamDetectionToolbar) + _diameter_vbox.addWidget(self._diamDetectionToolbar) + + # + # coming soon + # + # _velocityWidget = QtWidgets.QWidget() + # self._tabwidget.addTab(_velocityWidget, "Velocity") + + # metadata + _metaDataWidget = MetaDataWidget(self._kymRoiAnalysis.header) + self._tabwidget.addTab(_metaDataWidget, "Meta Data") + + # + vBoxForClipsScatterTable = QtWidgets.QVBoxLayout() + + _tmpWidget = QtWidgets.QWidget() + _tmpWidget.setLayout(vBoxForClipsScatterTable) + + self.peakClipsWidget = KymRoiClipsWidget(self._kymRoiAnalysis) + self.peakClipsWidget.setVisible(False) + self.signalRoiSumChanged.connect(self.peakClipsWidget.slot_selectRoi) + self.signalRoiDiameterChanged.connect(self.peakClipsWidget.slot_selectRoi) + # self.signalRoiSelected.connect(self.peakClipsWidget.slot_selectRoi) + self._kymRoiImageWidget.signalSelectRoi.connect(self.peakClipsWidget.slot_selectRoi) + vBoxForClipsScatterTable.addWidget(self.peakClipsWidget) + + # + # simple scatter plot + self.simpleScatter = SimpleRoiScatter(self._kymRoiAnalysis) + self.simpleScatter.setVisible(False) + self.signalRoiSumChanged.connect(self.simpleScatter.slot_analysisChanged) + self.signalRoiDiameterChanged.connect(self.simpleScatter.slot_analysisChanged) + vBoxForClipsScatterTable.addWidget(self.simpleScatter) + + # + # file list as a dock + self.fileDock = QtWidgets.QDockWidget('Files') + self.fileDock.setWidget(_tmpWidget) + self.fileDock.setFeatures(QtWidgets.QDockWidget.NoDockWidgetFeatures | \ + QtWidgets.QDockWidget.DockWidgetVerticalTitleBar) + self.fileDock.setFloating(False) + self.fileDock.setTitleBarWidget(QtWidgets.QWidget()) + self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.fileDock) + + def slot_roiChanged(self, roiLabel : str): + """Slot responding to roi change. + """ + logger.info(f'roiLabel:"{roiLabel}"') + + # update the f0 plot + self.updateRoiIntensityPlot(roiLabel, doAnalysis=True) + + # update the diameter plot + # self.updateRoiDiameterPlot(roiLabel, doAnalysis=False) + + def slot_f0_changed(self, channelIdx, roiLabel, f0Value): + """Recieved from SetF0Widget (f0 is already updated in detection. + """ + self.updateRoiIntensityPlot(roiLabelStr=roiLabel, doAnalysis=True) + + def analyzeRoiDiam(self, roiLabel : str): + + logger.info(' === WIDGET PERFORMING ROI ANALYSIS ===> diam') + + # this creates 3x traces (left, right, diameter) + # self.roiList.kymRoiList[roi].detectDiam(self.currentChannel) + self._kymRoiAnalysis.detectDiam(roiLabel, self.currentChannel) + + def analyzeRoi(self, roiLabelStr : str, peakDetectionType : PeakDetectionTypes): + """Analyze one roi e.g. detect peaks. + """ + + if roiLabelStr is None: + logger.info('please select an roi to analyze') + self.mySetStatusbar('please select an roi to analyze') + return + + logger.info(f' === WIDGET PERFORMING ROI ANALYSIS ===> peakDetectionType:{peakDetectionType}') + + # ok = self.roiList.kymRoiList[roi].peakDetect(self.currentChannel, kymRoiDetection=kymRoiDetection, verbose=False) + kymRoi = self._kymRoiAnalysis.getRoi(roiLabelStr) + ok = kymRoi.peakDetect(self.currentChannel, peakDetectionType, verbose=False) + + return ok + + def saveAnalysis(self): + """Save all peak analysis into one csv file. + + This includes a header with roi [l,t,r,b] and detection parameters used. + """ + + _saved = self._kymRoiAnalysis.saveAnalysis() + + if _saved: + self.mySetStatusbar(f'Saved analysis for {self.path}') + else: + self.mySetStatusbar('Nothing to save') + + def _old_update_fo_plot(self, roiLabelStr : str): + + self.rawIntensityPlot.setData([], []) + + if roiLabelStr is None: + return + + channel = self.currentChannel + + timeSec = self._kymRoiAnalysis.getAnalysisTrace(roiLabelStr, 'Time (s)', channel) + intDetrend = self._kymRoiAnalysis.getAnalysisTrace(roiLabelStr, 'intDetrend', channel) + + logger.error(f'timeSec:{len(timeSec)} intDetrend:{len(intDetrend)}') + + # TODO: refactor to not use None + if timeSec is None or intDetrend is None: + logger.error('did not find timeSec or intDetrend') + return + + self.rawIntensityPlot.setData(timeSec, intDetrend) + + # f0Value = self._detectionParams['f0 Value'] + _detection = self._kymRoiAnalysis.getRoi(roiLabelStr).getDetectionParams(channel, PeakDetectionTypes.intensity) + f0Value = _detection['f0 Value'] + self.rawIntensity_f0_line.setPos(round(f0Value,2)) + + self._rawPlotCursors._showInView() + + def updateRoiDiameterPlot(self, roiLabel : str, doAnalysis=True): + """Analyze and then update diameter from kym image. + + This generates (left, right, and diam. + Diam is then peak detected. + """ + # left/right on kym image + # self._overlayKymDict['leftDiamOverlay'].setData([], []) + # self._overlayKymDict['rightDiamOverlay'].setData([], []) + + if roiLabel is None: + self._kymRoiImageWidget.refreshDiameterPlot([], [], []) + return + + # + # find left/right diam from kymograph + # 20241027, moving all analysis into (new roi, move roi, change detection params) + if doAnalysis: + self.analyzeRoiDiam(roiLabel) + + # + # update plots + # timeSec = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'timeSec', self.currentChannel) + timeSec = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'Time (s)', self.currentChannel) + leftDiameterUm = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'Left Diameter (um)', self.currentChannel) + rightDiameterUm = self._kymRoiAnalysis.getAnalysisTrace(roiLabel, 'Right Diameter (um)', self.currentChannel) + + if leftDiameterUm is None: + logger.info('no diameter to plot') + return + + # left/right on kym image + # self._overlayKymDict['leftDiamOverlay'].setData(timeSec, leftDiameterUm) + # self._overlayKymDict['rightDiamOverlay'].setData(timeSec, rightDiameterUm) + logger.info('REFRESHING KYM DIAM PLOT') + self._kymRoiImageWidget.refreshDiameterPlot(timeSec, leftDiameterUm, rightDiameterUm) + + def updateRoiIntensityPlot(self, roiLabelStr : str, doAnalysis=True): + """Update f/f0 intensity plot when user adjusts roi. + """ + + # perform analysis + # logger.warning(f'turned off auto analysis - implementing "Analyze" button doAnalysis:{doAnalysis}') + if doAnalysis: + ok = self.analyzeRoi(roiLabelStr, PeakDetectionTypes.intensity) + if ok is None: + logger.error('did not perform analysis') + return + + logger.warning(f' -->> signalRoiSumChanged.emit with currentChannel:{self.currentChannel} roiLabelStr:"{roiLabelStr}"') + self.signalRoiSumChanged.emit(self.currentChannel, roiLabelStr) + + # + # update other widgets + # + + # raw intensity plot (to manually set f0) + # self.update_fo_plot(roiLabelStr) + + def _resetZoom(self, doEmit=True): + + # order matter, do sum then image + # + # self.sumIntensityPlotItem.autoRange() # item=self._roiIntensityPlot[roi] + # self.diameterPlotItem.autoRange() + + self._kymRoiImageWidget.kymographPlot.autoRange(item=self._kymRoiImageWidget.myImageItem) + + def keyReleaseEvent(self, event): + + key = event.key() + isShift = key == QtCore.Qt.Key_Shift + + if isShift: + # default is x-zoom + self._kymRoiImageWidget.kymographPlot.setMouseEnabled(x=True, y=False) + self.sumIntensityPlotItem.setMouseEnabled(x=True, y=False) + # self.rawIntensityPlotItem.setMouseEnabled(x=True, y=False) + self.diameterPlotItem.setMouseEnabled(x=True, y=False) + + def keyPressEvent(self, event): + """Respond to user key press. + + Parameters + ---------- + event : PyQt5.QtGui.QKeyEvent + """ + key = event.key() + text = event.text() + + isShift = event.modifiers() == QtCore.Qt.ShiftModifier + isAlt = event.modifiers() == QtCore.Qt.AltModifier + isCtrl = event.modifiers() == QtCore.Qt.ControlModifier + + logger.info(f'key:{key} text:{text} isCtrl:{isCtrl} isAlt:{isAlt} isShift:{isShift}') + + if isShift: + # default is x-zoom + # self.kymographPlot.setMouseEnabled(x=True, y=False) + # switch it to y-zoom + self._kymRoiImageWidget.kymographPlot.setMouseEnabled(x=False, y=True) + self.sumIntensityPlotItem.setMouseEnabled(x=False, y=True) + # self.rawIntensityPlotItem.setMouseEnabled(x=False, y=True) + self.diameterPlotItem.setMouseEnabled(x=False, y=True) + + if key in [QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return]: + self._resetZoom() + + elif key == QtCore.Qt.Key.Key_Escape: + self._kymRoiImageWidget._selectRoi(None) + + elif key in [QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace]: + #self.removeSelectedRoi() + self._kymRoiImageWidget.onUserDeleteRoi() + + elif key in [QtCore.Qt.Key_Plus, QtCore.Qt.Key_Equal]: + self._kymRoiImageWidget.onUserAddRoi() + + # elif key in [QtCore.Qt.Key_C] and isCtrl: + # self.simpleScatter.copyTableToClipboard() + + elif isCtrl and key == QtCore.Qt.Key_S: + self.saveAnalysis() + + # elif key == QtCore.Qt.Key_S: + # self.savePlotItemAs() + + else: + logger.warning('did not understand user key press') + + def savePlotItemAs(self, plotItem : pg.graphicsItems.PlotItem, name : str): + """Save a plot item to file. + """ + import pyqtgraph.exporters + # filename = 'filename.png' + # filename = 'filename.pdf' # does not work + # filename = 'filename.tif' + # filename = 'filename.svg' + + # roi = self.roiList.selectedRoi + # if roi is None: + # return + + saveFolder = self._kymRoiAnalysis._getSaveFolder(createFolder=True) + + _, _file = os.path.split(self.path) + _file, _ = os.path.splitext(_file) + _file = _file + '-' + name + '.png' + savePath = os.path.join(saveFolder, _file) + + logger.info(f'saving {name} to {savePath}') + + exporter = pg.exporters.ImageExporter(plotItem) + exporter.export(savePath) + _ret = f'Saved "{name}" to {savePath}' + return _ret + + def _contextMenu(self, pos): + """Context menu for entire widget. + + See also myRawContextMenu. + """ + # logger.info('') + + # build menu + contextMenu = QtWidgets.QMenu() + contextMenu.addAction('Full Zoom') + contextMenu.addSeparator() + + # toggle kym image + _toggleKymAction = contextMenu.addAction('Kymograph Image') + _toggleKymAction.setCheckable(True) + _toggleKymAction.setChecked(self._kymRoiImageWidget.isVisible()) + + # toggle intensity plot + _toggleIntensity = contextMenu.addAction('Intensity Plot') + _toggleIntensity.setCheckable(True) + _toggleIntensity.setChecked(self.sumIntensityPlotItem.isVisible()) + + # a menu to select an roi (use this when they visually overlap) + contextMenu.addSeparator() + for kymRoi in self._kymRoiAnalysis: + roiLabelText = kymRoi.getLabel() + contextMenu.addAction(f'Select ROI: {roiLabelText}') + + # set santana norm line scan + contextMenu.addSeparator() + _setSantanaNormLine = contextMenu.addAction('Set Santana Norm Scan') + + # show analysis folder + contextMenu.addSeparator() + _showAnalysisFolder = contextMenu.addAction('Show Analysis Folder') + + # paste rois from clipboad + # check that we have rois on the clipboard + app = QtWidgets.QApplication.instance() + _json = app.clipboard().text() + try: + _dict = json.loads(_json) + except(json.decoder.JSONDecodeError) as e: + # logger.error(f'clipboard does not contain roi json:\n{_json}') + _dict = {} + contextMenu.addSeparator() + # copy rois to clipboad + _copyRois = contextMenu.addAction('Copy ROIs to Clipboard') + _pasteRois = contextMenu.addAction('Paste ROIs from Clipboard') + _pasteRois.setEnabled(len(_dict) > 0) + + # show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + # respond to menu selection + _ret = '' + actionText = action.text() + if actionText == 'Full Zoom': + self._resetZoom() + _ret = 'Reset zoom' + + # elif actionText == 'Cursors': + # _checked = action.isChecked() + # self._sanpyCursors.toggleCursors(_checked) + # # self._rawPlotCursors.toggleCursors(_checked) + + # elif actionText == 'Save Kym Image ...': + # _ret = self.savePlotItemAs(self.kymographPlot, 'kym-image') + + # elif actionText == 'Save Sum Plot ...': + # roiLabelText = self.roiList.roiLabelList[selectedRoi].toPlainText() + # _ret = self.savePlotItemAs(self.sumIntensityPlotItem.plotItem, f'sum-plot-roi-{roiLabelText}') + + # elif actionText == 'Save Clips ...': + # roiLabelText = self.roiList.roiLabelList[selectedRoi].toPlainText() + # _ret = self.savePlotItemAs(self.peakClipsWidget.peakClipPlotItem.plotItem, f'clip-plot-{roiLabelText}') + + # elif actionText == 'Copy Stats Table ...': + # _ret = self.simpleScatter.copyTableToClipboard() + + # special case on transition to backend + elif action == _toggleKymAction: + _checked = action.isChecked() + self._kymRoiImageWidget.setVisible(_checked) + self._kymRoiImageWidget.setEnabled(_checked) + + elif action == _toggleIntensity: + _checked = action.isChecked() + self.sumIntensityPlotItem.setVisible(_checked) + self.sumIntensityPlotItem.setEnabled(_checked) + + elif actionText.startswith('Select ROI'): + _, roiLabel = actionText.split(': ') + logger.info(f'selecting roiLabel:{roiLabel}') + self._kymRoiImageWidget.selectRoiFromLabel(roiLabel) + + elif action == _setSantanaNormLine: + # grab the current line scan from the kym image widget slider + lineScanNumber = self._kymRoiImageWidget._lineScanSlider.value() + logger.info(f'setting santana norm line to lineScanNumber:{lineScanNumber}') + self._kymRoiAnalysis.setKymDetectionParam('Divide Line Scan', lineScanNumber) + + elif action == _showAnalysisFolder: + saveFolder = self._kymRoiAnalysis._getSaveFolder(enclosingFolder=True, createFolder=False) + logger.info(f'showing analysis folder:\n{saveFolder}') + # open in finder + subprocess.run(['open', saveFolder] ) + + # copy rois to clipboard + elif action == _copyRois: + # copy rois to clipboard + _dict = self._kymRoiAnalysis.getCopyToClipboard() + _json = json.dumps(_dict, indent=4) + logger.info(f'copying rois to clipboard:\n{_json}') + # QtGui.QGuiApplication().clipboard().setText(_json) + app = QtWidgets.QApplication.instance() + app.clipboard().setText(_json) + + _ret = f'Copied {len(_dict)} rois to clipboard' + + # paste rois from clipboard + elif action == _pasteRois: + app = QtWidgets.QApplication.instance() + _json = app.clipboard().text() + try: + _dict = json.loads(_json) + except(json.decoder.JSONDecodeError) as e: + # logger.error(f'clipboard does not contain roi json:\n{_json}') + return + + # logger.info(f'retrieved rois from clipboard _dict:\n{_dict}') + + # self._kymRoiAnalysis.setRoiDict(_dict) + + # delete all roi + logger.info(f'deleting {self._kymRoiAnalysis.numRoi} existing rois') + for roiLabel in self._kymRoiAnalysis.getRoiLabels(): + # self._kymRoiAnalysis.deleteRoi(roiLabel) + self._kymRoiImageWidget.onUserDeleteRoi(roiLabel=roiLabel) + + logger.info(f'adding {len(_dict)} rois from the clipboard') + for roiLabel, roiDict in _dict.items(): + # roi = KymRoi(roiLabel, roiDict) + # self._kymRoiAnalysis.addRoi(roi) + ltrb = [0] * 4 + ltrb[0] = roiDict['left'] + ltrb[1] = roiDict['top'] + ltrb[2] = roiDict['right'] + ltrb[3] = roiDict['bottom'] + # self._kymRoiAnalysis.addRoi(ltrb) + self._kymRoiImageWidget.onUserAddRoi(ltrbRoi=ltrb) + + _ret = f'Pasted {len(_dict)} rois from clipboard' + + self.mySetStatusbar(_ret) \ No newline at end of file diff --git a/sanpy/kym/interface/kymRoiWidget_base.py b/sanpy/kym/interface/kymRoiWidget_base.py new file mode 100644 index 00000000..63488b16 --- /dev/null +++ b/sanpy/kym/interface/kymRoiWidget_base.py @@ -0,0 +1,15 @@ +from PyQt5 import QtGui, QtCore, QtWidgets + +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis + +class KymRoiWidget_Base(QtWidgets.QWidget): + def __init__(self, kymRoiAnalysis : KymRoiAnalysis): + super().__init__() + + self._kymRoiAnalysis = kymRoiAnalysis + self._currentChannel = 0 + + def slot_switchChannel(self, channel : int): + self._currentChannel = channel + + \ No newline at end of file diff --git a/sanpy/kym/interface/kym_file_list/__init__.py b/sanpy/kym/interface/kym_file_list/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanpy/kym/interface/kym_file_list/tif_table_view.py b/sanpy/kym/interface/kym_file_list/tif_table_view.py new file mode 100644 index 00000000..768620f4 --- /dev/null +++ b/sanpy/kym/interface/kym_file_list/tif_table_view.py @@ -0,0 +1,623 @@ +import os +import subprocess +import sys +from functools import partial +from typing import List, Optional + +import pandas as pd +from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import Qt, pyqtSignal, QUrl +from PyQt5.QtWidgets import ( + QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QHeaderView, + QLabel, QLineEdit, QMenu, QMessageBox, QPushButton, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget +) +from PyQt5.QtGui import QDesktopServices + +from sanpy.kym.tif_file_backend import TifFileBackend +from sanpy.kym.logger import get_logger + +logger = get_logger(__name__) + +class TifTableView(QWidget): + """A QTableWidget that displays TIF file data with advanced features.""" + + # Signals + fileToggled = pyqtSignal(str, bool) # file_path, checked + fileSelected = pyqtSignal(str) # file_path + fileDoubleClicked = pyqtSignal(str) # file_path + plotRoisRequested = pyqtSignal(str) # tif_file_path + exportCompleted = pyqtSignal(str) # export_filepath + loadKymAnalysisRequested = pyqtSignal(str) # tif_file_path + + def __init__(self, backend: TifFileBackend, parent=None): + super().__init__(parent) + self.backend = backend + + # Get visible columns from backend + self.visible_columns = self.backend.get_visible_columns('table') + + # Filter state + self.current_filters = {} + + # Selection state + self.selected_rows = set() + + # Set up context menu + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + # Create UI + self._createUI() + self._setupTable() + self._populateTable() + self._setupConnections() + + def _createUI(self): + """Create the user interface elements.""" + # Create filter controls + self._createFilterControls() + + # Create table + self.table = QTableWidget() + self.table.setAlternatingRowColors(True) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setSelectionMode(QTableWidget.ExtendedSelection) + + # Layout + layout = QVBoxLayout() + + # Filter section + filter_layout = QHBoxLayout() + filter_layout.addWidget(QLabel("Filters:")) + filter_layout.addWidget(self.condition_filter) + filter_layout.addWidget(self.region_filter) + filter_layout.addWidget(self.repeat_filter) + filter_layout.addWidget(self.search_box) + filter_layout.addWidget(self.clear_filters_btn) + filter_layout.addStretch() + layout.addLayout(filter_layout) + + # Table + layout.addWidget(self.table) + + self.setLayout(layout) + + def _createFilterControls(self): + """Create filter controls.""" + # Condition filter + self.condition_filter = QComboBox() + self.condition_filter.addItem("All Conditions") + self.condition_filter.currentTextChanged.connect(self._applyFilters) + + # Region filter + self.region_filter = QComboBox() + self.region_filter.addItem("All Regions") + self.region_filter.currentTextChanged.connect(self._applyFilters) + + # Repeat filter + self.repeat_filter = QComboBox() + self.repeat_filter.addItem("All Repeats") + self.repeat_filter.currentTextChanged.connect(self._applyFilters) + + # Search box + self.search_box = QLineEdit() + self.search_box.setPlaceholderText("Search filenames...") + self.search_box.textChanged.connect(self._applyFilters) + + # Clear filters button + self.clear_filters_btn = QPushButton("Clear Filters") + self.clear_filters_btn.clicked.connect(self._clearFilters) + + def _setupTable(self): + """Set up the table configuration.""" + # Enable sorting + self.table.setSortingEnabled(True) + + # Disable default double-click editing behavior + self.table.setEditTriggers(QTableWidget.NoEditTriggers) + + # Set up headers using backend column display names + display_names = self.backend.get_column_display_names(self.visible_columns) + self.table.setColumnCount(len(self.visible_columns)) + self.table.setHorizontalHeaderLabels(display_names) + + # Configure header + header = self.table.horizontalHeader() + header.setStretchLastSection(False) + header.setSectionResizeMode(QHeaderView.Interactive) + + # Set column widths and stretch behavior using backend config + for col_idx, column_name in enumerate(self.visible_columns): + width = self.backend.get_column_width(column_name) + + if self.backend.is_column_stretch(column_name): + header.setSectionResizeMode(col_idx, QHeaderView.Stretch) + elif width is not None: + self.table.setColumnWidth(col_idx, width) + + def _setupConnections(self): + """Set up signal connections.""" + self.table.itemSelectionChanged.connect(self._onSelectionChanged) + try: + self.table.itemDoubleClicked.disconnect() + except Exception: + pass + self.table.itemDoubleClicked.connect(self._onItemDoubleClicked) + self.table.cellChanged.connect(self._onCellChanged) + + def _populateTable(self): + """Populate the table with data from the backend.""" + if self.backend.df is None or len(self.backend.df) == 0: + return + + # Get filtered data + filtered_df = self._getFilteredData() + + # Set row count + self.table.setRowCount(len(filtered_df)) + + # Populate data + for row_idx, (_, row) in enumerate(filtered_df.iterrows()): + for col_idx, column in enumerate(self.visible_columns): + value = row[column] + + if column == 'show_file': + # Create checkbox + checkbox = QCheckBox() + checkbox.setChecked(value) + checkbox.stateChanged.connect( + partial(self._onCheckboxChanged, row['relative_path']) + ) + self.table.setCellWidget(row_idx, col_idx, checkbox) + elif column == '_KymRoiAnalysis_Loaded': + # Create status icon item + if value: + # Green circle for loaded + item = QTableWidgetItem("●") + item.setForeground(QtGui.QColor("#449944")) # Green + else: + # Empty for not loaded + item = QTableWidgetItem("○") + item.setForeground(QtGui.QColor("#999999")) # Gray + + item.setData(Qt.UserRole, row['relative_path']) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row_idx, col_idx, item) + else: + # Regular text item + item = QTableWidgetItem(str(value)) + item.setData(Qt.UserRole, row['relative_path']) # Store relative path for reference + + # Set edit flags based on backend column configuration + if self.backend.is_column_editable(column): + item.setFlags(item.flags() | Qt.ItemIsEditable) + else: + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + + self.table.setItem(row_idx, col_idx, item) + + # Update status + self._updateStatus() + + # Update filter options + self._updateFilterOptions() + + def _getFilteredData(self): + """Get filtered data based on current filters.""" + if self.backend.df is None: + return pd.DataFrame() + + df = self.backend.df.copy() + + # Apply filters + if self.current_filters.get('condition') and self.current_filters['condition'] != "All Conditions": + df = df[df['condition'] == self.current_filters['condition']] + + if self.current_filters.get('region') and self.current_filters['region'] != "All Regions": + df = df[df['region'] == self.current_filters['region']] + + if self.current_filters.get('repeat') and self.current_filters['repeat'] != "All Repeats": + df = df[df['repeat'] == int(self.current_filters['repeat'])] + + if self.current_filters.get('search'): + search_term = self.current_filters['search'].lower() + df = df[df['filename'].str.lower().str.contains(search_term, na=False)] + + return df + + def _updateFilterOptions(self): + """Update filter dropdown options based on current data.""" + if self.backend.df is None: + return + + # Store current filter selections + current_condition = self.condition_filter.currentText() + current_region = self.region_filter.currentText() + current_repeat = self.repeat_filter.currentText() + + # Temporarily disconnect signals to prevent infinite loop + self.condition_filter.currentTextChanged.disconnect() + self.region_filter.currentTextChanged.disconnect() + self.repeat_filter.currentTextChanged.disconnect() + + # Update condition filter + conditions = ['All Conditions'] + sorted(self.backend.df['condition'].unique().tolist()) + self.condition_filter.clear() + self.condition_filter.addItems(conditions) + + # Update region filter + regions = ['All Regions'] + sorted(self.backend.df['region'].unique().tolist()) + self.region_filter.clear() + self.region_filter.addItems(regions) + + # Update repeat filter + repeats = ['All Repeats'] + [str(r) for r in sorted(self.backend.df['repeat'].unique().tolist())] + self.repeat_filter.clear() + self.repeat_filter.addItems(repeats) + + # Restore current filter selections if they still exist in the new options + if current_condition in conditions: + self.condition_filter.setCurrentText(current_condition) + else: + self.condition_filter.setCurrentText("All Conditions") + + if current_region in regions: + self.region_filter.setCurrentText(current_region) + else: + self.region_filter.setCurrentText("All Regions") + + if current_repeat in repeats: + self.repeat_filter.setCurrentText(current_repeat) + else: + self.repeat_filter.setCurrentText("All Repeats") + + # Reconnect signals + self.condition_filter.currentTextChanged.connect(self._applyFilters) + self.region_filter.currentTextChanged.connect(self._applyFilters) + self.repeat_filter.currentTextChanged.connect(self._applyFilters) + + def _applyFilters(self): + """Apply current filters to the table.""" + # Update current filters + self.current_filters = { + 'condition': self.condition_filter.currentText(), + 'region': self.region_filter.currentText(), + 'repeat': self.repeat_filter.currentText(), + 'search': self.search_box.text() + } + + # Repopulate table with filtered data + self._populateTable() + + def _clearFilters(self): + """Clear all filters.""" + self.condition_filter.setCurrentText("All Conditions") + self.region_filter.setCurrentText("All Regions") + self.repeat_filter.setCurrentText("All Repeats") + self.search_box.clear() + self._applyFilters() + + def _updateStatus(self): + """Update status label.""" + # Status is now handled by the main window's status bar + pass + + def _onSelectionChanged(self): + """Handle selection changes.""" + self._updateStatus() + + def _onItemDoubleClicked(self, item): + """Handle double-click on table item.""" + if item is None: + return + row = item.row() + column = item.column() + if not self.is_checkbox_column(column): + if self.is_editable_column(column): + self.table.editItem(item) + return + file_path = item.data(Qt.UserRole) + if file_path: + # Debug: log the path and column info + col_name = self.visible_columns[column] if column < len(self.visible_columns) else "unknown" + logger.info(f"Double-click - Row {row}, Col {column} ({col_name}): {file_path}") + self.fileDoubleClicked.emit(file_path) + + def _onCellChanged(self, row, column): + """Handle cell content changes.""" + if column < 0 or column >= len(self.visible_columns): + return + + column_name = self.visible_columns[column] + + # Handle note changes + if column_name == 'note': + # Get the new note value + item = self.table.item(row, column) + if item is None: + return + + new_note = item.text() + + # Get the filtered data to find the correct row + filtered_df = self._getFilteredData() + if filtered_df is None or len(filtered_df) == 0: + return + + if row >= len(filtered_df): + return + + # Get the relative_path for this row + relative_path = filtered_df.iloc[row]['relative_path'] + + # Update the note in the backend + self._updateNoteInBackend(relative_path, new_note) + + logger.info(f"Note updated for {relative_path}: {new_note}") + + def _updateNoteInBackend(self, relative_path: str, note: str): + """Update the note for a specific file in the backend.""" + if self.backend.df is None: + return + + # Find the row with this relative path + mask = self.backend.df['relative_path'] == relative_path + if mask.any(): + # Update the note + self.backend.df.loc[mask, 'note'] = note + + def _onCheckboxChanged(self, file_path, state): + """Handle checkbox state changes.""" + checked = state == Qt.Checked + self.backend.set_checked('file', file_path, checked) + self.fileToggled.emit(file_path, checked) + + def _contextMenu(self, pos): + """Handle right-click context menu.""" + selected_items = self.table.selectedItems() + if not selected_items: + return + + # Get the relative_path from the selected row in the backend + item = selected_items[0] + row = item.row() + + # Get the filtered data to find the correct row + filtered_df = self._getFilteredData() + if filtered_df is None or len(filtered_df) == 0: + return + + if row >= len(filtered_df): + return + + # Get the relative_path from the backend for this row + relative_path = filtered_df.iloc[row]['relative_path'] + if not relative_path: + return + + context_menu = QMenu() + + # Single file actions only + context_menu.addAction('Plot ROIs', partial(self.plotRoisRequested.emit, relative_path)) + context_menu.addAction('Load Kym Analysis', partial(self.loadKymAnalysisRequested.emit, relative_path)) + context_menu.addSeparator() + context_menu.addAction('Open File', partial(self._openFile, relative_path)) + context_menu.addAction('Show in Finder', partial(self._showInFinder, relative_path)) + context_menu.addAction('Copy Path', partial(self._copyPath, relative_path)) + + # Show menu + pos = self.mapToGlobal(pos) + context_menu.exec_(pos) + + def _openFile(self, relative_path): + """Open file in default application.""" + try: + full_path = self.backend.resolve_path(relative_path) + if os.path.exists(full_path): + QDesktopServices.openUrl(QUrl.fromLocalFile(full_path)) + else: + logger.error(f"File not found: {full_path}") + except Exception as e: + logger.error(f"Error opening file {relative_path}: {e}") + + def _showInFinder(self, relative_path): + """Show file in Finder/Explorer.""" + try: + full_path = self.backend.resolve_path(relative_path) + if os.path.exists(full_path): + if sys.platform == "darwin": # macOS + subprocess.run(["open", "-R", full_path]) + elif sys.platform == "win32": # Windows + subprocess.run(["explorer", "/select,", full_path]) + else: # Linux + subprocess.run(["xdg-open", os.path.dirname(full_path)]) + else: + logger.error(f"File not found: {full_path}") + except Exception as e: + logger.error(f"Error showing file {relative_path} in Finder: {e}") + + def _copyPath(self, relative_path): + """Copy file path to clipboard.""" + try: + # Copy the full absolute path + full_path = self.backend.resolve_path(relative_path) + clipboard = QApplication.clipboard() + clipboard.setText(full_path) + except Exception as e: + logger.error(f"Error copying path for {relative_path}: {e}") + + def _copyPaths(self, filenames): + """Copy multiple file paths to clipboard.""" + relative_paths = [] + for filename in filenames: + mask = self.backend.df['filename'] == filename + if mask.any(): + relative_path = self.backend.df[mask]['relative_path'].iloc[0] + relative_paths.append(relative_path) + + clipboard = QApplication.clipboard() + clipboard.setText('\n'.join(relative_paths)) + + def _selectFiles(self, filenames, checked): + """Select/deselect multiple files.""" + for filename in filenames: + mask = self.backend.df['filename'] == filename + if mask.any(): + relative_path = self.backend.df[mask]['relative_path'].iloc[0] + self.backend.set_checked('file', relative_path, checked) + self._populateTable() + + # Public methods + def refresh(self): + """Refresh the table data.""" + self.backend.refresh() + self._populateTable() + logger.info("Table refreshed") + + def saveState(self): + """Save the current state to a CSV file.""" + from PyQt5.QtWidgets import QApplication + QApplication.processEvents() # Ensure all checkbox changes are committed + if self.backend: + csv_filepath = self.backend.save_state() + if csv_filepath: + csv_filename = os.path.basename(csv_filepath) + logger.info(f"State saved to: {csv_filename}") + return csv_filename + return None + + def getSelectedFiles(self) -> List[str]: + """Get list of selected file paths.""" + selected_files = [] + checkbox_col = self.get_column_index('show_file') + filename_col = self.get_column_index('filename') + + for row in range(self.table.rowCount()): + checkbox = self.table.cellWidget(row, checkbox_col) + if checkbox and checkbox.isChecked(): + item = self.table.item(row, filename_col) + if item: + file_path = item.data(Qt.UserRole) + if file_path: + selected_files.append(file_path) + return selected_files + + def getVisibleFiles(self) -> List[str]: + """Get list of all visible file paths.""" + visible_files = [] + filename_col = self.get_column_index('filename') + + for row in range(self.table.rowCount()): + item = self.table.item(row, filename_col) + if item: + file_path = item.data(Qt.UserRole) + if file_path: + visible_files.append(file_path) + return visible_files + + def getTableData(self) -> Optional[pd.DataFrame]: + """Get the current filtered table data as a DataFrame.""" + if self.backend.df is None or len(self.backend.df) == 0: + return None + + # Get filtered data (same as what's displayed in table) + filtered_df = self._getFilteredData() + + if len(filtered_df) == 0: + return None + + # Return only visible columns + export_columns = [col for col in self.visible_columns] + return filtered_df[export_columns] + + def saveTableDataToCSV(self, table_data: pd.DataFrame) -> Optional[str]: + """Save table data to a CSV file and return the filepath.""" + if table_data is None or len(table_data) == 0: + return None + + # Get save file path + file_path, _ = QFileDialog.getSaveFileName( + self, "Save table as...", + os.path.join(self.backend.root_path, "tif_table_export.csv"), + "CSV Files (*.csv);;All Files (*)" + ) + + if file_path: + try: + table_data.to_csv(file_path, index=False) + logger.info(f"Table data exported to: {file_path}") + return file_path + except Exception as e: + logger.error(f"Failed to export table data: {e}") + return None + + return None + + def get_column_index(self, column_name: str) -> int: + """Get the index of a column by name.""" + return self.visible_columns.index(column_name) + + def is_checkbox_column(self, column: int) -> bool: + """Check if the given column is a checkbox column.""" + if column < 0 or column >= len(self.visible_columns): + return False + column_name = self.visible_columns[column] + return self.backend.is_checkbox_column(column_name) + + def is_editable_column(self, column: int) -> bool: + """Check if the given column is editable.""" + if column < 0 or column >= len(self.visible_columns): + return False + column_name = self.visible_columns[column] + return self.backend.is_column_editable(column_name) + + def updateKymAnalysisStatus(self, filename: str): + """ + Update the KymRoiAnalysis status icon for a specific file. + + This method should be called after a KymRoiAnalysis object is loaded + to refresh the visual indicator in the table. + + Parameters + ---------- + filename : str + The filename (e.g., "20250312 ISAN R1 LS1 Control.tif") + """ + # Find the row with this filename in the current filtered data + filtered_df = self._getFilteredData() + if filtered_df is None or len(filtered_df) == 0: + return + + # Find the row index in the filtered data + mask = filtered_df['filename'] == filename + if not mask.any(): + return + + row_idx = mask.idxmax() + # Convert to table row index + table_row_idx = filtered_df.index.get_loc(row_idx) + + # Find the status column index + try: + status_col_idx = self.get_column_index('_KymRoiAnalysis_Loaded') + except ValueError: + # Status column not visible + return + + # Check if the KymRoiAnalysis is loaded + is_loaded = self.backend.df.loc[row_idx, '_KymRoiAnalysis_Loaded'] + + # Update the table item + if is_loaded: + # Green circle for loaded + item = QTableWidgetItem("●") + item.setForeground(QtGui.QColor("#449944")) # Green + else: + # Empty circle for not loaded + item = QTableWidgetItem("○") + item.setForeground(QtGui.QColor("#999999")) # Gray + + item.setData(Qt.UserRole, filename) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(table_row_idx, status_col_idx, item) \ No newline at end of file diff --git a/sanpy/kym/interface/kym_file_list/tif_tree_widget.py b/sanpy/kym/interface/kym_file_list/tif_tree_widget.py new file mode 100644 index 00000000..c75d33f1 --- /dev/null +++ b/sanpy/kym/interface/kym_file_list/tif_tree_widget.py @@ -0,0 +1,650 @@ +import sys +import os +import subprocess +from typing import List, Optional, Dict, Any + +import matplotlib.pyplot as plt + +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.QtWidgets import ( + QWidget, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QHBoxLayout, QPushButton, QApplication, QCheckBox +) +from PyQt5.QtCore import pyqtSignal, Qt +import pandas as pd + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +from sanpy.kym.tif_file_backend import TifFileBackend + +class TreeColumnConfig: + """Configuration class for tree widget columns.""" + + def __init__(self): + # Current columns + self.current_columns = [ + 'files_folders', # Main tree column with checkboxes + 'condition', # Condition column + 'repeat' # Repeat column + ] + + # Planned additional columns (from backend) + self.planned_columns = [ + 'date', # Date column (redundant with table) + 'region', # Region column (redundant with table) + 'error', # Error column (new) + 'um_per_pixel', # um per pixel (new) + 'ms_per_line', # ms per line (new) + 'acq_date', # Acquisition date (new) + 'acq_time' # Acquisition time (new) + ] + + # All columns (current + planned) + self.all_columns = self.current_columns + self.planned_columns + + # Column metadata + self.column_metadata = { + # Current columns + 'files_folders': { + 'display_name': 'Files and Folders', + 'width': 300, + 'stretch': True, + 'editable': False, + 'has_checkbox': True, + 'backend_field': None # Special case for tree structure + }, + 'condition': { + 'display_name': 'Condition', + 'width': 100, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'condition' + }, + 'repeat': { + 'display_name': 'Repeat', + 'width': 80, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'repeat' + }, + + # Planned columns + 'date': { + 'display_name': 'Date', + 'width': 100, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'date' + }, + 'region': { + 'display_name': 'Region', + 'width': 80, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'region' + }, + 'error': { + 'display_name': 'Error', + 'width': 80, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'error' + }, + 'um_per_pixel': { + 'display_name': 'μm/px', + 'width': 70, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'um_per_pixel' + }, + 'ms_per_line': { + 'display_name': 'ms/line', + 'width': 70, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'ms_per_line' + }, + 'acq_date': { + 'display_name': 'Acq Date', + 'width': 90, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'acq_date' + }, + 'acq_time': { + 'display_name': 'Acq Time', + 'width': 90, + 'stretch': False, + 'editable': False, + 'has_checkbox': False, + 'backend_field': 'acq_time' + } + } + + # Active columns (can be modified to show/hide columns) + self.active_columns = self.current_columns.copy() + + # Update indexes + self._update_indexes() + + def _update_indexes(self): + """Update column indexes based on active columns.""" + self.column_indexes = {col: idx for idx, col in enumerate(self.active_columns)} + + def get_index(self, column_name: str) -> int: + """Get column index by name.""" + return self.column_indexes.get(column_name, -1) + + def get_display_name(self, column_name: str) -> str: + """Get display name for column.""" + return self.column_metadata.get(column_name, {}).get('display_name', column_name) + + def get_width(self, column_name: str) -> Optional[int]: + """Get column width by name.""" + return self.column_metadata.get(column_name, {}).get('width') + + def is_stretch(self, column_name: str) -> bool: + """Check if column should stretch.""" + return self.column_metadata.get(column_name, {}).get('stretch', False) + + def has_checkbox(self, column_name: str) -> bool: + """Check if column has checkboxes.""" + return self.column_metadata.get(column_name, {}).get('has_checkbox', False) + + def get_backend_field(self, column_name: str) -> Optional[str]: + """Get backend field name for column.""" + return self.column_metadata.get(column_name, {}).get('backend_field') + + def get_active_columns(self) -> List[str]: + """Get list of currently active columns.""" + return self.active_columns.copy() + + def get_active_display_names(self) -> List[str]: + """Get list of display names for active columns.""" + return [self.get_display_name(col) for col in self.active_columns] + + def add_column(self, column_name: str): + """Add a column to the active columns.""" + if column_name in self.all_columns and column_name not in self.active_columns: + self.active_columns.append(column_name) + self._update_indexes() + + def remove_column(self, column_name: str): + """Remove a column from active columns.""" + if column_name in self.active_columns and column_name != 'files_folders': + self.active_columns.remove(column_name) + self._update_indexes() + + def set_columns(self, column_names: List[str]): + """Set the active columns.""" + if 'files_folders' not in column_names: + column_names.insert(0, 'files_folders') # Always keep files_folders first + + self.active_columns = [col for col in column_names if col in self.all_columns] + self._update_indexes() + + def is_checkbox_column(self, column_index: int) -> bool: + """Check if column index is a checkbox column.""" + if column_index < 0 or column_index >= len(self.active_columns): + return False + column_name = self.active_columns[column_index] + return self.has_checkbox(column_name) + +class TifTreeWidget(QWidget): + """A QTreeWidget that displays .tif files from a folder structure with refresh capability.""" + + # Emitted when a file is checked or unchecked + fileToggled = pyqtSignal(str, bool) # file_path, checked + + # Emitted when the user selects a folder + folderSelected = pyqtSignal(str) # folder_path + + # Emitted when the user selects a file + fileSelected = pyqtSignal(str) # file_path + + # Emitted when the user selects a grandparent folder + grandparentSelected = pyqtSignal(str) # grandparent_name + + # Emitted when the user selects a great-grandparent folder + greatGrandparentSelected = pyqtSignal(str) # great_grandparent_name + + # Emitted when files are refreshed + filesRefreshed = pyqtSignal(list) # list of file paths + + # Emitted when a .tif file is double-clicked + fileDoubleClicked = pyqtSignal(str) # file_path + + # Emitted when user selects "Plot ROIs" from context menu + plotRoisRequested = pyqtSignal(str) # tif_file_path + + # Emitted when state is saved + stateSaved = pyqtSignal(str) # csv_filename + + def __init__(self, backend: TifFileBackend, show_third_level: bool = False, parent=None): + super().__init__(parent) + self.backend = backend + self.show_third_level = show_third_level + + # Column configuration + self.column_config = TreeColumnConfig() + + # Set up context menu + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + # Create UI elements + self._createUI() + + # Initial population + self._populateTree() + + def _createUI(self): + """Create the user interface elements.""" + # Create tree with multiple columns + self.tree = QTreeWidget() + self.tree.setHeaderLabels(self.column_config.get_active_display_names()) + self.tree.itemChanged.connect(self.handleItemChanged) + self.tree.itemSelectionChanged.connect(self.handleItemSelectionChanged) + self.tree.itemDoubleClicked.connect(self.handleItemDoubleClicked) + + # Set up column widths + self._setup_column_widths() + + # Main layout + layout = QVBoxLayout() + layout.addWidget(self.tree) + self.setLayout(layout) + + def refresh(self): + """Refresh the tree by refreshing the backend data.""" + logger.info("Refreshing tree from backend") + + # Refresh backend data + self.backend.refresh() + + # Rebuild tree display + self._rebuildTreeDisplay() + + # Emit signal with found files + self.filesRefreshed.emit(self.backend.get('files')) + + logger.info(f"Found {self.backend.get('file_count')} .tif files") + + def _populateTree(self): + """Populate the tree with folders and files using backend data.""" + if self.backend.df is None or len(self.backend.df) == 0: + return + + # Get unique great-grandparent folders + great_grandparents = self.backend.df['great_grandparent_folder'].unique() + great_grandparents = [gg for gg in great_grandparents if gg] # Remove empty strings + + if great_grandparents: + # We have great-grandparent folders, use the original logic + for great_grandparent_name in sorted(great_grandparents): + # Create great-grandparent item + great_grandparent_item = self._create_tree_item("great_grandparent", great_grandparent_name) + + # Get grandparent folders for this great-grandparent + grandparent_data = self.backend.df[self.backend.df['great_grandparent_folder'] == great_grandparent_name] + grandparents = grandparent_data['grandparent_folder'].unique() + grandparents = [gp for gp in grandparents if gp] # Remove empty strings + + for grandparent_name in sorted(grandparents): + # Create grandparent item + grandparent_item = self._create_tree_item("grandparent", grandparent_name) + + # Get data for this grandparent + parent_data = grandparent_data[grandparent_data['grandparent_folder'] == grandparent_name] + + if self.show_third_level: + # Three-level structure: great_grandparent -> grandparent -> folder -> files + folders = parent_data['parent_folder'].unique() + folders = [f for f in folders if f] # Remove empty strings + + for folder_name in sorted(folders): + # Create folder item + folder_item = self._create_tree_item("folder", folder_name) + + # Add .tif files as children + folder_data = parent_data[parent_data['parent_folder'] == folder_name] + for _, row in folder_data.iterrows(): + file_item = self._create_tree_item("file", row['filename'], row) + folder_item.addChild(file_item) + + grandparent_item.addChild(folder_item) + else: + # Two-level structure: great_grandparent -> grandparent -> files + # Add .tif files directly as children of grandparent + for _, row in parent_data.iterrows(): + file_item = self._create_tree_item("file", row['filename'], row) + grandparent_item.addChild(file_item) + + great_grandparent_item.addChild(grandparent_item) + + self.tree.addTopLevelItem(great_grandparent_item) + great_grandparent_item.setExpanded(True) + else: + # No great-grandparent folders, create top-level items for grandparent folders + grandparents = self.backend.df['grandparent_folder'].unique() + grandparents = [gp for gp in grandparents if gp] # Remove empty strings + + for grandparent_name in sorted(grandparents): + # Create grandparent item + grandparent_item = self._create_tree_item("grandparent", grandparent_name) + + # Get data for this grandparent + parent_data = self.backend.df[self.backend.df['grandparent_folder'] == grandparent_name] + + if self.show_third_level: + # Three-level structure: grandparent -> folder -> files + folders = parent_data['parent_folder'].unique() + folders = [f for f in folders if f] # Remove empty strings + + for folder_name in sorted(folders): + # Create folder item + folder_item = self._create_tree_item("folder", folder_name) + + # Add .tif files as children + folder_data = parent_data[parent_data['parent_folder'] == folder_name] + for _, row in folder_data.iterrows(): + file_item = self._create_tree_item("file", row['filename'], row) + folder_item.addChild(file_item) + + grandparent_item.addChild(folder_item) + else: + # Two-level structure: grandparent -> files + # Add .tif files directly as children of grandparent + for _, row in parent_data.iterrows(): + file_item = self._create_tree_item("file", row['filename'], row) + grandparent_item.addChild(file_item) + + self.tree.addTopLevelItem(grandparent_item) + grandparent_item.setExpanded(True) + + # Expand all items by default + self._expandAllItems() + + def _expandAllItems(self): + """Expand all tree items to show disclosure triangles open.""" + def expandItem(item): + item.setExpanded(True) + for i in range(item.childCount()): + expandItem(item.child(i)) + + for i in range(self.tree.topLevelItemCount()): + expandItem(self.tree.topLevelItem(i)) + + def _rebuildTreeDisplay(self): + """Rebuild the tree display from scratch.""" + self.tree.clear() + self._populateTree() + + def setShowThirdLevel(self, show: bool): + """Set whether to show the third level (enclosing folder) and rebuild the tree display.""" + if self.show_third_level != show: + self.show_third_level = show + + # Only rebuild the tree display, don't refresh the backend data + self._rebuildTreeDisplay() + + def getShowThirdLevel(self) -> bool: + """Get whether the third level is currently shown.""" + return self.show_third_level + + def toggleThirdLevel(self): + """Toggle the third level display on/off.""" + self.setShowThirdLevel(not self.show_third_level) + + def _contextMenu(self, pos): + """Handle right-click context menu.""" + selectedItems = self.tree.selectedItems() + if len(selectedItems) == 0: + return + + item = selectedItems[0] + data = item.data(0, Qt.UserRole) + if data is None: + return + + contextMenu = QtWidgets.QMenu() + + if data[0] == "great_grandparent": + contextMenu.addAction('Show in Finder') + elif data[0] == "grandparent": + contextMenu.addAction('Show in Finder') + elif data[0] == "folder": + contextMenu.addAction('Open Folder') + contextMenu.addAction('Show in Finder') + elif data[0] == "file": + contextMenu.addAction('Plot ROIs') + contextMenu.addSeparator() + contextMenu.addAction('Open File') + contextMenu.addAction('Show in Finder') + contextMenu.addAction('Copy Path') + + # Show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + actionText = action.text() + + if actionText == 'Plot ROIs': + if data[0] == "file": + _tifFile = data[1] + self.plotRoisRequested.emit(_tifFile) + + elif actionText == 'Open Folder': + if data[0] == "folder": + subprocess.run(['open', data[1]]) + elif actionText == 'Show in Finder': + if data[0] == "great_grandparent": + # Find the actual path for great-grandparent + great_grandparent_name = data[1] + great_grandparent_path = os.path.join(self.backend.root_path, great_grandparent_name) + if os.path.exists(great_grandparent_path): + subprocess.run(['open', great_grandparent_path]) + elif data[0] == "grandparent": + # Find the actual path for grandparent + grandparent_name = data[1] + grandparent_path = os.path.join(self.backend.root_path, grandparent_name) + if os.path.exists(grandparent_path): + subprocess.run(['open', grandparent_path]) + elif data[0] == "folder": + subprocess.run(['open', data[1]]) + elif data[0] == "file": + # Resolve relative path to absolute path for file operations + file_path = os.path.join(self.backend.root_path, data[1]) + subprocess.run(['open', '-R', file_path]) + elif actionText == 'Open File': + if data[0] == "file": + # Resolve relative path to absolute path for file operations + file_path = os.path.join(self.backend.root_path, data[1]) + subprocess.run(['open', file_path]) + elif actionText == 'Copy Path': + if data[0] == "file": + clipboard = QApplication.clipboard() + # Resolve relative path to absolute path for clipboard + file_path = os.path.join(self.backend.root_path, data[1]) + clipboard.setText(file_path) + + def handleItemChanged(self, item, column): + """Handle checkbox state changes for individual files.""" + data = item.data(self.get_column_index('files_folders'), Qt.UserRole) + if data is None: + return + + # Only handle file checkboxes in the checkbox column + if data[0] == "file" and self.is_checkbox_column(column): + file_path = data[1] + checked = item.checkState(column) == Qt.Checked + + # Update backend + self.backend.set_checked('file', file_path, checked) + + # Emit signal + self.fileToggled.emit(file_path, checked) + + def handleItemSelectionChanged(self): + """Handle item selection changes.""" + selected_items = self.tree.selectedItems() + if not selected_items: + return + + item = selected_items[0] + data = item.data(self.get_column_index('files_folders'), Qt.UserRole) + if data is None: + return + + if data[0] == "great_grandparent": + self.greatGrandparentSelected.emit(data[1]) + elif data[0] == "grandparent": + self.grandparentSelected.emit(data[1]) + elif data[0] == "folder": + self.folderSelected.emit(data[1]) + elif data[0] == "file": + self.fileSelected.emit(data[1]) + + def handleItemDoubleClicked(self, item, column): + """Handle double-click on .tif files.""" + data = item.data(self.get_column_index('files_folders'), Qt.UserRole) + if data is None: + return + + if data[0] == "file": + file_path = data[1] + self.fileDoubleClicked.emit(file_path) + + def getCheckedFiles(self) -> List[str]: + """Get list of checked file paths.""" + return self.backend.get('files') + + def getFileCount(self) -> int: + """Get total number of files.""" + return self.backend.get('file_count') + + def getBackend(self) -> TifFileBackend: + """Get the backend instance.""" + return self.backend + + def setAllChecked(self, checked: bool): + """ + Programmatically set all checkboxes to the specified state. + + Parameters + ---------- + checked : bool + Whether all items should be checked (True) or unchecked (False) + """ + if self.backend.df is not None: + # Update backend for all files using the backend's API + for _, row in self.backend.df.iterrows(): + self.backend.set_checked('file', row['relative_path'], checked) + + # Update tree display to reflect the changes + self._rebuildTreeDisplay() + + def saveState(self): + """Save the current state to a CSV file.""" + from PyQt5.QtWidgets import QApplication + QApplication.processEvents() # Ensure all checkbox changes are committed + if self.backend: + csv_filepath = self.backend.save_state() + if csv_filepath: + csv_filename = os.path.basename(csv_filepath) + self.stateSaved.emit(csv_filename) + logger.info(f"State saved to: {csv_filename}") + + def get_column_index(self, column_name: str) -> int: + """Get the column index for a given column name.""" + return self.column_config.get_index(column_name) + + def is_checkbox_column(self, column: int) -> bool: + """Check if the given column is a checkbox column.""" + return self.column_config.is_checkbox_column(column) + + def add_column(self, column_name: str): + """Add a column to the tree widget.""" + self.column_config.add_column(column_name) + self._rebuild_tree_headers() + self._rebuildTreeDisplay() + + def remove_column(self, column_name: str): + """Remove a column from the tree widget.""" + self.column_config.remove_column(column_name) + self._rebuild_tree_headers() + self._rebuildTreeDisplay() + + def set_columns(self, column_names: List[str]): + """Set the active columns for the tree widget.""" + self.column_config.set_columns(column_names) + self._rebuild_tree_headers() + self._rebuildTreeDisplay() + + def _rebuild_tree_headers(self): + """Rebuild the tree headers based on current column configuration.""" + if hasattr(self, 'tree'): + self.tree.setHeaderLabels(self.column_config.get_active_display_names()) + self._setup_column_widths() + + def _setup_column_widths(self): + """Set up column widths based on column configuration.""" + for column_name in self.column_config.get_active_columns(): + col_idx = self.get_column_index(column_name) + width = self.column_config.get_width(column_name) + + if width is not None: + self.tree.setColumnWidth(col_idx, width) + + def _create_tree_item(self, item_type: str, item_name: str, row_data: pd.Series = None) -> QTreeWidgetItem: + """Create a tree item with the appropriate columns based on configuration.""" + # Initialize item text for all active columns + item_texts = [""] * len(self.column_config.get_active_columns()) + + # Set the main item name in the files_folders column + files_folders_idx = self.get_column_index('files_folders') + if files_folders_idx >= 0: + item_texts[files_folders_idx] = item_name + + # For file items, populate data from backend + if item_type == "file" and row_data is not None: + for column_name in self.column_config.get_active_columns(): + col_idx = self.get_column_index(column_name) + if col_idx >= 0 and column_name != 'files_folders': + backend_field = self.column_config.get_backend_field(column_name) + if backend_field and backend_field in row_data: + value = row_data[backend_field] + # Format the value appropriately + if pd.isna(value): + item_texts[col_idx] = "" + elif isinstance(value, (int, float)): + item_texts[col_idx] = str(value) + else: + item_texts[col_idx] = str(value) + + # Create the tree item + item = QTreeWidgetItem(item_texts) + + # Set flags and data + if item_type == "file": + item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable) + if row_data is not None: + checked = row_data.get('show_file', False) + item.setCheckState(files_folders_idx, Qt.Checked if checked else Qt.Unchecked) + item.setData(files_folders_idx, Qt.UserRole, ("file", row_data['relative_path'])) + else: + item.setFlags(item.flags() | Qt.ItemIsSelectable) + item.setData(files_folders_idx, Qt.UserRole, (item_type, item_name)) + + return item \ No newline at end of file diff --git a/sanpy/kym/interface/kym_file_list/tif_tree_window.py b/sanpy/kym/interface/kym_file_list/tif_tree_window.py new file mode 100644 index 00000000..2a8dd64e --- /dev/null +++ b/sanpy/kym/interface/kym_file_list/tif_tree_window.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +""" +Example script demonstrating the TifTreeWidget class. + +This script creates a simple application that shows how to use the TifTreeWidget +to browse and select .tif files from a folder structure. +""" + +import sys +import os +from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QVBoxLayout, QWidget, QLabel, QPushButton, QFileDialog, QCheckBox, QMenuBar, QMenu, QAction, QHBoxLayout, QTabWidget, QMessageBox +) +from PyQt5 import QtWidgets +from PyQt5.QtCore import Qt +from functools import partial +import sip + +from sanpy.interface.sanpy_app import SanPyApp + +from sanpy.kym.tif_file_backend import TifFileBackend +from sanpy.kym.interface.kym_file_list.tif_tree_widget import TifTreeWidget +from sanpy.kym.interface.kym_file_list.tif_table_view import TifTableView +from sanpy.kym.interface.preferences_dialog import PreferencesDialog +from sanpy.kym.interface.preferences_manager import preferences_manager + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def yesNoCancelDialog(message, informativeText=None): + """Simple yes/no/cancel dialog to avoid importing from sanpy.interface.bDialog.""" + msg = QMessageBox() + msg.setIcon(QMessageBox.Warning) + msg.setText(message) + if informativeText is not None: + msg.setInformativeText(informativeText) + msg.setWindowTitle(message) + msg.setStandardButtons( + QMessageBox.Yes + | QMessageBox.No + | QMessageBox.Cancel + ) + retval = msg.exec_() + return retval + +class TifTreeWindow(QMainWindow): + def __init__(self, sanPyApp : SanPyApp, path=None): + """ + Parameters + ---------- + sanPyApp : SanPyApp + Allows access to app wide info such as options, file loaders, plugins + path : str + Full path to folder with raw files (abf,csv,tif). + """ + super().__init__() + self.setWindowTitle("TifTreeWindow") + self.setGeometry(100, 100, 1000, 700) + + self._sanPyApp : SanPyApp = sanPyApp + self.path = path + + # Track open widgets: {widget_id: (widget_instance, label)} + self.open_widgets = {} + self.widget_counter = 0 + + # Default path + self.default_path = path + + # Create menu bar + self.createMenuBar() + + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Create layout + layout = QVBoxLayout(central_widget) + + # Create controls + self.createControls(layout) + + # Create tab widget + self.tab_widget = QTabWidget() + layout.addWidget(self.tab_widget) + + # Initialize widgets (will be created when folder is selected) + self.tree_widget = None + self.table_widget = None + self.backend = None + + # Connect signals + self.connectSignals() + + # Initialize with default path if it exists + if self.default_path and os.path.exists(self.default_path): + self.initializeWidgets(self.default_path) + else: + self.statusBar().showMessage("No folder selected") + + def createMenuBar(self): + """Create the menu bar with Windows menu.""" + menubar = self.menuBar() + + # Create File menu + self.file_menu = QMenu("File", self) + menubar.addMenu(self.file_menu) + + # Add Save table as... action + self.save_table_action = QAction("Save table as...", self) + self.save_table_action.setShortcut("Ctrl+S") + self.save_table_action.setStatusTip("Save current table data to CSV file") + self.save_table_action.setEnabled(False) # Disabled until folder is loaded + self.save_table_action.triggered.connect(self.saveTableAs) + self.file_menu.addAction(self.save_table_action) + + # Create Settings menu + self.settings_menu = QMenu("Settings", self) + menubar.addMenu(self.settings_menu) + + # Add Preferences action + self.preferences_action = QAction("Preferences...", self) + self.preferences_action.setShortcut("Ctrl+,") + self.preferences_action.setStatusTip("Open application preferences") + self.preferences_action.triggered.connect(self.showPreferences) + self.settings_menu.addAction(self.preferences_action) + + # Create Windows menu + self.windows_menu = QMenu("Windows", self) + menubar.addMenu(self.windows_menu) + + # Update the Windows menu + self.updateWindowsMenu() + + def updateWindowsMenu(self): + """Update the Windows menu with current open widgets.""" + # Robustness: Only update if menu and window are valid + if not hasattr(self, 'windows_menu') or self.windows_menu is None: + return + if sip.isdeleted(self.windows_menu): + return + self.windows_menu.clear() + if not self.open_widgets: + no_windows_action = QAction("No windows open", self) + no_windows_action.setEnabled(False) + self.windows_menu.addAction(no_windows_action) + else: + for widget_id, (widget, label) in self.open_widgets.items(): + action = QAction(label, self) + action.triggered.connect(partial(self.bringWidgetToFront, widget)) + self.windows_menu.addAction(action) + + def bringWidgetToFront(self, widget): + """Bring the specified widget to the front.""" + if widget: + widget.raise_() + widget.activateWindow() + + def addOpenWidget(self, widget, label, widget_type="Widget"): + """Add a widget to the tracking system.""" + self.widget_counter += 1 + widget_id = f"{widget_type} {self.widget_counter}" + self.open_widgets[widget_id] = (widget, label) + # Connect widget's closeEvent to remove from menu immediately + orig_closeEvent = widget.closeEvent + def new_closeEvent(ev): + self.removeOpenWidget(widget_id) + orig_closeEvent(ev) + widget.closeEvent = new_closeEvent + # Also connect destroyed for robustness + widget.destroyed.connect(partial(self.removeOpenWidget, widget_id)) + self.updateWindowsMenu() + return widget_id + + def removeOpenWidget(self, widget_id): + """Remove a widget from the tracking system.""" + if widget_id in self.open_widgets: + del self.open_widgets[widget_id] + if hasattr(self, 'windows_menu') and self.windows_menu is not None: + if not sip.isdeleted(self.windows_menu): + self.updateWindowsMenu() + + def createControls(self, layout): + """Create control buttons for the top toolbar.""" + # Create horizontal layout for buttons + button_layout = QHBoxLayout() + + # Load Folder button + self.select_folder_btn = QPushButton("Load Folder") + self.select_folder_btn.setToolTip("Select a folder containing .tif files to load") + button_layout.addWidget(self.select_folder_btn) + + # Add some spacing + button_layout.addSpacing(10) + + # Refresh button + self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.setToolTip("Refresh the file list from disk") + self.refresh_btn.setEnabled(False) # Disabled until folder is loaded + button_layout.addWidget(self.refresh_btn) + + # Save State button + self.save_state_btn = QPushButton("Save State") + self.save_state_btn.setToolTip("Save current selection state to CSV file") + self.save_state_btn.setEnabled(False) # Disabled until folder is loaded + button_layout.addWidget(self.save_state_btn) + + # Add stretch to push buttons to the left + button_layout.addStretch() + + # Add the button layout to the main layout + layout.addLayout(button_layout) + + def connectSignals(self): + """Connect button signals to slots.""" + self.select_folder_btn.clicked.connect(self.selectFolder) + self.refresh_btn.clicked.connect(self.refreshData) + self.save_state_btn.clicked.connect(self.saveState) + + def selectFolder(self): + """Open folder dialog and initialize tree widget.""" + folder_path = QFileDialog.getExistingDirectory( + self, + "Select Folder Containing .tif Files", + self.default_path if self.default_path and os.path.exists(self.default_path) else os.path.expanduser("~") + ) + + if folder_path: + self.initializeWidgets(folder_path) + + def initializeWidgets(self, folder_path): + """Initialize the tree and table widgets with the selected folder.""" + # Remove existing widgets if they exist + if self.tree_widget: + self.tree_widget.deleteLater() + if self.table_widget: + self.table_widget.deleteLater() + + # Create backend + # abb sort_by_grandparent=True for opening tif exported from olympus + self.backend = TifFileBackend(folder_path, exclude_folders=["sanpy-reports-pdf"], sort_by_grandparent=True) + + # Create new tree widget (default to no third level) + self.tree_widget = TifTreeWidget(self.backend, show_third_level=False, parent=self) + + # Create new table widget + self.table_widget = TifTableView(self.backend, parent=self) + + # Add widgets to tab widget + self.tab_widget.addTab(self.table_widget, "Table View") + self.tab_widget.addTab(self.tree_widget, "Tree View") + + # Update window title with folder name + folder_name = os.path.basename(folder_path) + self.setWindowTitle(f"{folder_name}") + + # Update status + file_count = self.tree_widget.getFileCount() + self.statusBar().showMessage(f"Folder: {folder_name} | Found {file_count} .tif files") + + # Enable the toolbar buttons now that we have a backend + self.refresh_btn.setEnabled(True) + self.save_state_btn.setEnabled(True) + + # Enable the save table action + self.save_table_action.setEnabled(True) + + # Connect tree widget signals + self.tree_widget.filesRefreshed.connect(self.onFilesRefreshed) + self.tree_widget.fileSelected.connect(self.onFileSelected) + self.tree_widget.folderSelected.connect(self.onFolderSelected) + self.tree_widget.fileToggled.connect(self.onFileToggled) + self.tree_widget.fileDoubleClicked.connect(self.onFileDoubleClicked) + self.tree_widget.plotRoisRequested.connect(self.onPlotRoisRequested) + self.tree_widget.stateSaved.connect(self.onStateSaved) + + # Connect table widget signals (only connect to signals that exist) + self.table_widget.fileSelected.connect(self.onFileSelected) + self.table_widget.fileToggled.connect(self.onFileToggled) + self.table_widget.fileDoubleClicked.connect(self.onFileDoubleClicked) + self.table_widget.plotRoisRequested.connect(self.onPlotRoisRequested) + self.table_widget.loadKymAnalysisRequested.connect(self.onLoadKymAnalysisRequested) + + def onFilesRefreshed(self, absolute_path_list): + """Called when files are refreshed.""" + logger.info(f"Files refreshed. Found {len(absolute_path_list)} .tif files") + file_count = len(absolute_path_list) + self.statusBar().showMessage(f"Refreshed: Found {file_count} .tif files") + + def onFileSelected(self, relative_path): + """Called when a file is selected.""" + logger.info(f"File selected: {relative_path}") + + def onFolderSelected(self, absolute_path): + """Called when a folder is selected.""" + logger.info(f"Folder selected: {absolute_path}") + + def onFileToggled(self, relative_path, checked): + """Called when a file is checked/unchecked.""" + # self.showSelectedFiles() + pass + + def onFileDoubleClicked(self, relative_path): + """Called when a .tif file is double-clicked.""" + logger.info(f"File double-clicked: {relative_path}") + # You can add custom actions here, such as opening the file + # subprocess.run(['open', relative_path]) # Uncomment to open files + + from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis + from sanpy.kym.interface.kymRoiWidget import KymRoiWidget + + # Create a custom KymRoiWidget that uses our local dialog function + class CustomKymRoiWidget(KymRoiWidget): + def closeEvent(self, event): + logger.info('veto close if peak analysis is dirty') + acceptAndContinue = True + if self.isDirty(): + logger.info(' kym peak analysis is dirty, prompt to save') + + userResp = yesNoCancelDialog( + "There is analysis that is not saved.\nDo you want to save?" + ) + if userResp == QtWidgets.QMessageBox.Yes: + logger.warning('TODO: actually save kym roi peaks') + self.saveAnalysis() + acceptAndContinue = True + elif userResp == QtWidgets.QMessageBox.No: + acceptAndContinue = True + else: # userResp == QtWidgets.QMessageBox.Cancel: + acceptAndContinue = False + + if acceptAndContinue: + event.accept() + else: + event.ignore() + + # Resolve the relative path to full path + if self.backend: + absolute_path = self.backend.resolve_path(relative_path) + logger.info(f"Resolved relative path '{relative_path}' to absolute path '{absolute_path}'") + else: + # Fallback if no backend available + absolute_path = relative_path + logger.warning(f"No backend available, using path as-is: {relative_path}") + + logger.info(f"creating widget: {absolute_path}") + + aKymRoiAnalysis = KymRoiAnalysis(absolute_path) + aWidget = CustomKymRoiWidget(aKymRoiAnalysis) + + # Add widget to tracking system, use tif file name as label + tif_label = os.path.basename(relative_path) + widget_id = self.addOpenWidget(aWidget, tif_label, "KymRoi") + aWidget.setWindowTitle(f"KymRoi - {tif_label}") + aWidget.show() + + def onPlotRoisRequested(self, relative_path): + """Called when user selects 'Plot ROIs' from context menu.""" + logger.info(f"Plot ROIs requested for: {relative_path}") + # You can add custom actions here, such as opening a plotting window + + def onLoadKymAnalysisRequested(self, relative_path): + """Called when user selects 'Load Kym Analysis' from context menu.""" + logger.info(f"Load Kym Analysis requested for: {relative_path}") + + try: + # Use the backend's lazy loading to get or create the KymRoiAnalysis + kym_analysis = self.backend.get_kym_roi_analysis_by_path(relative_path) + + if kym_analysis is not None: + # Get the filename for status update + filename = os.path.basename(relative_path) + # Update the table to show the loaded status + self.table_widget.updateKymAnalysisStatus(filename) + + # Show success message + self.statusBar().showMessage(f"KymRoiAnalysis loaded for: {filename}") + + logger.info(f"Successfully loaded KymRoiAnalysis for: {filename}") + else: + # Show error message + self.statusBar().showMessage(f"Failed to load KymRoiAnalysis for: {relative_path}") + + logger.error(f"Failed to load KymRoiAnalysis for: {relative_path}") + + except Exception as e: + logger.error(f"Error loading KymRoiAnalysis for {relative_path}: {e}") + self.statusBar().showMessage(f"Error loading KymRoiAnalysis: {str(e)}") + + def onStateSaved(self, csv_filename): + """Update the status bar when state is saved.""" + self.statusBar().showMessage(f"State saved to: {csv_filename}") + + def showSelectedFiles(self): + """Show all currently checked files.""" + if not self.backend: + return + + checked_files = self.backend.get('files') + + logger.info("\n=== Selected Files ===") + for absolute_path in checked_files: + logger.info(f" {absolute_path}") + + logger.info(f"\nTotal: {len(checked_files)} files selected") + + def refreshData(self): + """Refresh the data from disk.""" + if self.backend: + logger.info("Refreshing data from disk") + + # Refresh the backend + self.backend.refresh() + + # Refresh both widgets + if self.tree_widget: + self.tree_widget.refresh() + if self.table_widget: + self.table_widget.refresh() + + # Update status + file_count = self.backend.get('file_count') + folder_name = os.path.basename(self.backend.root_path) + self.statusBar().showMessage(f"Refreshed: {folder_name} | Found {file_count} .tif files") + + logger.info(f"Data refreshed. Found {file_count} .tif files") + else: + logger.warning("No backend available to refresh") + + def saveState(self): + """Save the current state to a CSV file.""" + if self.backend: + logger.info("Saving current state") + + # Save state using the backend + csv_filepath = self.backend.save_state() + + if csv_filepath: + csv_filename = os.path.basename(csv_filepath) + self.statusBar().showMessage(f"State saved to: {csv_filename}") + logger.info(f"State saved to: {csv_filename}") + else: + self.statusBar().showMessage("Failed to save state") + logger.error("Failed to save state") + else: + logger.warning("No backend available to save state") + + def saveTableAs(self): + """Save the current table data to a CSV file.""" + if not self.table_widget: + self.statusBar().showMessage("No table widget available") + return + + # Get the table data + table_data = self.table_widget.getTableData() + + if table_data is None or len(table_data) == 0: + self.statusBar().showMessage("No table data to save") + return + + # Show current status + total_rows = len(table_data) + self.statusBar().showMessage(f"Saving {total_rows} rows of table data...") + + # Save the table data to a CSV file + csv_filepath = self.table_widget.saveTableDataToCSV(table_data) + + if csv_filepath: + csv_filename = os.path.basename(csv_filepath) + self.statusBar().showMessage(f"Table saved: {csv_filename} ({total_rows} rows)") + logger.info(f"Table saved to: {csv_filepath} ({total_rows} rows)") + else: + self.statusBar().showMessage("Save cancelled or failed") + logger.warning("Table save was cancelled or failed") + + def showPreferences(self): + """Show the preferences dialog.""" + dialog = PreferencesDialog(self) + + # Connect the preferences saved signal + dialog.preferencesSaved.connect(self.onPreferencesSaved) + + # Show the dialog + dialog.exec_() + + def onPreferencesSaved(self, preferences): + """Called when preferences are saved.""" + logger.info("Preferences saved") + self.statusBar().showMessage("Preferences saved successfully") + + # You can add code here to apply preferences to the current application state + # For example, if the Olympus Export preference changed, you might need to + # reload the current data or update the UI accordingly + + # Example: Check if Olympus Export preference changed + olympus_export = preferences.get('Load Kymograph', {}).get('olympus_export', False) + logger.info(f"Olympus Export setting: {olympus_export}") + + # If you have a backend loaded, you might want to refresh it with new settings + if self.backend: + # You could add a method to the backend to reload with new preferences + # self.backend.reloadWithPreferences(preferences) + pass + +def main(): + """Main function to run the example.""" + # Set dark theme before creating QApplication + try: + import qdarktheme + qdarktheme.enable_hi_dpi() + except ImportError: + print("qdarktheme not available, using default theme") + + from sanpy.interface.sanpy_app import SanPyApp + sanPyApp = SanPyApp(sys.argv) + + # app = QApplication(sys.argv) + + # Apply dark theme after QApplication is created + # try: + # import qdarktheme + # qdarktheme.setup_theme("dark") + # except ImportError: + # pass + + # Default path to use + default_path = "/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-atp-20250623-RHC" + + # Create and show the main window + window = TifTreeWindow(sanPyApp=sanPyApp, path=default_path) + window.show() + + # Run the application + sys.exit(sanPyApp.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sanpy/kym/interface/kym_file_list/tif_viewer_base.py b/sanpy/kym/interface/kym_file_list/tif_viewer_base.py new file mode 100644 index 00000000..48cf45d1 --- /dev/null +++ b/sanpy/kym/interface/kym_file_list/tif_viewer_base.py @@ -0,0 +1,327 @@ +import sys +from functools import partial +from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, + QToolBar, QPushButton, QCheckBox, QSplitter, + QLabel, QMainWindow, QAction, QMenu) +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPalette + + +class CustomWidget(QWidget): + """Main widget with toolbar and three resizable sections""" + + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + self.setWindowTitle('Custom Layout Widget') + self.setGeometry(100, 100, 800, 600) + + # Create main layout + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Create toolbar + self.create_toolbar() + main_layout.addWidget(self.toolbar) + + # Create splitter for the three widgets + self.splitter = QSplitter(Qt.Vertical) + + # Create three widgets with different background colors and store in dictionary + self.widgets = { + "Widget 1": self.create_colored_widget("Widget 1", "#FFE6E6"), # Light red + "Widget 2": self.create_colored_widget("Widget 2", "#E6F3FF"), # Light blue + "Widget 3": self.create_colored_widget("Widget 3", "#E6FFE6") # Light green + } + + # Add widgets to splitter + for widget in self.widgets.values(): + self.splitter.addWidget(widget) + + # Set initial sizes (equal distribution) + self.splitter.setSizes([200, 200, 200]) + + # Add splitter to main layout + main_layout.addWidget(self.splitter) + + self.setLayout(main_layout) + + # Enable context menu + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_context_menu) + + def create_toolbar(self): + """Create the top toolbar with fixed height""" + self.toolbar = QToolBar() + self.toolbar.setFixedHeight(40) + self.toolbar.setMovable(False) + + # Store toolbar widgets in dictionary using meaningful names + from PyQt5.QtWidgets import QLineEdit, QLabel + + self.toolbar_widgets = { + "Button 1": QPushButton("Button 1"), + "Button 2": QPushButton("Button 2"), + "Option 1": QCheckBox("Option 1"), + "Option 2": QCheckBox("Option 2"), + "Text Input": QLineEdit(), + "Status Label": QLabel("Status: Ready") + } + + # Set some initial properties + self.toolbar_widgets["Text Input"].setPlaceholderText("Enter text...") + self.toolbar_widgets["Text Input"].setMaximumWidth(150) + + # Add widgets to toolbar in desired order + self.toolbar.addWidget(self.toolbar_widgets["Button 1"]) + self.toolbar.addWidget(self.toolbar_widgets["Button 2"]) + self.toolbar.addSeparator() + self.toolbar.addWidget(self.toolbar_widgets["Option 1"]) + self.toolbar.addWidget(self.toolbar_widgets["Option 2"]) + self.toolbar.addSeparator() + self.toolbar.addWidget(self.toolbar_widgets["Text Input"]) + self.toolbar.addSeparator() + self.toolbar.addWidget(self.toolbar_widgets["Status Label"]) + + # Connect events using partial instead of lambda + self.toolbar_widgets["Button 1"].clicked.connect(partial(self.on_toolbar_action, "Button 1")) + self.toolbar_widgets["Button 2"].clicked.connect(partial(self.on_toolbar_action, "Button 2")) + self.toolbar_widgets["Option 1"].stateChanged.connect(partial(self.on_toolbar_state_changed, "Option 1")) + self.toolbar_widgets["Option 2"].stateChanged.connect(partial(self.on_toolbar_state_changed, "Option 2")) + self.toolbar_widgets["Text Input"].textChanged.connect(partial(self.on_toolbar_text_changed, "Text Input")) + + def create_colored_widget(self, text, color): + """Create a widget with colored background for debugging""" + widget = QWidget() + layout = QVBoxLayout() + + # Create label to show which widget this is + label = QLabel(text) + label.setAlignment(Qt.AlignCenter) + layout.addWidget(label) + + widget.setLayout(layout) + + # Set background color + widget.setStyleSheet(f"background-color: {color}; border: 1px solid #CCCCCC;") + + return widget + + def show_context_menu(self, position): + """Show context menu to toggle toolbar and widget visibility""" + context_menu = QMenu(self) + + # Create toggle toolbar action + toggle_toolbar_action = QAction("Toggle Toolbar", self) + toggle_toolbar_action.setCheckable(True) + toggle_toolbar_action.setChecked(self.toolbar.isVisible()) + toggle_toolbar_action.triggered.connect(self.toggle_toolbar) + + context_menu.addAction(toggle_toolbar_action) + context_menu.addSeparator() + + # Create toggle actions for each widget using partial + for widget_name in self.widgets: + toggle_action = QAction(f"Toggle {widget_name}", self) + toggle_action.setCheckable(True) + toggle_action.setChecked(self.widgets[widget_name].isVisible()) + toggle_action.triggered.connect(partial(self.toggle_widget, widget_name)) + context_menu.addAction(toggle_action) + + context_menu.exec_(self.mapToGlobal(position)) + + def toggle_toolbar(self): + """Toggle toolbar visibility""" + self.toolbar.setVisible(not self.toolbar.isVisible()) + + def toggle_widget(self, name): + """Toggle widget visibility by name""" + if name in self.widgets: + widget = self.widgets[name] + widget.setVisible(not widget.isVisible()) + else: + print(f"Warning: Widget '{name}' not found") + + def on_toolbar_action(self, widget_name): + """Handle toolbar button clicks""" + print(f"Toolbar {widget_name} clicked") + + def on_toolbar_state_changed(self, widget_name, state): + """Handle toolbar checkbox state changes""" + is_checked = state == 2 # Qt.Checked + print(f"Toolbar {widget_name}: {is_checked}") + + def on_toolbar_text_changed(self, widget_name, text): + """Handle toolbar text input changes""" + print(f"Toolbar {widget_name}: {text}") + + def get_toolbar_widget(self, name): + """Get a specific toolbar widget by name""" + return self.toolbar_widgets.get(name) + + def get_toolbar_widgets(self): + """Get all toolbar widgets dictionary""" + return self.toolbar_widgets + + def add_toolbar_widget(self, name, widget, position=None): + """Add a new toolbar widget with uniqueness check""" + if name in self.toolbar_widgets: + print(f"Warning: Toolbar widget '{name}' already exists. Use a unique name.") + return False + + self.toolbar_widgets[name] = widget + + if position is None: + self.toolbar.addWidget(widget) + else: + # Insert at specific position (advanced usage) + actions = self.toolbar.actions() + if position < len(actions): + self.toolbar.insertWidget(actions[position], widget) + else: + self.toolbar.addWidget(widget) + return True + + def remove_toolbar_widget(self, name): + """Remove a toolbar widget""" + if name not in self.toolbar_widgets: + print(f"Warning: Toolbar widget '{name}' not found") + return False + + widget = self.toolbar_widgets[name] + self.toolbar.removeAction(widget) + widget.setParent(None) + del self.toolbar_widgets[name] + return True + + def set_toolbar_value(self, name, value): + """Set value for a toolbar widget""" + widget = self.toolbar_widgets.get(name) + if not widget: + print(f"Warning: Toolbar widget '{name}' not found") + return False + + # Handle different widget types + if hasattr(widget, 'setChecked'): # QCheckBox + widget.setChecked(value) + elif hasattr(widget, 'setText'): # QLabel, QLineEdit, QPushButton + widget.setText(str(value)) + else: + print(f"Warning: Don't know how to set value for widget type {type(widget)}") + return False + return True + + def get_toolbar_value(self, name): + """Get value from a toolbar widget""" + widget = self.toolbar_widgets.get(name) + if not widget: + print(f"Warning: Toolbar widget '{name}' not found") + return None + + # Handle different widget types + if hasattr(widget, 'isChecked'): # QCheckBox + return widget.isChecked() + elif hasattr(widget, 'text'): # QLabel, QLineEdit, QPushButton + return widget.text() + else: + print(f"Warning: Don't know how to get value for widget type {type(widget)}") + return None + + def get_widgets(self): + """Return the widgets dictionary for external modification""" + return self.widgets + + def get_widget(self, name): + """Return a specific widget by name""" + return self.widgets.get(name) + + def add_widget(self, name, widget, background_color="#F0F0F0"): + """Add a new widget to the layout with uniqueness check""" + if name in self.widgets: + print(f"Warning: Widget '{name}' already exists. Use a unique name.") + return False + + # Set background color for debugging + widget.setStyleSheet(f"background-color: {background_color}; border: 1px solid #CCCCCC;") + + self.widgets[name] = widget + self.splitter.addWidget(widget) + return True + + def remove_widget(self, name): + """Remove a widget from the layout""" + if name not in self.widgets: + print(f"Warning: Widget '{name}' not found") + return False + + widget = self.widgets[name] + self.splitter.removeWidget(widget) + widget.setParent(None) # Remove from parent + del self.widgets[name] + return True + + +class MainWindow(QMainWindow): + """Main window to contain the custom widget""" + + def __init__(self): + super().__init__() + self.init_ui() + + def init_ui(self): + self.setWindowTitle('PyQt5 Custom Layout Demo') + self.setGeometry(100, 100, 800, 600) + + # Create and set the custom widget as central widget + self.custom_widget = CustomWidget() + self.setCentralWidget(self.custom_widget) + + +def main(): + app = QApplication(sys.argv) + + # Create and show the main window + window = MainWindow() + window.show() + + # Example of how to access the widgets for customization + widgets_dict = window.custom_widget.get_widgets() + toolbar_widgets = window.custom_widget.get_toolbar_widgets() + + print("Available widgets:", list(widgets_dict.keys())) + print("Available toolbar widgets:", list(toolbar_widgets.keys())) + print("You can now add your own widgets to each section") + + # Example: Access individual widgets using meaningful names + widget1 = window.custom_widget.get_widget("Widget 1") + option1_checkbox = window.custom_widget.get_toolbar_widget("Option 1") + + # Example: Programmatic control of toolbar widgets using meaningful names + window.custom_widget.set_toolbar_value("Option 1", True) + window.custom_widget.set_toolbar_value("Status Label", "Status: Connected") + window.custom_widget.set_toolbar_value("Text Input", "Hello World") + + print("Option 1 checked:", window.custom_widget.get_toolbar_value("Option 1")) + print("Text Input value:", window.custom_widget.get_toolbar_value("Text Input")) + + # Example: Adding new widgets dynamically (with uniqueness check) + from PyQt5.QtWidgets import QCheckBox, QLabel + + # This will work + new_checkbox = QCheckBox("Enable Debug") + success = window.custom_widget.add_toolbar_widget("Enable Debug", new_checkbox) + print(f"Added 'Enable Debug' checkbox: {success}") + + # This will fail due to duplicate name + duplicate_checkbox = QCheckBox("Enable Debug") + success = window.custom_widget.add_toolbar_widget("Enable Debug", duplicate_checkbox) + print(f"Added duplicate 'Enable Debug' checkbox: {success}") + + sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/sanpy/kym/interface/preferences_dialog.py b/sanpy/kym/interface/preferences_dialog.py new file mode 100644 index 00000000..4a288b58 --- /dev/null +++ b/sanpy/kym/interface/preferences_dialog.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +Preferences dialog for SanPy Kymograph application. + +This module provides a preferences dialog that allows users to configure +application settings. Preferences are stored in a JSON file and loaded +with validation against a gold standard preferences dictionary. +""" + +import os +import json +from typing import Dict, Any, Optional +from PyQt5.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QCheckBox, + QComboBox, QLineEdit, QPushButton, QLabel, QFormLayout, + QDialogButtonBox, QMessageBox, QWidget, QScrollArea +) +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class PreferencesDialog(QDialog): + """ + Preferences dialog for SanPy Kymograph application. + + This dialog provides a user interface for configuring application + preferences. It uses QGroupBox widgets to organize settings and + stores preferences in a JSON file. + """ + + # Signal emitted when preferences are saved + preferencesSaved = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("SanPy Preferences") + self.setModal(True) + self.setMinimumSize(500, 400) + + # Initialize preferences + self.preferences = {} + self.widgets = {} # Store widget references for easy access + + # Setup UI + self.setupUI() + + # Load current preferences + self.loadPreferences() + + # Connect signals + self.connectSignals() + + def setupUI(self): + """Setup the user interface.""" + # Main layout + main_layout = QVBoxLayout(self) + + # Create scroll area for preferences + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Create scroll widget + scroll_widget = QWidget() + scroll_layout = QVBoxLayout(scroll_widget) + + # Create preference groups + self.createLoadKymographGroup(scroll_layout) + + # Add stretch to push everything to the top + scroll_layout.addStretch() + + # Set scroll widget + scroll_area.setWidget(scroll_widget) + main_layout.addWidget(scroll_area) + + # Create button box + button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, + Qt.Horizontal, self + ) + main_layout.addWidget(button_box) + + # Connect button box signals + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + def createLoadKymographGroup(self, parent_layout): + """Create the 'Load Kymograph' preference group.""" + group_box = QGroupBox("Load Kymograph") + group_layout = QFormLayout(group_box) + + # Olympus Export checkbox + self.olympus_export_checkbox = QCheckBox("Olympus Export") + self.olympus_export_checkbox.setToolTip( + "Enable Olympus export format when loading kymograph files" + ) + group_layout.addRow("Olympus Export:", self.olympus_export_checkbox) + + # Store widget reference + self.widgets['Load Kymograph'] = { + 'olympus_export': self.olympus_export_checkbox + } + + parent_layout.addWidget(group_box) + + def connectSignals(self): + """Connect widget signals.""" + # Connect the accept signal to save preferences + self.accepted.connect(self.savePreferences) + + def getDefaultPreferences(self) -> Dict[str, Dict[str, Any]]: + """ + Get the default preferences dictionary. + + This is the 'gold standard' preferences dict that defines + all valid preference keys and their default values. + + Returns: + Dictionary containing default preferences organized by group. + """ + return { + 'Load Kymograph': { + 'olympus_export': False + } + } + + def getPreferencesFilePath(self) -> str: + """ + Get the path to the preferences file. + + Returns: + Path to the preferences JSON file. + """ + # Use the sanpy user files directory + user_files_dir = os.path.join( + os.path.expanduser("~"), + "SanPy-User-Files" + ) + + # Create directory if it doesn't exist + os.makedirs(user_files_dir, exist_ok=True) + + return os.path.join(user_files_dir, "kymograph_preferences.json") + + def loadPreferences(self): + """Load preferences from JSON file.""" + preferences_file = self.getPreferencesFilePath() + + # Start with default preferences + self.preferences = self.getDefaultPreferences() + + # Try to load from file + if os.path.exists(preferences_file): + try: + with open(preferences_file, 'r') as f: + loaded_prefs = json.load(f) + + # Validate loaded preferences against gold standard + self.preferences = self.validatePreferences(loaded_prefs) + logger.info(f"Loaded preferences from {preferences_file}") + + except (json.JSONDecodeError, IOError) as e: + logger.warning(f"Failed to load preferences from {preferences_file}: {e}") + # Keep default preferences + else: + logger.info(f"No preferences file found at {preferences_file}, using defaults") + + # Update UI with loaded preferences + self.updateUIFromPreferences() + + def validatePreferences(self, loaded_prefs: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """ + Validate loaded preferences against the gold standard. + + Only loads keys/values that exist in the default preferences dict. + + Args: + loaded_prefs: Preferences loaded from JSON file + + Returns: + Validated preferences dictionary + """ + default_prefs = self.getDefaultPreferences() + validated_prefs = default_prefs.copy() + + for group_name, group_prefs in loaded_prefs.items(): + if group_name in default_prefs: + for key, value in group_prefs.items(): + if key in default_prefs[group_name]: + # Validate value type + expected_type = type(default_prefs[group_name][key]) + if isinstance(value, expected_type): + validated_prefs[group_name][key] = value + else: + logger.warning( + f"Invalid preference type for {group_name}.{key}: " + f"expected {expected_type}, got {type(value)}" + ) + else: + logger.warning(f"Unknown preference key: {group_name}.{key}") + else: + logger.warning(f"Unknown preference group: {group_name}") + + return validated_prefs + + def updateUIFromPreferences(self): + """Update UI widgets with current preferences.""" + # Update Load Kymograph group + if 'Load Kymograph' in self.preferences: + load_prefs = self.preferences['Load Kymograph'] + + if 'olympus_export' in load_prefs: + self.olympus_export_checkbox.setChecked(load_prefs['olympus_export']) + + def getPreferencesFromUI(self) -> Dict[str, Dict[str, Any]]: + """ + Get current preferences from UI widgets. + + Returns: + Dictionary containing current preferences from UI. + """ + prefs = {} + + # Get Load Kymograph preferences + prefs['Load Kymograph'] = { + 'olympus_export': self.olympus_export_checkbox.isChecked() + } + + return prefs + + def savePreferences(self): + """Save preferences to JSON file.""" + # Get preferences from UI + self.preferences = self.getPreferencesFromUI() + + # Save to file + preferences_file = self.getPreferencesFilePath() + + try: + with open(preferences_file, 'w') as f: + json.dump(self.preferences, f, indent=2) + + logger.info(f"Preferences saved to {preferences_file}") + + # Emit signal + self.preferencesSaved.emit(self.preferences) + + except IOError as e: + logger.error(f"Failed to save preferences to {preferences_file}: {e}") + QMessageBox.warning( + self, + "Save Error", + f"Failed to save preferences:\n{str(e)}" + ) + + def getPreference(self, group: str, key: str, default: Any = None) -> Any: + """ + Get a specific preference value. + + Args: + group: Preference group name + key: Preference key name + default: Default value if preference doesn't exist + + Returns: + Preference value or default + """ + if group in self.preferences and key in self.preferences[group]: + return self.preferences[group][key] + return default + + def setPreference(self, group: str, key: str, value: Any): + """ + Set a specific preference value. + + Args: + group: Preference group name + key: Preference key name + value: Value to set + """ + if group not in self.preferences: + self.preferences[group] = {} + self.preferences[group][key] = value \ No newline at end of file diff --git a/sanpy/kym/interface/preferences_manager.py b/sanpy/kym/interface/preferences_manager.py new file mode 100644 index 00000000..fe7df27f --- /dev/null +++ b/sanpy/kym/interface/preferences_manager.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Preferences manager for SanPy Kymograph application. + +This module provides a singleton preferences manager that handles +loading, saving, and accessing application preferences. +""" + +import os +import json +from typing import Dict, Any, Optional +from PyQt5.QtCore import QObject, pyqtSignal + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class PreferencesManager(QObject): + """ + Singleton preferences manager for SanPy Kymograph application. + + This class provides a centralized way to access and manage + application preferences. It loads preferences from a JSON file + and validates them against a gold standard preferences dictionary. + """ + + # Signal emitted when preferences are changed + preferencesChanged = pyqtSignal(dict) + + _instance = None + _initialized = False + + def __new__(cls): + """Ensure singleton pattern.""" + if cls._instance is None: + cls._instance = super(PreferencesManager, cls).__new__(cls) + return cls._instance + + def __init__(self): + """Initialize the preferences manager.""" + if self._initialized: + return + + super().__init__() + + # Initialize preferences + self.preferences = {} + + # Load preferences + self.loadPreferences() + + self._initialized = True + + def getDefaultPreferences(self) -> Dict[str, Dict[str, Any]]: + """ + Get the default preferences dictionary. + + This is the 'gold standard' preferences dict that defines + all valid preference keys and their default values. + + Returns: + Dictionary containing default preferences organized by group. + """ + return { + 'Load Kymograph': { + 'olympus_export': False + } + } + + def getPreferencesFilePath(self) -> str: + """ + Get the path to the preferences file. + + Returns: + Path to the preferences JSON file. + """ + # Use the sanpy user files directory + user_files_dir = os.path.join( + os.path.expanduser("~"), + "SanPy-User-Files" + ) + + # Create directory if it doesn't exist + os.makedirs(user_files_dir, exist_ok=True) + + return os.path.join(user_files_dir, "kymograph_preferences.json") + + def loadPreferences(self): + """Load preferences from JSON file.""" + preferences_file = self.getPreferencesFilePath() + + # Start with default preferences + self.preferences = self.getDefaultPreferences() + + # Try to load from file + if os.path.exists(preferences_file): + try: + with open(preferences_file, 'r') as f: + loaded_prefs = json.load(f) + + # Validate loaded preferences against gold standard + self.preferences = self.validatePreferences(loaded_prefs) + logger.info(f"Loaded preferences from {preferences_file}") + + except (json.JSONDecodeError, IOError) as e: + logger.warning(f"Failed to load preferences from {preferences_file}: {e}") + # Keep default preferences + else: + logger.info(f"No preferences file found at {preferences_file}, using defaults") + + def validatePreferences(self, loaded_prefs: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """ + Validate loaded preferences against the gold standard. + + Only loads keys/values that exist in the default preferences dict. + + Args: + loaded_prefs: Preferences loaded from JSON file + + Returns: + Validated preferences dictionary + """ + default_prefs = self.getDefaultPreferences() + validated_prefs = default_prefs.copy() + + for group_name, group_prefs in loaded_prefs.items(): + if group_name in default_prefs: + for key, value in group_prefs.items(): + if key in default_prefs[group_name]: + # Validate value type + expected_type = type(default_prefs[group_name][key]) + if isinstance(value, expected_type): + validated_prefs[group_name][key] = value + else: + logger.warning( + f"Invalid preference type for {group_name}.{key}: " + f"expected {expected_type}, got {type(value)}" + ) + else: + logger.warning(f"Unknown preference key: {group_name}.{key}") + else: + logger.warning(f"Unknown preference group: {group_name}") + + return validated_prefs + + def savePreferences(self, preferences: Optional[Dict[str, Dict[str, Any]]] = None): + """ + Save preferences to JSON file. + + Args: + preferences: Preferences to save. If None, uses current preferences. + """ + if preferences is not None: + self.preferences = preferences + + # Save to file + preferences_file = self.getPreferencesFilePath() + + try: + with open(preferences_file, 'w') as f: + json.dump(self.preferences, f, indent=2) + + logger.info(f"Preferences saved to {preferences_file}") + + # Emit signal + self.preferencesChanged.emit(self.preferences) + + except IOError as e: + logger.error(f"Failed to save preferences to {preferences_file}: {e}") + raise + + def getPreference(self, group: str, key: str, default: Any = None) -> Any: + """ + Get a specific preference value. + + Args: + group: Preference group name + key: Preference key name + default: Default value if preference doesn't exist + + Returns: + Preference value or default + """ + if group in self.preferences and key in self.preferences[group]: + return self.preferences[group][key] + return default + + def setPreference(self, group: str, key: str, value: Any): + """ + Set a specific preference value. + + Args: + group: Preference group name + key: Preference key name + value: Value to set + """ + if group not in self.preferences: + self.preferences[group] = {} + self.preferences[group][key] = value + + def getAllPreferences(self) -> Dict[str, Dict[str, Any]]: + """ + Get all current preferences. + + Returns: + Dictionary containing all current preferences. + """ + return self.preferences.copy() + + def resetToDefaults(self): + """Reset all preferences to default values.""" + self.preferences = self.getDefaultPreferences() + self.savePreferences() + logger.info("Preferences reset to defaults") + + def reloadPreferences(self): + """Reload preferences from file.""" + self.loadPreferences() + self.preferencesChanged.emit(self.preferences) + logger.info("Preferences reloaded from file") + +# Global instance +preferences_manager = PreferencesManager() \ No newline at end of file diff --git a/sanpy/kym/interface/tests/test_preferences.py b/sanpy/kym/interface/tests/test_preferences.py new file mode 100644 index 00000000..022fed55 --- /dev/null +++ b/sanpy/kym/interface/tests/test_preferences.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Test script for the preferences system. + +This script demonstrates how to use the preferences dialog and manager. +""" + +import sys +import os +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QLabel, QHBoxLayout +from PyQt5.QtCore import Qt + +# Add the sanpy directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..')) + +from sanpy.kym.interface.preferences_dialog import PreferencesDialog +from sanpy.kym.interface.preferences_manager import preferences_manager +from sanpy.kym.logger import get_logger + +logger = get_logger(__name__) + +class PreferencesTestWindow(QMainWindow): + """Simple test window for the preferences system.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Preferences Test") + self.setGeometry(100, 100, 400, 300) + + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Create layout + layout = QVBoxLayout(central_widget) + + # Add some test controls + self.createControls(layout) + + # Connect to preferences manager signals + preferences_manager.preferencesChanged.connect(self.onPreferencesChanged) + + def createControls(self, layout): + """Create test controls.""" + # Title + title_label = QLabel("Preferences System Test") + title_label.setAlignment(Qt.AlignCenter) + title_label.setStyleSheet("font-size: 16px; font-weight: bold; margin: 10px;") + layout.addWidget(title_label) + + # Current preferences display + self.prefs_label = QLabel("Current preferences will be shown here") + self.prefs_label.setWordWrap(True) + self.prefs_label.setStyleSheet("background-color: #f0f0f0; padding: 10px; border: 1px solid #ccc;") + layout.addWidget(self.prefs_label) + + # Update the display + self.updatePreferencesDisplay() + + # Buttons + button_layout = QHBoxLayout() + + # Show preferences dialog button + show_prefs_btn = QPushButton("Show Preferences Dialog") + show_prefs_btn.clicked.connect(self.showPreferences) + button_layout.addWidget(show_prefs_btn) + + # Reset preferences button + reset_prefs_btn = QPushButton("Reset to Defaults") + reset_prefs_btn.clicked.connect(self.resetPreferences) + button_layout.addWidget(reset_prefs_btn) + + # Reload preferences button + reload_prefs_btn = QPushButton("Reload from File") + reload_prefs_btn.clicked.connect(self.reloadPreferences) + button_layout.addWidget(reload_prefs_btn) + + layout.addLayout(button_layout) + + # Add stretch to push everything to the top + layout.addStretch() + + def showPreferences(self): + """Show the preferences dialog.""" + dialog = PreferencesDialog(self) + + # Connect the preferences saved signal + dialog.preferencesSaved.connect(self.onPreferencesSaved) + + # Show the dialog + result = dialog.exec_() + + if result == PreferencesDialog.Accepted: + logger.info("Preferences dialog accepted") + else: + logger.info("Preferences dialog cancelled") + + def onPreferencesSaved(self, preferences): + """Called when preferences are saved from the dialog.""" + logger.info("Preferences saved from dialog") + self.updatePreferencesDisplay() + + def onPreferencesChanged(self, preferences): + """Called when preferences are changed via the manager.""" + logger.info("Preferences changed via manager") + self.updatePreferencesDisplay() + + def resetPreferences(self): + """Reset preferences to defaults.""" + preferences_manager.resetToDefaults() + logger.info("Preferences reset to defaults") + + def reloadPreferences(self): + """Reload preferences from file.""" + preferences_manager.reloadPreferences() + logger.info("Preferences reloaded from file") + + def updatePreferencesDisplay(self): + """Update the preferences display label.""" + prefs = preferences_manager.getAllPreferences() + + # Format preferences for display + prefs_text = "Current Preferences:\n\n" + + for group_name, group_prefs in prefs.items(): + prefs_text += f"{group_name}:\n" + for key, value in group_prefs.items(): + prefs_text += f" {key}: {value}\n" + prefs_text += "\n" + + self.prefs_label.setText(prefs_text) + +def main(): + """Main function to run the test.""" + # Set dark theme before creating QApplication + try: + import qdarktheme + qdarktheme.enable_hi_dpi() + except ImportError: + print("qdarktheme not available, using default theme") + + app = QApplication(sys.argv) + + # Apply dark theme after QApplication is created + try: + import qdarktheme + qdarktheme.setup_theme("dark") + except ImportError: + pass + + # Create and show the test window + window = PreferencesTestWindow() + window.show() + + # Run the application + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sanpy/kym/interface/tests/test_tab_window.py b/sanpy/kym/interface/tests/test_tab_window.py new file mode 100644 index 00000000..09f488e1 --- /dev/null +++ b/sanpy/kym/interface/tests/test_tab_window.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" +Test script for the tabbed TifTreeWindow with both tree and table views. +""" + +import sys +import os +from PyQt5.QtWidgets import QApplication + +# Add the parent directory to the path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) + +from sanpy.kym.interface.kym_file_list.tif_tree_window import TifTreeWindow + +def main(): + """Test the tabbed window with both views.""" + app = QApplication(sys.argv) + + # Use a test path that exists + test_path = "/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-atp-20250623-RHC" + + # Create and show the window + window = TifTreeWindow(path=test_path) + window.show() + + print("Tabbed window created successfully!") + print("You should see two tabs: 'Tree View' and 'Table View'") + print("Both views should show the same data from the backend") + + sys.exit(app.exec_()) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sanpy/kym/interface/tests/test_table_view.py b/sanpy/kym/interface/tests/test_table_view.py new file mode 100644 index 00000000..aa6d33d8 --- /dev/null +++ b/sanpy/kym/interface/tests/test_table_view.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Test script to demonstrate the TifTableView widget. +""" + +import sys +import tempfile +import os +import shutil + +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget +from PyQt5.QtCore import Qt + +from sanpy.kym.tif_file_backend import TifFileBackend +from sanpy.kym.interface.kym_file_list.tif_table_view import TifTableView + +def create_test_data(): + """Create test TIF files for demonstration.""" + temp_dir = tempfile.mkdtemp() + + # Create directory structure + os.makedirs(os.path.join(temp_dir, "Experiment1", "Control", "Day1"), exist_ok=True) + os.makedirs(os.path.join(temp_dir, "Experiment1", "Control", "Day2"), exist_ok=True) + os.makedirs(os.path.join(temp_dir, "Experiment1", "Treatment", "Day1"), exist_ok=True) + os.makedirs(os.path.join(temp_dir, "Experiment2", "Control", "Day1"), exist_ok=True) + + # Create TIF files with proper naming convention + files_to_create = [ + ("Experiment1/Control/Day1/20250312 ISAN R1 LS1 Control.tif", "test"), + ("Experiment1/Control/Day1/20250312 ISAN R2 LS1 Control.tif", "test"), + ("Experiment1/Control/Day2/20250312 ISAN R1 LS1 Control.tif", "test"), + ("Experiment1/Treatment/Day1/20250312 ISAN R1 LS1 Treatment.tif", "test"), + ("Experiment1/Treatment/Day1/20250312 ISAN R2 LS1 Treatment.tif", "test"), + ("Experiment2/Control/Day1/20250312 SSAN R1 LS1 Control.tif", "test"), + ("Experiment2/Control/Day1/20250312 SSAN R2 LS1 Control.tif", "test"), + ] + + for filepath, content in files_to_create: + full_path = os.path.join(temp_dir, filepath) + with open(full_path, 'w') as f: + f.write(content) + + return temp_dir + +class TestWindow(QMainWindow): + """Test window for the TifTableView widget.""" + + def __init__(self, data_dir): + super().__init__() + self.data_dir = data_dir + self.setupUI() + + def setupUI(self): + """Set up the user interface.""" + self.setWindowTitle("TifTableView Test") + self.setGeometry(100, 100, 1200, 800) + + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Create layout + layout = QVBoxLayout(central_widget) + + # Create backend + self.backend = TifFileBackend(self.data_dir, sort_by_grandparent=True) + + # Create table view + self.table_view = TifTableView(self.backend) + + # Connect signals + self.table_view.fileToggled.connect(self.onFileToggled) + self.table_view.fileDoubleClicked.connect(self.onFileDoubleClicked) + self.table_view.plotRoisRequested.connect(self.onPlotRoisRequested) + self.table_view.exportCompleted.connect(self.onExportCompleted) + + # Add to layout + layout.addWidget(self.table_view) + + def onFileToggled(self, file_path, checked): + """Handle file toggle events.""" + print(f"File toggled: {os.path.basename(file_path)} -> {'checked' if checked else 'unchecked'}") + + def onFileDoubleClicked(self, file_path): + """Handle file double-click events.""" + print(f"File double-clicked: {os.path.basename(file_path)}") + + def onPlotRoisRequested(self, file_path): + """Handle plot ROIs requests.""" + print(f"Plot ROIs requested for: {os.path.basename(file_path)}") + + def onExportCompleted(self, export_path): + """Handle export completion.""" + print(f"Export completed: {export_path}") + +def main(): + """Main function to run the test.""" + app = QApplication(sys.argv) + + # Create test data + data_dir = create_test_data() + + try: + # Create and show test window + window = TestWindow(data_dir) + window.show() + + print("TifTableView Test Window") + print("Features to test:") + print("1. Column sorting (click column headers)") + print("2. Filtering (use dropdowns and search box)") + print("3. Checkbox selection (click checkboxes)") + print("4. Bulk operations (Select All, Deselect All, Invert Selection)") + print("5. Context menu (right-click on rows)") + print("6. Export functionality (Export button)") + print("7. Double-click on files") + + # Run the application + sys.exit(app.exec_()) + + finally: + # Clean up test data + shutil.rmtree(data_dir) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sanpy/kym/interface/tif_pool_scatter.py b/sanpy/kym/interface/tif_pool_scatter.py new file mode 100644 index 00000000..42818970 --- /dev/null +++ b/sanpy/kym/interface/tif_pool_scatter.py @@ -0,0 +1,1745 @@ +import os +import sys +from functools import partial +from itertools import combinations +from typing import Optional, List + +import pandas as pd +import numpy as np + +from scipy.stats import variation + +from matplotlib.backends import backend_qt5agg +import matplotlib as mpl +import matplotlib.pyplot as plt + +import seaborn as sns + +# sns.set_palette("colorblind") + +from statannotations.Annotator import Annotator + +from PyQt5 import QtGui, QtCore, QtWidgets + +# from colin_traces import ColinTraces +from sanpy.kym.simple_scatter.colin_tree_widget import KymTreeWidget +from sanpy.kym.interface.kym_file_list.tif_viewer_base import MainWindow + +# from colin_traces import plotCellID +# from colin_global import conditionOrder +# from colin_simple_figure import KymRoiMainWindow + +# abb 20250704 +conditionOrder = ['Control', 'Ivab'] + +from sanpy.kym.logger import get_logger + +logger = get_logger(__name__) + + +class simpleTableWidget(QtWidgets.QTableWidget): + def __init__(self, df: Optional[pd.DataFrame] = None): + super().__init__(None) + self._df = df + if self._df is not None: + self.setDf(self._df) + + def setDf(self, df: pd.DataFrame): + self._df = df + + self.setRowCount(df.shape[0]) + self.setColumnCount(df.shape[1]) + self.setHorizontalHeaderLabels(df.columns) + + for row in range(df.shape[0]): + for col in range(df.shape[1]): + item = QtWidgets.QTableWidgetItem(str(df.iloc[row, col])) + self.setItem(row, col, item) + + +def _makeComboBox( + name: str, items: List[str], defaultItem: Optional[str] = None, callbackFn=None +) -> QtWidgets.QHBoxLayout: + hBoxLayout = QtWidgets.QHBoxLayout() + aLabel = QtWidgets.QLabel(name) + hBoxLayout.addWidget(aLabel) + + aComboBox = QtWidgets.QComboBox() + hBoxLayout.addWidget(aComboBox) + + aComboBox.addItems(items) + + if defaultItem is not None: + _index = items.index(defaultItem) + else: + # default to first + _index = 0 + aComboBox.setCurrentIndex(_index) + + if callbackFn is not None: + aComboBox.currentTextChanged.connect(partial(callbackFn, name)) + + return hBoxLayout + + +def _getNumSpikesDf(df) -> pd.DataFrame: + dictList = [] + for oneCellID in df['Cell ID'].unique(): + logger.info(f'oneCellStr:{oneCellID}') + oneDf = df[df['Cell ID'] == oneCellID] + + _oneRegion = oneDf['Region'].iloc[0] + + numSpikesControl = 0 + for oneCond in conditionOrder: + oneCondDf = oneDf[oneDf['Condition'] == oneCond] + # print(oneCondDf) + numSpikes = len(oneCondDf) + # print(f'numSpikes:{numSpikes}') + # if numSpikes == 0: + # logger.error(f'0 spikes oneCellStr:{oneCellID} cond:{oneCond}') + # continue + if oneCond == 'Control': + numSpikesControl = numSpikes + percentChange = 100 + else: + if numSpikesControl == 0: + percentChange = np.nan + else: + percentChange = numSpikes / numSpikesControl * 100 + + # if numSpikes == 0: + # oneFreq = np.nan + # else: + # from colin_peak_freq import getSimpleFreq + # tifPath = oneCondDf['tifPath'].iloc[0] + # oneFreq = getSimpleFreq(tifPath, csvPath) + + oneDict = { + 'Cell ID': oneCellID, + 'Region': _oneRegion, + 'Condition': oneCond, + 'Num Spikes': numSpikes, + 'percentChange': percentChange, + } + # print(oneDict) + dictList.append(oneDict) + + dfNumSpikes = pd.DataFrame(dictList) + print('=== dfNumSpikes:') + print(dfNumSpikes) + + # + # run stats on num spikes + from scipy.stats import mannwhitneyu + + # 1) isan vs ssan in control + iSanSpikes = dfNumSpikes + iSanSpikes = iSanSpikes[iSanSpikes['Region'] == 'ISAN'] + iSanControlSpikes = iSanSpikes[iSanSpikes['Condition'] == 'Control'] + iSanIvabSpikes = iSanSpikes[iSanSpikes['Condition'] == 'Ivab'] + # + sSanSpikes = dfNumSpikes + sSanSpikes = sSanSpikes[sSanSpikes['Region'] == 'SSAN'] + sSanControlSpikes = sSanSpikes[sSanSpikes['Condition'] == 'Control'] + sSanIvabSpikes = sSanSpikes[sSanSpikes['Condition'] == 'Ivab'] + + # compare control and ivan (for each of isan and ssan) + # ssan + sample1 = sSanControlSpikes['Num Spikes'].to_list() + sample2 = sSanIvabSpikes['Num Spikes'].to_list() + if len(sample1) == 0 or len(sample2) == 0: + pass + else: + print('=== mannwhitneyu comparing num spikes in Control versus Ivab (SSAN)') + result = mannwhitneyu(sample1, sample2) + print(result) + + # isan + sample1 = iSanControlSpikes['Num Spikes'].to_list() + sample2 = iSanIvabSpikes['Num Spikes'].to_list() + if len(sample1) == 0 or len(sample2) == 0: + pass + else: + print('=== mannwhitneyu comparing num spikes in Control versus Ivab (ISAN)') + result = mannwhitneyu(sample1, sample2) + print(result) + + # compare isan to ssan control spikes + sample1 = iSanControlSpikes['Num Spikes'].to_list() + sample2 = sSanControlSpikes['Num Spikes'].to_list() + logger.warning(f'sample1:{len(sample1)} sample2:{len(sample2)}') + if len(sample1) == 0 or len(sample2) == 0: + pass + else: + print('=== mannwhitneyu comparing num spikes in control (SSAN vs ISAN)') + result = mannwhitneyu(sample1, sample2) + print(result) + + return dfNumSpikes + + +class ScatterOptions(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Scatter Options") + self.setGeometry(100, 100, 300, 200) + + # Create layout and widgets here + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + + # Add widgets to the layout + label = QtWidgets.QLabel("Scatter Options") + layout.addWidget(label) + + +class ScatterWidget(QtWidgets.QMainWindow): + """Plot a scatter plot from peak/diameter detection df.""" + + # TODO emit signal on user selection + def __init__( + self, + masterDf: pd.DataFrame, + meanDf: pd.DataFrame, + xStat: str, + yStat: str, + hueList: List[str], + defaultPlotType: str = None, + defaultHue: str = None, + defaultStyle: str = None, + imgFolder: str = None, # to load sm2 images + plotColumns=None, + ): + super().__init__(None) + + # logger.error('problems with tif Path') + # print(df['Path'].iloc[0]) + # sys.exit() + + # always construct from master (one peak per row) + # self._colinTraces = ColinTraces(masterDf, meanDf) + + self._masterDf = masterDf + self._meanDf = meanDf + + # columns in analysis df + if plotColumns is None: + # all columns + self._columns = list( + masterDf.columns + ) # reduce analysis keys, just for the user + else: + # only show these columns + self._columns = plotColumns + + self._hueList = hueList + + # for sm2 output sparkmaster + self._imgFolder = imgFolder + + self._plotColumns = plotColumns + + self._plotTypes = [ + 'Line Plot', + 'Scatter', + 'Swarm', + 'Swarm + Mean', + 'Swarm + Mean + STD', + 'Swarm + Mean + SEM', + 'Box Plot', + 'Histogram', + 'Cumulative Histogram', + ] + + self._state = { + 'xStat': xStat, + 'yStat': yStat, + 'hue': hueList[0] if defaultHue is None else defaultHue, + 'style': hueList[0] if defaultStyle is None else defaultHue, + 'plotType': 'Scatter' if defaultPlotType is None else defaultPlotType, + # 'Swarm + Mean', + 'makeSquare': False, + 'legend': True, + # 'tables': True, + 'stats': True, + 'plotDf': 'Mean', + 'plotStat': 'Mean', # either mean(default) or _cv + } + + # re-wire right-click (for entire widget) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + self._buildUI() + + # enable/disable combobox(es) based on plot type + self._updateGuiOnPlotType(self._state['plotType']) + + self.replot() + + def getDf(self): + if self.state['plotDf'] == 'Raw': + return self._masterDf + elif self.state['plotDf'] == 'Mean': + return self._meanDf + else: + logger.error(f"did not understand state plotDf {self.state['plotDf']}") + + def _buildUI(self): + + self.status_bar = self.statusBar() + self.setStatusBar('started ...') + + # this is dangerous, collides with self.mplWindow() + self.fig = mpl.figure.Figure() + # self.static_canvas = backend_qt5agg.FigureCanvas(self.fig) + self.static_canvas = backend_qt5agg.FigureCanvasQTAgg(self.fig) + self.static_canvas.setFocusPolicy( + QtCore.Qt.ClickFocus + ) # this is really tricky and annoying + self.static_canvas.setFocus() + # self.axs[idx] = self.static_canvas.figure.add_subplot(numRow,1,plotNum) + + # abb 202505 removed + # self.gs = self.fig.add_gridspec( + # 1, 1, left=0.1, right=0.9, bottom=0.1, top=0.9, wspace=0.05, hspace=0.05 + # ) + + # redraw everything + self.static_canvas.figure.clear() + + # self.axScatter = self.static_canvas.figure.add_subplot(self.gs[0, 0]) + self.axScatter = self.static_canvas.figure.add_subplot(1, 1, 1) + + # despine top/right + self.axScatter.spines["right"].set_visible(False) + self.axScatter.spines["top"].set_visible(False) + # self.axScatter = None + + self.fig.canvas.mpl_connect("key_press_event", self.keyPressEvent) + + self.mplToolbar = mpl.backends.backend_qt5agg.NavigationToolbar2QT( + self.static_canvas, self.static_canvas + ) + + # put toolbar and static_canvas in a V layout + # plotWidget = QtWidgets.QWidget() + vLayoutPlot = QtWidgets.QVBoxLayout(self) + aWidget = QtWidgets.QWidget() + aWidget.setLayout(vLayoutPlot) + self.setCentralWidget(aWidget) + # vLayoutPlot.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + self._topToolbar = self._buildTopToobar() + vLayoutPlot.addLayout(self._topToolbar) + + # Create a horizontal splitter for resizable left toolbar and plot + self._mainSplitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + self._mainSplitter.setHandleWidth( + 8 + ) # Make the splitter handle wider and more visible + self._mainSplitter.setStyleSheet( + """ + QSplitter::handle { + background-color: #cccccc; + border: 1px solid #999999; + } + QSplitter::handle:hover { + background-color: #aaaaaa; + } + """ + ) + vLayoutPlot.addWidget(self._mainSplitter) + + # Build left toolbar as a widget + self._leftToolbarWidget = self._buildLeftToobar() + self._mainSplitter.addWidget(self._leftToolbarWidget) + + # Add the matplotlib canvas to the splitter + self._mainSplitter.addWidget(self.static_canvas) + + # Set initial splitter sizes (left toolbar gets 300px, rest goes to plot) + self._mainSplitter.setSizes([300, 800]) + + # + # raw and y-stat summary in tabs + self._tabwidget = QtWidgets.QTabWidget() + + self.rawTableWidget = simpleTableWidget(self.getDf()) + self._tabwidget.addTab(self.rawTableWidget, "Raw") + + self.yStatSummaryTableWidget = simpleTableWidget() + self._tabwidget.addTab(self.yStatSummaryTableWidget, "Y-Stat Summary") + + self._tabwidget.setCurrentIndex(0) + + self._tabwidget.setVisible(False) + + vLayoutPlot.addWidget(self._tabwidget) + + # + self.static_canvas.draw() + + @property + def state(self): + return self._state + + def copyTableToClipboard(self): + _tabIndex = self._tabwidget.currentIndex() + if _tabIndex == 0: + logger.info('=== copy raw to clipboard ===') + self.getDf().to_clipboard(sep="\t", index=False) + _ret = 'Copied raw table to clipboard' + elif _tabIndex == 1: + logger.info('=== copy summary to clipboard ===') + self._dfYStatSummary.to_clipboard(sep="\t", index=False) + _ret = 'Copied y-stat-summary table to clipboard' + else: + logger.warning(f'did not understand tab: {_tabIndex}') + _ret = 'Did not copy, please select a table' + return _ret + + def _buildLeftToobar(self) -> QtWidgets.QWidget: + # Create a widget to hold the layout + leftToolbarWidget = QtWidgets.QWidget() + leftToolbarWidget.setMinimumWidth(200) # Set minimum width for the toolbar + leftToolbarWidget.setMaximumWidth(600) # Set maximum width for the toolbar + + vBoxLayout = QtWidgets.QVBoxLayout(leftToolbarWidget) + # vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + vBoxLayout.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + # hLayout for ['Regions', 'Conditions'] + regionConditionHBox = QtWidgets.QHBoxLayout() + vBoxLayout.addLayout(regionConditionHBox) + + # + # Regions group box + regionsGroupBox = QtWidgets.QGroupBox('Regions') + regionConditionHBox.addWidget(regionsGroupBox) + + # one checkbox for each Region + regionsVLayout = QtWidgets.QVBoxLayout() + regionsGroupBox.setLayout(regionsVLayout) + + regions = self.getDf()['Region'].unique() + for regionStr in regions: + regionCheckBox = QtWidgets.QCheckBox(regionStr) + regionCheckBox.setChecked(True) + regionCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Region', regionStr) + ) + regionsVLayout.addWidget(regionCheckBox) + + # + # conditions group box + conditionsGroupBox = QtWidgets.QGroupBox('Conditions') + regionConditionHBox.addWidget(conditionsGroupBox) + + # one checkbox for each Condition + conditionsVLayout = QtWidgets.QVBoxLayout() + conditionsGroupBox.setLayout(conditionsVLayout) + + conditions = sorted(self.getDf()['Condition'].unique()) + for conditionStr in conditions: + # each condition has a hlayout with epoch checkboxes + conditionHLayout = QtWidgets.QHBoxLayout() + conditionsVLayout.addLayout(conditionHLayout) + + conditionCheckBox = QtWidgets.QCheckBox(conditionStr) + conditionCheckBox.setChecked(True) + conditionCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Condition', conditionStr) + ) + conditionHLayout.addWidget(conditionCheckBox) + + # one checkbox for each Epoch + theseRows = self.getDf()['Condition'] == conditionStr + # abb 20250704 switched epoch to repeat !!! + try: + theseEpochs = sorted(self.getDf()[theseRows]['Repeat'].unique()) + except (KeyError) as e: + logger.error(f'did not understand Epoch column: {e}') + logger.error('self.getDf() is:') + logger.error(f'available columns are: {self.getDf().columns}') + return + + for epochInt in theseEpochs: + epochStr = str(epochInt) + epochCheckBox = QtWidgets.QCheckBox(epochStr) + epochCheckBox.setChecked(True) + + # if condition has 1 epoch, disable + if len(theseEpochs) == 1: + epochCheckBox.setEnabled(False) + + epochCheckBox.stateChanged.connect( + partial( + self._on_condition_checkbox, 'Epoch', conditionStr, epochStr + ) + ) + conditionHLayout.addWidget(epochCheckBox) + + # + # polarity group box + polarityGroupBox = QtWidgets.QGroupBox('Polarity') + regionConditionHBox.addWidget(polarityGroupBox) + + # one checkbox for each Condition + polaritiesVLayout = QtWidgets.QVBoxLayout() + polarityGroupBox.setLayout(polaritiesVLayout) + + # abb 202707 + try: + polarities = sorted(self.getDf()['Polarity'].unique()) + except (KeyError) as e: + logger.error(f'did not understand Polarity column: {e}') + logger.error('self.getDf() is:') + logger.error(f'available columns are: {self.getDf().columns}') + else: + + for polarityStr in polarities: + polarityCheckBox = QtWidgets.QCheckBox(polarityStr) + polarityCheckBox.setChecked(True) + polarityCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Polarity', polarityStr) + ) + polaritiesVLayout.addWidget(polarityCheckBox) + + # + # cell id group box + cellGroupBox = QtWidgets.QGroupBox('Cells') + vBoxLayout.addWidget(cellGroupBox) + + # one checkbox for each Condition + cellVLayout = QtWidgets.QVBoxLayout() + cellGroupBox.setLayout(cellVLayout) + + # All checkbox + # cellCheckBox = QtWidgets.QCheckBox('All') + # cellCheckBox.setChecked(True) + # cellCheckBox.stateChanged.connect( + # partial(self._on_cell_checkbox, 'All') + # ) + # cellVLayout.addWidget(cellCheckBox) + + # one checkbox per cell id + # populate with mean df + _df = self._meanDf + kymTreeWidget = KymTreeWidget(_df) + kymTreeWidget.toggleAllToggled.connect(self.slot_toggle_all_cell_id) + kymTreeWidget.cellToggled.connect(self.slot_toggle_cell) + kymTreeWidget.roiToggled.connect(self.slot_toggle_roi) + kymTreeWidget.roiSelected.connect(self.slot_roi_selected) + kymTreeWidget.cellSelected.connect(self.slot_cell_selected) + kymTreeWidget.plotCellID.connect(self.slot_plot_cell_id) + cellVLayout.addWidget(kymTreeWidget) + + return leftToolbarWidget + + def slot_toggle_all_cell_id(self, value: bool): + """Handle toggle all checkbox.""" + self.getDf()['show_cell'] = value + self.getDf()['show_roi'] = value + self.replot() + + def slot_toggle_cell(self, cell_id: str, checked: bool): + """Handle cell checkbox toggle.""" + df = self.getDf() + theseRows = df['Cell ID'] == cell_id + df.loc[theseRows, 'show_cell'] = checked + self.replot() + + def slot_toggle_roi(self, cell_id: str, roi_number: int, checked: bool): + """Handle ROI checkbox toggle.""" + df = self.getDf() + theseRows = (df['Cell ID'] == cell_id) & (df['ROI Number'] == roi_number) + df.loc[theseRows, 'show_roi'] = checked + self.replot() + + def slot_cell_selected(self, cell_id: str, condition: str): + logger.info(f'cell_id:"{cell_id}" condition:"{condition}"') + + def slot_plot_cell_id(self, cell_id: str, roi_number: int): + logger.info(f'cell_id:"{cell_id}" roi_number:{roi_number}') + + logger.warning('TODO: plotCellID()') + return + + # fig, ax = self._colinTraces.plotCellID(cell_id, roiLabelStr=roi_number) + + from colin_pool_plot import new_plotCellID + + fig, ax, _tmpDict = new_plotCellID( + self._masterDf, self._meanDf, cell_id, roi_number + ) + + if fig is None or ax is None: + return + + self._mainWindow = MainWindow(fig, ax) + self._mainWindow.setWindowTitle(f'cell ID:"{cell_id}" roi:{roi_number}') + self._mainWindow.show() + + def slot_roi_selected(self, cell_id: str, condition: str, roi_number: int): + # logger.info(f'cellID:"{cell_id}" condition:{condition} roiLabelInt:{roi_number} -->> plotCellID()') + # logger.info('-->> off') + logger.info('TODO: select roi peaks in plot !!!') + pass + + def _buildTopToobar(self) -> QtWidgets.QVBoxLayout: + vBoxLayout = QtWidgets.QVBoxLayout() + # vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + + # row 1 + hBoxLayout = QtWidgets.QHBoxLayout() + hBoxLayout.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hBoxLayout) + + plotTypeComboBox = _makeComboBox( + name='Plot Type', + items=self._plotTypes, + defaultItem=self._state['plotType'], + callbackFn=self._on_stat_combobox, + ) + hBoxLayout.addLayout(plotTypeComboBox) + + # plot type + # aName = 'Plot Type' + # aLabel = QtWidgets.QLabel(aName) + # hBoxLayout.addWidget(aLabel) + + # plotTypeComboBox = QtWidgets.QComboBox() + # plotTypeComboBox.addItems(self._plotTypes) + # _index = self._plotTypes.index('Scatter') # default to scatter plot + # plotTypeComboBox.setCurrentIndex(_index) + # plotTypeComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, aName) + # ) + # hBoxLayout.addWidget(plotTypeComboBox) + + # hue + _hueList = ['None'] + self._hueList + hueComboBox = _makeComboBox( + name='Hue', + items=_hueList, + defaultItem=self._state['hue'], + callbackFn=self._on_stat_combobox, + ) + hBoxLayout.addLayout(hueComboBox) + + # hueName = 'Hue' + # hueLabel = QtWidgets.QLabel(hueName) + # hBoxLayout.addWidget(hueLabel) + + # self.hueComboBox = QtWidgets.QComboBox() + # _hueList = ['None'] + self._hueList + # self.hueComboBox.addItems(_hueList) + # # find index from self._defaultHue + # if self._state['hue'] in self._hueList: + # _index = self._hueList.index(self._state['hue']) + # else: + # _index = 0 + # self.hueComboBox.setCurrentIndex(_index) + # # self.hueComboBox.setCurrentIndex(1) # default to 'ROI Number' + # self.hueComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, hueName) + # ) + # hBoxLayout.addWidget(self.hueComboBox) + + # style + _hueList = ['None'] + self._hueList + styleCombo = _makeComboBox( + name='Style', + items=_hueList, + callbackFn=self._on_stat_combobox, + ) + hBoxLayout.addLayout(styleCombo) + + # aName = 'Style' + # aLabel = QtWidgets.QLabel(aName) + # hBoxLayout.addWidget(aLabel) + + # self.styleComboBox = QtWidgets.QComboBox() + # _hueList = ['None'] + self._hueList + # self.styleComboBox.addItems(_hueList) + # # find index from self._defaultHue + # if self._state['style'] in self._hueList: + # _index = self._hueList.index(self._state['style']) + # else: + # _index = 0 + # self.styleComboBox.setCurrentIndex(_index) + # # self.hueComboBox.setCurrentIndex(1) # default to 'ROI Number' + # self.styleComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, aName) + # ) + # hBoxLayout.addWidget(self.styleComboBox) + + # legend + legendCheckBox = QtWidgets.QCheckBox('Legend') + legendCheckBox.setChecked(True) + legendCheckBox.stateChanged.connect( + lambda state: self._on_stat_combobox('Legend', state) + ) + hBoxLayout.addWidget(legendCheckBox) + + # tables + aCheckbox = QtWidgets.QCheckBox('Tables') + aCheckbox.setChecked(False) + aCheckbox.stateChanged.connect( + lambda state: self._on_stat_combobox('Tables', state) + ) + hBoxLayout.addWidget(aCheckbox) + + # stats + aCheckbox = QtWidgets.QCheckBox('Stats') + aCheckbox.setChecked(True) + aCheckbox.stateChanged.connect( + lambda state: self._on_stat_combobox('Stats', state) + ) + hBoxLayout.addWidget(aCheckbox) + + # plot + aPushButton = QtWidgets.QPushButton('Replot') + aPushButton.setCheckable(False) + aPushButton.clicked.connect(partial(self.replot)) + hBoxLayout.addWidget(aPushButton) + + # second row + hBoxLayout2 = QtWidgets.QHBoxLayout() + hBoxLayout2.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hBoxLayout2) + + # + xName = 'X-Stat' + xLabel = QtWidgets.QLabel(xName) + hBoxLayout2.addWidget(xLabel) + + self.xComboBox = QtWidgets.QComboBox() + self.xComboBox.addItems(self._columns) + _index = self._columns.index(self.state['xStat']) + self.xComboBox.setCurrentIndex(_index) + self.xComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, xName) + ) + hBoxLayout2.addWidget(self.xComboBox) + + # + yName = 'Y-Stat' + yLabel = QtWidgets.QLabel(yName) + hBoxLayout2.addWidget(yLabel) + + self.yComboBox = QtWidgets.QComboBox() + self.yComboBox.addItems(self._columns) + _index = self._columns.index(self.state['yStat']) + self.yComboBox.setCurrentIndex(_index) + self.yComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, yName) + ) + hBoxLayout2.addWidget(self.yComboBox) + + # popup to plot (raw, mean) dataframe + hBox = _makeComboBox( + name="Plot Data", + items=('Raw', 'Mean'), + defaultItem='Mean', + callbackFn=self._on_stat_combobox, + ) + hBoxLayout2.addLayout(hBox) + + # popup to plot mean(default) or CV + hBox = _makeComboBox( + name="Plot Stat", + items=('Mean', 'CV'), + defaultItem='Mean', + callbackFn=self._on_stat_combobox, + ) + hBoxLayout2.addLayout(hBox) + + # + return vBoxLayout + + def _updateGuiOnPlotType(self, plotType): + """Enable/disable giu on plot type.""" + # plot types turn on/off X-Stat + enableXStat = plotType == 'Scatter' + # self.xComboBox.setEnabled(enableXStat) + + def _on_stat_combobox(self, name: str, value: str): + """Handle both combobox and checkbox changes""" + logger.info(f'name:{name} value:{value}') + + if name == 'Plot Type': + self.state['plotType'] = value + self._updateGuiOnPlotType(self._state['plotType']) + + elif name == 'X-Stat': + self.state['xStat'] = value + + elif name == 'Y-Stat': + self.state['yStat'] = value + + elif name == 'Hue': + if value == 'None': + value = None + self.state['hue'] = value + + elif name == 'Style': + if value == 'None': + value = None + self.state['style'] = value + + elif name == 'Legend': + self.state['legend'] = value == QtCore.Qt.Checked + + elif name == 'Tables': + self._tabwidget.setVisible(value == QtCore.Qt.Checked) + # don't replot + return + + elif name == 'Stats': + # statistical tests on/off + self.state['stats'] = value == QtCore.Qt.Checked + + elif name == 'Plot Data': + # either master or mead dataframe + self.state['plotDf'] = value + + elif name == 'Plot Stat': + # either mean (default) or cv + self.state['plotStat'] = value + + else: + logger.warning(f'did not understand "{name}"') + + self.replot() + + def _on_condition_checkbox( + self, + name: str, + conditionStr: str, # either (Condition, Region) + epochStr: str = None, + value=None, # PyQt Checkbox value + ): + logger.info(f'name:{name} conditionStr:{conditionStr} value:{value}') + if name == 'Condition': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + self.getDf().loc[ + self.getDf()['Condition'] == conditionStr, 'show_condition' + ] = value + + self.replot() + + elif name == 'Region': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + self.getDf().loc[ + self.getDf()['Region'] == conditionStr, 'show_region' + ] = value + + self.replot() + + elif name == 'Polarity': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + self.getDf().loc[ + self.getDf()['Polarity'] == conditionStr, 'show_polarity' + ] = value + + self.replot() + + elif name == 'Epoch': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + logger.info(f' conditionStr:"{conditionStr}" epochStr:"{epochStr}"') + epochInt = int(epochStr) + theseRows = (self.getDf()['Condition'] == conditionStr) & ( + self.getDf()['Epoch'] == epochInt + ) + self.getDf().loc[theseRows, 'show_epoch'] = value + + self.replot() + + else: + logger.warning(f'did not understand name:"{name}') + + def _on_user_pick_scatterplot(self, event): + """ + event : matplotlib.backend_bases.PickEvent + """ + # logger.info(event) + logger.info( + f' event.artist:{event.artist}' + ) # matplotlib.collections.PathCollection + logger.info(f' event.ind:{event.ind}') + + # assuming scatterplot preserves index + ind = event.ind + dfRow = self.getDf().loc[ind] + print(dfRow) + + # todo: open image and overlay spark roi + # oneIdx = ind[0] # just the first ind + # self._sparkMasterShowImg(oneIdx) + + def _on_user_pick_lineplot(self, event): + logger.info('===') + logger.info(f'event.ind:{event.ind} event.artist:{event.artist}') + + def _on_user_pick_stripplot(self, event): + """ + Problem + ======= + If there is a nan value, like inst freq, our ind=ind+1 + """ + logger.info('===') + + try: + event.artist.label + except AttributeError as e: + logger.warning(f'event.artist.label failed: {e}') + return + + df = self._dfStripPlot + + xStat = self.state['xStat'] + yStat = self.state['yStat'] + hue = self.state['hue'] + # plotType = self.state['plotType'] + + dfNoNan = df[df[yStat].notna()] + + # print(f' xStat: {xStat} hue: {hue} plotType: {plotType}') + + # print(f' event.artist.label: "{event.artist.label}"') + # print(f' event.artist.animal: "{event.artist.animal}"') + # print(f' event.ind: {event.ind}') + + # CRITICAL TO STRIP NAN HERE IN CALLBACK !!! + df2 = dfNoNan[dfNoNan[hue] == event.artist.label] + colList = ['Cell ID', 'Region', 'Condition', 'ROI Number', xStat, yStat, hue] + # print(f'df2 from event.artist.label:"{event.artist.label}" is:') + # print(df2[colList]) + + # abb 20250519 THIS IS TOTALLY WRONG + # WTF DID I DO !!!! + # logger.info(f'=== user picked row ind :{event.ind}') + # print(df2[colList].iloc[event.ind]) + + # TODO: fix + df3 = df2[df2[xStat] == event.artist.animal] # animal is region (ssan, isan) + + # print(f'df3 from event.artist.animal:"{event.artist.animal}" is:') + # print(df3[colList]) + + # logger.info(f'picked gui event.ind:{event.ind}') + # try: + # print(df3[colList].iloc[event.ind]) + # except (IndexError) as e: + # logger.error('!!!!!') + # print(f'event.ind:{event.ind}') + # print(df3[colList]) + + # dfClicked = df3[colList].iloc[event.ind] + dfClicked = df3.iloc[event.ind] + print('user clicked -->>') + print(dfClicked[colList]) + # print(dfClicked.columns) + + # update status bar + cellID = dfClicked['Cell ID'].iloc[0] + condition = dfClicked['Condition'].iloc[0] + roiLabelStr = dfClicked['ROI Number'].iloc[0] + _str = f"Cell ID:'{cellID}' Condition:{condition} ROI Number: {roiLabelStr}" + self.setStatusBar(_str) + + modifiers = QtWidgets.QApplication.keyboardModifiers() + isShift = modifiers == QtCore.Qt.ShiftModifier + + # show raw analysis (first peak, user click can yield multiple) + if isShift: + clickedCellID = dfClicked['Cell ID'].iloc[0] + roiLabelStr = dfClicked['ROI Number'].iloc[0] + logger.info( + f'-->> plotting cell id:"{clickedCellID}" roiLabelStr:{roiLabelStr}' + ) + + # fig, ax = self._colinTraces.plotCellID(clickedCellID, roiLabelStr=roiLabelStr) + from colin_pool_plot import new_plotCellID + + fig, ax = new_plotCellID( + self._masterDf, self._meanDf, clickedCellID, roiLabelStr + ) + + if fig is None or ax is None: + return + + self._mainWindow = MainWindow(fig, ax) + self._mainWindow.setWindowTitle( + f'cell ID:"{clickedCellID}" roi:{roiLabelStr}' + ) + self._mainWindow.show() + + def _old_sparkMasterShowImg(self, ind): + """Open image from spark master""" + if self._imgFolder is None: + logger.warning('imgFolder is None') + return + + logger.warning(f'ind:{ind} type:{type(ind)}') + + # assuming sm2 saved file has these !!!! + # get l,t,r,b + minRow = self._df.at[ind, 'bounding box min row (pixels)'] + maxRow = self._df.at[ind, 'bounding box max row (pixels)'] + minCol = self._df.at[ind, 'bounding box min column (pixels)'] + maxCol = self._df.at[ind, 'bounding box max column (pixels)'] + # like: minRow:203 maxRow:220 minCol:0 maxCol:15 + logger.info(f'minRow:{minRow} maxRow:{maxRow} minCol:{minCol} maxCol:{maxCol}') + + import matplotlib.patches as patches + + # _x = minCol + # _y = minRow + # _width = maxCol - minCol + # _height = maxRow - minRow + + _x = minRow + _y = minCol + _width = maxRow - minRow + _height = maxCol - minCol + + _rect = patches.Rectangle( + (_x, _y), _width, _height, linewidth=1, edgecolor='r', facecolor='none' + ) + logger.info(f'_rect is:{_rect}') + + imgFile = self._df.at[ind, 'File Name'] + # File Name is analysis .csv, convert to original + logger.warning('assuming raw data is png (usually tif)') + imgFile = os.path.splitext(imgFile)[0] + '.png' + + imgPath = os.path.join(self._imgFolder, imgFile) + logger.info(f'opening image: {imgPath}') + + logger.warning( + 'defaulting to matplotlib imread -->> no good!' + ) # import tifffile + # imgData = tifffile.imread(imgPath) + imgData = plt.imread(imgPath) + logger.info(f'lodaded imgData: {imgData.shape} {imgData.dtype}') + # convert float32 to int8 + imgData -= np.min(imgData) + imgData = imgData / np.max(imgData) * 255 + imgData = imgData.astype(np.uint8) + logger.info(f'now imgData: {imgData.shape} {imgData.dtype}') + + fig, ax = plt.subplots(1) + + _plot = ax.imshow(imgData) # AxesImage + # todo: add path to title + # _plot.setTitle(imgPath) + + ax.add_patch(_rect) + + plt.show() + + def linePlot(self, df, yStat, hue): + logger.info(f'=== yStat:{yStat} hue:{hue}') + + legend = self.state['legend'] + logger.warning(f'forcing xStat to "Condition"') + xStat = 'Condition' + hue_order = conditionOrder + try: + df[xStat] = pd.Categorical(df[xStat], categories=hue_order, ordered=True) + except KeyError as e: + logger.error(f'KeyError xStat:{xStat} avail col:{df.columns}') + myHue = 'Cell ID' # one line per cell across (C, i, t) + + if 1: + self.axScatter = sns.lineplot( + data=df, + x="Condition", # want order ['c', 'i', 't'] + y=yStat, + hue=myHue, # one line per cell (x is control, ivab, thaps) + # style='Region', + style='ROI Number', + # hue_order=hue_order, + markers=True, + legend=legend, + # err_style="bars", # for mean df we have _std and _sem columns + # errorbar=("se", 1), + errorbar=None, + ax=self.axScatter, + ) + + # dfPlot = df.groupby(['Cell ID', 'ROI Number']) + + _lines = self.axScatter.get_lines() + print('_lines') + print(_lines) + # Enable picking on each line + for line in self.axScatter.lines: + line.set_picker(5) # Enable picking + self.fig.canvas.mpl_connect("pick_event", self._on_user_pick_lineplot) + # self.axScatter.figure.canvas.mpl_connect("pick_event", self._on_user_pick_lineplot) + + # plot 2x plots for region (SSAN, ISAN) + if 0: + # relPlot return "FacetGrid" ??? + # self.axScatter = sns.relplot( + _ax = sns.relplot( + data=df, + x="Condition", + y=yStat, + col="Region", # 2x plots across (SSAN, ISAN) + hue=myHue, + # style="event", + kind="line", + # ax=self.axScatter, # not available in relplot + ) + logger.info(f'_ax:{_ax}') + plt.show() + + def getFilteredDf(self): + """Get the filtered df""" + df = self.getDf() + df = df[df['show_region']] # include if true + df = df[df['show_condition']] # include if true + df = df[df['show_cell']] # include if true + df = df[df['show_roi']] # include if true + df = df[df['show_polarity']] # include if true + df = df[df['show_epoch']] # include if true + return df + + def replot(self): + """Replot the scatter plot""" + + df = self.getDf() + # reduce based on show_condition and show_cell + df = df[df['show_region']] # include if true + df = df[df['show_condition']] # include if true + df = df[df['show_cell']] # include if true + df = df[df['show_roi']] # include if true + df = df[df['show_polarity']] # include if true + df = df[df['show_epoch']] # include if true + + # logger.info(f'plotting df with rows:{len(df)}') + # print(df) + + # store df for striplot callback _on_user_pick_striplot + self._dfStripPlot = df + + xStat = self.state['xStat'] + yStat = self.state['yStat'] + if self.state['plotStat'] == 'CV': + yStat = f'{yStat}_cv' + hue = None if self.state['hue'] == 'None' else self.state['hue'] + style = None if self.state['style'] == 'None' else self.state['style'] + plotType = self.state['plotType'] + legend = self.state['legend'] + + uniqueHue = [] if hue is None else df[hue].unique() + numHue = len(uniqueHue) + + if hue in ['Condition', 'Region', 'Condition Epoch']: + hue_order = sorted(df[hue].unique()) + else: + hue_order = None + + # if hue == 'Condition': + # # hue_order = conditionOrder + # hue_order = sorted(df[hue].unique()) + # elif hue == 'Region': + # # hue_order = regionOrder + # hue_order = sorted(df[hue].unique()) + # elif hue == 'Condition Epoch': + # hue_order = sorted(df[hue].unique()) + # else: + # hue_order = None + + if hue_order is not None and len(hue_order) == 0: + hue_order = None + + logger.info(f'hue:"{hue}" hue_order:"{hue_order}"') + + # dodge = False if numHue<=1 else 0.5 + dodge = True if numHue <= 1 else 0.5 + + logger.info(f'numHue:{numHue} dodge:{dodge}') + + # print stats to cli + # from colin_summary import genStats + # logger.info(f'=== generating stats for "{yStat}"') + # genStats(df, yStat) + + # broken + logger.warning('turned off getGroupedDataframe') + if 0: + dfGrouped = self.getGroupedDataframe(yStat, groupByColumn=hue) + self._dfYStatSummary = dfGrouped + + if self.rawTableWidget is not None: + self.rawTableWidget.setDf(self.getDf()) + self.yStatSummaryTableWidget.setDf(dfGrouped) + + # sns.set_palette() + # numRoiNum = len(df['ROI Number'].unique()) + logger.warning('202505 turned off palette') + # numRoiNum = len(df) + # sns.set_palette("colorblind") + # palette = sns.color_palette(n_colors=numRoiNum) + + # try: + if 1: + + # returns "matplotlib.axes._axes.Axes" + if self.axScatter is not None: + self.axScatter.cla() + + if len(df) == 0: + logger.warning('nothing to plot -->> return') + return + + if plotType == 'Line Plot': + self.linePlot(df, yStat, hue) + + elif plotType == 'Scatter': + self.axScatter = sns.scatterplot( + data=df, + x=xStat, + y=yStat, + hue=hue, + # palette=palette, + ax=self.axScatter, + legend=legend, # TODO ad QCheckbox for this + picker=5, + ) # return matplotlib.axes.Axes + + # abb 202505 removed + self.axScatter.figure.canvas.mpl_connect( + "pick_event", self._on_user_pick_scatterplot + ) + + if self._state['makeSquare']: + # logger.info('making scatter plot square') + _xLim = self.axScatter.get_xlim() + _yLim = self.axScatter.get_ylim() + _min = min(_xLim[0], _yLim[0]) + _max = max(_xLim[1], _yLim[1]) + + # logger.info(f'_min:{_min} _max:{_max}') + self.axScatter.set_xlim([_min, _max]) + self.axScatter.set_ylim([_min, _max]) + + # draw a diagonal line + # Using transform=self.axScatter.transAxes, the supplied x and y coordinates are interpreted as axes coordinates instead of data coordinates. + self.axScatter.plot( + [0, 1], [0, 1], '--', transform=self.axScatter.transAxes + ) + + elif plotType in ['Box Plot']: + self.axScatter = sns.boxplot( + data=df, + x=xStat, + y=yStat, + hue=hue, + legend=legend, + ax=self.axScatter, + dodge=dodge, # separate the points by hue + hue_order=hue_order, + ) + + elif plotType in [ + 'Swarm', + 'Swarm + Mean', + 'Swarm + Mean + STD', + 'Swarm + Mean + SEM', + ]: + # 20250515 colin, got it working + # picker does not work for stripplot + # fernando's favorite + + # reduce the brightness of raw data + if plotType in [ + 'Swarm + Mean', + 'Swarm + Mean + STD', + 'Swarm + Mean + SEM', + ]: + alpha = 0.6 + # ['ci', 'pi', 'se', 'sd'] + if plotType == 'Swarm + Mean': + errorBar = None + elif plotType == 'Swarm + Mean + STD': + errorBar = 'sd' + elif plotType == 'Swarm + Mean + SEM': + errorBar = 'se' + else: + alpha = 1 + errorBar = None + + logger.info(f'=== making stripplot xStat:"{xStat}" hue:"{hue}"') + # logger.info(f'plotDict hue_order is:{hue_order}') + + # 20250520 hue_order has to be the same length as unique() hue + # hue_order = ['Control', 'Ivab'] + + plotDict = { + 'data': df, + 'x': xStat, + 'y': yStat, + 'hue': hue, + # 'style': style, # only used in scatterplot + 'alpha': alpha, + # 'palette': None, + 'legend': legend, + # 'ax': self.axScatter, + 'picker': 5, + 'dodge': dodge, # separate the points by hue + # 'hue_order': hue_order, # hue_order is tripping up our complex code for picking with groups and splits !!!! + } + if hue_order is not None: + plotDict['hue_order'] = hue_order + + # 20250515, switched to swarmplot + # self.axScatter = sns.stripplot(data=df, + self.axScatter = sns.swarmplot(ax=self.axScatter, **plotDict) + + _mplFigure = self.axScatter.figure + # logger.info(f'_mplFigure:{_mplFigure}') + + # see: + # https://stackoverflow.com/questions/66201678/oclick-with-seaborn-stripplot + + if hue is None: + logger.warning('no picking with hue None') + else: + # TODO: refactor to handle simple case, group_len = 1 + # hue_order is tripping up our complex code for picking with groups and splits !!!! + + logger.warning(f'need groups using hue order instead ??? hue:{hue}') + groups = df[hue].unique() + groups = sorted(groups) # fake hue order based on sorting + # groups = hue_order + splits = df[xStat].unique() + # + # print(self.axScatter.collections) + group_len = len(groups) + # print(f'groups is len:{group_len} {groups}') + # print(f'splits is:{splits}') + for idx, artist in enumerate(self.axScatter.collections): + # logger.info(f'idx:{idx} artist:{artist}') + # matplotlib.collections.PathCollection + artist.animal = splits[ + idx // group_len + ] # floor division 5//2 = 2 + artist.label = groups[idx % group_len] + # print(f'{idx}') + # print(f' artist.animal: "{artist.animal}"') + # print(f' artist.label: "{artist.label}"') + + self.fig.canvas.mpl_connect( + "pick_event", self._on_user_pick_stripplot + ) + + # add stats + if self.state['stats']: + # # between all hue within each x (cell) + if hue is None or (xStat == hue): + logger.warning( + 'no pairwise comparison if no hue -->> default to pairwise x-stat' + ) + # pairwise between x-stat (cell id) + _xUnique = df[xStat].unique() + pairs = list(combinations(_xUnique, 2)) + else: + pairs = [] + for _xStat in df[xStat].unique(): + # logger.info(f'adding pair for _xStat: {_xStat}') + onePair = [ + [(_xStat, a), (_xStat, b)] + for a, b in combinations(uniqueHue, 2) + ] + # logger.info(f'onePair is len:{len(onePair)}') + # logger.info(f' {onePair}') + # append items in onePair to pairs + pairs.extend(onePair) + + # print(f'=== pairs is {type(pairs)} {len(pairs)}:') + # print(pairs) + # for idx, pair in enumerate(pairs): + # print(f' {idx}: {pair}') + + try: + + logger.info( + f'constructing stats Annotator Mann-Whitney NO CORRECTION' + ) + annotator = Annotator( + pairs=pairs, ax=self.axScatter, **plotDict + ) + # logger.info(' annotator.configure') + annotator.configure( + test='Mann-Whitney', + text_format='star', + # loc='outside', + verbose=False, + ) + # logger.info(' annotator.apply_and_annotate') + annotator.apply_and_annotate() + except ValueError as e: + logger.error(f'annotator failed: {e}') + # 20250708 + except TypeError as e: + logger.error(f'annotator failed: {e}') + logger.error(f' pairs:{pairs}') + logger.error(f' hue:{hue}') + + # overlay mean +- std or sem + # no overlay if hue is None + # if hue is not None and \ + # plotType in ['Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + if plotType in [ + 'Swarm + Mean', + 'Swarm + Mean + STD', + 'Swarm + Mean + SEM', + ]: + + errorbar = errorBar + markersize = 30 + sns.pointplot( + data=df, + x=xStat, + y=yStat, + # ??? + # hue= xStat if hue is None else hue, + hue=hue, + errorbar=errorbar, # can be 'se', 'sem', etc + # capsize=capsize, + linestyle='none', # do not connect (with line) between categorical x + marker="_", + markersize=markersize, + # markeredgewidth=3, + legend=False, + # palette=palette, + # dodge=0.5, # separate the points by hue + # dodge=None if len(uniqueHue)==1 else 0.5, # separate the points by hue + dodge=dodge, + hue_order=hue_order, + ax=self.axScatter, + ) + + elif plotType == 'Histogram': + self.axScatter = sns.histplot( + data=df, + x=yStat, + hue=hue, + # palette=palette, + legend=legend, + ax=self.axScatter, + ) + + elif plotType == 'Cumulative Histogram': + self.axScatter = sns.histplot( + data=df, + x=yStat, + hue=hue, + element="step", + fill=False, + cumulative=True, + stat="density", + common_norm=False, + # palette=palette, + legend=legend, + ax=self.axScatter, + ) + + else: + logger.warning(f'did not understand plot type: {plotType}') + + self.static_canvas.draw() + + # except (ValueError) as e: + # logger.error(e) + plt.tight_layout() + + def keyPressEvent(self, event): + _handled = False + isMpl = isinstance(event, mpl.backend_bases.KeyEvent) + if isMpl: + text = event.key + logger.info(f'mpl key: "{text}"') + + doCopy = text in ["ctrl+c", "cmd+c"] + if doCopy: + self.copyTableToClipboard() + + def _contextMenu(self, pos): + logger.info('') + + contextMenu = QtWidgets.QMenu() + + makeSquareAction = QtWidgets.QAction('Make Square') + makeSquareAction.setCheckable(True) + makeSquareAction.setChecked(self._state['makeSquare']) + makeSquareAction.setEnabled(self._state['plotType'] == 'Scatter') + contextMenu.addAction(makeSquareAction) + + contextMenu.addSeparator() + contextMenu.addAction('Copy Stats Table ...') + + # Add splitter control options + contextMenu.addSeparator() + resetSplitterAction = QtWidgets.QAction('Reset Toolbar Width') + contextMenu.addAction(resetSplitterAction) + + # contextMenu.addSeparator() + # contextMenu.addAction('Show Analysis Folder') + + # show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + _ret = '' + actionText = action.text() + if action == makeSquareAction: + self._state['makeSquare'] = makeSquareAction.isChecked() + self.replot() + + elif actionText == 'Copy Stats Table ...': + _ret = self.copyTableToClipboard() + + elif actionText == 'Reset Toolbar Width': + self.resetSplitterSizes() + + def setStatusBar(self, msg: str, msecs: int = 0): + """Set the text of the status bar. + + Defaults to 4 seconds. + """ + # if msecs is None: + # msecs=4000 + self.status_bar.showMessage(msg, msecs=msecs) + + def getGroupedDataframe(self, statColumn, groupByColumn): + + logger.info(f'groupByColumn:{groupByColumn}') + + aggList = ["count", "mean", "std", "sem", variation, "median", "min", "max"] + + # if len(self._df)>0: + # # get first row value + # detectedTrace = self._df['Detected Trace'].iloc[0] + # else: + # detectedTrace = 'N/A' + + # aggDf = self._df.groupby(groupByColumn, as_index=False)[statColumn].agg(aggList) + dfDropNan = self.getDf().dropna( + subset=[statColumn] + ) # drop rows where statColumn is nan + + try: + aggDf = dfDropNan.groupby(groupByColumn).agg({statColumn: aggList}) + except TypeError as e: + logger.error(f'groupByColumn "{groupByColumn}" failed e:{e}') + aggDf = dfDropNan + + try: + aggDf.columns = aggDf.columns.droplevel( + 0 + ) # get rid of statColumn multiindex + except ValueError as e: + logger.error(e) + aggDf.insert(0, 'Stat', statColumn) # add column 0, in place + aggDf = ( + aggDf.reset_index() + ) # move groupByColum (e.g. 'ROI Number') from row index label to column + + # rename column 'variation' as 'CV' + aggDf = aggDf.rename(columns={'variation': 'CV'}) + + # round some columns + aggList = ["mean", "std", "sem", "CV", "median", "min", "max"] + for agg in aggList: + if agg == 'count': + continue + # logger.info(f'rounding agg:{agg}') + try: + aggDf[agg] = round(aggDf[agg], 2) + except KeyError as e: + logger.warning( + f'did not find agg column:{agg} possible keys are {aggDf.columns}' + ) + return aggDf + + def setSplitterSizes(self, leftWidth: int, rightWidth: int = None): + """Set the splitter sizes programmatically. + + Args: + leftWidth: Width for the left toolbar in pixels + rightWidth: Width for the right plot area in pixels (optional) + """ + if hasattr(self, '_mainSplitter'): + if rightWidth is None: + # Calculate right width based on current window size + currentSizes = self._mainSplitter.sizes() + totalWidth = sum(currentSizes) + rightWidth = totalWidth - leftWidth + self._mainSplitter.setSizes([leftWidth, rightWidth]) + + def getSplitterSizes(self): + """Get current splitter sizes.""" + if hasattr(self, '_mainSplitter'): + return self._mainSplitter.sizes() + return [300, 800] # Default fallback + + def resetSplitterSizes(self): + """Reset splitter sizes to default.""" + self.setSplitterSizes(300, 800) + + def saveSplitterSizes(self): + """Save current splitter sizes to settings.""" + if hasattr(self, '_mainSplitter'): + sizes = self._mainSplitter.sizes() + # You can save this to a settings file or registry + # For now, just store in instance variable + self._savedSplitterSizes = sizes + logger.info(f'Saved splitter sizes: {sizes}') + + def restoreSplitterSizes(self): + """Restore saved splitter sizes.""" + if hasattr(self, '_savedSplitterSizes'): + self._mainSplitter.setSizes(self._savedSplitterSizes) + logger.info(f'Restored splitter sizes: {self._savedSplitterSizes}') + + def closeEvent(self, event): + """Override closeEvent to save splitter sizes before closing.""" + self.saveSplitterSizes() + super().closeEvent(event) + + +def run(): + # this was my analysis with 1 roi per kym + # savePath = '/Users/cudmore/colin_peak_summary_20250517.csv' + + # this is new colin analysis with multiple roi per kym + # savePath = '/Users/cudmore/colin_peak_summary_20250521.csv' + # savePath = '/Users/cudmore/colin_peak_summary_20250527.csv' + + # load csv analysis files as pd DataFrame + # from colin_global import loadMasterDfFile, loadMeanDfFile, getMeanDfPath + + # masterDf = loadMasterDfFile() + # meanDf = loadMeanDfFile() + + # load pandas from csv + # masterDf = '/Users/cudmore/Sites/SanPy/sanpy/kym/sample-data/tif_pool_main.csv' + masterDf = pd.read_csv('/Users/cudmore/Sites/SanPy/sanpy/kym/sample-data/tif_pool_main.csv') + + # load pandas from csv + # meanDf = '/Users/cudmore/Sites/SanPy/sanpy/kym/sample-data/tif_pool_mean.csv' + meanDf = pd.read_csv('/Users/cudmore/Sites/SanPy/sanpy/kym/sample-data/tif_pool_mean.csv') + + logger.info('meanDf.columns:') + for _column in meanDf.columns: + logger.info(f' {_column}') + + # abb 20250704 + # for backward compatibility, add show_roi, show_cell, and show_region set to True + meanDf['show_roi'] = True + meanDf['show_cell'] = True + meanDf['show_region'] = True + meanDf['show_condition'] = True + meanDf['show_polarity'] = True + meanDf['show_epoch'] = True + + # print(meanDf.columns) + # meanSavePath = '/Users/cudmore/colin_peak_mean_20250521.csv' + # meanDf = pd.read_csv(meanSavePath) + + hueList = [ + 'File Number', + 'Cell ID', + # 'Cell ID (plot)', # removed 20250521 + # 'Tif File', + 'Condition', + 'Epoch', + 'Condition Epoch', + 'Region', + 'Date', + 'ROI Number', + 'Polarity', + ] + + # limit what we show user + plotColumns = [ + 'Cell ID', + # 'Cell ID (plot)', # removed 20250521 + 'File Number', + 'Tif File', + 'Condition', + 'Region', + 'Date', + 'ROI Number', + 'Polarity', + # 'Onset (s)', + # 'Decay (s)', + 'Peak Inst Interval (s)', + 'Peak Inst Freq (Hz)_mean', + 'Peak Height', + 'FW (ms)', + 'HW (ms)', + 'Rise Time (ms)', + 'Decay Time (ms)', + 'Area Under Peak', + 'Area Under Peak (Sum)', + 'Number of Spikes', + # 'Spike Frequency (Hz)', + 'fit_tau', + 'fit_tau1', + ] + + app = QtWidgets.QApplication(sys.argv) + app.setStyle("Fusion") + # app.setStyleSheet("QWidget { font-size: 12pt; }") + # app.setWindowIcon(QtGui.QIcon('sanpy.png')) + app.setFont(QtGui.QFont("Arial", 10)) + # app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + + myWin = ScatterWidget( + masterDf, + meanDf, + xStat='Region', + # yStat='Peak Inst Freq (Hz)', + yStat='Peak Inst Freq (Hz)_mean', + + # defaultPlotType='Line Plot', + defaultPlotType='Swarm + Mean + SEM', + hueList=hueList, + defaultHue='Condition', + imgFolder=None, + plotColumns=plotColumns, + ) + + # myWin.setWindowTitle(getMeanDfPath()) + + # plot all control traces for ssan and isan + # myWin._colinTraces.plotOneCond('Control', 'SSAN') + # myWin._colinTraces.plotOneCond('Control', 'ISAN') + # plt.show() + + myWin.show() + + # options = ScatterOptions() + # options.show() + + sys.exit(app.exec_()) + + +if __name__ == '__main__': + run() diff --git a/sanpy/kym/kymRoiAnalysis.py b/sanpy/kym/kymRoiAnalysis.py new file mode 100644 index 00000000..94c10acb --- /dev/null +++ b/sanpy/kym/kymRoiAnalysis.py @@ -0,0 +1,2794 @@ +import json +import os +import sys +from typing import List, Optional, Dict, Any, Tuple +from enum import Enum + +import numpy as np +import pandas as pd +# import uuid +import tifffile + +import scipy.optimize +from scipy.signal import peak_widths, medfilt, savgol_filter, detrend, find_peaks +from skimage import restoration + +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle +from scipy import ndimage +from scipy.stats import linregress +import warnings + +from sanpy.bAnalysis_ import bAnalysis +from sanpy._util import _loadLineScanHeader +from sanpy.kym.kymRoiDetection import KymRoiDetection, getAnalysisDict +from sanpy.kym.kymRoiResults import KymRoiResults +from sanpy.kym.kymRoiMetaData import KymRoiMetaData +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def _printImgStats(imgData:np.ndarray, name:str=''): + logger.info(f'name:"{name}" imgStats: {imgData.shape} min:{np.min(imgData)} max:{np.max(imgData)}') + +def myDetrend(xPlot, yPlot, doPlot=False): + # from scipy.signal import detrend + + y_log = np.log(yPlot) + try: + y_log_detrended = detrend(y_log) + except (ValueError) as e: + logger.error(f'got -inf in log (is the image empty?) {e} -->> No Detrend') + return None, None + + # y_detrended = np.exp(y_log_detrended) + + try: + _params, _cov = scipy.optimize.curve_fit(myMonoExp, xPlot, yPlot) + except (RuntimeError) as e: + logger.error(f'{e} -->> No Detrend') + return None, None + + _m, _tau, _b = _params + fit_y = myMonoExp(xPlot, _m, _tau, _b) + y_minus_single = np.subtract(yPlot, fit_y) + + # try: + # _params, _cov = scipy.optimize.curve_fit(myDoubleExp, xPlot, yPlot) + # except (RuntimeError) as e: + # logger.error(e) + # logger.error('DID NOT PERFORM dbl exp DETREND') + # return None, None + # _m1, _tau1, _m2, _tau2 = _params + # fit_y2 = myDoubleExp(xPlot, _m1, _tau1, _m2, _tau2) + # y_minus_double = np.subtract(yPlot, fit_y2) + + if doPlot: + plt.plot(xPlot, yPlot, label='raw') + # plt.plot(xPlot, y_detrended, label='detrended') + plt.plot(xPlot, fit_y, 'r-', label='exp fit') + plt.plot(xPlot, y_minus_single, 'k-', label='exp fit 1 detrend') + + # plt.plot(xPlot, fit_y2, 'g-', label='exp fit 2') + # plt.plot(xPlot, y_minus_double, 'c-', label='exp fit 2 detrend') + + plt.legend() + plt.show() + + # return y_minus_double + expFitDict = { + 'fn': 'myMonoExp', + 'm': _m, + 'tau': _tau, + 'b': _b, + } + return expFitDict, y_minus_single + # return y_detrended + +def myMonoExp(x, m, t, b): + """ + m: a_0 + t: tau_0 + b: + """ + # this triggers + # "RuntimeWarning: overflow encountered in exp" during search for parameters, just ignor it + # ret = m * np.exp(-t * x) + b + try: + ret = m * np.exp(-t * x) + b + except (RuntimeWarning) as e: + # RuntimeWarning: overflow encountered in exp + logger.error(e) + logger.error(f'x:{x} m:{m} t:{t} b:{b}') + return ret + +def myDoubleExp(x, m1, t1, m2, t2): + # pos peak -> decay + return m1 * np.exp(-t1 * x) + m2 * np.exp(-t2 * x) + + # neg peak -> recovery + # return m1 * np.exp(t1 * x) + m2 * np.exp(t2 * x) + +def _expFit2(xPlot, yPlot, xPeakBins, decayFitBins :int): + doDebug = False + + numPeaks = len(xPeakBins) + fit_m1 = [np.nan] * numPeaks + fit_tau1 = [np.nan] * numPeaks + fit_m2 = [np.nan] * numPeaks + fit_tau2 = [np.nan] * numPeaks + fit_r2 = [np.nan] * numPeaks + fit_error = [''] * numPeaks + + for _peakIdx, _peakBin in enumerate(xPeakBins): + # logger.info(f'_peakIdx:{_peakIdx}') + + try: + xRange = xPlot[_peakBin:_peakBin+decayFitBins] - xPlot[_peakBin] + except (IndexError) as e: + # logger.error(f'{e}') + # logger.error(f' _peakIdx:{_peakIdx}') + # logger.error(f' xPlot: {len(xPlot)} {xPlot}') + # logger.error(f' yPlot: {len(yPlot)} {yPlot}') + # logger.error(f' decayFitBins:{decayFitBins}') + # logger.error(f' _peakBin:{_peakBin}') + # logger.error(f' _peakBin+fitWinPnts:{_peakBin+decayFitBins}') + continue + + yRange = yPlot[_peakBin:_peakBin+decayFitBins] + + # plot the raw data + if doDebug: + plt.plot(xRange+xPlot[_peakBin], yRange) + + try: + try: + _params, _cov = scipy.optimize.curve_fit(myDoubleExp, xRange, yRange) + except (ValueError) as e: + fit_error[_peakIdx] += f'peak {_peakIdx} _expFit2:{e}' + ';' + continue + + # plot the fit + _fit_m1, _fit_tau1, _fit_m2, _fit_tau2 = _params + + fit_m1[_peakIdx] = _fit_m1 + fit_tau1[_peakIdx] = _fit_tau1 + fit_m2[_peakIdx] = _fit_m2 + fit_tau2[_peakIdx] = _fit_tau2 + + # + # residual sum of squares + _y_fit = myDoubleExp(xRange, _fit_m1, _fit_tau1, _fit_m2, _fit_tau2) + ss_res = np.sum((yRange - _y_fit) ** 2) + ss_tot = np.sum((yRange - np.mean(yRange)) ** 2) # total sum of squares + fit_r2[_peakIdx] = 1 - (ss_res / ss_tot) # r-squared + + if doDebug: + fit_y = myDoubleExp(xRange, _fit_m1, _fit_tau1, _fit_m2, _fit_tau2) + plt.plot(xRange+xPlot[_peakBin], fit_y, 'c-') + + except (RuntimeError, TypeError) as e: + if doDebug: + logger.error(f' _peakIdx:{_peakIdx} _peakBin:{_peakBin} -->> {e}') + # fit_error[_peakIdx] += f'peak {_peakIdx} _expFit2:{e}' + ';' + fit_error[_peakIdx] += f'_expFit2():{e}' + ';' + + if doDebug: + plt.show() + + return fit_m1, fit_tau1, fit_m2, fit_tau2, fit_r2, fit_error + +def _expFit(xPlot, yPlot, xPeakBins, decayFitBins :int): + """Perform single exponential fit (myMonoExp) for each peak. + """ + doDebug = False + + numPeaks = len(xPeakBins) + fit_m = [np.nan] * numPeaks + fit_tau = [np.nan] * numPeaks + fit_b = [np.nan] * numPeaks + fit_r2 = [np.nan] * numPeaks + fit_error = [''] * numPeaks + + for _peakIdx, _peakBin in enumerate(xPeakBins): + # logger.info(f'_peakIdx:{_peakIdx}') + + try: + xRange = xPlot[_peakBin:_peakBin+decayFitBins] - xPlot[_peakBin] + except (IndexError) as e: + if doDebug: + logger.error(f'{e}') + logger.error(f' _peakIdx:{_peakIdx}') + logger.error(f' xPlot: {len(xPlot)} {xPlot}') + logger.error(f' yPlot: {len(yPlot)} {yPlot}') + logger.error(f' decayFitBins:{decayFitBins}') + logger.error(f' _peakBin:{_peakBin}') + logger.error(f' _peakBin+fitWinPnts:{_peakBin+decayFitBins}') + continue + + yRange = yPlot[_peakBin:_peakBin+decayFitBins] + + # plot the raw data + if doDebug: + plt.plot(xRange+xPlot[_peakBin], yRange) + + try: + try: + _params, _cov = scipy.optimize.curve_fit(myMonoExp, xRange, yRange) + except (ValueError) as e: + _errStr = f'_expFit():{e}' + # logger.error(_errStr) + fit_error[_peakIdx] += _errStr + ';' + print(xRange) + print(yRange) + continue + + # plot the fit + _m, _tau, _b = _params + + fit_m[_peakIdx] = _m + fit_tau[_peakIdx] = _tau + fit_b[_peakIdx] = _b + + # + # residual sum of squares + _y_fit = myMonoExp(xRange, _m, _tau, _b) + ss_res = np.sum((yRange - _y_fit) ** 2) + # total sum of squares + ss_tot = np.sum((yRange - np.mean(yRange)) ** 2) + # r-squared + fit_r2[_peakIdx] = 1 - (ss_res / ss_tot) + + if doDebug: + fit_y = myMonoExp(xRange, _m, _tau, _b) + plt.plot(xRange+xPlot[_peakBin], fit_y, 'r-') + + except (RuntimeError, TypeError) as e: + # _errStr = f' _peakIdx:{_peakIdx} myMonoExp:{e}' + _errStr = f'_expFit():{e}' + # logger.error(_errStr) + fit_error[_peakIdx] += _errStr + ';' + + if doDebug: + plt.show() + + return fit_m, fit_tau, fit_b, fit_r2, fit_error + +def getSavitzkyGolay_Filter(y: np.ndarray, pnts: int = 5, poly: int = 2, verbose=False): + """Get SavitzkyGolay filtered version of y using scipy.signal.savgol_filter""" + if verbose: + logger.info("") + filtered = savgol_filter(y, pnts, poly, mode="nearest", axis=0) + return filtered + +class PeakDetectionTypes(Enum): + intensity = 'Intensity' + diameter = 'Diameter (um)' + # diameter = 'diameter' + +class KymRoiTraces: + """Class to hold a number of raw traces for one Kym ROI. + + Each instance of *this is for one channel + """ + def __init__(self, numLineScans : int, secondsPerLine : float): + self._analysisTraceList = ['Time (s)', + 'intRaw', + 'intDetrend', + 'df/f0', + 'f/f0', + 'Divided', + # diameter + 'Diameter (um)', + 'Left Diameter (um)', + 'Right Diameter (um)' + ] + + self._analysisTraces = {} + """Keys are trace name, values are np.ndarray.""" + + for trace in self._analysisTraceList: + # self._analysisTraces[trace] = np.empty( shape=(0) ) + self._analysisTraces[trace] = np.empty( shape=(numLineScans) ) + self._analysisTraces[trace][:] = np.nan + + # fill in time sec and time bin + _timeBins = np.arange(0, numLineScans) + _timeSeconds = _timeBins * secondsPerLine + self.setTrace2('Time (s)', _timeBins, _timeSeconds) + + def setTrace2(self, traceName, bins : np.ndarray, values : np.ndarray): + """bins tell us which line scans to set, this accounts for left/right of roi rect. + """ + if traceName not in self._analysisTraces.keys(): + logger.error(f'traceName "{traceName}" not in keys, available keys are {self._analysisTraces.keys()}') + return + + self._analysisTraces[traceName][:] = np.nan + self._analysisTraces[traceName][bins] = values + + def getTrace(self, name, + # stripNan=False + ) -> Optional[np.ndarray]: + """Get a trace name fomr analysis. + """ + if name not in self._analysisTraces.keys(): + logger.error(f'did not find trace name "{name}". Available names are {self._analysisTraces.keys()}') + return + _ret = self._analysisTraces[name] + # if stripNan: + # _ret = _ret[~np.isnan(_ret)] + return _ret + + def loadTraces(self, roiNumber, loadedIntDf : pd.DataFrame): + """Load traces from a pandas df. + + This is for one kymRoi. + """ + roiTraceList = self._analysisTraceList + for roiTrace in roiTraceList: + colName = f'ROI {roiNumber} {roiTrace}' + + if colName not in loadedIntDf.columns: + # logger.error(f' did not find trace name "{colName}" column in file') + # logger.info(f'available columns from file are:{loadedIntDf.columns}') + continue + + oneTrace = loadedIntDf[colName].to_numpy() # added as_numpy() 20241012 + self._analysisTraces[roiTrace] = oneTrace + + def items(self): + return self._analysisTraces.items() + + def keys(self): + return self._analysisTraces.keys() + + def __getitem__(self, key): + # to mimic a dictionary + ret = None + try: + ret = self._analysisTraces[key] + except KeyError: + logger.error(f'Error getting trace key "{key}" available keys are {self._analysisTraces.keys()}') + raise + # + return ret + + def __setitem__(self, key, value): + # to mimic a dictionary + if isinstance(value, list): + logger.error(f'key:{key} is a list, should be numpy array') + value = np.ndarray(value) + try: + self._analysisTraces[key] = value + except KeyError as e: + logger.error(f"{e}") + +class KymRoi: + """One rectangular ROI. + + Has the imgData, detection params, analysis results, and traces for one roi. + """ + def __init__(self, label : str, + imgData : List[np.ndarray], + header : dict, + ltrbRect : List[int] = None, + reuseRoiLabel : str = None, + kymRoiAnalysis = None, + # mode : str = None + ): + """ + Parameters + ========== + label : str + Unique string label for the roi, usually 1,2,3,.. + imgData : np.ndarray + Underlying image data. The full image we extract an roi with getRoiImg() + header : dict + Requires two keys ('umPerPixel', 'secondsPerline') + ltrbRect : List[l, t, r, b] + Seed with this rect, if None than use getDefaultRect() + reuseRoiLabel : str + Reuse detection params from this roi (can be None) + kymRoiAnalysis : KymRoiAnalysis + Only used to fetch params if reuseRoiLabel is not None + """ + self._label = label + self._imgData : List[np.ndarray] = imgData + self._header = header + + self._ltrbRect : List[int] = ltrbRect + + if ltrbRect is None: + ltrbRect = self.getDefaultRect() # needs imgData + + self._kymRoiAnalysis : KymRoiAnalysis = kymRoiAnalysis + + # self._mode : str = mode + + self.setRect(ltrbRect) # will contrain + + #self._isDirty = False + self.setDirty(False) + + _numChannels = self.header['numChannels'] + self._detectioParams = [None] * _numChannels + self._analysisResults = [None] * _numChannels + self.kymRoiTraces = [None] * _numChannels + # self._mode = [None] * _numChannels + + # each roi has detection, analysis, and traces for each image channel + for channel in range(_numChannels): + + # self._mode[channel] = mode + + self._detectioParams[channel] = {} + self._analysisResults[channel] = {} + + for peakDetectionType in PeakDetectionTypes: + if reuseRoiLabel is not None: + _reuseKymRoiDetection = kymRoiAnalysis.getDetectionParams(reuseRoiLabel, peakDetectionType, channel) + thisDetection = KymRoiDetection(peakDetectionType, kymRoiDetection=_reuseKymRoiDetection) + else: + # uses current defaults set in code + thisDetection = KymRoiDetection(peakDetectionType) + + # abb 20250529 colin + # rect will always exist, was created as default if not specified + thisDetection.setParam('ltrb', self.getRect()) + + if peakDetectionType == PeakDetectionTypes.diameter: + thisDetection.setParam('detectThisTrace', 'Diameter (um)') + # thisDetection.setParam('Prominence', 2) + # thisDetection.setParam('Polarity', 'Neg') + + self._detectioParams[channel][peakDetectionType.name] = thisDetection + self._analysisResults[channel][peakDetectionType.name] = KymRoiResults() + + numLineScans = imgData[channel].shape[1] + self.kymRoiTraces[channel] = KymRoiTraces(numLineScans, self.secondsPerLine) + # traces are basically a df with sum for each line scan, columns are things like (time, raw, norm, int_f0, df_f0) + # traces are one value per line scan (multiple columns) and shared between peak detection in f0 and diameter + + def setDirty(self, value : bool): + # logger.info(f'setting to value:{value}') + self._isDirty = value + + def getDetectionParams(self, channel : int, detectionType : PeakDetectionTypes) -> KymRoiDetection: + return self._detectioParams[channel][detectionType.name] + + def setDetection(self, channel, detectionType : PeakDetectionTypes, kymRoiDetection : KymRoiDetection): + """Set detection params for a channel. + """ + self._detectioParams[channel][detectionType.name] = kymRoiDetection + self.setDirty(True) + + def getAnalysisResults(self, channel : int, detectionType : PeakDetectionTypes) -> KymRoiResults: + return self._analysisResults[channel][detectionType.name] + + def getTrace(self, channel : int, + name : str, + ) -> np.ndarray: + # return self.kymRoiTraces[channel][name] + return self.kymRoiTraces[channel].getTrace(name) + + def setResults(self, channel, detectionType : PeakDetectionTypes, kymRoiResults : KymRoiResults) -> KymRoiResults: + # logger.info(f'channel:{channel} detectionType:{detectionType}') + self._analysisResults[channel][detectionType.name] = kymRoiResults + self.setDirty(True) + return kymRoiResults + + def setTrace2(self, channel, name, xBins, values : np.ndarray): + """ + Parameters + ========== + xBins : arange + The line scan bins to set + """ + self.kymRoiTraces[channel].setTrace2(name, xBins, values) + + def setTrace(self, channel, name, values : np.ndarray): + """Set a named trace in kym analysis. + + Parameters + ========== + name : str + Something like df_f0, diameter (um), etc. + + Traces are a df of rows (line scans) and columns (trace names). + """ + self.kymRoiTraces[channel][name] = values + + def __str__(self): + ret = f'kymRoi label:{self._label} ltrb:{self._ltrbRect}' + return ret + + def getLabel(self): + return self._label + + @property + def header(self): + return self._header + + @property + def path(self): + """Get the path to the tif file. + """ + return self._header['path'] + + @property + def umPerPixel(self): + return self._header['umPerPixel'] + + @property + def secondsPerLine(self): + return self._header['secondsPerLine'] + + def getDefaultRect(self): + # h, w = self._imgData.shape + left = 0 + top = self.header['imageHeight'] + right = self.header['imageWidth'] + bottom = 0 + + return [left, top, right, bottom] + + def getPosSize(self): + """Get rect roi as (pos[], size[]) + + Used in GUI. + """ + ltrbRoi = self.getRect() + pos = [ltrbRoi[0], ltrbRoi[3]] + size = [ltrbRoi[2] - ltrbRoi[0], ltrbRoi[1] - ltrbRoi[3]] + return pos, size + + def getRect_json(self) -> str: + """Get the current roi as a json string + """ + rectDict = self.getRectDict() + return json.dumps(rectDict) + + def getRect(self) -> List[int]: + """Get the current roi as [l, t, r, b] + """ + return self._ltrbRect + + def getRectDict(self) -> dict: + """Get the current roi as a dict + """ + ltrb = self.getRect() + return { + 'left': ltrb[0], + 'top': ltrb[1], + 'right': ltrb[2], + 'bottom': ltrb[3] + } + + # abb 202505 + def setRoiHeightPixels(self, heightPixels : int): + """Set the roi height in pixels. + + This is used to set the height of the roi in the GUI. + """ + ltrb = self.getRect() + ltrb[1] = ltrb[3] + heightPixels + self.setRect(ltrb) + + # abb 202505 + def nudge(self, direction : str): + """Nudge the roi rect by delta pixels. + + This is used to set the height of the roi in the GUI. + """ + ltrb = self.getRect() + if direction == 'left': + delta = -1 + ltrb[0] = ltrb[0] + delta + ltrb[2] = ltrb[2] + delta + elif direction == 'right': + delta = 1 + ltrb[0] = ltrb[0] + delta + ltrb[2] = ltrb[2] + delta + elif direction == 'up': + delta = 1 + ltrb[1] = ltrb[1] + delta + ltrb[3] = ltrb[3] + delta + elif direction == 'down': + delta = -1 + ltrb[1] = ltrb[1] + delta + ltrb[3] = ltrb[3] + delta + else: + logger.error(f'unknown direction "{direction}"') + return + + logger.info(f' roi nudge "{direction}" delta:{delta} orig ltrb:{self.getRect()}') + self.setRect(ltrb) + logger.info(f' -->> accepted rect is:{self.getRect()}') + + def setRect(self, ltrb : List): #, doAnalysis : bool = False): + """Set the roi rect [l, t, r, b] + + + """ + ltrbActual = self._getConstrainedRoi(ltrb) + self._ltrbRect = ltrbActual + self.setDirty(True) + + def setRectPosSize(self, pos, size): + """Set ltrb rect using pos() and size() + + Parameters + ---------- + pos : (left, bottom) + size : (width, heigh) + """ + + left = pos[0] + top = pos[1] + size[1] + right = pos[0] + size[0] + bottom = pos[1] + + left = int(left) + top = int(top) + right = int(right) + bottom = int(bottom) + + newRect = [left, top, right, bottom] + self.setRect(newRect) + + return newRect + + def _getConstrainedRoi(self, ltrb) -> List[int]: + """Given a rect, return rect constrained to imgData. + """ + + left = ltrb[0] + top = ltrb[1] + right = ltrb[2] + bottom = ltrb[3] + + heightMax = self.header['imageHeight'] + widthMax = self.header['imageWidth'] + + if left<0: + left = 0 + if top > heightMax: + top = heightMax + if right > widthMax: + right = widthMax + if bottom < 0: + bottom = 0 + + newRect = [left, top, right, bottom] + + return newRect + + def getRoiImg(self, channel) -> np.ndarray: + """Get roi img data inside the roi rect. + + Return a copy so when modified does not modify the original (e.g. background subtract). + """ + left, top, right, bottom = self.getRect() + roiImg = self._imgData[channel][bottom:top, left:right] + roiImg = np.copy(roiImg) + return roiImg + + def backgroundSubtract(self, channel, roiImg, peakDetectionTypes : PeakDetectionTypes): + + detectionParameters = self.getDetectionParams(channel, peakDetectionTypes) + + backgroundsubtract = detectionParameters['Background Subtract'] + # logger.info(f'performing backgroundsubtract:"{backgroundsubtract}"') + + if backgroundsubtract == 'Off': + pass + + elif backgroundsubtract == 'Rolling-Ball': + _rollingBallRadius = detectionParameters['Rolling-Ball Radius'] + rollingBackground = restoration.rolling_ball(roiImg, radius=_rollingBallRadius) + roiImg = roiImg - rollingBackground + + elif backgroundsubtract == 'Median': + # problem with median is "after" we get negative value and + # can not perform log transform !!!' + _subtactValue = np.median(roiImg).astype(np.int64) + # logger.info(f' _subtactValue: {type(_subtactValue)} {_subtactValue}') + roiImg = roiImg - _subtactValue + roiImg[roiImg<0] = 0 + detectionParameters['backgroundSubtractValue'] = int(_subtactValue) + + elif backgroundsubtract == 'Mean': + _subtactValue = np.mean(roiImg).astype(np.int64) + roiImg = roiImg - _subtactValue + roiImg[roiImg<0] = 0 + detectionParameters['backgroundSubtractValue'] = int(_subtactValue) + + else: + logger.error(f'did not understand background subtract "{backgroundsubtract}" -->> no subtraction') + + # _printImgStat(roiImg, f'after background subtract: "{backgroundsubtract}"') + + self.setDirty(True) + + return roiImg + + def getTimeBins(self): + _left, _top, _right, _bottom = self.getRect() + xPlot = np.arange(_left, _right, dtype=np.int32) + return xPlot + + def getRoiImgClips(self, channel) -> Dict: + """Get roi img data inside the roi rect. + + Return a copy so when modified does not modify the original (e.g. background subtract). + + Returns + ------- + Dict with keys: + - raw + - bs + - binned + - divided + """ + + detectionParams = self.getDetectionParams(channel, PeakDetectionTypes.intensity) + + f0Value = detectionParams['f0 Value Percentile'] + + roiImg_raw = self.getRoiImg(channel) + + roiImg_f_f0 = roiImg_raw / f0Value + roiImg_df_f0 = (roiImg_raw - f0Value) / f0Value + + # "background" subtract + roiImg_bs = self.backgroundSubtract(channel, roiImg_raw, PeakDetectionTypes.intensity) + + binLineScans = detectionParams['Bin Line Scans'] + if binLineScans == 0: + pass + else: + roiImg_binned = BinLineScans(roiImg_bs, binLineScans) + + # divided + # divideLinescan = detectionParams['Divide Line Scan'] + santanaLineScanNorm = self._kymRoiAnalysis.getKymDetectionParam('Divide Line Scan') + # logger.info(f' santanaLineScanNorm:{santanaLineScanNorm}') + if santanaLineScanNorm is None: + # logger.warning('no santana division') + roiImg_divided = roiImg_raw.astype('float64') # copy + + else: + column_to_divide_by = roiImg_raw[:, santanaLineScanNorm] + reshaped_column = column_to_divide_by.reshape(-1, 1) + + try: + # Cannot cast ufunc 'divide' output from dtype('float64') to dtype('int16') + roiImgFloat = roiImg_raw.astype('float64') # copy + reshaped_column_float = reshaped_column.astype('float64') + ones_like = np.ones_like(roiImgFloat) + roiImg_divided = np.divide(roiImgFloat, reshaped_column_float, out=ones_like, where=reshaped_column != 0) + + # this col should be all 1 + if not np.all(roiImg_divided[:,santanaLineScanNorm] == 1): + logger.error(f'santanaLineScanNorm:{santanaLineScanNorm} column should be all 1') + # print(dividedImg[:,santanaLineScanNorm]) + + except (RuntimeWarning) as e: + # RuntimeWarning: divide by zero encountered in divide + logger.error(e) + + retDict = { + 'raw': roiImg_raw, + 'f_f0': roiImg_f_f0, + 'df_f0': roiImg_df_f0, + # + 'bs': roiImg_bs, + 'binned': roiImg_binned, + 'divided': roiImg_divided + } + return retDict + + def getSumIntensity(self, channel): + """Get sum intensity for each line scan. + + Algorithm + --------- + - Background subtract roi image + - Bin line scans using mean for each pixel across a bin line scan width + - Sum intensity of each line scan + - Normalize each intensity line scan to number of pixels + + Returns + ------- + Tuple of (xPlot, sumInt) + + xPlot : np.arange + Line scan time seconds (after clipping to left/right of roi rect) + sumInt : np.ndarray + Sum of each line scan (after clipping to left/right and top/bottom of roiRect) + + """ + _debug = False + + detectionParams = self.getDetectionParams(channel, PeakDetectionTypes.intensity) + roiImg = self.getRoiImg(channel) # get imgData within ROI + _left, _, _right, _ = self.getRect() + + xPlot = np.arange(_left, _right, dtype=np.float32) * self.secondsPerLine + + # "background" subtract + roiImg = self.backgroundSubtract(channel, roiImg, PeakDetectionTypes.intensity) + if _debug: + _printImgStats(roiImg, 'after background subtract') + + binLineScans = detectionParams['Bin Line Scans'] + if binLineScans == 0: + pass + else: + roiImg = BinLineScans(roiImg, binLineScans) + if _debug: + _printImgStats(roiImg, 'after bin line scans') + + # sum intensities in each line scan + sumInt = np.sum(roiImg, axis=0) + if _debug: + _printImgStats(sumInt, 'after sum intensities') + # normalize to number of points in line scan + sumInt = sumInt / roiImg.shape[0] + if _debug: + _printImgStats(sumInt, 'after normalize') + + # divideLinescan = detectionParams['Divide Line Scan'] + santanaLineScanNorm = self._kymRoiAnalysis.getKymDetectionParam('Divide Line Scan') + # set in roi, can be None + detectionParams.setParam('Divide Line Scan', santanaLineScanNorm) + + if _debug: + logger.info(f' santanaLineScanNorm:{santanaLineScanNorm}') + if santanaLineScanNorm is None: + # logger.error(f'no divide normalization (santana), e.g. "Divide Line Scan"') + dividedInt = sumInt.copy() + dividedInt[:] = np.nan + + else: + # logger.warning(f'dividing by santanaLineScanNorm:{santanaLineScanNorm} roiImg:{roiImg.shape}') + column_to_divide_by = roiImg[:, santanaLineScanNorm] + reshaped_column = column_to_divide_by.reshape(-1, 1) + + try: + # Cannot cast ufunc 'divide' output from dtype('float64') to dtype('int16') + roiImgFloat = roiImg.astype('float64') + reshaped_column_float = reshaped_column.astype('float64') + ones_like = np.ones_like(roiImgFloat) + + # logger.warning(f'roiImg:{roiImg.dtype} reshaped_column:{reshaped_column.dtype} zeros_like:{zeros_like.dtype}') + + dividedImg = np.divide(roiImgFloat, reshaped_column_float, out=ones_like, where=reshaped_column != 0) + + # this col should be all 1 + if not np.all(dividedImg[:,santanaLineScanNorm] == 1): + logger.error(f'santanaLineScanNorm:{santanaLineScanNorm} column should be all 1') + # print(dividedImg[:,santanaLineScanNorm]) + + except (RuntimeWarning) as e: + # RuntimeWarning: divide by zero encountered in divide + logger.error(e) + + # logger.warning(f' dividedImg:{dividedImg.shape} {dividedImg.dtype}') + + # sum intensities in each line scan + dividedInt = np.sum(dividedImg, axis=0) + # normalize to number of points in line scan + dividedInt = dividedInt / dividedImg.shape[0] + + # logger.warning(f' dividedInt:{dividedInt.shape} {dividedInt.dtype}') + + # + # filter - before detection + if detectionParams['Median Filter']: + # 1) medfilt + medianfilterkernel = detectionParams['Median Filter Kernel'] + logger.info(f' applying median filter with medianfilterkernel:{medianfilterkernel}') + sumInt = medfilt(sumInt, kernel_size=medianfilterkernel) + if _debug: + _printImgStats(sumInt, 'after median filter') + + if detectionParams['Savitzky-Golay']: + # 2) SavitzkyGolay_Filter + logger.info(' applying Savitzky-Golay filter') + sumInt = getSavitzkyGolay_Filter(sumInt) + + # logger.warning(f'xPlot:{xPlot.shape} sumInt:{sumInt.shape} dividedInt:{dividedInt.shape}') + # logger.warning(f'xPlot:{xPlot.dtype} sumInt:{sumInt.dtype} dividedInt:{dividedInt.dtype}') + + # check if sumInt is all nan + if np.isnan(sumInt).all(): + logger.error('sumInt is all nan -->> ABORTING') + sys.exit(1) + + postMedianFilterKernel = detectionParams['Post Median Filter Kernel'] + if postMedianFilterKernel > 0: + # if postMedianFilterKernel is even, make it odd + if postMedianFilterKernel % 2 == 0: + logger.warning(f'forcing odd postMedianFilterKernel:{postMedianFilterKernel}') + postMedianFilterKernel += 1 + sumInt = medfilt(sumInt, kernel_size=postMedianFilterKernel) + dividedInt = medfilt(dividedInt, kernel_size=postMedianFilterKernel) + + return xPlot, sumInt, dividedInt + + def _lineToSecond(self, lineNumber) -> float: + """Convert a line scan to seconds. + """ + _left, _top, _right, _bottom = self.getRect() + _lineNumber = lineNumber - _left + _seconds = _lineNumber * self.secondsPerLine + return _seconds + + def _msToBin(self, msValue : float) -> int: + """Convert ms to nearest bin using round. + """ + _retBin1 = msValue / 1000 / self.secondsPerLine + _retBin2 = int(round(_retBin1)) + return _retBin2 + + def _umToBin(self, umValue : float) -> int: + """Convert ms to nearest bin using round. + """ + _retBin1 = umValue / self.umPerPixel + _retBin2 = int(round(_retBin1)) + return _retBin2 + + def getLineProfile(self, channel, lineIdx): + """Get an intensity profile for one line. + """ + roiImg = self.getRoiImg(channel) + + detectionParams = self.getDetectionParams(channel, PeakDetectionTypes.diameter) + + doBackgroundSubtract = detectionParams['do_background_subtract_diam'] + line_width_diam = detectionParams['line_width_diam'] + line_median_kernel_diam = detectionParams['line_median_kernel_diam'] + # stdThreshold = detectionParams['std_threshold_mult_diam'] + # lineScanFraction = detectionParams['line_scan_fraction_diam'] # fraction of line for lef/right, 4 is 25% and 2 is 50% + line_interp_mult_diam = detectionParams['line_interp_mult_diam'] # interpolate each line scan by this multiplyer + + from sanpy.kym.kymRoiDiameter import getLineProfile + lineProfile = getLineProfile(roiImg, + lineIdx=lineIdx, + doBackgroundSubtract=doBackgroundSubtract, + lineWidth=line_width_diam, + lineMedianKernel=line_median_kernel_diam, + lineInterptMult=line_interp_mult_diam + ) + + xUm = np.arange(len(lineProfile)) * self.umPerPixel + if line_interp_mult_diam > 1: + xUm /= line_interp_mult_diam + + return xUm, lineProfile + + # TODO: extend this to 10/90 rise/decay + def getHalfWidthPlot(self, channel, peakDetectionType : PeakDetectionTypes): + """Get x/y to plot half-width for all peaks. + """ + analysisResults = self.getAnalysisResults(channel, peakDetectionType) + + # _peakBins is just used to iterate through peaks + _peakBins = analysisResults.getValues('Peak Bin') + + xHalfWidth = [] + yHalfWidth = [] + for _peakIdx, _peakBin in enumerate(_peakBins): + hwLeftSecond = analysisResults.getValues('HW Left (s)')[_peakIdx] + hwRightSecond = analysisResults.getValues('HW Right (s)')[_peakIdx] + + hwLeftInt = analysisResults.getValues('HW Left Int')[_peakIdx] + # using 'hw left int' for both, otherwise the half-width line is crooked + hwRightInt = analysisResults.getValues('HW Left Int')[_peakIdx] + + # logger.info(f' hwLeftSecond:{hwLeftSecond} {type(hwLeftSecond)}') + # hwLeftSecond = float(hwLeftSecond) + + # here we are expanding by one float (do not extend) + xHalfWidth.append(hwLeftSecond) + xHalfWidth.append(hwRightSecond) + xHalfWidth.append(np.nan) + + yHalfWidth.append(hwLeftInt) + yHalfWidth.append(hwRightInt) + yHalfWidth.append(np.nan) + + return xHalfWidth, yHalfWidth + + def getExpDecayPlot(self, channel, peakDetectionType : PeakDetectionTypes): + """Get x/y to plot exp decay for all peaks. + + Exp Decay depends on detection 'Polarity' + """ + kymRoi = self + + detectionParams = self.getDetectionParams(channel, peakDetectionType) + analysisResults = self.getAnalysisResults(channel, peakDetectionType) + timeSec = self.getTrace(channel, 'Time (s)') + + # fix this constant bug !!!! + # [_left, _, _, _] = kymRoi.getRect() + _peakBins = analysisResults.getValues('Peak Bin') + + xDecay = [] + yDecay = [] + for _peakIdx, _peakBin in enumerate(_peakBins): + + # _peakBin = _peakBin - _left + + fit_m = analysisResults.getValues('fit_m')[_peakIdx] + fit_tau = analysisResults.getValues('fit_tau')[_peakIdx] + fit_b = analysisResults.getValues('fit_b')[_peakIdx] + + if np.isnan(fit_m): + # logger.warning(f'no fit for peak {_peakIdx}') + continue + + # ms to bin + _decayMs = detectionParams['Decay (ms)'] + _decayBin = _decayMs / 1000 / kymRoi._header['secondsPerLine'] + _decayBin = int(round(_decayBin)) + + # decayFitBins = self._detectionDict['decay (ms)'] / 1000 / self.secondsPerLine + decayFitBins = _decayBin + _xRange = timeSec[_peakBin:_peakBin+decayFitBins] - timeSec[_peakBin] + + # get line showing our fit + fit_y = myMonoExp(_xRange, fit_m, fit_tau, fit_b) + + # here we need to extend because we are adding more than one point + xDecay.extend(_xRange+timeSec[_peakBin]) + xDecay.append(np.nan) + + yDecay.extend(fit_y) + yDecay.append(np.nan) + + return xDecay, yDecay + + def getPeakClips(self, peakDetectionType : PeakDetectionTypes, + channel : int, + asPercent : bool = True, + plusMinusMs : float = 50) -> tuple[np.ndarray, np.ndarray]: + + xPeakBins = self.getAnalysisResults(channel, peakDetectionType).getValues('Peak Bin') + numPeaks = len(xPeakBins) + + if numPeaks == 0: + # logger.warning(f'peakDetectionType:{peakDetectionType} channel:{channel} -->> no detection found') + return None, None + + detectThisTrace = self.getDetectionParams(channel, peakDetectionType).getParam('detectThisTrace') + yPlot = self.getTrace(channel, detectThisTrace) + + plusMinusBins = self._msToBin(plusMinusMs) + numPntsInClip = plusMinusBins * 2 + + # all clips share same x + xOneClip = [(x-plusMinusBins)*self.secondsPerLine for x in range(numPntsInClip)] + + xPlotClips = np.empty((numPeaks*2, numPntsInClip)) + xPlotClips[:] = np.nan + yPlotClips = np.empty((numPeaks*2, numPntsInClip)) + yPlotClips[:] = np.nan + + for peakIdx, xPeak in enumerate(xPeakBins): + # logger.info(f'peakIdx:{peakIdx}') + + xStart = xPeak - plusMinusBins + if xStart < 0: + xStart = 0 + xStop = xPeak + plusMinusBins + if xStop > len(yPlot)-1: + xStop = len(yPlot)-1 + + # logger.info(f'xStart:{xStart} xStop:{xStop} plusMinusBins:{plusMinusBins}') + yOneClip = yPlot[xStart:xStop] + + # normalize to max -> percent + try: + if asPercent: + yOneClip = yOneClip / np.max(yOneClip) * 100 + + except (ValueError) as e: + logger.warning(f' (1) not calculating clip for peak: {peakIdx} --> {e}') + logger.error(f' peakIdx:{peakIdx} xStart:{xStart} yStart:{xStart}') + continue + + xPlotClips[peakIdx*2, :] = xOneClip + + try: + yPlotClips[peakIdx*2, :] = yOneClip + except (ValueError) as e: + logger.warning(f' (2) not calculating clip for peak: {peakIdx} --> {e}') + + return xPlotClips, yPlotClips + + def detectDiam(self, channel, verbose : bool = False): + """Detect diam from kym image. + + Uses detectKymRoiDiam() + """ + self.setDirty(True) + + roiImg = self.getRoiImg(channel) # clipped to ltrb of roi rect + + logger.info(f'==>> calling detectKymRoiDiam() with roi {self.getLabel()} {roiImg.shape}') + + from sanpy.kym.kymRoiDiameter import detectKymRoiDiam + + detectionParams = self.getDetectionParams(channel, PeakDetectionTypes.diameter) + + # print('detectionParams:') + # print(detectionParams) + + doBackgroundSubtract = detectionParams['do_background_subtract_diam'] + lineWidth = detectionParams['line_width_diam'] + lineMedianKernel = detectionParams['line_median_kernel_diam'] + stdThreshold = detectionParams['std_threshold_mult_diam'] + lineScanFraction = detectionParams['line_scan_fraction_diam'] # fraction of line for lef/right, 4 is 25% and 2 is 50% + lineInterptMult = detectionParams['line_interp_mult_diam'] # interpolate each line scan by this multiplyer + + # lineScanFraction = 2 # 2 # fraction of line scan to detect for onset/offset + # if 2 then half/half + # if 4 then first/last 25% (good for colin) + + # logger.info(f' doBackgroundSubtract:{doBackgroundSubtract}') + + # need to shift bins to make roi _bottom + leftThresholdBins, rightThresholdBins, diameterBins, sumIntensity = \ + detectKymRoiDiam(roiImg, # roiImg is clipped to ltrb of roi rect + doBackgroundSubtract=doBackgroundSubtract, + lineWidth=lineWidth, + lineMedianKernel=lineMedianKernel, + lineScanFraction=lineScanFraction, + lineInterptMult=lineInterptMult, + stdThreshold=stdThreshold, + ) + + _left, _top, _right, _bottom = self.getRect() + leftThresholdBins += _bottom + rightThresholdBins += _bottom + + _timeBins = self.getTimeBins() + + # self.setTrace2(channel, 'leftThresholdBins', _timeBins, leftThresholdBins) + # self.setTrace2(channel, 'rightThresholdBins', _timeBins, rightThresholdBins) + # self.setTrace2(channel, 'diameterBins', _timeBins, diameterBins) + + self.setTrace2(channel, 'Left Diameter (um)', _timeBins, leftThresholdBins * self.umPerPixel) + self.setTrace2(channel, 'Right Diameter (um)', _timeBins, rightThresholdBins * self.umPerPixel) + self.setTrace2(channel, 'Diameter (um)', _timeBins, diameterBins * self.umPerPixel) + + def peakDetect(self, channel, + peakDetectionType : PeakDetectionTypes, + verbose : bool = False): + """ + Parameters + ========== + channel : int + Channel number to analyze (0 base) + """ + # diameterTraces = ['Diameter (um)', 'Left Diameter (um)', 'Right Diameter (um)'] + + # either intensity or diameter + detectionParams = self.getDetectionParams(channel, detectionType=peakDetectionType) + + if verbose: + logger.info(f'-->> peakDetectionType:{peakDetectionType}') + # print(detectionParams.printValues()) + + # store the ltrb of rect we analysed + roiRect = self.getRect() + detectionParams['ltrb'] = roiRect + _left = roiRect[0] + _right = roiRect[2] + + detectThisTrace = detectionParams['detectThisTrace'] # from (df_f0, f_f0, Diameter (um)) + + # if detectThisTrace in diameterTraces: + if peakDetectionType == PeakDetectionTypes.diameter: + _tmpTrace = self.getTrace(channel, detectThisTrace) + if np.isnan(_tmpTrace).all(): + logger.error(f'trace "{detectThisTrace}" has no value, did you perform "detect diameter"?-->> no detection performed') + return + + if verbose: + logger.info(f'<<=== KymRoi.peakDetect() ==>>') + logger.info(f' roi label:{self._label} with trace:"{detectThisTrace}"') + print(detectionParams.printValues()) + + # parameters + polarity = detectionParams['Polarity'] + prominence = detectionParams['Prominence'] + width = detectionParams['Width (ms)'] / 1000 / self.secondsPerLine + distance = detectionParams['Distance (ms)'] / 1000 / self.secondsPerLine + + # abb 20250530 + # linescan to divide kym image for normalization (Santana) + # 20250608, this is for kym, not individual roi + # divideLinescan = detectionParams['Divide Line Scan'] + + # abb 202505 colin, use 2x f0 value, one for auto, another for manual + # if f0ManualPercentile=='Manual' then user need to directly set f0, we do not calulate it + f0ManualPercentile = detectionParams['f0 Type'] # in (Manual, Percentile) + f0Percentile = detectionParams['f0 Percentile'] + _do__manual_f0 = f0ManualPercentile == 'Manual' + _do__percentile_f0 = f0ManualPercentile == 'Percentile' + if _do__manual_f0 and detectionParams['f0 Value Manual'] is None: + logger.error('ManualPercentile is set to "Manual" but "f0 Value Manual" is None --> user needs to set value') + return + if _do__manual_f0: + f0 = detectionParams['f0 Value Manual'] + elif _do__percentile_f0: + # wil get calculated after removing exp (below) + f0 = detectionParams['f0 Value Percentile'] + + self.setDirty(True) + + # if detectThisTrace in diameterTraces: + if peakDetectionType == PeakDetectionTypes.diameter: + xPlot, _, _ = self.getSumIntensity(channel) # does background subtraction + else: + # xPlot are line scan seconds within roi rect + # yPlot is corresponding sum intensity within roi rect + xPlot, yRaw, dividedInt = self.getSumIntensity(channel) # does background subtraction + + # check if yRaw is all nan + if np.isnan(yRaw).all(): + logger.error(' yRaw is all nan -->> using yRaw') + # sys.exit(1) + # yRaw = dividedInt + + # subtract single exponential to account for intensity decay (bleaking) + # yRaw can not have negative values (We are taking the log) + doExpDetrend = detectionParams['Exponential Detrend'] + # logger.info(f' -->> Exponential Detrend:{doExpDetrend}') + if doExpDetrend: + # logger.info('performing Exponential Detrend') + + fitDict, yDetrend = myDetrend(xPlot, yRaw, doPlot=False) + + if fitDict is None: + logger.error(' error calling myDetrend -->> using yRaw') + yDetrend = yRaw + + else: + detectionParams['expDetrendFit'] = fitDict # store the fit params + + # shift back to all positive + yDetrend += abs(np.min(yDetrend)) + + else: + # logger.info('skipping Exponential Detrend') + yDetrend = yRaw + detectionParams['expDetrendFit'] = None # when off -->> no fit + + if polarity == 'Neg': + f0Percentile = 100 - f0Percentile + + if _do__percentile_f0: + # f0 is a percentile of f_f0, 50 percentile is median + f0 = np.percentile(yDetrend, f0Percentile) + detectionParams['f0 Value Percentile'] = float(f0) # store the f0 value + if verbose: + logger.info(f' -->> using f0 Type: "{f0ManualPercentile}" f0Percentile:{f0Percentile} f0 is :{f0}') + + # the time bins (line scans) within the roi rect + _timeBins = self.getTimeBins() + + if peakDetectionType == PeakDetectionTypes.diameter: + pass + else: + if f0 == 0: + logger.error(f'f0 is {f0}, did not perform df/f0') + f0 = 1 + + # proper dF/F0 + yDf_f0 = (yDetrend - f0) / f0 + # santana likes f/f0 + f_f0 = yDetrend / f0 + + # + # store what we used for analysis + self.setTrace2(channel, 'intRaw', _timeBins, yRaw) + self.setTrace2(channel, 'intDetrend', _timeBins, yDetrend) + self.setTrace2(channel, 'df/f0', _timeBins, yDf_f0) + self.setTrace2(channel, 'f/f0', _timeBins, f_f0) + self.setTrace2(channel, 'Divided', _timeBins, dividedInt) + + yDf_f0 = self.getTrace(channel, detectThisTrace) + # clip to _left/_right + # logger.info(f' clipping _left:{_left} right:{_right } yDf_f0 "{detectThisTrace}" from {len(yDf_f0)}') + yDf_f0 = yDf_f0[_left:_right] + # logger.info(f' to: {len(yDf_f0)}') + + _numNan = np.count_nonzero(np.isnan(yDf_f0)) + # logger.info(f' num nan: {_numNan}') + + # + # add everything to a NEW results object (results are one of PeakDetectionTypes) + oneRoiResults = self.setResults(channel, peakDetectionType, KymRoiResults()) + + # + if polarity == 'Pos': + detectThisY = yDf_f0 + elif polarity == 'Neg': + detectThisY = -yDf_f0 + else: + logger.error(f' did not understand polarity:"{polarity}"') + + self.setDirty(True) + + # + # find peaks (this includes half-width) + # + # will default to rel_height 0.5, 1 is foot, 0 is peak + peakTuple = find_peaks(detectThisY, + prominence=prominence, + distance=distance, + width=width, + # rel_height = thresh_rel_height, + ) + peakBins = peakTuple[0] + peakDict = peakTuple[1] + + logger.info(f' -->> detected {len(peakBins)} peaks') + logger.info(f' roi label:{self._label} {os.path.split(self.path)[1]}') + + if polarity == 'Neg': + peakDict['width_heights'] = -peakDict['width_heights'] + + numPeaks = len(peakBins) + yPeaks = [] + if numPeaks == 0: + return True + + # 20250521 if our folder/files were moved, this self.path is bad !!! + # print(f'self.path:{self.path}') + # sys.exit(1) + + # GOT SOME PEAKS + # CRITICAL, do this first to seed proper number of rows + oneRoiResults.setValues('Peak Number', range(1,numPeaks+1)) # +1 because range is [) + oneRoiResults.setValues('Detected Trace', detectThisTrace) + oneRoiResults.setValues('Channel Number', channel+1) + oneRoiResults.setValues('ROI Number', self._label) + oneRoiResults.setValues('Path', self.path) + oneRoiResults.setValues('Accept', True) # all peaks start as Accept=True + oneRoiResults.setValues('Detection Errors', '') # all peaks start as errors = '' + + # 20241104 was this + # oneRoiResults.setValues('Peak Bin', peakBins) + oneRoiResults.setValues('Peak Bin', peakBins + _left) + + _peakSeconds = (peakBins + _left) * self.secondsPerLine + oneRoiResults.setValues('Peak (s)', _peakSeconds) + + yPeaks = yDf_f0[peakBins] # yDf_f0 is NOT inverted on 'Neg' polarity (always raw trace to analyze) + oneRoiResults.setValues('Peak Int', yPeaks) + + # shift everything by the left pixel of our ROI + # peakBins = peakBins + _left + # peakDict['left_ips'] = peakDict['left_ips'] + _left + # peakDict['right_ips'] = peakDict['right_ips'] + _left + + # + # get the onset and offset of the peak + # + thresh_rel_height = detectionParams['thresh_rel_height'] # using default of 0.85, 1 is foot, 0 is peak + # logger.info(f'finding initial onset/offset thresholds using thresh_rel_height:{thresh_rel_height}') + peak_10_tuple = peak_widths(detectThisY, peakBins, rel_height=thresh_rel_height) + # peak10_widths = peak_10_tuple[0] + # peak10_width_heights = peak_10_tuple[1] + peak10_left_ips = peak_10_tuple[2] + peak10_right_ips = peak_10_tuple[3] + + # shift everything by the left pixel of our ROI + # peak10_left_ips = peak10_left_ips + _left + # peak10_right_ips = peak10_right_ips + _left + + # IMPORTANT: DO THIS AFTER ALL DETECTION IS DONE + # peakBins = peakBins + _left + + thresholdBin = np.round(peak10_left_ips) + thresholdBin = thresholdBin.astype(np.int64) # use this as list index + peakHeight = np.subtract(yPeaks, yDf_f0[thresholdBin]) + oneRoiResults.setValues('Peak Height', peakHeight) + + # inter-peak-interval (seconds) + _peakInterval = np.diff(_peakSeconds) + _peakInterval = np.insert(_peakInterval, 0, np.nan) # insert np.nan as first (0) element + oneRoiResults.setValues('Peak Inst Interval (s)', _peakInterval) + oneRoiResults.setValues('Peak Inst Freq (Hz)', 1 / _peakInterval) + + hwLeftSecond = (peakDict['left_ips'] + _left) * self.secondsPerLine + hwRightSecond = (peakDict['right_ips'] + _left) * self.secondsPerLine + + oneRoiResults.setValues('HW Left (s)', hwLeftSecond) + oneRoiResults.setValues('HW Right (s)', hwRightSecond) + + hwLeftBin = np.round(peakDict['left_ips']) + hwLeftBin = hwLeftBin.astype(np.int64) # use this as list index + hwRightBin = np.round(peakDict['right_ips']) + hwRightBin = hwRightBin.astype(np.int64) # use this as list index + + hwLeftIntensity = yDf_f0[hwLeftBin] + # half-width left/right intensity use the left ??? + hwRigthIntensity = yDf_f0[hwRightBin] + # hwRigthIntensity = yDf_f0[hwLeftBin] + oneRoiResults.setValues('HW Left Int', hwLeftIntensity) + oneRoiResults.setValues('HW Right Int', hwRigthIntensity) + + oneRoiResults.setValues('HW Height', peakDict['width_heights']) + + hwSeconds = ((peakDict['right_ips'] + _left) * self.secondsPerLine) - ((peakDict['left_ips'] + _left) * self.secondsPerLine) + hwMs = hwSeconds * 1000 + oneRoiResults.setValues('HW (ms)', hwMs) + + ## + # CRITICAL: refine peak params using my findThreshold() + # this requires the following to be filled in: (Peak Bin, Peak, Peak Height) + ## + newOnsetOffsetFraction = detectionParams['newOnsetOffsetFraction'] + # logger.info(f'calling findThreshold() with newOnsetOffsetFraction:{newOnsetOffsetFraction} polarity:{polarity}') + if polarity == 'Neg': + doMpl = False + else: + doMpl = False + + newOnsetBins, newDecayBins, newOnset10Bins, newDecay10Bins, _errorList = \ + findThreshold(detectThisY, + polarity, + oneRoiResults, + _leftRoiRect=_left, + newOnsetOffsetFraction=newOnsetOffsetFraction, + doMpl=doMpl) + + newOnsetSeconds = [(_bin+_left)*self.secondsPerLine for _bin in newOnsetBins] + newDecaySeconds = [(_bin+_left)*self.secondsPerLine for _bin in newDecayBins] + + newOnset10Seconds = [(_bin+_left)*self.secondsPerLine for _bin in newOnset10Bins] + newDecay10Seconds = [(_bin+_left)*self.secondsPerLine for _bin in newDecay10Bins] + + # 20241104 + # for numpy + # newOnsetBins += _left + # newDecayBins += _left + # newOnset10Bins += _left + # newDecay10Bins += _left + # as lists + # newOnsetBins = [x+_left for x in newOnsetBins] + # newDecayBins = [x+_left for x in newDecayBins] + # newOnset10Bins = [x+_left for x in newOnset10Bins] + # newDecay10Bins = [x+_left for x in newDecay10Bins] + + # fill in with new/refined results + # oneRoiResults.setValues('Onset Bin', newOnsetBins) + oneRoiResults.setValues('Onset (s)', newOnsetSeconds) + # IMPORTANT: newOnsetBins can have nan values + _tmp_yDf_f0 = [yDf_f0[_newOnsetBin] if ~np.isnan(_newOnsetBin) else np.nan for _newOnsetBin in newOnsetBins] + # oneRoiResults.setValues('Onset Int', yDf_f0[newOnsetBins]) # yPlot is already reduced to roi (left) + oneRoiResults.setValues('Onset Int', _tmp_yDf_f0) # yPlot is already reduced to roi (left) + # peak height is wrt onset (not decay) + peakHeight = np.subtract(oneRoiResults.getValues('Peak Int'), oneRoiResults.getValues('Onset Int')) + oneRoiResults.setValues('Peak Height', peakHeight) + + # oneRoiResults.setValues('Onset 10 Bin', newOnset10Bins) + oneRoiResults.setValues('Onset 10 (s)', newOnset10Seconds) + # IMPORTANT: newOnset10Bins can have nan values + _tmp_yDf_f0 = [yDf_f0[_newOnset10Bin] if ~np.isnan(_newOnset10Bin) else np.nan for _newOnset10Bin in newOnset10Bins] + # oneRoiResults.setValues('Onset 10 Int', yDf_f0[newOnset10Bins]) # yPlot is already reduced to roi (left) + oneRoiResults.setValues('Onset 10 Int', _tmp_yDf_f0) # yPlot is already reduced to roi (left) + + # oneRoiResults.setValues('Decay Bin', newDecayBins) + oneRoiResults.setValues('Decay (s)', newDecaySeconds) + # IMPORTANT: newDecayBins can have nan values + _tmp_yDf_f0 = [yDf_f0[_newDecayBin] if ~np.isnan(_newDecayBin) else np.nan for _newDecayBin in newDecayBins] + # oneRoiResults.setValues('Decay Int', yDf_f0[newDecayBins]) # yPlot is already reduced to roi (left) + oneRoiResults.setValues('Decay Int', _tmp_yDf_f0) # yPlot is already reduced to roi (left) + + # oneRoiResults.setValues('Decay 10 Bin', newDecay10Bins) + oneRoiResults.setValues('Decay 10 (s)', newDecay10Seconds) + # IMPORTANT: newDecay10Bins can have nan values + _tmp_yDf_f0 = [yDf_f0[_newDecay10Bin] if ~np.isnan(_newDecay10Bin) else np.nan for _newDecay10Bin in newDecay10Bins] + # oneRoiResults.setValues('Decay 10 Int', yDf_f0[newDecay10Bins]) # yPlot is already reduced to roi (left) + oneRoiResults.setValues('Decay 10 Int', _tmp_yDf_f0) # yPlot is already reduced to roi (left) + + # logger.info(f'adding _errorList: {len(_errorList)} {_errorList}') + # print(oneRoiResults.df) + for _peakIdx, _err in enumerate(_errorList): + oneRoiResults.addError(_peakIdx, _err) # +1 because we start at 1 + ## + # done refining with findThreshold() + ## + + # 90% or rise and decay works with peak detect + rel_height = 0.1 # 1 is base, 0 is peak + peak_90_tuple = peak_widths(detectThisY, peakBins, rel_height=rel_height) + # peak90_widths = peak_90_tuple[0] + # peak90_width_heights = peak_90_tuple[1] + peak90_left_ips = peak_90_tuple[2] + peak90_right_ips = peak_90_tuple[3] + + # shift everything by the left pixel of our ROI + # peak90_left_ips = peak90_left_ips + _left + # peak90_right_ips = peak90_right_ips + _left + + peak90Bin = np.round(peak90_left_ips) + peak90Bin = peak90Bin.astype(np.int64) # use this as list index + # oneRoiResults.setValues('Peak 90 Bin Fraction', peak90_left_ips) # threshold in fractional bins + # oneRoiResults.setValues('Onset 90 Bin', peak90Bin) # potentially sloppy + oneRoiResults.setValues('Onset 90 (s)', (peak90Bin + _left) * self.secondsPerLine) + oneRoiResults.setValues('Onset 90 Int', yDf_f0[peak90Bin]) # yPlot is already reduced to roi (left) + + # 90 of decay, this is from peak_detect and is good + decay90Bin = np.round(peak90_right_ips) + decay90Bin = decay90Bin.astype(np.int64) # use this as list index + # oneRoiResults.setValues('Decay 90 Bin Fraction', peak90_right_ips) # threshold in fractional bins + # oneRoiResults.setValues('Decay 90 Bin', decay90Bin) # potentially sloppy + oneRoiResults.setValues('Decay 90 (s)', (decay90Bin + _left) * self.secondsPerLine) + oneRoiResults.setValues('Decay 90 Int', yDf_f0[decay90Bin]) # yPlot is already reduced to roi (left) + + # + # colin symposium, full width from onset to offset (using peak "10" detection) + fwSeconds = ((peak10_right_ips + _left) * self.secondsPerLine) - ((peak10_left_ips + _left) * self.secondsPerLine) + fwMs = fwSeconds * 1000 + oneRoiResults.setValues('FW (ms)', fwMs) + + # colin, full with from rise 10 to decay 10 + fw10Seconds = (oneRoiResults.getValues('Decay 10 (s)') - oneRoiResults.getValues('Onset 10 (s)')) + fw10Ms = fw10Seconds * 1000 + oneRoiResults.setValues('FW 10 (ms)', fw10Ms) + + # Rise Decay 10 width (ms) + + # rise and decay time (from onset to peak and peak to decay) + riseTimeSeconds = oneRoiResults.getValues('Peak (s)') - oneRoiResults.getValues('Onset (s)') # element wise subtract + riseTimeMs = riseTimeSeconds * 1000 + oneRoiResults.setValues('Rise Time (ms)', riseTimeMs) + + decayTimeSeconds = oneRoiResults.getValues('Decay (s)') - oneRoiResults.getValues('Peak (s)') # element wide subtraction + decayTimeMs = decayTimeSeconds * 1000 + oneRoiResults.setValues('Decay Time (ms)', decayTimeMs) + + riseTen90Seconds = oneRoiResults.getValues('Onset 90 (s)') - oneRoiResults.getValues('Onset 10 (s)') # element wise subtract + rise1090Ms = riseTen90Seconds * 1000 + oneRoiResults.setValues('10-90 Rise Time (ms)', rise1090Ms) + + decay1090Seconds = oneRoiResults.getValues('Decay 10 (s)') - oneRoiResults.getValues('Decay 90 (s)') # element wise subtract + decay1090Ms = decay1090Seconds * 1000 + oneRoiResults.setValues('10-90 Decay Time (ms)', decay1090Ms) + + # + # (1) exp fit of each peak + # [_left, _, _, _] = self.roiList._roiAsRect(roi) + # decayFitBins = self._detectionDict['decay (ms)'] / 1000 / self.secondsPerLine # TODO: convert to sec or ms + decayFitBins = self._msToBin(detectionParams['Decay (ms)']) + # fit_m, fit_tau, fit_b, fit_r2, fit_error = _expFit(xPlot, yDf_f0, peakBins - _left, decayFitBins=decayFitBins) + fit_m, fit_tau, fit_b, fit_r2, fit_error = _expFit(xPlot, yDf_f0, peakBins, decayFitBins=decayFitBins) + oneRoiResults.setValues('fit_m', fit_m) + oneRoiResults.setValues('fit_tau', fit_tau) + oneRoiResults.setValues('fit_b', fit_b) + oneRoiResults.setValues('fit_r2', fit_r2) + # logger.info(f'fit_error is:{fit_error}') + for _peakErrorIdx, oneError in enumerate(fit_error): + if oneError != '': + # logger.warning(f'_expFit oneError:{oneError}') + oneRoiResults.addError(_peakErrorIdx, oneError) + + # + # (2) double exp fit of each peak + # [_left, _, _, _] = self.roiList._roiAsRect(roi) + # decayFitBins = self._detectionDict['decay (ms)'] / 1000 / self.secondsPerLine # TODO: convert to sec or ms + decayFitBins = self._msToBin(detectionParams['Decay (ms)']) + # fit_m1, fit_tau1, fit_m2, fit_tau2, fit_r22, fit_error = _expFit2(xPlot, yDf_f0, peakBins - _left, decayFitBins=decayFitBins) + fit_m1, fit_tau1, fit_m2, fit_tau2, fit_r22, fit_error = _expFit2(xPlot, yDf_f0, peakBins, decayFitBins=decayFitBins) + oneRoiResults.setValues('fit_m1', fit_m1) + oneRoiResults.setValues('fit_tau1', fit_tau1) + oneRoiResults.setValues('fit_m2', fit_m2) + oneRoiResults.setValues('fit_tau2', fit_tau2) + oneRoiResults.setValues('fit_r22', fit_r22) + # logger.info(f'fit_error is:{fit_error}') + for _peakErrorIdx, oneError in enumerate(fit_error): + if oneError != '': + # logger.warning(f'_expFit2 oneError:{oneError}') + oneRoiResults.addError(_peakErrorIdx, oneError) + + # 202505 colin, calculate sum intensity in the peak + # there are a few ways to do this !!! + _sumPeakList = [] + # logger.error(f'TODO 202505 getAreaUnderPeak') + for _idx, newOnsetBin in enumerate(newOnsetBins): + newDecayBin = newDecayBins[_idx] + _sumPeak = getAreaUnderPeak(detectThisY, newOnsetBin, newDecayBin) + _sumPeakList.append(_sumPeak) + oneRoiResults.setValues('Area Under Peak', _sumPeakList) + + # + return True + +# abb 202505 colin +def getAreaUnderPeak(yPlot, onsetBin, offsetBin, baseline:float = None) -> float: + """ + Get the sum/area under the peak. + + This is normalized sum of intensity from oneset to offset (inclusive). + + TODO: we want to have an alternate "Santana" version where user specifies a baseline. + + TODO: This is currently in pixels, maybe convert to physical units of "per second"? + Rather than "per pixel" + """ + if onsetBin is None or onsetBin is None: + logger.error(f'onsetBin:{onsetBin} offsetBin:{offsetBin}') + return np.nan + + if baseline is not None: + onsetBaseline = baseline + else: + onsetBaseline = yPlot[onsetBin] # this will be 0 + + try: + peakCLip = yPlot[onsetBin:offsetBin+1] - onsetBaseline + peakCLipSum = np.sum(peakCLip) # the area under the peak + except (TypeError) as e: + logger.error(e) + logger.error(f'colin 20250521 onsetBin:{onsetBin} offsetBin:{offsetBin} onsetBaseline:{onsetBaseline}') + return np.nan + + return peakCLipSum + +class KymRoiAnalysis: + def __init__(self, path : str = None, + imgData : List[np.ndarray] = None, + kymRoiWidget = None, + loadAnalysis : bool = False, + loadImgData:bool = True): + """ + Holds a number of kymRoi for one image file (multiple channels). + + Parameters + ---------- + path : str + Full path to .tif file + imgData : List[np.ndarray] + A list of equal sized 2d arrays, one item per image channel. + kymRoiWidget : sanpy.kym.interface.KymRoiWidget + During GUI runtime to update the statusbar + """ + self._path = path + self._kymRoiWidget = kymRoiWidget # used to just call mySetStatusbar(str) + self._loadError = False + + self._roiDict = {} + """Keys are labels, values are KymRoi""" + + # 202505 colin + # now our kym itself (not roi) has detection params + self._kymDetectionParams = { + 'Divide Line Scan': None, + } + + self._imgData : List[np.ndarray] = imgData + """List of single channel images.""" + + self._kymRoiMetaData:KymRoiMetaData = KymRoiMetaData(path, imgData) + """KymRoiMetaData object.""" + + # load image data + if loadImgData: + ba = bAnalysis(path) + self._imgData = ba.fileLoader._tif # list of color channel images + + if not isinstance(self._imgData, List): + self._imgData = [imgData] + # ingest or load image data + if imgData is not None: + if not isinstance(imgData, List): + imgData = [imgData] + if len(imgData) == 0: + logger.error(f'did not get image data for path:{path}') + self._loadError = True + return + + self._imgData = imgData + """List of single channel images.""" + + self._kymRoiMetaData = KymRoiMetaData(path, self._imgData) + + # logger.info(f'from imgData channels:{len(self._imgData)} shape:{self._imgData[0].shape}') + + self._fakeScale = False + self._isDirty = False + + # 1) try and load from saved files + loadedHeaderDict = self.loadAnalysis() + + if loadedHeaderDict is not None: + self._kymRoiMetaData['Acq Date'] = loadedHeaderDict['Acq Date'] + self._kymRoiMetaData['Acq Time'] = loadedHeaderDict['Acq Time'] + self._kymRoiMetaData['secondsPerLine'] = loadedHeaderDict['secondsPerLine'] + self._kymRoiMetaData['umPerPixel'] = loadedHeaderDict['umPerPixel'] + + else: + # find corresponding Olympus txt file with params + olympusHeader = _loadLineScanHeader(path) + if olympusHeader is not None: + self._kymRoiMetaData['Acq Date'] = olympusHeader['date'] + self._kymRoiMetaData['Acq Time'] = olympusHeader['time'] + self._kymRoiMetaData['secondsPerLine'] = olympusHeader['secondsPerLine'] + self._kymRoiMetaData['umPerPixel'] = olympusHeader['umPerPixel'] + else: + self._fakeScale = True + _secondsPerLine = 0.002 + _umPerPixel = 0.15 + self._kymRoiMetaData['secondsPerLine'] = _secondsPerLine + self._kymRoiMetaData['umPerPixel'] = _umPerPixel + logger.error(f'USING FAKE IMAGE SCALE !! secondsPerLine:{_secondsPerLine} umPerPixel:{_umPerPixel}') + + _xAxisBins = np.arange(0, self.numLineScans) + self._xAxisSeconds = np.array(_xAxisBins, dtype=np.float32) + self._xAxisSeconds *= self.secondsPerLine + """Static X-Axis (s). + """ + + self.setDirty(False) + + def setKymDetectionParam(self, key, value): + if key not in self._kymDetectionParams.keys(): + logger.error(f'key:{key} not in self._kymDetectionParams.keys()') + return + self._kymDetectionParams[key] = value + + def getKymDetectionParam(self, key): + if key not in self._kymDetectionParams.keys(): + logger.error(f'key:{key} not in self._kymDetectionParams.keys()') + return + return self._kymDetectionParams[key] + + def getChannelColor(self, channel : int): + colorConfig = { + 0 : 'Red', + 1 : 'Green' + } + if self.numChannels == 1: + # 1 channel will always be green + return colorConfig[1] + else: + return colorConfig[channel] + + def peakDetectAllRoi(self, channel): + logger.info('=== detecting peaks for all roi') + for roiLabel, kymRoi in self._roiDict.items(): + logger.info(f' -->> roiLabel:{roiLabel}') + kymRoi.peakDetect(channel, peakDetectionType=PeakDetectionTypes.intensity) + + def setDirty(self, value): + # logger.info(f'value:{value}') + self._isDirty = value + + @property + def path(self): + return self._path + + def getImageChannel(self, channel): + if channel > len(self._imgData) - 1: + logger.error(f'bad image channel {channel}, max channel number is {len(self._imgData)-1}') + return + return self._imgData[channel] + + @property + def header(self): + # return self._headerDict + return self._kymRoiMetaData + + @property + def umPerPixel(self) -> float: + return self.header['umPerPixel'] + + @property + def numChannels(self) -> int: + return self.header['numChannels'] + + @property + def secondsPerLine(self) -> float: + return self.header['secondsPerLine'] + + @property + def numLineScans(self) -> float: + return self.header['imageWidth'] + + @property + def numPixelsPerLine(self) -> float: + """Number of pixels in each line scan. + """ + return self.header['imageHeight'] + + @property + def numRoi(self) -> int: + """Get the number of rois. + """ + return len(self._roiDict.keys()) + + def getRoiLabels(self) -> List[str]: + return list(self._roiDict.keys()) + + # abb 202505 colin + def getCopyToClipboard(self) -> dict: + """Get a json serializable dict of all roi + """ + roiLabels = self.getRoiLabels() + ret = {} + for label in roiLabels: + roi = self.getRoi(label) + oneRoiDict = roi.getRectDict() + ret[label] = oneRoiDict + return ret + + def _getNextRoiLabel(self) -> str: + """Get the next available roi label. + """ + if self.numRoi == 0: + return '1' + else: + nextLabel = list(self._roiDict.keys())[self.numRoi - 1] + nextLabel = int(nextLabel) + nextLabel += 1 + return str(nextLabel) + + def addROI(self, + ltrbRect : List[int] = None, + reuseRoiLabel : str = None, + mode : str = None + ) -> KymRoi: + """Add a new roi. + + Parameters + ---------- + ltrb : [l, t, r, b] + reuseRoiLabel : + Reuse detection params of existing roi. + """ + + roiLabel = self._getNextRoiLabel() + + # logger.info(f'adding new roi label {roiLabel}') + + newRoi = KymRoi(roiLabel, + self._imgData, + header=self.header, + ltrbRect=ltrbRect, + reuseRoiLabel=reuseRoiLabel, + kymRoiAnalysis=self, # so roi can use reuseRoiLabel + ) + self._roiDict[roiLabel] = newRoi + self.setDirty(True) + return newRoi + + def deleteRoi(self, roiLabel : str) -> bool: + # roi will be none if it is not a key + # logger.info(f'pop roiLabel:{roiLabel} from self._roiDict keys:{self._roiDict.keys()}') + roi = self._roiDict.pop(roiLabel, None) + self.setDirty(True) + return roi + + def getRoi(self, roiLabel : str) -> KymRoi: + """Get a KymRoi from a label str. + """ + if not isinstance(roiLabel, str): + roiLabel = str(roiLabel) + if roiLabel not in self._roiDict.keys(): + logger.error(f'roiLabel "{roiLabel}" does not exist, available roi keys are {list(self._roiDict.keys())}') + return + return self._roiDict[roiLabel] + + def getXAxis(self): + return self._xAxisSeconds + + def _msToBin(self, msValue : float) -> int: + """Convert ms to nearest bin using round. + """ + _retBin1 = msValue / 1000 / self.secondsPerLine + _retBin2 = int(round(_retBin1)) + # logger.info(f'msValue:{msValue} _retBin1:{_retBin1} _retBin2:{_retBin2}') + return _retBin2 + + # TODO: refactor, only used by _getSaveFile + def _getSaveFolder(self, enclosingFolder=False, createFolder=True): + _folder, _ = os.path.split(self.path) # folder the raw tif is in + + if not enclosingFolder: + # folder we save csv into + _folder = os.path.join(_folder, 'sanpy-kym-roi-analysis') + if createFolder and not os.path.isdir(_folder): + os.mkdir(_folder) + + return _folder + + def _getSaveFile(self, channel, createFolder=True): + """Get full path to file to save/load analysis. + + Returns + ------- + peaks + diameter + traces + """ + saveFolder = self._getSaveFolder(createFolder=createFolder) + + _, _file = os.path.split(self.path) # folder the raw tif is in + + _saveFile = os.path.splitext(_file)[0] + + saveFilePeaks = _saveFile + f'-ch{channel}-roiPeaks.csv' # peaks + saveFilePeaks = os.path.join(saveFolder, saveFilePeaks) + + saveFileDiameter = _saveFile + f'-ch{channel}-roiDiameter.csv' # diameter + saveFileDiameter = os.path.join(saveFolder, saveFileDiameter) + + saveFileInt = _saveFile + f'-ch{channel}-roiTraces.csv' # intensity + saveFileIntPath = os.path.join(saveFolder, saveFileInt) + + return saveFilePeaks, saveFileDiameter, saveFileIntPath + + def getDataFrame(self, + channel, + peakDetectionType : PeakDetectionTypes, + roiLabel = None): + """Get results df for one roi or all roi (use roi=None). + + Only results for one typ of PeakDetectionTypes + """ + if roiLabel is not None: + return self.getRoi(roiLabel).getAnalysisResults(channel, peakDetectionType) + else: + columns = list(KymRoiResults.analysisDict.keys()) + df = pd.DataFrame(columns=columns) # empty df with proper columns + for _roiIdx, roi in enumerate(self._roiDict.values()): + # logger.info(f'oneDf:{oneDf}') + oneDf = roi.getAnalysisResults(channel, peakDetectionType).df + if _roiIdx == 0: + df = oneDf + else: + # abb 20250621 + # fixes + # FutureWarning: The behavior of DataFrame concatenation with empty or all-NA entries is deprecated + if len(oneDf) > 0: + df = pd.concat([df, oneDf], axis=0) + try: + df = df.reset_index(drop=True) + except (ValueError) as e: + logger.error(e) + + return df + + def getCombindedDataFrame(self, channel): + """Get combined DataFrame of analysis including both f_f0 and diameter. + """ + dfSum = self.getDataFrame(channel, PeakDetectionTypes.intensity) + dfSum['Analysis Type'] = 'f/f0' + + dfDiameter = self.getDataFrame(channel, PeakDetectionTypes.diameter) + dfDiameter['Analysis Type'] = 'Diameter (um)' + + df = pd.concat([dfSum, dfDiameter], axis=0) + df = df.reset_index(drop=True) + return df + + def isDirty(self): + """isDirty is true if we are dirty or any of the rois are dirty. + """ + if self._isDirty: + logger.info('kymRoiAnalysis is dirty') + return True + for roiLabel, kymRoi in self._roiDict.items(): + if kymRoi._isDirty: + logger.info(f'roiLabel: {roiLabel} is dirty') + return True + return False + + def mySetStatusBar(self, statusStr : str): + """Set the status bar of a parent kymRoiWidget. + + Only exists during PyQt runtime (not in scripts). + """ + if self._kymRoiWidget is not None: + self._kymRoiWidget.mySetStatusbar(statusStr) + + def saveAnalysisTraces(self, channel): + """Save all roi traces for a channel in one csv file. + """ + _, _, tracePath = self._getSaveFile(channel) + + df = pd.DataFrame() + + for roiLabel, kymRoi in self._roiDict.items(): + + kymRoi:KymRoi = kymRoi + + kymRoiTraces = kymRoi.kymRoiTraces[channel] + # if kymRoiTraces.isEmpty(): + # continue + for traceKey, traceValues in kymRoiTraces.items(): + # don't save if all nan + if np.all(np.isnan(traceValues)): + # logger.warning(f' not saving trace:{traceKey} -->> all nan') + continue + + colName = f'ROI {roiLabel} {traceKey}' + # df[colName] = [np.nan] * numLineScans + # logger.info(f' roiLabel:{roiLabel} traceKey:{traceKey} colName:{colName} len:{len(traceValues)}') + df[colName] = traceValues # might be nan + + # logger.warning('todo: save a one line header with um/pixel and seconds/line') + _fileHeaderJson = self._kymRoiMetaData.toJson() + + # logger.warning('saving traces even if 0 peaks') + # if len(df) == 0: + # # nothing to save + # pass + # else: + if 1: + # logger.info(f'saving intensity traces to: {tracePath}') + # df.to_csv(intPath, index=False) + with open(tracePath, 'w') as f: + f.write(_fileHeaderJson) + f.write('\n') + f.write(df.to_csv(header=True, index=False, mode='a')) + + def saveImageClips(self): + """Save image clips for each roi. + + Each ROI will have 4x files (* color channel) + - raw + - background subtracted + - binned + - divided + """ + # cell id from tif file + _folder, _name = os.path.split(self.path) + cellID = os.path.splitext(_name)[0] + + # WARNING: we need to ignore folder 'roi-img-clips' when building tif list !!! + tifFolder = os.path.join(self._getSaveFolder(), 'kym-roi-img-clips') + # logger.info(f'saving to tifFolder:{tifFolder}') + + for channel in range(self.numChannels): + for roiLabel, kymRoi in self._roiDict.items(): + kymRoi:KymRoi = kymRoi + # roiImg_raw, roiImg_bs, roiImg_binned, roiImg_divided = \ + + # get a number of image clips (raw, f/f0 df/f0, divided) + roiImgClipsDict = kymRoi.getRoiImgClips(channel) + + for clipKey, clipImgData in roiImgClipsDict.items(): + if not os.path.isdir(tifFolder): + os.makedirs(tifFolder) + tifFileName = f'{cellID}-ch{channel}-roi{roiLabel}-{clipKey}.tif' + savePath = os.path.join(tifFolder, tifFileName) + # logger.info(f' roiLabel:{roiLabel} clipKey:{clipKey}') + # logger.info(f' tifFileName:{tifFileName}') + # logger.info(f' savePath:{savePath}') + tifffile.imwrite(savePath, clipImgData) + + + def saveAnalysis(self): + """Save all analysis into a number of csv files. + + This includes a header with roi [l,t,r,b] and detection parameters used. + + Each ROI will have 3x files (* color channel) + - intensity peaks + - diameter peaks + - traces (raw data that was analyzed, for both f/f0 peaks and diameter peaks) + """ + + # logger.info('') + if not self.isDirty: + _noSaveStr = 'No changes to save.' + logger.info(_noSaveStr) + self.mySetStatusBar(_noSaveStr) + return False + + # self.saveImageClips() + + for channel in range(self.numChannels): + + # each channel goes to its own file + _fileHeaderDict = {} + _fileHeaderDictDiameter = {} + + # 202505 colin + # logger.info(f'saving key "_kymDetectionParams":{self._kymDetectionParams}') + _fileHeaderDict['kymDetectionParams'] = self._kymDetectionParams + + for roiLabel, kymRoi in self._roiDict.items(): + # what was used for detection, including [l,t,r,b] of rect roi + + # just key value pairs for detection parameters + _fileHeaderDict[roiLabel] = kymRoi.getDetectionParams(channel, PeakDetectionTypes.intensity).getValueDict() + _fileHeaderDictDiameter[roiLabel] = kymRoi.getDetectionParams(channel, PeakDetectionTypes.diameter).getValueDict() + + # one line json header with all roi and their detection params + _fileHeaderJson = json.dumps(_fileHeaderDict) + _fileHeaderJson_diameter = json.dumps(_fileHeaderDictDiameter) + + peakPath, diameterPath, _ = self._getSaveFile(channel) + + # _savedPeaks = False + + dfToSaveIntensity = self.getDataFrame(channel, PeakDetectionTypes.intensity) + + # if len(dfToSaveIntensity) == 0: + # pass + # # no intensity peaks to save + # else: + if 1: + # logger.info(f'saving f/f0 peaks to: {peakPath}') + # _savedPeaks = True + with open(peakPath, 'w') as f: + f.write(_fileHeaderJson) + f.write('\n') + f.write(dfToSaveIntensity.to_csv(header=True, index=False, mode='a')) + + dfToSaveDiameter = self.getDataFrame(channel, PeakDetectionTypes.diameter) + + if len(dfToSaveDiameter) == 0: + # no diameter peaks to save + pass + else: + # logger.info(f'saving diameter to: {diameterPath}') + # _savedPeaks = True + with open(diameterPath, 'w') as f: + f.write(_fileHeaderJson_diameter) + f.write('\n') + f.write(dfToSaveDiameter.to_csv(header=True, index=False, mode='a')) + + # only save analysis traces if we save (intensity or diameter) peaks + # 202505 always save, even of no peaks + # if _savedPeaks: + if 1: + self.saveAnalysisTraces(channel) + + self.setDirty(False) + for roiLabel, roi in self._roiDict.items(): + roi.setDirty(False) + + return True + + def _loadThisFile(self, filePath, channel, peakDetectionType : PeakDetectionTypes, addRois): + """Load peak detection file from either intensity or diameter. + + Parameters + ========== + addRois : bool + If true then add rois + """ + if not os.path.isfile(filePath): + # if peakDetectionType == PeakDetectionTypes.diameter: + # logger.info(f'did not find file to load:{filePath}') + return False + + with open(filePath) as f: + headerJson = f.readline() + _headerDict = json.loads(headerJson) + + dfLoadedFromFile = pd.read_csv(filePath, header=1) # can be empty + + # _headerDict is a dict with roi name keys, make a number of rois + # self._detectionDict = _headerDict + _firstRoi = None + for roiNumber,detectionDict in _headerDict.items(): + # roiNumber is str like '1', '2', '3',... + # logger.info(f'{roiNumber}: {detectionDict}') + + if roiNumber == 'kymDetectionParams': + # abb 202505 colin, global detection params for kym + # logger.info(f'loading kymDetectionParams detectionDict:{detectionDict}') + # print(filePath) + self._kymDetectionParams = detectionDict + continue + + kymRoiDetection = KymRoiDetection(peakDetectionType, fromDict=detectionDict) + + # add the roi + if addRois: + kymRoi = self.addROI(kymRoiDetection['ltrb']) # add to all channels + # logger.info(f' added roi channel:{channel} kymRoi:{kymRoi}') + else: + kymRoi = self.getRoi(roiNumber) + + # set detection + kymRoi.setDetection(channel, PeakDetectionTypes.intensity, kymRoiDetection) + + # ValueError: invalid literal for int() with base 10: 'kymDetectionParams' + # fill in analysis results + oneRoiResults = KymRoiResults() + dfRoi = dfLoadedFromFile[ dfLoadedFromFile['ROI Number']==int(roiNumber) ] + dfRoi = dfRoi.reset_index(drop=True) # Do not try to insert index into dataframe columns. + oneRoiResults._swapInNewDf(dfRoi) + # kymRoi._kymRoiResults = oneRoiResults + kymRoi.setResults(channel, PeakDetectionTypes.intensity, oneRoiResults) + return True + + def loadAnalysis(self): + _maxChannels = 3 + _loadedHeaderDict = None + addRois = True + for channel in range(_maxChannels): + peakPath, diameterPath, tracePath = self._getSaveFile(channel, createFolder=False) + + # load header (metadata) from trace file + if not os.path.isfile(tracePath): + continue + + # trace header has path to tif, if use moves entire folder, this is broken + + with open(tracePath) as f: + headerJson = f.readline() + _loadedHeaderDict = json.loads(headerJson) + # print(f'self.path is: {self.path}') + # print(f"_loadedHeaderDict.path is: {_loadedHeaderDict['path']}") + _loadedHeaderDict['path'] = self.path + # logger.info(f'trace header is:{_loadedHeaderDict}') + # self._kymRoiMetaData.fromJson(headerJson) + self._kymRoiMetaData = KymRoiMetaData.fromDict(_loadedHeaderDict) + + loaded_f_f0 = self._loadThisFile(peakPath, channel, PeakDetectionTypes.intensity, addRois=addRois) + if loaded_f_f0: + addRois = False + loaded_diameter = self._loadThisFile(diameterPath, channel, PeakDetectionTypes.diameter, addRois=addRois) + if loaded_diameter: + addRois = False + + # there is one trace file per channel, with all roi traces + # need to file them into the correct kymRoi + + # with open(tracePath) as f: + # headerJson = f.readline() + # _loadedHeaderDict = json.loads(headerJson) + # logger.info(f'trace header is:{_loadedHeaderDict}') + + # set secondsPerLine and umPerPixel + # self.header['secondsPerLine'] = _headerDict['secondsPerLine'] + # self.header['umPerPixel'] = _headerDict['umPerPixel'] + + # one df spanning all roi(s) + loadedIntDf = pd.read_csv(tracePath, header=1) + + for _roiLabel in self.getRoiLabels(): + try: + kymRoi = self.getRoi(_roiLabel) + kymRoi.kymRoiTraces[channel].loadTraces(_roiLabel, loadedIntDf) + except pd.errors.EmptyDataError: + # file was empty -->> nothing to load + pass + + return _loadedHeaderDict + + def getParamDataFrame(self) -> pd.DataFrame: + """Get a dataframe of all detection params. + + One row per roi. + """ + dictList = [] + for roiLabel, roi in self._roiDict.items(): + dictList.append(roi.detectionParams.getValueDict()) + df = pd.DataFrame.from_dict(dictList) + return df + + def getAnalysisTrace(self, roi : str, name : str, channel : int) -> np.ndarray: + kymRoi = self.getRoi(roi) + return kymRoi.getTrace(channel, name) + + def getDetectionParams(self, + roi : str, + detectionType : PeakDetectionTypes, + channel : int) -> KymRoiDetection: + kymRoi = self.getRoi(roi) + return kymRoi.getDetectionParams(channel, detectionType) + + def getAnalysisResults(self, roi : str, + detectionType : PeakDetectionTypes, + channel : int) -> KymRoiResults: + kymRoi = self.getRoi(roi) + if kymRoi is None: + logger.error(f'did not find roi label "{roi}"') + return + return kymRoi.getAnalysisResults(channel, detectionType) + + def detectDiam(self, roi : str, channel : int): + kymRoi = self.getRoi(roi) + kymRoi.detectDiam(channel=channel) + + def __iter__(self): + self._currentIter = -1 + return self + + def __next__(self): # Python 2: def next(self) + self._currentIter += 1 + if self._currentIter < self.numRoi: + _keyList = list(self._roiDict.keys()) + _key = _keyList[self._currentIter] + return self.getRoi(_key) + raise StopIteration + +# utils +def _printImgStat(imgData : np.ndarray, name : str = ''): + + if name != '': + name += ':' + numZeros = np.count_nonzero(imgData==0) + logger.info(f'{name} {imgData.shape} mean:{np.mean(imgData)} median:{np.median(imgData)} min:{np.min(imgData)} max:{np.max(imgData)} num zero:{numZeros} {imgData.dtype}') + +def BinLineScans(imgData, numLinesPerBin): + numLines = imgData.shape[1] + + # logger.info(f'numLines:{numLines} numLinesPerBin:{numLinesPerBin}') + + retImg = np.empty_like(imgData) + + for _idx, line in enumerate(range(numLines)): + + lineStart = line - numLinesPerBin + if lineStart < 0: + lineStart = 0 + lineStop = line + numLinesPerBin + if lineStop > imgData.shape[1]-1: + lineStop = imgData.shape[1] + + + oneSlice = imgData[:, lineStart:lineStop] + oneLine = np.mean(oneSlice, axis=1) + + # if _idx == 100: + # logger.info(f'imgData:{imgData.shape} oneSlice:{oneSlice.shape} oneLine:{oneLine.shape}') + + retImg[:,line] = oneLine + + return retImg + +def findThreshold(kymRoiTrace : np.ndarray, + polarity : str, + kymRoiResults : KymRoiResults, + _leftRoiRect : int, + newOnsetOffsetFraction : float = 0.1, + doMpl = False): + """Refine all peak parameters. + - Onset + - Rise 10 + - [depreciated] Rise 90 + - Offset + - Decay 10 + - [depecriated] Decay 90 + -half-width + + Notes: + ------ + Removed Rise 90 and Decay 90, scipy peak_detect is good at getting this + + Although scipy peak_detect is awesome for peaks, it is not great at onset/offset beyond half-height. + Started 20240925 - colin symposium + """ + + # _left, _, _, _ = kymRoi.getRect() + # logger.info(f'_left:{_left}') + + int_df_f0 = kymRoiTrace + + peakBins = kymRoiResults.getValues('Peak Bin') + peakValues = kymRoiResults.getValues('Peak Int') # y-value at peak + + # will be negative if we detected negative peaks + peakHeights = kymRoiResults.getValues('Peak Height') # peak - threshold value + if polarity == 'Neg': + peakHeights = -peakHeights + peakValues = -peakValues + + # logger.info(f' peakHeights:{peakHeights}') + # logger.info(f' peakValues:{peakValues}') + + # logger.info(f'polarity:{polarity}') + # cludge, trying to remove all if pos/neg below + polarity = 'Pos' + + numPeaks = len(peakBins) + + # returns + # onsetBinList = np.empty(shape=numPeaks) # [np.nan] * numPeaks + # onsetBinList[:] = np.nan + # offsetBinList = np.empty(shape=numPeaks) # [np.nan] * numPeaks + # offsetBinList[:] = np.nan + # onset10BinList = np.empty(shape=numPeaks) # [np.nan] * numPeaks + # onset10BinList[:] = np.nan + # offset10BinList = np.empty(shape=numPeaks) # [np.nan] * numPeaks + # offset10BinList[:] = np.nan + # newPeakHeightList = np.empty(shape=numPeaks) # [np.nan] * numPeaks + # newPeakHeightList[:] = np.nan + + onsetBinList = [np.nan] * numPeaks + offsetBinList = [np.nan] * numPeaks + onset10BinList = [np.nan] * numPeaks + offset10BinList = [np.nan] * numPeaks + newPeakHeightList = [np.nan] * numPeaks + + peaksErrors = [''] * numPeaks + + for _idx, peakBin in enumerate(peakBins): + + # THIS IS SUPER BAD ... BUT NEEDED + peakBin -= _leftRoiRect + + yPeak = peakValues[_idx] + peakHeight = peakHeights[_idx] + + # + # new rise and decay + if polarity == 'Pos': + threshold = yPeak - (peakHeight * newOnsetOffsetFraction) # 0.9 is 90% height + elif polarity == 'Neg': + threshold = yPeak + (peakHeight * newOnsetOffsetFraction) # 0.9 is 90% height + else: + logger.error(f'did not understand polarity:{polarity}') + + if polarity == 'Pos': + threshold_crossings = np.diff(int_df_f0 > threshold, append=False) # "False" is the value to append + elif polarity == 'Neg': + threshold_crossings = np.diff(int_df_f0 < threshold, append=False) # "False" is the value to append + thresholdBins = np.where(threshold_crossings[0:peakBin]==1)[0] + if len(thresholdBins) == 0: + # _errStr = f'peak {_idx} findThreshold failed to find rise bin.' + _errStr = f'findThreshold failed to find rise bin.' + peaksErrors[_idx] += _errStr + ';' + logger.error(_errStr) + else: + thresholdBin = thresholdBins[-1] # first threshold before peak + # back it up by one bin ??? When onset is fast, actual threshold crossing is way too high + # this is achieved with apend=False + # thresholdBin = thresholdBin - 1 + onsetBinList[_idx] = thresholdBin + + # we have a new peak height + if polarity == 'Pos': + peakHeight2 = yPeak - int_df_f0[thresholdBin] + elif polarity == 'Neg': + peakHeight2 = yPeak + int_df_f0[thresholdBin] + newPeakHeightList[_idx] = peakHeight2 + + # 10% in rise + if polarity == 'Pos': + threshold2 = yPeak - (peakHeight2 * 0.9) # 0.9 is 90% height, eg rise 10 + if polarity == 'Neg': + threshold2 = yPeak + (peakHeight2 * 0.9) # 0.9 is 90% height, eg rise 10 + + if polarity == 'Pos': + threshold_crossings2 = np.diff(int_df_f0 > threshold2, prepend=False) + elif polarity == 'Neg': + threshold_crossings2 = np.diff(int_df_f0 < threshold2, prepend=False) + threshold10Bins = np.where(threshold_crossings2[0:peakBin]==1)[0] + if len(threshold10Bins) == 0: + # _errStr = f'peak {_idx} findThreshold failed to find 10% rise bin' + _errStr = f'findThreshold failed to find 10% rise bin' + logger.error(_errStr) + peaksErrors[_idx] += _errStr + ';' + else: + threshold10Bin = threshold10Bins[-1] # first threshold before peak + onset10BinList[_idx] = threshold10Bin + + if polarity == 'Pos': + threshold_crossings = np.diff(int_df_f0 > threshold, prepend=False) # "False" is the value to append + elif polarity == 'Neg': + threshold_crossings = np.diff(int_df_f0 < threshold, prepend=False) # "False" is the value to append + decayBins = np.where(threshold_crossings[peakBin:-1]==1)[0] + if len(decayBins) == 0: + # _errStr = f'peak {_idx} findThreshold failed to find falling bin' + _errStr = f'findThreshold failed to find falling bin' + peaksErrors[_idx] += _errStr + ';' + logger.error(_errStr) + else: + decayBin = decayBins[0] # first threshold after peak + decayBin += peakBin + offsetBinList[_idx] = decayBin + + # decay has different height + if polarity == 'Pos': + peakHeight3 = yPeak - int_df_f0[decayBin] + threshold3 = yPeak - (peakHeight3 * 0.9) # 0.9 is 90% height + elif polarity == 'Neg': + peakHeight3 = yPeak + int_df_f0[decayBin] + threshold3 = yPeak + (peakHeight3 * 0.9) # 0.9 is 90% height + # using append=False to backup bin by one + if polarity == 'Pos': + threshold_crossings3 = np.diff(int_df_f0 > threshold3, append=False) # "False" is the value to append + elif polarity == 'Neg': + threshold_crossings3 = np.diff(int_df_f0 < threshold3, append=False) # "False" is the value to append + decay10Bins = np.where(threshold_crossings3[peakBin:-1]==1)[0] + if len(decay10Bins) == 0: + # _errStr = f'peak {_idx} findThreshold failed to find 10% falling bin' + _errStr = f'findThreshold failed to find 10% falling bin' + logger.error(_errStr) + peaksErrors[_idx] += _errStr + ';' + else: + decay10Bin = decay10Bins[0] # first threshold after peak + decay10Bin += peakBin + offset10BinList[_idx] = decay10Bin + + if doMpl and _idx in [1,2]: + # make a new figure each time. I do not understand matplotlib !!! + fig, axs = plt.subplots(1, 1, figsize=(18,10)) + axs = [axs] + + # raw int + axs[0].plot(int_df_f0, color='k', marker='', label='intensity') + axs[0].set_xlim([peakBin-75, peakBin+75]) + + # peak + axs[0].plot(peakBin, int_df_f0[peakBin], color='c', marker='^', label='peak') + + # + axs[0].axhline(y=threshold) + + # new threshold + try: + plt.plot(thresholdBin, int_df_f0[thresholdBin], color='c', marker='o', markersize=10, label='thresholdBin') + except (UnboundLocalError) as e: + logger.error(e) + + # old threshold + # plt.plot(thresholdBin_orig, int_df_f0[thresholdBin_orig], color='y', marker='s', markersize=10, label='thresholdBin_orig') + # plt.plot(decayBin_orig, int_df_f0[decayBin_orig], color='y', marker='s', markersize=10, label='decayBin_orig') + + # decay + try: + plt.plot(decayBin, int_df_f0[decayBin], color='m', marker='o', markersize=10, label='decayBin') + except (UnboundLocalError) as e: + logger.error(e) + + # threshold crossings + # try: + # axs[0].plot(threshold_crossings, color='r', marker='o', label='threshold_crossings') + # except (UnboundLocalError) as e: + # logger.error(e) + + axs[0].legend() + plt.show() + + return onsetBinList, offsetBinList, onset10BinList, offset10BinList, peaksErrors + +# def plotDetectionResults(kymRoi : KymRoi, channel): +def plotDetectionResults(kymRoiAnalysis : KymRoiAnalysis, + roiLabelStr, + channel): + """Plot analysis steps using MatPlotLib. + - Raw sum + - Detrended sum + - df/d0 + + Parameters + ---------- + kymRoi : KymROi + Results for one ROI + """ + _channelColor = kymRoiAnalysis.getChannelColor(channel=channel) + + kymRoi = kymRoiAnalysis.getRoi(roiLabelStr) + + imgData = kymRoi.getRoiImg(channel=channel) + + timeSec = kymRoi.getTrace(channel, 'Time (s)') # seconds + intRaw = kymRoi.getTrace(channel, 'intRaw') + intDetrend = kymRoi.getTrace(channel, 'intDetrend') + logger.warning('defaulting to santana f/f0') + int_df_f0 = kymRoi.getTrace(channel, 'f/f0') # yDf_f0 + + detectionParams = kymRoi.getDetectionParams(channel, PeakDetectionTypes.intensity) + analysisResults = kymRoi.getAnalysisResults(channel, PeakDetectionTypes.intensity) + + peakSecond = analysisResults.getValues('Peak (s)') + peakValue = analysisResults.getValues('Peak Int') + + # + fig, axs = plt.subplots(4, 1, figsize=(8,10), sharex=True) + + roiLabel = kymRoi.getLabel() + _backgroundsubtract = f"Background subtract: {detectionParams['Background Subtract']}" + _title = f'{os.path.split(kymRoi.path)[1]}, ROI {roiLabel}, {_backgroundsubtract}' + fig.suptitle( _title ) + + # image + left, top, right, bottom = kymRoi.getRect() + # logger.info(f'timeSec:{timeSec} {type(timeSec)}') + # abb removed 20250508 + # try: + # leftSec = timeSec.values[0] + # rightSec = timeSec.values[-1] + # except (AttributeError) as e: + # logger.error(f'sometimes timeSec is pandas, sometimes numpy ???: {e}') + # logger.error(f' timeSec is type:{type(timeSec)}') + # logger.error(f' timeSec:{timeSec}') + # logger.error(f' roi left:{left}') + # leftSec = timeSec[0] + # rightSec = timeSec[-1] + + logger.warning('should be using left of roi (pixels)') + leftSec = timeSec[0] + rightSec = timeSec[-1] + + _extent=[leftSec, rightSec, bottom, top] + + detectThisTrace = detectionParams['detectThisTrace'] + f0 = detectionParams['f0 Value Percentile'] + if detectThisTrace == 'f/f0': + imgData = imgData / f0 + imgData = imgData.astype(np.int16) + elif detectThisTrace == 'df/f0': + imgData = (imgData - f0) / f0 + imgData = imgData.astype(np.int16) + else: + logger.error(f'detectThisTrace:{detectThisTrace} not supported') + return + + from sanpy.kym.kymUtils import getAutoContrast + _min, _max = getAutoContrast(imgData) # new 20240925, should mimic ImageJ + + # imgplot = axs[0].imshow(imgData, extent=_extent, aspect="auto") + + axs[0].imshow(imgData, + cmap="Greens", + origin='lower', + aspect='auto', + extent=_extent, + vmin=_min, + vmax=_max, + ) + + # I do not like how 'Greens' look ... + # imgplot.set_cmap('nipy_spectral') + # if _channelColor == 'Green': + # imgPlotColor = 'Greens' + # else: + # imgPlotColor = 'Reds' + # imgplot.set_cmap(imgPlotColor) + # axs[0].legend(loc='upper right') # legend does not work with imshow() + + # raw sum with fit + axs[1].plot(timeSec, intRaw, _channelColor, label=f"Sum (bins={detectionParams['Bin Line Scans']})") + axs[1].set_ylabel('Intensity (per pixel)') + # add exp fit + fitDict = detectionParams['expDetrendFit'] + if fitDict is None: + logger.info('expDetrend is off -->> no fit') + # axs[0].legend('No Exp Detrend') + else: + _m = fitDict['m'] + _t = fitDict['tau'] + _b = fitDict['b'] + yFit = myMonoExp(timeSec, _m, _t, _b) + _m = round(_m, 1) + _t = round(_t, 1) + _b = round(_b, 1) + # ret = m * np.exp(-t * x) + b + # _label = f'y = {_m} * exp(-{_t} * x) + {_b}' + _label = 'Exp Fit' + axs[1].plot(timeSec, yFit, 'c', label=_label) + axs[1].legend() + + # after remove fit (if on) and selecting f0 + # axs[2].plot(timeSec, intDetrend, 'r', label='Detrend') + axs[2].plot(timeSec, intDetrend, _channelColor) + axs[2].set_ylabel('Subtract exp') + # add f0 + f0_type = detectionParams['f0 Type'] + if f0_type == 'Percentile': + _f0 = detectionParams['f0 Value Percentile'] + elif f0_type == 'Manual': + _f0 = detectionParams['f0 Value Manual'] + else: + logger.error(f'did not understand f0_type:{f0_type}') + _f0=1 + _label = f"f0 {f0_type} = {round(_f0,2)}" + axs[2].axhline(y=_f0, label=_label, color='c') + axs[2].legend() + + # final dF/F0, with peaks (and fit) + logger.warning('TODO: dynamically switch betwee df/d0 and santana f/f0') + axs[3].plot(timeSec, int_df_f0, _channelColor, label='f/f0') + axs[3].set_ylabel('f/f0') + axs[3].plot(peakSecond, peakValue, 'go') + axs[2].legend() + + # rise + # axs[2].plot(peak10_left_ips, yDf_f0[peak10_left_ips.astype(int)], 'ro') + # axs[2].plot(peak90_left_ips, yDf_f0[peak90_left_ips.astype(int)], 'r^') + + # decay + # axs[2].plot(peak10_right_ips, yDf_f0[peak10_right_ips.astype(int)], 'co') + # axs[2].plot(peak90_right_ips, yDf_f0[peak90_right_ips.astype(int)], 'c^') + + # + # (1) exp decay + + # fix this constant bug !!!! + [_left, _, _, _] = kymRoi.getRect() + _peakBins = analysisResults.getValues('Peak Bin') + + xDecay = [] + yDecay = [] + for _peakIdx, _peakBin in enumerate(_peakBins): + + _peakBin = _peakBin - _left + + fit_m = analysisResults.getValues('fit_m')[_peakIdx] + fit_tau = analysisResults.getValues('fit_tau')[_peakIdx] + fit_b = analysisResults.getValues('fit_b')[_peakIdx] + + if np.isnan(fit_m): + # logger.warning(f'no fit for peak {_peakIdx}') + continue + + # ms to bin + _decayMs = detectionParams['Decay (ms)'] + _decayBin = _decayMs / 1000 / kymRoi._header['secondsPerLine'] + _decayBin = int(round(_decayBin)) + + # decayFitBins = self._detectionDict['decay (ms)'] / 1000 / self.secondsPerLine + decayFitBins = _decayBin + _xRange = timeSec[_peakBin:_peakBin+decayFitBins] - timeSec[_peakBin] + + # get line showing our fit + fit_y = myMonoExp(_xRange, fit_m, fit_tau, fit_b) + + xDecay.extend(_xRange+timeSec[_peakBin]) + xDecay.append(np.nan) + + yDecay.extend(fit_y) + yDecay.append(np.nan) + # + axs[3].plot(xDecay, yDecay, 'c') # single exp fit to decay + + # + # (2) double exp decay + xDecay2 = [] + yDecay2 = [] + for _peakIdx, _peakBin in enumerate(_peakBins): + + # fix this constant bug !!!! + _peakBin = _peakBin - _left + + fit_m1 = analysisResults.getValues('fit_m1')[_peakIdx] + fit_tau1 = analysisResults.getValues('fit_tau1')[_peakIdx] + fit_m2 = analysisResults.getValues('fit_m2')[_peakIdx] + fit_tau2 = analysisResults.getValues('fit_tau2')[_peakIdx] + + if np.isnan(fit_m1): + # logger.warning(f'no dbl exp fit for peak {_peakIdx}') + continue + + # ms to bin + _decayMs = detectionParams['Decay (ms)'] + decayFitBins = _decayMs / 1000 / kymRoi._header['secondsPerLine'] + decayFitBins = int(round(decayFitBins)) + + _xRange = timeSec[_peakBin:_peakBin+decayFitBins] - timeSec[_peakBin] + + # get line showing our fit + fit_y = myDoubleExp(_xRange, fit_m1, fit_tau1, fit_m2, fit_tau2) + + xDecay2.extend(_xRange+timeSec[_peakBin]) + xDecay2.append(np.nan) + + yDecay2.extend(fit_y) + yDecay2.append(np.nan) + # + axs[3].plot(xDecay2, yDecay2, 'b') # double exp fit to decay + + return fig, axs + +if __name__ == '__main__': + pass \ No newline at end of file diff --git a/sanpy/kym/kymRoiDetection.py b/sanpy/kym/kymRoiDetection.py new file mode 100644 index 00000000..acfa46dd --- /dev/null +++ b/sanpy/kym/kymRoiDetection.py @@ -0,0 +1,495 @@ +import os +import sys +import pandas as pd +import numpy as np +from typing import List, Tuple, Optional + +# from sanpy.kym.kymRoiAnalysis import PeakDetectionTypes + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +# specify default for diameter detection +def getDiameterDefault(): + ret = { + 'Background Subtract' : 'Off', + 'Polarity' : 'Neg', + 'Bin Line Scans' : 2, + 'Prominence' : 4, + } + return ret + +def getAnalysisDict(): + """Returns a dict of dict, first key is "stat" name. + """ + ret = {} + + # 0.2 added some columns to analysis + ret['version'] = { + 'defaultvalue': 0.2, + 'value': None, + 'description': 'File version for both detection and results.', + 'type': "float", + 'userdisplay': True, # display to user + } + + # + # filled in during anlysis + ret['Detection Type'] = { + 'defaultvalue': 'Intensity', # constructor will switch it to 'Diameter' + 'value': None, + 'description': 'Detection type, either Intensity or Diameter', + 'type': "str", + 'userdisplay': True, # display to user + } + + # + # filled in during anlysis + ret['ltrb'] = { + 'defaultvalue': None, + 'value': None, + 'description': 'ROI rect [l, t, r, b] - set on analysis', + 'type': "list", + 'userdisplay': True, # display to user + } + + ret['expDetrendFit'] = { + 'defaultvalue': None, + 'value': None, + 'description': 'Exp fit used to remove bleaching - set on analysis', + 'type': "dict", + 'userdisplay': True, # display to user + } + + ret['backgroundSubtractValue'] = { + 'defaultvalue': None, + 'value': None, + 'description': 'Value subtracted from the background - set on analysis.', + 'type': "int", + 'userdisplay': True, # display to user + } + + # + # actual detection params + ret['detectThisTrace'] = { + 'defaultvalue': 'f/f0', + 'value': None, + 'description': 'Specify which trace to detect from.', + 'type': "str", + 'userdisplay': True, # display to user + } + + ret['Auto'] = { + 'defaultvalue': True, + 'value': None, + 'description': 'Auto detect on ROI parameter change.', + 'type': "bool", + 'userdisplay': True, # display to user + } + + ret['Bin Line Scans'] = { + 'defaultvalue': 3, + 'value': None, + 'description': 'Number of line scans to bin when calculating the sum intensity (f/f_0). Zero (0) is off', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['Exponential Detrend'] = { + 'defaultvalue': True, + 'value': None, + 'description': 'Detrend raw data by subtracting an exponential - fit params set in "expDetrendFit"', + 'type': "bool", + 'userdisplay': True, # display to user + } + + ret['Background Subtract'] = { + 'defaultvalue': "Median", + 'value': None, + 'description': 'Background subtract from (Off, Rolling-Ball, Median, Mean)', + 'type': "str", + 'userdisplay': True, # display to user + } + + ret['Rolling-Ball Radius'] = { + 'defaultvalue': 50, + 'value': None, + 'description': 'Background subtract Rolling-Ball radius', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['Polarity'] = { + 'defaultvalue': 'Pos', + 'value': None, + 'description': 'Polarity of detection , "Pos" for positive peaks, "Neg" for negative peaks', + 'type': "str", + 'userdisplay': True, # display to user + } + + ret['f0 Type'] = { + 'defaultvalue': 'Percentile', + 'value': None, + 'description': 'Calculate f0 either (Manual, Percentile).', + 'type': "str", + 'userdisplay': True, # display to user + } + + ret['f0 Percentile'] = { + 'defaultvalue': 10, + 'value': None, + 'description': 'Calculate f0 as a percentile (50 is median). This is a percentage 0 to 100.', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['f0 Value Manual'] = { + 'defaultvalue': 1, + 'value': None, + 'description': 'Value of f0. Manually set by the user.', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['f0 Value Percentile'] = { + 'defaultvalue': 1, + 'value': None, + 'description': 'Value of f0. As a percentile of f/f0 df/f0 value.', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['Median Filter'] = { + 'defaultvalue': False, + 'value': None, + 'description': 'If True then apply median filter (using medianfilterkernel)', + 'type': "bool", + 'userdisplay': True, # display to user + } + + ret['Median Filter Kernel'] = { + 'defaultvalue': 3, + 'value': None, + 'description': 'Kernel size (bins) for median filter. Must be odd.', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['Savitzky-Golay'] = { + 'defaultvalue': False, + 'value': None, + 'description': 'If True then apply Savitzky-Golay filter.', + 'type': "bool", + 'userdisplay': True, # display to user + } + + ret['Prominence'] = { + 'defaultvalue': 1.0, #0.5, #0.09, + 'value': None, + 'description': 'Detect peaks that rise this amount above surrounding.', + 'type': "float", + 'userdisplay': True, # display to user + } + + ret['Width (ms)'] = { + 'defaultvalue': 15.0, + 'value': None, + 'description': 'Detect peaks with width larger than this.', + 'type': "float", + 'userdisplay': True, # display to user + } + + ret['Distance (ms)'] = { + 'defaultvalue': 200.0, + 'value': None, + 'description': 'Minimum allowed interval (distance) between peaks.', + 'type': "float", + 'userdisplay': True, # display to user + } + + ret['thresh_rel_height'] = { + 'defaultvalue': 0.85, + 'value': None, + 'description': 'Find initial threshold as "width" at this fraction of height. 1 is base, 0 is peak.', + 'type': "float", + 'userdisplay': True, # display to user + } + + # was used by v0 + # ret['decay_rel_height'] = { + # 'defaultvalue': 0.85, + # 'value': None, + # 'description': 'find return to baseline as "width" at this fraction of height. 1 is base, 0 is peak.', + # 'type': "float", + # 'userdisplay': True, # display to user + # } + + ret['Decay (ms)'] = { + 'defaultvalue': 200, + 'value': None, + 'description': 'Window to fit single and double exp decay from peak.', + 'type': "float", + 'userdisplay': True, # display to user + } + + ret['newOnsetOffsetFraction'] = { + 'defaultvalue': 0.9, + 'value': None, + 'description': 'New onset/offset as fraction of peak height.', + 'type': "float", + 'userdisplay': True, # display to user + } + + # we will always do the division and add a new trace "Line Divided" + # santana divide line scan + ret['Divide Line Scan'] = { + 'defaultvalue': None, + 'value': None, + 'description': 'Linescan to divide image by (new Santana normalization).', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['Post Median Filter Kernel'] = { + 'defaultvalue': 0, + 'value': None, + 'description': 'Kernel size (bins) for median filter on trace/sum. Must be odd.', + 'type': "int", + 'userdisplay': True, # display to user + } + + return ret + +def getDetectDiamDict(): + # + # Detect diameters in kymograph + + ret = {} + + ret['do_background_subtract_diam'] = { + 'defaultvalue': True, + 'value': None, + 'description': 'Background subtract kym roi image with mean (diameter).', + 'type': "bool", + 'userdisplay': True, # display to user + } + + ret['line_width_diam'] = { + 'defaultvalue': 3, + 'value': None, + 'description': 'Number of line scans (chunks) to generate line intensity profile (diameter).', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['line_median_kernel_diam'] = { + 'defaultvalue': 3, + 'value': None, + 'description': 'Kernel size to median filter each line scan (diameter).', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['std_threshold_mult_diam'] = { + 'defaultvalue': 1.2, + 'value': None, + 'description': 'Detect onset/offset using this * STD (value 2 is 2*STD) (diameter).', + 'type': "float", + 'userdisplay': True, # display to user + } + + ret['line_scan_fraction_diam'] = { + 'defaultvalue': 4, + 'value': None, + 'description': 'Fraction of line for lef/right, 4 is 25% and 2 is 50% (diameter).', + 'type': "int", + 'userdisplay': True, # display to user + } + + ret['line_interp_mult_diam'] = { + 'defaultvalue': 4, + 'value': None, + 'description': 'Factor to oversample each line scan to smooth diameter measurements (diameter).', + 'type': "int", + 'userdisplay': True, # display to user + } + + return ret + +class KymRoiDetection: + """Dictionary of detection parameters. + """ + + backgroundSubtractTypes = ['Off', 'Rolling-Ball', 'Median', 'Mean'] + + def __init__(self, peakDetectionType, + kymRoiDetection = None, + fromDict : dict = None): + """ + Parameters + ---------- + kymRoiDetection : KymRoiDetection + Make a copy from kymRoiDetection + fromDict : dict + Set values from dict (used on load) + """ + from sanpy.kym.kymRoiAnalysis import PeakDetectionTypes + self._peakDetectionType : PeakDetectionTypes = peakDetectionType + + self._dict = getAnalysisDict() + + if peakDetectionType == PeakDetectionTypes.diameter: + self._dict['Detection Type']['defaultvalue'] = 'Diameter' + + # some additional keys inique to diameter detection + _diamDict = getDetectDiamDict() + for k, v in _diamDict.items(): + self._dict[k] = v + + # special default for diam detection + for k, v in getDiameterDefault().items(): + try: + self._dict[k]['defaultvalue'] = v + except (KeyError): + logger.error(f'getDiameterDefault() contained bad key "{k}"') + + self.setDefaults() + + if kymRoiDetection is not None: + self._fromDict(kymRoiDetection.getValueDict()) + # for k,v in kymRoiDetection._dict.items(): + # self._dict[k] = v + + if fromDict is not None: + self._fromDict(fromDict) + + # logger.info('initialized detection as:') + # print(self.getDataframe()) + + def keys(self): + return self._dict.keys() + + def getDescription(self, key) -> str: + if key not in self._dict.keys(): + logger.error(f'Did not find key:"{key}"') + logger.error(f' Available keys are {self._dict.keys()}') + return '' + return self._dict[key]['description'] + + def getType(self, key) -> str: + if key not in self._dict.keys(): + logger.error(f'Did not find key:"{key}"') + logger.error(f' Available keys are {self._dict.keys()}') + return '' + return self._dict[key]['type'] + + def getParam(self, key): + if key not in self._dict.keys(): + logger.error(f'Did not find key:"{key}"') + logger.error(f' Available keys are {self._dict.keys()}') + return + return self._dict[key]['value'] + + def setParam(self, key, value): + if key not in self._dict.keys(): + logger.error(f'Did not find key:"{key}"') + logger.error(f' Available keys are {self._dict.keys()}') + sys.exit(1) + return + self._dict[key]['value'] = value + return True + + def setDefaults(self): + """Set default values. + + 'diameter' detection needs to have some specific parameters off. + """ + for k, v in self._dict.items(): + # logger.info(f'k:{k} v:{v}') + defaultValue = v['defaultvalue'] + self.setParam(k, defaultValue) + + # if self._dict + # setParam + + # def getDict(self) -> dict: + # """Get underlying dictionary. + # """ + # return self._dict + + def getValueDict(self): + """Get dictionary of [key][value]. + + Used to save. + """ + valueDict = {} + for k, v in self._dict.items(): + valueDict[k] = v['value'] + return valueDict + + def getDataframe(self): + # _columns = ['Parameter', 'Default', 'Value', 'Type', 'Description'] + _columns = ['Parameter', 'Default', 'Type', 'Description'] + + df = pd.DataFrame(columns=_columns) + + paramList = [] + defaultList = [] + # valueList = [] + typeList = [] + descriptionList = [] + for k,v in self._dict.items(): + paramList.append(k) + defaultList.append(v['defaultvalue']) + # valueList.append(v['value']) + typeList.append(v['type']) + descriptionList.append(v['description']) + + df['Parameter'] = paramList + df['Default'] = defaultList + # df['Value'] = valueList + df['Type'] = typeList + df['Description'] = descriptionList + + return df + + def _fromDict(self, d : dict): + """From dictionary of key values. + """ + for k,v in d.items(): + self.setParam(k, v) + + def __getitem__(self, key): + return self.getParam(key) + + def __setitem__(self, key, value): + return self.setParam(key, value) + + def __str__(self): + ret = '' + for k,v in self._dict.items(): + ret += f' {k}: {v}\n' + return ret + + def printValues(self): + """Return a string with the name value pairs. + """ + ret = '' + for k,v in self._dict.items(): + ret += f" {k}: {v['value']}\n" + return ret + +def _makeMarkdownTable(): + """ + Requires + ======== + pip install tabulate + """ + from kymRoiAnalysis import PeakDetectionTypes + krd = KymRoiDetection(PeakDetectionTypes.diameter) + df = krd.getDataframe() + md = df.to_markdown() + print(md) + +if __name__ == '__main__': + _makeMarkdownTable() \ No newline at end of file diff --git a/sanpy/kym/kymRoiDiameter.py b/sanpy/kym/kymRoiDiameter.py new file mode 100644 index 00000000..e54286d7 --- /dev/null +++ b/sanpy/kym/kymRoiDiameter.py @@ -0,0 +1,263 @@ +import numpy as np +# from scipy.signal import peak_widths, medfilt, savgol_filter, detrend, find_peaks +from scipy.signal import medfilt +from typing import List, Tuple, Optional +import pandas as pd + +import matplotlib.pyplot as plt + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def backgroundSubtract(imgData): + """Subtract median. + """ + theMedian = np.median(imgData) + retData = imgData - theMedian + return retData + +def getLineProfile(imgData : np.ndarray, + lineIdx : int, + doBackgroundSubtract : bool, + lineWidth : int, + lineMedianKernel, + lineInterptMult : int = 1) -> np.ndarray: + + # if doBackgroundSubtract: + # imgData = backgroundSubtract(imgData) + + m,n = imgData.shape + + # logger.info(f'imgData.shape:{imgData.shape}') + + if lineWidth == 0: + lineImg = imgData[:,lineIdx] + + else: + startLine = lineIdx - lineWidth + if startLine < 0: + startLine = 0 + endLine = lineIdx + lineWidth + if endLine > n: + endLine = n + + lineImg = imgData[:,startLine:endLine] + lineImg = np.mean(lineImg, axis=1) + + if lineMedianKernel > 0: + lineImg = medfilt(lineImg, lineMedianKernel) + + if lineInterptMult > 1: + # if lineIdx == 10: + # logger.info(f'before lineIdx:{lineIdx} lineInterptMult:{lineInterptMult} lineImg:{len(lineImg)}') + + _nIntensityProfile = len(lineImg) + # logger.info(f'_nIntensityProfile:{_nIntensityProfile} lineInterptMult:{lineInterptMult}') + _xOld = np.linspace(0, _nIntensityProfile, num=_nIntensityProfile) + _xNew = np.linspace(0, _nIntensityProfile, num=_nIntensityProfile*lineInterptMult) + + # ValueError: array of sample points is empty + lineImg = np.interp(_xNew, _xOld, lineImg) + + return lineImg + +def detectKymRoiDiam(imgData, + doBackgroundSubtract, + lineWidth, + lineMedianKernel, + stdThreshold, + lineScanFraction = 4, + lineInterptMult = 1, + verbose = False + ): + """ + Parameters + ---------- + lineScanFraction : float + Fraction of the line scan to look at left/right. + + 2 will use middle (half), 4 will use 25% (1/4) , etc + + lineInterptMult : int + Interpolate each line scan by this multiplyer (if 4, then we have 4x points along line). + Default = 1 yields no interpolation. + This reduces pixelation in diameter. + """ + + # background subtract the entire image + if doBackgroundSubtract: + imgData = backgroundSubtract(imgData) + + # middle bin in the line scan + mLine = imgData.shape[0] + firstQuarterBin = int(mLine/lineScanFraction) + lastQuarterBin = mLine - int(mLine/lineScanFraction) + + m,n = imgData.shape + + # + leftThresholdBinList = np.empty((n,)) + leftThresholdBinList[:] = np.nan # [np.nan] * n + # leftThresholdIntList = [np.nan] * n + # + rightThresholdBinList = np.empty((n,)) + rightThresholdBinList[:] = np.nan # [np.nan] * n + # rightThresholdIntList = [np.nan] * n + + if lineInterptMult > 1: + firstQuarterBin *= lineInterptMult + lastQuarterBin *= lineInterptMult + + for lineIdx in range(n): + startLine = lineIdx - lineWidth + if startLine < 0: + startLine = 0 + endLine = lineIdx + lineWidth + if endLine > n: + endLine = n + + lineImg = imgData[:,startLine:endLine] + lineImg = np.mean(lineImg, axis=1) + + if lineMedianKernel > 0: + lineImg = medfilt(lineImg, lineMedianKernel) + + # interpolate each line scan (this screws up our firstQauarterBin ???) + if lineInterptMult > 1: + # if lineIdx == 10: + # logger.info(f'before lineIdx:{lineIdx} lineInterptMult:{lineInterptMult} lineImg:{len(lineImg)}') + + _nIntensityProfile = len(lineImg) + _xOld = np.linspace(0, _nIntensityProfile, num=_nIntensityProfile) + _xNew = np.linspace(0, _nIntensityProfile, num=_nIntensityProfile*lineInterptMult) + lineImg = np.interp(_xNew, _xOld, lineImg) + + # if lineIdx == 10: + # logger.info(f'after lineIdx:{lineIdx} lineInterptMult:{lineInterptMult} lineImg:{len(lineImg)}') + + # + # left + startLineImg = lineImg[0:firstQuarterBin] + + leftLineMean = np.mean(startLineImg) + leftLineSTD = np.std(startLineImg) + leftThreshold = leftLineMean + (leftLineSTD * stdThreshold) + + leftThreshold_crossings = np.diff(startLineImg > leftThreshold, append=False) + leftThresholdBins = np.where(leftThreshold_crossings==1)[0] + if len(leftThresholdBins) == 0: + if verbose: + logger.error(f'lineIDx:{lineIdx} --> no left threshold') + else: + leftThresholdBin = leftThresholdBins[0] # from left to right, first threshold crossing + # leftThresholdInt = startLineImg[leftThresholdBin] + + leftThresholdBinList[lineIdx] = leftThresholdBin + # leftThresholdIntList[lineIdx] = leftThresholdInt + + # + # right + endLineImg = lineImg[lastQuarterBin:] + + rightLineMean = np.mean(endLineImg) + rightLineSTD = np.std(endLineImg) + rightThreshold = rightLineMean + (rightLineSTD * stdThreshold) + + rightThreshold_crossings = np.diff(endLineImg > rightThreshold, append=False) + rightThresholdBins = np.where(rightThreshold_crossings==1)[0] + if len(rightThresholdBins) == 0: + if verbose: + logger.error(f'lineIDx:{lineIdx} --> no right threshold') + else: + rightThresholdBin = rightThresholdBins[-1] # from right to right, last threshold crossing + # rightThresholdInt = endLineImg[rightThresholdBin] + + rightThresholdBin += lastQuarterBin # we started in the middle + + rightThresholdBinList[lineIdx] = rightThresholdBin + # rightThresholdIntList[lineIdx] = rightThresholdInt + + # might want a final medfilt on the diameter? + diamBins = np.subtract(rightThresholdBinList, leftThresholdBinList) + + # not needed here, just interesting + sumIntensity = np.sum(imgData, axis=0) + + if lineInterptMult > 1: + # leftThresholdBinList = [_bin / lineInterptMult for _bin in leftThresholdBinList] + leftThresholdBinList /= lineInterptMult + # + # rightThresholdBinList = [_bin / lineInterptMult for _bin in rightThresholdBinList] + rightThresholdBinList /= lineInterptMult + # + # diamBins = [_bin / lineInterptMult for _bin in diamBins] + diamBins /= lineInterptMult + + + return leftThresholdBinList, rightThresholdBinList, diamBins, sumIntensity + +def plotKyn(imgData, leftThresholdBinList, rightThresholdBinList, diamBins, sumIntensity=None): + """Plot kym with left diameter. + """ + xLines = np.arange(imgData.shape[1]) + + fig, axs = plt.subplots(2, 1, sharex=True) + + # imgData = np.flip(imgData) # flip for matplotlib + + axs[0].imshow(imgData, aspect='auto') + axs[0].plot(xLines, leftThresholdBinList, 'g') + axs[0].plot(xLines, rightThresholdBinList, 'r') + + axs[1].plot(diamBins, label='Diameter (bins)') + axs[1].set_ylabel('Diameter (bins)') + + if sumIntensity is not None: + axs2 = axs[1].twinx() + axs2.plot(sumIntensity, 'k', label='sum') + axs2.set_ylabel('Sum Intensity') + + plt.show() + +def plotLine(lineImg, lineMean, lineSTD, threshold, thresholdBin, thresholdInt): + """Plot one line scan. + """ + n = len(lineImg) + + plt.plot(lineImg) + plt.hlines(lineMean, xmin=0, xmax=n) + plt.hlines(lineMean+lineSTD, xmin=0, xmax=n, linestyles='dotted') + plt.hlines(lineMean+2*lineSTD, xmin=0, xmax=n, linestyles='dotted') + plt.hlines(threshold, xmin=0, xmax=n, colors='r', linestyles='dotted') + plt.plot(thresholdBin, thresholdInt, 'or') + + plt.show() + +if __name__ == '__main__': + from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis + + path = '/Users/cudmore/Dropbox/data/cell-shortening/paula/cell01_C002T001.tif' + kra = KymRoiAnalysis(path) + roi = kra.getRoi('1') + roiImg = roi.getRoiImg() # get imgData within ROI + + doBackgroundSubtract = True + lineMedianKernel = 3 + lineWidth = 1 # 2 # don't go too big (filters too much) + stdThreshold = 1.2 # 1.8 + lineInterptMult = 4 # 0 # interpolate each line scan by this multiplyer + lineScanFraction = 4 # 2 # percent of line scan to detect for onset/offset + # if 2 then half/half + # if 4 then first/last 25% + + leftThresholdBinList, rightThresholdBinList, diamBins, sumIntensity = \ + detectKymRoiDiam(roiImg, + doBackgroundSubtract=doBackgroundSubtract, + lineWidth=lineWidth, + lineMedianKernel=lineMedianKernel, + lineInterptMult=lineInterptMult, + stdThreshold=stdThreshold, + ) + + plotSumIntensity = None + plotKyn(roiImg, leftThresholdBinList, rightThresholdBinList, diamBins, sumIntensity=plotSumIntensity) diff --git a/sanpy/kym/kymRoiMetaData.py b/sanpy/kym/kymRoiMetaData.py new file mode 100644 index 00000000..0567ba4b --- /dev/null +++ b/sanpy/kym/kymRoiMetaData.py @@ -0,0 +1,138 @@ +import os +from typing import List, Optional, Any +import json + +import numpy as np +import pandas as pd + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def get_parent_folders(path): + folder, _ = os.path.split(path) + parent = os.path.basename(folder) + grandparent = os.path.basename(os.path.dirname(folder)) + great_grandparent = os.path.basename(os.path.dirname(os.path.dirname(folder))) + return parent, grandparent, great_grandparent + +class KymRoiMetaData(): + def __init__(self, path : str, + imgData : List[np.ndarray] = None) -> None: + + folder, filename = os.path.split(path) + + + parentFolder1, parentFolder2, parentFolder3 = get_parent_folders(path) + + self._dict = { + # user can edit these, see allowTextEdit() + 'Animal ID' : '', + 'Region' : '', + 'Cell Type' : '', + 'Cell ID' : '', + 'Condition' : '', + 'Note' : '', + + # not editable + 'path' : path, + 'File Name' : filename, + 'Parent Folder 1' : parentFolder1, + 'Parent Folder 2' : parentFolder2, + 'Parent Folder 3' : parentFolder3, + 'Acq Date': '', + 'Acq Time': '', + 'secondsPerLine' : None, + 'umPerPixel' : None, + 'numChannels' : 0 if imgData is None else len(imgData), + 'imageHeight' : 0 if imgData is None else imgData[0].shape[0], # number of pixels in each line scan + 'imageWidth' : 0 if imgData is None else imgData[0].shape[1], # number of line scans + } + + self._allowEdit = ['Animal ID', + 'Region', + 'Cell Type', + 'Cell ID', + 'Condition', + 'Note', + ] + """Keys that are editable in qt dialog. + """ + + self._doNotShowInGui = 'path' + """Keys to not show in GUI. + """ + + def setParam(self, key, value) -> bool: + if key not in self._dict.keys(): + # logger.error(f'did not set "{key}", available keys are {self._dict.keys()}') + return False + + try: + self._dict[key] = value + return True + except (KeyError): + logger.error(f'did not set "{key}", available keys are {self._dict.keys()}') + return False + + def getParam(self, key) -> Optional[Any]: + try: + return self._dict[key] + except (KeyError): + logger.error(f'did not set "{key}", available keys are {self._dict.keys()}') + return None + + def showInGui(self, key): + """Return True if we show in Qt gui. + """ + return key not in self._doNotShowInGui + + def allowTextEdit(self, key): + """Return True if we edit a str in the gui. + """ + return key in self._allowEdit + + def toJson(self): + _ret = json.dumps(self._dict) + return _ret + + def fromJson(self,jsonStr): + logger.info('') + _dict = json.loads(jsonStr) + for k,v in _dict.items(): + self.setParam(k, v) + + @classmethod + def fromDict(cls, _dict): + """Create a KymRoiMetaData instance from a dictionary. + + Parameters + ---------- + _dict : dict + Dictionary containing metadata values + + Returns + ------- + KymRoiMetaData + New instance with values from the dictionary + """ + logger.info('') + # Extract required parameters for __init__ + path = _dict.get('path', '') + + # Create the instance with optional imgData (None is now allowed) + instance = cls(path, imgData=None) + + # Set all the dictionary values + for k, v in _dict.items(): + instance.setParam(k, v) + + return instance + + def __setitem__(self, key, value) -> bool: + return self.setParam(key, value) + + def __getitem__(self, key) -> Optional[Any]: + return self.getParam(key) + + def items(self): + return self._dict.items() \ No newline at end of file diff --git a/sanpy/kym/kymRoiResults.py b/sanpy/kym/kymRoiResults.py new file mode 100644 index 00000000..d7a1278b --- /dev/null +++ b/sanpy/kym/kymRoiResults.py @@ -0,0 +1,626 @@ +import numpy as np +import pandas as pd +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def getAnalysisDict(): + """Returns a dict of dict, first key is "stat" name. + """ + ret = {} + + ret['Detected Trace'] = { + 'value': None, + 'description': 'The trace that was detected. One of f/f0, df/f0, diameter, left diameter, right diameter.', + 'type': int, + 'userdisplay': True, # display to user + }, + + ret['Channel Number'] = { + 'value': None, + 'description': 'Channel number of the detected trace', + 'type': int, + 'userdisplay': True, # display to user + }, + + ret['ROI Number'] = { + 'value': None, + 'description': 'ROI number of the detected trace', + 'type': int, + 'userdisplay': True, # display to user + }, + + ret['Peak Number'] = { + 'value': None, + 'description': '', + 'type': int, + 'userdisplay': True, # display to user + }, + + ret['Accept'] = { + 'value': None, + 'description': 'TODO: Implement user peak selection and allow user to set peak to Accept (True, False)', + 'type': bool, + 'userdisplay': False, # display to user + }, + + # this is the only 'Bin' we need + ret['Peak Bin'] = { + 'value': None, + 'description': '', + 'type': int, + 'userdisplay': False, # display to user + }, + + ret['Peak (s)'] = { + 'value': None, + 'description': 'Time of peak (s)', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Peak Int'] = { + 'value': None, + 'description': 'Intensity of peak.', + 'type': int, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'o', + 'color' : 'Red' + }, + + ret['Peak Height'] = { + 'value': None, + 'description': 'The height of the peak above threshold = (Peak Int) - (Onset Int)', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Peak Inst Interval (s)'] = { + 'value': None, + 'description': 'Inter-peak-interval in s, first peak is nan', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Peak Inst Freq (Hz)'] = { + 'value': None, + 'description': 'Inter-peak-frequency in Hz, first peak is nan', + 'type': float, + 'userdisplay': True, # display to user + }, + + # ret['Onset Bin'] = { + # 'value': None, + # 'description': '', + # 'type': int, + # 'userdisplay': False, # display to user + # }, + + ret['Onset (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Onset Int'] = { + 'value': None, + 'description': 'Intensity at onset', + 'type': float, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'o', + 'color' : 'Green' + + }, + + # ret['Onset 10 Bin'] = { + # 'value': None, + # 'description': '', + # 'type': int, + # 'userdisplay': False, # display to user + # }, + + ret['Onset 10 (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Onset 10 Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'x', + 'color' : (255, 0, 255, 200) # megenta + + }, + + # ret['Onset 90 Bin'] = { + # 'value': None, + # 'description': '', + # 'type': int, + # 'userdisplay': False, # display to user + # }, + + ret['Onset 90 (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Onset 90 Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'x', + 'color' : (255, 255, 0, 200) # yellow + + }, + + # ret['Decay Bin'] = { + # 'value': None, + # 'description': '', + # 'type': int, + # 'userdisplay': False, # display to user + # }, + + ret['Decay (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Decay Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'o', + 'color' : (255, 0, 255, 200) # megenta + }, + + # ret['Decay 10 Bin'] = { + # 'value': None, + # 'description': '', + # 'type': int, + # 'userdisplay': False, # display to user + # }, + + ret['Decay 10 (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Decay 10 Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'x', + 'color' : (255, 0, 255, 200) # megenta + }, + + # ret['Decay 90 Bin'] = { + # 'value': None, + # 'description': '', + # 'type': int, + # 'userdisplay': False, # display to user + # }, + + ret['Decay 90 (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Decay 90 Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': True, # display to user + 'displayoverlay' : True, + 'marker' : 'x', + 'color' : (255, 255, 0, 200) # yellow + }, + + # half-width + ret['HW (ms)'] = { + 'value': None, + 'description': 'Half width (ms)', + 'type': float, + 'userdisplay': True, # display to user + }, + + # 20241031 switching from bin to second + # ret['HW Left Bin'] = { + # 'value': None, + # 'description': '', + # 'type': float, + # 'userdisplay': False, # display to user + # }, + + # ret['HW Right Bin'] = { + # 'value': None, + # 'description': '', + # 'type': float, + # 'userdisplay': False, # display to user + # }, + + ret['HW Left (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['HW Right (s)'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['HW Left Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['HW Right Int'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['HW Height'] = { + 'value': None, + 'description': 'Y Value of half-width', + 'type': float, + 'userdisplay': True, # display to user + }, + + # full widths + ret['FW (ms)'] = { + 'value': None, + 'description': 'Full width (ms) from onset to offset', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['FW 10 (ms)'] = { + 'value': None, + 'description': 'Full width (ms) from onset 10 to offset 10', + 'type': float, + 'userdisplay': True, # display to user + }, + + # rise/decay time + ret['Rise Time (ms)'] = { + 'value': None, + 'description': 'Rise time from oset to peak (ms)', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Decay Time (ms)'] = { + 'value': None, + 'description': 'Decay time from peak to offset (ms)', + 'type': float, + 'userdisplay': True, # display to user + }, + + # 10-90 rise/decay time + ret['10-90 Rise Time (ms)'] = { + 'value': None, + 'description': 'Rise time 10-90 (ms)', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['10-90 Decay Time (ms)'] = { + 'value': None, + 'description': 'Decay time 10-90 (ms)', + 'type': float, + 'userdisplay': True, # display to user + }, + + # exp fits from peaks + ret['fit_m'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_tau'] = { + 'value': None, + 'description': 'Single exp fit of decay', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_b'] = { + 'value': None, + 'description': '', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_r2'] = { + 'value': None, + 'description': 'r-squared, e.g. Coefficient of determination', + 'type': float, + 'userdisplay': False, # display to user + }, + + # dbl exp decay + ret['fit_m1'] = { + 'value': None, + 'description': 'dbl exp', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_tau1'] = { + 'value': None, + 'description': 'dbl exp', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_m2'] = { + 'value': None, + 'description': 'dbl exp', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_tau2'] = { + 'value': None, + 'description': 'dbl exp', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['fit_r22'] = { + 'value': None, + 'description': 'dbl exp r-squared, e.g. Coefficient of determination', + 'type': float, + 'userdisplay': False, # display to user + }, + + ret['Detection Errors'] = { + 'value': None, + 'description': 'Peak detection errors (Currently just in exp decay fit)', + 'type': str, + 'userdisplay': True, # display to user + }, + + ret['Area Under Peak'] = { + 'value': None, + 'description': 'Area under peak', + 'type': float, + 'userdisplay': True, # display to user + }, + + ret['Path'] = { + 'value': None, + 'description': 'Path to raw tiff file', + 'type': str, + 'userdisplay': False, # display to user + }, + + # + return ret + +def _makeMarkdownTable(): + """Make table describing each analysis results. + """ + _columns = ['Stat Name', "Type", "Description"] + + df = pd.DataFrame(columns=_columns) + + statList = [] + typeList = [] + descriptionList = [] + + d = getAnalysisDict() + for k,v in d.items(): + v = v[0] + if not v['userdisplay']: + continue + statList.append(k) + typeList.append(v['type']) + descriptionList.append(v['description']) + + df['Stat Name'] = statList + df['Type'] = typeList + df['Description'] = descriptionList + + md = df.to_markdown(index=False) + print(md) + +def getUserAnalysisKeys(): + keyList = [] + for k,v in getAnalysisDict().items(): + v = v[0] # wtf is going on? + # print(k,v) + if v['userdisplay']: + keyList.append(k) + return keyList + +def getOverlayKeys(): + keyList = [] + for k,v in getAnalysisDict().items(): + v = v[0] # wtf is going on? + # print(k,v) + try: + if v['displayoverlay']: + keyList.append(k) + except (KeyError): + continue + return keyList + +class KymRoiResults: + """Kym Roi peak detection results. + + Basically a pandas dataframe. + """ + analysisDict = getAnalysisDict() # full + """Static analysis dict.""" + + userAnalysisKeys = getUserAnalysisKeys() # abbreviated for userdisplay=True + """Static analysis keys to display in scatter""" + + overlayKeys = getOverlayKeys() + """Static analysis keys to display in scatter plots.""" + + def getMarker(key): + """Got the marker to display in scatter. + """ + if key not in getAnalysisDict().keys(): + logger.error(f'did not find key column: {key}') + return False + try: + return getAnalysisDict()[key][0]['marker'] + except (KeyError): + logger.info(f'results key "{key}" did not have a specified "marker"') + + def getColor(key): + """Got the color to display in scatter. + """ + if key not in getAnalysisDict().keys(): + logger.error(f'did not find key column: {key}') + return False + try: + return getAnalysisDict()[key][0]['color'] + except (KeyError): + logger.info(f'results key "{key}" did not have a specified "color"') + + def __init__(self): + self._dict = getAnalysisDict() + self._df = pd.DataFrame(columns=self.columns) + + def _swapInNewDf(self, dfLoaded : pd.DataFrame): + """USed when loading from file. + + Notes + ----- + Need to be very careful about preserving our existing columns (if new analysis was added. + """ + # logger.error('on load orig df is:') + # print(self._df) + + numLoadedPeaks = len(dfLoaded) + self._df = pd.DataFrame(index=np.arange(numLoadedPeaks), columns=self.columns) + + # print(self._df) + + # was this + # self._df = dfLoaded + + for column in dfLoaded.columns: + self._df[column] = dfLoaded[column] + + @property + def columns(self): + return list(self._dict.keys()) + + @property + def df(self): + return self._df + + def addError(self, peakNumber : int, err : str): + """Add an error str for one peak. + + Notes + ----- + I can never in the infinite time we have understand + "A value is trying to be set on a copy of a slice from a DataFrame" + """ + # print('') + # logger.info(f'adding error peakNumber:{peakNumber} err:{type(err)} "{err}"') + + if err.endswith(';'): + # strip trailing ; + err = err[:-1] + + if len(err) == 0: + return + + _currentError = self.df.loc[peakNumber]['Detection Errors'] + _newError = _currentError + err + ';' + self.df.at[peakNumber, 'Detection Errors'] = _newError + + # logger.warning(f" AFTER addError peakNumber:{peakNumber} Detection Errors is:") + # logger.warning(f" {self.df.loc[peakNumber]['Detection Errors']}") + + # call this with 'num peaks' to initialize all rows + def setValues(self, key : str, values): + if key not in self.columns: + logger.error(f'did not find roi results key: {key}') + return False + + self.df[key] = values + return True + + def getValues(self, key : str): + if key not in self.columns: + logger.error(f'did not find key column: {key}') + return False + + return self.df[key] + + def _old_displayOverlay(self, key): + """Return true if we show results as an overlay. + + Basically, a scatter plot in kymPlotWidget. + """ + if key not in self.columns: + logger.error(f'did not find key column: {key}') + return False + + try: + return self._dict[key]['displayoverlay'] + except (KeyError): + return False + + def _old_getMarker(self, key): + """Got the marker to display in scatter. + """ + if key not in self.columns: + logger.error(f'did not find key column: {key}') + return False + try: + return self._dict[key]['marker'] + except (KeyError): + logger.info(f'results key "{key}" did not have a specified "marker"') + + def _old_getColor(self, key): + """Got the color to display in scatter. + """ + if key not in self.columns: + logger.error(f'did not find key column: {key}') + return False + try: + return self._dict[key]['color'] + except (KeyError): + logger.info(f'results key "{key}" did not have a specified "color"') + +if __name__ == '__main__': + _makeMarkdownTable() \ No newline at end of file diff --git a/sanpy/kym/kymUtils.py b/sanpy/kym/kymUtils.py new file mode 100644 index 00000000..36fd22e8 --- /dev/null +++ b/sanpy/kym/kymUtils.py @@ -0,0 +1,126 @@ +import numpy as np +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +# found 20240925 at +# https://forum.image.sc/t/macro-for-image-adjust-brightness-contrast-auto-button/37157/5 + +# Python rewriting of ImageJ's auto-threshold option (Image > Adjust > Brightness/Contrast > 'Auto' button) +# Based on https://github.com/imagej/ImageJ/blob/706f894269622a4be04053d1f7e1424094ecc735/ij/plugin/frame/ContrastAdjuster.java#L780 +# (function autoAdjust) +# The algorithm is basically a contrast setting the max white value to the max of the image (and same for black for +# min), with some saturation : i.e., it's not the max(min) of the image which is actually used but a lower(higher) +# value to eliminate the thin "tails" of the histogram and get an output dynamic range which allows for good +# visualisation of most of the image's pixels (at the expense of a few saturated pixels). +# While some (most ?) algorithms parametrize this saturation to eliminate a set percentage of pixels, +# ImageJ's algorithm selects the closest values to the max(min) values whose count are over a certain proportion of the +# total amount of pixels. + + +def getAutoContrast(imgData : np.ndarray): + + im = imgData + + im_type = im.dtype + im_min = np.min(im) + im_max = np.max(im) + + # converting image ================================================================================================= + + # case of color image : contrast is computed on image cast to grayscale + if len(im.shape) == 3 and im.shape[2] == 3: + # depending on the options you chose in ImageJ, conversion can be done either in a weighted or unweighted way + # go to Edit > Options > Conversion to verify if the "Weighted RGB conversion" box is checked. + # if it's not checked, use this line + # im = np.mean(im, axis = -1) + # instead of the following + im = 0.3 * im[:,:,2] + 0.59 * im[:,:,1] + 0.11 * im[:,:,0] + im = im.astype(im_type) + + # histogram computation ============================================================================================= + + # parameters of histogram computation depend on image dtype. + # following https://imagej.nih.gov/ij/developer/macro/functions.html#getStatistics + # 'The histogram is returned as a 256 element array. For 8-bit and RGB images, the histogram bin width is one. + # for 16-bit and 32-bit images, the bin width is (max-min)/256.' + if im_type in (np.uint8, np.int8): # abb np.int8 + hist_min = 0 + hist_max = 256 + elif im_type in (np.uint16, np.int16, np.int32): + # use img min/max + hist_min = im_min + hist_max = im_max + else: + raise NotImplementedError(f"Not implemented for dtype {im_type}") + + # compute histogram + histogram = np.histogram(im, bins = 256, range = (hist_min, hist_max))[0] + bin_size = (hist_max - hist_min)/256 + + # compute output min and max bins ================================================================================= + + # various algorithm parameters + h, w = im.shape[:2] + pixel_count = h * w + # the following values are taken directly from the ImageJ file. + limit = pixel_count/10 + const_auto_threshold = 5000 + auto_threshold = 0 + + auto_threshold = const_auto_threshold if auto_threshold <= 10 else auto_threshold/2 + threshold = int(pixel_count/auto_threshold) + + # setting the output min bin + i = -1 + found = False + # going through all bins of the histogram in increasing order until you reach one where the count if more than + # pixel_count/auto_threshold + # while not found and i <= 255: + while not found and i < 255: + i += 1 + + try: + count = histogram[i] + except (IndexError) as e: + logger.error(f'histogram.shape:{histogram.shape} i:{i} threshold:{threshold} {e}') + logger.error(f' hist_min:{hist_min} hist_max:{hist_max} threshold:{threshold} {e}') + + if count > limit: + count = 0 + found = count > threshold + hmin = i + found = False + + # setting the output max bin : same thing but starting from the highest bin. + i = 256 + while not found and i > 0: + i -= 1 + count = histogram[i] + if count > limit: + count = 0 + found = count > threshold + hmax = i + + # compute output min and max pixel values from output min and max bins =============================================== + if hmax >= hmin: + min_ = hist_min + hmin * bin_size + max_ = hist_min + hmax * bin_size + # bad case number one, just return the min and max of the histogram + if min_ == max_: + min_ = hist_min + max_ = hist_max + # bad case number two, same + else: + min_ = hist_min + max_ = hist_max + + # apply the contrast ================================================================================================ + #imr = (im-min_)/(max_-min_) * 255 + + # return imr + min_ = int(min_) + max_ = int(max_) + + logger.info(f'min_:{min_} max_:{max_}') + + return min_, max_ diff --git a/sanpy/kym/logger.py b/sanpy/kym/logger.py new file mode 100644 index 00000000..a52520d6 --- /dev/null +++ b/sanpy/kym/logger.py @@ -0,0 +1,100 @@ +import logging +import logging.handlers +import os +from pathlib import Path + +def setup_logger(name: str = None) -> logging.Logger: + """ + Set up a logger with both console and file handlers. + + Parameters + ---------- + name : str, optional + Logger name. If None, uses the calling module's name. + + Returns + ------- + logging.Logger + Configured logger instance + """ + # Get the logger name from the calling module if not provided + if name is None: + import inspect + frame = inspect.currentframe().f_back + name = frame.f_globals.get('__name__', 'unknown') + + logger = logging.getLogger(name) + + # Avoid adding handlers multiple times + if logger.handlers: + return logger + + # Set the base logging level + logger.setLevel(logging.DEBUG) + + # Create logs directory if it doesn't exist + logs_dir = Path(__file__).parent.parent.parent / 'logs' + logs_dir.mkdir(exist_ok=True) + + # File handler with rotation (1MB max size, keep 5 backup files) + log_file = logs_dir / 'kym.log' + file_handler = logging.handlers.RotatingFileHandler( + log_file, + maxBytes=1024*1024, # 1MB + backupCount=5, + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + + # Console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # Create formatters + # File formatter: detailed with date/time, filename, function, line + file_formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(funcName)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + # Console formatter: simpler without date/time + console_formatter = logging.Formatter( + '%(levelname)s - %(name)s - %(filename)s:%(funcName)s:%(lineno)d - %(message)s' + ) + + # Set formatters + file_handler.setFormatter(file_formatter) + console_handler.setFormatter(console_formatter) + + # Add handlers to logger + logger.addHandler(file_handler) + logger.addHandler(console_handler) + + return logger + +def get_logger(name: str = None) -> logging.Logger: + """ + Get a logger instance. Creates one if it doesn't exist. + + Parameters + ---------- + name : str, optional + Logger name. If None, uses the calling module's name. + + Returns + ------- + logging.Logger + Logger instance + """ + if name is None: + import inspect + frame = inspect.currentframe().f_back + name = frame.f_globals.get('__name__', 'unknown') + + logger = logging.getLogger(name) + + # If logger doesn't have handlers, set it up + if not logger.handlers: + logger = setup_logger(name) + + return logger \ No newline at end of file diff --git a/sanpy/kym/mpLineProfile.py b/sanpy/kym/mpLineProfile.py new file mode 100644 index 00000000..766be8d0 --- /dev/null +++ b/sanpy/kym/mpLineProfile.py @@ -0,0 +1,146 @@ +import time +import math +import os +from multiprocessing import Pool + +import numpy as np +from skimage.measure import profile # for profile_line() +import matplotlib.pyplot as plt +from matplotlib.patches import Rectangle +from typing import List, Tuple, Optional, Union +import pandas as pd + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def roiLineProfileWorker(imgData, tmp): + """ + Parameters + ========== + imgData : np.ndarry + Image data to extract line profile from + Assuming shape (line scans, pixels per line) + line scans must be odd + detectionDict : dict + 'lineWidth': lineWidth, + 'lineFilterKernel': lineFilterKernel, + 'percentOfMax': percentOfMax, + 'src_pnt_space': src_pnt_space, + 'dst_pnt_space': dst_pnt_space, + """ + lineWidth = imgData.shape[1] + numPixels = imgData.shape[0] + + if lineWidth == 1: + intensityProfile = imgData[:,0] + # intensityProfile = np.flip(intensityProfile) # FLIPPED + + else: + # intensityProfile will always have len() of number of pixels in line scane + # -1 because profile_line uses last pnt (unlike numpy) + + # middleLine = math.floor(lineWidth/2) + # src = (numPixels-1, middleLine) + # dst = (0, middleLine) # -1 because profile_line uses last pnt (unlike numpy) + # intensityProfile = profile.profile_line( + # imgData, src, dst, linewidth=lineWidth + # ) + + intensityProfile = np.mean(imgData, axis=1) + + return intensityProfile + +def roiLineProfilePool(imgData : np.ndarray, lineWidth : int): + """ + Parameters + ========== + tifData : np.ndarry + tif data slice to analyze + lineWidth : int + Must be odd + """ + startTime = time.time() + + numLines = imgData.shape[1] + + logger.info(f'imgData:{imgData.shape} lineWidth:{lineWidth} numLines:{numLines}') # tifData:(10000, 519) + + result_objs = [] + + with Pool(processes=os.cpu_count() - 1) as pool: + + # add the workers + for line in range(numLines): + startLine = line - math.floor(lineWidth/2) + if startLine < 0: + startLine = 0 + # numpy uses (] indexing + stopLine = line + math.floor(lineWidth/2) + 1 + if stopLine > numLines: + stopLine = numLines + + imageSlice = imgData[:, startLine:stopLine] + + if line == 0: + logger.info(f'line:{line} imageSlice:{imageSlice.shape}') + + workerParams = (imageSlice, None) + + result = pool.apply_async(roiLineProfileWorker, workerParams) + result_objs.append(result) + + # run the workers - this HAS TO BE IN CONTEXT OF Pool() + logger.info(f' getting results from {len(result_objs)} workers') + results = [result.get() for result in result_objs] + + # building only takes like 1 ms + _outImg = np.empty_like(imgData) + for _resultIdx, result in enumerate(results): + _outImg[:,_resultIdx] = result + + stopTime = time.time() + logger.info(f' took {round(stopTime-startTime,3)} seconds') + + + # results is a list of intensity profiles + + # stopTime2 = time.time() + # logger.info(f' building _outImg took {round(stopTime2-stopTime,3)} seconds') + + # logger.info(f'results len:{len(results)} results[0].shape:{results[0].shape}') + + return _outImg + +def testPool(): + import tifffile + + # path = '/Users/cudmore/Dropbox/data/colin/sanAtp/ISAN Linescan 1.tif' + + # a kym with negative peaks + path = '/Users/cudmore/Dropbox/data/colin/sanAtp/SSAN Linescan 8.tif' + + imgData = tifffile.imread(path) + + imgData = np.rot90(imgData) + + imgData = imgData[:, :, 1] + + left = 0 + top = 822 + right = 999 + bottom = 654 + roiImg = imgData[bottom:top, left:right] + + logger.info(f'imgData:{imgData.shape} roiImg:{roiImg.shape}') + + lineWidth = 3 + _outImg = roiLineProfilePool(roiImg, lineWidth=lineWidth) + + plt.imshow(_outImg) + plt.show() + + # print('results[0]') + # print(results[0]) + +if __name__ == '__main__': + testPool() \ No newline at end of file diff --git a/sanpy/kym/notebooks/diam-analysis.ipynb b/sanpy/kym/notebooks/diam-analysis.ipynb new file mode 100644 index 00000000..c39b9d6a --- /dev/null +++ b/sanpy/kym/notebooks/diam-analysis.ipynb @@ -0,0 +1,100 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 43, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from scipy.signal import medfilt\n", + "\n", + "import pandas as pd\n", + "import numpy as np\n", + "\n", + "import seaborn as sns\n", + "sns.set_theme('talk')\n", + "\n", + "import matplotlib.pyplot as plt\n", + "plt.style.use(\"dark_background\");\n", + "\n", + "\n", + "path = '/Users/cudmore/Dropbox/data/cell-shortening/paula/kymAnalysis/cell01_C002T001-kymDiameter.csv'\n", + "\n", + "df = pd.read_csv(path, header=1)\n", + "\n", + "# print(df)\n", + "\n", + "sumintensity_raw = df['sumintensity_raw']\n", + "diameter_um = df['diameter_um']\n", + "\n", + "sumintensity_raw = medfilt(sumintensity_raw, 3)\n", + "diameter_um = medfilt(diameter_um, 3)\n", + "\n", + "startBin = 450 # 110\n", + "stopBin = 750 #880\n", + "\n", + "sumintensity_raw = sumintensity_raw[startBin:stopBin]\n", + "diameter_um = diameter_um[startBin:stopBin]\n", + "\n", + "# norm to 0..1\n", + "sumintensity_raw = sumintensity_raw / np.max(sumintensity_raw)\n", + "sumintensity_raw[sumintensity_raw<0.59] = np.nan\n", + "\n", + "fig = plt.figure(figsize=(3, 3), dpi=90)\n", + "axs = fig.add_subplot(111)\n", + "axs.grid(False)\n", + "axs.plot(sumintensity_raw, diameter_um);\n", + "sns.despine()\n", + "# axs.set_xticklabels([])\n", + "\n", + "fig2, axs2 = plt.subplots(2)\n", + "axs2[0].plot(sumintensity_raw)\n", + "axs2[1].plot(diameter_um)\n", + "\n", + "sns.despine()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "sanpy-env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/sanpy/kym/notebooks/freq_seaborn.ipynb b/sanpy/kym/notebooks/freq_seaborn.ipynb new file mode 100644 index 00000000..9836a654 --- /dev/null +++ b/sanpy/kym/notebooks/freq_seaborn.ipynb @@ -0,0 +1,294 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Load a kymograph ROI analysis and plot some stats for each ROI" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + " INFO sanpy._util _util.py _loadLineScanHeader() line:204 -- looking for Olympus Metadata.txt file\n", + "WARNING sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py __init__() line:864 -- converting self._imgData from: uint8 to np.int8\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py __init__() line:871 -- from path:(1024, 1000, 3)\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py __init__() line:906 -- loaded self._imgData.shape:(1024, 1000)\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py __init__() line:907 -- self._path:/Users/cudmore/Desktop/retreat-sept-2024/SSAN Linescan 12.tif\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py loadAnalysis() line:1158 -- === loading analysis from: /Users/cudmore/Desktop/retreat-sept-2024/sanpy-kym-roi-analysis/SSAN Linescan 12-roiPeaks.csv\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py loadAnalysis() line:1175 -- 1: {'version': 0.2, 'ltrb': [0, 180, 711, 70], 'expDetrendFit': {'fn': 'myMonoExp', 'm': 1.4240498175042517, 'tau': 0.4614437432654208, 'b': 3.5051298708217082}, 'backgroundSubtractValue': 13, 'f0Value': 2.944238194552335, 'detectThisTrace': 'int_f_f0', 'binLineScans': 2, 'doExpDetrend': True, 'backgroundsubtract': 'Median', 'polarity': 'Neg', 'f0ManualPercentile': 'Percentile', 'f0 Percentile': 20.0, 'medianfilter': False, 'medianfilterkernel': 3, 'filter': False, 'prominence': 0.4, 'width (ms)': 40.0, 'distance (ms)': 200.0, 'thresh_rel_height': 0.85, 'decay_rel_height': 0.85, 'decay (ms)': 50.0, 'newOnsetOffsetFraction': 0.9}\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py addROI() line:967 -- adding new roi label 1\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py loadAnalysis() line:1175 -- 2: {'version': 0.2, 'ltrb': [49, 342, 996, 247], 'expDetrendFit': {'fn': 'myMonoExp', 'm': 4.755564103973695, 'tau': 1.0975690559489104, 'b': 3.5362562272469993}, 'backgroundSubtractValue': 19, 'f0Value': 1.657411731920744, 'detectThisTrace': 'int_f_f0', 'binLineScans': 2, 'doExpDetrend': True, 'backgroundsubtract': 'Median', 'polarity': 'Pos', 'f0ManualPercentile': 'Percentile', 'f0 Percentile': 30.0, 'medianfilter': False, 'medianfilterkernel': 3, 'filter': False, 'prominence': 0.8, 'width (ms)': 20.0, 'distance (ms)': 180.0, 'thresh_rel_height': 0.85, 'decay_rel_height': 0.85, 'decay (ms)': 55.0, 'newOnsetOffsetFraction': 0.9}\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py addROI() line:967 -- adding new roi label 2\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py loadAnalysis() line:1175 -- 3: {'version': 0.2, 'ltrb': [0, 899, 380, 802], 'expDetrendFit': {'fn': 'myMonoExp', 'm': 12.812071176809363, 'tau': 0.17583669672124375, 'b': -6.527136979863766}, 'backgroundSubtractValue': 18, 'f0Value': 5.463084734100656, 'detectThisTrace': 'int_f_f0', 'binLineScans': 2, 'doExpDetrend': True, 'backgroundsubtract': 'Median', 'polarity': 'Neg', 'f0ManualPercentile': 'Percentile', 'f0 Percentile': 10.0, 'medianfilter': False, 'medianfilterkernel': 3, 'filter': False, 'prominence': 0.8, 'width (ms)': 20.0, 'distance (ms)': 180.0, 'thresh_rel_height': 0.85, 'decay_rel_height': 0.85, 'decay (ms)': 50.0, 'newOnsetOffsetFraction': 0.9}\n", + " INFO sanpy.kym.kymRoiAnalysis kymRoiAnalysis.py addROI() line:967 -- adding new roi label 3\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " ROI Number Peak Number Accept Peak Bin Peak Second Peak Int \\\n", + "0 1 1 True 29 0.091031 0.042684 \n", + "1 1 2 True 110 0.345290 0.140342 \n", + "2 1 3 True 196 0.615244 0.111455 \n", + "3 1 4 True 276 0.866364 0.000000 \n", + "4 1 5 True 360 1.130040 0.179185 \n", + "5 1 6 True 435 1.365465 0.251971 \n", + "6 1 7 True 520 1.632280 0.321950 \n", + "7 1 8 True 588 1.845732 0.130261 \n", + "8 1 9 True 665 2.087435 0.210708 \n", + "9 2 1 True 82 0.411209 2.779131 \n", + "10 2 2 True 166 0.674885 3.060431 \n", + "11 2 3 True 248 0.932283 2.800105 \n", + "12 2 4 True 328 1.183403 3.334341 \n", + "13 2 5 True 403 1.418828 3.239509 \n", + "14 2 6 True 480 1.660531 3.570918 \n", + "15 2 7 True 558 1.905373 3.203864 \n", + "16 2 8 True 630 2.131381 2.976879 \n", + "17 2 9 True 705 2.366806 3.033506 \n", + "18 2 10 True 780 2.602231 2.872559 \n", + "19 2 11 True 855 2.837656 2.649728 \n", + "20 2 12 True 927 3.063664 2.842842 \n", + "21 3 1 True 29 0.091031 0.000000 \n", + "22 3 2 True 109 0.342151 0.160080 \n", + "23 3 3 True 194 0.608966 0.136743 \n", + "24 3 4 True 277 0.869503 0.148063 \n", + "25 3 5 True 361 1.133179 0.333604 \n", + "\n", + " Peak Height Peak Interval (s) Peak Freq (Hz) Onset Bin ... fit_tau \\\n", + "0 -0.970325 NaN NaN 12 ... NaN \n", + "1 -0.862923 0.254259 3.932997 92 ... -9.832845 \n", + "2 -0.983535 0.269954 3.704335 176 ... -26.368354 \n", + "3 -1.014424 0.251120 3.982160 260 ... NaN \n", + "4 -0.831096 0.263676 3.792533 325 ... NaN \n", + "5 -0.759251 0.235425 4.247637 394 ... -16.515432 \n", + "6 -0.584747 0.266815 3.747915 505 ... NaN \n", + "7 -0.946570 0.213452 4.684894 534 ... NaN \n", + "8 -0.954300 0.241703 4.137309 621 ... NaN \n", + "9 2.033977 NaN NaN 67 ... 12.439357 \n", + "10 2.605246 0.263676 3.792533 150 ... 25.284695 \n", + "11 1.721290 0.257398 3.885034 234 ... 33.241213 \n", + "12 2.527415 0.251120 3.982160 299 ... 32.740632 \n", + "13 2.140293 0.235425 4.247637 394 ... 8.797882 \n", + "14 2.555691 0.241703 4.137309 463 ... 34.255227 \n", + "15 2.241668 0.244842 4.084267 543 ... 24.683535 \n", + "16 2.054363 0.226008 4.424622 614 ... NaN \n", + "17 1.965141 0.235425 4.247637 693 ... NaN \n", + "18 1.906522 0.235425 4.247637 767 ... NaN \n", + "19 1.724817 0.235425 4.247637 833 ... 19.577583 \n", + "20 1.176673 0.226008 4.424622 922 ... 3.215469 \n", + "21 -0.885241 NaN NaN 10 ... NaN \n", + "22 -0.811770 0.251120 3.982160 100 ... NaN \n", + "23 -0.784240 0.266815 3.747915 139 ... NaN \n", + "24 -0.751803 0.260537 3.838226 244 ... NaN \n", + "25 -0.687628 0.263676 3.792533 297 ... -6.339792 \n", + "\n", + " fit_b fit_r2 fit_m1 fit_tau1 fit_m2 fit_tau2 \\\n", + "0 NaN NaN NaN NaN NaN NaN \n", + "1 -1.275426 0.924645 NaN NaN NaN NaN \n", + "2 -0.291855 0.972385 9.367511e+01 -16.317768 -93.600818 -16.232549 \n", + "3 NaN NaN -9.580102e+01 -23.537493 95.884879 -23.594623 \n", + "4 NaN NaN NaN NaN NaN NaN \n", + "5 -0.307215 0.960775 NaN NaN NaN NaN \n", + "6 NaN NaN 1.642858e+02 11.832509 -164.055234 12.047384 \n", + "7 NaN NaN 1.382374e+02 3.305477 -138.135748 3.430461 \n", + "8 NaN NaN 3.409009e-01 -16.809336 -0.140328 128.683191 \n", + "9 -0.377339 0.758197 2.054043e+00 14.837242 0.753715 14.836925 \n", + "10 0.164732 0.956441 3.171176e+00 24.170393 0.000170 -127.969249 \n", + "11 0.745588 0.896168 1.968194e-03 -97.672692 2.927507 22.568519 \n", + "12 0.537523 0.949565 5.164261e-01 685.980519 2.818443 20.420620 \n", + "13 -2.452445 0.955022 1.989808e+00 18.856978 1.348859 18.857222 \n", + "14 0.771406 0.962133 3.024793e+00 18.043917 0.545176 222.261901 \n", + "15 0.318805 0.939327 3.764845e-08 -295.893859 3.219926 21.737665 \n", + "16 NaN NaN 2.305237e+00 14.142655 0.796356 14.143493 \n", + "17 NaN NaN 1.820014e+00 21.684914 1.588169 21.684708 \n", + "18 NaN NaN -4.131939e+02 41.720826 415.950634 41.401196 \n", + "19 0.315919 0.949113 1.027935e+02 16.078674 -100.167503 16.078646 \n", + "20 -6.618862 0.775048 9.938887e+01 1.895912 -96.611906 1.637147 \n", + "21 NaN NaN -2.148085e-01 124.386821 0.182219 -35.846270 \n", + "22 NaN NaN 2.801581e-01 -43.306576 -0.188859 -43.306907 \n", + "23 NaN NaN 8.313985e+01 -23.523460 -83.015782 -23.480939 \n", + "24 NaN NaN 1.502543e-01 -38.349052 0.008576 214.112382 \n", + "25 -2.527789 0.873593 NaN NaN NaN NaN \n", + "\n", + " fit_r22 Detection Errors \\\n", + "0 NaN _expFit:Optimal parameters not found: Number o... \n", + "1 NaN _expFit2:Optimal parameters not found: Number ... \n", + "2 0.973070 NaN \n", + "3 0.902995 _expFit:Optimal parameters not found: Number o... \n", + "4 NaN _expFit:Optimal parameters not found: Number o... \n", + "5 NaN _expFit2:Optimal parameters not found: Number ... \n", + "6 0.869059 _expFit:Optimal parameters not found: Number o... \n", + "7 0.919255 _expFit:Optimal parameters not found: Number o... \n", + "8 0.955555 _expFit:Optimal parameters not found: Number o... \n", + "9 0.757898 NaN \n", + "10 0.958958 NaN \n", + "11 0.904119 NaN \n", + "12 0.967475 NaN \n", + "13 0.949190 NaN \n", + "14 0.970298 NaN \n", + "15 0.946729 NaN \n", + "16 0.821661 _expFit:Optimal parameters not found: Number o... \n", + "17 0.894425 _expFit:Optimal parameters not found: Number o... \n", + "18 0.933833 _expFit:Optimal parameters not found: Number o... \n", + "19 0.948445 NaN \n", + "20 0.775066 NaN \n", + "21 0.974051 _expFit:Optimal parameters not found: Number o... \n", + "22 0.942140 _expFit:Optimal parameters not found: Number o... \n", + "23 0.975913 _expFit:Optimal parameters not found: Number o... \n", + "24 0.991759 _expFit:Optimal parameters not found: Number o... \n", + "25 NaN _expFit2:Optimal parameters not found: Number ... \n", + "\n", + " Path \n", + "0 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "1 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "2 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "3 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "4 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "5 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "6 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "7 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "8 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "9 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "10 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "11 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "12 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "13 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "14 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "15 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "16 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "17 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "18 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "19 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "20 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "21 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "22 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "23 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "24 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "25 /Users/cudmore/Desktop/retreat-sept-2024/SSAN ... \n", + "\n", + "[26 rows x 48 columns]\n" + ] + } + ], + "source": [ + "path = '/Users/cudmore/Desktop/retreat-sept-2024/SSAN Linescan 12.tif'\n", + "\n", + "kra = KymRoiAnalysis(path)\n", + "\n", + "df = kra.getDataFrame()\n", + "\n", + "print(df)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/var/folders/76/6bdl7smj72g6tynz3985xxl40000gn/T/ipykernel_79551/2721006032.py:17: UserWarning: The palette list has more values (10) than needed (3), which may not be intended.\n", + " axScatter = sns.stripplot(data=df,\n", + "/var/folders/76/6bdl7smj72g6tynz3985xxl40000gn/T/ipykernel_79551/2721006032.py:28: UserWarning: The palette list has more values (10) than needed (3), which may not be intended.\n", + " axScatter = sns.pointplot(data=df,\n" + ] + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import seaborn as sns\n", + "\n", + "sns.set_theme('talk')\n", + "\n", + "sns.set_palette(\"colorblind\")\n", + "palette = sns.color_palette()\n", + "\n", + "import matplotlib.pyplot as plt\n", + "plt.style.use(\"dark_background\")\n", + "\n", + "fig = plt.figure(figsize=(3, 3), dpi=90)\n", + "axs = fig.add_subplot(111)\n", + "\n", + "yStat = 'Peak Inst Freq (Hz)'\n", + "# yStat = 'Peak Height'\n", + "\n", + "axScatter = sns.stripplot(data=df,\n", + " x='ROI Number',\n", + " y=yStat,\n", + " hue='ROI Number',\n", + " alpha=0.6,\n", + " palette=palette,\n", + " ax=axs,\n", + " legend=False,\n", + " )\n", + "axScatter.grid(False)\n", + "\n", + "axScatter = sns.pointplot(data=df,\n", + " x='ROI Number',\n", + " y=yStat,\n", + " hue='ROI Number',\n", + " errorbar=None, # can be 'se', 'sem', etc\n", + " # errorbar='se',\n", + " linestyle='none', # do not connect (with line) between categorical x\n", + " marker=\"_\",\n", + " markersize=30,\n", + " # markeredgewidth=3,\n", + " legend=False,\n", + " palette=palette,\n", + " ax=axs\n", + ")\n", + "axScatter.grid(False)\n", + "sns.despine()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "sanpy-env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/sanpy/kym/sandbox/__init__.py b/sanpy/kym/sandbox/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanpy/kym/sandbox/myRollingBall.py b/sanpy/kym/sandbox/myRollingBall.py new file mode 100644 index 00000000..f7392b06 --- /dev/null +++ b/sanpy/kym/sandbox/myRollingBall.py @@ -0,0 +1,41 @@ +import matplotlib.pyplot as plt +import numpy as np +import pywt + +import tifffile + +from skimage import data, restoration # use util to invert if needed + +def plot_result(image, background): + fig, ax = plt.subplots(nrows=1, ncols=3) + + ax[0].imshow(image, cmap='gray') + ax[0].set_title('Original image') + ax[0].axis('off') + + ax[1].imshow(background, cmap='gray') + ax[1].set_title('Background') + ax[1].axis('off') + + ax[2].imshow(image - background, cmap='gray') + ax[2].set_title('Result') + ax[2].axis('off') + + fig.tight_layout() + + +# image = data.coins() + +path = '/Users/cudmore/Dropbox/data/colin/sanAtp/ISAN Linescan 3.tif' +image = tifffile.imread(path) +if len(image.shape) > 2: + image = image[:, :, 1] +# image = np.rot90(image) + +_rollingBallRadius = 50 +background = restoration.rolling_ball(image, radius=_rollingBallRadius) + +print(f' background:{background.shape} min:{np.min(background)} max:{np.max(background)} mean:{np.mean(background)}') + +plot_result(image, background) +plt.show() \ No newline at end of file diff --git a/sanpy/kym/sandbox/tryEnum.py b/sanpy/kym/sandbox/tryEnum.py new file mode 100644 index 00000000..5e2efd2a --- /dev/null +++ b/sanpy/kym/sandbox/tryEnum.py @@ -0,0 +1,8 @@ +from enum import StrEnum, Enum + +class PeakDetectionTypes(Enum): + f_fo = 'f_fo' + diameter = 'diameter' + +for item in PeakDetectionTypes: + print('XXXXXX', item.value) diff --git a/sanpy/kym/simple_scatter/__init__.py b/sanpy/kym/simple_scatter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanpy/kym/simple_scatter/colin_global.py b/sanpy/kym/simple_scatter/colin_global.py new file mode 100644 index 00000000..e1477d5f --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_global.py @@ -0,0 +1,373 @@ +import os +import sys +from typing import List +from dataclasses import dataclass + +import pandas as pd +import shutil +import numpy as np +import matplotlib.pyplot as plt +import seaborn as sns +from typing import List, Dict, Any, Optional, NamedTuple +from datetime import datetime +import warnings + +from sanpy.analysisDir import _walk +# from sanpy.bAnalysis_ import bAnalysis +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +@dataclass +class FileInfo: + """Dataclass to hold parsed file information from tif file paths. + + Example path: "20250602 ISAN R3 LS3 Control Epoch 1.tif" + """ + cellID: str + epoch: int + date: str + region: str + condition: str + + @classmethod + def from_path(cls, tif_path: str) -> 'FileInfo': + """Create FileInfo from a tif file path. + + Args: + tif_path: Path like "20250602 ISAN R3 LS3 Control Epoch 1.tif" + + Returns: + FileInfo object with parsed components + """ + # Get just the filename without path + _, tif_file = os.path.split(tif_path) + + # Remove .tif extension + tif_file = tif_file.replace('.tif', '') + + # Split into components + parts = tif_file.split(' ') + + # Extract date (first part) + date = parts[0] + + # Extract region (ISAN or SSAN) + region = None + for part in parts: + if part in ['ISAN', 'SSAN']: + region = part + break + if region is None: + logger.error(f'ERROR: did not find ISAN or SSAN in raw tif file ??? {tif_file}') + region = 'Unknown' + + # Extract condition + condition = None + for cond in conditionOrder: + if cond in tif_file: + condition = cond + break + if condition is None: + logger.error(f'ERROR: did not find one of {conditionOrder} in raw tif file ??? {tif_file}') + condition = 'Unknown' + + # Extract epoch + epoch = 0 # default + if 'Epoch' in tif_file: + epoch_str = tif_file.split('Epoch ')[1] + epoch = int(epoch_str) + + # Extract cellID - everything up to the condition + # Remove epoch part if present + cell_id_parts = tif_file + if 'Epoch' in cell_id_parts: + cell_id_parts = cell_id_parts.split(' Epoch')[0] + + # Remove condition part + for cond in conditionOrder: + if cond in cell_id_parts: + cell_id_parts = cell_id_parts.replace(f' {cond}', '') + break + + cellID = cell_id_parts.strip() + + return cls( + cellID=cellID, + epoch=epoch, + date=date, + region=region, + condition=condition + ) + + +conditionOrder = ['Control', 'Ivab', 'Thap', 'FCCP'] +# List of all possible conditions + +# abb 20250621 +# conditionEpochOrder = ['Control 0', 'Control 1', 'Ivab 0', 'Thap 0'] + +# fijiConditions = ['Control', 'Ivabradine', 'Thapsigargin'] +fijiConditions = ['Control', 'Ivabradine', 'Thapsigargin', 'FCCP'] + +regionOrder = ['SSAN', 'ISAN'] + +""" +This was my analysis from 0521 through 0528 + +Used prominence 1.2 for all analysis + The peaks were under-detected in control +""" +# dataPath = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/renamed-20250521' + +""" +20250528 switching over to using this global to control analysis. + +TODO: + - Move all roi to the right so we can skip bleaching + - rerun analysis to make sure we detect peaks in control +""" + +# df_f0 +# _ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/sanpy-20250610-df_f0' + +# f_f0 +# _ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/sanpy-20250610-f_f0' + +# adding mode 1/2 to each roi +# _ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/sanpy-20250612-f_f0' + +# analyzing divided +# _ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/sanpy-20250608-div' +# working on import of new data, this wil be merged into main fiolder +# _ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/new-20250613/20250602' + +_ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250618-rhc' + +_ROOT_ANALYSIS_FOLDER = '/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-atp-20250623-RHC' + +_ROOT_SANPY_REPORT_FOLDER = 'sanpy-reports-pdf' + +# _IGOR_THESE_FOLDERS = [_ROOT_ANALYSIS_FOLDER] + +def getCellCondEpoch(df:pd.DataFrame, cellID:str, condition:str, epoch:int, roi:int=None) -> pd.DataFrame: + """Get a dataframe with only the rows for a given cellID, condition, epoch, and roi. + """ + df = df[df['Cell ID'] == cellID] + df = df[df['Condition'] == condition] + df = df[df['Epoch'] == epoch] + if roi is not None: + df = df[df['ROI Number'] == roi] + return df + +def getRootAnalysisFolder() -> str: + return _ROOT_ANALYSIS_FOLDER + +def loadMasterDfFile() -> pd.DataFrame: + """Load our master df csv file and return as DataFrame. + """ + file = getMasterDfPath() + if not os.path.isfile(file): + logger.error(f'did not find master csv file') + print(file) + else: + df = pd.read_csv(file) + return df + +def loadMeanDfFile() -> pd.DataFrame: + """Load our mean df csv file and return as DataFrame. + """ + meanDfPath = getMeanDfPath() + if not os.path.isfile(meanDfPath): + logger.error(f'did not find meanDfPath') + print(meanDfPath) + else: + df = pd.read_csv(meanDfPath) + return df + +def getMasterDfPath() -> str: + """Get the full path to our master df csv file. + + One peak per row. + """ + dfMasterFile = 'df-master-file.csv' + dfMaster = os.path.join(_ROOT_ANALYSIS_FOLDER, dfMasterFile) + return dfMaster + +def getMeanDfPath() -> str: + """Get the full path to our mean df csv file. + + One (cell id, condition, roi label) per row. + """ + dfMeanFile = 'df-mean-file.csv' + dfMean = os.path.join(_ROOT_ANALYSIS_FOLDER, dfMeanFile) + return dfMean + +def getAllTifFilePaths() -> List[str]: + """Get a list of all raw tif paths. + + This assumes all tif files in analysis folder are to be analyzed. + """ + thisExt = '.tif' + paths = _walk(_ROOT_ANALYSIS_FOLDER, thisExt, 5) + paths = list(paths) + + # only accept tif files not in folder 'roi-img-clips + paths = [p for p in paths if 'roi-img-clips' not in p and 'sanpy-reports-pdf' not in p] + + return paths + +def getAllSanPyAnalysisCsv() -> List[str]: + thisExt = '.csv' + paths = _walk(_ROOT_ANALYSIS_FOLDER, thisExt, 5) + paths = list(paths) + + # only accept paths that have 'sanpy-kym-roi-analysis' folder + paths = [p for p in paths if 'sanpy-kym-roi-analysis' in p] + + return paths + +def getAllPeakAnalysisCsv() -> List[str]: + """Get all SanPy saved analysis csv files using G_MASTER_DATA_FOLDER. + + Be sure to skip csv files not saved by SanPy. + """ + thisExt = '.csv' + paths = _walk(_ROOT_ANALYSIS_FOLDER, thisExt, 5) + paths = list(paths) + + # only accept "roiPeaks.csv" files + paths = [p for p in paths if 'roiPeaks.csv' in p and 'sanpy-kym-roi-analysis' in p] + + return paths + +def getSanpyReportsPdfPath() -> str: + """Get the full path to the sanpy reports pdf folder. + + 'sanpy-reports-pdf' + """ + pdfPath = os.path.join(_ROOT_ANALYSIS_FOLDER, _ROOT_SANPY_REPORT_FOLDER) + if not os.path.isdir(pdfPath): + os.makedirs(pdfPath) + return pdfPath + +def getCellIdPdfFolder(pdfOutputType: str, + cellId: str, + dateStr:str, + regionStr:str + ) -> str: + """Get the full folder path to the cell id pdf folder. + + like 'Per Cell Cond Plots' + + Actually makes folders + """ + reportPath = getSanpyReportsPdfPath() + reportPath = os.path.join(reportPath, pdfOutputType) + if not os.path.exists(reportPath): + # like 'Per Cell Cond Plots' + os.mkdir(reportPath) + + # make date folders + reportPath = os.path.join(reportPath, dateStr) + if not os.path.exists(reportPath): + os.mkdir(reportPath) + + # make region folders + reportPath = os.path.join(reportPath, regionStr) + if not os.path.exists(reportPath): + os.mkdir(reportPath) + + return reportPath + +def iterate_unique_cell_rows(df: pd.DataFrame, cellID: str, roiNumber: int = None): + """Iterator function that returns unique rows from DataFrame for a given cellID. + + Args: + df: DataFrame with columns 'Cell ID', 'Condition', 'Epoch' + cellID: The cell ID to filter by + roiNumber: Optional ROI number to filter by + + Yields: + pd.Series: Each unique row for the given cellID (unique by Condition and Epoch) + """ + # Filter DataFrame for the given cellID + cell_df = df[df['Cell ID'] == cellID] + + if roiNumber is not None: + roiNumber = int(roiNumber) + cell_df = cell_df[cell_df['ROI Number'] == roiNumber] + + # Get unique combinations of Condition and Epoch + # Use drop_duplicates to remove duplicate rows based on these columns + unique_rows = cell_df.drop_duplicates(subset=['Condition', 'Epoch']) + + # Yield each unique row + for _, row in unique_rows.iterrows(): + yield row + +def loadAllKymRoiAnalysis(loadImgData=True) -> dict: + """Load analysis for each tif and store in a dict. + + keys are tif path + values are KymRoiAnalysis + """ + retDict = {} + # from sanpy.kym.simple_scatter.colin_global import getAllTifFilePaths + tifPaths = getAllTifFilePaths() + logger.info(f'loading all kym roi analysis from {len(tifPaths)} tif files, loadImgData:{loadImgData}') + for tifPath in tifPaths: + # print(tifPath) + ka = _loadKymRoiAnalysis(tifPath, loadImgData=loadImgData) + retDict[tifPath] = ka + return retDict + +def _loadKymRoiAnalysis(tifPath, loadImgData=True): + """Load one kymRoiAnalysis.. + """ + # ba = bAnalysis(tifPath) + # imgData = ba.fileLoader._tif # list of color channel images + # logger.info(f'imgData:{imgData[0].shape} {np.min(imgData[0])} {np.max(imgData[0])} {np.mean(imgData[0])}') + ka = KymRoiAnalysis(tifPath, + # imgData=imgData, + loadImgData=loadImgData) + return ka + +if __name__ == '__main__': + print('') + + # getAllPeakAnalysisCsv() + # print(getMasterDfPath()) + # print(getMeanDfPath()) + + # df = loadMeanDfFile() + # print(df.columns) + + # if use passes DELETE_ALL_SANPY_ANALYSIS, delete all sanpy analysis + if 'DELETE_ALL_SANPY_ANALYSIS' in sys.argv: + # delete all 'sanpy-kym-roi-analysis' folders + logger.warning('=== DELETING ALL SANPY ANALYSIS CSV FILES') + logger.warning(getRootAnalysisFolder()) + csv = getAllSanPyAnalysisCsv() + for path in csv: + _path, _file = os.path.split(path) + if 'sanpy-kym-roi-analysis' in _path: + if os.path.isdir(_path): + shutil.rmtree(_path) + # else: + # os.remove(path) + # pass + + logger.warning(f' deleted {len(csv)} csv files.') + + # delete folder with all pdf + pdfPath = getSanpyReportsPdfPath() + logger.warning('=== DELETING ALL SANPY output pdf') + if os.path.isdir(pdfPath): + logger.info(' deleting folder pdf folder:') + logger.info(f' {pdfPath}') + shutil.rmtree(pdfPath) + + + # print(getSanpyReportsPdfPath()) + # print(getCellIdPdfFolder('test pdf out', 'fakecellid', '20250521', 'left')) \ No newline at end of file diff --git a/sanpy/kym/simple_scatter/colin_import.py b/sanpy/kym/simple_scatter/colin_import.py new file mode 100644 index 00000000..fc78b184 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_import.py @@ -0,0 +1,127 @@ +import os +import pathlib +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt + +from colin_global import _walk + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def rename_files2(): + rename_files('.tif') + rename_files('.txt') + +def rename_files(thisExt:str): + """20250616 + '_0001' is colins for control? + """ + # basePath = '/Users/cudmore/Dropbox/data/colin/2025' + # # dataPath = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/renamed2' + # # second set of analysis from colin + # dataPath = os.path.join(basePath, 'new-20250613') + + # this is colins main atp folder -->> working + # dataPath = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250618-rhc' + + logger.info(f'thisExt:"{thisExt}"') + + from colin_global import getRootAnalysisFolder + rootPath = getRootAnalysisFolder() + print(f'rootPath:{rootPath}') + + paths = _walk(rootPath, thisExt, 5) + paths = list(paths) + + logger.info(f'original list len is:{len(paths)}') + # remove 'Region Name' folder + from colin_global import _ROOT_SANPY_REPORT_FOLDER + paths = [p for p in paths if 'Region Images' not in p and _ROOT_SANPY_REPORT_FOLDER not in p] + + + for _idx, path in enumerate(paths): + + # just for debugging + # shortPath = path.replace(dataPath, '') + # shortPath = path + + print(f'_idx:{_idx+1} of {len(paths)} to rename') + # print(shortPath) + + # first element is date + _tmpPath = path.replace(rootPath, '') + p = pathlib.Path(_tmpPath) + dateStr = p.parts[1] # short path starts with '/' + logger.info(f' dateStr:"{dateStr}"') + + _rootPath, orig_filename = os.path.split(path) + orig_filename, _ext = os.path.splitext(orig_filename) + logger.info(f'orig_filename: {orig_filename}') + + if dateStr not in orig_filename: + newname = f'{dateStr} {orig_filename}' + else: + newname = orig_filename + + # expCond = 1 + if 'Thapsigargin' in newname: + newname = newname.replace('Thapsigargin', '') + newname = newname.replace('Ivabradine', '') # remove + newname += ' Thap' + elif 'Ivabradine' in newname: + newname = newname.replace('Ivabradine', '') + newname += ' Ivab' + # 20250623 mito atp + elif 'FCCP' in newname: + newname = newname.replace('FCCP', '') + newname += ' FCCP' + elif 'Control' in newname: + newname = newname.replace('Control', '') + newname += ' Control' + + else: + newname += ' Control' + + # epoch are repeats within a condition + numEpochs = 10 + for epoch in range(numEpochs): + epochStr = f'_{str(epoch).zfill(4)}' + if epochStr in newname: + logger.info(f'found epochStr:{epochStr} in newname:{newname} orig_filename:{orig_filename}') + newname = newname.replace(epochStr, '') + newname += f' Epoch {epoch}' + break + + logger.info(f' 1) newname: {newname}') + + newname = newname.replace(' ', ' ') + newname = newname.replace(' ', ' ') + # print(f' newname -->> "{newname}"') + + logger.info(f' 2) final newname: {newname}') + + newPathName = os.path.join(_rootPath, newname + _ext) + # print(newPathName) + if path == newPathName: + logger.warning(' same name, skipping') + continue + + # logger.info(newPathName) + + # ACTUALLY DO RENAME + os.rename(path, newPathName) + + print(f'found {len(paths)} files') + +if __name__ == '__main__': + # add in command line switch 'RENAME-FILES + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('--rename-files', action='store_true', help='rename files') + args = parser.parse_args() + + if args.rename_files: + rename_files2() + else: + print('call with "--rename-files"') diff --git a/sanpy/kym/simple_scatter/colin_notes.md b/sanpy/kym/simple_scatter/colin_notes.md new file mode 100644 index 00000000..49bb9167 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_notes.md @@ -0,0 +1,25 @@ + +- Set priminence to 1.8 or 2.0 +- set roi rect left to 100 (to skip bleaching) + +20250527 + +1) shift roi to remove transient (bleaching) in control. + Will shift roi same amount in Ivab and Thap + +2) rerun detection, less aggressive prominence. + First run was 1.0, second run was 1.8 (too aggressive) + +3) Are you 100% confident that your rois were made in the same order? + I will write code to show each to visually verify. + +4) *** Add kym image/roi to browser, make pdf folder + +5) finish implementing picker for line plot + +6) Come up with a simple metric to validate control kymograph + +7) script to generate pdf reports for ALL cells, one pdf per roi + - normalize to f0 + +8) add legend to mpl browser diff --git a/sanpy/kym/simple_scatter/colin_pool_plot.py b/sanpy/kym/simple_scatter/colin_pool_plot.py new file mode 100644 index 00000000..d1130edf --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_pool_plot.py @@ -0,0 +1,933 @@ + # plot each cell id in each cond (control, ivab, thap) +import os +import sys +import ast +from typing import List, Optional +from pprint import pprint +from datetime import datetime + +import numpy as np +import pandas as pd + +# import matplotlib as mpl +import matplotlib.pyplot as plt +from matplotlib.axes import Axes +import matplotlib.patches as patches + +import seaborn as sns +# import mplcursors + +import tifffile + +from sanpy.kym.kymRoiAnalysis import plotDetectionResults + +from sanpy.kym.simple_scatter.colin_global import (loadMasterDfFile, + loadMeanDfFile, + getCellIdPdfFolder, + iterate_unique_cell_rows, + _loadKymRoiAnalysis, + # conditionOrder, + FileInfo, + ) + +from sanpy.kym.kymRoiAnalysis import KymRoi, KymRoiAnalysis, PeakDetectionTypes +from sanpy.kym.kymUtils import getAutoContrast + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +roiColorList = ['r', 'g', 'b', 'c', 'm', 'y'] + +# TODO: this is old, use ColinTraces +def _old_getDfTraces(tifFile, cellID, condition) -> pd.DataFrame: + tifPath, tifName = os.path.split(tifFile) + # cell id + # 250318 SSAN R5 LS1 + # trace file + # 250304 ISAN R3 LS1 c1-ch0-roiTraces.csv + # 250304 ISAN R3 LS1 c2 Ivab-ch0-roiTraces.csv + if condition == 'Control 0': + cPrefix = 'c0' + condStr = '' + elif condition == 'Control': + cPrefix = 'c1' + condStr = '' + elif condition == 'Ivab': + cPrefix = 'c2' + condStr = ' Ivab' + elif condition == 'Thap': + cPrefix = 'c3' + condStr = ' Thap' + + roiTracesFile = f'{cellID} {cPrefix}{condStr}-ch0-roiTraces.csv' + roiTracesPath = os.path.join(tifPath, 'sanpy-kym-roi-analysis', roiTracesFile) + + dfTraces = pd.read_csv(roiTracesPath, header=1) + + return dfTraces + +# def plotOneKym(dfMaster, dfMean, +# cellID, +# condition, +# epoch, +# ): +def plotOneKym(tifPath:str) -> tuple[plt.Figure, plt.Axes]: + """Plot all ROI in one kym image. + + This includes the kym image and one f/f0 plot per ROI. + """ + # dfMeanPlot = dfMean[dfMean['Cell ID'] == cellID] + # dfMeanPlot = dfMeanPlot[dfMeanPlot['Condition'] == condition] + # dfMeanPlot = dfMeanPlot[dfMeanPlot['Epoch'] == epoch] + + # tifFile = dfMeanPlot.iloc[0]['Path'] + + # load kymRoiAnalysis + kymRoiAnalysis = _loadKymRoiAnalysis(tifPath) + + numRois = kymRoiAnalysis.numRoi + + fig, ax = plt.subplots(nrows=numRois+1, + ncols=1, + figsize=(8, 6), + sharex=True, + ) + fileInfo = FileInfo.from_path(tif_path=tifPath) + + fig.suptitle(f'"{fileInfo.cellID}" condition:"{fileInfo.condition}" epoch:{fileInfo.epoch}', fontsize=11) + + # despine top and right + for oneAx in ax: + sns.despine(top=True, right=True, ax=oneAx) + + linewidth = 1 + markersize = 5 + + plotKymRoiTif(ax[0], + kymRoiAnalysis, + ) + + roiLabels = kymRoiAnalysis.getRoiLabels() + for roiIdx, roiLabelStr in enumerate(roiLabels): + logger.info(f'-->> plotting roiIdx:{roiIdx} roiLabelStr:{roiLabelStr}') + plotRow = roiIdx + 1 + try: + _color = roiColorList[roiIdx] + except IndexError: + tifPath = kymRoiAnalysis.path + tifName = os.path.basename(tifPath) + logger.error(f'tifName:{tifName} roiIdx:{roiIdx} out of range for roiColorList:{roiColorList}') + _color = 'k' + + kymRoi = kymRoiAnalysis.getRoi(roiLabelStr) + kymRoiDetection = kymRoi.getDetectionParams(channel=0, + detectionType=PeakDetectionTypes.intensity) + f0 = kymRoiDetection.getParam('f0 Value Percentile') + + # set the subplot title with f0 value and roi label + _label = f'roi "{roiLabelStr}" f0:{f0:.1f}' + ax[plotRow].set_title(_label, color=_color) + + timeTrace = kymRoiAnalysis.getAnalysisTrace(roiLabelStr, 'Time (s)', 0) + f_f0Trace = kymRoiAnalysis.getAnalysisTrace(roiLabelStr, 'f/f0', 0) + + # timeTrace = dfTraces[f'ROI {roiLabelStr} Time (s)'] + # logger.info(f'timeTrace:{timeTrace}') + # f_f0Trace = dfTraces[f'ROI {roiLabelStr} f/f0'] # hard coding f/f0 -->> df/f0 + ax[plotRow].plot(timeTrace, f_f0Trace, + 'g', + linewidth=linewidth, + label=f'{fileInfo.cellID} {fileInfo.condition}') + + # in kymRoi, labels are str, in df they are int + roiLabelInt = int(roiLabelStr) + + # theseRows = (dfMaster['Cell ID']==cellID) \ + # & (dfMaster['ROI Number']==roiLabelInt) \ + # & (dfMaster['Condition']==condition) \ + # & (dfMaster['Epoch']==epoch) + # dfPeaks = dfMaster.loc[theseRows] + + kymRoiResults = kymRoiAnalysis.getDataFrame(channel=0, + peakDetectionType=PeakDetectionTypes.intensity, + roiLabel=roiLabelInt) + dfPeaks = kymRoiResults.df + + # onset + xPlot = dfPeaks['Onset (s)'] + yPlot = dfPeaks['Onset Int'] + ax[plotRow].plot(xPlot, yPlot, 'co', markersize=markersize) + + # peak + xPlot = dfPeaks['Peak (s)'] + yPlot = dfPeaks['Peak Int'] + ax[plotRow].plot(xPlot, yPlot, 'ro', markersize=markersize) + + # onset + xPlot = dfPeaks['Decay (s)'] + yPlot = dfPeaks['Decay Int'] + ax[plotRow].plot(xPlot, yPlot, 'mo', markersize=markersize) + + if plotRow > 1: + ax[plotRow].sharey(ax[plotRow-1]) + + return fig, ax + +def plotStatSwarm(dfMaster, yStat, cellID, epoch, roiLabel, ax): + """plot a swarmplot for one kym, one roi + + x-axis is cond, y-axis is stat + """ + theseRows = (dfMaster['Cell ID']==cellID) \ + & (dfMaster['Epoch']==epoch) \ + & (dfMaster['ROI Number']==roiLabel) + dfSwarm = dfMaster.loc[theseRows] + + # Create new column with condition and epoch on separate lines + # dfSwarm['Condition Epoch'] = dfSwarm['Condition Epoch'].str.replace(' ', '\n') + dfSwarm.loc[:, 'Condition Epoch'] = dfSwarm['Condition Epoch'].str.replace(' ', '\n') + # dfSwarm['Condition Epoch'] = dfSwarm['Condition Epoch Multiline'].astype('category') + + if yStat not in dfSwarm.columns: + logger.warning(f'yStat:"{yStat}" not in dfSwarm.columns') + logger.warning(f'dfSwarm.columns:{dfSwarm.columns}') + return + + hue = 'Condition Epoch' + + # abb 20250623 mito atp, need to limit this so we do not plot non-existent conditions... + hue_order = ['Control\n0', 'Control\n1', 'Ivab\n0', 'Thap\n0', 'FCCP\n0'] + + plotDict = { + 'data': dfSwarm, # df for one cell id + 'x': 'Condition Epoch', + 'y': yStat, + # using new 'Condition Epoch' column + 'hue': hue, + 'order': hue_order + } + sns.swarmplot(ax=ax, **plotDict) + + # overlay mean bar + markersize = 30 + sns.pointplot(data=dfSwarm, + x='Condition Epoch', + y=yStat, + hue=hue, + # order=conditionOrder, + errorbar=None, # can be 'se', 'sem', etc + # capsize=capsize, + linestyle='none', # do not connect (with line) between categorical x + marker="_", + markersize=markersize, + # markeredgewidth=3, + legend=False, + # palette=palette, + # dodge=0.5, # separate the points by hue + # dodge=None if len(uniqueHue)==1 else 0.5, # separate the points by hue + # dodge=True, # 0.5 or true + order = hue_order, + ax=ax, + ) + +def plotRoiRects(ax:Axes, + kymRoiAnalysis: KymRoiAnalysis, + ): + """Plot roi rect on a mpl axis. + """ + secondsPerLine = kymRoiAnalysis.secondsPerLine + + roiLabels = kymRoiAnalysis.getRoiLabels() + for roiIdx, kymRoiLabel in enumerate(roiLabels): + try: + roiColor = roiColorList[roiIdx] + except IndexError: + tifPath = kymRoiAnalysis.path + tifName = os.path.basename(tifPath) + logger.error(f'tifName:{tifName} roiIdx:{roiIdx} out of range for roiColorList:{roiColorList}') + roiColor = 'k' + + kymRoi = kymRoiAnalysis.getRoi(kymRoiLabel) + roiRect = kymRoi.getRect() + + left = roiRect[0] + top = roiRect[1] + right = roiRect[2] + bottom = roiRect[3] + + left = left * secondsPerLine + right = right * secondsPerLine + + # plot roi as points + # x = [left, left, right, right] + # y = [bottom, top, top, bottom] + # ax.plot(x, y, 'or') + + width = right - left + height = top - bottom + # 20250528, if we plotted with origin='lower', height is negative !!! + # nope, we seem to always need the negative of height + # if our file loader does flip y then we do not need this + # assumin when we import Fiji roi, we swap left with right + # logger.info('negative height ???') + _origin = 'lower' + if _origin == 'lower': + height = - height + + rect = patches.Rectangle((left, top), + width, height, + linewidth=1, + edgecolor=roiColor, + facecolor='none') + ax.add_patch(rect) + + # label roi in image + # oneLabel = f'roiIdx:{roiIdx}' + # _xOffset = 20 # 5 + # ax.annotate(oneLabel, xy=(left, bottom), + # xytext=(left+_xOffset, bottom-20), + # arrowprops=dict(arrowstyle='->'), + # fontsize=12, + # weight='bold', + # color=roiColor) + +def plotKymRoiTif(ax:Axes, + kymRoiAnalysis: KymRoiAnalysis, + roiLabelStr:str=None, + detectThisTrace:str=None, + ): + """Plot either a full kym tif or one roi tif. + + If specified, both (detectThisTrace and f0) need to be specified. + """ + channel = 0 + _path = kymRoiAnalysis.path + _path, _file = os.path.split(_path) + secondsPerLine = kymRoiAnalysis.secondsPerLine + numLineScans = kymRoiAnalysis.numLineScans + f0 = None + if roiLabelStr is None: + # full kym + imgData = kymRoiAnalysis.getImageChannel(channel=channel) + height = kymRoiAnalysis.numPixelsPerLine + # _rect = kymRoiAnalysis.getRect() + else: + # one roi + kymRoi = kymRoiAnalysis.getRoi(roiLabelStr) + imgData = kymRoi.getRoiImg(channel) + _rect = kymRoi.getRect() + detectionParams = kymRoi.getDetectionParams(channel=channel, + detectionType=PeakDetectionTypes.intensity) + f0 = detectionParams.getParam('f0 Value Percentile') + + height = _rect[1] - _rect[3] + + # this wil fail for kymroi with left != 0 + left = 0 + right = numLineScans + + left_sec = left * secondsPerLine + right_sec = right * secondsPerLine + # top_um = top * umPerPixel + # bottom_um = bottom * umPerPixel + # extent=[left_sec, right_sec, bottom_um, top_um] # [left, right, top, bottom] + extent=[left_sec, right_sec, 0, height] # [left, right, top, bottom] + + if f0 is not None: + if detectThisTrace == 'f/f0': + _imgDataDisplay = imgData / f0 + elif detectThisTrace == 'df/f0': + _imgDataDisplay = (imgData - f0) / f0 + else: + logger.error(f'detectThisTrace:{detectThisTrace} not supported') + return + else: + _imgDataDisplay = imgData + + # imgMin = np.percentile(imgData, 5) # 2 + # imgMax = np.percentile(imgData, 90) # 98 + _min, _max = getAutoContrast(_imgDataDisplay) # new 20240925, should mimic ImageJ + + logger.info(f'plotting {_file} _min:{_min} _max:{_max} \ + image min:{_imgDataDisplay.min()} \ + image max:{_imgDataDisplay.max()}') + + ax.imshow(imgData, + cmap="Greens", + origin='lower', + aspect='auto', + extent=extent, + vmin=_min, + vmax=_max, + ) + + ax.xaxis.set_tick_params(which='both', labelbottom=False) + sns.despine(bottom=True, left=True, ax=ax) + + # plot all roi on full kym img + plotRoiRects(ax, kymRoiAnalysis) + +def plotTif(path:str, + ax:Axes, + channel=0, + f0:float=None, + detectThisTrace:str = None, + roiRect:List[int] = None, + cond:str = None, # debug + roiLabelStr:str = None, + secondsPerLine:float = None, + umPerPixel:float = None, # debug + ) -> np.ndarray: + """Plot clipped roi for one ROI and its tif + """ + from sanpy.bAnalysis_ import bAnalysis + ba = bAnalysis(path) + imgData = ba.fileLoader._tif # list of color channel images + imgData = imgData[channel] + + if f0 is not None: + if detectThisTrace == 'f/f0': + imgData = imgData / f0 + imgData = imgData.astype(np.int16) + elif detectThisTrace == 'df/f0': + imgData = (imgData - f0) / f0 + imgData = imgData.astype(np.int16) + else: + logger.error(f'detectThisTrace:{detectThisTrace} not supported') + return + + # _tifFile = os.path.split(path)[1] + + if roiRect is not None: + left = roiRect[0] + top = roiRect[1] + right = roiRect[2] + bottom = roiRect[3] + + # width = right - left + height = top - bottom + + # clip to [l, t, r, b] + imgData = imgData[bottom:top, left:right] + + # numcols = height + # numrows = width + if secondsPerLine is not None: + left_sec = left * secondsPerLine + right_sec = right * secondsPerLine + # top_um = top * umPerPixel + # bottom_um = bottom * umPerPixel + # extent=[left_sec, right_sec, bottom_um, top_um] # [left, right, top, bottom] + extent=[left_sec, right_sec, 0, height] # [left, right, top, bottom] + else: + extent=[left, right, 0, height] # [left, right, top, bottom] + + + imgMin, imgMax = getAutoContrast(imgData) # new 20240925, should mimic ImageJ + + # plot the roi kym, normalizaition is critical + ax.imshow(imgData, + cmap="Greens", + origin='lower', + aspect='auto', + extent=extent, + vmin=imgMin, + vmax=imgMax, + ) + + # despine bottom and left + if 0: + ax.spines['bottom'].set_visible(False) + ax.set_xticks([]) # Removes x-axis tick marks and labels + + ax.spines['left'].set_visible(False) + ax.set_yticks([]) # Removes x-axis tick marks and labels + + # this removes labels, not ticks + ax.xaxis.set_tick_params(which='both', labelbottom=False) + sns.despine(bottom=True, left=True, ax=ax) + + # ax.set_aspect('auto') + return imgData + +def plotTif2(path:str, + ax:Axes, + channel=0, + ): + """Plot clipped roi for one ROI and its tif + """ + from sanpy.bAnalysis_ import bAnalysis + ba = bAnalysis(path) + imgData = ba.fileLoader._tif # list of color channel images + imgData = imgData[channel] + + imgMin = imgData.min() + imgMax = imgData.max() + + imgMin = np.percentile(imgData, 2) + imgMax = np.percentile(imgData, 98) + + ax.imshow(imgData, + cmap="Greens", + origin='lower', + aspect='auto', + vmin=imgMin, + vmax=imgMax, + ) + + ax.xaxis.set_tick_params(which='both', labelbottom=False) + sns.despine(bottom=True, left=True, ax=ax) + +def plotRois(): + """Plot all rois f/f0 for one (cell id, condition, epoch) + + Use this to look at f/f0 for each roi in a kym. + """ + dfMaster = loadMasterDfFile() + dfMean = loadMeanDfFile() + + # list of cell id + cellIDs = dfMean['Cell ID'].unique() + for cellID in cellIDs: + # using new api + for row in iterate_unique_cell_rows(dfMean, cellID): + condition = row['Condition'] + epoch = row['Epoch'] + dateStr = row['Date'] + dateStr = str(dateStr) # dateStr is coming in as np.int64 (makes sense) + regionStr = row['Region'] + tifPath = row['Path'] + + _tifFile = os.path.split(tifPath)[1] + _tifFile, _ = os.path.splitext(_tifFile) + + # logger.info(f'cellID:"{cellID}" condition:"{condition}" epoch:"{epoch}" Date:"{row["Date"]}"') + + # plot all roi for one kym + fig, ax = plotOneKym(tifPath) + + # save the figure + pdfPath = getCellIdPdfFolder(pdfOutputType='Per Cell ROI Plots', + cellId=cellID, + dateStr=dateStr, + regionStr=regionStr) + # pdfFilePath = os.path.join(pdfPath, f'{cellID} ROIs.pdf') + pdfFilePath = os.path.join(pdfPath, f'{_tifFile} ROIs.pdf') + logger.info(f'saving pdfFilePath:{pdfFilePath}') + + plt.savefig(pdfFilePath, format="pdf", dpi=300) + plt.close() + +def _exportClips(cellID): + """Export image clips and traces. + + Traverse all conditions for one cell id and roi label + """ + + logger.info('') + + channel = 0 + + dfMean = loadMeanDfFile() + + theseRows = (dfMean['Cell ID']==cellID) \ + & (dfMean['Condition']==condition) \ + & (dfMean['Epoch']==epoch) + dfMeanOne = dfMean.loc[theseRows] + + regionStr = dfMeanOne.iloc[0]['Region'] + dateStr = dfMeanOne.iloc[0]['Date'] + dateStr = str(dateStr) # dateStr is coming in as np.int64 (makes sense) + tifPath = dfMeanOne.iloc[0]['Path'] + + pdfPath = getCellIdPdfFolder(pdfOutputType='Per Cell Cond Plots', + cellId=cellID, + dateStr=dateStr, + regionStr=regionStr) + # clipsPath = os.path.join(pdfPath, 'clips') + # os.makedirs(clipsPath, exist_ok=True) + + # logger.info(f'region:{regionStr} date:{dateStr} conditions:{conditions}') + + # we will save one csv with columns across each condition + dfSaveTraces = pd.DataFrame() + # load the kymRoiAnalysis + ka = _loadKymRoiAnalysis(tifPath) + for roiLabelStr in dfMeanOne['ROI Number'].unique(): + kymRoi = ka.getRoi(roiLabelStr) + roiImgClipsDict = kymRoi.getRoiImgClips(channel) + # logger.info(f'roiImgClipsDict:{roiImgClipsDict.keys()}') + + # main pdf across conditions is like: + # 250225 ISAN R1 LS1 ROI 1 + clipFolder = f'{cellID} ROI {roiLabelStr}' + clipFolderPath = os.path.join(pdfPath, clipFolder) + os.makedirs(clipFolderPath, exist_ok=True) + + # theseClips = ['raw', 'f_f0', 'df_f0'] + theseClips = ['raw', 'f_f0', 'df_f0'] + for thisClip in theseClips: + # logger.info(f'thisClip:{thisClip}') + # logger.info(f'clip:{clip.shape}') + + # # save the clip + clipFile = f'{cellID} ROI {roiLabelStr} {condition} {thisClip}.tif' + clipPath = os.path.join(clipFolderPath, clipFile) + # logger.info(f' saving thisClip:{thisClip} to clipPath: {clipPath}') + clip = roiImgClipsDict[thisClip] + tifffile.imwrite(clipPath, clip) + + # + # just use kymroianalysis api + + logger.error('TODO: use kymroianalysis api') + return + + # save one df with f/f_0 for each condition + # logger.info(f'dfAnalysisResults:{dfAnalysisResults.columns}') + oneTraceCondition = _traces.getTraceDf(cellID, condition) + # grab something like "ROI 3 f/f0" + # ROI 1 Time (s) + _time_key = f'ROI {roiLabelStr} Time (s)' # redundant + _f_f0_key = f'ROI {roiLabelStr} f/f0' + _time = oneTraceCondition[_time_key] + _f_f0 = oneTraceCondition[_f_f0_key] + # make a df with just time and f_f0 + dfSaveTraces[_time_key] = _time # redundant + _cond_f_f0_key = f'{_f_f0_key} {condition} ' + dfSaveTraces[_cond_f_f0_key] = _f_f0 + + # save dfSaveTraces + dfTraceFile = f'{cellID} ROI {roiLabelStr} traces.csv' + # logger.info(f' saving dfTraceFile:{dfTraceFile}') + dfSaveTraces.to_csv(os.path.join(clipFolderPath, dfTraceFile), index=False) + +def new_plotCellID(dfMaster, + dfMean, + cellID, + roiLabelStr:int) -> tuple[plt.Figure, plt.Axes, dict]: + roiLabelInt = int(roiLabelStr) + channel = 0 + + # get rows across all Epoch, each row becomes a column + theseRows = (dfMean['Cell ID']==cellID) \ + & (dfMean['ROI Number']==roiLabelInt) + dfPlot = dfMean.loc[theseRows] + # plot each (cond, epoch) as a column + numCols = len(dfPlot) + if numCols < 3: + numCols = 3 + + linewidth = 1 + markersize = 4 # for overlay of (onset, peak, decay) + + # always 3x3 + fig, ax = plt.subplots(nrows=3, + ncols=numCols, + figsize=(11, 8), + height_ratios=[1, 2, 2], # shrink first row (kym image) + ) + + _dateStr = datetime.now().strftime("%Y%m%d %H:%M") + _suptitle = f'cell:"{cellID}" ROI:{roiLabelInt} saved:{_dateStr}' + fig.suptitle(_suptitle, fontsize=11) + + # despine top/right of each subplot + for oneAx in ax.flatten(): + sns.despine(top=True, right=True, ax=oneAx) + + imgRow = 0 + peakRow = 1 + plotRow = 2 + + # link x/y axis + for colIdx in range(numCols): + if colIdx > 0: + # all columns in imgRow share y axis + ax[imgRow][colIdx].sharey(ax[imgRow][colIdx-1]) + # all columns in peakRow share y axis + ax[peakRow][colIdx].sharey(ax[peakRow][colIdx-1]) + + # each column in peakRow shares x axis with same column in imgRow + ax[peakRow][colIdx].sharex(ax[imgRow][colIdx]) + + # iterate through rows in dfPlot (one column per row) + # logger.info('dfPlot is:') + # print(dfPlot) + + # return a dict with imgData and x/y traces + imgDataDict = {} + + for rowIdx, (rowLabel, row) in enumerate(dfPlot.iterrows()): + colIdx = rowIdx # each row in df corresponds to a column + cond = row['Condition'] + epoch = row['Epoch'] + # tifFile = row['Tif File'] + tifPath = row['Path'] + + axImg = ax[imgRow][colIdx] + + # load the kymRoiAnalysis + ka = _loadKymRoiAnalysis(tifPath) + kymRoi:KymRoi = ka.getRoi(roiLabelStr) + + secondsPerLine = kymRoi.secondsPerLine + umPerPixel = kymRoi.umPerPixel + from sanpy.kym.kymRoiAnalysis import PeakDetectionTypes + _detectionParams = kymRoi.getDetectionParams(channel, PeakDetectionTypes.intensity) + detectThisTrace = _detectionParams['detectThisTrace'] + f0_value_percentile = _detectionParams['f0 Value Percentile'] + polarity = _detectionParams['Polarity'] + roiRect = _detectionParams['ltrb'] + + logger.info(f' cond:{cond} epoch:{epoch} roiRect:{roiRect} f0:{f0_value_percentile}') + + imgData = plotTif(tifPath, + ax=axImg, + f0=f0_value_percentile, + detectThisTrace=detectThisTrace, + roiRect=roiRect, + secondsPerLine=secondsPerLine, + umPerPixel=umPerPixel, + cond=cond, + roiLabelStr=roiLabelStr, + ) + + axImg.set_title(f'"{cond} {epoch}" f0:{round(f0_value_percentile,1)} {polarity}', + fontsize=10) + + # turn x-axis labels/ticks back on + ax[peakRow][colIdx].xaxis.set_tick_params(which='both', labelbottom=True) + + # plot sum intensity like f/f_0 + xPlot = kymRoi.getTrace(channel, 'Time (s)') + yPlot = kymRoi.getTrace(channel, detectThisTrace) + + # collect into return dict + # info for one (Cell id, cond, epoch, roi label) + f_f0 = kymRoi.getTrace(channel, 'f/f0') + df_f0 = kymRoi.getTrace(channel, 'df/f0') + raw = kymRoi.getTrace(channel, 'intRaw') + imgDataDict[tifPath] = { + 'roiLabelStr': roiLabelStr, + 'imgDataClip': imgData, + 'Time (s)': xPlot, + 'intRaw': raw, + 'f_f0': f_f0, + 'df_f0': df_f0, + } + + ax[peakRow][colIdx].plot(xPlot, yPlot, + color='g', + linewidth=linewidth, + ) + + if colIdx == 0: + ax[peakRow][colIdx].set_ylabel(detectThisTrace) + + # overlay peak detection (onset, peak, decay) from dfMaster + dfMasterOne = dfMaster.loc[dfMaster['Cell ID']==cellID] + dfMasterOne = dfMasterOne.loc[dfMasterOne['Condition']==cond] + dfMasterOne = dfMasterOne.loc[dfMasterOne['Epoch']==epoch] + dfMasterOne = dfMasterOne.loc[dfMasterOne['ROI Number']==roiLabelStr] + + # onset + xPlot = dfMasterOne['Onset (s)'] + yPlot = dfMasterOne['Onset Int'] + ax[peakRow][colIdx].plot(xPlot, yPlot, 'co', markersize=markersize) + + # peak + xPlot = dfMasterOne['Peak (s)'] + yPlot = dfMasterOne['Peak Int'] + ax[peakRow][colIdx].plot(xPlot, yPlot, 'ro', markersize=markersize) + + # decay + xPlot = dfMasterOne['Decay (s)'] + yPlot = dfMasterOne['Decay Int'] + ax[peakRow][colIdx].plot(xPlot, yPlot, 'mo', markersize=markersize) + + # plot ystat across conditions + plotStatSwarm(dfMean, 'Number of Spikes', cellID, epoch, roiLabelStr, ax[plotRow][0]) + + # make 2x swarm plot (height, mass) + plotStatSwarm(dfMaster, 'Peak Height', cellID, epoch, roiLabelStr, ax[plotRow][1]) + plotStatSwarm(dfMaster, 'Area Under Peak', cellID, epoch, roiLabelStr, ax[plotRow][2]) + + if numCols > 3: + # fig.delaxes(ax[plotRow][3]) + ax[plotRow][3].set_visible(False) + + fig.tight_layout() + + return fig, ax, imgDataDict + + +def _plotQuality(): + """Plot quality of detection for one cell id, roi. + """ + _debug = False + + channel = 0 + + dfMean = loadMeanDfFile() + + cellIDs = dfMean['Cell ID'].unique() + + if _debug: + cellIDs = ['20250602 ISAN R1 LS3'] + + for cellID in cellIDs: + dfCellID = dfMean[dfMean['Cell ID'] == cellID] + + regionStr = dfCellID.iloc[0]['Region'] + dateStr = dfCellID.iloc[0]['Date'] + dateStr = str(dateStr) # dateStr is coming in as np.int64 (makes sense) + tifPath = dfCellID.iloc[0]['Path'] + + roiLabels = dfCellID['ROI Number'].unique() + logger.info(f'cellID:{cellID} roiLabels:{roiLabels}') + + kymRoiAnalysis = _loadKymRoiAnalysis(tifPath) + + # one plot per tif file roi + for roiLabelStr in roiLabels: + fig, ax = plotDetectionResults(kymRoiAnalysis, roiLabelStr, channel) + + pdfPath = getCellIdPdfFolder(pdfOutputType='ROI Quality Plots', + cellId=cellID, + dateStr=dateStr, + regionStr=regionStr) + _tifPath, _tifFile = os.path.split(tifPath) + _tifFile, _ = os.path.splitext(_tifFile) + pdfFilePath = os.path.join(pdfPath, f'{_tifFile} ROI {roiLabelStr}.pdf') + # print(f'saving pdfPath:{pdfPath}') + if _debug: + pass + else: + plt.savefig(pdfFilePath, format="pdf") + plt.close() + + if _debug: + plt.show() + +def plotCellID(): + """ Plot one pdf for each (cell id, roi), plot a column for each condition. + + Now with 'Epoch', we have one column for each (Condition, Epoch). + """ + _debug = False + + dfMaster = loadMasterDfFile() + dfMean = loadMeanDfFile() + + # abb depreciate this and just load kymRoiAnalysis? + # _traces = ColinTraces(dfMaster, dfMean) + + numCellID = 0 + cellIDs = dfMean['Cell ID'].unique() + logger.info(f'plotting {len(cellIDs)} cell ids') + + if _debug: + cellIDs = ['20250602 ISAN R1 LS3'] + + for cellID in cellIDs: + dfCellID = dfMean[dfMean['Cell ID'] == cellID] + + regionStr = dfCellID.iloc[0]['Region'] + dateStr = dfCellID.iloc[0]['Date'] + dateStr = str(dateStr) # dateStr is coming in as np.int64 (makes sense) + + # logger.info(f'dateStr:"{dateStr}" {type(dateStr)}') + # sys.exit(1) + + roiLabels = dfCellID['ROI Number'].unique() + logger.info(f'cellID:{cellID} roiLabels:{roiLabels}') + + for roiLabelStr in roiLabels: + # plot one cell id across all (conditions, epochs) + fig, ax, imgDataDict = \ + new_plotCellID(dfMaster, dfMean, cellID, roiLabelStr=roiLabelStr) + # fig, ax = plotCellID(dfMaster=dfMaster, dfMean=dfMean, cellID=cellID, roiLabelStr=roiLabelStr) + if fig is None: + logger.warning('did not plot?') + continue + + # save the figure + pdfPath = getCellIdPdfFolder(pdfOutputType='Per Cell Cond Plots', + cellId=cellID, + dateStr=dateStr, + regionStr=regionStr) + pdfFilePath = os.path.join(pdfPath, f'{cellID} ROI {roiLabelStr}.pdf') + # print(f'saving pdfPath:{pdfPath}') + if _debug: + pass + else: + plt.savefig(pdfFilePath, format="pdf") + plt.close() + + """ + imgDataDict[tifPath] = { + 'roiLabelStr': roiLabelStr, + 'imgDataClip': imgData, + 'Time (s)': xPlot, + 'intRaw': raw, + 'f_f0': f_f0, + 'df_f0': df_f0, + } + """ + # make a new folder and save: tif clips and plotted detectThisTrace + cellIdPath = os.path.join(pdfPath, cellID) + os.makedirs(cellIdPath, exist_ok=True) + _roiDir = os.path.join(cellIdPath, f'ROI {roiLabelStr}') + os.makedirs(_roiDir, exist_ok=True) + for _tif, _dict in imgDataDict.items(): + # fileInfo = FileInfo.from_path(_tif) + imgDataClip = _dict['imgDataClip'] + + _tifBase = os.path.split(_tif)[1] + _tifBase, _ = os.path.splitext(_tifBase) + _tifBase += f' ROI {roiLabelStr}' + # make df from (raw, f_f0, df_f0) + _df = pd.DataFrame() + _df['Time (s)'] = _dict['Time (s)'] + _df['raw'] = _dict['intRaw'] + _df['f_f0'] = _dict['f_f0'] + _df['df_f0'] = _dict['df_f0'] + _tifBase = os.path.join(_roiDir, _tifBase) + print(f'_tifBase:{_tifBase}') + _df.to_csv(_tifBase + '.csv', index=False) + tifffile.imwrite(_tifBase + '.tif', imgDataClip) + + # + # _exportClips(cellID) + + numCellID += 1 + + # if numCellID > 2: + # break + + if _debug: + plt.show() + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description='SanPy kymograph plotting tools') + parser.add_argument('command', + choices=['plotcellid', 'plotrois', 'plotquality'], + help='Which plotting function to run') + + args = parser.parse_args() + + if args.command == 'plotcellid': + # one pdf for a cell id across conditions and folder of clips, traces + plotCellID() + elif args.command == 'plotrois': + # plot all rois f/f0 for one (cell id, condition) + plotRois() + elif args.command == 'plotquality': + # plot quality of detection for one cell id, roi + _plotQuality() + else: + print(f"Unknown command: {args.command}") + print("Available commands: plotcellid, plotrois, plotquality") \ No newline at end of file diff --git a/sanpy/kym/simple_scatter/colin_scatter_widget.py b/sanpy/kym/simple_scatter/colin_scatter_widget.py new file mode 100644 index 00000000..5a0bfa6b --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_scatter_widget.py @@ -0,0 +1,1598 @@ +import os +import sys +from functools import partial +from itertools import combinations +from typing import Optional, List + +import pandas as pd +import numpy as np + +from scipy.stats import variation + +from matplotlib.backends import backend_qt5agg +import matplotlib as mpl +import matplotlib.pyplot as plt + +import seaborn as sns +# sns.set_palette("colorblind") + +from statannotations.Annotator import Annotator + +from PyQt5 import QtGui, QtCore, QtWidgets + +# from colin_traces import ColinTraces +from colin_tree_widget import KymTreeWidget +# from colin_traces import plotCellID +from colin_global import conditionOrder +from colin_simple_figure import KymRoiMainWindow + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class simpleTableWidget(QtWidgets.QTableWidget): + def __init__(self, df : Optional[pd.DataFrame] = None): + super().__init__(None) + self._df = df + if self._df is not None: + self.setDf(self._df) + + def setDf(self, df : pd.DataFrame): + self._df = df + + self.setRowCount(df.shape[0]) + self.setColumnCount(df.shape[1]) + self.setHorizontalHeaderLabels(df.columns) + + for row in range(df.shape[0]): + for col in range(df.shape[1]): + item = QtWidgets.QTableWidgetItem(str(df.iloc[row, col])) + self.setItem(row, col, item) + +def _makeComboBox(name:str, + items: List[str], + defaultItem:Optional[str]=None, + callbackFn=None + ) -> QtWidgets.QHBoxLayout: + hBoxLayout = QtWidgets.QHBoxLayout() + aLabel = QtWidgets.QLabel(name) + hBoxLayout.addWidget(aLabel) + + aComboBox = QtWidgets.QComboBox() + hBoxLayout.addWidget(aComboBox) + + aComboBox.addItems(items) + + if defaultItem is not None: + _index = items.index(defaultItem) + else: + # default to first + _index = 0 + aComboBox.setCurrentIndex(_index) + + if callbackFn is not None: + aComboBox.currentTextChanged.connect( + partial(callbackFn, name) + ) + + return hBoxLayout + +def _getNumSpikesDf(df) -> pd.DataFrame: + dictList = [] + for oneCellID in df['Cell ID'].unique(): + logger.info(f'oneCellStr:{oneCellID}') + oneDf = df[df['Cell ID'] == oneCellID] + + _oneRegion = oneDf['Region'].iloc[0] + + numSpikesControl = 0 + for oneCond in conditionOrder: + oneCondDf = oneDf[oneDf['Condition'] == oneCond] + # print(oneCondDf) + numSpikes = len(oneCondDf) + # print(f'numSpikes:{numSpikes}') + # if numSpikes == 0: + # logger.error(f'0 spikes oneCellStr:{oneCellID} cond:{oneCond}') + # continue + if oneCond == 'Control': + numSpikesControl = numSpikes + percentChange = 100 + else: + if numSpikesControl == 0: + percentChange = np.nan + else: + percentChange = numSpikes / numSpikesControl * 100 + + # if numSpikes == 0: + # oneFreq = np.nan + # else: + # from colin_peak_freq import getSimpleFreq + # tifPath = oneCondDf['tifPath'].iloc[0] + # oneFreq = getSimpleFreq(tifPath, csvPath) + + oneDict = { + 'Cell ID': oneCellID, + 'Region': _oneRegion, + 'Condition': oneCond, + 'Num Spikes': numSpikes, + 'percentChange': percentChange, + } + # print(oneDict) + dictList.append(oneDict) + + dfNumSpikes = pd.DataFrame(dictList) + print('=== dfNumSpikes:') + print(dfNumSpikes) + + # + # run stats on num spikes + from scipy.stats import mannwhitneyu + # 1) isan vs ssan in control + iSanSpikes = dfNumSpikes + iSanSpikes = iSanSpikes[iSanSpikes['Region']=='ISAN'] + iSanControlSpikes = iSanSpikes[iSanSpikes['Condition']=='Control'] + iSanIvabSpikes = iSanSpikes[iSanSpikes['Condition']=='Ivab'] + # + sSanSpikes = dfNumSpikes + sSanSpikes = sSanSpikes[sSanSpikes['Region']=='SSAN'] + sSanControlSpikes = sSanSpikes[sSanSpikes['Condition']=='Control'] + sSanIvabSpikes = sSanSpikes[sSanSpikes['Condition']=='Ivab'] + + # compare control and ivan (for each of isan and ssan) + # ssan + sample1 = sSanControlSpikes['Num Spikes'].to_list() + sample2 = sSanIvabSpikes['Num Spikes'].to_list() + if len(sample1)==0 or len(sample2)==0: + pass + else: + print('=== mannwhitneyu comparing num spikes in Control versus Ivab (SSAN)') + result = mannwhitneyu(sample1, sample2) + print(result) + + # isan + sample1 = iSanControlSpikes['Num Spikes'].to_list() + sample2 = iSanIvabSpikes['Num Spikes'].to_list() + if len(sample1)==0 or len(sample2)==0: + pass + else: + print('=== mannwhitneyu comparing num spikes in Control versus Ivab (ISAN)') + result = mannwhitneyu(sample1, sample2) + print(result) + + # compare isan to ssan control spikes + sample1 = iSanControlSpikes['Num Spikes'].to_list() + sample2 = sSanControlSpikes['Num Spikes'].to_list() + logger.warning(f'sample1:{len(sample1)} sample2:{len(sample2)}') + if len(sample1)==0 or len(sample2)==0: + pass + else: + print('=== mannwhitneyu comparing num spikes in control (SSAN vs ISAN)') + result = mannwhitneyu(sample1, sample2) + print(result) + + return dfNumSpikes + +class ScatterOptions(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Scatter Options") + self.setGeometry(100, 100, 300, 200) + + # Create layout and widgets here + layout = QtWidgets.QVBoxLayout() + self.setLayout(layout) + + # Add widgets to the layout + label = QtWidgets.QLabel("Scatter Options") + layout.addWidget(label) + +class ScatterWidget(QtWidgets.QMainWindow): + """Plot a scatter plot from peak/diameter detection df. + """ + + # TODO emit signal on user selection + def __init__(self, + masterDf : pd.DataFrame, + meanDf : pd.DataFrame, + xStat:str, + yStat:str, + hueList: List[str], + defaultPlotType:str = None, + defaultHue:str = None, + defaultStyle:str = None, + imgFolder:str = None, # to load sm2 images + plotColumns = None + ): + super().__init__(None) + + # logger.error('problems with tif Path') + # print(df['Path'].iloc[0]) + # sys.exit() + + # always construct from master (one peak per row) + # self._colinTraces = ColinTraces(masterDf, meanDf) + + self._masterDf = masterDf + self._meanDf = meanDf + + # columns in analysis df + if plotColumns is None: + # all columns + self._columns = list(masterDf.columns) # reduce analysis keys, just for the user + else: + # only show these columns + self._columns = plotColumns + + self._hueList = hueList + + # for sm2 output sparkmaster + self._imgFolder = imgFolder + + self._plotColumns = plotColumns + + self._plotTypes = ['Line Plot', + 'Scatter', + 'Swarm', + 'Swarm + Mean', + 'Swarm + Mean + STD', + 'Swarm + Mean + SEM', + 'Box Plot', + 'Histogram', + 'Cumulative Histogram'] + + self._state = { + 'xStat': xStat, + 'yStat': yStat, + 'hue': hueList[0] if defaultHue is None else defaultHue, + 'style': hueList[0] if defaultStyle is None else defaultHue, + 'plotType': 'Scatter' if defaultPlotType is None else defaultPlotType, + # 'Swarm + Mean', + 'makeSquare': False, + 'legend': True, + # 'tables': True, + 'stats': True, + 'plotDf': 'Mean', + 'plotStat': 'Mean', # either mean(default) or _cv + } + + # re-wire right-click (for entire widget) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + self._buildUI() + + # enable/disable combobox(es) based on plot type + self._updateGuiOnPlotType(self._state['plotType']) + + self.replot() + + def getDf(self): + if self.state['plotDf'] == 'Raw': + return self._masterDf + elif self.state['plotDf'] == 'Mean': + return self._meanDf + else: + logger.error(f"did not understand state plotDf {self.state['plotDf']}") + + def _buildUI(self): + + self.status_bar = self.statusBar() + self.setStatusBar('started ...') + + # this is dangerous, collides with self.mplWindow() + self.fig = mpl.figure.Figure() + # self.static_canvas = backend_qt5agg.FigureCanvas(self.fig) + self.static_canvas = backend_qt5agg.FigureCanvasQTAgg(self.fig) + self.static_canvas.setFocusPolicy( + QtCore.Qt.ClickFocus + ) # this is really tricky and annoying + self.static_canvas.setFocus() + # self.axs[idx] = self.static_canvas.figure.add_subplot(numRow,1,plotNum) + + # abb 202505 removed + # self.gs = self.fig.add_gridspec( + # 1, 1, left=0.1, right=0.9, bottom=0.1, top=0.9, wspace=0.05, hspace=0.05 + # ) + + # redraw everything + self.static_canvas.figure.clear() + + # self.axScatter = self.static_canvas.figure.add_subplot(self.gs[0, 0]) + self.axScatter = self.static_canvas.figure.add_subplot(1,1,1) + + # despine top/right + self.axScatter.spines["right"].set_visible(False) + self.axScatter.spines["top"].set_visible(False) + # self.axScatter = None + + self.fig.canvas.mpl_connect("key_press_event", self.keyPressEvent) + + self.mplToolbar = mpl.backends.backend_qt5agg.NavigationToolbar2QT( + self.static_canvas, self.static_canvas + ) + + # put toolbar and static_canvas in a V layout + # plotWidget = QtWidgets.QWidget() + vLayoutPlot = QtWidgets.QVBoxLayout(self) + aWidget = QtWidgets.QWidget() + aWidget.setLayout(vLayoutPlot) + self.setCentralWidget(aWidget) + # vLayoutPlot.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + self._topToolbar = self._buildTopToobar() + vLayoutPlot.addLayout(self._topToolbar) + + # Create a horizontal splitter for resizable left toolbar and plot + self._mainSplitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + self._mainSplitter.setHandleWidth(8) # Make the splitter handle wider and more visible + self._mainSplitter.setStyleSheet(""" + QSplitter::handle { + background-color: #cccccc; + border: 1px solid #999999; + } + QSplitter::handle:hover { + background-color: #aaaaaa; + } + """) + vLayoutPlot.addWidget(self._mainSplitter) + + # Build left toolbar as a widget + self._leftToolbarWidget = self._buildLeftToobar() + self._mainSplitter.addWidget(self._leftToolbarWidget) + + # Add the matplotlib canvas to the splitter + self._mainSplitter.addWidget(self.static_canvas) + + # Set initial splitter sizes (left toolbar gets 300px, rest goes to plot) + self._mainSplitter.setSizes([300, 800]) + + # + # raw and y-stat summary in tabs + self._tabwidget = QtWidgets.QTabWidget() + + self.rawTableWidget = simpleTableWidget(self.getDf()) + self._tabwidget.addTab(self.rawTableWidget, "Raw") + + self.yStatSummaryTableWidget = simpleTableWidget() + self._tabwidget.addTab(self.yStatSummaryTableWidget, "Y-Stat Summary") + + self._tabwidget.setCurrentIndex(0) + + self._tabwidget.setVisible(False) + + vLayoutPlot.addWidget(self._tabwidget) + + # + self.static_canvas.draw() + + @property + def state(self): + return self._state + + def copyTableToClipboard(self): + _tabIndex = self._tabwidget.currentIndex() + if _tabIndex == 0: + logger.info('=== copy raw to clipboard ===') + self.getDf().to_clipboard(sep="\t", index=False) + _ret = 'Copied raw table to clipboard' + elif _tabIndex == 1: + logger.info('=== copy summary to clipboard ===') + self._dfYStatSummary.to_clipboard(sep="\t", index=False) + _ret = 'Copied y-stat-summary table to clipboard' + else: + logger.warning(f'did not understand tab: {_tabIndex}') + _ret = 'Did not copy, please select a table' + return _ret + + def _buildLeftToobar(self) -> QtWidgets.QWidget: + # Create a widget to hold the layout + leftToolbarWidget = QtWidgets.QWidget() + leftToolbarWidget.setMinimumWidth(200) # Set minimum width for the toolbar + leftToolbarWidget.setMaximumWidth(600) # Set maximum width for the toolbar + + vBoxLayout = QtWidgets.QVBoxLayout(leftToolbarWidget) + # vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + vBoxLayout.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + # hLayout for ['Regions', 'Conditions'] + regionConditionHBox = QtWidgets.QHBoxLayout() + vBoxLayout.addLayout(regionConditionHBox) + + # + # Regions group box + regionsGroupBox = QtWidgets.QGroupBox('Regions') + regionConditionHBox.addWidget(regionsGroupBox) + + # one checkbox for each Region + regionsVLayout = QtWidgets.QVBoxLayout() + regionsGroupBox.setLayout(regionsVLayout) + + regions = self.getDf()['Region'].unique() + for regionStr in regions: + regionCheckBox = QtWidgets.QCheckBox(regionStr) + regionCheckBox.setChecked(True) + regionCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Region', regionStr) + ) + regionsVLayout.addWidget(regionCheckBox) + + # + # conditions group box + conditionsGroupBox = QtWidgets.QGroupBox('Conditions') + regionConditionHBox.addWidget(conditionsGroupBox) + + # one checkbox for each Condition + conditionsVLayout = QtWidgets.QVBoxLayout() + conditionsGroupBox.setLayout(conditionsVLayout) + + conditions = sorted(self.getDf()['Condition'].unique()) + for conditionStr in conditions: + # each condition has a hlayout with epoch checkboxes + conditionHLayout = QtWidgets.QHBoxLayout() + conditionsVLayout.addLayout(conditionHLayout) + + conditionCheckBox = QtWidgets.QCheckBox(conditionStr) + conditionCheckBox.setChecked(True) + conditionCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Condition', conditionStr) + ) + conditionHLayout.addWidget(conditionCheckBox) + + # one checkbox for each Epoch + theseRows = (self.getDf()['Condition']==conditionStr) + theseEpochs = sorted(self.getDf()[theseRows]['Epoch'].unique()) + for epochInt in theseEpochs: + epochStr = str(epochInt) + epochCheckBox = QtWidgets.QCheckBox(epochStr) + epochCheckBox.setChecked(True) + + # if condition has 1 epoch, disable + if len(theseEpochs) == 1: + epochCheckBox.setEnabled(False) + + epochCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Epoch', conditionStr, epochStr) + ) + conditionHLayout.addWidget(epochCheckBox) + + # + # polarity group box + polarityGroupBox = QtWidgets.QGroupBox('Polarity') + regionConditionHBox.addWidget(polarityGroupBox) + + # one checkbox for each Condition + polaritiesVLayout = QtWidgets.QVBoxLayout() + polarityGroupBox.setLayout(polaritiesVLayout) + + polarities = sorted(self.getDf()['Polarity'].unique()) + for polarityStr in polarities: + polarityCheckBox = QtWidgets.QCheckBox(polarityStr) + polarityCheckBox.setChecked(True) + polarityCheckBox.stateChanged.connect( + partial(self._on_condition_checkbox, 'Polarity', polarityStr) + ) + polaritiesVLayout.addWidget(polarityCheckBox) + + + # + # cell id group box + cellGroupBox = QtWidgets.QGroupBox('Cells') + vBoxLayout.addWidget(cellGroupBox) + + # one checkbox for each Condition + cellVLayout = QtWidgets.QVBoxLayout() + cellGroupBox.setLayout(cellVLayout) + + # All checkbox + # cellCheckBox = QtWidgets.QCheckBox('All') + # cellCheckBox.setChecked(True) + # cellCheckBox.stateChanged.connect( + # partial(self._on_cell_checkbox, 'All') + # ) + # cellVLayout.addWidget(cellCheckBox) + + # one checkbox per cell id + # populate with mean df + _df = self._meanDf + kymTreeWidget = KymTreeWidget(_df) + kymTreeWidget.toggleAllToggled.connect(self.slot_toggle_all_cell_id) + kymTreeWidget.cellToggled.connect(self.slot_toggle_cell) + kymTreeWidget.roiToggled.connect(self.slot_toggle_roi) + kymTreeWidget.roiSelected.connect(self.slot_roi_selected) + kymTreeWidget.cellSelected.connect(self.slot_cell_selected) + kymTreeWidget.plotCellID.connect(self.slot_plot_cell_id) + cellVLayout.addWidget(kymTreeWidget) + + return leftToolbarWidget + + def slot_toggle_all_cell_id(self, value: bool): + """Handle toggle all checkbox.""" + self.getDf()['show_cell'] = value + self.getDf()['show_roi'] = value + self.replot() + + def slot_toggle_cell(self, cell_id: str, checked: bool): + """Handle cell checkbox toggle.""" + df = self.getDf() + theseRows = (df['Cell ID']==cell_id) + df.loc[theseRows, 'show_cell'] = checked + self.replot() + + def slot_toggle_roi(self, cell_id: str, roi_number: int, checked: bool): + """Handle ROI checkbox toggle.""" + df = self.getDf() + theseRows = (df['Cell ID']==cell_id) & (df['ROI Number']==roi_number) + df.loc[theseRows, 'show_roi'] = checked + self.replot() + + def slot_cell_selected(self, cell_id: str, condition: str): + logger.info(f'cell_id:"{cell_id}" condition:"{condition}"') + + def slot_plot_cell_id(self, cell_id: str, roi_number: int): + logger.info(f'cell_id:"{cell_id}" roi_number:{roi_number}') + + # fig, ax = self._colinTraces.plotCellID(cell_id, roiLabelStr=roi_number) + + from colin_pool_plot import new_plotCellID + fig, ax, _tmpDict = new_plotCellID(self._masterDf, self._meanDf, cell_id, roi_number) + + if fig is None or ax is None: + return + + self._mainWindow = MainWindow(fig, ax) + self._mainWindow.setWindowTitle(f'cell ID:"{cell_id}" roi:{roi_number}') + self._mainWindow.show() + + def slot_roi_selected(self, cell_id: str, condition: str, roi_number: int): + # logger.info(f'cellID:"{cell_id}" condition:{condition} roiLabelInt:{roi_number} -->> plotCellID()') + # logger.info('-->> off') + logger.info('TODO: select roi peaks in plot !!!') + pass + + def _buildTopToobar(self) -> QtWidgets.QVBoxLayout: + vBoxLayout = QtWidgets.QVBoxLayout() + # vBoxLayout.setAlignment(QtCore.Qt.AlignTop) + + # row 1 + hBoxLayout = QtWidgets.QHBoxLayout() + hBoxLayout.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hBoxLayout) + + plotTypeComboBox = _makeComboBox(name='Plot Type', + items=self._plotTypes, + defaultItem=self._state['plotType'], + callbackFn=self._on_stat_combobox, + ) + hBoxLayout.addLayout(plotTypeComboBox) + + # plot type + # aName = 'Plot Type' + # aLabel = QtWidgets.QLabel(aName) + # hBoxLayout.addWidget(aLabel) + + # plotTypeComboBox = QtWidgets.QComboBox() + # plotTypeComboBox.addItems(self._plotTypes) + # _index = self._plotTypes.index('Scatter') # default to scatter plot + # plotTypeComboBox.setCurrentIndex(_index) + # plotTypeComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, aName) + # ) + # hBoxLayout.addWidget(plotTypeComboBox) + + # hue + _hueList = ['None'] + self._hueList + hueComboBox = _makeComboBox(name='Hue', + items=_hueList, + defaultItem=self._state['hue'], + callbackFn=self._on_stat_combobox, + ) + hBoxLayout.addLayout(hueComboBox) + + # hueName = 'Hue' + # hueLabel = QtWidgets.QLabel(hueName) + # hBoxLayout.addWidget(hueLabel) + + # self.hueComboBox = QtWidgets.QComboBox() + # _hueList = ['None'] + self._hueList + # self.hueComboBox.addItems(_hueList) + # # find index from self._defaultHue + # if self._state['hue'] in self._hueList: + # _index = self._hueList.index(self._state['hue']) + # else: + # _index = 0 + # self.hueComboBox.setCurrentIndex(_index) + # # self.hueComboBox.setCurrentIndex(1) # default to 'ROI Number' + # self.hueComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, hueName) + # ) + # hBoxLayout.addWidget(self.hueComboBox) + + # style + _hueList = ['None'] + self._hueList + styleCombo = _makeComboBox(name='Style', + items=_hueList, + callbackFn=self._on_stat_combobox, + ) + hBoxLayout.addLayout(styleCombo) + + # aName = 'Style' + # aLabel = QtWidgets.QLabel(aName) + # hBoxLayout.addWidget(aLabel) + + # self.styleComboBox = QtWidgets.QComboBox() + # _hueList = ['None'] + self._hueList + # self.styleComboBox.addItems(_hueList) + # # find index from self._defaultHue + # if self._state['style'] in self._hueList: + # _index = self._hueList.index(self._state['style']) + # else: + # _index = 0 + # self.styleComboBox.setCurrentIndex(_index) + # # self.hueComboBox.setCurrentIndex(1) # default to 'ROI Number' + # self.styleComboBox.currentTextChanged.connect( + # partial(self._on_stat_combobox, aName) + # ) + # hBoxLayout.addWidget(self.styleComboBox) + + # legend + legendCheckBox = QtWidgets.QCheckBox('Legend') + legendCheckBox.setChecked(True) + legendCheckBox.stateChanged.connect( + lambda state: self._on_stat_combobox('Legend', state) + ) + hBoxLayout.addWidget(legendCheckBox) + + # tables + aCheckbox = QtWidgets.QCheckBox('Tables') + aCheckbox.setChecked(False) + aCheckbox.stateChanged.connect( + lambda state: self._on_stat_combobox('Tables', state) + ) + hBoxLayout.addWidget(aCheckbox) + + # stats + aCheckbox = QtWidgets.QCheckBox('Stats') + aCheckbox.setChecked(True) + aCheckbox.stateChanged.connect( + lambda state: self._on_stat_combobox('Stats', state) + ) + hBoxLayout.addWidget(aCheckbox) + + # plot + aPushButton = QtWidgets.QPushButton('Replot') + aPushButton.setCheckable(False) + aPushButton.clicked.connect( + partial(self.replot) + ) + hBoxLayout.addWidget(aPushButton) + + # second row + hBoxLayout2 = QtWidgets.QHBoxLayout() + hBoxLayout2.setAlignment(QtCore.Qt.AlignLeft) + vBoxLayout.addLayout(hBoxLayout2) + + # + xName = 'X-Stat' + xLabel = QtWidgets.QLabel(xName) + hBoxLayout2.addWidget(xLabel) + + self.xComboBox = QtWidgets.QComboBox() + self.xComboBox.addItems(self._columns) + _index = self._columns.index(self.state['xStat']) + self.xComboBox.setCurrentIndex(_index) + self.xComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, xName) + ) + hBoxLayout2.addWidget(self.xComboBox) + + # + yName = 'Y-Stat' + yLabel = QtWidgets.QLabel(yName) + hBoxLayout2.addWidget(yLabel) + + self.yComboBox = QtWidgets.QComboBox() + self.yComboBox.addItems(self._columns) + _index = self._columns.index(self.state['yStat']) + self.yComboBox.setCurrentIndex(_index) + self.yComboBox.currentTextChanged.connect( + partial(self._on_stat_combobox, yName) + ) + hBoxLayout2.addWidget(self.yComboBox) + + # popup to plot (raw, mean) dataframe + hBox = _makeComboBox(name="Plot Data", + items=('Raw', 'Mean'), + defaultItem='Mean', + callbackFn=self._on_stat_combobox) + hBoxLayout2.addLayout(hBox) + + # popup to plot mean(default) or CV + hBox = _makeComboBox(name="Plot Stat", + items=('Mean', 'CV'), + defaultItem='Mean', + callbackFn=self._on_stat_combobox) + hBoxLayout2.addLayout(hBox) + + # + return vBoxLayout + + def _updateGuiOnPlotType(self, plotType): + """Enable/disable giu on plot type. + """ + # plot types turn on/off X-Stat + enableXStat = plotType == 'Scatter' + # self.xComboBox.setEnabled(enableXStat) + + def _on_stat_combobox(self, name : str, + value : str): + """Handle both combobox and checkbox changes + """ + logger.info(f'name:{name} value:{value}') + + if name == 'Plot Type': + self.state['plotType'] = value + self._updateGuiOnPlotType(self._state['plotType']) + + elif name == 'X-Stat': + self.state['xStat'] = value + + elif name == 'Y-Stat': + self.state['yStat'] = value + + elif name == 'Hue': + if value == 'None': + value = None + self.state['hue'] = value + + elif name == 'Style': + if value == 'None': + value = None + self.state['style'] = value + + elif name == 'Legend': + self.state['legend'] = value == QtCore.Qt.Checked + + elif name == 'Tables': + self._tabwidget.setVisible(value == QtCore.Qt.Checked) + # don't replot + return + + elif name == 'Stats': + # statistical tests on/off + self.state['stats'] = value == QtCore.Qt.Checked + + elif name == 'Plot Data': + # either master or mead dataframe + self.state['plotDf'] = value + + elif name == 'Plot Stat': + # either mean (default) or cv + self.state['plotStat'] = value + + else: + logger.warning(f'did not understand "{name}"') + + self.replot() + + def _on_condition_checkbox(self, name : str, + conditionStr : str, # either (Condition, Region) + epochStr : str = None, + value = None, # PyQt Checkbox value + ): + logger.info(f'name:{name} conditionStr:{conditionStr} value:{value}') + if name == 'Condition': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + self.getDf().loc[self.getDf()['Condition'] == conditionStr, 'show_condition'] = value + + self.replot() + + elif name == 'Region': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + self.getDf().loc[self.getDf()['Region'] == conditionStr, 'show_region'] = value + + self.replot() + + elif name == 'Polarity': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + self.getDf().loc[self.getDf()['Polarity'] == conditionStr, 'show_polarity'] = value + + self.replot() + + elif name == 'Epoch': + value = value == QtCore.Qt.Checked + # logger.info(f'name:{name} name: "{conditionStr}" value: {value}') + logger.info(f' conditionStr:"{conditionStr}" epochStr:"{epochStr}"') + epochInt = int(epochStr) + theseRows = (self.getDf()['Condition'] == conditionStr) \ + & (self.getDf()['Epoch'] == epochInt) + self.getDf().loc[theseRows, 'show_epoch'] = value + + self.replot() + + else: + logger.warning(f'did not understand name:"{name}') + + def _on_user_pick_scatterplot(self, event): + """ + event : matplotlib.backend_bases.PickEvent + """ + # logger.info(event) + logger.info(f' event.artist:{event.artist}') # matplotlib.collections.PathCollection + logger.info(f' event.ind:{event.ind}') + + # assuming scatterplot preserves index + ind = event.ind + dfRow = self.getDf().loc[ind] + print(dfRow) + + # todo: open image and overlay spark roi + # oneIdx = ind[0] # just the first ind + # self._sparkMasterShowImg(oneIdx) + + def _on_user_pick_lineplot(self, event): + logger.info('===') + logger.info(f'event.ind:{event.ind} event.artist:{event.artist}') + + def _on_user_pick_stripplot(self, event): + """ + Problem + ======= + If there is a nan value, like inst freq, our ind=ind+1 + """ + logger.info('===') + + try: + event.artist.label + except (AttributeError) as e: + logger.warning(f'event.artist.label failed: {e}') + return + + df = self._dfStripPlot + + xStat = self.state['xStat'] + yStat = self.state['yStat'] + hue = self.state['hue'] + # plotType = self.state['plotType'] + + dfNoNan = df[df[yStat].notna()] + + # print(f' xStat: {xStat} hue: {hue} plotType: {plotType}') + + # print(f' event.artist.label: "{event.artist.label}"') + # print(f' event.artist.animal: "{event.artist.animal}"') + # print(f' event.ind: {event.ind}') + + # CRITICAL TO STRIP NAN HERE IN CALLBACK !!! + df2 = dfNoNan[dfNoNan[hue] == event.artist.label] + colList = ['Cell ID', 'Region', 'Condition', 'ROI Number', xStat, yStat, hue] + # print(f'df2 from event.artist.label:"{event.artist.label}" is:') + # print(df2[colList]) + + # abb 20250519 THIS IS TOTALLY WRONG + # WTF DID I DO !!!! + # logger.info(f'=== user picked row ind :{event.ind}') + # print(df2[colList].iloc[event.ind]) + + # TODO: fix + df3 = df2[df2[xStat] == event.artist.animal] # animal is region (ssan, isan) + + # print(f'df3 from event.artist.animal:"{event.artist.animal}" is:') + # print(df3[colList]) + + # logger.info(f'picked gui event.ind:{event.ind}') + # try: + # print(df3[colList].iloc[event.ind]) + # except (IndexError) as e: + # logger.error('!!!!!') + # print(f'event.ind:{event.ind}') + # print(df3[colList]) + + # dfClicked = df3[colList].iloc[event.ind] + dfClicked = df3.iloc[event.ind] + print('user clicked -->>') + print(dfClicked[colList]) + # print(dfClicked.columns) + + # update status bar + cellID = dfClicked['Cell ID'].iloc[0] + condition = dfClicked['Condition'].iloc[0] + roiLabelStr = dfClicked['ROI Number'].iloc[0] + _str = f"Cell ID:'{cellID}' Condition:{condition} ROI Number: {roiLabelStr}" + self.setStatusBar(_str) + + modifiers = QtWidgets.QApplication.keyboardModifiers() + isShift = modifiers == QtCore.Qt.ShiftModifier + + # show raw analysis (first peak, user click can yield multiple) + if isShift: + clickedCellID = dfClicked['Cell ID'].iloc[0] + roiLabelStr = dfClicked['ROI Number'].iloc[0] + logger.info(f'-->> plotting cell id:"{clickedCellID}" roiLabelStr:{roiLabelStr}') + + # fig, ax = self._colinTraces.plotCellID(clickedCellID, roiLabelStr=roiLabelStr) + from colin_pool_plot import new_plotCellID + fig, ax = new_plotCellID(self._masterDf, self._meanDf, clickedCellID, roiLabelStr) + + if fig is None or ax is None: + return + + self._mainWindow = MainWindow(fig, ax) + self._mainWindow.setWindowTitle(f'cell ID:"{clickedCellID}" roi:{roiLabelStr}') + self._mainWindow.show() + + def _old_sparkMasterShowImg(self, ind): + """Open image from spark master + """ + if self._imgFolder is None: + logger.warning('imgFolder is None') + return + + logger.warning(f'ind:{ind} type:{type(ind)}') + + # assuming sm2 saved file has these !!!! + # get l,t,r,b + minRow = self._df.at[ind, 'bounding box min row (pixels)'] + maxRow = self._df.at[ind, 'bounding box max row (pixels)'] + minCol = self._df.at[ind, 'bounding box min column (pixels)'] + maxCol = self._df.at[ind, 'bounding box max column (pixels)'] + # like: minRow:203 maxRow:220 minCol:0 maxCol:15 + logger.info(f'minRow:{minRow} maxRow:{maxRow} minCol:{minCol} maxCol:{maxCol}') + + import matplotlib.patches as patches + # _x = minCol + # _y = minRow + # _width = maxCol - minCol + # _height = maxRow - minRow + + _x = minRow + _y = minCol + _width = maxRow - minRow + _height = maxCol - minCol + + _rect = patches.Rectangle((_x, _y), _width, _height, linewidth=1, edgecolor='r', facecolor='none') + logger.info(f'_rect is:{_rect}') + + imgFile = self._df.at[ind, 'File Name'] + # File Name is analysis .csv, convert to original + logger.warning('assuming raw data is png (usually tif)') + imgFile = os.path.splitext(imgFile)[0] + '.png' + + imgPath = os.path.join(self._imgFolder, imgFile) + logger.info(f'opening image: {imgPath}') + + logger.warning('defaulting to matplotlib imread -->> no good!')# import tifffile + # imgData = tifffile.imread(imgPath) + imgData = plt.imread(imgPath) + logger.info(f'lodaded imgData: {imgData.shape} {imgData.dtype}') + # convert float32 to int8 + imgData -= np.min(imgData) + imgData = imgData / np.max(imgData) * 255 + imgData = imgData.astype(np.uint8) + logger.info(f'now imgData: {imgData.shape} {imgData.dtype}') + + fig, ax = plt.subplots(1) + + _plot = ax.imshow(imgData) # AxesImage + # todo: add path to title + # _plot.setTitle(imgPath) + + ax.add_patch(_rect) + + plt.show() + + def linePlot(self, df, yStat, hue): + logger.info(f'=== yStat:{yStat} hue:{hue}') + + legend = self.state['legend'] + logger.warning(f'forcing xStat to "Condition"') + xStat = 'Condition' + hue_order = conditionOrder + try: + df[xStat] = pd.Categorical(df[xStat], categories=hue_order, ordered=True) + except (KeyError) as e: + logger.error(f'KeyError xStat:{xStat} avail col:{df.columns}') + myHue = 'Cell ID' # one line per cell across (C, i, t) + + if 1: + self.axScatter = sns.lineplot(data=df, + x="Condition", # want order ['c', 'i', 't'] + y=yStat, + hue=myHue, # one line per cell (x is control, ivab, thaps) + # style='Region', + style='ROI Number', + # hue_order=hue_order, + markers=True, + legend=legend, + # err_style="bars", # for mean df we have _std and _sem columns + # errorbar=("se", 1), + errorbar=None, + ax=self.axScatter) + + # dfPlot = df.groupby(['Cell ID', 'ROI Number']) + + _lines = self.axScatter.get_lines() + print('_lines') + print(_lines) + # Enable picking on each line + for line in self.axScatter.lines: + line.set_picker(5) # Enable picking + self.fig.canvas.mpl_connect("pick_event", self._on_user_pick_lineplot) + # self.axScatter.figure.canvas.mpl_connect("pick_event", self._on_user_pick_lineplot) + + # plot 2x plots for region (SSAN, ISAN) + if 0: + # relPlot return "FacetGrid" ??? + # self.axScatter = sns.relplot( + _ax = sns.relplot( + data=df, + x="Condition", + y=yStat, + col="Region", # 2x plots across (SSAN, ISAN) + hue=myHue, + # style="event", + kind="line", + # ax=self.axScatter, # not available in relplot + ) + logger.info(f'_ax:{_ax}') + plt.show() + + def replot(self): + """Replot the scatter plot + """ + + df = self.getDf() + # reduce based on show_condition and show_cell + df = df[df['show_region']] # include if true + df = df[df['show_condition']] # include if true + df = df[df['show_cell']] # include if true + df = df[df['show_roi']] # include if true + df = df[df['show_polarity']] # include if true + df = df[df['show_epoch']] # include if true + + # logger.info(f'plotting df with rows:{len(df)}') + # print(df) + + # store df for striplot callback _on_user_pick_striplot + self._dfStripPlot = df + + xStat = self.state['xStat'] + yStat = self.state['yStat'] + if self.state['plotStat'] == 'CV': + yStat = f'{yStat}_cv' + hue = None if self.state['hue']=='None' else self.state['hue'] + style = None if self.state['style']=='None' else self.state['style'] + plotType = self.state['plotType'] + legend = self.state['legend'] + + uniqueHue = [] if hue is None else df[hue].unique() + numHue = len(uniqueHue) + + if hue in ['Condition', 'Region', 'Condition Epoch']: + hue_order = sorted(df[hue].unique()) + else: + hue_order = None + + # if hue == 'Condition': + # # hue_order = conditionOrder + # hue_order = sorted(df[hue].unique()) + # elif hue == 'Region': + # # hue_order = regionOrder + # hue_order = sorted(df[hue].unique()) + # elif hue == 'Condition Epoch': + # hue_order = sorted(df[hue].unique()) + # else: + # hue_order = None + + if hue_order is not None and len(hue_order) == 0: + hue_order = None + + logger.info(f'hue:"{hue}" hue_order:"{hue_order}"') + + # dodge = False if numHue<=1 else 0.5 + dodge = True if numHue<=1 else 0.5 + + logger.info(f'numHue:{numHue} dodge:{dodge}') + + # print stats to cli + # from colin_summary import genStats + # logger.info(f'=== generating stats for "{yStat}"') + # genStats(df, yStat) + + # broken + logger.warning('turned off getGroupedDataframe') + if 0: + dfGrouped = self.getGroupedDataframe(yStat, groupByColumn=hue) + self._dfYStatSummary = dfGrouped + + if self.rawTableWidget is not None: + self.rawTableWidget.setDf(self.getDf()) + self.yStatSummaryTableWidget.setDf(dfGrouped) + + # sns.set_palette() + # numRoiNum = len(df['ROI Number'].unique()) + logger.warning('202505 turned off palette') + # numRoiNum = len(df) + # sns.set_palette("colorblind") + # palette = sns.color_palette(n_colors=numRoiNum) + + # try: + if 1: + + # returns "matplotlib.axes._axes.Axes" + if self.axScatter is not None: + self.axScatter.cla() + + if len(df) == 0: + logger.warning('nothing to plot -->> return') + return + + if plotType == 'Line Plot': + self.linePlot(df, yStat, hue) + + elif plotType == 'Scatter': + self.axScatter = sns.scatterplot(data=df, + x=xStat, + y=yStat, + hue=hue, + # palette=palette, + ax=self.axScatter, + legend=legend, # TODO ad QCheckbox for this + picker=5) # return matplotlib.axes.Axes + + # abb 202505 removed + self.axScatter.figure.canvas.mpl_connect("pick_event", self._on_user_pick_scatterplot) + + if self._state['makeSquare']: + # logger.info('making scatter plot square') + _xLim = self.axScatter.get_xlim() + _yLim = self.axScatter.get_ylim() + _min = min(_xLim[0], _yLim[0]) + _max = max(_xLim[1], _yLim[1]) + + # logger.info(f'_min:{_min} _max:{_max}') + self.axScatter.set_xlim([_min, _max]) + self.axScatter.set_ylim([_min, _max]) + + # draw a diagonal line + # Using transform=self.axScatter.transAxes, the supplied x and y coordinates are interpreted as axes coordinates instead of data coordinates. + self.axScatter.plot([0, 1], [0, 1], '--', transform=self.axScatter.transAxes) + + elif plotType in ['Box Plot']: + self.axScatter = sns.boxplot(data=df, + x=xStat, + y=yStat, + hue=hue, + legend=legend, + ax=self.axScatter, + dodge=dodge, # separate the points by hue + hue_order=hue_order, + ) + + elif plotType in ['Swarm', 'Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + # 20250515 colin, got it working + # picker does not work for stripplot + # fernando's favorite + + # reduce the brightness of raw data + if plotType in ['Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + alpha = 0.6 + # ['ci', 'pi', 'se', 'sd'] + if plotType == 'Swarm + Mean': + errorBar = None + elif plotType == 'Swarm + Mean + STD': + errorBar = 'sd' + elif plotType == 'Swarm + Mean + SEM': + errorBar = 'se' + else: + alpha = 1 + errorBar = None + + logger.info(f'=== making stripplot xStat:"{xStat}" hue:"{hue}"') + # logger.info(f'plotDict hue_order is:{hue_order}') + + # 20250520 hue_order has to be the same length as unique() hue + # hue_order = ['Control', 'Ivab'] + + plotDict = { + 'data': df, + 'x': xStat, + 'y': yStat, + 'hue': hue, + # 'style': style, # only used in scatterplot + 'alpha': alpha, + # 'palette': None, + 'legend': legend, + # 'ax': self.axScatter, + 'picker': 5, + 'dodge': dodge, # separate the points by hue + # 'hue_order': hue_order, # hue_order is tripping up our complex code for picking with groups and splits !!!! + } + if hue_order is not None: + plotDict['hue_order'] = hue_order + + # 20250515, switched to swarmplot + # self.axScatter = sns.stripplot(data=df, + self.axScatter = sns.swarmplot(ax=self.axScatter, **plotDict) + + _mplFigure = self.axScatter.figure + # logger.info(f'_mplFigure:{_mplFigure}') + + # see: + # https://stackoverflow.com/questions/66201678/oclick-with-seaborn-stripplot + + if hue is None: + logger.warning('no picking with hue None') + else: + # TODO: refactor to handle simple case, group_len = 1 + # hue_order is tripping up our complex code for picking with groups and splits !!!! + + logger.warning(f'need groups using hue order instead ??? hue:{hue}') + groups = df[hue].unique() + groups = sorted(groups) # fake hue order based on sorting + # groups = hue_order + splits = df[xStat].unique() + # + # print(self.axScatter.collections) + group_len = len(groups) + # print(f'groups is len:{group_len} {groups}') + # print(f'splits is:{splits}') + for idx, artist in enumerate(self.axScatter.collections): + # logger.info(f'idx:{idx} artist:{artist}') + # matplotlib.collections.PathCollection + artist.animal = splits[idx // group_len] # floor division 5//2 = 2 + artist.label = groups[idx % group_len] + # print(f'{idx}') + # print(f' artist.animal: "{artist.animal}"') + # print(f' artist.label: "{artist.label}"') + + self.fig.canvas.mpl_connect("pick_event", self._on_user_pick_stripplot) + + # add stats + if self.state['stats']: + # # between all hue within each x (cell) + if hue is None or (xStat == hue): + logger.warning('no pairwise comparison if no hue -->> default to pairwise x-stat') + # pairwise between x-stat (cell id) + _xUnique = df[xStat].unique() + pairs = list(combinations(_xUnique, 2)) + else: + pairs = [] + for _xStat in df[xStat].unique(): + # logger.info(f'adding pair for _xStat: {_xStat}') + onePair = [[(_xStat, a), (_xStat, b)] for a, b in combinations(uniqueHue, 2)] + # logger.info(f'onePair is len:{len(onePair)}') + # logger.info(f' {onePair}') + # append items in onePair to pairs + pairs.extend(onePair) + + # print(f'=== pairs is {type(pairs)} {len(pairs)}:') + # print(pairs) + # for idx, pair in enumerate(pairs): + # print(f' {idx}: {pair}') + + try: + + logger.info(f'constructing stats Annotator Mann-Whitney NO CORRECTION') + annotator = Annotator(pairs=pairs, + ax=self.axScatter, + **plotDict) + # logger.info(' annotator.configure') + annotator.configure(test='Mann-Whitney', + text_format='star', + # loc='outside', + verbose=False, + ) + # logger.info(' annotator.apply_and_annotate') + annotator.apply_and_annotate() + except (ValueError) as e: + logger.error(f'annotator failed: {e}') + # 20250708 + except( TypeError) as e: + logger.error(f'annotator failed: {e}') + logger.error(f' pairs:{pairs}') + logger.error(f' hue:{hue}') + + + # overlay mean +- std or sem + # no overlay if hue is None + # if hue is not None and \ + # plotType in ['Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + if plotType in ['Swarm + Mean', 'Swarm + Mean + STD', 'Swarm + Mean + SEM']: + + errorbar = errorBar + markersize = 30 + sns.pointplot(data=df, + x=xStat, + y=yStat, + # ??? + # hue= xStat if hue is None else hue, + hue=hue, + errorbar=errorbar, # can be 'se', 'sem', etc + # capsize=capsize, + linestyle='none', # do not connect (with line) between categorical x + marker="_", + markersize=markersize, + # markeredgewidth=3, + legend=False, + # palette=palette, + # dodge=0.5, # separate the points by hue + # dodge=None if len(uniqueHue)==1 else 0.5, # separate the points by hue + dodge=dodge, + hue_order=hue_order, + ax=self.axScatter) + + elif plotType == 'Histogram': + self.axScatter = sns.histplot(data=df, + x=yStat, + hue=hue, + # palette=palette, + legend=legend, + ax=self.axScatter) + + elif plotType == 'Cumulative Histogram': + self.axScatter = sns.histplot(data=df, + x=yStat, + hue=hue, + element="step", + fill=False, + cumulative=True, + stat="density", + common_norm=False, + # palette=palette, + legend=legend, + ax=self.axScatter) + + else: + logger.warning(f'did not understand plot type: {plotType}') + + self.static_canvas.draw() + + # except (ValueError) as e: + # logger.error(e) + plt.tight_layout() + + def keyPressEvent(self, event): + _handled = False + isMpl = isinstance(event, mpl.backend_bases.KeyEvent) + if isMpl: + text = event.key + logger.info(f'mpl key: "{text}"') + + doCopy = text in ["ctrl+c", "cmd+c"] + if doCopy: + self.copyTableToClipboard() + + def _contextMenu(self, pos): + logger.info('') + + contextMenu = QtWidgets.QMenu() + + makeSquareAction = QtWidgets.QAction('Make Square') + makeSquareAction.setCheckable(True) + makeSquareAction.setChecked(self._state['makeSquare']) + makeSquareAction.setEnabled(self._state['plotType'] == 'Scatter') + contextMenu.addAction(makeSquareAction) + + contextMenu.addSeparator() + contextMenu.addAction('Copy Stats Table ...') + + # Add splitter control options + contextMenu.addSeparator() + resetSplitterAction = QtWidgets.QAction('Reset Toolbar Width') + contextMenu.addAction(resetSplitterAction) + + # contextMenu.addSeparator() + # contextMenu.addAction('Show Analysis Folder') + + # show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + _ret = '' + actionText = action.text() + if action == makeSquareAction: + self._state['makeSquare'] = makeSquareAction.isChecked() + self.replot() + + elif actionText == 'Copy Stats Table ...': + _ret = self.copyTableToClipboard() + + elif actionText == 'Reset Toolbar Width': + self.resetSplitterSizes() + + def setStatusBar(self, msg:str, msecs:int=0): + """Set the text of the status bar. + + Defaults to 4 seconds. + """ + # if msecs is None: + # msecs=4000 + self.status_bar.showMessage(msg, msecs=msecs) + + def getGroupedDataframe(self, + statColumn, + groupByColumn): + + logger.info(f'groupByColumn:{groupByColumn}') + + aggList = ["count", "mean", "std", "sem", variation, "median", "min", "max"] + + # if len(self._df)>0: + # # get first row value + # detectedTrace = self._df['Detected Trace'].iloc[0] + # else: + # detectedTrace = 'N/A' + + # aggDf = self._df.groupby(groupByColumn, as_index=False)[statColumn].agg(aggList) + dfDropNan = self.getDf().dropna(subset=[statColumn]) # drop rows where statColumn is nan + + try: + aggDf = dfDropNan.groupby(groupByColumn).agg({statColumn : aggList}) + except (TypeError) as e: + logger.error(f'groupByColumn "{groupByColumn}" failed e:{e}') + aggDf = dfDropNan + + try: + aggDf.columns = aggDf.columns.droplevel(0) # get rid of statColumn multiindex + except (ValueError) as e: + logger.error(e) + aggDf.insert(0, 'Stat', statColumn) # add column 0, in place + aggDf = aggDf.reset_index() # move groupByColum (e.g. 'ROI Number') from row index label to column + + # rename column 'variation' as 'CV' + aggDf = aggDf.rename(columns={'variation': 'CV'}) + + # round some columns + aggList = ["mean", "std", "sem", "CV", "median", "min", "max"] + for agg in aggList: + if agg == 'count': + continue + # logger.info(f'rounding agg:{agg}') + try: + aggDf[agg] =round(aggDf[agg], 2) + except (KeyError) as e: + logger.warning(f'did not find agg column:{agg} possible keys are {aggDf.columns}') + return aggDf + + def setSplitterSizes(self, leftWidth: int, rightWidth: int = None): + """Set the splitter sizes programmatically. + + Args: + leftWidth: Width for the left toolbar in pixels + rightWidth: Width for the right plot area in pixels (optional) + """ + if hasattr(self, '_mainSplitter'): + if rightWidth is None: + # Calculate right width based on current window size + currentSizes = self._mainSplitter.sizes() + totalWidth = sum(currentSizes) + rightWidth = totalWidth - leftWidth + self._mainSplitter.setSizes([leftWidth, rightWidth]) + + def getSplitterSizes(self): + """Get current splitter sizes.""" + if hasattr(self, '_mainSplitter'): + return self._mainSplitter.sizes() + return [300, 800] # Default fallback + + def resetSplitterSizes(self): + """Reset splitter sizes to default.""" + self.setSplitterSizes(300, 800) + + def saveSplitterSizes(self): + """Save current splitter sizes to settings.""" + if hasattr(self, '_mainSplitter'): + sizes = self._mainSplitter.sizes() + # You can save this to a settings file or registry + # For now, just store in instance variable + self._savedSplitterSizes = sizes + logger.info(f'Saved splitter sizes: {sizes}') + + def restoreSplitterSizes(self): + """Restore saved splitter sizes.""" + if hasattr(self, '_savedSplitterSizes'): + self._mainSplitter.setSizes(self._savedSplitterSizes) + logger.info(f'Restored splitter sizes: {self._savedSplitterSizes}') + + def closeEvent(self, event): + """Override closeEvent to save splitter sizes before closing.""" + self.saveSplitterSizes() + super().closeEvent(event) + +def run(): + # this was my analysis with 1 roi per kym + # savePath = '/Users/cudmore/colin_peak_summary_20250517.csv' + + # this is new colin analysis with multiple roi per kym + # savePath = '/Users/cudmore/colin_peak_summary_20250521.csv' + # savePath = '/Users/cudmore/colin_peak_summary_20250527.csv' + + # load csv analysis files as pd DataFrame + from colin_global import loadMasterDfFile, loadMeanDfFile, getMeanDfPath + masterDf = loadMasterDfFile() + meanDf = loadMeanDfFile() + + # print(meanDf.columns) + # meanSavePath = '/Users/cudmore/colin_peak_mean_20250521.csv' + # meanDf = pd.read_csv(meanSavePath) + + hueList = ['File Number', + 'Cell ID', + # 'Cell ID (plot)', # removed 20250521 + # 'Tif File', + 'Condition', + 'Epoch', + 'Condition Epoch', + 'Region', + 'Date', + 'ROI Number', + 'Polarity', + ] + + # limit what we show user + plotColumns = [ + 'Cell ID', + # 'Cell ID (plot)', # removed 20250521 + 'File Number', + 'Tif File', + 'Condition', + 'Region', + 'Date', + 'ROI Number', + 'Polarity', + # 'Onset (s)', + # 'Decay (s)', + 'Peak Inst Interval (s)', + 'Peak Inst Freq (Hz)', + 'Peak Height', + 'FW (ms)', + 'HW (ms)', + 'Rise Time (ms)', + 'Decay Time (ms)', + 'Area Under Peak', + 'Area Under Peak (Sum)', + 'Number of Spikes', + # 'Spike Frequency (Hz)', + 'fit_tau', + 'fit_tau1', + ] + + app = QtWidgets.QApplication(sys.argv) + app.setStyle("Fusion") + # app.setStyleSheet("QWidget { font-size: 12pt; }") + # app.setWindowIcon(QtGui.QIcon('sanpy.png')) + app.setFont(QtGui.QFont("Arial", 10)) + # app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + + myWin = ScatterWidget(masterDf, + meanDf, + xStat='Region', + yStat='Peak Inst Freq (Hz)', + #defaultPlotType='Line Plot', + defaultPlotType='Swarm + Mean + SEM', + hueList=hueList, + defaultHue='Condition', + imgFolder = None, + plotColumns=plotColumns, + ) + + myWin.setWindowTitle(getMeanDfPath()) + + # plot all control traces for ssan and isan + # myWin._colinTraces.plotOneCond('Control', 'SSAN') + # myWin._colinTraces.plotOneCond('Control', 'ISAN') + # plt.show() + + myWin.show() + + # options = ScatterOptions() + # options.show() + + sys.exit(app.exec_()) + +if __name__ == '__main__': + run() \ No newline at end of file diff --git a/sanpy/kym/simple_scatter/colin_simple_figure.py b/sanpy/kym/simple_scatter/colin_simple_figure.py new file mode 100644 index 00000000..cc7dbbc7 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_simple_figure.py @@ -0,0 +1,50 @@ +import sys +import matplotlib.pyplot as plt +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar + +import seaborn as sns + +from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget + +#TODO: use this to plot one of seaborn plots + +class KymRoiMainWindow(QMainWindow): + def __init__(self, figure, ax): + super().__init__() + self.setWindowTitle("Matplotlib in PyQt") + + # Create a Matplotlib figure and axes + #self.figure, self.ax = plt.subplots() + self.figure = figure + self.ax = ax + self.canvas = FigureCanvas(self.figure) + + self.toolbar = NavigationToolbar(self.canvas, self) + + # Create a layout and add the canvas + layout = QVBoxLayout() + layout.addWidget(self.canvas) + layout.addWidget(self.toolbar) + + # Create a central widget and set the layout + central_widget = QWidget() + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + # Plot some data + # self.ax = sns.scatterplot(x=[1,2,3], y=[3,10,2]) + # self.ax = sns.lineplot(x=[1,2,3], y=[3,10,2]) + # self.ax = ax + # self.ax.plot([0, 1, 2, 3, 4], [10, 1, 20, 3, 40]) + + # self.figure = figure + # Redraw the canvas + self.canvas.draw() + + +if __name__ == '__main__': + app = QApplication(sys.argv) + main_window = MainWindow() + main_window.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/sanpy/kym/simple_scatter/colin_stats.py b/sanpy/kym/simple_scatter/colin_stats.py new file mode 100644 index 00000000..26463b20 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_stats.py @@ -0,0 +1,419 @@ +import os +import sys +from pprint import pprint +from typing import List, Optional +import itertools + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from datetime import datetime +import warnings +from scipy import stats +from scipy.stats import mannwhitneyu, ttest_ind, pearsonr, spearmanr + +from scipy.stats import variation + +from sanpy.bAnalysis_ import bAnalysis +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes + +from sanpy.kym.simple_scatter.colin_global import loadMasterDfFile, loadAllKymRoiAnalysis + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +# Import FileInfo from colin_global +from sanpy.kym.simple_scatter.colin_global import FileInfo + +def genStats3(df, stat, region, cond1, cond2): + logger.info(f'stat:{stat} region:{region} -> {cond1} vs {cond2}') + + df = df[df['Region']==region] + + dfCond1 = df[df['Condition']==cond1] + dfCond1 = dfCond1.dropna(subset=[stat]) # drop nan values + values_1 = dfCond1[stat].to_list() + n1 = len(values_1) + + dfCond2 = df[df['Condition']==cond2] + dfCond2 = dfCond2.dropna(subset=[stat]) # drop nan values + values_2 = dfCond2[stat].to_list() + n2 = len(values_2) + + logger.info(f'{cond1}:{n1} {cond2}:{n2}') + + nRows = max(n1, n2) + + dfRet = pd.DataFrame( + { + cond1: [np.nan] * nRows, + cond2: [np.nan] * nRows, + } + ) + dfRet[cond1][0:n1] = values_1 + dfRet[cond2][0:n2] = values_2 + + print(dfRet) + + # pValue = mannwhitneyu(dfRet[cond1], dfRet[cond2]) + pValue = mannwhitneyu(values_1, values_2) + print(pValue) + +def genStats2(df, stat): + """Iterate through region and cpndition, + generate a pairwise stat for each pair + """ + dfPlot = df + + regions = df['Region'].unique() + for region in regions: + dfPlot = dfPlot[dfPlot['Region']==region] + conds = df['Condition'].unique() + conds = sorted(conds) + pairwiseConds = list(itertools.combinations(conds, 2)) + for onePair in pairwiseConds: + genStats3(df, stat, region, onePair[0], onePair[1]) + +def getGroupedDataframe(df, + statColumn, + groupByColumns:List[str] = Optional[List[str]], + decimalPlaces: int = 3): + + # groupByColumns = ['Region', 'Condition'] + + # aggList = ["count", "mean", "std", "sem", variation, "median", "min", "max"] + # aggList = ["count", np.nanmean, np.nanstd, "sem"] + # FutureWarning: The provided callable is currently using SeriesGroupBy.std. In a future version of pandas, the provided callable will be used directly. To keep current behavior pass the string "std" instead. + # aggList = ["count", np.nanmean, np.nanstd, "sem", "sum"] # sum is for "Area Under Peak" + aggList = ["count", "mean", "std", "sem", "sum"] # sum is for "Area Under Peak" + + # df = df.dropna(subset=[statColumn]) # drop rows where statColumn is nan + + try: + # AttributeError: 'SeriesGroupBy' object has no attribute 'Region' + aggDf = df.groupby(groupByColumns).agg({ + 'Region': 'first', + # 'Condition': lambda x: x.iloc[0], # mimic 'first' + statColumn : aggList, + 'Path': 'last', + 'Date': lambda x: x.iloc[0], + # 'Epoch': lambda x: x.iloc[0], + }) + except (TypeError) as e: + logger.error(f'groupByColumn "{groupByColumns}" failed e:{e}') + aggDf = df + + # we end up with column multiindex with (stat, region, path) + # aggDf.columns = ['_'.join(col) for col in aggDf.columns] + + + try: + # df.columns = df.columns.droplevel() + aggDf.columns = aggDf.columns.droplevel(0) # get rid of statColumn multiindex + except (ValueError) as e: + logger.error(e) + + _columns = list(aggDf.columns) + # logger.info(f'aggDf.columns:{_columns}') + firstLambda = _columns.index('') + _columns[firstLambda] = 'Date' + # secondLambda = _columns.index('') + # _columns[secondLambda] = 'Epoch' + aggDf.columns = _columns + # print(aggDf) + # sys.exit(1) + + + # rename column 'first' as 'Region' + aggDf = aggDf.rename(columns={'first': 'Region'}) + # aggDf = aggDf.rename(columns={'': 'Date'}) # how do I use lambda and get a column other than ? + aggDf = aggDf.rename(columns={'last': 'Path'}) + + aggDf = aggDf.reset_index() # move groupByColum (e.g. 'ROI Number') from row index label to column + aggDf.insert(0, 'Stat', statColumn) # add column 0, in place + + # rename column 'variation' as 'CV' + # aggDf = aggDf.rename(columns={'variation': 'CV'}) + aggDf = aggDf.rename(columns={'nanmean': 'mean'}) + aggDf = aggDf.rename(columns={'nanstd': 'std'}) + + # round some columns + aggList = ["mean", "std", "sem"] + for agg in aggList: + if agg == 'count': + continue + try: + aggDf[agg] =round(aggDf[agg], decimalPlaces) + except (KeyError) as e: + logger.error(f'did not find agg column:{agg} possible keys are {aggDf.columns}') + + return aggDf + +def makeMeanDf(): + """Take master df (one row per spike) and make mean df. + We get one mean per (cell id, condition, roi number) + This is used to generate a table for the scatter widget. + """ + + df = loadMasterDfFile() + + # load all kym roi analysis once + _kymRoiAnalysisDict = loadAllKymRoiAnalysis(loadImgData=True) + + statColumns = [ + # 'Peak Height', + 'Peak Inst Interval (s)', + 'Peak Inst Freq (Hz)', + 'Rise Time (ms)', + 'Decay Time (ms)', + 'FW (ms)', + 'HW (ms)', + 'Area Under Peak', + # 'Number of Spikes', + # 'Spike Frequency (Hz)', + 'fit_tau', + 'fit_tau1', + ] + + # + # seed with peak height (gives us number or rows) + logger.info('seeding df with peak height') + stat = 'Peak Height' + groupByColumns = ['Cell ID', 'Condition', 'Epoch', 'ROI Number'] + dfGrouped = getGroupedDataframe(df, stat, groupByColumns=groupByColumns) + # dfGrouped has 'Path' to tif file + + dfGrouped.drop('Stat', axis=1, inplace=True) + + # rename columns + dfGrouped = dfGrouped.rename(columns={'count': 'Number of Spikes'}) + + dfGrouped[stat+'_cv'] = dfGrouped['std'] / dfGrouped['mean'] * 100 + + # dfGrouped = dfGrouped.rename(columns={'mean': stat+"_mean"}) + dfGrouped = dfGrouped.rename(columns={'mean': stat}) + dfGrouped = dfGrouped.rename(columns={'std': stat+"_std"}) + dfGrouped = dfGrouped.rename(columns={'sem': stat+"_sem"}) + + # logger.info('dfGrouped is:') + # cols = ['Cell ID', 'Region', 'Condition', 'ROI Number', 'Number of Spikes', + # stat, stat+'_std', stat+'_sem', stat+'_cv'] + # print(dfGrouped[cols]) + + # print(dfGrouped[dfGrouped['Number of Spikes']==np.nan]) + + # + # add all other stat columns + logger.info(f' adding all stat columns: {statColumns}') + for stat in statColumns: + oneDf = getGroupedDataframe(df, stat, groupByColumns=groupByColumns) + + oneDf['cv'] = oneDf['std'] / oneDf['mean'] * 100 + # oneDf = oneDf.rename(columns={'mean': stat}) + # oneDf = oneDf.rename(columns={'std': stat+"_std"}) + # oneDf = oneDf.rename(columns={'sem': stat+"_sem"}) + + dfGrouped[stat] = oneDf['mean'] + dfGrouped[stat+"_std"] = oneDf['std'] + dfGrouped[stat+"_sem"] = oneDf['sem'] + dfGrouped[stat+"_cv"] = oneDf['cv'] + + if stat == 'Area Under Peak': + # add sum column for "Area Under Peak" + #dfGrouped[stat+"_sum"] = oneDf['sum'] + dfGrouped[stat + " (Sum)"] = oneDf['sum'] + + # 20250526 find (cell id, cond, roi) that have zero (0) peaks + # append to mean df as 'Number of Spikes' == 0 + logger.info('finding roi with 0 peaks') + zeroSpikeDictList = findZeroPeaks(_kymRoiAnalysisDict) + logger.info(f' found {len(zeroSpikeDictList)} cell/cond/roi with zero peaks') + for zeroSpike in zeroSpikeDictList: + # logger.info(f' {zeroSpike}') + cellID = zeroSpike['Cell ID'] + condition = zeroSpike['Condition'] + roiNumber = zeroSpike['ROI Number'] + searchThis = (dfGrouped['Cell ID']==cellID) \ + & (dfGrouped['Condition'] == condition) \ + & (dfGrouped['ROI Number'] == roiNumber) + dfEmpty = dfGrouped.loc[searchThis] + if len(dfEmpty>0): + logger.error(f'NOT EMPTY {zeroSpike}') + dfZeroSpike = pd.DataFrame(zeroSpikeDictList) + # print(dfZeroSpike) + + dfGrouped = pd.concat([dfGrouped, dfZeroSpike], axis=0).reset_index(drop=True) + + # for each row (kym) make a 'Tif File' column from 'Path' + dfGrouped['Tif File'] = dfGrouped['Path'].apply(lambda x: os.path.basename(x)) + + # print(dfGrouped.columns) + # print(dfGrouped['Tif File']) + # sys.exit(1) + + logger.info('appending roi rect to df ...') + # for each row (cellid, cond, roi number), append (l,t,r,b) of roi + dfGrouped['Polarity'] = '' + dfGrouped['ROI Rect'] = '' + dfGrouped['Detection Params'] = '' + for index, row in dfGrouped.iterrows(): + tifPath = row['Path'] + roiNumber = row['ROI Number'] + #ka = loadKymRoiAnalysis(tifPath) + ka:KymRoiAnalysis = _kymRoiAnalysisDict[tifPath] + + # roiRect = fetchRoiRect(ka, roiNumber) + roi = ka.getRoi(roiLabel=roiNumber) + roiRect = roi.getRect() # [l, t, r, b] + # logger.info(f'index:{index} roiNumber:{roiNumber} roiRect:{roiRect}') + dfGrouped.at[index, 'ROI Rect'] = roiRect + + _kymRoiDetection = roi.getDetectionParams(0, PeakDetectionTypes.intensity) + _polarity = _kymRoiDetection.getParam('Polarity') + dfGrouped.at[index, 'Polarity'] = _polarity + + # add detection params for row + detectionParams = ka.getDetectionParams(roiNumber, PeakDetectionTypes.intensity, channel=0) + + _detectionDict = detectionParams.getValueDict() + dfGrouped.at[index, 'Detection Params'] = _detectionDict + + f0_value_percentile = detectionParams.getParam('f0 Value Percentile') + # print(f'f0_value_percentile:{f0_value_percentile}') + dfGrouped.at[index, 'f0_value_percentile'] = f0_value_percentile + + # print(dfGrouped.columns) + # print(dfGrouped) + + # for each row, udate the 'Epoch' column based on the name of 'Tif File' + dfGrouped['Epoch'] = dfGrouped['Tif File'].apply(lambda x: + FileInfo.from_path(x).epoch) + + dfGrouped['show_region'] = True + dfGrouped['show_condition'] = True + dfGrouped['show_cell'] = True + dfGrouped['show_roi'] = True + dfGrouped['show_polarity'] = True + dfGrouped['show_epoch'] = True + + # flag control kym roi with less than or equal to 2 spikes + _removeOneSpikeControl(dfGrouped) + + dfGrouped['Condition Epoch'] = \ + (dfGrouped['Condition'].fillna('') + ' ' + dfGrouped['Epoch'].astype(str).fillna('')).str.strip() + dfGrouped['Condition Epoch'] = \ + dfGrouped['Condition Epoch'].astype('category') + + # save as csv to load into scatter widget + # was this 20250528 before switch to colin_global + # savePath = '/Users/cudmore/colin_peak_mean_20250521.csv' + # savePath = '/Users/cudmore/colin_peak_mean_20250527.csv' + + from sanpy.kym.simple_scatter.colin_global import getMeanDfPath + savePath = getMeanDfPath() + logger.info('saving dfGrouped csv:') + print(savePath) + + dfGrouped.to_csv(savePath) + + print('final mean df is:') + print(dfGrouped) + # print(dfGrouped.columns) + +def findZeroPeaks(kymAnalysisDict): + """Find rois with 0 peaks and add to mean df. + + This looks for Control Trial 1 ROIs with zero peaks + to then set all other cell id (thap, Ivab) off for that roi. + """ + from sanpy.kym.simple_scatter.colin_global import getAllTifFilePaths + + rawTifPaths = getAllTifFilePaths() + + channel = 0 + + zeroSpikeDictList = [] + for rawTifPath in rawTifPaths: + ka:KymRoiAnalysis = kymAnalysisDict[rawTifPath] + + for roi in ka.getRoiLabels(): + roi = ka.getRoi(roi) + roiLabel = roi.getLabel() + results = roi.getAnalysisResults(channel, PeakDetectionTypes.intensity) + # dfResults = results.df + numSpikes = len(results.df) + if numSpikes == 0: + # have to add a row with 'numSpikes' 0 + # Use FileInfo to parse the file path + file_info = FileInfo.from_path(rawTifPath) + + oneDict = { + 'Cell ID': file_info.cellID, + 'Condition': file_info.condition, + 'Epoch': file_info.epoch, + 'Date': file_info.date, + 'ROI Number': roiLabel, + 'Region': file_info.region, + 'Number of Spikes': 0, + 'Path': rawTifPath, + } + zeroSpikeDictList.append(oneDict) + + # for zeroSpikeDict in zeroSpikeDictList: + # logger.info(f'zero spike dict: {zeroSpikeDict}') + + return zeroSpikeDictList + +def _removeOneSpikeControl(dfMean: pd.DataFrame): + # remove cells that have 1 spike in control (remove control, ivab, thap) + # from colin_global import loadMeanDfFile, getMeanDfPath + # dfMean = loadMeanDfFile() + + # flag by setting 'show_roi' 'le2_peaks' + removeLessThanEqual = 2 + + dfMean['le2_peaks'] = False + + # columns = ['Cell ID', 'Region', 'Condition', 'ROI Number', 'Number of Spikes'] + + # Create an explicit copy to avoid SettingWithCopyWarning + dfControl = dfMean[dfMean['Condition'] == 'Control'].copy() + + # we might have a number of Epoch(s) 0,1,2. Use the last epoch + # for each cell id, get the last epoch + dfControl['Last Epoch'] = dfControl.groupby('Cell ID')['Epoch'].transform('max') + + # filter by last epoch + dfControl = dfControl[dfControl['Epoch'] == dfControl['Last Epoch']] + + # filter by number of spikes + dfOneSpike = dfControl[dfControl['Number of Spikes'] <= removeLessThanEqual] + + numControlWithOneSpike = len(dfOneSpike['Cell ID'].unique()) + logger.info(f'num Cell Id Control with <= {removeLessThanEqual} peaks is {numControlWithOneSpike}') + + # print(f'before drop, df mean has {len(dfMean)} rows.') + + # remove all rows that have (cell id, ROI Number) + for rowLabel, rowDict in dfOneSpike.iterrows(): # iterate our <=1 spike (cell id, roi number) + cellID = rowDict['Cell ID'] + roiNumber = rowDict['ROI Number'] + + theseRows = (dfMean['Cell ID'] == cellID) & (dfMean['ROI Number'] == roiNumber) + theseRows2 = dfMean.loc[theseRows] + + dfMean.loc[theseRows2.index, 'le2_peaks'] = True + + dfMean.loc[theseRows2.index, 'show_cell'] = False + dfMean.loc[theseRows2.index, 'show_roi'] = False + +if __name__ == '__main__': + + # works, make the mean df from master, one row per (cell id, cond, roi) + makeMeanDf() + + # _removeOneSpikeControl() + + # find roi with 0 peaks and add to mean df + # findZeroPeaks() \ No newline at end of file diff --git a/sanpy/kym/simple_scatter/colin_summary.py b/sanpy/kym/simple_scatter/colin_summary.py new file mode 100644 index 00000000..92610996 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_summary.py @@ -0,0 +1,170 @@ +import os +import sys +import pathlib +# import itertools + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from typing import List, Dict, Any, Optional +from datetime import datetime +import warnings + +from scipy.stats import mannwhitneyu +from sanpy.analysisDir import analysisDir +from sanpy.fileloaders import getFileLoaders + +from sanpy.kym.simple_scatter.colin_global import FileInfo + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def _old_run(): + # Define the path and folder depth + path = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc' + folderDepth = 5 + + fileLoaderDict = getFileLoaders(verbose=True) + + # Create an instance of AnalysisDirectory + analysis_dir = analysisDir(path=path, + fileLoaderDict=fileLoaderDict, + folderDepth=folderDepth) + + df = analysis_dir.getDataFrame() + + df.to_csv('colin_summary.csv', index=False) + + print(df.columns) + print(df) + + +def collect_analysis(): + """Walk through a path and collect csv files into one dataframe. + + This is our master df. + """ + + from colin_global import getAllPeakAnalysisCsv + paths = getAllPeakAnalysisCsv() + + # collect all the dataframes + dfMaster = pd.DataFrame() + for idx, csvPath in enumerate(paths): + # print(f'loading peak anaysis csvPath: {csvPath}') + _path, csvFile = os.path.split(csvPath) + + df = pd.read_csv(csvPath, header=1) # first line is detection parameters + + # df.insert(0, 'File Name', csvFile) + df.insert(0, 'File Number', idx) + + # get original filename from df['path'] + try: + rawTifPath = df.at[0, 'Path'] # capitol 'P' + except (KeyError) as e: + # empty dataframe, no spikes (in any roi) + logger.error(f' ERROR: did not find any spikes in {csvPath}') + continue + + _path, rawTifFile = os.path.split(rawTifPath) + rawTifFile, _ext = os.path.splitext(rawTifFile) + df.insert(0, 'Tif File', rawTifFile) + + # insert condition + # _cond = getCondFromTifPath(rawTifPath) + # df.insert(0, 'Condition', _cond) + + # insert condition from FileInfo + fileInfo = FileInfo.from_path(rawTifPath) + df.insert(0, 'Condition', fileInfo.condition) + + # insert epoch from FileInfo + df.insert(0, 'Epoch', fileInfo.epoch) + # all peaks are labeled with Epoch. + # df.insert(0, 'Epoch', 0 if _hasEpoch else 1) + + # rawTiFile is like "250225 ISAN R1 LS1 c2 Ivab" + # pull out a unique cell id (we can then group by cell id and condition) + _rawTiFile = rawTifFile.split(' ') + cellID = f'{_rawTiFile[0]} {_rawTiFile[1]} {_rawTiFile[2]} {_rawTiFile[3]}' + dateStr = _rawTiFile[0] + # print(f' unique cellID:"{cellID}"') + df.insert(0, 'Date', dateStr) + df.insert(0, 'Cell ID', cellID) + + # get the region from the filename + if 'SSAN' in rawTifFile: + sanRegion = 'SSAN' + elif 'ISAN' in rawTifFile: + sanRegion = 'ISAN' + else: + print('ERROR: no region found') + sanRegion = 'Unknown' + df.insert(0, 'Region', sanRegion) + + # print(f'loaded {csvFile} {rawDataTif} with {len(df)} peak') + dfMaster = pd.concat([dfMaster, df], ignore_index=True) + + dfMaster['Condition Epoch'] = \ + (dfMaster['Condition'].fillna('') + ' ' + dfMaster['Epoch'].astype(str).fillna('')).str.strip() + dfMaster['Condition Epoch'] = \ + dfMaster['Condition Epoch'].astype('category') + + # for show/hide in scatter widget + dfMaster['show_region'] = True + dfMaster['show_condition'] = True + dfMaster['show_cell'] = True + dfMaster['show_roi'] = True + dfMaster['show_polarity'] = True + dfMaster['show_epoch'] = True + + print('dfMaster is:') + print(dfMaster) + + if len(dfMaster) == 0: + logger.error('dfMaster has 0 length!!!') + sys.exit(1) + # save to csv + # this was my analysis with 1 roi per kym + # savePath = '/Users/cudmore/colin_peak_summary_20250517.csv' + + # this is colins analysis with multiple roi per kym + # savePath = '/Users/cudmore/colin_peak_summary_20250527.csv' + from colin_global import getMasterDfPath + savePath = getMasterDfPath() + print(f'=== saving dfMaster to savePath:{savePath}') + dfMaster.to_csv(savePath, index=False) + +def create_condition_epoch_column(df: pd.DataFrame, condition_col: str = 'Condition', epoch_col: str = 'Epoch') -> pd.DataFrame: + """Create a 'Condition Epoch' column by combining Condition and Epoch columns. + + Args: + df: DataFrame containing Condition and Epoch columns + condition_col: Name of the condition column (default: 'Condition') + epoch_col: Name of the epoch column (default: 'Epoch') + + Returns: + DataFrame with new 'Condition Epoch' column added + """ + df_copy = df.copy() + + # Combine condition and epoch with space, handle NaN values + # Convert epoch to string first to avoid TypeError + df_copy['Condition Epoch'] = \ + (df_copy[condition_col].fillna('') + ' ' + df_copy[epoch_col].astype(str).fillna('')).str.strip() + + # Convert to categorical for efficiency + df_copy['Condition Epoch'] = df_copy['Condition Epoch'].astype('category') + + return df_copy + +if __name__ == "__main__": + # run() + #rename_files() + + # works + # this appends all peak analysis into one saved df + if 1: + collect_analysis() diff --git a/sanpy/kym/simple_scatter/colin_tests.py b/sanpy/kym/simple_scatter/colin_tests.py new file mode 100644 index 00000000..19e51356 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_tests.py @@ -0,0 +1,168 @@ +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import seaborn as sns +from typing import List, Dict, Any, Optional +import os +import pathlib + +import tifffile +import roifile # to import Fiji roi manager zip files + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def test_load_roi(): + """20250528, need to perfect importing Fii ROI into sanpy. + """ + tifPath = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/test-fiji-roi/ISAN Ivabradine R1 LS1.tif.frames/ISAN Ivabradine R1 LS1.tif' + + if not os.path.isfile(tifPath): + logger.error('file not found') + return + + rootPath = '/Users/cudmore/Dropbox/data/colin/2025/roi manager - 20250520' + oneZipFile = '250225/ISAN/20250225 ISAN RoiSet.zip' + + oneZipPath = os.path.join(rootPath, oneZipFile) + + fijiRoiList = roifile.roiread(oneZipPath) + + # logger.info(f'') + + roiRectList = [] + for fijiRoiIdx, oneFijiRoi in enumerate(fijiRoiList): + name = oneFijiRoi.name + # if 'R1 LS1 Ivabradine Mode 1_2' not in name: + if 'R1 LS1 Ivabradine' not in name: + continue + print(f'=== fijiRoiIdx:{fijiRoiIdx}') + print(oneFijiRoi) + + left = oneFijiRoi.left + top = oneFijiRoi.top + right = oneFijiRoi.right + bottom = oneFijiRoi.bottom + + # colin is specifying a 1-2 pixel roi with a stroke with + # this will expand left/right + stroke_width = oneFijiRoi.stroke_width + + half_stroke_width = stroke_width // 2 + left -= half_stroke_width + right += half_stroke_width + + # up till 20250528, was this + # this is correct, we need to just flip our tif files in y-axis !!!! + # sanpyRect_v1 = [top, right, bottom, left] # l/t/r/b + logger.info('assuming file loader will flip y') + # sanpyRect_v1 = [top, right, bottom, left] # l/t/r/b + # assuming file loader flips y + sanpyRect_v1 = [top, left, bottom, right] # l/t/r/b + roiRectList.append(sanpyRect_v1) + + # sanpyRect = [top, left, bottom, right] # l/t/r/b + + # + test_plot_kym_image_with_roi(tifPath=tifPath, roiRectList=roiRectList) + +def test_plot_kym_image_with_roi(tifPath, roiRectList, ax=None): + """Plot one kym image with its rois. + """ + + if ax is None: + fig, ax = plt.subplots(nrows=1, + ncols=1, + figsize=(8, 6), + ) + + imgData = tifffile.imread(tifPath) + imgData = np.rot90(imgData) + # new 20250528 !!! + imgData = np.flip(imgData, axis=0) + # the start/stop of my line scan might be wrong !!! + logger.info(f'new 20250528 flipped imgData:{imgData.shape}') + + # np.flip() + _origin = 'lower' + ax.imshow(imgData, + cmap="Grays", + # origin='lower', # (0,0) is bottom left + origin=_origin, # (0,0) is bottom left + aspect='auto', + ) + + roiColorList = ['r', 'g', 'b', 'c', 'm', 'y'] + for roiIdx, roiRect in enumerate(roiRectList): + logger.info(f'plotting roiIdx:{roiIdx} roiRect:{roiRect}') + + roiColor = roiColorList[roiIdx] + + left = roiRect[0] + top = roiRect[1] + right = roiRect[2] + bottom = roiRect[3] + + # plot roi as points + x = [left, left, right, right] + y = [bottom, top, top, bottom] + ax.plot(x, y, 'or') + + width = right - left + height = top - bottom + # 20250528, if we plotted with origin='lower', height is negative !!! + # nope, we seem to always need the negative of height + # if our file loader does flip y then we do not need this + # assumin when we import Fiji roi, we swap left with right + logger.info('negative height ???') + if _origin == 'lower': + height = - height + + import matplotlib.patches as patches + rect = patches.Rectangle((left, top), + width, height, + linewidth=2, + edgecolor=roiColor, + facecolor='none') + ax.add_patch(rect) + + # label roi in image + # f0_value_percentile = round(f0_value_percentile,1) + # oneLabel = f'{roiLabel} f0:{f0_value_percentile}' + oneLabel = f'roiIdx:{roiIdx}' + _xOffset = 20 # 5 + ax.annotate(oneLabel, xy=(left, bottom), + xytext=(left+_xOffset, bottom-20), + arrowprops=dict(arrowstyle='->'), + fontsize=12, + weight='bold', + color=roiColor) + + # + plt.show() + +def testRot(tifPath): + # Show loaded tif with no rot90 or flipy + imgData = tifffile.imread(tifPath) + + fig, ax = plt.subplots(nrows=1, + ncols=1, + figsize=(8, 6), + ) + + _origin = 'lower' + ax.imshow(imgData, + cmap="Grays", + # origin='lower', # (0,0) is bottom left + origin=_origin, # (0,0) is bottom left + aspect='auto', + ) + + plt.show() + +if __name__ == '__main__': + + # tifPath = '/Users/cudmore/Dropbox/data/colin/2025/analysis-20250510-rhc/test-fiji-roi/ISAN Ivabradine R1 LS1.tif.frames/ISAN Ivabradine R1 LS1.tif' + # testRot(tifPath) + + test_load_roi() \ No newline at end of file diff --git a/sanpy/kym/simple_scatter/colin_tree_widget.py b/sanpy/kym/simple_scatter/colin_tree_widget.py new file mode 100644 index 00000000..c40514db --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_tree_widget.py @@ -0,0 +1,260 @@ +import sys +import os +import subprocess + +from PyQt5 import QtGui, QtCore, QtWidgets + +from PyQt5.QtWidgets import ( + QWidget, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QApplication, QCheckBox +) +from PyQt5.QtCore import pyqtSignal, Qt +import pandas as pd + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +class KymTreeWidget(QWidget): + # Emitted when a cell (group of ROIs) is checked or unchecked + cellToggled = pyqtSignal(str, str, int, bool) # cell_id, condition, epoch, checked + + # Emitted when an individual ROI is checked or unchecked + roiToggled = pyqtSignal(str, int, bool) # cell_id, roi_number, checked + + # Emitted when the user selects a cell (row selected, not checkbox) + cellSelected = pyqtSignal(str, str, int) # cell_id, condition, epoch + + # Emitted when the user selects a ROI (row selected, not checkbox) + roiSelected = pyqtSignal(str, str, int, int) # cell_id, condition, epoch, roi_number + + # Emitted when the "Toggle All" checkbox is toggled (True = checked) + toggleAllToggled = pyqtSignal(bool) + + # Emitted on right-click plot cell id (cell id, roi number) + plotCellID = pyqtSignal(str, int) + + def __init__(self, dataframe, parent=None): + super().__init__(parent) + self.df = dataframe + + # re-wire right-click (for entire widget) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._contextMenu) + + # Create Toggle All checkbox + self.toggleAllCheckbox = QCheckBox("Toggle All") + self.toggleAllCheckbox.setTristate(False) + self.toggleAllCheckbox.stateChanged.connect(self.toggleAll) + + # Create tree + self.tree:QTreeWidget = QTreeWidget() + self.tree.setHeaderLabels(["Cell ID / ROI Number"]) + self.tree.itemChanged.connect(self.handleItemChanged) + self.tree.itemSelectionChanged.connect(self.handleItemSelectionChanged) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.toggleAllCheckbox) + layout.addWidget(self.tree) + self.setLayout(layout) + + self._populateTree() + + def _contextMenu(self, pos): + logger.info('') + + selectedItems = self.tree.selectedItems() + if len(selectedItems) == 0: + logger.warning('no items selected') + return + + item = selectedItems[0] # one selected item + + contextMenu = QtWidgets.QMenu() + + contextMenu.addAction('Show Analysis Folder') + _action = contextMenu.addAction('Plot Cell ID') + if item.data(0, Qt.UserRole)[0] != "roi": + _action.setEnabled(False) + + # show menu + pos = self.mapToGlobal(pos) + action = contextMenu.exec_(pos) + if action is None: + return + + actionText = action.text() + + if actionText == 'Show Analysis Folder': + cell_id = item.data(0, Qt.UserRole)[1] + condition = item.data(0, Qt.UserRole)[2] + epoch = item.data(0, Qt.UserRole)[3] + + # find cell id row in df + theseRows = (self.df['Cell ID']==cell_id) & (self.df['Condition']==condition) & (self.df['Epoch']==epoch) + df = self.df[theseRows] + tifPath = df.iloc[0]['Path'] + tifFolder = os.path.split(tifPath)[0] + logger.info(f'analysis folder is:{tifFolder}') + + # open in finder + subprocess.run(['open', tifFolder]) + + elif actionText == 'Plot Cell ID': + if item.data(0, Qt.UserRole)[0] != "roi": + # only for an roi (plots a kym roi across conditions) + return + + cell_id = item.data(0, Qt.UserRole)[1] + roi_number = item.data(0, Qt.UserRole)[4] + self.plotCellID.emit(cell_id, roi_number) + + def _populateTree(self): + # Group by Cell ID, Condition, and Epoch (each group represents a top-level item) + grouped = self.df.groupby(["Cell ID", "Condition", "Epoch"]) + for (cell_id, condition, epoch), group in grouped: + # region = group["Region"].iloc[0] if "Region" in group.columns else "Unknown" + # cell_text = f"{cell_id} | Region: {region} | Condition: {condition}" + cell_text = f"{cell_id} | {condition} | Epoch {epoch}" + + cell_item = QTreeWidgetItem([cell_text]) + cell_item.setFlags(cell_item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable) + cell_item.setCheckState(0, Qt.Checked if group["show_cell"].iloc[0] else Qt.Unchecked) + cell_item.setData(0, Qt.UserRole, ("cell", cell_id, condition, epoch)) + # abb + cell_item.setData(1, Qt.UserRole, ("cell", condition, epoch)) + + for _, row in group.iterrows(): + num_peaks = row["Number of Spikes"] if "Number of Spikes" in row else "N/A" + polarity = row["Polarity"] if "Polarity" in row else "N/A" + roi_text = f"ROI {row['ROI Number']} | {polarity} | Peaks: {num_peaks}" + + roi_item = QTreeWidgetItem([roi_text]) + roi_item.setFlags(roi_item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsSelectable) + roi_item.setCheckState(0, Qt.Checked if row["show_roi"] else Qt.Unchecked) + roi_item.setData(0, Qt.UserRole, ("roi", cell_id, condition, epoch, row['ROI Number'])) + # roi_item.setData(1, Qt.UserRole, ("roi", cell_id, condition, epoch, row['ROI Number'])) + cell_item.addChild(roi_item) + + self.tree.addTopLevelItem(cell_item) + cell_item.setExpanded(True) + + def handleItemChanged(self, item, column): + data = item.data(0, Qt.UserRole) + if data is None: + return + + self.tree.itemChanged.disconnect(self.handleItemChanged) + + if data[0] == "cell": + cell_id = data[1] + condition = data[2] + epoch = data[3] + checked = item.checkState(0) == Qt.Checked + for i in range(item.childCount()): + child = item.child(i) + child.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + self.cellToggled.emit(cell_id, condition, epoch, checked) + + elif data[0] == "roi": + cell_id, condition, epoch, roi_number = data[1], data[2], data[3], data[4] + checked = item.checkState(0) == Qt.Checked + self.roiToggled.emit(cell_id, roi_number, checked) + + # Update parent state based on children's state + parent = item.parent() + if parent: + all_checked = all(parent.child(i).checkState(0) == Qt.Checked for i in range(parent.childCount())) + all_unchecked = all(parent.child(i).checkState(0) == Qt.Unchecked for i in range(parent.childCount())) + if all_checked: + parent.setCheckState(0, Qt.Checked) + elif all_unchecked: + parent.setCheckState(0, Qt.Unchecked) + else: + parent.setCheckState(0, Qt.PartiallyChecked) + + self.tree.itemChanged.connect(self.handleItemChanged) + + def handleItemSelectionChanged(self): + selected_items = self.tree.selectedItems() + if not selected_items: + return + + item = selected_items[0] + data = item.data(0, Qt.UserRole) + if data is None: + return + + if data[0] == "cell": + # dataCondition = item.data(1, Qt.UserRole) + cell_id = data[1] + condition = data[2] + epoch = data[3] + self.cellSelected.emit(cell_id, condition, epoch) + + elif data[0] == "roi": + # (cell id, condition, epoch, roi) + cellID = data[1] + condition = data[2] + epoch = data[3] + roiNumber = data[4] + self.roiSelected.emit(cellID, condition, epoch, roiNumber) + + def toggleAll(self, state): + """Toggles all tree items on/off based on the checkbox state.""" + self.tree.itemChanged.disconnect(self.handleItemChanged) + + for i in range(self.tree.topLevelItemCount()): + parent = self.tree.topLevelItem(i) + parent.setCheckState(0, Qt.Checked if state == Qt.Checked else Qt.Unchecked) + for j in range(parent.childCount()): + child = parent.child(j) + child.setCheckState(0, Qt.Checked if state == Qt.Checked else Qt.Unchecked) + + self.tree.itemChanged.connect(self.handleItemChanged) + self.toggleAllToggled.emit(state == Qt.Checked) + + +# Example usage +if __name__ == '__main__': + app = QApplication(sys.argv) + + # Sample data + df = pd.DataFrame({ + 'Cell ID': ['Cell1', 'Cell1', 'Cell2', 'Cell2', 'Cell2'], + 'ROI Number': [1, 2, 1, 2, 3], + 'accept cell': [True, True, False, False, False], + 'accept roi': [True, False, True, False, True], + 'Number of Spikes': [5, 3, 7, 0, 2], + 'Region': ['CA1', 'CA1', 'CA3', 'CA3', 'CA3'], + 'Condition': ['Control', 'Control', 'Treated', 'Treated', 'Stimulated'], + 'Epoch': [1, 1, 1, 2, 1], + 'show_cell': [True, True, False, False, False], + 'show_roi': [True, False, True, False, True] + }) + + widget = KymTreeWidget(df) + + def on_cell_toggled(cell_id, condition, epoch, state): + print(f"Cell {cell_id} in Condition {condition} and Epoch {epoch} toggled to {state}") + + def on_roi_toggled(cell_id, roi, state): + print(f"ROI {roi} in Cell {cell_id} toggled to {state}") + + def on_cell_selected(cell_id, condition, epoch): + print(f"Selected Cell: {cell_id} in Condition {condition} and Epoch {epoch}") + + def on_roi_selected(cell_id, condition, epoch, roi): + print(f"Selected ROI {roi} in Cell {cell_id} in Condition {condition} and Epoch {epoch}") + + def on_toggle_all(state): + print(f"'Toggle All' checkbox toggled to {state}") + + widget.cellToggled.connect(on_cell_toggled) + widget.roiToggled.connect(on_roi_toggled) + widget.cellSelected.connect(on_cell_selected) + widget.roiSelected.connect(on_roi_selected) + widget.toggleAllToggled.connect(on_toggle_all) + + widget.show() + sys.exit(app.exec_()) diff --git a/sanpy/kym/simple_scatter/colin_util.py b/sanpy/kym/simple_scatter/colin_util.py new file mode 100644 index 00000000..2409fa77 --- /dev/null +++ b/sanpy/kym/simple_scatter/colin_util.py @@ -0,0 +1,603 @@ +import sys +import os +import time +from pprint import pprint + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from typing import List, Dict, Any, Optional, Tuple +import pathlib +from datetime import datetime +import warnings +from scipy import stats +from scipy.stats import mannwhitneyu, ttest_ind, pearsonr, spearmanr + +import roifile # to import Fiji roi manager zip files + +from colin_global import getAllTifFilePaths, FileInfo + +from sanpy.bAnalysis_ import bAnalysis +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis, PeakDetectionTypes + +from sanpy.kym.simple_scatter.colin_global import fijiConditions + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def _getRawTifDict(): + """Grab a list of all raw tif file and a list of all rois + + Returns + ------- + masterDict is a dict of list, keys are tif file and list is list of roi rects + rawTifList is a list of raw tif file paths + """ + rawTifList = getAllTifFilePaths() + + rawTifFiles = [os.path.split(x)[1] for x in rawTifList] + # make a dict of list, keys are tif file and list is list of roi rects + masterDict = {} + for rawTifFile in rawTifFiles: + # 20250612, masterDict was list [], now is dict + masterDict[rawTifFile] = { + 'sanpyRoiRects': [], + 'fijiRoiRects': [], + 'Region': '', + 'Cell ID': '', + 'Condition': '', + 'ROI Number': [], + 'Fiji ROI Name': [], + 'Mode': [], # mode 1/2 + } + + return masterDict, rawTifList + +# abb 20250617 +def _parseFijiRoiName(fijiRoiName:str, + dateFolder:str, + regionFolder:str, + fijiRoiRect:list, + sanpyRoiRect:list, + ) -> dict: + """Parse Colins Fiji roi name from RoiManager zip file + and return a dict of the roi info + + we get date and region from original folder path. + + trailing _1, _2, _3, ... are ROIs + """ + # 20250602 SSAN Control R1 LS1 Mode 1_1 + # 20250602 SSAN Control R2 LS1_0001 Mode 1_1 + # 20250602 SSAN Control R2 LS1_0002 Mode 1_1 + # 20250602 SSAN Control R2 LS1_0003 Mode 1_1 # the final one is 'Control' + + # logger.info(f'parsing fijiRoiName:{fijiRoiName}') + + roiName2 = fijiRoiName.replace(f'{dateFolder} ', '') + roiName2 = roiName2.replace(f'{regionFolder} ', '') + # now "Control R1 LS1 Mode 1_1" + + retDict = { + 'fijiRoiName': fijiRoiName, + 'Date': dateFolder, + 'Region': regionFolder, + 'Fiji ROI Condition': '', + 'Sanpy ROI Condition': '', # different from final condition based on _000n + # 'Fiji Repeat Number': '', + 'Fiji ROI Number': '', # from trailing _n where n is integer + 'Epoch': None, # first epoch _0000 will not have epoch (epoch 0) + 'Mode': '', + 'Polarity': '', + 'fijiRoiRect': fijiRoiRect, + 'sanpyRoiRect': sanpyRoiRect, + 'tifFileName': '', + } + + for cond in fijiConditions: + # cond is sanpy short name (Control, Ivab, Thap) + if cond in roiName2: + roiName2 = roiName2.replace(f'{cond} ', '') + fijiCond = cond + retDict['Fiji ROI Condition'] = fijiCond + if fijiCond == 'Ivabradine': + retDict['Sanpy ROI Condition'] = 'Ivab' + sanpyCond = 'Ivab' + elif fijiCond == 'Thapsigargin': + retDict['Sanpy ROI Condition'] = 'Thap' + sanpyCond = 'Thap' + # abb 20250623 mito atp + elif fijiCond == 'FCCP': + retDict['Sanpy ROI Condition'] = 'FCCP' + sanpyCond = 'FCCP' + elif fijiCond == 'Control': + retDict['Sanpy ROI Condition'] = 'Control' + sanpyCond = 'Control' + + else: + logger.warning(f'did not find condition in fijiRoiName:"{fijiRoiName}"') + retDict['Sanpy ROI Condition'] = 'Control' + sanpyCond = 'Control' + + break + + # two types of mode ('Mode 1', Mode 2') + for modeIdx, mode in enumerate(['Mode 1', 'Mode 2']): + if mode in roiName2: + roiName2 = roiName2.replace(f'{mode}', '') # no space after mode + retDict['Mode'] = mode + retDict['Polarity'] = 'Pos' if modeIdx == 0 else 'Neg' + break + + # find _000n where n is an integer + retDict['Epoch'] = 0 # default to 0 (there is never _0000) + for epochNumber in range(10): + epochNumberStr = f'_{str(epochNumber).zfill(4)}' + if epochNumberStr in roiName2: + roiName2 = roiName2.replace(epochNumberStr, '') + # for a given condition, different repeats + # the first kym will not have this !!! + retDict['Epoch'] = epochNumber + break + + # the trailing _n where n is integer is the ROI number + # we need to get the ROI number + # logger.info(f'roiName2:{roiName2}') # is "R1 LS1 _1" + roiNumberStr = roiName2.split('_')[-1] # TODO: will not work for roi numbeer >= 10 + retDict['Fiji ROI Number'] = roiNumberStr + roiName2 = roiName2.replace(f'_{roiNumberStr}', '') + + # renamed tif is like: 20250602 ISAN R1 LS3 Control Epoch 1 + tifFileName = f'{dateFolder} {regionFolder} {roiName2} {sanpyCond}' + if retDict["Epoch"] > 0: + tifFileName += f' Epoch {retDict["Epoch"]}' + tifFileName = tifFileName.replace(' ', ' ') + tifFileName += '.tif' + retDict['tifFileName'] = tifFileName + # logger.info(f'renamed tif is like:"20250602 ISAN R1 LS3 Control Epoch 1"') + # logger.info(f' tifFileName is:"{tifFileName}') + # tifFileName is:"20250602 ISAN R1 LS1 Control Epoch 0 + # sys.exit(1) + + return retDict + +def loadRoiFile(masterDict, oneZipFile) -> pd.DataFrame: + """Grab a list of all raw tif file and a list of all rois + + Returns + ------- + df is a df of roi info + masterDict is a dict of dict, keys are tif file and dict is dict of roi info + """ + + _path, _zipFile = os.path.split(oneZipFile) + # the _parentFolder is (isan, ssan) + # this is dependent on colins folder structue (very brittle') + _path, _regionFolder = os.path.split(_path) + _dateFolder = os.path.split(_path)[1] + + # logger.info(f'_dateFolder:{_dateFolder}') # should be (isan, ssan) + # logger.info(f'_regionFolder:{_regionFolder}') # should be (isan, ssan) + # logger.info(f'_zipFile:{_zipFile}') # should be (isan, ssan) + + # if _dateFolder == '250225': + # dateStr = '20250225' + # else: + # logger.error(f'did not find matching data folder for "{_dateFolder}') + + logger.info('loading roi zip:') + print(f' {oneZipFile}') + + fijiRoiList = roifile.roiread(oneZipFile) + logger.info(f' loaded {len(fijiRoiList)} Fiji rois') + # read_roi_zip(oneZipFile) + + # logger.info(f'roi_dict is:') + # pprint(roiDict, indent=2) + + roiInfoList = [] + + for fijiRoiNumber, roi in enumerate(fijiRoiList): + # top/bottom correspond to line scans + # left/right correspond to line scan points + + # [left, top, right, bottom] + # TODO: check for overflow (out of bounds) !!!! + left = roi.left + top = roi.top + right = roi.right + bottom = roi.bottom + + fijiRoiRect = [left, top, right, bottom] + + # colin is specifying a 1-2 pixel roi with a stroke with + # this will expand left/right + stroke_width = roi.stroke_width + + half_stroke_width = stroke_width // 2 + left -= half_stroke_width + right += half_stroke_width + + # up till 20250530 + # in sanpy, top has to be bigger than bottom !!! + sanpyRect = [top, right, bottom, left] # l/t/r/b + + # "20250602 SSAN Control R1 LS1 Mode 1_1" + fijiRoiName = roi.name + # logger.info(f'fijiRoiName:"{fijiRoiName}"') + + # handle some bad roi names + # 20250602 SSAN Control R2 LS1_0002 Mode 1_1 -> 20250602 SSAN Control R2 LS1_0001 Mode 1_2 + # 20250602 SSAN Control R2 LS1_0003 Mode 1_1 -> 20250602 SSAN Control R2 LS1_0001 Mode 1_3 + if fijiRoiName == "20250602 SSAN Control R2 LS1_0002 Mode 1_1": + fijiRoiName = "20250602 SSAN Control R2 LS1_0001 Mode 1_2" + elif fijiRoiName == "20250602 SSAN Control R2 LS1_0003 Mode 1_1": + fijiRoiName = "20250602 SSAN Control R2 LS1_0001 Mode 1_3" + + roiInfoDict = _parseFijiRoiName(fijiRoiName, _dateFolder, _regionFolder, fijiRoiRect, sanpyRect) + # logger.info(f'roiInfoDict:') + # pprint(roiInfoDict, indent=4) + + # check if we can match fiji roi name with a raw tif file + tifFileName = roiInfoDict['tifFileName'] + + # badRoiName = "20250602 SSAN Control R2 LS1" + # if badRoiName in fijiRoiName: + # logger.warning(f'potentialls bad fijiRoiName:"{fijiRoiName}"') + + if tifFileName not in masterDict.keys(): + # 20250602 SSAN Control R2 LS1_0002 Mode 1_1 + # 20250602 SSAN Control R2 LS1_0003 Mode 1_1 + # find all items in masterDict.keys() that match "20250602 SSAN R2 LS1 Control" + _tifList = masterDict.keys() + for _tif in _tifList: + if '20250602 SSAN R2 LS1 Control' in _tif: + # 20250602 SSAN R2 LS1 Control.tif + # 20250602 SSAN R2 LS1 Control Epoch 1.tif + print(_tif) + + logger.error(f'fijiRoiName:"{fijiRoiName}"') + logger.error(' did not find roi') + logger.error(f' tifFileName "{tifFileName}" in masterDict keys') + + # + # before we append, make sure we matched roi name with a tif file + roiInfoList.append(roiInfoDict) + + # make a df from roiInfoList + df = pd.DataFrame(roiInfoList) + logger.info(f'df has {len(df)} rows (one row per fiji roi)') + # pprint(df, indent=4) + + return df + + +def test_loadRoiFile(): + dfRoi, roiDict, tifList = makeRoiDictFromZips2() + + # roiTifList = dfRoi['tifFileName'].unique() + # for tifPath in tifList: + # tifFile = os.path.split(tifPath)[1] + # if tifFile not in roiTifList: + # logger.error(f'did not find tif file "{tifFile}" in roi dfMaster') + + logger.info(f'df has {len(dfRoi)} rows (one row per fiji roi)') + +def makeRoiDictFromZips2() -> tuple[pd.DataFrame, dict, list]: + """While starting analysis for mito atp. + """ + + masterDict, rawTifList = _getRawTifDict() + + from colin_global import _ROOT_ANALYSIS_FOLDER, _walk + zipPaths = _walk(_ROOT_ANALYSIS_FOLDER, '.zip', 5) + zipPaths = list(zipPaths) + zipPaths = [z for z in zipPaths if 'RoiSet.zip' in z] + + for zipIdx, oneZipPath in enumerate(zipPaths): + print(f'zipIdx:{zipIdx} of {len(zipPaths)}') + print(oneZipPath) + + dfOne = loadRoiFile(masterDict, oneZipPath) + if zipIdx == 0: + dfMaster = dfOne + else: + dfMaster = pd.concat([dfMaster, dfOne]) + + # check if any tif file keys have empty list (no roi) + # for k,v in masterDict.items(): + # if len(v['sanpyRoiRects'])==0: + # logger.error(f'did not get any rois for tif file "{k}"') + + #pprint(masterDict, indent=4) + return dfMaster, masterDict, rawTifList + +def makeRoiDictFromZips() -> tuple[pd.DataFrame, dict, list]: + """ + Returns + ------- + dfMaster is a df of roi info + masterDict is a dict of dict, keys are tif file and dict is dict of roi info + rawTifList is a list of raw tif file paths + """ + masterDict, rawTifList = _getRawTifDict() + + """ + masterDict[rawTifFile] = { + 'sanpyRoiRects': [], + 'fijiRoiRects': [], + 'Mode': [], + 'Region': '', + 'Cell ID': '', + 'Condition': '', + 'ROI Number': [], + 'Fiji ROI Name': [], + } + """ + # TODO: recurisvely walk folder and find .zip files + rootPath1 = '/Users/cudmore/Dropbox/data/colin/2025/roi manager - 20250520' + + zipList1 = [ + '20250225/ISAN/20250225 ISAN RoiSet.zip', + '20250225/SSAN/20250225 SSAN RoiSet.zip', + + '20250304/ISAN/20250304 ISAN RoiSet.zip', # changed name SSAN->ISAN + '20250304/SSAN/20250304 SSAN RoiSet.zip', + + '20250318/ISAN/20250318 ISAN RoiSet.zip', + '20250318/SSAN/20250318 SSAN RoiSet.zip', + ] + + rootPath2 = '/Users/cudmore/Dropbox/data/colin/2025/new-20250613' + zipList2 = ['20250602/ISAN/ISAN RoiSet.zip', + '20250602/SSAN/SSAN RoiSet.zip', + ] + + zipListFinal = [] + for oneZip in zipList1: + zipListFinal.append(os.path.join(rootPath1, oneZip)) + for oneZip in zipList2: + zipListFinal.append(os.path.join(rootPath2, oneZip)) + + + for zipIdx, oneZipPath in enumerate(zipListFinal): + # oneZipFile = os.path.join(rootPath, oneZip) + dfOne = loadRoiFile(masterDict, oneZipPath) + if zipIdx == 0: + dfMaster = dfOne + else: + dfMaster = pd.concat([dfMaster, dfOne]) + + # check if any tif file keys have empty list (no roi) + # for k,v in masterDict.items(): + # if len(v['sanpyRoiRects'])==0: + # logger.error(f'did not get any rois for tif file "{k}"') + + #pprint(masterDict, indent=4) + return dfMaster, masterDict, rawTifList + +def _setDetection_df_f0(kymRoiAnalysis:KymRoiAnalysis, + roiLabel : str, + cond : str): + + kymRoi = kymRoiAnalysis.getRoi(roiLabel) + + kymRoiDetection = kymRoi.getDetectionParams(0, PeakDetectionTypes.intensity) + + _ok = kymRoiDetection.setParam('detectThisTrace', 'df/f0') + + # prominence is different across conditions + if cond == 'Control': + prominence = 1.2 + else: + prominence = 1.4 + _ok = kymRoiDetection.setParam('Prominence', prominence) + # kymRoiDetection.setParam('Distance (ms)', 220) + _ok = kymRoiDetection.setParam('Width (ms)', 24) + +def _setDetection_Divide(kymRoiAnalysis:KymRoiAnalysis, + roiLabel : str, + cond : str): + + kymRoi = kymRoiAnalysis.getRoi(roiLabel) + + kymRoiDetection = kymRoi.getDetectionParams(0, PeakDetectionTypes.intensity) + + _ok = kymRoiDetection.setParam('detectThisTrace', 'Divided') + if _ok is None: + logger.error('detectThisTrace') + sys.exit(1) + # no background subtraction + prominence = 0.2 + _ok = kymRoiDetection.setParam('Prominence', prominence) + + _ok = kymRoiDetection.setParam('Background Subtract', 'Off') + if _ok is None: + logger.error('Background Subtract') + sys.exit(1) + kymRoiDetection.setParam('Exponential Detrend', False) # global + if _ok is None: + logger.error('Exponential Detrend') + sys.exit(1) + + kymRoiDetection.setParam('Distance (ms)', 220) + + # set global divide line scan + # NEEDS TO BE MANUALLY SET IN GUI + kymRoiAnalysis.setKymDetectionParam('Divide Line Scan', 1665) # global + +def insertRoiIntoSanPy(): + """Load all of Colins Fiji roi manager zip files and recreate SanPy analysis. + + DOES PEAK DETECTION ON NEWLY INSERTED ROIs + + Important: + When we load previous SanPy peaks and then detect no peaks -->> do we save no peaks ??? + """ + + _startTime = time.time() + + logger.info('making master dict') + dfMaster, masterDict, rawTifList = makeRoiDictFromZips2() + + channel = 0 + + for tifIdx, rawTifPath in enumerate(rawTifList): + tifFile = os.path.split(rawTifPath)[1] + print(f'=== === {tifIdx} of {len(rawTifList)} is "{tifFile}"') + + tifInfo = FileInfo.from_path(tifFile) + + # fetch all rows for tif file from dfMaster + _tifList = dfMaster['tifFileName'].unique() + if tifFile not in _tifList: + logger.error(f'did not find tif file "{tifFile}" in roi dfMaster') + + # when code fails, turn this on + # for p in _tifList: + # print(p) + # logger.error('-->> exit') + # sys.exit(1) + + continue + + dfTif = dfMaster[dfMaster['tifFileName']==tifFile] + # logger.info(f'dfTif:') + # print(dfTif) + if len(dfTif) == 0: + logger.error(f'did not find any rois for tif file "{tifFile}" in roi dfMaster') + logger.error('-->> exit') + sys.exit(1) + + # load tif as bAnalysis (does proper rotation) + ba = bAnalysis(rawTifPath) + imgData = ba.fileLoader._tif # list of color channel images + + # this will load my previous analysis (just one roi) + ka = KymRoiAnalysis(rawTifPath, imgData=imgData) + + # delete existing rois + roiLabels = ka.getRoiLabels() + for roiLabel in roiLabels: + existingRoiRect = ka.getRoi(roiLabel).getRect() + # top is bigger than bottom, imgdata has (0,0) in bottom left + logger.info(f' DELETIING ROI {roiLabel} existingRoiRect is:{existingRoiRect}') + ka.deleteRoi(roiLabel) + + # + # add all rois from fiji, for one tifFile + # fijiRoiDict = masterDict[tifFile] + + # sanpyRects = fijiRoiDict['sanpyRoiRects'] + # for sanpyRectIdx, sanpyRect in enumerate(sanpyRects): + for rowLabel, rowDict in dfTif.iterrows(): + # mode = mode=fijiRoiDict['Mode'][sanpyRectIdx] + polarity = rowDict['Polarity'] + sanpyRect = rowDict['sanpyRoiRect'] + # add roi with mode 1/2 + logger.info(f' adding roi with polarity:{polarity} sanpyRect:{sanpyRect}') + + newRoi = ka.addROI(sanpyRect) + kymRoiDetection = newRoi.getDetectionParams(0, PeakDetectionTypes.intensity) + + _ok = kymRoiDetection.setParam('Polarity', polarity) + + # + # peak detect each roi (each new roi from fiji) + if 1: + cond = tifInfo.condition + if cond == 'Control': + prominence = 0.8 + else: + prominence = 1.6 + logger.info(f' cond:{cond} prominence:{prominence}') + + for roiLabel in ka.getRoiLabels(): + kymRoi = ka.getRoi(roiLabel) + + kymRoiDetection = kymRoi.getDetectionParams(0, PeakDetectionTypes.intensity) + # logger.info(f'kymRoiDetection:{kymRoiDetection.getParam("Polarity")}') + kymRoiDetection.setParam('Distance (ms)', 220) + kymRoiDetection.setParam('Width (ms)', 20) + + # prominence is different across conditions + _ok = kymRoiDetection.setParam('Prominence', prominence) + + # f/f0 + # _setDetection_f_f0(ka, roiLabel, cond) + + # df/f0 + # _setDetection_df_f0(ka, roiLabel, cond) + + # + # 20250608 detect 'Divided' + # + # sanpy-20250608-div + # _setDetection_Divide(ka, roiLabel, cond) + + # peak detect + kymRoi.peakDetect(channel=0, peakDetectionType=PeakDetectionTypes.intensity) + + # save the analysis + logger.info(f'SAVING ANALYSIS {tifFile} with {ka.numRoi} rois') + ka.saveAnalysis() + + # + # break + # if tifIdx > 1: + # break + + _endTime = time.time() + _elapsedTime = _endTime - _startTime + logger.info(f' elapsed time:{_elapsedTime:.2f} seconds') + + +def checkRoiPerCondition(): + """Check that each (cell id, condition) has the same number of 'ROI Number' + + If this is not true -->> problems !!! + + We are getting different number of roi per (id, condition) + -->> remake my master df by throwing out all previous sanpy csv analysis + """ + # savePath = '/Users/cudmore/colin_peak_mean_20250521.csv' + # df = pd.read_csv(savePath) + + from colin_global import loadMeanDfFile + df = loadMeanDfFile() + + cellIDs = df['Cell ID'].unique() + for cellID in cellIDs: + logger.info(f'cellID:{cellID}') + dfCellID = df[df['Cell ID']==cellID] + conditions = dfCellID['Condition'].unique() + # logger.info(f'cellID:{cellID} has conditions:{conditions}') + _numRoi = None + for idx, condition in enumerate(conditions): + dfCondition = dfCellID[dfCellID['Condition']==condition] + rois = dfCondition['ROI Number'].unique() + if idx == 0: + _numRoi = len(rois) + numRoi = len(rois) + print(f' cellID:{cellID} condition:{condition} num:{numRoi}') + if numRoi != _numRoi: + logger.error(f' cellID:{cellID} condition:{condition} numRoi:{numRoi} != _numRoi:{_numRoi}') + # sys.exit(1) + +if __name__ == '__main__': + # run this first to ensure we get matches between colins fiji roi names and tif file + if 0: + test_loadRoiFile() + + # load all Colin's fiji roi manager and recreate ALL sanpy analysis + if 0: + # WILL PERFORM PEAK DETECTION !!! + insertRoiIntoSanPy() + + # _throwOutAllSanPyAnalysis() + + # works + # check that each (Cell ID, Condition) has the same number of ROI + if 1: + checkRoiPerCondition() diff --git a/sanpy/kym/tests/README.md b/sanpy/kym/tests/README.md new file mode 100644 index 00000000..d7415cfe --- /dev/null +++ b/sanpy/kym/tests/README.md @@ -0,0 +1,67 @@ +# Colin Module Tests + +This directory contains tests for the colin-related modules in the `simple_scatter` package. + +## Test Files + +### `test_colin_global.py` +Tests for the `colin_global` module, including: +- **FileInfo dataclass**: Tests the parsing of file paths like "20250602 ISAN R3 LS3 Control Epoch 1.tif" +- **iterate_unique_cell_rows function**: Tests the iterator that returns unique rows from a DataFrame for a given cellID +- **Edge cases**: Tests error handling for malformed file paths + +### `test_colin_stats.py` +Tests for the `colin_stats` module, including: +- **FileInfo integration**: Tests that FileInfo works correctly within colin_stats +- **_removeOneSpikeControl logic**: Tests the logic for filtering control cells with low spike counts +- **DataFrame operations**: Tests various pandas operations used in the module + +## Running Tests + +### Individual Test Files +```bash +# Run colin_global tests +python sanpy/kym/tests/test_colin_global.py + +# Run colin_stats tests +python sanpy/kym/tests/test_colin_stats.py +``` + +### All Tests +```bash +# Run all colin-related tests +python sanpy/kym/tests/run_all_tests.py +``` + +## Test Structure + +Each test file follows this structure: +1. **Setup**: Import necessary modules and set up test data +2. **Test Functions**: Individual test functions for specific functionality +3. **Main Runner**: A main section that runs all tests when the file is executed directly + +## What Was Tested Today + +### FileInfo Dataclass +- Parsing of file paths with different conditions (Control, Ivab, Thap) +- Extraction of cellID, epoch, date, region, and condition +- Handling of files with and without epochs +- Error handling for malformed paths + +### Iterator Function +- Returning unique rows for a given cellID +- Handling duplicate rows correctly +- Working with non-existent cellIDs +- Integration with list comprehensions + +### DataFrame Operations +- Groupby and transform operations +- Filtering by conditions +- Avoiding SettingWithCopyWarning with `.copy()` + +## Notes + +- Tests are designed to be run independently +- Each test function includes detailed output for debugging +- The test runner provides a summary of all test results +- Tests use sample data that mimics the real data structure \ No newline at end of file diff --git a/sanpy/kym/tests/__init__.py b/sanpy/kym/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sanpy/kym/tests/run_all_tests.py b/sanpy/kym/tests/run_all_tests.py new file mode 100644 index 00000000..e5b35855 --- /dev/null +++ b/sanpy/kym/tests/run_all_tests.py @@ -0,0 +1,63 @@ +""" +Test runner for colin-related modules. +""" + +import sys +import os +import subprocess + +def run_test_file(test_file): + """Run a test file and return success status.""" + print(f"\n{'='*60}") + print(f"Running {test_file}") + print(f"{'='*60}") + + try: + result = subprocess.run([sys.executable, test_file], + capture_output=True, text=True, cwd=os.path.dirname(__file__)) + + if result.returncode == 0: + print("✅ PASSED") + print(result.stdout) + else: + print("❌ FAILED") + print("STDOUT:") + print(result.stdout) + print("STDERR:") + print(result.stderr) + + return result.returncode == 0 + + except Exception as e: + print(f"❌ ERROR running {test_file}: {e}") + return False + +def main(): + """Run all colin-related tests.""" + test_files = [ + 'test_colin_global.py', + 'test_colin_stats.py' + ] + + print("Running all colin-related tests...") + + passed = 0 + total = len(test_files) + + for test_file in test_files: + if run_test_file(test_file): + passed += 1 + + print(f"\n{'='*60}") + print(f"Test Summary: {passed}/{total} tests passed") + print(f"{'='*60}") + + if passed == total: + print("🎉 All tests passed!") + return 0 + else: + print("⚠️ Some tests failed!") + return 1 + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file diff --git a/sanpy/kym/tests/test_colin_global.py b/sanpy/kym/tests/test_colin_global.py new file mode 100644 index 00000000..b33f2674 --- /dev/null +++ b/sanpy/kym/tests/test_colin_global.py @@ -0,0 +1,219 @@ +""" +Tests for colin_global module. +""" + +import sys +import os + +# Add the simple_scatter directory to the path so we can import the modules +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'simple_scatter')) + +import pandas as pd +from colin_global import FileInfo, iterate_unique_cell_rows + + +def test_file_info(): + """Test the FileInfo dataclass with various file paths.""" + test_cases = [ + ("20250602 ISAN R3 LS3 Control Epoch 1.tif", { + 'cellID': '20250602 ISAN R3 LS3', + 'epoch': 1, + 'date': '20250602', + 'region': 'ISAN', + 'condition': 'Control' + }), + ("20250602 SSAN R1 LS2 Ivab.tif", { + 'cellID': '20250602 SSAN R1 LS2', + 'epoch': 0, + 'date': '20250602', + 'region': 'SSAN', + 'condition': 'Ivab' + }), + ("20250602 ISAN R2 LS1 Thap Epoch 2.tif", { + 'cellID': '20250602 ISAN R2 LS1', + 'epoch': 2, + 'date': '20250602', + 'region': 'ISAN', + 'condition': 'Thap' + }), + ("20250602 SSAN R4 LS3 Control.tif", { + 'cellID': '20250602 SSAN R4 LS3', + 'epoch': 0, + 'date': '20250602', + 'region': 'SSAN', + 'condition': 'Control' + }) + ] + + for test_path, expected in test_cases: + file_info = FileInfo.from_path(test_path) + print(f"Testing FileInfo with: {test_path}") + print(f" cellID: {file_info.cellID}") + print(f" epoch: {file_info.epoch}") + print(f" date: {file_info.date}") + print(f" region: {file_info.region}") + print(f" condition: {file_info.condition}") + print() + + # Assertions + assert file_info.cellID == expected['cellID'], f"cellID mismatch: expected {expected['cellID']}, got {file_info.cellID}" + assert file_info.epoch == expected['epoch'], f"epoch mismatch: expected {expected['epoch']}, got {file_info.epoch}" + assert file_info.date == expected['date'], f"date mismatch: expected {expected['date']}, got {file_info.date}" + assert file_info.region == expected['region'], f"region mismatch: expected {expected['region']}, got {file_info.region}" + assert file_info.condition == expected['condition'], f"condition mismatch: expected {expected['condition']}, got {file_info.condition}" + + print("✅ All FileInfo assertions passed!") + + +def test_iterator_function(): + """Test the iterate_unique_cell_rows function.""" + # Create a sample DataFrame with some duplicate rows and edge cases + data = { + 'Cell ID': ['20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', + '20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', # Duplicate Control rows + '20250602 SSAN R1 LS2', '20250602 SSAN R1 LS2', + '20250602 ISAN R2 LS1', '20250602 ISAN R2 LS1'], + 'Condition': ['Control', 'Ivab', 'Thap', 'Control', 'Control', # Duplicate Control + 'Control', 'Ivab', + 'Control', 'Ivab'], + 'Epoch': [0, 0, 0, 0, 0, # Same epoch for duplicates + 1, 1, + 2, 2], + 'Region': ['ISAN', 'ISAN', 'ISAN', 'ISAN', 'ISAN', + 'SSAN', 'SSAN', + 'ISAN', 'ISAN'], + 'Value': [10, 15, 12, 10, 10, # Same values for duplicates + 8, 11, + 20, 25] + } + df = pd.DataFrame(data) + + print("Sample DataFrame (with duplicates):") + print(df) + print() + + # Test with first cell ID (has duplicates) + cell_id = '20250602 ISAN R3 LS3' + print(f"Unique rows for cell ID: {cell_id}") + unique_count = 0 + unique_conditions = [] + unique_epochs = [] + unique_values = [] + + for i, row in enumerate(iterate_unique_cell_rows(df, cell_id)): + print(f" Row {i+1}: {row['Condition']} Epoch {row['Epoch']} Value {row['Value']}") + unique_count += 1 + unique_conditions.append(row['Condition']) + unique_epochs.append(row['Epoch']) + unique_values.append(row['Value']) + + print(f" Total unique rows: {unique_count}") + print() + + # Assertions for first cell ID + assert unique_count == 3, f"Expected 3 unique rows, got {unique_count}" + assert set(unique_conditions) == {'Control', 'Ivab', 'Thap'}, f"Expected conditions {'Control', 'Ivab', 'Thap'}, got {set(unique_conditions)}" + assert all(epoch == 0 for epoch in unique_epochs), f"Expected all epochs to be 0, got {unique_epochs}" + assert set(unique_values) == {10, 15, 12}, f"Expected values {10, 15, 12}, got {set(unique_values)}" + + # Test with second cell ID (no duplicates) + cell_id = '20250602 SSAN R1 LS2' + print(f"Unique rows for cell ID: {cell_id}") + unique_count = 0 + unique_conditions = [] + unique_epochs = [] + + for i, row in enumerate(iterate_unique_cell_rows(df, cell_id)): + print(f" Row {i+1}: {row['Condition']} Epoch {row['Epoch']} Value {row['Value']}") + unique_count += 1 + unique_conditions.append(row['Condition']) + unique_epochs.append(row['Epoch']) + + print(f" Total unique rows: {unique_count}") + print() + + # Assertions for second cell ID + assert unique_count == 2, f"Expected 2 unique rows, got {unique_count}" + assert set(unique_conditions) == {'Control', 'Ivab'}, f"Expected conditions {'Control', 'Ivab'}, got {set(unique_conditions)}" + assert set(unique_epochs) == {1}, f"Expected all epochs to be 1, got {set(unique_epochs)}" + + # Test with non-existent cell ID + cell_id = 'NonExistentCell' + print(f"Unique rows for cell ID: {cell_id}") + unique_count = 0 + for i, row in enumerate(iterate_unique_cell_rows(df, cell_id)): + print(f" Row {i+1}: {row['Condition']} Epoch {row['Epoch']}") + unique_count += 1 + print(f" Total unique rows: {unique_count}") + print() + + # Assertions for non-existent cell ID + assert unique_count == 0, f"Expected 0 unique rows for non-existent cell, got {unique_count}" + + # Demonstrate using the iterator in a list comprehension + cell_id = '20250602 ISAN R3 LS3' + unique_conditions = [row['Condition'] for row in iterate_unique_cell_rows(df, cell_id)] + print(f"Unique conditions for {cell_id}: {unique_conditions}") + + # Assertions for list comprehension + assert len(unique_conditions) == 3, f"Expected 3 conditions, got {len(unique_conditions)}" + assert set(unique_conditions) == {'Control', 'Ivab', 'Thap'}, f"Expected conditions {'Control', 'Ivab', 'Thap'}, got {set(unique_conditions)}" + + print("✅ All iterator function assertions passed!") + + +def test_file_info_edge_cases(): + """Test FileInfo with edge cases and error conditions.""" + # Test with missing region + try: + file_info = FileInfo.from_path("20250602 R3 LS3 Control.tif") + print(f"Unexpected success with missing region: {file_info.region}") + # Should not reach here - should have logged an error + assert file_info.region == 'Unknown', f"Expected 'Unknown' region, got {file_info.region}" + except Exception as e: + print(f"Expected error with missing region: {e}") + + # Test with missing condition + try: + file_info = FileInfo.from_path("20250602 ISAN R3 LS3.tif") + print(f"Unexpected success with missing condition: {file_info.condition}") + # Should not reach here - should have logged an error + assert file_info.condition == 'Unknown', f"Expected 'Unknown' condition, got {file_info.condition}" + except Exception as e: + print(f"Expected error with missing condition: {e}") + + # Test with different epoch formats + test_paths = [ + ("20250602 ISAN R3 LS3 Control Epoch 5.tif", 5), + ("20250602 ISAN R3 LS3 Control.tif", 0), # No epoch + ("20250602 ISAN R3 LS3 Control Epoch 0.tif", 0) + ] + + for test_path, expected_epoch in test_paths: + file_info = FileInfo.from_path(test_path) + print(f"Testing: {test_path}") + print(f" Epoch: {file_info.epoch}") + print() + + # Assertions + assert file_info.epoch == expected_epoch, f"Expected epoch {expected_epoch}, got {file_info.epoch}" + assert file_info.region == 'ISAN', f"Expected region 'ISAN', got {file_info.region}" + assert file_info.condition == 'Control', f"Expected condition 'Control', got {file_info.condition}" + + print("✅ All edge case assertions passed!") + + +if __name__ == '__main__': + print("Running colin_global tests...") + print("=" * 50) + + print("\n1. Testing FileInfo dataclass:") + test_file_info() + + print("\n2. Testing iterator function:") + test_iterator_function() + + print("\n3. Testing edge cases:") + test_file_info_edge_cases() + + print("\n🎉 All tests completed successfully!") \ No newline at end of file diff --git a/sanpy/kym/tests/test_colin_stats.py b/sanpy/kym/tests/test_colin_stats.py new file mode 100644 index 00000000..f06d9b51 --- /dev/null +++ b/sanpy/kym/tests/test_colin_stats.py @@ -0,0 +1,218 @@ +""" +Tests for colin_stats module. +""" + +import sys +import os + +# Add the simple_scatter directory to the path so we can import the modules +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'simple_scatter')) + +import pandas as pd +from colin_stats import FileInfo + + +def test_file_info_usage(): + """Test that FileInfo works correctly in colin_stats module.""" + test_path = "20250602 ISAN R3 LS3 Control Epoch 1.tif" + file_info = FileInfo.from_path(test_path) + print(f"Testing FileInfo in colin_stats with: {test_path}") + print(f" cellID: {file_info.cellID}") + print(f" epoch: {file_info.epoch}") + print(f" date: {file_info.date}") + print(f" region: {file_info.region}") + print(f" condition: {file_info.condition}") + print() + + # Assertions + expected = { + 'cellID': '20250602 ISAN R3 LS3', + 'epoch': 1, + 'date': '20250602', + 'region': 'ISAN', + 'condition': 'Control' + } + + assert file_info.cellID == expected['cellID'], f"cellID mismatch: expected {expected['cellID']}, got {file_info.cellID}" + assert file_info.epoch == expected['epoch'], f"epoch mismatch: expected {expected['epoch']}, got {file_info.epoch}" + assert file_info.date == expected['date'], f"date mismatch: expected {expected['date']}, got {file_info.date}" + assert file_info.region == expected['region'], f"region mismatch: expected {expected['region']}, got {file_info.region}" + assert file_info.condition == expected['condition'], f"condition mismatch: expected {expected['condition']}, got {file_info.condition}" + + print("✅ All FileInfo usage assertions passed!") + + +def test_remove_one_spike_control_logic(): + """Test the logic of _removeOneSpikeControl function without running the full analysis.""" + # Create a sample DataFrame that mimics the structure used in _removeOneSpikeControl + data = { + 'Cell ID': ['20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', + '20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', '20250602 ISAN R3 LS3', + '20250602 SSAN R1 LS2', '20250602 SSAN R1 LS2', '20250602 SSAN R1 LS2'], + 'Condition': ['Control', 'Control', 'Control', 'Ivab', 'Ivab', 'Thap', + 'Control', 'Ivab', 'Thap'], + 'Epoch': [0, 1, 2, 0, 1, 0, 0, 0, 0], + 'ROI Number': ['R1', 'R1', 'R1', 'R1', 'R1', 'R1', 'R2', 'R2', 'R2'], + 'Number of Spikes': [1, 2, 3, 5, 6, 4, 0, 3, 2], # First cell has <=2 spikes in last epoch + 'Region': ['ISAN', 'ISAN', 'ISAN', 'ISAN', 'ISAN', 'ISAN', 'SSAN', 'SSAN', 'SSAN'] + } + dfMean = pd.DataFrame(data) + + print("Sample DataFrame for _removeOneSpikeControl test:") + print(dfMean) + print() + + # Simulate the logic from _removeOneSpikeControl + removeLessThanEqual = 2 + + # Create an explicit copy to avoid SettingWithCopyWarning + dfControl = dfMean[dfMean['Condition'] == 'Control'].copy() + + # For each cell id, get the last epoch + dfControl['Last Epoch'] = dfControl.groupby('Cell ID')['Epoch'].transform('max') + + # Filter by last epoch + dfControl = dfControl[dfControl['Epoch'] == dfControl['Last Epoch']] + + # Filter by number of spikes + dfOneSpike = dfControl[dfControl['Number of Spikes'] <= removeLessThanEqual] + + print("Control rows after filtering by last epoch:") + print(dfControl) + print() + + print("Rows with <=2 spikes in last epoch:") + print(dfOneSpike) + print() + + numControlWithOneSpike = len(dfOneSpike['Cell ID'].unique()) + print(f"Number of Cell IDs with <= {removeLessThanEqual} peaks: {numControlWithOneSpike}") + + # Show which cells would be flagged + flagged_cells = [] + for _, row in dfOneSpike.iterrows(): + cellID = row['Cell ID'] + roiNumber = row['ROI Number'] + print(f" Would flag: Cell ID '{cellID}', ROI '{roiNumber}'") + flagged_cells.append((cellID, roiNumber)) + + # Assertions + assert len(dfControl) == 2, f"Expected 2 control rows after filtering by last epoch, got {len(dfControl)}" + assert len(dfOneSpike) == 1, f"Expected 1 row with <=2 spikes, got {len(dfOneSpike)}" + assert numControlWithOneSpike == 1, f"Expected 1 cell ID with <=2 peaks, got {numControlWithOneSpike}" + + # Check specific values + expected_flagged_cell = ('20250602 SSAN R1 LS2', 'R2') + assert flagged_cells == [expected_flagged_cell], f"Expected flagged cell {expected_flagged_cell}, got {flagged_cells}" + + # Check that the flagged cell has 0 spikes + flagged_row = dfOneSpike.iloc[0] + assert flagged_row['Number of Spikes'] == 0, f"Expected 0 spikes for flagged cell, got {flagged_row['Number of Spikes']}" + assert flagged_row['Epoch'] == 0, f"Expected epoch 0 for flagged cell, got {flagged_row['Epoch']}" + + print("✅ All _removeOneSpikeControl logic assertions passed!") + + +def test_dataframe_operations(): + """Test various DataFrame operations that might be used in colin_stats.""" + # Test the groupby and transform operations + data = { + 'Cell ID': ['A', 'A', 'A', 'B', 'B', 'B'], + 'Epoch': [0, 1, 2, 0, 1, 3], + 'Value': [10, 20, 30, 5, 15, 25] + } + df = pd.DataFrame(data) + + print("Original DataFrame:") + print(df) + print() + + # Test groupby transform + df['Max Epoch'] = df.groupby('Cell ID')['Epoch'].transform('max') + print("After adding Max Epoch column:") + print(df) + print() + + # Test filtering by condition + df_filtered = df[df['Epoch'] == df['Max Epoch']] + print("Filtered to last epoch for each cell:") + print(df_filtered) + print() + + # Assertions + assert len(df) == 6, f"Expected 6 rows in original DataFrame, got {len(df)}" + assert len(df_filtered) == 2, f"Expected 2 rows after filtering, got {len(df_filtered)}" + + # Check Max Epoch values + cell_a_max_epoch = df[df['Cell ID'] == 'A']['Max Epoch'].iloc[0] + cell_b_max_epoch = df[df['Cell ID'] == 'B']['Max Epoch'].iloc[0] + assert cell_a_max_epoch == 2, f"Expected max epoch 2 for cell A, got {cell_a_max_epoch}" + assert cell_b_max_epoch == 3, f"Expected max epoch 3 for cell B, got {cell_b_max_epoch}" + + # Check filtered results + filtered_cell_ids = df_filtered['Cell ID'].tolist() + filtered_epochs = df_filtered['Epoch'].tolist() + filtered_values = df_filtered['Value'].tolist() + + assert filtered_cell_ids == ['A', 'B'], f"Expected cell IDs ['A', 'B'], got {filtered_cell_ids}" + assert filtered_epochs == [2, 3], f"Expected epochs [2, 3], got {filtered_epochs}" + assert filtered_values == [30, 25], f"Expected values [30, 25], got {filtered_values}" + + # Test that the filtered rows have the correct Max Epoch values + for _, row in df_filtered.iterrows(): + assert row['Epoch'] == row['Max Epoch'], f"Epoch {row['Epoch']} should equal Max Epoch {row['Max Epoch']}" + + print("✅ All DataFrame operations assertions passed!") + + +def test_copy_operation(): + """Test that .copy() operation works correctly to avoid SettingWithCopyWarning.""" + # Create original DataFrame + original_data = { + 'Cell ID': ['A', 'A', 'B', 'B'], + 'Condition': ['Control', 'Control', 'Control', 'Control'], + 'Epoch': [0, 1, 0, 1], + 'Value': [10, 20, 30, 40] + } + df_original = pd.DataFrame(original_data) + + # Create a slice (this would normally cause SettingWithCopyWarning) + df_slice = df_original[df_original['Condition'] == 'Control'] + + # Create a copy (this should avoid the warning) + df_copy = df_original[df_original['Condition'] == 'Control'].copy() + + # Modify the copy + df_copy['New Column'] = 'test' + + # Assertions + assert len(df_slice) == 4, f"Expected 4 rows in slice, got {len(df_slice)}" + assert len(df_copy) == 4, f"Expected 4 rows in copy, got {len(df_copy)}" + assert 'New Column' in df_copy.columns, "New Column should be added to copy" + assert 'New Column' not in df_original.columns, "Original DataFrame should not be modified" + assert 'New Column' not in df_slice.columns, "Slice should not be modified" + + # Check that original data is preserved + assert len(df_original) == 4, f"Original DataFrame should still have 4 rows, got {len(df_original)}" + assert 'New Column' not in df_original.columns, "Original DataFrame should not have New Column" + + print("✅ All copy operation assertions passed!") + + +if __name__ == '__main__': + print("Running colin_stats tests...") + print("=" * 50) + + print("\n1. Testing FileInfo usage in colin_stats:") + test_file_info_usage() + + print("\n2. Testing _removeOneSpikeControl logic:") + test_remove_one_spike_control_logic() + + print("\n3. Testing DataFrame operations:") + test_dataframe_operations() + + print("\n4. Testing copy operation:") + test_copy_operation() + + print("\n🎉 All tests completed successfully!") \ No newline at end of file diff --git a/sanpy/kym/tests/test_kym_roi.py b/sanpy/kym/tests/test_kym_roi.py new file mode 100644 index 00000000..56f4bd70 --- /dev/null +++ b/sanpy/kym/tests/test_kym_roi.py @@ -0,0 +1,30 @@ +import sys +import os +import numpy as np +import matplotlib.pyplot as plt +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import Qt + +from sanpy.kym.kymRoiAnalysis import KymRoi +# from sanpy.kym.kymRoiAnalysis import MplKym + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def _broken_test_kym_roi(): + import tifffile + + path = '/Users/cudmore/Dropbox/data/colin/sanAtp/ISAN Linescan 1.tif' + path = '/Users/cudmore/Dropbox/data/colin/sanAtp/ISAN Linescan 4.tif' + + imgData = tifffile.imread(path) + imgData = np.rot90(imgData) + # imgData = np.flip(imgData) + logger.info(f"Loaded tif with shape: {imgData.shape}") + + kroi = KymRoi(imgData, path=None) + + # MplKym(imgData) + +if __name__ == '__main__': + _broken_test_kym_roi() \ No newline at end of file diff --git a/sanpy/kym/tests/test_kym_roi_widget.py b/sanpy/kym/tests/test_kym_roi_widget.py new file mode 100644 index 00000000..a3d3f036 --- /dev/null +++ b/sanpy/kym/tests/test_kym_roi_widget.py @@ -0,0 +1,111 @@ +import sys +import os +import numpy as np +import matplotlib.pyplot as plt +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import Qt + +import qdarktheme + +from PyQt5 import QtWidgets + +from sanpy.kym.interface.kymRoiWidget import KymRoiWidget + +from sanpy.kym.logger import get_logger +logger = get_logger(__name__) + +def _broken_test_kym_roi_widget(): + from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis + + # path = '/Users/cudmore/Dropbox/data/colin/sanAtp/ISAN Linescan 3.tif' + + # + # colin retreat sept 2024 + # + + # santana int done + # (1) + # path = '/Users/cudmore/Desktop/retreat-sept-2024/ISAN Linescan 1.tif' + # (2) + # super noisy - no peaks? + # path = '/Users/cudmore/Desktop/retreat-sept-2024/ISAN Linescan 9.tif' + # (3) + # negative peaks - REALY NOISY + # roi there is exponential decay but fit failed + # path = '/Users/cudmore/Desktop/retreat-sept-2024/ISAN Linescan 10.tif' #negative peak, roi 2 has issues + # (4), good for negative peaks + # path = '/Users/cudmore/Desktop/retreat-sept-2024/SSAN Linescan 1.tif' + # (5) + # path = '/Users/cudmore/Desktop/retreat-sept-2024/SSAN Linescan 11.tif' + # (6) + + # colin line across lots of cells + path = '/Users/cudmore/Desktop/retreat-sept-2024/SSAN Linescan 12.tif' + + # paula for diam detection + # path = '/Users/cudmore/Dropbox/data/cell-shortening/paula/cell01_C002T001.tif' + + # from andy collaborator (manual save of czi to tif) + # this is one line scan of blood flow + path = '/Users/cudmore/Dropbox/data/sanpy-users/kym-users/czi-data/linescansForVelocityMeasurement/fiji-export/Image 2.tif' + + path = '/Users/cudmore/Dropbox/data/sanpy-users/kym-users/czi-data/disjointedlinescansandframescans/fiji-export/Image 10.tif' + + # + # paula 2-channel + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 01/cell 01_C001T001.tif' + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 02/cell 02_0001_C001T001.tif' + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 03/cell 03_0001_C001T001.tif' + # increase of iATP during Ca++ rise + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 04/cell 04_C001T001.tif' + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 06/cell 06_C001T001.tif' + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 07/cell 07_C001T001.tif' + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 08/cell 08_C001T001.tif' + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 09/cell 09_C001T001.tif' + + # decrease iATP + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 10/cell 10_C001T001.tif' + + # bi-phasic atp, decrease then increase + # path = '/Users/cudmore/Dropbox/data/colin/2-channel kymographs/cell 14/cell 14_C001T001.tif' + + path = '/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-atp-20250623-RHC/20250312/ISAN/20250312 ISAN FCCP R1 LS2.tif.frames/20250312 ISAN R1 LS2 FCCP.tif' + + from sanpy.fileloaders import fileLoader_tif + flt = fileLoader_tif(path) + + # _imgData1 = flt.getTifData(channel=1) + # print(f'_imgData1:{_imgData1.shape}') + # imgData = flt.tifData # property to get first channel + + imgData = flt._tif # list of color channel images + + logger.warning(f'removing sum 0 color channels from imgData:{len(imgData)}') + + finalImgData = [] + for _imgData in imgData: + if np.sum(_imgData) == 0: + continue + else: + finalImgData.append(_imgData) + + logger.info(f' final number of channels is {len(finalImgData)}') + # + kra = KymRoiAnalysis(path, imgData=finalImgData) + + # kra.peakDetectAllRoi() + + app = QtWidgets.QApplication(sys.argv) + + qdarktheme.setup_theme("dark") + + kw = KymRoiWidget(kra) + kw.show() + + sys.exit(app.exec_()) + +def loadSaveAllRoi(): + pass + +if __name__ == '__main__': + _broken_test_kym_roi_widget() \ No newline at end of file diff --git a/sanpy/kym/tests/test_resizable_toolbar.py b/sanpy/kym/tests/test_resizable_toolbar.py new file mode 100644 index 00000000..26955872 --- /dev/null +++ b/sanpy/kym/tests/test_resizable_toolbar.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Test script to demonstrate the resizable toolbar functionality in ScatterWidget. +""" + +import sys +import pandas as pd +from PyQt5 import QtWidgets, QtGui, QtCore + +# Add the parent directory to the path to import the module +sys.path.append('..') +from colin_scatter_widget import ScatterWidget + +def create_test_data(): + """Create some test data for the scatter widget.""" + # Create sample data + data = { + 'Cell ID': ['Cell1', 'Cell1', 'Cell2', 'Cell2', 'Cell3', 'Cell3'], + 'Region': ['SSAN', 'SSAN', 'ISAN', 'ISAN', 'SSAN', 'SSAN'], + 'Condition': ['Control', 'Ivab', 'Control', 'Ivab', 'Control', 'Ivab'], + 'Epoch': [1, 1, 1, 1, 1, 1], + 'Polarity': ['Positive', 'Positive', 'Negative', 'Negative', 'Positive', 'Positive'], + 'ROI Number': [1, 1, 1, 1, 1, 1], + 'Peak Inst Freq (Hz)': [10.5, 8.2, 12.1, 9.8, 11.3, 7.9], + 'Peak Height': [0.8, 0.6, 0.9, 0.7, 0.85, 0.65], + 'File Number': [1, 1, 2, 2, 3, 3], + 'Tif File': ['test1.tif', 'test1.tif', 'test2.tif', 'test2.tif', 'test3.tif', 'test3.tif'], + 'Date': ['2024-01-01', '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03', '2024-01-03'], + 'show_region': [True, True, True, True, True, True], + 'show_condition': [True, True, True, True, True, True], + 'show_cell': [True, True, True, True, True, True], + 'show_roi': [True, True, True, True, True, True], + 'show_polarity': [True, True, True, True, True, True], + 'show_epoch': [True, True, True, True, True, True], + } + + masterDf = pd.DataFrame(data) + meanDf = masterDf.copy() # For simplicity, use same data for mean + + return masterDf, meanDf + +def main(): + """Main function to test the resizable toolbar.""" + app = QtWidgets.QApplication(sys.argv) + app.setStyle("Fusion") + app.setFont(QtGui.QFont("Arial", 10)) + + # Create test data + masterDf, meanDf = create_test_data() + + # Define hue list for the widget + hueList = [ + 'File Number', 'Cell ID', 'Condition', 'Epoch', + 'Condition Epoch', 'Region', 'Date', 'ROI Number', 'Polarity' + ] + + # Create the scatter widget + scatterWidget = ScatterWidget( + masterDf=masterDf, + meanDf=meanDf, + xStat='Region', + yStat='Peak Inst Freq (Hz)', + hueList=hueList, + defaultPlotType='Swarm + Mean + SEM', + defaultHue='Condition' + ) + + scatterWidget.setWindowTitle("Test - Resizable Toolbar") + scatterWidget.resize(1200, 800) + + # Set initial splitter sizes + scatterWidget.setSplitterSizes(350, 850) + + # Show the widget + scatterWidget.show() + + print("ScatterWidget with resizable toolbar is now running.") + print("You can:") + print("1. Drag the splitter handle to resize the left toolbar") + print("2. Right-click to access context menu with 'Reset Toolbar Width' option") + print("3. The toolbar width will be saved when you close the window") + + sys.exit(app.exec_()) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/sanpy/kym/tests/test_tif_file_backend.py b/sanpy/kym/tests/test_tif_file_backend.py new file mode 100644 index 00000000..63552e06 --- /dev/null +++ b/sanpy/kym/tests/test_tif_file_backend.py @@ -0,0 +1,712 @@ +#!/usr/bin/env python3 +""" +Unit tests for TifFileBackend class. +""" + +import unittest +import tempfile +import shutil +import os + +from sanpy.kym.tif_file_backend import TifFileBackend + + +class TestTifFileBackend(unittest.TestCase): + """Test cases for TifFileBackend class.""" + + def setUp(self): + """Set up test fixtures.""" + # Create a temporary directory for test files + self.test_dir = tempfile.mkdtemp() + self.create_test_files() + + def tearDown(self): + """Clean up test fixtures.""" + shutil.rmtree(self.test_dir) + + def create_test_files(self): + """Create test .tif files with various conditions and repeats.""" + test_structure = [ + "20250312 ISAN R1 LS2 FCCP.tif", + "20250312 ISAN R1 LS1 Control.tif", + "20250312 ISAN R2 LS2 FCCP.tif", + "20250312 ISAN R2 LS1 Control.tif", + "20250312 ISAN R3 LS2 Ivab.tif", + "20250312 ISAN R3 LS1 Thap.tif", + "20250312 ISAN R3 LS1 Thap_0001.tif", + "20250312 ISAN R3 LS1 Thap_0002.tif", + ] + + for file_path in test_structure: + full_path = os.path.join(self.test_dir, file_path) + # Create an empty file + with open(full_path, 'w') as f: + f.write("test") + + def test_backend_initialization(self): + """Test backend initialization and file scanning.""" + backend = TifFileBackend(self.test_dir) + + # Check that files were found + file_count = backend.get('file_count') + self.assertGreater(file_count, 0) + self.assertEqual(file_count, 8) # We created 8 test files + + # Check that conditions were extracted + conditions = backend.get('unique_conditions') + self.assertIn('Control', conditions) + self.assertIn('FCCP', conditions) + self.assertIn('Ivab', conditions) + self.assertIn('Thap', conditions) + + def test_condition_extraction(self): + """Test condition extraction from filenames.""" + backend = TifFileBackend(self.test_dir) + + # Check specific files + for idx, row in backend.df.iterrows(): + filename = row['filename'] + condition = row['condition'] + + if 'FCCP' in filename: + self.assertEqual(condition, 'FCCP') + elif 'Control' in filename: + self.assertEqual(condition, 'Control') + elif 'Ivab' in filename: + self.assertEqual(condition, 'Ivab') + elif 'Thap' in filename: + self.assertEqual(condition, 'Thap') + + def test_repeat_extraction(self): + """Test repeat number extraction from filenames.""" + backend = TifFileBackend(self.test_dir) + + # Check that repeat numbers are extracted correctly + for idx, row in backend.df.iterrows(): + filename = row['filename'] + repeat = row['repeat'] + + # TifFileBackend only extracts from _0001 pattern, not R1/R2/R3 + if '0001' in filename: + self.assertEqual(repeat, 1) + elif '0002' in filename: + self.assertEqual(repeat, 2) + else: + # R1, R2, R3 patterns are not extracted by current implementation + self.assertEqual(repeat, 0) + + def test_filtering_by_condition(self): + """Test filtering files by condition.""" + backend = TifFileBackend(self.test_dir) + + # Filter by Control condition + control_files = backend.get('filter_by_condition', condition="Control") + self.assertEqual(len(control_files), 2) # Should find 2 Control files + + # Filter by FCCP condition + fccp_files = backend.get('filter_by_condition', condition="FCCP") + self.assertEqual(len(fccp_files), 2) # Should find 2 FCCP files + + def test_filtering_by_repeat(self): + """Test filtering files by repeat number.""" + backend = TifFileBackend(self.test_dir) + + # Filter by repeat 1 (only _0001 files) + repeat_1_files = backend.get('filter_by_repeat', repeat=1) + self.assertEqual(len(repeat_1_files), 1) # Only 1 file with _0001 pattern + + # Filter by repeat 2 (only _0002 files) + repeat_2_files = backend.get('filter_by_repeat', repeat=2) + self.assertEqual(len(repeat_2_files), 1) # Only 1 file with _0002 pattern + + # Filter by repeat 0 (files without _000 pattern) + repeat_0_files = backend.get('filter_by_repeat', repeat=0) + self.assertEqual(len(repeat_0_files), 6) # 6 files without _000 pattern + + def test_checked_state_management(self): + """Test setting and getting checked states.""" + backend = TifFileBackend(self.test_dir) + + # Get initial checked files + initial_checked = backend.get('files') + initial_count = len(initial_checked) + + # Set a file to unchecked + if len(backend.df) > 0: + first_relative_path = backend.df.iloc[0]['relative_path'] + first_full_path = backend.get_full_path(first_relative_path) + backend.set_checked('file', first_full_path, False) + + # Check if the change was applied + updated_checked = backend.get('files') + self.assertEqual(len(updated_checked), initial_count - 1) + + # Set it back to checked + backend.set_checked('file', first_full_path, True) + final_checked = backend.get('files') + self.assertEqual(len(final_checked), initial_count) + + def test_state_saving_and_loading(self): + """Test saving and loading state.""" + backend = TifFileBackend(self.test_dir) + + # Modify a file's checked state + if len(backend.df) > 0: + first_relative_path = backend.df.iloc[0]['relative_path'] + first_full_path = backend.get_full_path(first_relative_path) + backend.set_checked('file', first_full_path, False) + + # Save state + state_file = os.path.join(self.test_dir, "test_state.csv") + backend.save_state(state_file) + + # Verify file was created + self.assertTrue(os.path.exists(state_file)) + + # Load state back + backend.load_state(state_file) + + # Check if the modification was preserved + is_checked = backend.df.iloc[0]['show_file'] + self.assertFalse(is_checked) + + def test_automatic_state_loading(self): + """Test automatic state loading on initialization.""" + backend1 = TifFileBackend(self.test_dir) + + # Modify state + if len(backend1.df) > 0: + first_relative_path = backend1.df.iloc[0]['relative_path'] + first_full_path = backend1.get_full_path(first_relative_path) + print(f"DEBUG: Setting file {first_full_path} to False") + backend1.set_checked('file', first_full_path, False) + backend1.save_state() # Save to default location + + # Create a new backend instance (should auto-load the state) + backend2 = TifFileBackend(self.test_dir) + + # Check if state was automatically loaded for the same file by relative_path + if len(backend2.df) > 0: + mask = backend2.df['relative_path'] == first_relative_path + self.assertTrue(mask.any()) + is_checked = backend2.df.loc[mask, 'show_file'].iloc[0] + print(f"DEBUG: File {first_full_path} has show_file = {is_checked} (type: {type(is_checked)})") + print(f"DEBUG: All show_file values: {backend2.df['show_file'].tolist()}") + self.assertFalse(is_checked) + + def test_folder_hierarchy_extraction(self): + """Test extraction of folder hierarchy information.""" + # Create a nested folder structure + nested_dir = os.path.join(self.test_dir, "level1", "level2", "level3") + os.makedirs(nested_dir, exist_ok=True) + + _newFile = '20250312 ISAN R1 LS2.tif' + + # Create a file in the nested directory + nested_file = os.path.join(nested_dir, _newFile) + with open(nested_file, 'w') as f: + f.write("test") + + backend = TifFileBackend(self.test_dir) + + # Find the nested file + nested_file_row = backend.df[backend.df['filename'] == _newFile].iloc[0] + + # Check folder hierarchy + self.assertEqual(nested_file_row['parent_folder'], 'level3') + self.assertEqual(nested_file_row['grandparent_folder'], 'level2') + self.assertEqual(nested_file_row['great_grandparent_folder'], 'level1') + + def test_exclude_folders(self): + """Test that excluded folders are properly ignored.""" + # Create an excluded folder + excluded_dir = os.path.join(self.test_dir, "sanpy-reports-pdf") + os.makedirs(excluded_dir, exist_ok=True) + + # Create a file in the excluded directory + excluded_file = os.path.join(excluded_dir, "excluded_file.tif") + with open(excluded_file, 'w') as f: + f.write("test") + + backend = TifFileBackend(self.test_dir) + + # Check that the excluded file is not in the dataframe + excluded_files = backend.df[backend.df['filename'] == 'excluded_file.tif'] + self.assertEqual(len(excluded_files), 0) + + def test_invalid_root_path(self): + """Test behavior with invalid root path.""" + invalid_path = "/path/that/does/not/exist" + backend = TifFileBackend(invalid_path) + + # Should handle gracefully without crashing + self.assertIsNotNone(backend.df) + self.assertEqual(len(backend.df), 0) + + def test_tifinfo_integration(self): + """Test that TifFileBackend correctly uses TifInfo for parsing filenames.""" + # Clean up any existing files first + for file in os.listdir(self.test_dir): + if file.endswith('.tif'): + os.remove(os.path.join(self.test_dir, file)) + + # Create test files with various TifInfo patterns + test_files = [ + "20250312 ISAN R3 LS1 Thap_0003.tif", # Valid TifInfo format with repeat 3 + "20250315 ISAN R1 LS2 Control_0001.tif", # Valid TifInfo format with repeat 1 + "20250318 ISAN R2 LS3 FCCP_0002.tif", # Valid TifInfo format with repeat 2 + "20250320 ISAN R1 LS4 Ivab_0001.tif", # Valid TifInfo format with repeat 1 + "invalid_filename.tif", # Invalid format - should have error + "20250325 ISAN R5 LS5 Unknown_0005.tif", # Valid format with unknown condition and repeat 5 + ] + + for filename in test_files: + file_path = os.path.join(self.test_dir, filename) + with open(file_path, 'w') as f: + f.write("test") + + backend = TifFileBackend(self.test_dir) + + # Check that all files were found + self.assertEqual(len(backend.df), len(test_files)) + + # Check that TifInfo columns are present + expected_columns = ['date', 'cell_id', 'region', 'condition', 'repeat', 'error'] + for col in expected_columns: + self.assertIn(col, backend.df.columns) + + # Check specific TifInfo parsing results + for idx, row in backend.df.iterrows(): + filename = row['filename'] + + if filename == "20250312 ISAN R3 LS1 Thap_0003.tif": + self.assertEqual(row['date'], "20250312") + self.assertEqual(row['cell_id'], "20250312 ISAN R3 LS1") + self.assertEqual(row['region'], "ISAN") + self.assertEqual(row['condition'], "Thap") + self.assertEqual(row['repeat'], 3) + self.assertEqual(row['error'], "") + + elif filename == "20250315 ISAN R1 LS2 Control_0001.tif": + self.assertEqual(row['date'], "20250315") + self.assertEqual(row['cell_id'], "20250315 ISAN R1 LS2") + self.assertEqual(row['region'], "ISAN") + self.assertEqual(row['condition'], "Control") + self.assertEqual(row['repeat'], 1) + self.assertEqual(row['error'], "") + + elif filename == "20250318 ISAN R2 LS3 FCCP_0002.tif": + self.assertEqual(row['date'], "20250318") + self.assertEqual(row['cell_id'], "20250318 ISAN R2 LS3") + self.assertEqual(row['region'], "ISAN") + self.assertEqual(row['condition'], "FCCP") + self.assertEqual(row['repeat'], 2) + self.assertEqual(row['error'], "") + + elif filename == "20250320 ISAN R1 LS4 Ivab_0001.tif": + self.assertEqual(row['date'], "20250320") + self.assertEqual(row['cell_id'], "20250320 ISAN R1 LS4") + self.assertEqual(row['region'], "ISAN") + self.assertEqual(row['condition'], "Ivab") + self.assertEqual(row['repeat'], 1) + self.assertEqual(row['error'], "") + + elif filename == "invalid_filename.tif": + # Should have error and default values + self.assertEqual(row['date'], "") + self.assertEqual(row['cell_id'], "") + self.assertEqual(row['region'], "") + self.assertEqual(row['condition'], "Control") # Default condition + self.assertEqual(row['repeat'], 0) + self.assertNotEqual(row['error'], "") # Should have error message + + elif filename == "20250325 ISAN R5 LS5 Unknown_0005.tif": + self.assertEqual(row['date'], "20250325") + self.assertEqual(row['cell_id'], "20250325 ISAN R5 LS5 Unknown") + self.assertEqual(row['region'], "ISAN") + self.assertEqual(row['condition'], "Control") # Default condition since "Unknown" is not in POSSIBLE_CONDITIONS + self.assertEqual(row['repeat'], 5) + self.assertEqual(row['error'], "") + + # Check that repeat column is always integer + self.assertTrue(backend.df['repeat'].dtype in ['int32', 'int64']) + + # Check that condition extraction is now from TifInfo (not regex) + conditions = backend.get('unique_conditions') + self.assertIn('Control', conditions) + self.assertIn('FCCP', conditions) + self.assertIn('Ivab', conditions) + self.assertIn('Thap', conditions) + + # Check that repeat extraction is now from TifInfo + repeats = backend.get('unique_repeats') + self.assertIn(1, repeats) + self.assertIn(2, repeats) + self.assertIn(3, repeats) + self.assertIn(5, repeats) + self.assertIn(0, repeats) # From invalid filename + + def test_real_data_folder_1(self): + """Test backend with real data from mito-atp-20250623-RHC folder.""" + folder_path = "/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-atp-20250623-RHC" + + if not os.path.exists(folder_path): + self.skipTest(f"Test folder does not exist: {folder_path}") + + print(f"\nTesting real data folder 1: {folder_path}") + + # Create backend + backend = TifFileBackend(folder_path) + + # Basic checks + self.assertIsNotNone(backend.df) + file_count = len(backend.df) + print(f"Found {file_count} .tif files") + + if file_count > 0: + # Check that we have the expected columns + expected_columns = ['relative_path', 'filename', 'date', 'cell_id', 'region', 'condition', 'repeat', 'error'] + for col in expected_columns: + self.assertIn(col, backend.df.columns, f"Missing column: {col}") + + # Check that relative_path is properly set and full_path is constructed correctly + for idx, row in backend.df.iterrows(): + relative_path = row['relative_path'] + filename = row['filename'] + + # Verify relative_path is relative to root + self.assertFalse(os.path.isabs(relative_path), f"relative_path should be relative: {relative_path}") + + # Verify full_path is constructed correctly + full_path = backend.get_full_path(relative_path) + self.assertTrue(os.path.isabs(full_path), f"full_path should be absolute: {full_path}") + + # Verify resolve_path works correctly + resolved_path = backend.resolve_path(relative_path) + self.assertEqual(resolved_path, full_path, f"Path resolution failed for {filename}") + + # Verify file exists + self.assertTrue(os.path.exists(full_path), f"File does not exist: {full_path}") + + # Check unique conditions and repeats + conditions = backend.get('unique_conditions') + repeats = backend.get('unique_repeats') + print(f"Unique conditions: {conditions}") + print(f"Unique repeats: {repeats}") + + # Test filtering + if conditions: + first_condition = conditions[0] + filtered_files = backend.get('filter_by_condition', condition=first_condition) + print(f"Files with condition '{first_condition}': {len(filtered_files)}") + + # Test path resolution for a few files + for i, row in backend.df.head(3).iterrows(): + relative_path = row['relative_path'] + full_path = backend.get_full_path(relative_path) + resolved_path = backend.resolve_path(relative_path) + self.assertEqual(resolved_path, full_path, f"Path resolution failed for row {i}") + + # Test state saving (should not fail even with no files) + state_file = backend.save_state() + if state_file: + print(f"State saved to: {state_file}") + self.assertTrue(os.path.exists(state_file)) + + def test_real_data_folder_2(self): + """Test backend with real data from mito-iATP Analysis folder.""" + folder_path = "/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-iATP Analysis" + + if not os.path.exists(folder_path): + self.skipTest(f"Test folder does not exist: {folder_path}") + + print(f"\nTesting real data folder 2: {folder_path}") + + # Create backend + backend = TifFileBackend(folder_path) + + # Basic checks + self.assertIsNotNone(backend.df) + file_count = len(backend.df) + print(f"Found {file_count} .tif files") + + if file_count > 0: + # Check that we have the expected columns + expected_columns = ['relative_path', 'filename', 'date', 'cell_id', 'region', 'condition', 'repeat', 'error'] + for col in expected_columns: + self.assertIn(col, backend.df.columns, f"Missing column: {col}") + + # Check that relative_path is properly set and full_path is constructed correctly + for idx, row in backend.df.iterrows(): + relative_path = row['relative_path'] + filename = row['filename'] + + # Verify relative_path is relative to root + self.assertFalse(os.path.isabs(relative_path), f"relative_path should be relative: {relative_path}") + + # Verify full_path is constructed correctly + full_path = backend.get_full_path(relative_path) + self.assertTrue(os.path.isabs(full_path), f"full_path should be absolute: {full_path}") + + # Verify resolve_path works correctly + resolved_path = backend.resolve_path(relative_path) + self.assertEqual(resolved_path, full_path, f"Path resolution failed for {filename}") + + # Verify file exists + self.assertTrue(os.path.exists(full_path), f"File does not exist: {full_path}") + + # Check unique conditions and repeats + conditions = backend.get('unique_conditions') + repeats = backend.get('unique_repeats') + print(f"Unique conditions: {conditions}") + print(f"Unique repeats: {repeats}") + + # Test filtering + if conditions: + first_condition = conditions[0] + filtered_files = backend.get('filter_by_condition', condition=first_condition) + print(f"Files with condition '{first_condition}': {len(filtered_files)}") + + # Test path resolution for a few files + for i, row in backend.df.head(3).iterrows(): + relative_path = row['relative_path'] + full_path = backend.get_full_path(relative_path) + resolved_path = backend.resolve_path(relative_path) + self.assertEqual(resolved_path, full_path, f"Path resolution failed for row {i}") + + # Test state saving (should not fail even with no files) + state_file = backend.save_state() + if state_file: + print(f"State saved to: {state_file}") + self.assertTrue(os.path.exists(state_file)) + + def test_kym_roi_analysis_integration(self): + """Test KymRoiAnalysis integration with real data folders.""" + # Test with both real folders + test_folders = [ + "/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-atp-20250623-RHC", + "/Users/cudmore/Dropbox/data/colin/2025/mito-atp/mito-iATP Analysis" + ] + + for folder_path in test_folders: + if not os.path.exists(folder_path): + print(f"Skipping folder (does not exist): {folder_path}") + continue + + print(f"\nTesting KymRoiAnalysis integration with: {folder_path}") + + backend = TifFileBackend(folder_path) + + if len(backend.df) == 0: + print("No files found, skipping KymRoiAnalysis tests") + continue + + # Check that KymRoiAnalysis columns exist + self.assertIn('_KymRoiAnalysis', backend.df.columns) + self.assertIn('_KymRoiAnalysis_Loaded', backend.df.columns) + + # Check initial state + self.assertTrue(backend.df['_KymRoiAnalysis'].isna().all(), "KymRoiAnalysis should be None initially") + self.assertFalse(backend.df['_KymRoiAnalysis_Loaded'].any(), "KymRoiAnalysis should not be loaded initially") + + # Test loading KymRoiAnalysis for first few files + for i, row in backend.df.head(2).iterrows(): + filename = row['filename'] + relative_path = row['relative_path'] + + print(f"Testing KymRoiAnalysis loading for: {filename}") + + # Test loading by row index + kym_analysis = backend.get_kym_roi_analysis(i) + if kym_analysis is not None: + print(f" ✓ Successfully loaded KymRoiAnalysis for row {i}") + self.assertTrue(backend.df.iloc[i]['_KymRoiAnalysis_Loaded']) + else: + print(f" ✗ Failed to load KymRoiAnalysis for row {i}") + + # Test loading by relative path + kym_analysis_by_relative_path = backend.get_kym_roi_analysis_by_path(relative_path) + if kym_analysis_by_relative_path is not None: + print(f" ✓ Successfully loaded KymRoiAnalysis by relative path: {relative_path}") + else: + print(f" ✗ Failed to load KymRoiAnalysis by relative path: {relative_path}") + + # Test cache clearing + cached_count = backend.get_cached_kym_roi_analysis_count() + print(f"Cached KymRoiAnalysis objects: {cached_count}") + + if cached_count > 0: + backend.clear_kym_roi_analysis_cache() + new_cached_count = backend.get_cached_kym_roi_analysis_count() + self.assertEqual(new_cached_count, 0, "Cache should be cleared") + print(" ✓ Cache cleared successfully") + + def test_note_column_workflow(self): + """Test the complete note column workflow: edit, save, reload.""" + print(f"\nTesting note column workflow with test directory: {self.test_dir}") + + # Step 1: Create initial backend and verify note column exists + backend1 = TifFileBackend(self.test_dir) + self.assertIn('note', backend1.df.columns, "Note column should exist in DataFrame") + + if len(backend1.df) == 0: + self.skipTest("No files found for note testing") + + # Get first file info + first_row = backend1.df.iloc[0] + first_relative_path = first_row['relative_path'] + initial_note = first_row['note'] + + print(f"Testing with file: {first_relative_path}") + print(f"Initial note: '{initial_note}'") + + # Step 2: Edit a note using the backend API + test_note = "This is a test note for the file" + print(f"Setting note to: '{test_note}'") + + # Update the note in the backend + mask = backend1.df['relative_path'] == first_relative_path + self.assertTrue(mask.any(), f"File {first_relative_path} should exist in backend") + backend1.df.loc[mask, 'note'] = test_note + + # Verify the note was updated in memory + updated_note = backend1.df.loc[mask, 'note'].iloc[0] + self.assertEqual(updated_note, test_note, f"Note should be updated in memory. Expected: '{test_note}', Got: '{updated_note}'") + print(f"✅ Note updated in memory: '{updated_note}'") + + # Step 3: Save the state to CSV + state_filepath = backend1.save_state() + self.assertIsNotNone(state_filepath, "State should be saved successfully") + self.assertTrue(os.path.exists(state_filepath), f"State file should exist: {state_filepath}") + print(f"✅ State saved to: {state_filepath}") + + # Step 4: Create a new backend instance to test loading + backend2 = TifFileBackend(self.test_dir) + self.assertIn('note', backend2.df.columns, "Note column should exist in new backend") + + # Step 5: Verify the note was loaded correctly + mask2 = backend2.df['relative_path'] == first_relative_path + self.assertTrue(mask2.any(), f"File {first_relative_path} should exist in new backend") + + loaded_note = backend2.df.loc[mask2, 'note'].iloc[0] + print(f"Loaded note: '{loaded_note}'") + + # The note should be loaded correctly + self.assertEqual(loaded_note, test_note, f"Note should be loaded correctly. Expected: '{test_note}', Got: '{loaded_note}'") + print(f"✅ Note loaded correctly: '{loaded_note}'") + + # Step 6: Test that other files still have empty notes + other_files = backend2.df[backend2.df['relative_path'] != first_relative_path] + if len(other_files) > 0: + other_notes = other_files['note'].tolist() + print(f"Other files notes: {other_notes}") + for note in other_notes: + self.assertEqual(note, '', f"Other files should have empty notes, got: '{note}'") + print("✅ Other files have empty notes") + + # Step 7: Test editing multiple notes + if len(backend2.df) > 1: + second_row = backend2.df.iloc[1] + second_relative_path = second_row['relative_path'] + second_test_note = "Second file note" + + print(f"Testing second file: {second_relative_path}") + print(f"Setting second note to: '{second_test_note}'") + + # Update second note + mask3 = backend2.df['relative_path'] == second_relative_path + backend2.df.loc[mask3, 'note'] = second_test_note + + # Save and reload + backend2.save_state() + backend3 = TifFileBackend(self.test_dir) + + # Verify both notes are correct + mask4 = backend3.df['relative_path'] == first_relative_path + mask5 = backend3.df['relative_path'] == second_relative_path + + first_note_final = backend3.df.loc[mask4, 'note'].iloc[0] + second_note_final = backend3.df.loc[mask5, 'note'].iloc[0] + + self.assertEqual(first_note_final, test_note, f"First note should persist. Expected: '{test_note}', Got: '{first_note_final}'") + self.assertEqual(second_note_final, second_test_note, f"Second note should be saved. Expected: '{second_test_note}', Got: '{second_note_final}'") + + print(f"✅ First note persists: '{first_note_final}'") + print(f"✅ Second note saved: '{second_note_final}'") + + print("✅ Note column workflow test completed successfully!") + + def test_note_column_initialization(self): + """Test that note column is properly initialized.""" + backend = TifFileBackend(self.test_dir) + + # Check that note column exists and is configured correctly + self.assertIn('note', backend.df.columns, "Note column should exist") + + note_config = backend.get_column_config('note') + self.assertTrue(note_config.get('editable', False), "Note column should be editable") + self.assertTrue(note_config.get('table_visible', False), "Note column should be visible in table") + + # Check that all notes are initialized to empty string + if len(backend.df) > 0: + note_values = backend.df['note'].tolist() + for note in note_values: + self.assertEqual(note, '', f"Notes should be initialized to empty string, got: '{note}'") + + print("✅ Note column initialization test passed") + + def test_note_column_with_special_characters(self): + """Test that note column handles special characters correctly.""" + backend = TifFileBackend(self.test_dir) + + if len(backend.df) == 0: + self.skipTest("No files found for special character testing") + + # Test note with special characters + special_note = "Test note with special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?`~" + first_relative_path = backend.df.iloc[0]['relative_path'] + + # Update note + mask = backend.df['relative_path'] == first_relative_path + backend.df.loc[mask, 'note'] = special_note + + # Save and reload + backend.save_state() + backend2 = TifFileBackend(self.test_dir) + + # Verify special characters are preserved + mask2 = backend2.df['relative_path'] == first_relative_path + loaded_note = backend2.df.loc[mask2, 'note'].iloc[0] + + self.assertEqual(loaded_note, special_note, f"Special characters should be preserved. Expected: '{special_note}', Got: '{loaded_note}'") + print("✅ Special characters preserved in note") + + def test_note_column_with_multiline_text(self): + """Test that note column handles multiline text correctly.""" + backend = TifFileBackend(self.test_dir) + + if len(backend.df) == 0: + self.skipTest("No files found for multiline testing") + + # Test note with newlines + multiline_note = "Line 1\nLine 2\nLine 3 with special chars: !@#$%" + first_relative_path = backend.df.iloc[0]['relative_path'] + + # Update note + mask = backend.df['relative_path'] == first_relative_path + backend.df.loc[mask, 'note'] = multiline_note + + # Save and reload + backend.save_state() + backend2 = TifFileBackend(self.test_dir) + + # Verify multiline text is preserved + mask2 = backend2.df['relative_path'] == first_relative_path + loaded_note = backend2.df.loc[mask2, 'note'].iloc[0] + + self.assertEqual(loaded_note, multiline_note, f"Multiline text should be preserved. Expected: '{multiline_note}', Got: '{loaded_note}'") + print("✅ Multiline text preserved in note") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/sanpy/kym/tests/test_tif_tree_window.py b/sanpy/kym/tests/test_tif_tree_window.py new file mode 100644 index 00000000..0416609d --- /dev/null +++ b/sanpy/kym/tests/test_tif_tree_window.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +""" +Unit tests for TifTreeWindow to verify checkbox state saving and loading. +""" + +import unittest +import tempfile +import os +import shutil +import sys +from unittest.mock import patch, MagicMock + +# Mock PyQt5 for testing without GUI +sys.modules['PyQt5'] = MagicMock() +sys.modules['PyQt5.QtWidgets'] = MagicMock() +sys.modules['PyQt5.QtCore'] = MagicMock() +sys.modules['PyQt5.QtGui'] = MagicMock() + +from sanpy.kym.tif_file_backend import TifFileBackend +from sanpy.kym.interface.kym_file_list.tif_tree_window import TifTreeWindow + +class TestTifTreeWindow(unittest.TestCase): + """Test TifTreeWindow functionality.""" + + def setUp(self): + """Set up test environment.""" + self.test_dir = tempfile.mkdtemp() + self.create_test_files() + + def tearDown(self): + """Clean up test environment.""" + shutil.rmtree(self.test_dir) + + def create_test_files(self): + """Create test .tif files with proper TifInfo format.""" + test_files = [ + "20250312 ISAN R1 LS1 Control_0001.tif", + "20250312 ISAN R1 LS1 Control_0002.tif", + "20250312 ISAN R2 LS1 FCCP_0001.tif", + "20250312 ISAN R2 LS1 FCCP_0002.tif", + "20250315 ISAN R1 LS2 Ivab_0001.tif", + "20250315 ISAN R1 LS2 Thap_0001.tif", + ] + + for filename in test_files: + file_path = os.path.join(self.test_dir, filename) + with open(file_path, 'w') as f: + f.write("test") + + def test_backend_state_saving_and_loading(self): + """Test that TifFileBackend correctly saves and loads checkbox states.""" + # Create backend + backend = TifFileBackend(self.test_dir) + + # Verify initial state - all files should be checked + initial_files = backend.get('files') + self.assertEqual(len(initial_files), 6) + + # Uncheck some files + if len(backend.df) > 0: + # Uncheck first file + first_file = backend.df.iloc[0]['full_path'] + backend.set_checked('file', first_file, False) + + # Uncheck second file + second_file = backend.df.iloc[1]['full_path'] + backend.set_checked('file', second_file, False) + + # Verify changes + updated_files = backend.get('files') + self.assertEqual(len(updated_files), 4) # 6 - 2 = 4 + + # Save state + state_file = backend.save_state() + self.assertIsNotNone(state_file) + + # Create new backend and load state + backend2 = TifFileBackend(self.test_dir) + + # Verify state was loaded correctly + loaded_files = backend2.get('files') + self.assertEqual(len(loaded_files), 4) + + # Verify specific files are unchecked + self.assertFalse(backend2.df.loc[backend2.df['full_path'] == first_file, 'show_file'].iloc[0]) + self.assertFalse(backend2.df.loc[backend2.df['full_path'] == second_file, 'show_file'].iloc[0]) + + def test_boolean_type_consistency(self): + """Test that boolean values are consistently Python bool type.""" + backend = TifFileBackend(self.test_dir) + + # Check that show_file column contains Python bools + for val in backend.df['show_file']: + self.assertIsInstance(val, bool) + + # Test setting and getting boolean values + if len(backend.df) > 0: + first_file = backend.df.iloc[0]['full_path'] + backend.set_checked('file', first_file, False) + + # Check that the value is still a Python bool + updated_val = backend.df.loc[backend.df['full_path'] == first_file, 'show_file'].iloc[0] + self.assertIsInstance(updated_val, bool) + self.assertFalse(updated_val) + + def test_backend_initialization(self): + """Test that TifFileBackend initializes correctly with test data.""" + backend = TifFileBackend(self.test_dir) + + # Check that DataFrame was created + self.assertIsNotNone(backend.df) + self.assertGreater(len(backend.df), 0) + + # Check that all expected columns exist + expected_columns = [ + 'relative_path', 'full_path', 'filename', 'parent_folder', + 'grandparent_folder', 'great_grandparent_folder', 'date', + 'cell_id', 'region', 'condition', 'repeat', 'error', 'show_file' + ] + for col in expected_columns: + self.assertIn(col, backend.df.columns) + + # Check that all files are checked by default + self.assertTrue(backend.df['show_file'].all()) + + # Check that we found the expected number of files + self.assertEqual(len(backend.df), 6) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/sanpy/kym/tests/test_tifinfo.py b/sanpy/kym/tests/test_tifinfo.py new file mode 100644 index 00000000..0b2a072e --- /dev/null +++ b/sanpy/kym/tests/test_tifinfo.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +Unit tests for TifInfo dataclass +""" + +import unittest +from sanpy.kym.tif_info import TifInfo + +class TestTifInfo(unittest.TestCase): + def setUp(self): + TifInfo.set_possible_conditions(['Control', 'Ivab', 'Thap', 'FCCP']) + TifInfo.set_possible_regions(['ISAN', 'SSAN']) + + def test_required_examples(self): + """Test the specific examples provided by the user.""" + test_cases = [ + { + 'filename': '20250312 ISAN FCCP R1 LS1.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'FCCP', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 SSAN R1 LS1.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 SSAN R1 LS1', + 'condition': 'Control', + 'region': 'SSAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN R1 LS1 FCCP.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'FCCP', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN R1 LS1 Control.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'Control', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250602 ISAN R1 LS1 Ivab.tif', + 'expected': { + 'date': '20250602', + 'cellid': '20250602 ISAN R1 LS1', + 'condition': 'Ivab', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250602 ISAN R1 LS1.tif', + 'expected': { + 'date': '20250602', + 'cellid': '20250602 ISAN R1 LS1', + 'condition': 'Control', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + } + ] + for i, test_case in enumerate(test_cases, 1): + with self.subTest(i=i, filename=test_case['filename']): + filename = test_case['filename'] + expected = test_case['expected'] + tif_info = TifInfo.from_filename(filename) + self.assertEqual(tif_info.date, expected['date']) + self.assertEqual(tif_info.cellid, expected['cellid']) + self.assertEqual(tif_info.condition, expected['condition']) + self.assertEqual(tif_info.region, expected['region']) + self.assertEqual(tif_info.repeat, expected['repeat']) + self.assertEqual(tif_info.error, expected['error']) + + def test_repeat_numbers(self): + test_cases = [ + {'filename': '20250312 ISAN R1 LS1_0001.tif', 'expected_repeat': 1}, + {'filename': '20250312 ISAN FCCP R1 LS1_0002.tif', 'expected_repeat': 2}, + {'filename': '20250312 SSAN R2 LS3 Thap_0003.tif', 'expected_repeat': 3}, + {'filename': '20250312 ISAN R1 LS1_0000.tif', 'expected_repeat': 0}, + {'filename': '20250312 ISAN R1 LS1_9999.tif', 'expected_repeat': 9999} + ] + for i, test_case in enumerate(test_cases, 1): + with self.subTest(i=i, filename=test_case['filename']): + tif_info = TifInfo.from_filename(test_case['filename']) + self.assertEqual(tif_info.repeat, test_case['expected_repeat']) + self.assertEqual(tif_info.error, "") + + def test_edge_cases(self): + test_cases = [ + { + 'filename': 'invalid_filename.tif', + 'description': 'Invalid filename with insufficient parts', + 'expected_error_contains': 'insufficient parts' + }, + { + 'filename': '20250312 UNKNOWN R1 LS1.tif', + 'description': 'Unknown region', + 'expected_error_contains': 'Region not found' + }, + { + 'filename': '20250312 ISAN R1 LS1_abc.tif', + 'description': 'Invalid repeat number format (non-digits)', + 'expected_repeat': 0 + }, + { + 'filename': '20250312 ISAN R1 LS1_0001_extra.tif', + 'description': 'Extra text after repeat number (not at end)', + 'expected_repeat': 0 + }, + { + 'filename': '20250312 ISAN R1 LS1.tif', + 'description': 'Valid filename without extension', + 'expected_error': '' + }, + { + 'filename': '20250312 ISAN R1 LS1_12.tif', + 'description': 'Repeat number too short (2 digits instead of 3-4)', + 'expected_repeat': 0 + }, + { + 'filename': '20250312 ISAN R1 LS1_12345.tif', + 'description': 'Repeat number too long (5 digits instead of 3-4)', + 'expected_repeat': 0 + } + ] + for i, test_case in enumerate(test_cases, 1): + with self.subTest(i=i, filename=test_case['filename']): + tif_info = TifInfo.from_filename(test_case['filename']) + if 'expected_error_contains' in test_case: + self.assertIn(test_case['expected_error_contains'], tif_info.error) + elif 'expected_error' in test_case: + self.assertEqual(tif_info.error, test_case['expected_error']) + elif 'expected_repeat' in test_case: + self.assertEqual(tif_info.repeat, test_case['expected_repeat']) + + def test_configurable_conditions_and_regions(self): + TifInfo.set_possible_conditions(['Control', 'Ivab', 'Thap', 'FCCP', 'ATP']) + tif_info = TifInfo.from_filename('20250312 ISAN R1 LS1 ATP.tif') + self.assertEqual(tif_info.condition, 'ATP') + TifInfo.set_possible_regions(['ISAN', 'SSAN', 'ESAN']) + tif_info = TifInfo.from_filename('20250312 ESAN R1 LS1.tif') + self.assertEqual(tif_info.region, 'ESAN') + tif_info = TifInfo.from_filename('20250312 ISAN R1 LS1 FCCP.tif') + self.assertEqual(tif_info.condition, 'FCCP') + + def test_date_validation(self): + test_cases = [ + {'filename': '20250312 ISAN R1 LS1.tif', 'should_pass': True}, + {'filename': '2025031 ISAN R1 LS1.tif', 'should_pass': False}, + {'filename': '202503123 ISAN R1 LS1.tif', 'should_pass': False}, + {'filename': '2025031a ISAN R1 LS1.tif', 'should_pass': False} + ] + for i, test_case in enumerate(test_cases, 1): + tif_info = TifInfo.from_filename(test_case['filename']) + if test_case['should_pass']: + self.assertNotEqual(tif_info.date, "") + self.assertEqual(tif_info.error, "") + else: + self.assertIn("not a valid 8-digit date", tif_info.error) + + def test_multiple_spaces(self): + test_cases = [ + { + 'filename': '20250312 ISAN FCCP R1 LS1.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'FCCP', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN R1 LS1.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'Control', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN FCCP R1 LS1.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'FCCP', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN R1 LS1 Control.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'Control', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN R1 LS1 Ivab.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'Ivab', + 'region': 'ISAN', + 'repeat': 0, + 'error': '' + } + }, + { + 'filename': '20250312 ISAN R1 LS1 Thap 0001.tif', + 'expected': { + 'date': '20250312', + 'cellid': '20250312 ISAN R1 LS1', + 'condition': 'Thap', + 'region': 'ISAN', + 'repeat': 1, + 'error': '' + } + } + ] + for i, test_case in enumerate(test_cases, 1): + tif_info = TifInfo.from_filename(test_case['filename']) + expected = test_case['expected'] + self.assertEqual(tif_info.date, expected['date']) + self.assertEqual(tif_info.cellid, expected['cellid']) + self.assertEqual(tif_info.condition, expected['condition']) + self.assertEqual(tif_info.region, expected['region']) + self.assertEqual(tif_info.repeat, expected['repeat']) + self.assertEqual(tif_info.error, expected['error']) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/sanpy/kym/tif_file_backend.py b/sanpy/kym/tif_file_backend.py new file mode 100644 index 00000000..eebd7f9f --- /dev/null +++ b/sanpy/kym/tif_file_backend.py @@ -0,0 +1,930 @@ +""" +TifFileBackend - Backend class for managing .tif file data and metadata. + +HOW TO ADD NEW COLUMNS: +====================== + +1. ADD TO COLUMNS DICTIONARY (around line 25): + Add your column with its data type: + 'your_column': str, # or int, bool, float, etc. + +2. ADD TO COLUMN_CONFIG DICTIONARY (around line 40): + Add configuration for display and behavior: + 'your_column': ColumnConfig( + display_name='Your Column', # What users see + width=100, # Column width (None for auto) + stretch=False, # Should column stretch? + editable=False, # Can users edit this column? + widget_type=None, # 'checkbox', 'dropdown', etc. + tree_visible=True, # Show in tree widget? + table_visible=True, # Show in table widget? + sortable=True # Can users sort by this column? + ) + +3. ADD DATA POPULATION (around line 400): + In the file_data.append() call, add your data: + 'your_column': your_value_or_calculation, + +4. ADD TYPE CONVERSION (if needed, around line 280): + If your column needs special type conversion when loading from CSV: + if 'your_column' in common_cols: + update_df['your_column'] = your_conversion_function(update_df['your_column']) + +5. ADD TYPE CONVERSION IN MAIN LOOP (if needed, around line 300): + In the column update loop, add your type conversion: + elif col == 'your_column': + updated_column = your_conversion_function(updated_column) + +EXAMPLES: +- String column: Just add to COLUMNS and COLUMN_CONFIG +- Integer column: Add type conversion in _load_saved_state +- Boolean column: Add to checkbox conversion list +- Calculated column: Add calculation in _scan_files + +SPECIAL COLUMNS: +- _KymRoiAnalysis: Special column that stores KymRoiAnalysis objects in memory during runtime. + This column is NOT saved to CSV files and is used for lazy loading of analysis objects. + Use get_kym_roi_analysis(row_index) or get_kym_roi_analysis_by_path(tif_path) to access. + +WIDGET COMPATIBILITY: +- Table widget: Automatically shows columns with table_visible=True +- Tree widget: Currently uses hardcoded columns (needs refactoring) +""" + +import os +import re +# import random # Uncomment when adding columns that need random data +from typing import List, Optional, Dict, Any, Union +from dataclasses import dataclass +import pandas as pd + +from sanpy.kym.logger import get_logger +from sanpy.kym.tif_info import TifInfo +from sanpy.kym.kymRoiAnalysis import KymRoiAnalysis + +logger = get_logger(__name__) + +@dataclass +class ColumnConfig: + """Configuration for a column in the TifFileBackend. + + This dataclass provides a type-safe way to configure column behavior + and appearance across different UI widgets. + """ + display_name: str + width: Optional[int] = None + stretch: bool = False + editable: bool = False + widget_type: Optional[str] = None + backend_field: Optional[str] = None + tree_visible: bool = False + table_visible: bool = False + sortable: bool = True + + def __post_init__(self): + """Auto-set backend_field if not provided.""" + if self.backend_field is None: + # Convert display_name to snake_case for backend field + self.backend_field = self.display_name.lower().replace(' ', '_') + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for backward compatibility.""" + return { + 'display_name': self.display_name, + 'width': self.width, + 'stretch': self.stretch, + 'editable': self.editable, + 'widget_type': self.widget_type, + 'backend_field': self.backend_field, + 'tree_visible': self.tree_visible, + 'table_visible': self.table_visible, + 'sortable': self.sortable + } + +class TifFileBackend: + """Backend class for managing .tif file data and metadata. + + Given a root path, loads all .tif files into DataFrame. + + Can save as csv file to root_path/tif_file_backend_state.csv + """ + + # Define column structure for easy modification + # IMPORTANT: When adding new columns, you need to: + # 1. Add the column here with its data type + # 2. Add column configuration below in COLUMN_CONFIG + # 3. Add data population in _scan_files() method + # 4. Add type conversion in _load_saved_state() method if needed + COLUMNS = { + 'relative_path': str, + 'filename': str, + 'parent_folder': str, + 'grandparent_folder': str, + 'great_grandparent_folder': str, + 'date': str, + 'cell_id': str, + 'region': str, + 'condition': str, + 'repeat': int, + 'error': str, + 'show_file': bool, + 'note': str, # New editable note column + '_KymRoiAnalysis': object, # Special column for storing KymRoiAnalysis objects in memory + '_KymRoiAnalysis_Loaded': bool, # Track loading status for UI display + # '_random_': int # Example: New random column (commented out) + } + + # Column display configuration for UI widgets + COLUMN_CONFIG = { + # Core display columns (always shown) + 'filename': ColumnConfig( + display_name='Filename', + width=None, # Will stretch + stretch=True, + editable=False, + widget_type=None, + backend_field='filename', + tree_visible=True, + table_visible=True, + sortable=True + ), + 'date': ColumnConfig( + display_name='Date', + width=100, + stretch=False, + editable=False, + widget_type=None, + backend_field='date', + tree_visible=True, + table_visible=True, + sortable=True + ), + 'cell_id': ColumnConfig( + display_name='Cell ID', + width=80, + stretch=False, + editable=False, + widget_type=None, + backend_field='cell_id', + tree_visible=True, + table_visible=True, + sortable=True + ), + 'region': ColumnConfig( + display_name='Region', + width=80, + stretch=False, + editable=False, + widget_type=None, + backend_field='region', + tree_visible=True, + table_visible=True, + sortable=True + ), + 'condition': ColumnConfig( + display_name='Condition', + width=100, + stretch=False, + editable=False, + widget_type=None, + backend_field='condition', + tree_visible=True, + table_visible=True, + sortable=True + ), + 'repeat': ColumnConfig( + display_name='Repeat', + width=80, + stretch=False, + editable=False, + widget_type=None, + backend_field='repeat', + tree_visible=True, + table_visible=True, + sortable=True + ), + 'note': ColumnConfig( + display_name='Note', + width=150, + stretch=False, + editable=True, # Make it editable + widget_type=None, + backend_field='note', + tree_visible=False, # Not in tree by default + table_visible=True, # Show in table + sortable=True + ), + 'show_file': ColumnConfig( + display_name='Show', + width=80, + stretch=False, + editable=True, + widget_type='checkbox', + backend_field='show_file', + tree_visible=True, + table_visible=True, + sortable=False + ), + # '_random_': { + # 'display_name': 'Random', + # 'width': 80, + # 'stretch': False, + # 'editable': False, + # 'widget_type': None, + # 'backend_field': '_random_', + # 'tree_visible': True, + # 'table_visible': True, + # 'sortable': True + # }, + # Hidden columns (for internal use) + 'relative_path': ColumnConfig( + display_name='Relative Path', + width=None, + stretch=False, + editable=False, + widget_type=None, + backend_field='relative_path', + tree_visible=False, + table_visible=False, + sortable=True + ), + 'parent_folder': ColumnConfig( + display_name='Parent Folder', + width=None, + stretch=False, + editable=False, + widget_type=None, + backend_field='parent_folder', + tree_visible=False, + table_visible=False, + sortable=True + ), + 'grandparent_folder': ColumnConfig( + display_name='Grandparent Folder', + width=None, + stretch=False, + editable=False, + widget_type=None, + backend_field='grandparent_folder', + tree_visible=False, + table_visible=False, + sortable=True + ), + 'great_grandparent_folder': ColumnConfig( + display_name='Great Grandparent Folder', + width=None, + stretch=False, + editable=False, + widget_type=None, + backend_field='great_grandparent_folder', + tree_visible=False, + table_visible=False, + sortable=True + ), + 'error': ColumnConfig( + display_name='Error', + width=80, + stretch=False, + editable=False, + widget_type=None, + backend_field='error', + tree_visible=False, + table_visible=False, + sortable=True + ), + '_KymRoiAnalysis': ColumnConfig( + display_name='KymRoiAnalysis', + width=None, + stretch=False, + editable=False, + widget_type=None, + backend_field='_KymRoiAnalysis', + tree_visible=False, + table_visible=False, + sortable=False + ), + '_KymRoiAnalysis_Loaded': ColumnConfig( + display_name='Kym Loaded', + width=80, + stretch=False, + editable=False, + widget_type='status_icon', + backend_field='_KymRoiAnalysis_Loaded', + tree_visible=False, + table_visible=True, + sortable=False + ) + } + + def __init__(self, root_path: str, exclude_folders: List[str] = None, sort_by_grandparent: bool = True): + """ + Initialize the backend with a root path and optional folder exclusions. + + Parameters + ---------- + root_path : str + Root directory to scan for .tif files + exclude_folders : List[str], optional + List of folder names to exclude from scanning + sort_by_grandparent : bool, optional + If True, sort files by grandparent folder (ignoring parent folder). + If False, sort files by parent folder. Default is True. + """ + self.root_path = root_path + self.exclude_folders = exclude_folders or ["sanpy-reports-pdf"] + self.sort_by_grandparent = sort_by_grandparent + + # Initialize DataFrame with proper column structure + self.df = pd.DataFrame(columns=list(self.COLUMNS.keys())) + + if not os.path.exists(root_path): + logger.error(f"Root path does not exist: {root_path}") + return + + self._scan_files() + self._load_saved_state() + + def _should_exclude_path(self, path: str) -> bool: + """Check if path contains any excluded folders.""" + return any(excluded in path for excluded in self.exclude_folders) + + def _get_state_filepath(self) -> str: + """Get the filepath for saving/loading state.""" + return os.path.join(self.root_path, "tif_file_backend_state.csv") + + def _load_saved_state(self): + """Load saved state from CSV file if it exists.""" + state_filepath = self._get_state_filepath() + if os.path.exists(state_filepath): + try: + loaded_df = pd.read_csv(state_filepath) + + if len(self.df) > 0 and len(loaded_df) > 0: + # Only load columns that exist in both dataframes and are expected, EXCLUDING 'full_path' + common_cols = [col for col in loaded_df.columns if col in self.df.columns and col in self.COLUMNS] + + # Ensure relative_path is available for mapping + if 'relative_path' not in loaded_df.columns: + logger.error(f"relative_path column missing from saved state file: {state_filepath}") + return + + if common_cols: + # Create a mapping dataframe with only the columns we want to update + # Include relative_path for mapping, but don't process it as a regular column + update_cols = ['relative_path'] + [col for col in common_cols if col != 'relative_path'] + update_df = loaded_df[update_cols].copy() + + # Step 1: Handle special column types before merging + # Ensure 'repeat' column is integer type + if 'repeat' in common_cols: + update_df['repeat'] = pd.to_numeric(update_df['repeat'], errors='coerce').fillna(0).astype(int) + + # Ensure '_random_' column is integer type + # if '_random_' in common_cols: + # update_df['_random_'] = pd.to_numeric(update_df['_random_'], errors='coerce').fillna(0).astype(int) + + # Step 2: Convert 0/1 to Python booleans for checkbox columns + for col in ['show_file']: + if col in common_cols: + # Convert 0/1 to Python booleans + update_df[col] = update_df[col].astype(int).astype(bool) + + # Step 3: Merge by relative_path and update only the specified columns + for col in common_cols: + if col == 'relative_path': + continue # Never update or map the index column itself + # Step 3a: Create a mapping from loaded data + mapping_series = update_df.set_index('relative_path')[col] + + # Step 3b: Apply the mapping to update the main dataframe + mapped_values = self.df['relative_path'].map(mapping_series) + + # Step 3c: Fill missing values with existing data + updated_column = mapped_values.fillna(self.df[col]) + + # Step 3d: Update the main dataframe with proper type conversion + if col in ['show_file']: + # Ensure Python boolean type (not numpy.bool_) + updated_column = updated_column.map(lambda x: bool(x)) + updated_column = updated_column.astype(object) + elif col == 'repeat': + updated_column = pd.to_numeric(updated_column, errors='coerce').fillna(0).astype(int) + # elif col == '_random_': + # updated_column = pd.to_numeric(updated_column, errors='coerce').fillna(0).astype(int) + elif col in ['parent_folder', 'grandparent_folder', 'great_grandparent_folder']: + # Ensure folder columns are always strings + updated_column = updated_column.astype(str) + # Convert 'nan' strings back to empty strings + updated_column = updated_column.replace('nan', '') + elif col == 'note': + # Ensure note column is always string and handle NaN values + updated_column = updated_column.astype(str) + # Convert 'nan' strings back to empty strings + updated_column = updated_column.replace('nan', '') + elif hasattr(updated_column, 'infer_objects') and updated_column.dtype == 'object': + updated_column = updated_column.infer_objects(copy=False) + self.df[col] = updated_column + + logger.info(f"Loaded state from: {state_filepath}") + else: + logger.warning(f"No matching columns found in saved state file: {state_filepath}") + except Exception as e: + logger.error(f"Error loading state from {state_filepath}: {e}") + + def _scan_files(self): + """Scan for .tif files and populate the dataframe.""" + logger.info(f"Scanning for .tif files in: {self.root_path}") + + file_data = [] + + try: + for root, dirs, files in os.walk(self.root_path): + # Skip if this path should be excluded + if self._should_exclude_path(root): + continue + + # Filter for .tif files and sort them + tif_files = sorted([f for f in files if f.lower().endswith('.tif')]) + + # logger.debug('') + # print(f"Found {len(tif_files)} .tif files in {root}: {tif_files}") + + # logger.warning(f'root:{root}') + + for _idx, tif_file in enumerate(tif_files): + + # print(f' _idx:{_idx} of {len(tif_files)} tif_file:{tif_file}') + + full_path = os.path.join(root, tif_file) + + # Calculate relative path + try: + relative_path = os.path.relpath(full_path, self.root_path) + except ValueError: + # Handle case where paths are on different drives + relative_path = full_path + + # Extract folder hierarchy + path_parts = relative_path.split(os.sep) + + if len(path_parts) >= 4: + # We have enough levels for the full hierarchy + parent_folder = path_parts[-2] # Folder containing the .tif file + grandparent_folder = path_parts[-3] + great_grandparent_folder = path_parts[-4] + elif len(path_parts) == 3: + # Three levels: great_grandparent/grandparent/file.tif + parent_folder = path_parts[-2] + grandparent_folder = path_parts[-3] + great_grandparent_folder = "" + elif len(path_parts) == 2: + # Two levels: grandparent/file.tif + parent_folder = path_parts[-2] + grandparent_folder = "" + great_grandparent_folder = "" + else: + # File is directly in root + parent_folder = "" + grandparent_folder = "" + great_grandparent_folder = "" + + # Parse filename with TifInfo + try: + tif_info = TifInfo.from_filename(tif_file) + date = tif_info.date + cell_id = tif_info.cellid + region = tif_info.region + condition = tif_info.condition + repeat = tif_info.repeat + error = tif_info.error + except Exception as e: + # If TifInfo parsing fails, use default values and log the error + logger.warning(f"Failed to parse filename '{tif_file}': {e}") + date = "" + cell_id = "" + region = "" + condition = "Unknown" + repeat = 0 + error = str(e) + + file_data.append({ + 'relative_path': relative_path, + 'filename': tif_file, + 'parent_folder': parent_folder, + 'grandparent_folder': grandparent_folder, + 'great_grandparent_folder': great_grandparent_folder, + 'date': date, + 'cell_id': cell_id, + 'region': region, + 'condition': condition, + 'repeat': repeat, + 'error': error, + 'show_file': True, + 'note': '', # Initialize note to empty string + '_KymRoiAnalysis': None, # Initialize to None, will be created on demand + '_KymRoiAnalysis_Loaded': False, # Track loading status for UI display + # '_random_': random.randint(0, 100) # Example: Generate random value 0-100 + }) + except PermissionError as e: + logger.error(f"Permission denied accessing {self.root_path}: {e}") + except Exception as e: + logger.error(f"Error scanning {self.root_path}: {e}") + + # Create DataFrame from collected data + self.df = pd.DataFrame(file_data) + + # Sort by grandparent_folder first, then by filename + # This groups all files in the same grandparent folder together + if len(self.df) > 0: + if self.sort_by_grandparent: + self.df = self.df.sort_values(['great_grandparent_folder', 'grandparent_folder', 'filename']) + else: + self.df = self.df.sort_values(['parent_folder', 'filename']) + + if len(self.df) > 0: + logger.info(f"Found {len(self.df)} .tif files") + else: + logger.warning("No .tif files found") + + def get(self, item_type: str, **kwargs) -> Union[List[str], Dict[str, int], List[str], int]: + """ + Get data from the backend. + + Parameters + ---------- + item_type : str + Type of data to retrieve. Valid options: + - 'files': Get checked file paths + - 'file_count': Get total number of files + - 'condition_counts': Get counts by condition + - 'repeat_counts': Get counts by repeat number + - 'unique_conditions': Get list of unique conditions + - 'unique_repeats': Get list of unique repeat numbers + - 'filter_by_condition': Filter by condition (requires condition parameter) + - 'filter_by_repeat': Filter by repeat (requires repeat parameter) + + Returns + ------- + Union[List[str], Dict[str, int], List[str], int, pd.DataFrame] + The requested data + """ + if self.df is None or len(self.df) == 0: + return [] if item_type in ['files', 'unique_conditions', 'unique_repeats'] else {} + + if item_type == 'files': + # Construct full paths from relative paths + relative_paths = self.df[self.df['show_file']]['relative_path'].tolist() + return [self.resolve_path(rel_path) for rel_path in relative_paths] + + elif item_type == 'file_count': + return len(self.df) + + elif item_type == 'condition_counts': + return self.df['condition'].value_counts().to_dict() + + elif item_type == 'repeat_counts': + return self.df['repeat'].value_counts().to_dict() + + elif item_type == 'unique_conditions': + return self.df['condition'].unique().tolist() + + elif item_type == 'unique_repeats': + return sorted(self.df['repeat'].unique().tolist()) + + elif item_type == 'filter_by_condition': + condition = kwargs.get('condition') + if condition is None: + raise ValueError("condition parameter required for filter_by_condition") + return self.df[self.df['condition'] == condition] + + elif item_type == 'filter_by_repeat': + repeat = kwargs.get('repeat') + if repeat is None: + raise ValueError("repeat parameter required for filter_by_repeat") + return self.df[self.df['repeat'] == repeat] + + else: + raise ValueError(f"Invalid item_type: {item_type}. Valid options: ['files', 'file_count', 'condition_counts', 'repeat_counts', 'unique_conditions', 'unique_repeats', 'filter_by_condition', 'filter_by_repeat']") + + def set_checked(self, item_type: str, identifier: str, checked: bool): + """ + Set checkbox state for individual files. + + Parameters + ---------- + item_type : str + Type of item to set. Only 'file' is supported. + identifier : str + The full path of the file + checked : bool + Whether the file should be checked + """ + if self.df is None: + return + + if item_type == 'file': + # Convert full path to relative path for matching + if os.path.isabs(identifier): + # If it's an absolute path, convert to relative path + try: + relative_path = os.path.relpath(identifier, self.root_path) + except ValueError: + # Handle case where paths are on different drives + relative_path = identifier + else: + relative_path = identifier + + mask = self.df['relative_path'] == relative_path + if mask.any(): + # Ensure we're setting a Python bool, not numpy.bool_ + self.df.loc[mask, 'show_file'] = bool(checked) + # Force the entire column to be native Python bool + self.df['show_file'] = self.df['show_file'].map(lambda x: bool(x)).astype(object) + else: + raise ValueError(f"Invalid item_type: {item_type}. Only 'file' is supported.") + + def refresh(self): + """Refresh the data by re-scanning the files.""" + logger.info("Refreshing file data") + self._scan_files() + + def save_state(self, filepath: str = None): + """Save the current dataframe state to a CSV file.""" + if len(self.df) > 0: + if filepath is None: + filepath = self._get_state_filepath() + + # Create a copy for saving to avoid modifying the original + save_df = self.df.copy() + + # Remove the _KymRoiAnalysis column before saving (it contains objects that can't be serialized) + if '_KymRoiAnalysis' in save_df.columns: + save_df = save_df.drop(columns=['_KymRoiAnalysis']) + + # Remove the _KymRoiAnalysis_Loaded column before saving (it's runtime-only) + if '_KymRoiAnalysis_Loaded' in save_df.columns: + save_df = save_df.drop(columns=['_KymRoiAnalysis_Loaded']) + + # Convert boolean columns to 0/1 for reliable CSV storage + for col in ['show_file']: + if col in save_df.columns: + save_df[col] = save_df[col].astype(bool).astype(int) # True->1, False->0 + + # Ensure repeat column is integer + if 'repeat' in save_df.columns: + save_df['repeat'] = pd.to_numeric(save_df['repeat'], errors='coerce').fillna(0).astype(int) + + save_df.to_csv(filepath, index=False) + logger.info(f"Saved state to: {filepath}") + return filepath + return None + + def load_state(self, filepath: str): + """Load state from a specific CSV file.""" + # Implementation for loading from specific file + pass + + # Column management methods for frontend widgets + def get_column_config(self, column_name: str) -> Dict[str, Any]: + """Get configuration for a specific column.""" + config = self.COLUMN_CONFIG.get(column_name) + return config.to_dict() if config else {} + + def get_visible_columns(self, widget_type: str = 'table') -> List[str]: + """Get list of visible columns for a specific widget type.""" + visible_cols = [] + for col_name, config in self.COLUMN_CONFIG.items(): + if getattr(config, f'{widget_type}_visible', False): + visible_cols.append(col_name) + return visible_cols + + def get_column_display_names(self, columns: List[str]) -> List[str]: + """Get display names for a list of columns.""" + return [getattr(self.COLUMN_CONFIG.get(col), 'display_name', col) for col in columns] + + def get_column_width(self, column_name: str) -> Optional[int]: + """Get width for a specific column.""" + config = self.COLUMN_CONFIG.get(column_name) + return getattr(config, 'width', None) if config else None + + def is_column_stretch(self, column_name: str) -> bool: + """Check if a column should stretch.""" + config = self.COLUMN_CONFIG.get(column_name) + return getattr(config, 'stretch', False) if config else False + + def is_column_editable(self, column_name: str) -> bool: + """Check if a column is editable.""" + config = self.COLUMN_CONFIG.get(column_name) + return getattr(config, 'editable', False) if config else False + + def get_column_widget_type(self, column_name: str) -> Optional[str]: + """Get widget type for a column.""" + config = self.COLUMN_CONFIG.get(column_name) + return getattr(config, 'widget_type', None) if config else None + + def is_checkbox_column(self, column_name: str) -> bool: + """Check if a column is a checkbox column.""" + return self.get_column_widget_type(column_name) == 'checkbox' + + def get_column_backend_field(self, column_name: str) -> Optional[str]: + """Get the backend field name for a column.""" + config = self.COLUMN_CONFIG.get(column_name) + return getattr(config, 'backend_field', None) if config else None + + def get_all_columns(self) -> List[str]: + """Get all available columns.""" + return list(self.COLUMN_CONFIG.keys()) + + def add_column(self, column_name: str, config: Dict[str, Any] = None): + """Add a new column to the configuration.""" + if column_name not in self.COLUMN_CONFIG: + # Create default ColumnConfig with sensible defaults + default_config = ColumnConfig( + display_name=column_name.replace('_', ' ').title(), + width=100, + stretch=False, + editable=False, + widget_type=None, + backend_field=column_name, + tree_visible=False, + table_visible=False, + sortable=True + ) + + # Override with provided config if any + if config: + # Convert dict to ColumnConfig by updating the default + for key, value in config.items(): + if hasattr(default_config, key): + setattr(default_config, key, value) + + self.COLUMN_CONFIG[column_name] = default_config + + # Add to DataFrame if it doesn't exist + if column_name not in self.df.columns: + self.df[column_name] = None + + def remove_column(self, column_name: str): + """Remove a column from the configuration.""" + if column_name in self.COLUMN_CONFIG: + del self.COLUMN_CONFIG[column_name] + + # Remove from DataFrame if it exists + if column_name in self.df.columns: + self.df = self.df.drop(columns=[column_name]) + + def get_kym_roi_analysis(self, row_index: int) -> Optional[KymRoiAnalysis]: + """ + Get or create a KymRoiAnalysis object for a specific row. + + This method implements lazy loading - it only creates the KymRoiAnalysis + object when first requested, and returns the cached object on subsequent calls. + + Parameters + ---------- + row_index : int + The row index in the DataFrame + + Returns + ------- + Optional[KymRoiAnalysis] + The KymRoiAnalysis object for the specified row, or None if the row doesn't exist + or if there's an error loading the file + """ + if self.df is None or len(self.df) == 0: + return None + + if row_index < 0 or row_index >= len(self.df): + logger.error(f"Row index {row_index} is out of bounds (0-{len(self.df)-1})") + return None + + # Check if we already have a KymRoiAnalysis object for this row + existing_analysis = self.df.iloc[row_index]['_KymRoiAnalysis'] + if existing_analysis is not None: + return existing_analysis + + # Get the file path for this row + relative_path = self.df.iloc[row_index]['relative_path'] + tif_path = self.resolve_path(relative_path) + + # logger.info(f'relative_path:{relative_path}') + # logger.info(f'tif_path:{tif_path}') + + try: + # Create the KymRoiAnalysis object + logger.info(f"=== Loading KymRoiAnalysis for: {relative_path}") + kym_analysis = KymRoiAnalysis(path=tif_path) + + # Set the KymRoiAnalysis object for this row + + # this is my solution + self.df.at[row_index, '_KymRoiAnalysis'] = kym_analysis + self.df.at[row_index, '_KymRoiAnalysis_Loaded'] = True + + # this was Cursor solutuon which trigger an error "object of type 'KymRoiAnalysis' has no len()" + # Store it in the DataFrame + # self.df.iloc[row_index, self.df.columns.get_loc('_KymRoiAnalysis')] = kym_analysis + + # Update loading status + # self.df.iloc[row_index, self.df.columns.get_loc('_KymRoiAnalysis_Loaded')] = True + + return kym_analysis + + except Exception as e: + # object of type 'KymRoiAnalysis' has no len() + logger.error(f"Error creating KymRoiAnalysis for {tif_path}: {e}") + return None + + def get_kym_roi_analysis_by_path(self, relative_path: str) -> Optional[KymRoiAnalysis]: + """ + Get or create a KymRoiAnalysis object for a specific relative path. + + Parameters + ---------- + relative_path : str + The relative path (e.g., "20250401/SSAN/20250401 SSAN FCCP R2 LS3.tif") + + Returns + ------- + Optional[KymRoiAnalysis] + The KymRoiAnalysis object for the specified relative path, or None if not found + """ + if self.df is None or len(self.df) == 0: + return None + + # Find the row with this relative path + mask = self.df['relative_path'] == relative_path + if not mask.any(): + logger.error(f"Relative path not found in DataFrame: {relative_path}") + return None + + row_index = mask.idxmax() + return self.get_kym_roi_analysis(row_index) + + def clear_kym_roi_analysis_cache(self): + """ + Clear all cached KymRoiAnalysis objects to free memory. + + This is useful when you want to free up memory or when you suspect + the cached objects might be stale. + """ + if self.df is not None and '_KymRoiAnalysis' in self.df.columns: + self.df['_KymRoiAnalysis'] = None + if '_KymRoiAnalysis_Loaded' in self.df.columns: + self.df['_KymRoiAnalysis_Loaded'] = False + logger.info("Cleared KymRoiAnalysis cache") + + def get_cached_kym_roi_analysis_count(self) -> int: + """ + Get the number of currently cached KymRoiAnalysis objects. + + Returns + ------- + int + Number of cached KymRoiAnalysis objects + """ + if self.df is None or '_KymRoiAnalysis' not in self.df.columns: + return 0 + + return self.df['_KymRoiAnalysis'].notna().sum() + + def get_full_path(self, relative_path: str) -> str: + """ + Get the full path for a relative path. + + Parameters + ---------- + relative_path : str + The relative path + + Returns + ------- + str + The full absolute path + """ + return self.resolve_path(relative_path) + + def resolve_path(self, path: str) -> str: + """ + Resolve a path to an absolute path. + + If the path is relative, it will be resolved relative to the backend's root_path. + If the path is already absolute, it will be normalized. + + Parameters + ---------- + path : str + The path to resolve (can be absolute or relative) + + Returns + ------- + str + The resolved absolute path + """ + if not os.path.isabs(path): + # If it's a relative path, resolve it relative to root_path + resolved_path = os.path.join(self.root_path, path) + else: + resolved_path = path + + return resolved_path \ No newline at end of file diff --git a/sanpy/kym/tif_info.py b/sanpy/kym/tif_info.py new file mode 100644 index 00000000..342b52fc --- /dev/null +++ b/sanpy/kym/tif_info.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +TifInfo dataclass for parsing TIF file names. +""" + +import re +from dataclasses import dataclass +from typing import Optional +from datetime import datetime +import os + +from sanpy.kym.logger import get_logger + +logger = get_logger(__name__) + +@dataclass +class TifInfo: + """Dataclass to hold parsed file information from TIF file names. + + Example filenames: + "20250312 ISAN R1 LS1.tif" + "20250312 ISAN FCCP R1 LS1.tif" + "20250312 ISAN R1 LS1_0001.tif" + """ + date: str + cellid: str + condition: str + region: str + repeat: int + error: str = "" + + # Class variables for configurable parsing + POSSIBLE_CONDITIONS = ['Control', 'Ivab', 'Thap', 'FCCP'] + POSSIBLE_REGIONS = ['ISAN', 'SSAN'] + + @classmethod + def from_filename(cls, filename: str) -> 'TifInfo': + """Create TifInfo from a TIF filename. + + Args: + filename: Filename like "20250312 ISAN R1 LS1.tif" or "20250312 ISAN FCCP R1 LS1.tif" + + Returns: + TifInfo object with parsed components + """ + errors = [] + + # Remove .tif extension if present + clean_filename = filename.replace('.tif', '') + + # Normalize multiple spaces to single spaces + # Handle cases like ' ' or ' ' becoming ' ' + while ' ' in clean_filename: + clean_filename = clean_filename.replace(' ', ' ') + + # Split into parts + parts = clean_filename.split(' ') + + # Validate minimum parts + if len(parts) < 4: + error_msg = f"Filename '{filename}' has insufficient parts (need at least 4, got {len(parts)})" + logger.error(error_msg) + errors.append(error_msg) + return cls( + date="", + cellid="", + condition="Control", # default + region="", + repeat=0, + error="; ".join(errors) + ) + + # Extract date (first part) + date = parts[0] + if not date.isdigit() or len(date) != 8: + error_msg = f"Date '{date}' in filename '{filename}' is not a valid 8-digit date (YYYYMMDD)" + logger.error(error_msg) + errors.append(error_msg) + date = "" + + # Extract region + region = None + for part in parts: + if part in cls.POSSIBLE_REGIONS: + region = part + break + + if region is None: + error_msg = f"Region not found in filename '{filename}'. Expected one of {cls.POSSIBLE_REGIONS}" + logger.error(error_msg) + errors.append(error_msg) + region = "" + + # Extract condition + condition = "Control" # default + for cond in cls.POSSIBLE_CONDITIONS: + if cond in clean_filename: + condition = cond + break + + # Extract repeat number + repeat = 0 # default + # Look for pattern like _0001, _0002, etc. or spaces followed by 3-4 digits at the end + repeat_pattern = r'[_\s](\d{3,4})$' # underscore or space followed by 3-4 digits at the end + repeat_match = re.search(repeat_pattern, clean_filename) + if repeat_match: + repeat_str = repeat_match.group(1) + try: + repeat = int(repeat_str) + except ValueError: + error_msg = f"Could not parse repeat number '{repeat_str}' from filename '{filename}'" + logger.error(error_msg) + errors.append(error_msg) + + # Extract cellid - everything up to the repeat pattern + cellid_parts = clean_filename + if repeat_match: + # Remove the repeat pattern from the end + cellid_parts = clean_filename[:repeat_match.start()] + + # Remove condition from cellid if present + for cond in cls.POSSIBLE_CONDITIONS: + if cond in cellid_parts: + cellid_parts = cellid_parts.replace(f' {cond}', '') + break + + cellid = cellid_parts.strip() + + # Validate cellid has expected format + if not cellid or len(cellid.split(' ')) < 3: + error_msg = f"CellID '{cellid}' from filename '{filename}' does not have expected format (should be like '20250312 ISAN R1 LS1')" + logger.error(error_msg) + errors.append(error_msg) + + return cls( + date=date, + cellid=cellid, + condition=condition, + region=region, + repeat=repeat, + error="; ".join(errors) if errors else "" + ) + + @classmethod + def set_possible_conditions(cls, conditions: list) -> None: + """Update the list of possible conditions for parsing. + + Args: + conditions: List of condition strings to recognize + """ + cls.POSSIBLE_CONDITIONS = conditions + + @classmethod + def set_possible_regions(cls, regions: list) -> None: + """Update the list of possible regions for parsing. + + Args: + regions: List of region strings to recognize + """ + cls.POSSIBLE_REGIONS = regions \ No newline at end of file diff --git a/sanpy/kymAnalysis.py b/sanpy/kymAnalysis.py index 10277dc3..4cb2d16b 100644 --- a/sanpy/kymAnalysis.py +++ b/sanpy/kymAnalysis.py @@ -136,6 +136,8 @@ def startStopFromDeriv(lineProfile, stdMult, doPlot=False, verbose=False): midPoint = int(lineProfile.shape[0]/2) # print('midPoint:', midPoint) + logger.info(f'lineDeriv:{lineDeriv.shape} midPoint:{midPoint}') + leftDeriv = lineDeriv[0:midPoint] rightDeriv = lineDeriv[midPoint:-1] @@ -609,7 +611,7 @@ def getDefaultAnalysisParams() -> dict: def __init__(self, path : str, - tifData : np.ndarray = None, + tifData : np.ndarray = None, # can be multichannel tifHeader : dict = None, autoLoad: bool = True): """ @@ -626,6 +628,9 @@ def __init__(self, auto load analysis """ + self._timeDim = 1 # 20241004, add while working on kym roi + self._spaceDim = 0 + self._path = path # path to tif file @@ -634,6 +639,7 @@ def __init__(self, self._kymImage = tifData else: # load + logger.error('DO NOT LOAD TIF DIRECTLY -->> USE fileloader_tiff') self._kymImage: np.ndarray = tifffile.imread(path) self._kymImageFiltered = None @@ -645,11 +651,12 @@ def __init__(self, logger.error(f"image must be 2d but is {self._kymImage.shape}") # image must be shape[0] is time/big, shape[1] is space/small - if self._kymImage.shape[0] < self._kymImage.shape[1]: - # logger.info(f"rot90 image with shape: {self._kymImage.shape}") - self._kymImage = np.rot90( - self._kymImage, 3 - ) # ROSIE, so lines are not backward + # logger.warning('1 removed np.rot90()') + # if self._kymImage.shape[0] < self._kymImage.shape[1]: + # # logger.info(f"rot90 image with shape: {self._kymImage.shape}") + # self._kymImage = np.rot90( + # self._kymImage, 3 + # ) # ROSIE, so lines are not backward # this is what we extract line profiles from # we create filtered version at start of detect() @@ -715,13 +722,13 @@ def printAnlysisParam(self): def getAnalysisParam(self, name): if name not in self._analysisParams.keys(): - logger.error(f'did not find key: {name}') + logger.error(f'did not find key: "{name}"') return return self._analysisParams[name] def setAnalysisParam(self, name, value): if name not in self._analysisParams.keys(): - logger.error(f'did not find key: {name}') + logger.error(f'did not find key: "{name}"') self._analysisParams[name] = value @property @@ -946,7 +953,7 @@ def setRoiRect(self, newRect): def getImage(self): return self._kymImage - def getImageContrasted(self, theMin, theMax, rot90=False): + def _old_getImageContrasted(self, theMin, theMax, rot90=False): bitDepth = self._header['bitDepth'] lut = np.arange(2**bitDepth, dtype="uint8") lut = self._getContrastedImage(lut, theMin, theMax) # get a copy of the image @@ -984,11 +991,11 @@ def getImageShape(self): def numLineScans(self): """Number of line scans in kymograph.""" - return self.getImageShape()[0] + return self.getImageShape()[self._timeDim] def pointsPerLineScan(self): """Number of points in each line scan.""" - return self.getImageShape()[1] + return self.getImageShape()[self._spaceDim] def _getAnalysisFolder(self): savePath, saveFile = os.path.split(self._path) @@ -1202,16 +1209,18 @@ def _getFitLineProfile(self, lineScanNumber : int, # we know the scan line, determine start/stop based on roi roiRect = self.getRoiRect() # (l, t, r, b) in um and seconds (float) + logger.info(f'lineWidth:{lineWidth} roiRect:{roiRect}') src_pnt_space = roiRect.getBottom() dst_pnt_space = roiRect.getTop() if lineWidth == 1: - intensityProfile = self._filteredImage[lineScanNumber, :] + # intensityProfile = self._filteredImage[lineScanNumber, :] + intensityProfile = self._filteredImage[:, lineScanNumber] intensityProfile = np.flip(intensityProfile) # FLIPPED else: # numPixels = self._filteredImage.shape[1] - _numLines = self._filteredImage.shape[0] + _numLines = self._filteredImage.shape[self._timeDim] halfLineWidth = (lineWidth-1) / 2 # assuming lineWidth is odd halfLineWidth = int(halfLineWidth) @@ -1223,11 +1232,15 @@ def _getFitLineProfile(self, lineScanNumber : int, if _stopLine >= _numLines: _stopLine = _numLines - 1 - _slice = self._filteredImage[_startLine:_stopLine, :] - intensityProfile = np.mean(_slice, axis=0) + _slice = self._filteredImage[:, _startLine:_stopLine] + logger.info(f' _slice:{_slice.shape}') + # intensityProfile = np.mean(_slice, axis=0) + intensityProfile = np.mean(_slice, axis=1) intensityProfile = np.flip(intensityProfile) # FLIPPED + logger.warning(f' 1 intensityProfile:{intensityProfile.shape}') + # median filter line profile if lineFilterKernel > 0: intensityProfile = scipy.signal.medfilt(intensityProfile, lineFilterKernel) @@ -1968,7 +1981,7 @@ def plotKym(ka: kymAnalysis): _extent = ka.getImageRect().getMatPlotLibExtent() # [l, t, r, b] logger.info(f"_extent [left, right, bottom, top]:{_extent}") tifDataCopy = ka.getImage().copy() - tifDataCopy = np.rot90(tifDataCopy) + # tifDataCopy = np.rot90(tifDataCopy) # axs[0].imshow(tifDataCopy, extent=_extent) axs[0].imshow(tifDataCopy) diff --git a/setup.py b/setup.py deleted file mode 100644 index 7e7254e5..00000000 --- a/setup.py +++ /dev/null @@ -1,144 +0,0 @@ -""" -nov sfn 2023, need to add aicsimageio for czi reading - -pip install aicsimageio -pip install aicspylibczi - -does not work on macos m1 -conda install does not work!!! -""" - -import os -from datetime import datetime - -from setuptools import setup, find_packages - -# manually keep in sync with sanpy/version.py -#VERSION = "0.1.11" - -# with open(os.path.join('sanpy', 'VERSION')) as version_file: -# VERSION = version_file.read().strip() - -# load the readme -_thisPath = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.abspath(_thisPath+"/README.md")) as f: - long_description = f.read() - -# update _buildDateTime.py -_buildDate = datetime.today().strftime('%Y-%m-%d') -_buildTime = datetime.today().strftime('%H:%M:%S') -with open('sanpy/_buildDateTime.py', 'w') as f: - f.write('# Auto generated in setup.py\n') - f.write(f'BUILD_DATE="{_buildDate}"\n') - f.write(f'BUILD_TIME="{_buildTime}"\n') - -guiRequirements = [ - 'numpy', # ==1.23.4', # 1.24 breaks PyQtGraph with numpy.float error - 'pandas', #==1.5', # version 2.0 removes dataframe append - 'scipy', - 'pyabf', - 'tifffile', - - # on 20231122 with newer 3.8.2 pyInstaller macOS app was hanging with 'building font cache' - #'matplotlib==3.8.0', - 'matplotlib', - - 'mplcursors', - 'seaborn', - 'requests', # to load from the cloud (for now github) - 'tables', # aka pytable for hdf5. Conflicts with conda install - # used for line profile in kym analysis - # 0.20.0 introduces pyinstaller bug because of lazy import - # scikit-image 0.20.0 requires pyInstaller-hooks-contrib >= 2023.2. - # and 0.22.0 requires >= 2023.10 - 'scikit-image', #==0.19.3', - 'h5py', # conflicts with conda install - - 'qtpy', - 'pyqtgraph', - 'pyqtdarktheme', # switched to this mar 2023 - 'PyQt5', # only install x86 version, need to use conda install pyqt -] - -devRequirements = guiRequirements + [ - 'mkdocs', - 'mkdocs-material', - 'mkdocs-jupyter', - 'mkdocstrings', - 'mkdocstrings-python', # resolve error as of April 30, 2023 - #'mkdocs-with-pdf', - 'tornado', # needed for pyinstaller - 'pyinstaller', - 'ipython', - 'jupyter', - 'tox', - 'pytest', - 'pytest-cov', - 'pytest-qt', - 'flake8', -] - -testRequirements = guiRequirements + [ - 'tox', - 'pytest', - 'pytest-cov', - 'pytest-qt', - 'flake8', -] - -setup( - name='sanpy-ephys', # the package name (on PyPi), still use 'import sanpy' - #version=VERSION, - description='Whole cell current-clamp analysis.', - long_description=long_description, - long_description_content_type = 'text/markdown', - url='http://github.com/cudmore/SanPy', - author='Robert H Cudmore', - author_email='rhcudmore@ucdavis.edu', - license='GNU General Public License, Version 3', - classifiers=[ - 'Programming Language :: Python :: 3', - 'Natural Language :: English', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Operating System :: MacOS', - 'Operating System :: Microsoft :: Windows', - 'Intended Audience :: Developers', - 'Intended Audience :: Science/Research', - 'Intended Audience :: End Users/Desktop', - 'Topic :: Scientific/Engineering :: Bio-Informatics', - 'Topic :: Scientific/Engineering :: Information Analysis', - 'Topic :: Scientific/Engineering :: Medical Science Apps.', - 'Topic :: Scientific/Engineering :: Visualization', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Software Development :: Libraries :: Application Frameworks', - ], - - # this is CRITICAL to import submodule like sanpy.userAnalysis - packages=find_packages(include=['sanpy', - 'sanpy.*', - 'sanpy.interface', - 'sanpy.fileloaders' - ]), - - include_package_data=True, # uses manifest.in - - use_scm_version=True, - setup_requires=['setuptools_scm'], - - # for conda based pyinstaller, we had to remove all installs - # please install with pip install -e '.[gui]' - install_requires=[], - - extras_require={ - 'gui': guiRequirements, - 'dev': devRequirements, - 'test': testRequirements, - }, - - python_requires=">=3.8", - entry_points={ - 'console_scripts': [ - 'sanpy=sanpy.interface.sanpy_app:main', - ] - }, -) diff --git a/tests/interface/test_plugins.py b/tests/interface/test_plugins.py index 492e9848..512ac5af 100644 --- a/tests/interface/test_plugins.py +++ b/tests/interface/test_plugins.py @@ -209,6 +209,7 @@ def test_plugins(qtbot, qapp): # try to close and garbage collect # _newPlugin.close() # _newPlugin = None + _newPlugin.closeEvent() logger.info(' done')