diff --git a/README.md b/README.md index b03990193f870835612c15be5e1321f9e70dbc61..5b5eb517ab987a0d8f462ba10bf47c94ab82440f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [](https://pypi.org/project/epicure) [](https://python.org) [](https://napari-hub.org/plugins/epicure) +[](https://doi.org/10.5281/zenodo.13952184)  diff --git a/notebooks/EpiCure_ExportAllDivision_OnFolder.ipynb b/notebooks/EpiCure_ExportAllDivision_OnFolder.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..5956d8428c4dcf59f07ce7512e93aa40f1d47a83 --- /dev/null +++ b/notebooks/EpiCure_ExportAllDivision_OnFolder.ipynb @@ -0,0 +1,99 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9629f8e8-86e7-4a6d-85a6-a6b34d548545", + "metadata": {}, + "source": [ + "# Export division/other events for all movies in a folder\n", + "\n", + "Notebook to automatically do an export step of EpiCure:\n", + "* **export all division events** (optionnaly, can export other events)\n", + "\n", + "This step correspond to the selection of `Export events` in the `Output` panel of EpiCure. \n", + "It will be done on all the movies in a given folder (in all sub-folders), provided that there is saved EpiCure files in the ouput `epics` folder(s).\n", + "All division (for all cells) will be exported as Fiji ROI. \n", + "\n", + "You can modify this notebook to change it to export only the division corresponding to a EpiCure Group or export other events.\n", + "\n", + "*This notebook is part of EpiCure release, see https://gitlab.pasteur.fr/gletort/epicure for more informations*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "47dd0f63-87ef-4dce-8034-413b45e77992", + "metadata": {}, + "outputs": [], + "source": [ + "#### Parent directory to process. It will go through all the sub-folders\n", + "main_path = \"../data/small/\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48bf6960-5798-45e9-8271-de1ece6897a4", + "metadata": {}, + "outputs": [], + "source": [ + "import epicure.epicuring as epicure\n", + "import os\n", + "\n", + "def doOneMovie( imgname ):\n", + " \"\"\" Process one movie: clear (or not) the border cells and performs tracking \"\"\"\n", + " print( \"EpiCuring movie \"+imgname+\".tif\" )\n", + " epic = epicure.EpiCure()\n", + " epic.verbose = 0 ## Choose the level of printing infos during process (0: minimal to 3: debug informations)\n", + " epic.load_movie( imgname+\".tif\" )\n", + " epic.go_epicure() ## start EpiCure, load the segmentation, prepare everything\n", + " \n", + " ## choose export division parameters\n", + " epic.outputing.output_mode.setCurrentText( \"All cells\" ) ## save division concerning all cells (change it to the group name to save only the one linked to a given group)\n", + " epic.outputing.events_select( \"division\", True ) ## output division\n", + " epic.outputing.events_select( \"suspect\", False ) ## dont output suspects\n", + " \n", + " ## do the export\n", + " epic.outputing.export_events()\n", + " \n", + "### Main loop, go through all subdirectories and process movies for which the associated segmentation file correspond to the segmentation_extension name\n", + "for root, dirs, files in os.walk( main_path ):\n", + " for file in files:\n", + " if (os.path.basename( root ) == \"epics\") and file.endswith( \"labels.tif\" ):\n", + " imgname = file[:len(file)-len(\"_labels.tif\")]\n", + " cdir = os.path.dirname( root )\n", + " imgname = os.path.join( cdir, imgname )\n", + " doOneMovie( imgname )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e374937-76fe-4f14-94dc-92562bedf397", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "epic", + "language": "python", + "name": "epic" + }, + "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.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/EpiCure_Initialize_OnFolder.ipynb b/notebooks/EpiCure_Initialize_OnFolder.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..cd4a015a15245c1b08a6e2428163a50996710f36 --- /dev/null +++ b/notebooks/EpiCure_Initialize_OnFolder.ipynb @@ -0,0 +1,98 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9629f8e8-86e7-4a6d-85a6-a6b34d548545", + "metadata": {}, + "source": [ + "# Initialize: remove border cells + tracking all movies in a folder\n", + "\n", + "Notebook to automatically do initial steps of EpiCure:\n", + "* **removing the border cells** (optionnaly, cells that are partly outside of the image)\n", + "* **performs the tracking** (with the default parameters)\n", + "* **save** the results\n", + "\n", + "These 3 steps will be done on all the movies in a given folder (in all sub-folders), provided that there is an associated segmentation file with the same name as the input image + given extension string (for example `_imagename_epyseg.tif`).\n", + "\n", + "*This notebook is part of EpiCure release, see https://gitlab.pasteur.fr/gletort/epicure for more informations*" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "47dd0f63-87ef-4dce-8034-413b45e77992", + "metadata": {}, + "outputs": [], + "source": [ + "#### Parent directory to process. It will go through all the sub-folders\n", + "main_path = \"../data/small/\"\n", + "#### Name of the associated segmentation file for each image. It must have the same name as the image, with the additional segmentation_extension\n", + "segmentation_extension = \"_epyseg\"\n", + "#### Option clear border of EpiCure: ON (True) or OFF (False)\n", + "remove_border_cells = True" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48bf6960-5798-45e9-8271-de1ece6897a4", + "metadata": {}, + "outputs": [], + "source": [ + "import epicure.epicuring as epicure\n", + "import os\n", + "\n", + "def doOneMovie( imgname ):\n", + " \"\"\" Process one movie: clear (or not) the border cells and performs tracking \"\"\"\n", + " print( \"EpiCuring movie \"+imgname+\".tif\" )\n", + " epic = epicure.EpiCure()\n", + " epic.verbose = 0 ## Choose the level of printing infos during process (0: minimal to 3: debug informations)\n", + " epic.load_movie( imgname+\".tif\" )\n", + " epic.go_epicure( \"epics\", imgname+segmentation_extension+\".tif\" ) ## start EpiCure, load the segmentation, prepare everything\n", + " if remove_border_cells:\n", + " epic.editing.border_size.setText(\"1\") ## Choose the border size parameter to remove the cells that are within the given distance of the image border\n", + " epic.editing.remove_border() ## EpiCure option to remove all cells that are touching the image border\n", + " epic.tracking.do_tracking() ## Performs tracking with the default parameters. If you have saved preferences, it will use it.\n", + " epic.save_epicures() ## save the results in the ouput \"epics\" folder(s)\n", + " \n", + " \n", + "### Main loop, go through all subdirectories and process movies for which the associated segmentation file correspond to the segmentation_extension name\n", + "for root, dirs, files in os.walk( main_path ):\n", + " for file in files:\n", + " if file.endswith( segmentation_extension+\".tif\" ):\n", + " imgname = file[:len(file)-4-len(segmentation_extension)]\n", + " imgname = os.path.join( root, imgname ) \n", + " doOneMovie( imgname )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e374937-76fe-4f14-94dc-92562bedf397", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "epic", + "language": "python", + "name": "epic" + }, + "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.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/setup.cfg b/setup.cfg index 8f7736a4ac676cac08d209f743349e005faa2e91..039071f2b3eb0e8b90ee12c81df9d90a8768405a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = epicure -version = 0.2.0 +version = 0.2.2 description = Napari plugin to manually correct epithelia segmentation in movies long_description = file: README.md long_description_content_type = text/markdown diff --git a/src/epicure/Utils.py b/src/epicure/Utils.py index c0715e670f39045fd44973b408adabfafd4d3653..f82ae114fbdaed9f77d5266030c3820d7a015eea 100644 --- a/src/epicure/Utils.py +++ b/src/epicure/Utils.py @@ -2,7 +2,7 @@ import numpy as np import os import time import math -from skimage.measure import regionprops +from skimage.measure import regionprops, find_contours, regionprops_table from skimage.segmentation import find_boundaries, expand_labels from napari.utils.translations import trans from napari.utils.notifications import show_info @@ -10,7 +10,11 @@ from napari.utils import notifications as nt from skimage.morphology import binary_dilation, disk import pandas as pd from epicure.laptrack_centroids import LaptrackCentroids -from skimage.measure import regionprops_table + +try: + from skimage.graph import RAG +except: + from skimage.future.graph import RAG ## older version of scikit-image def show_info(message): """ Display info in napari """ @@ -32,7 +36,7 @@ def show_debug(message): def show_documentation(): import webbrowser - webbrowser.open_new_tab("https://gitlab.pasteur.fr/gletort/epicure/-/wikis/EpiCure") + webbrowser.open_new_tab("https://gitlab.pasteur.fr/gletort/epicure/-/wikis/Home") return def show_documentation_page(page): @@ -167,6 +171,8 @@ def opentif(imagepath, verbose=True): #print(metadata) scale = 1 scalet = 1 + unitxy = "um" + unitt = "min" nchan = -1 if metadata is not None: if verbose: @@ -183,6 +189,9 @@ def opentif(imagepath, verbose=True): scale = float(metadata['physicalsizex']) if metadata['finterval'] is not None: scalet = float(metadata['finterval']) + if 'unit' in metadata: + if metadata['unit'] is not None: + unitxy = metatdata['unit'] except: metadatas = None #print(info) @@ -193,7 +202,7 @@ def opentif(imagepath, verbose=True): nchan = -1 image = img.asarray() img.close() - return image, scale, nchan + return image, nchan, scale, unitxy, scalet, unitt def writeTif(img, imgname, scale, imtype, what=""): import tifffile @@ -308,14 +317,14 @@ def convert_coords( coord ): int_coord = int_coord[1:3] return tframe, int_coord -def outerBBox2D(bbox, imshape): - if bbox[0] <= 0: +def outerBBox2D(bbox, imshape, margin=0): + if (bbox[0]-margin) <= 0: return True - if bbox[2] >= imshape[0]: + if (bbox[2]+margin) >= imshape[0]: return True - if bbox[1] <= 0: + if (bbox[1]-margin) <= 0: return True - if bbox[3] >= imshape[1]: + if (bbox[3]+margin) >= imshape[1]: return True return False @@ -385,6 +394,12 @@ def getBBoxFromPts(pts, extend, imshape, outdim=None, frame=None): bbox[(outdim==3)+i+outdim] = min(bbox[(outdim==3)+i+outdim] + extend, imshape[(outdim==3)+i] ) return bbox +def inside_bounds( pt, imshape ): + """ Chaque if given point is inside image limits """ + for i in range( len(pt) ): + if (pt[i] < 0) or (pt[i] >= imshape[i]): + return False + return True def extendBBox2D( bbox, extend_factor, imshape ): """ Extend bounding box with given margin """ @@ -406,7 +421,7 @@ def getBBox2D(img, label): def getPropLabel(img, label): """ Get the properties of label """ props = regionprops(np.uint8(img==label)) - print(props) + #print(props) return props[0] def getBBoxLabel(img, label): @@ -588,6 +603,14 @@ def match_labels( sega, segb ): parent_labels = laptrack.twoframes_track(df, labels) return parent_labels, labels +def labels_table( labimg, intensity_image=None, properties=None, extra_properties=None ): + """ Returns the regionprops_table of the labels """ + if properties is None: + properties = ['label', 'centroid'] + if intensity_image is not None: + return regionprops_table( labimg, intensity_image=intensity_image, properties=properties, extra_properties=extra_properties ) + return regionprops_table( labimg, properties=properties, extra_properties=extra_properties ) + def tuple_int(pos): if len(pos) == 3: return ( (int(pos[0]), int(pos[1]), int(pos[2])) ) @@ -638,6 +661,20 @@ def mean_nonzero( array ): return np.sum(array)/nonzero return 0 +def get_contours( binimg ): + """ Return the contour of a binary shape """ + return find_contours( binimg ) + +###### Connectivity labels +def touching_labels( img, expand=3 ): + """ Extends the labels to make them touch """ + return expand_labels( img, distance=expand ) + +def connectivity_graph( img, distance ): + """ Returns the region adjancy graph of labels """ + touchlab = touching_labels( img, expand=distance ) + return RAG( touchlab, connectivity=2 ) + ####### Distance measures def total_distance( pts_pos ): @@ -651,7 +688,7 @@ def net_distance( pts_pos ): """ Net distance travelled by point with coordinates xpos and ypos """ disp = pts_pos[len(pts_pos)-1] - pts_pos[0] return np.sum( np.sqrt( np.square(disp[0]) + np.square(disp[1]) ) ) - + ###### Time measures def start_time(): @@ -663,3 +700,35 @@ def show_duration(start_time, header=None): #show_info(header+"{:.3f}".format((time.time()-start_time)/60)+" min") print(header+"{:.3f}".format((time.time()-start_time)/60)+" min") +###### Preferences/shortcuts + +def shortcut_click_match( shortcut, event ): + """ Test if the click event corresponds to the shortcut """ + button = 1 + if shortcut["button"] == "Right": + button = 2 + if event.button != button: + return False + if "modifiers" in shortcut.keys(): + return set(list(event.modifiers)) == set(shortcut["modifiers"]) + else: + if len(event.modifiers) > 0: + return False + return True + +def print_shortcuts( shortcut_group ): + """ Put to text the subset of shortcuts """ + text = "" + for short_name, vals in shortcut_group.items(): + if vals["type"] == "key": + text += " <"+vals["key"]+"> "+vals["text"]+"\n" + if vals["type"] == "click": + modif = "" + if "modifiers" in vals.keys(): + modifiers = vals["modifiers"] + for mod in modifiers: + modif += mod+"-" + text += " <"+modif+vals["button"]+"-click> "+vals["text"]+"\n" + return text + + diff --git a/src/epicure/concatenate_movie.py b/src/epicure/concatenate_movie.py index 7b4871690d6d89e15e9b217829df5ab27273da57..044f6b904e93662c66a9ab6d2ba09211305a459d 100644 --- a/src/epicure/concatenate_movie.py +++ b/src/epicure/concatenate_movie.py @@ -47,7 +47,7 @@ def merge_epicures( first_movie, first_labels, second_movie, second_labels, full ## create the full movie full_mov = np.concatenate( (first_epicure.img, second_epicure.img[1:]), axis=0 ) full_movie_name = os.path.join(os.path.dirname(first_epicure.imgpath), fullname) - ut.writeTif( full_mov, full_movie_name, first_epicure.scale, first_epicure.img.dtype, what="Full movie") + ut.writeTif( full_mov, full_movie_name, first_epicure.epi_metadata["ScaleXY"], first_epicure.img.dtype, what="Full movie") print("Full movie created") ###### Read and merge the EpiCure data and merge the movie labels @@ -120,7 +120,7 @@ def merge_epicures( first_movie, first_labels, second_movie, second_labels, full if second_epicure.groups is not None: second = second_epicure.find_group( lab ) if second is not None: - epic.cell_ingroup( nextlabel, second ) + epic.cells_ingroup( [nextlabel], second ) if len(nextlabels) <= 0: ## the list of unused labels has been completly used, regenerates nextlabels = ut.get_free_labels( used_labels, 20 ) @@ -149,7 +149,7 @@ def merge_epicures( first_movie, first_labels, second_movie, second_labels, full full_lab = np.concatenate( (first_epicure.seg, second_epicure.seg[1:]), axis=0 ) epic.seg = full_lab epic.save_epicures() - print("Movie and EpiCure files merged; Suspects (if any) are not merged, use inspect tracks on merged movie to generate them") + print("Movie and EpiCure files merged; Suspects/Events (if any) are not merged, use inspect tracks on merged movie to generate them") def fullmovie_group( epic, first_label, second_label, second_epicure ): """ Check if second_label is in a group, and add it to full movie groups if relevant """ @@ -164,7 +164,7 @@ def fullmovie_group( epic, first_label, second_label, second_epicure ): print("Keep only the first movie group: "+first_group) else: if second is not None: - epic.cell_ingroup( first_label, second ) + epic.cells_ingroup( [first_label], second ) def concatenate_movies(): hist = get_save_history() diff --git a/src/epicure/displaying.py b/src/epicure/displaying.py index d7ad247a173522dbf21cbb47dfcf48152df22aac..76ecc40726a3dc20cfbcd2061d3100c560e253df 100644 --- a/src/epicure/displaying.py +++ b/src/epicure/displaying.py @@ -1,10 +1,10 @@ import numpy as np -import os from math import ceil -from qtpy.QtWidgets import QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QGroupBox, QCheckBox, QSlider, QLabel, QDoubleSpinBox, QComboBox, QLineEdit +from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget, QComboBox from qtpy.QtCore import Qt from magicgui.widgets import TextEdit import epicure.Utils as ut +import epicure.epiwidgets as wid class Displaying(QWidget): @@ -18,71 +18,128 @@ class Displaying(QWidget): self.seglayer = self.viewer.layers["Segmentation"] self.gmode = 0 ## view only movie mode on/off self.dmode = 0 ## view with light segmentation on/off + self.grid_color = [0.6, 0.7, 0.7, 0.7] ## default grid color layout = QVBoxLayout() ## Show a text window with some summary of the file - show_summary = QPushButton("Show summary", parent=self) + show_summary = wid.add_button( "Show summary", self.show_summary_window, "Pops-up a summary of the movie and segmentation informations" ) layout.addWidget(show_summary) - show_summary.clicked.connect(self.show_summary_window) ## Option show segmentation skeleton - show_skeleton_line = QHBoxLayout() - self.show_skeleton = QCheckBox(text="Show segmentation skeleton") - show_skeleton_line.addWidget(self.show_skeleton) - self.show_skeleton.stateChanged.connect(self.show_skeleton_segmentation) - layout.addLayout(show_skeleton_line) - self.show_skeleton.setChecked(False) + self.show_skeleton = wid.add_check( "Show segmentation skeleton", False, self.show_skeleton_segmentation, "Add a layer with the segmentation skeleton (not automatically updated)" ) + layout.addWidget(self.show_skeleton) + + ## Option to show the movie and seg side by side + show_sides = QHBoxLayout() + self.show_side = wid.add_check( "Side by side view", False, self.show_side_side, "View the movie and the other layers side by side" ) + show_sides.addWidget( self.show_side ) + self.directions = QComboBox() + self.directions.addItem( "Horizontal" ) + self.directions.addItem( "Vertical" ) + show_sides.addWidget( self.directions ) + self.directions.currentIndexChanged.connect( self.show_side_side ) + layout.addLayout( show_sides ) ## Option show shifted segmentation - show_shifted_line = QHBoxLayout() - self.show_shifted = QCheckBox(text="Overlay previous segmentation") - show_shifted_line.addWidget(self.show_shifted) - self.show_shifted.stateChanged.connect(self.show_shifted_segmentation) - layout.addLayout(show_shifted_line) - self.show_shifted.setChecked(False) + self.show_shifted = wid.add_check( "Overlay previous segmentation", False, self.show_shifted_segmentation, "Overlay the (frame-1) segmentation on the current segmentation") + layout.addWidget(self.show_shifted) ## Option show shifted movie (previous or next) show_prevmovie_line = QHBoxLayout() - self.show_previous_movie = QCheckBox(text="Overlay previous movie") - show_prevmovie_line.addWidget(self.show_previous_movie) - self.show_previous_movie.stateChanged.connect(self.show_shifted_previous_movie) - layout.addLayout(show_prevmovie_line) - self.show_previous_movie.setChecked(False) - show_nextmovie_line = QHBoxLayout() - self.show_next_movie = QCheckBox(text="Overlay next movie") - show_nextmovie_line.addWidget(self.show_next_movie) - self.show_next_movie.stateChanged.connect(self.show_shifted_next_movie) - layout.addLayout(show_nextmovie_line) - self.show_next_movie.setChecked(False) + self.show_previous_movie = wid.add_check( "Overlay previous movie", False, self.show_shifted_previous_movie, "Overlay the (frame-1) of the movie on the current movie" ) + layout.addWidget(self.show_previous_movie) + self.show_next_movie = wid.add_check( "Overlay next movie", False, self.show_shifted_next_movie, "Overlay (frame+1) of the movie on the current frame" ) + layout.addWidget(self.show_next_movie) ## Option create/show grid - self.show_grid_options = QCheckBox(text="Grid options") + grid_line, self.show_grid_options, self.group_grid = wid.checkgroup_help( "Grid options", True, "Show/hide subpanel to control grid view", "Display#grid-options", self.epicure.display_colors, groupnb="group" ) self.grid_parameters() layout.addWidget(self.show_grid_options) layout.addWidget(self.group_grid) - self.show_grid_options.setChecked(False) - self.show_grid_options.stateChanged.connect(self.grid_group_visibility) - self.grid_group_visibility() + + save_pref = wid.add_button( "Set current settings as default", self.save_current_display, "Save the current settings so that EpiCure will open in the same state next time" ) + layout.addWidget(save_pref) self.add_display_overlay_message() self.key_bindings() ## activate shortcuts for display options self.setLayout(layout) ut.set_active_layer( self.viewer, "Segmentation" ) + ### set current display as defaut + def save_current_display( self ): + """ Set current display parameters as defaut display """ + self.epicure.update_settings() + self.epicure.pref.save() + + def get_current_settings( self ): + """ Returns current display settings """ + disp = {} + disp["Layers"] = {} + for layer in self.viewer.layers: + disp["Layers"][layer.name] = layer.visible + disp["Show Grid"] = self.show_grid_options.isChecked() + disp["Grid nrows"] = self.nrows.text() + disp["Grid ncols"] = self.ncols.text() + disp["Grid width"] = self.gwidth.text() + disp["Grid color"] = self.grid_color + disp["Show side on"] = self.show_side.isChecked() + disp["Side direction"] = self.directions.currentText() + if "EpicGrid" in self.viewer.layers: + disp["Grid text"] = self.viewer.layers["EpicGrid"].text.visible + disp["Grid color"] = self.viewer.layers["EpicGrid"].edge_color[0] + return disp + + def apply_settings( self, settings ): + """ Set current display to prefered settings """ + add_grid = False + show_text = False + ## read the settings and apply them + for setty, val in settings.items(): + if setty == "Layers": + for layname, layvis in val.items(): + if layname in self.viewer.layers: + self.viewer.layers[layname].visible = layvis + else: + if layname == "EpicGrid": + add_grid = layvis + continue + if setty == "Show Grid": + self.show_grid_options.setChecked( val ) + continue + if setty == "Grid nrows": + self.nrows.setText( val ) + continue + if setty == "Grid ncols": + self.ncols.setText( val ) + continue + if setty == "Grid width": + self.gwidth.setText( val ) + continue + if setty == "Grid text": + show_text = val + continue + if setty == "Grid color": + self.grid_color = val + continue + if setty == "Show side on": + self.show_side.setChecked( val ) + if setty == "Side direction": + self.directions.setCurrentText( val ) + + ## if grid should be added, do it at the end when all values are updated + if add_grid: + self.add_grid() + self.viewer.layers["EpicGrid"].text.visible = show_text + + ######### overlay message def add_display_overlay_message(self): """ Shortcut list for display options """ disptext = "--- Display options --- \n" - disptext = disptext + " <b> show/hide segmentation layer \n" - disptext = disptext + " <v> show/hide movie layer \n" - disptext = disptext + " <x> show/hide suspects layer \n" - disptext = disptext + " <c> show ONLY movie layer \n" - disptext = disptext + " <d> on/off light segmentation view \n" - disptext = disptext + " <Ctrl-c>/<Ctrl-d> increase/decrease label contour \n" - disptext = disptext + " <k> show/update segmentation skeleton \n" - disptext = disptext + " <g> show/hide grid \n" + sdisp = self.epicure.shortcuts["Display"] + disptext += ut.print_shortcuts( sdisp ) self.epicure.overtext["Display"] = disptext def show_summary_window(self): @@ -95,40 +152,46 @@ class Displaying(QWidget): ################ Key binging for display options def key_bindings(self): + sdisp = self.epicure.shortcuts["Display"] - @self.seglayer.bind_key('b', overwrite=True) + @self.seglayer.bind_key( sdisp["vis. segmentation"]["key"], overwrite=True ) def see_segmentlayer(seglayer): seglayer.visible = not seglayer.visible - @self.seglayer.bind_key('v', overwrite=True) + @self.seglayer.bind_key( sdisp["vis. movie"]["key"], overwrite=True ) def see_movielayer(seglayer): ut.inv_visibility(self.viewer, "Movie") - @self.seglayer.bind_key('x', overwrite=True) - def see_suspectslayer(seglayer): - suslayer = self.viewer.layers["Suspects"] - suslayer.visible = not suslayer.visible + @self.seglayer.bind_key( sdisp["vis. event"]["key"], overwrite=True ) + def see_eventslayer(seglayer): + evlayer = self.viewer.layers["Events"] + evlayer.visible = not evlayer.visible - @self.seglayer.bind_key('k', overwrite=True) + @self.seglayer.bind_key( sdisp["skeleton"]["key"], overwrite=True ) def show_skeleton(seglayer): """ On/Off show skeleton """ if self.show_skeleton.isChecked(): self.show_skeleton.setChecked(False) else: self.show_skeleton.setChecked(True) + + @self.seglayer.bind_key( sdisp["show side"]["key"], overwrite=True ) + def show_byside(seglayer): + self.show_side.setChecked( not self.show_side.isChecked() ) + self.show_side_side() - @self.seglayer.bind_key('Control-c', overwrite=True) + @self.seglayer.bind_key( sdisp["increase"]["key"], overwrite=True ) def contour_increase(seglayer): if seglayer is not None: seglayer.contour = seglayer.contour + 1 - @self.seglayer.bind_key('Control-d', overwrite=True) + @self.seglayer.bind_key( sdisp["decrease"]["key"], overwrite=True ) def contour_decrease(seglayer): if seglayer is not None: if seglayer.contour > 0: seglayer.contour = seglayer.contour - 1 - @self.seglayer.bind_key('c', overwrite=True) + @self.seglayer.bind_key( sdisp["only movie"]["key"], overwrite=True ) def see_onlymovielayer(seglayer): """ if in "g" mode, show only movie, else put back to previous views """ if self.gmode == 0: @@ -143,7 +206,7 @@ class Displaying(QWidget): lay.visible = vis self.gmode = 0 - @self.seglayer.bind_key('d', overwrite=True) + @self.seglayer.bind_key( sdisp["light view"]["key"], overwrite=True ) def segmentation_lightmode(seglayer): """ if in "d" mode, show only movie and light segmentation, else put back to previous views """ if self.dmode == 0: @@ -165,11 +228,12 @@ class Displaying(QWidget): self.seglayer.opacity = self.unlight_opacity self.dmode = 0 - @self.seglayer.bind_key('g', overwrite=True) + @self.seglayer.bind_key( sdisp["grid"]["key"], overwrite=True ) def show_grid(seglayer): """ show/hide the grid to have a repere in space """ self.show_grid() + ### Display options def show_skeleton_segmentation(self): """ Show/hide/update skeleton """ if "Skeleton" in self.viewer.layers: @@ -178,6 +242,19 @@ class Displaying(QWidget): self.epicure.add_skeleton() ut.set_active_layer( self.viewer, "Segmentation" ) + def show_side_side( self ): + """ Show the layers side by side """ + layout_grid = self.viewer.grid + if self.show_side.isChecked(): + stride = len( self.viewer.layers ) - 1 + layout_grid.stride = stride + layout_grid.shape = (2,1) + if self.directions.currentText() == "Horizontal": + layout_grid.shape = (1,2) + layout_grid.enabled = True + else: + layout_grid.enabled = False + def show_shifted_segmentation(self): """ Show/Hide temporally shifted segmentation on top of current one """ @@ -234,46 +311,21 @@ class Displaying(QWidget): ut.set_active_layer( self.viewer, "Segmentation" ) #### Show/load a grid to have a repere in space - def grid_group_visibility(self): - """ Show/hide grid parameters """ - self.group_grid.setVisible(self.show_grid_options.isChecked()) - def grid_parameters(self): """ Interface to get grid parameters """ - self.group_grid = QGroupBox("Grid setup") grid_layout = QVBoxLayout() ## nrows - rows_line = QHBoxLayout() - rows_lab = QLabel() - rows_lab.setText("Nb rows:") - rows_line.addWidget(rows_lab) - self.nrows = QLineEdit() - self.nrows.setText("3") - rows_line.addWidget(self.nrows) + rows_line, self.nrows = wid.value_line( "Nb rows", "4", "Number of rows of the grid" ) grid_layout.addLayout(rows_line) ## ncols - cols_line = QHBoxLayout() - cols_lab = QLabel() - cols_lab.setText("Nb columns:") - cols_line.addWidget(cols_lab) - self.ncols = QLineEdit() - self.ncols.setText("3") - cols_line.addWidget(self.ncols) + cols_line, self.ncols = wid.value_line( "Nb columns:", "4", "Number of columns in the grid" ) grid_layout.addLayout(cols_line) ## grid edges width - width_line = QHBoxLayout() - width_lab = QLabel() - width_lab.setText("Grid width:") - width_line.addWidget(width_lab) - self.gwidth = QLineEdit() - self.gwidth.setText("4") - width_line.addWidget(self.gwidth) + width_line, self.gwidth = wid.value_line( "Grid width:", "3", "Width of the grid displayed lines/columns" ) grid_layout.addLayout(width_line) - #self.gwidth.changed.connect(self.add_grid) - ## go for grid - btn_add_grid = QPushButton("Add grid", parent=self) + ## go for grid + btn_add_grid = wid.add_button( "Add grid", self.add_grid, "Add a grid overlay to the main view" ) grid_layout.addWidget(btn_add_grid) - btn_add_grid.clicked.connect(self.add_grid) self.group_grid.setLayout(grid_layout) def add_grid(self): @@ -295,7 +347,8 @@ class Displaying(QWidget): rect = np.array([[x*wid, y*hei], [(x+1)*wid, (y+1)*hei]]) rects.append(rect) rects_names.append(chr(65+x)+"_"+str(y)) - self.viewer.add_shapes(rects, name="EpicGrid", text=rects_names, face_color=[1,0,0,0], edge_color=[0.7,0.7,0.7,0.7], edge_width=gwidth, opacity=0.8) + self.viewer.add_shapes(rects, name="EpicGrid", text=rects_names, face_color=[1,0,0,0], edge_color=self.grid_color, edge_width=gwidth, opacity=0.7) + self.viewer.layers["EpicGrid"].text.visible = False ut.set_active_layer( self.viewer, "Segmentation" ) def show_grid(self): @@ -305,3 +358,4 @@ class Displaying(QWidget): else: gridlay = self.viewer.layers["EpicGrid"] gridlay.visible = not gridlay.visible + gridlay.edge_color = self.grid_color diff --git a/src/epicure/editing.py b/src/epicure/editing.py index 6f48669d26f37cfa50929ea24cec33a67e85ac86..6d40953bd9e8a532e1efaedd1ce8d2425e2e9ef9 100644 --- a/src/epicure/editing.py +++ b/src/epicure/editing.py @@ -1,20 +1,20 @@ import numpy as np +import time +import edt from skimage.segmentation import watershed, expand_labels, clear_border, find_boundaries, random_walker from skimage.measure import regionprops, label, points_in_poly -from skimage.morphology import binary_closing, binary_dilation, binary_erosion, disk -from qtpy.QtWidgets import QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QGroupBox, QCheckBox, QSlider, QLabel, QDoubleSpinBox, QComboBox, QLineEdit -from qtpy.QtCore import Qt +from skimage.morphology import binary_closing, binary_opening, binary_dilation, binary_erosion, disk +from qtpy.QtWidgets import QVBoxLayout, QWidget, QGroupBox from napari.layers.labels._labels_utils import interpolate_coordinates from scipy.ndimage import binary_fill_holes, distance_transform_edt, generate_binary_structure from scipy.ndimage import label as ndlabel -from multiprocessing.pool import ThreadPool as Pool from napari.layers.labels._labels_utils import sphere_indices -import epicure.Utils as ut -import time from napari.utils import progress -import edt +import epicure.Utils as ut +import epicure.epiwidgets as wid +from napari.qt.threading import thread_worker -class Editing(QWidget): +class Editing( QWidget ): """ Handle user interaction to edit the segmentation """ def __init__(self, napari_viewer, epic): @@ -30,59 +30,31 @@ class Editing(QWidget): layout = QVBoxLayout() ## Option to remove all border cells - clean_line = QHBoxLayout() - clean_vis = QCheckBox(text="Cleaning options") - clean_line.addWidget(clean_vis) - clean_helpbtn = QPushButton("Help", parent=self) - clean_helpbtn.clicked.connect(self.help_clean) - clean_line.addWidget(clean_helpbtn) + clean_line, self.clean_vis, self.gCleaned = wid.checkgroup_help( name="Cleaning options", checked=False, descr="Show/hide options to clean the segmentation", help_link="Edit#cleaning-options", display_settings=self.epicure.display_colors, groupnb="group" ) layout.addLayout(clean_line) - clean_vis.stateChanged.connect(self.show_cleaningBlock) self.create_cleaningBlock() layout.addWidget(self.gCleaned) - clean_vis.setChecked(False) self.gCleaned.hide() ## handle grouping cells into categories - group_line = QHBoxLayout() - group_vis = QCheckBox(text="Group cells options") - group_line.addWidget(group_vis) - group_helpbtn = QPushButton("Help", parent=self) - group_helpbtn.clicked.connect(self.help_group) - group_line.addWidget(group_helpbtn) + group_line, self.group_vis, self.gGroup = wid.checkgroup_help( name="Cell group options", checked=False, descr="Show/hide options to define cell groups", help_link="Edit#group-options", display_settings=self.epicure.display_colors, groupnb="group2" ) layout.addLayout(group_line) - group_vis.stateChanged.connect(self.show_groupCellsBlock) self.create_groupCellsBlock() layout.addWidget(self.gGroup) - group_vis.setChecked(False) self.gGroup.hide() ## Selection option: crop, remove cells - select_line = QHBoxLayout() - self.select_vis = QCheckBox(text="ROI options") - select_line.addWidget(self.select_vis) - help_selectbtn = QPushButton("Help", parent=self) - help_selectbtn.clicked.connect(self.help_selection) - select_line.addWidget(help_selectbtn) + select_line, self.select_vis, self.gSelect = wid.checkgroup_help( name="ROI options", checked=False, descr="Show/hide options to work on Regions", help_link="Edit#roi-options", display_settings=self.epicure.display_colors, groupnb="group3" ) layout.addLayout(select_line) - self.select_vis.stateChanged.connect(self.show_selectBlock) self.create_selectBlock() layout.addWidget(self.gSelect) - self.select_vis.setChecked(False) self.gSelect.hide() ## Put seeds and do watershed from it - seed_line = QHBoxLayout() - self.seed_vis = QCheckBox(text="Seeds options") - seed_line.addWidget(self.seed_vis) - help_seedbtn = QPushButton("Help", parent=self) - help_seedbtn.clicked.connect(self.help_seeds) - seed_line.addWidget(help_seedbtn) + seed_line, self.seed_vis, self.gSeed = wid.checkgroup_help( name="Seeds options", checked=False, descr="Show/hide options to segment from seeds", help_link="Edit#seeds-options", display_settings=self.epicure.display_colors, groupnb="group4" ) layout.addLayout(seed_line) - self.seed_vis.stateChanged.connect(self.show_hide_seedMapBlock) self.create_seedsBlock() layout.addWidget(self.gSeed) - self.seed_vis.setChecked(False) self.gSeed.hide() self.setLayout(layout) @@ -97,12 +69,47 @@ class Editing(QWidget): self.napari_fill = self.epicure.seglayer.fill self.epicure.seglayer.fill = self.epicure_fill self.napari_paint = self.epicure.seglayer.paint - self.epicure.seglayer.paint = self.epicure_paint + self.epicure.seglayer.paint = self.lazy #self.epicure_paint ### scale and radius for paiting self.paint_scale = np.array([self.epicure.seglayer.scale[i+1] for i in range(2)], dtype=float) self.epicure.seglayer.events.brush_size.connect( self.paint_radius ) self.paint_radius() self.disk_one = disk(radius=1) + + + def apply_settings( self, settings ): + """ Load the prefered settings for Edit panel """ + for setting, val in settings.items(): + if setting == "Show group option": + self.group_vis.setChecked( val ) + if setting == "Show clean option": + self.clean_vis.setChecked( val ) + if setting == "Show ROI option": + self.select_vis.setChecked( val ) + if setting == "Show seed option": + self.seed_vis.setChecked( val ) + if setting == "Show groups": + self.group_show.setChecked( val ) + if setting == "Border size": + self.border_size.setText( val ) + if setting == "Seed method": + self.seed_method.setCurrentText( val ) + if setting == "Seed max cell": + self.max_distance.setText( val ) + + + def get_current_settings( self ): + """ Returns the current state of the Edit widget """ + setting = {} + setting["Show group option"] = self.group_vis.isChecked() + setting["Show clean option"] = self.clean_vis.isChecked() + setting["Show ROI option"] = self.select_vis.isChecked() + setting["Show seed option"] = self.seed_vis.isChecked() + setting["Show groups"] = self.group_show.isChecked() + setting["Border size"] = self.border_size.text() + setting["Seed method"] = self.seed_method.currentText() + setting["Seed max cell"] = self.max_distance.text() + return setting def paint_radius( self ): """ Update painitng radius with brush size """ @@ -182,23 +189,42 @@ class Editing(QWidget): ## put the active mode of the layer back to the zoom one self.epicure.seglayer.mode = "pan_zoom" if prev_label != 0: - self.epicure.tracking.remove_one_frame( [prev_label], tframe ) - - def epicure_paint(self, coord, new_label, refresh=True): - """ Action when trying to edit a label with paint tool """ - tframe, int_coord = ut.convert_coords( coord ) - mask_indices = np.array( int_coord ) + self.brush_indices + self.epicure.tracking.remove_one_frame( [prev_label], tframe, handle_gaps=self.epicure.forbid_gaps ) + + def lazy( self, coord, new_label, refresh=True ): + return + + def epicure_paint( self, coords, new_label, tframe, hascell ): + """ Edit a label with paint tool, with several pixels at once """ + mask_indices = None + ## convert the coords with brush size, check that is fully inside + for coord in coords: + int_coord = np.array( np.round(coord).astype(int)[1:3] ) + for brush in self.brush_indices: + pt = int_coord + brush + if ut.inside_bounds( pt, self.epicure.imgshape2D ): + if mask_indices is None: + mask_indices = pt + else: + mask_indices = np.vstack( ( mask_indices, pt ) ) + + ## crop around part of the image to update bbox = ut.getBBoxFromPts( mask_indices, extend=0, imshape=self.epicure.imgshape2D ) - bbox = ut.extendBBox2D( bbox, extend_factor=4, imshape=self.epicure.imgshape2D ) + if hascell: + ## extend around points a lot if the label is there already to avoid cutting it + extend = 4 + else: + extend = 1.5 + bbox = ut.extendBBox2D( bbox, extend_factor=extend, imshape=self.epicure.imgshape2D ) cropdata = ut.cropBBox2D( self.epicure.seglayer.data[tframe], bbox ) crop_indices = ut.positions2DIn2DBBox( mask_indices, bbox ) + + ## get previous data before painting prev_labels = np.unique( cropdata[ tuple(np.array(crop_indices).T) ] ).tolist() if 0 in prev_labels: prev_labels.remove(0) - if new_label > 0: - ## painting a new or extending a cell - hascell = self.epicure.has_label( new_label ) + if new_label > 0: if hascell: ## check that label is in current frame mask_before = cropdata==new_label @@ -216,21 +242,34 @@ class Editing(QWidget): return else: ## drawing new cell, fill it at the end - if self.epicure.verbose > 1: + if self.epicure.verbose > 2: print("Painting a new cell") ## Paint and update everything painted = np.copy(cropdata) painted[ tuple(np.array(crop_indices).T) ] = new_label if new_label > 0: - painted = binary_fill_holes( painted==new_label ) - crop_indices = np.argwhere(painted>0) + if self.epicure.seglayer.preserve_labels: + painted = painted*(np.isin( cropdata, [0, new_label] )) + painted = binary_fill_holes( (painted==new_label) ) + ## remove one-pixel thick lines + painted = binary_opening( painted ) + crop_indices = np.argwhere( (painted>0) ) + else: + painted = binary_fill_holes( painted==new_label ) + crop_indices = np.argwhere(painted>0) + ### if preseve label is on, there can be nothing left to paint + if len(crop_indices) <= 0: + return mask_indices = ut.toFullMoviePos( crop_indices, bbox, tframe ) new_labels = np.repeat(new_label, len(mask_indices)).tolist() ## Update label boundaries if necessary cind_bound = self.ind_boundaries( painted ) - ind_bound = [ ind for ind in cind_bound if cropdata[tuple(ind)] in prev_labels ] + if self.epicure.seglayer.preserve_labels: + ind_bound = [ ind for ind in cind_bound if (cropdata[tuple(ind)] == new_label) ] + else: + ind_bound = [ ind for ind in cind_bound if cropdata[tuple(ind)] in prev_labels ] if (new_label>0) and (len( ind_bound ) > 0): bound_ind = ut.toFullMoviePos( ind_bound, bbox, tframe ) bound_labels = np.repeat(0, len(bound_ind)).tolist() @@ -239,6 +278,108 @@ class Editing(QWidget): ## Go, apply the change, and update the tracks self.epicure.change_labels( mask_indices, new_labels ) + + def create_cell_from_line( self, tframe, positions ): + """ Create new cell(s) from drawn line (junction) """ + bbox = ut.getBBox2DFromPts( positions, extend=0, imshape=self.epicure.imgshape2D ) + bbox = ut.extendBBox2D( bbox, extend_factor=2, imshape=self.epicure.imgshape2D ) + + segt = self.epicure.seglayer.data[tframe] + cropt = ut.cropBBox2D( segt, bbox ) + crop_positions = ut.positionsIn2DBBox( positions, bbox ) + + line = np.zeros(cropt.shape, dtype="uint8") + ## fill the already filled pixels by other labels + line[ cropt > 0 ] = 1 + ## expand from one pixel to fill the junction + line = binary_dilation( line ) + ## fill the interpolated line + for i, pos in enumerate(crop_positions): + if cropt[round(pos[0]), round(pos[1])] == 0: + line[round(pos[0]), round(pos[1])] = 1 + if (i > 0): + prev = (crop_positions[i-1][0], crop_positions[i-1][1]) + cur = (pos[0], pos[1]) + interp_coords = interpolate_coordinates(prev, cur, 1) + for ic in interp_coords: + line[tuple(np.round(ic).astype(int))] = 1 + + ## close the junction gaps, and the line eventually + line = binary_closing( line ) + new_cells, nlabels = label( line, background=1, return_num=True, connectivity=1 ) + ## no new cell to create + if nlabels <= 0: + return + ## get the new labels to relabel and add as new cells + labels = list( set( new_cells.flatten() ) ) + if 0 in labels: + labels.remove(0) + + ## try to get new cell labels from previous and next slices + parents = [None]*len(labels) + if tframe > 0: + twoframes = ut.crop_twoframes( self.epicure.seglayer.data, bbox, tframe ) + orig = np.copy( twoframes[1] ) + twoframes[1] = new_cells + ut.keep_orphans( twoframes, orig, [] ) + parents = self.get_parents( twoframes, labels ) + childs = [None]*len(labels) + if tframe < (self.epicure.nframes-1): + twoframes = np.copy( ut.cropBBox2D(self.epicure.seglayer.data[tframe+1], bbox) ) + twoframes = np.stack( (twoframes, np.copy(new_cells)) ) + orig = ut.cropBBox2D(self.epicure.seglayer.data[tframe], bbox) + ut.keep_orphans( twoframes, orig, [] ) + childs = self.get_parents( twoframes, labels ) + + free_labels = self.epicure.get_free_labels( nlabels ) + torelink = [] + for i in range( len(labels) ): + print(parents[i]) + print(childs[i]) + if (parents[i] is not None) and (childs[i] is not None): + ## the two propagation agrees (gap allowed) + #if parents[i] == childs[i]: + # free_labels[i] = parents[i] + # if self.epicure.verbose > 0: + # print("Link new cell with previous/next "+str(free_labels[i])) + free_labels[i] = parents[i] + if self.epicure.verbose > 0: + print("Link new cell with previous/next "+str(free_labels[i])) + if childs[i] != parents[i]: + torelink.append( [free_labels[i], childs[i]] ) + ## only one link found, take it + if (parents[i] is not None) and (childs[i] is None): + free_labels[i] = parents[i] + if self.epicure.verbose > 0: + print("Link new cell with previous/next "+str(free_labels[i])) + if (parents[i] is None) and (childs[i] is not None): + free_labels[i] = childs[i] + if self.epicure.verbose > 0: + print("Link new cell with previous/next "+str(free_labels[i])) + + print(free_labels) + + ## get the new indices and labels to draw + new_labels = [] + indices = None + for i, lab in enumerate( labels ): + curindices = np.argwhere( new_cells == lab ) + if indices is None: + indices = curindices + else: + indices = np.vstack((indices, curindices)) + new_labels = new_labels + ([free_labels[i]]*curindices.shape[0]) + + ## add the label boundary + indbound = self.ind_boundaries( new_cells ) + indices = np.vstack( (indices, indbound) ) + new_labels = new_labels + np.repeat( 0, len(indbound) ).tolist() + indices = ut.toFullMoviePos( indices, bbox, tframe ) + self.epicure.change_labels( indices, new_labels ) + + ## relink child tracks if necessary + for relink in torelink: + self.epicure.replace_label( relink[1], relink[0], tframe ) def touching_masks(self, maska, maskb): """ Check if the two mask touch """ @@ -253,39 +394,78 @@ class Editing(QWidget): ## Merging/splitting cells functions def modify_cells(self): + sl = self.epicure.shortcuts["Labels"] self.epicure.overtext["labels"] = "---- Labels editing ---- \n" - self.epicure.overtext["labels"] += " <n> to set the current label to unused value and go to paint mode \n" - self.epicure.overtext["labels"] += " <Shift+n> to set the current label to unused value and go to fill mode \n" - self.epicure.overtext["labels"] += " Right-click, erase the cell \n" - self.epicure.overtext["labels"] += " <Control>+Left click, from one cell to another to merge them \n" - self.epicure.overtext["labels"] += " <Control>+Right click, accross a junction to split in 2 cells \n" - self.epicure.overtext["labels"] += " <Alt>+Right click drag, draw a junction to split in 2 cells \n" - self.epicure.overtext["labels"] += " <Alt>+Left click drag, draw a junction to correct it \n" - #self.epicure.overtext["mergesplit"] += "<Alt>+Left click on a suggestion to accept it \n" - self.epicure.overtext["labels"] += " <w> then <Control>+Left click on one cell to another to swap their values \n" + self.epicure.overtext["labels"] += ut.print_shortcuts( sl ) + sgroup = self.epicure.shortcuts["Groups"] self.epicure.overtext["grouped"] = "---- Group cells ---- \n" - self.epicure.overtext["grouped"] += " Shift+left click to add a cell to the current group \n" - self.epicure.overtext["grouped"] = self.epicure.overtext["grouped"] + " Shift+right click to remove the cell from its group \n" - #self.epicure.overtext["checked"] = self.epicure.overtext["checkmap"] + "<c> to show/hide checkmap \n" + self.epicure.overtext["grouped"] += ut.print_shortcuts( sgroup ) + sseed = self.epicure.shortcuts["Seeds"] self.epicure.overtext["seed"] = "---- Seed options --- \n" - self.epicure.overtext["seed"] += " <e> then left-click to place a seed \n" - #self.epicure.overtext["seed"] = self.epicure.overtext["seed"] + "\n" + self.epicure.overtext["seed"] += ut.print_shortcuts( sseed ) + @self.epicure.seglayer.mouse_drag_callbacks.append def set_checked(layer, event): if event.type == "mouse_press": - if (len(event.modifiers)==1) and ('Shift' in event.modifiers): - if event.button == 1: - if self.epicure.verbose > 0: - print("Mark cell in group "+self.group_group.text()) - self.add_cell_to_group(event) + if (event.button == 1) and (len(event.modifiers) == 0): + if layer.mode == "paint": + ### Overwrite the painting to check that everything stays within EpiCure constraints + if self.shapelayer_name not in self.viewer.layers: + self.create_shapelayer() + shape_lay = self.viewer.layers[self.shapelayer_name] + shape_lay.mode = "add_path" + shape_lay.visible = True + @thread_worker + def refresh_image(): + shape_lay.refresh() + return + pos = np.array( [event.position] ) + yield + ## record all the successives position of the mouse while clicked + iter = 0 + while (event.type == 'mouse_move'): # and (len(pos)<200): + pos = np.vstack( (pos, np.array(event.position)) ) + if iter == 5: + shape_lay.data = pos + shape_lay.shape_type = "path" + refresh_image() + #shape_lay.refresh() + iter = 0 + iter = iter + 1 + yield + pos = np.vstack( (pos, np.array(event.position)) ) + tframe = int( pos[0][0] ) + ## painting a new or extending a cell + new_label = layer.selected_label + hascell = None + if new_label > 0: + hascell = self.epicure.has_label( new_label ) + ## paint the selected pixels following EpiCure constraints + self.epicure_paint( pos, new_label, tframe, hascell ) + shape_lay.data = [] + shape_lay.refresh() + shape_lay.visible = False - if event.button == 2: - if self.epicure.verbose > 0: - print("Remove cell from its group") - self.remove_cell_group(event) + @self.epicure.seglayer.mouse_drag_callbacks.append + def set_checked(layer, event): + if event.type == "mouse_press": + if ut.shortcut_click_match( sgroup["add group"], event ): + if self.group_choice.currentText() == "": + ut.show_warning("Write a group name before") + return + if self.epicure.verbose > 0: + print("Mark cell in group "+self.group_choice.currentText()) + self.add_cell_to_group(event) + return + + if ut.shortcut_click_match( sgroup["remove group"], event ): + if self.epicure.verbose > 0: + print("Remove cell from its group") + self.remove_cell_group(event) + return @self.epicure.seglayer.bind_key("Control-z", overwrite=False) def undo_operations(seglayer): @@ -295,20 +475,20 @@ class Editing(QWidget): self.epicure.seglayer.undo() self.epicure.update_changed_labels_img( img_before, self.epicure.seglayer.data ) - @self.epicure.seglayer.bind_key('n', overwrite=True) + @self.epicure.seglayer.bind_key( sl["unused paint"]["key"], overwrite=True ) def set_nextlabel(layer): lab = self.epicure.get_free_label() ut.show_info( "Unused label "+": "+str(lab) ) ut.set_label(layer, lab) - @self.epicure.seglayer.bind_key('Shift-n', overwrite=True) + @self.epicure.seglayer.bind_key( sl["unused fill"]["key"], overwrite=True ) def set_nextlabel_paint(layer): lab = self.epicure.get_free_label() ut.show_info( "Unused label "+": "+str(lab) ) ut.set_label(layer, lab) layer.mode = "FILL" - - @self.epicure.seglayer.bind_key('w', overwrite=True) + + @self.epicure.seglayer.bind_key( sl["swap mode"]["key"], overwrite=True ) def key_swap(layer): """ Active key bindings for label swapping options """ ut.show_info("Begin swap mode: Control and click to swap two labels") @@ -342,9 +522,9 @@ class Editing(QWidget): ut.reactive_bindings( self.epicure.seglayer, self.old_mouse_drag, self.old_key_map ) ut.show_info("End swap") - @self.epicure.seglayer.bind_key('e', overwrite=True) + @self.epicure.seglayer.bind_key( sseed["new seed"]["key"], overwrite=True ) def place_seed(layer): - """ Add a seed if left click after pressing <e> """ + """ Add a seed if left click after pressing the shortcut """ ## desactivate other click-binding self.old_mouse_drag = self.epicure.seglayer.mouse_drag_callbacks.copy() @@ -362,22 +542,28 @@ class Editing(QWidget): else: self.end_place_seed() + @self.epicure.seglayer.bind_key( sl["draw junction mode"]["key"], overwrite=True ) + def manual_junction(layer): + """ Launch the manual drawing junction mode """ + self.drawing_junction_mode() @self.epicure.seglayer.mouse_drag_callbacks.append def click(layer, event): if event.type == "mouse_press": - if len(event.modifiers) == 0: - if event.button == 2: - # single right-click: erase the cell - tframe = ut.current_frame(self.viewer) - - ## erase the cell and get its value - erased = ut.setLabelValue(self.epicure.seglayer, self.epicure.seglayer, event, 0, tframe, tframe) - if erased is not None: - self.epicure.delete_track(erased, tframe) + ## erase cell option + if ut.shortcut_click_match( sl["erase"], event ): + # single right-click: erase the cell + tframe = ut.current_frame(self.viewer) + erased = ut.setLabelValue(self.epicure.seglayer, self.epicure.seglayer, event, 0, tframe, tframe) + ## delete also in track data + if erased is not None: + self.epicure.delete_track( erased, tframe ) + return - if (len(event.modifiers)==1) and ('Control' in event.modifiers): - # on move + merging = ut.shortcut_click_match( sl["merge"], event ) + splitting = ut.shortcut_click_match( sl["split accross"], event ) + if merging or splitting: + # get the start and last labels start_label = self.epicure.seglayer.get_value(position=event.position, view_direction = event.view_direction, dims_displayed=event.dims_displayed, world=True) start_pos = event.position yield @@ -392,15 +578,16 @@ class Editing(QWidget): print("One position is not a cell, do nothing") return - if event.button == 1: - # Control left-click: merge labels at each end of the click + if merging: + ## Merge labels at each end of the click if start_label != end_label: if self.epicure.verbose > 0: print("Merge cell "+str(start_label)+" with "+str(end_label)) self.merge_labels(tframe, start_label, end_label) + return - if event.button == 2: - # Control right-click: split label at each end of the click + if splitting: + ## split label at each end of the click if start_label == end_label: if self.epicure.verbose > 0: print("Split cell "+str(start_label)) @@ -408,8 +595,11 @@ class Editing(QWidget): else: if self.epicure.verbose > 0: print("Not the same cell already, do nothing") - - if (len(event.modifiers)==1) and ('Alt' in event.modifiers): + return + + drawing_split = ut.shortcut_click_match( sl["split draw"], event ) + redrawing = ut.shortcut_click_match( sl["redraw junction"], event ) + if drawing_split or redrawing: if self.shapelayer_name not in self.viewer.layers: self.create_shapelayer() shape_lay = self.viewer.layers[self.shapelayer_name] @@ -430,22 +620,69 @@ class Editing(QWidget): shape_lay.refresh() ut.set_active_layer(self.viewer, "Segmentation") tframe = int(event.position[0]) - if event.button == 1: - # ALT leftt-click: modify junction along the drawn line + if redrawing: + ## modify junction along the drawn line if self.epicure.verbose > 0: print("Correct junction with the drawn line ") self.redraw_along_line(tframe, pos) shape_lay.data = [] shape_lay.refresh() shape_lay.visible = False - if event.button == 2: - # ALT right-click: split labels along the drawn line + return + if drawing_split: + ## split labels along the drawn line if self.epicure.verbose > 0: print("Split cell along the drawn line ") self.split_along_line(tframe, pos) shape_lay.data = [] shape_lay.refresh() shape_lay.visible = False + return + + def drawing_junction_mode( self ): + """ Active mouse bindings for manually drawing the junction, and try to fill defined area """ + + sl = self.epicure.shortcuts["Labels"] + ut.show_info("Begin drawing junction: Control-Left-click to draw the junction and create new cell(s) from it") + self.old_mouse_drag, self.old_key_map = ut.clear_bindings( self.epicure.seglayer ) + + @self.epicure.seglayer.bind_key( sl["draw junction mode"]["key"], overwrite=True ) + def stop_draw_junction_mode( layer ): + ut.reactive_bindings( self.epicure.seglayer, self.old_mouse_drag, self.old_key_map ) + ut.show_info("End drawing mode") + + @self.epicure.seglayer.mouse_drag_callbacks.append + def click(layer, event): + if ut.shortcut_click_match( sl["drawing junction"], event ): + shape_lay = self.viewer.layers[self.shapelayer_name] + shape_lay.mode = "add_path" + shape_lay.visible = True + pos = [event.position] + yield + ## record all the successives position of the mouse while clicked + i = 0 + while event.type == 'mouse_move': + pos.append( event.position ) + if i%5 == 0: + # refresh display every n steps + shape_lay.data = np.array( pos ) + shape_lay.shape_type = "path" + shape_lay.refresh() + i = i + 1 + yield + pos.append(event.position) + shape_lay.data = np.array(pos) + shape_lay.shape_type = "path" + shape_lay.refresh() + ut.set_active_layer(self.viewer, "Segmentation") + tframe = int(event.position[0]) + self.create_cell_from_line( tframe, pos ) + shape_lay.data = [] + shape_lay.refresh() + shape_lay.visible = False + ut.reactive_bindings( self.epicure.seglayer, self.old_mouse_drag, self.old_key_map ) + ut.show_info("End drawing mode") + def split_label(self, tframe, startlab, start_pos, end_pos): """ Split the label in two cells based on the two seeds """ @@ -737,13 +974,36 @@ class Editing(QWidget): """ Remove all cells that touch the border """ start_time = time.time() self.viewer.window._status_bar._toggle_activity_dock(True) - for i in progress(range(0, self.epicure.nframes)): - self.remove_pixel_border( np.copy(self.epicure.seglayer.data[i]), i) + size = int(self.border_size.text()) + if size == 0: + for i in progress(range(0, self.epicure.nframes)): + img = np.copy( self.epicure.seglayer.data[i] ) + resimg = clear_border( img ) + self.epicure.seglayer.data[i] = resimg + self.epicure.removed_labels( img, resimg, i ) + else: + maxx = self.epicure.imgshape2D[0] - size - 1 + maxy = self.epicure.imgshape2D[1] - size - 1 + for i in progress(range(0, self.epicure.nframes)): + frame = self.epicure.seglayer.data[i] + img = np.copy( frame ) + crop_img = img[ size:maxx, size:maxy ] + crop_img = clear_border( crop_img ) + frame[0:size, :] = 0 + frame[:, 0:size] = 0 + frame[maxx:, :] = 0 + frame[:, maxy:] = 0 + frame[size:maxx, size:maxy] = crop_img + ## update the tracks after the potential disappearance of some cells + self.epicure.removed_labels( img, frame, i ) + self.viewer.window._status_bar._toggle_activity_dock(False) self.epicure.seglayer.refresh() if self.epicure.verbose > 0: ut.show_duration( start_time, "Border cells removed in ") + + def remove_smalls( self ): """ Remove all cells smaller than given area (in nb pixels) """ start_time = time.time() @@ -799,30 +1059,14 @@ class Editing(QWidget): self.merge_labels( frame, label, nlabel, 1.05 ) if self.epicure.verbose > 0: print( "Merged label "+str(label)+" into label "+str(nlabel)+" at frame "+str(frame) ) - - - - def remove_pixel_border(self, img, frame): - """ Remove if few pixels wide along border (cellpose) """ - size = int(self.border_size.text()) - if size == 0: - resimg = clear_border(img) - else: - crop_img = img[size:(img.shape[0]-size-1), size:(img.shape[1]-size-1)] - crop_img = clear_border( crop_img ) - resimg = np.zeros(img.shape) - resimg[size:(resimg.shape[0]-size-1), size:(resimg.shape[1]-size-1)] = crop_img - ## update the tracks after the potential disappearance of some cells - self.epicure.seglayer.data[frame] = resimg - self.epicure.removed_labels( img, resimg, frame ) - ############### ## Shapes functions - def create_shapelayer(self): + def create_shapelayer( self ): """ Create the layer that handle temporary drawings """ shapes = [] - shap = self.viewer.add_shapes(shapes, name=self.shapelayer_name, blending="additive", opacity=1, edge_width=2) + shap = self.viewer.add_shapes( shapes, name=self.shapelayer_name, blending="additive", opacity=1, edge_width=2 ) + shap.text.visible = False shap.visible = False ######################################" @@ -832,48 +1076,31 @@ class Editing(QWidget): if not self.gSeed.isVisible(): ut.remove_layer(self.viewer, "Seeds") - def show_seedMapBlock(self): - """ Show the seeds """ - self.gSeed.setVisible(True) - self.seed_vis.setChecked(True) - def create_seedsBlock(self): - self.gSeed = QGroupBox("Seeds") seed_layout = QVBoxLayout() - seed_createbtn = QPushButton("Create seeds layer", parent=self) - seed_createbtn.clicked.connect(self.reset_seeds) + reset_color = self.epicure.get_resetbtn_color() + seed_createbtn = wid.add_button( btn="Create seeds layer", btn_func=self.reset_seeds, descr="Create/reset the layer to add seeds", color=reset_color ) seed_layout.addWidget(seed_createbtn) - seed_loadbtn = QPushButton("Load seeds from previous time point", parent=self) - seed_loadbtn.clicked.connect(self.get_seeds_from_prev) + seed_loadbtn = wid.add_button( btn="Load seeds from previous time point", btn_func=self.get_seeds_from_prev, descr="Place seeds in background area where cells are in previous time point" ) seed_layout.addWidget(seed_loadbtn) ## choose method and segment from seeds gseg = QGroupBox("Seed based segmentation") gseg_layout = QVBoxLayout() - seed_btn = QPushButton("Segment cells from seeds", parent=self) - seed_btn.clicked.connect(self.segment_from_points) + seed_btn = wid.add_button( btn="Segment cells from seeds", btn_func=self.segment_from_points, descr="Segment new cells from placed seeds" ) gseg_layout.addWidget(seed_btn) - self.seed_method = QComboBox() + method_line, self.seed_method = wid.list_line( label="Method", descr="Seed based segmentation method to segment some cells" ) self.seed_method.addItem("Intensity-based (watershed)") self.seed_method.addItem("Distance-based") self.seed_method.addItem("Diffusion-based") - gseg_layout.addWidget(self.seed_method) - maxdist = QHBoxLayout() - maxdist_lab = QLabel() - maxdist_lab.setText("Max cell radius") - maxdist.addWidget(maxdist_lab) - self.max_distance = QLineEdit() - self.max_distance.setText("100.0") - maxdist.addWidget(self.max_distance) + gseg_layout.addLayout( method_line ) + maxdist, self.max_distance = wid.value_line( label="Max cell radius", default_value="100.0", descr="Max cell radius allowed in new cell creation" ) gseg_layout.addLayout(maxdist) gseg.setLayout(gseg_layout) seed_layout.addWidget(gseg) self.gSeed.setLayout(seed_layout) - def help_seeds(self): - ut.show_documentation_page("Seeds") - def create_seedlayer(self): pts = [] points = self.viewer.add_points( np.array(pts), face_color="blue", size = 7, edge_width=0, name="Seeds" ) @@ -986,7 +1213,8 @@ class Editing(QWidget): splitted = label(splitted) new_labels = np.unique(markers) i = 0 - for lab in set(splitted.flatten()): + lablist = set( splitted.flatten() ) + for lab in lablist: if lab > 0: splitted[splitted==lab] = new_labels[i] i = i + 1 @@ -1019,49 +1247,30 @@ class Editing(QWidget): def create_cleaningBlock(self): """ GUI for cleaning segmentation """ - self.gCleaned = QGroupBox("Cleaning") clean_layout = QVBoxLayout() ## cells on border - border_line = QHBoxLayout() - border_btn = QPushButton("Remove border cells (width pixels)", parent=self) - border_btn.clicked.connect(self.remove_border) - border_line.addWidget(border_btn) - self.border_size = QLineEdit() - self.border_size.setText("1") - border_line.addWidget(self.border_size) + border_line, self.border_size = wid.button_parameter_line( btn="Remove border cells", btn_func=self.remove_border, value="1", descr_btn="Remove all cell at a distance <= value (in pixels)", descr_value="Distance of the cells to be removed (in pixels)" ) clean_layout.addLayout(border_line) ## too small cells - small_line = QHBoxLayout() - small_btn = QPushButton("Remove mini cells (area pixels)", parent=self) - small_btn.clicked.connect(self.remove_smalls) - small_line.addWidget(small_btn) - self.small_size = QLineEdit() - self.small_size.setText("4") - small_line.addWidget(self.small_size) + small_line, self.small_size = wid.button_parameter_line( btn="Remove mini cells", btn_func=self.remove_smalls, value="4", descr_btn="Remove all cells smaller than given value (in pixels^2)", descr_value="Minimal cell area (in pixels^2)" ) clean_layout.addLayout(small_line) ## Cell inside another cell - inside_btn = QPushButton("Cell inside another: merge", parent=self) - inside_btn.clicked.connect(self.merge_inside_cells) + inside_btn = wid.add_button( btn="Cell inside another: merge", btn_func=self.merge_inside_cells, descr="Merge all small cells fully contained inside another cell to this cell" ) clean_layout.addWidget(inside_btn) ## sanity check - sanity_btn = QPushButton("Sanity check", parent=self) - sanity_btn.clicked.connect(self.sanity_check) + sanity_btn = wid.add_button( btn="Sanity check", btn_func=self.sanity_check, descr="Check that labels and tracks are consistent with EpiCure restrictions, and try to fix some errors" ) clean_layout.addWidget(sanity_btn) ## reset labels - reset_btn = QPushButton("Reset all", parent=self) - reset_btn.clicked.connect(self.reset_all) + reset_color = self.epicure.get_resetbtn_color() + reset_btn = wid.add_button( btn="Reset all", btn_func=self.reset_all, descr="Reset all tracks, groups, suspects..", color=reset_color ) clean_layout.addWidget(reset_btn) self.gCleaned.setLayout(clean_layout) - def show_cleaningBlock(self): - """ Show/hide cleaning interface """ - self.gCleaned.setVisible(not self.gCleaned.isVisible()) - #################################### ## Sanity check/correction options def sanity_check(self): @@ -1082,9 +1291,10 @@ class Editing(QWidget): self.check_unique_labels( label_list, progress_bar ) ## check and update if necessary tracks progress_bar.update(2) - progress_bar.set_description("Sanity check: track gaps") - ut.show_info("Check if some tracks contain gaps") - gaped = self.epicure.handle_gaps( track_list=None ) + if self.epicure.forbid_gaps: + progress_bar.set_description("Sanity check: track gaps") + ut.show_info("Check if some tracks contain gaps") + gaped = self.epicure.handle_gaps( track_list=None ) ## check that labels and tracks correspond progress_bar.set_description("Sanity check: label-track") progress_bar.update(3) @@ -1171,35 +1381,20 @@ class Editing(QWidget): ###################################### ## Selection options - def help_selection(self): - ut.show_documentation_page("Selection options") def create_selectBlock(self): """ GUI for handling selection with shapes """ - self.gSelect = QGroupBox("Selection options") select_layout = QVBoxLayout() ## create/select the ROI - draw_btn = QPushButton("Draw/Select ROI", parent=self) - draw_btn.clicked.connect(self.draw_shape) + draw_btn = wid.add_button( btn="Draw/Select ROI", btn_func=self.draw_shape, descr="Draw or select a ROI to apply region action on" ) select_layout.addWidget(draw_btn) - remove_sel_btn = QPushButton("Remove cells inside ROI", parent=self) - remove_sel_btn.clicked.connect(self.remove_cells_inside) + remove_sel_btn = wid.add_button( btn="Remove cells inside ROI", btn_func=self.remove_cells_inside, descr="Remove all cells inside the selected/first ROI" ) select_layout.addWidget(remove_sel_btn) - remove_line = QHBoxLayout() - removeout_sel_btn = QPushButton("Remove cells outside ROI", parent=self) - removeout_sel_btn.clicked.connect(self.remove_cells_outside) - remove_line.addWidget(removeout_sel_btn) - self.keep_new_cells = QCheckBox(text="Keep new cells") - self.keep_new_cells.setChecked(True) - remove_line.addWidget(self.keep_new_cells) + remove_line, self.keep_new_cells = wid.button_check_line( btn="Remove cells outside ROI", btn_func=self.remove_cells_outside, check="Keep new cells", checked=True, checkfunc=None, descr_btn="Remove all cells outside the current ROI", descr_check="Keep new cells tah appear in the ROI in later frames" ) select_layout.addLayout(remove_line) self.gSelect.setLayout(select_layout) - def show_selectBlock(self): - """ Show/hide select options block """ - self.gSelect.setVisible(not self.gSelect.isVisible()) - def draw_shape(self): """ Draw/select a shape in the Shapes layer """ if self.shapelayer_name not in self.viewer.layers: @@ -1277,15 +1472,17 @@ class Editing(QWidget): def group_cells_inside(self): """ Put all cells inside the selected ROI into current group """ + if self.group_choice.currentText() == "": + ut.show_warning("Write a group name before") + return tocheck = self.get_labels_inside() if tocheck is None: if self.epicure.verbose > 0: print("No cell to add to group") return - for lab in tocheck: - self.group_label(lab) + self.group_labels( tocheck ) if self.epicure.verbose > 0: - print(str(len(tocheck))+" cells assigend to group "+str(self.group_group.text())) + print(str(len(tocheck))+" cells assigend to group "+str(self.group_choice.currentText())) lay = self.viewer.layers[self.shapelayer_name] lay.remove_selected() self.epicure.finish_update() @@ -1293,49 +1490,22 @@ class Editing(QWidget): ###################################### ## Group cells functions - def show_groupCellsBlock(self): - self.gGroup.setVisible(not self.gGroup.isVisible()) - def create_groupCellsBlock(self): - self.gGroup = QGroupBox("Group cells") + """ Create subpanel of Cell group options """ group_layout = QVBoxLayout() - groupgr = QHBoxLayout() - groupgr_lab = QLabel() - groupgr_lab.setText("Group name") - groupgr.addWidget(groupgr_lab) - self.group_group = QLineEdit() - self.group_group.setText("Positive") - groupgr.addWidget(self.group_group) + groupgr, self.group_choice = wid.list_line( label="Group name", descr="Choose/Set the current group name" ) group_layout.addLayout(groupgr) + self.group_choice.setEditable(True) - self.group_show = QCheckBox(text="Show groups") - self.group_show.stateChanged.connect(self.see_groups) - self.group_show.setChecked(False) + self.group_show = wid.add_check( check="Show groups", checked=False, check_func=self.see_groups, descr="Add a layer with the cells colored by group" ) group_layout.addWidget(self.group_show) - #group_loadbtn = QPushButton("Load groups", parent=self) - #group_loadbtn.clicked.connect(self.load_groups) - #group_layout.addWidget(group_loadbtn) - #group_savebtn = QPushButton("Save groups", parent=self) - #group_savebtn.clicked.connect(self.save_group) - #group_layout.addWidget(group_savebtn) - group_resetbtn = QPushButton("Reset groups", parent=self) - group_resetbtn.clicked.connect(self.reset_group) + group_resetbtn = wid.add_button( btn="Reset groups", btn_func=self.reset_group, descr="Remove all groups and cell assignation to groups" ) group_layout.addWidget(group_resetbtn) - #self.lock_checked = QCheckBox("Lock checked cells") - #self.lock_checked.setChecked(True) - #check_layout.addWidget(self.lock_checked) - group_sel_btn = QPushButton("Cells inside ROI to group", parent=self) - group_sel_btn.clicked.connect(self.group_cells_inside) + group_sel_btn = wid.add_button( btn="Cells inside ROI to group", btn_func=self.group_cells_inside, descr="Add all cells inside ROI to the current group" ) group_layout.addWidget(group_sel_btn) self.gGroup.setLayout(group_layout) - def help_group(self): - ut.show_documentation_page("Edit#group-options") - - def help_clean(self): - ut.show_documentation_page("Edit#cleaning-options") - def load_checked(self): cfile = self.get_filename("_checked.txt") with open(cfile) as infile: @@ -1351,6 +1521,7 @@ class Editing(QWidget): grouped.data = np.zeros(grouped.data.shape, np.uint8) grouped.refresh() ut.set_active_layer(self.viewer, "Segmentation") + self.group_choice.clear() def save_groups(self): groupfile = self.get_filename("_groups.txt") @@ -1358,7 +1529,6 @@ class Editing(QWidget): out.write(";".join(group.write_group() for group in self.epicure.groups)) ut.show_info("Cell groups saved in "+groupfile) - def see_groups(self): if self.group_show.isChecked(): ut.remove_layer(self.viewer, self.grouplayer_name) @@ -1370,21 +1540,30 @@ class Editing(QWidget): ut.remove_layer(self.viewer, self.grouplayer_name) ut.set_active_layer(self.viewer, "Segmentation") - def group_label(self, label): - """ Add label to group """ - group = self.group_group.text() - self.group_ingroup(label, group) + def group_labels( self, labels ): + """ Add label(s) to group """ + if self.group_choice.currentText() == "": + ut.show_warning("Write group name before") + return + group = self.group_choice.currentText() + self.group_ingroup( labels, group ) def check_label(self, label): """ Mark label as checked """ group = self.check_group.text() self.check_ingroup(label, group) + + def update_group_list( self, group ): + """ Check if group has been added in the list choices of group """ + if self.group_choice.findText( group ) < 0: + ## not added yet. If user is typing the name and did not press enter, it can be still in edition mode, so not added + self.group_choice.addItem( group ) - def group_ingroup(self, label, group): + def group_ingroup(self, labels, group): """ Add the given label to chosen group """ - self.epicure.cell_ingroup( label, group ) + self.epicure.cells_ingroup( labels, group ) if self.grouplayer_name in self.viewer.layers: - self.redraw_label_group( label, group ) + self.redraw_label_group( labels, group ) def check_load_label(self, labelstr): """ Read the label to check from file """ @@ -1396,23 +1575,23 @@ class Editing(QWidget): def add_cell_to_group(self, event): """ Add cell under click to the current group """ label = ut.getCellValue( self.epicure.seglayer, event ) - self.group_label(label) + self.group_labels( [label] ) def remove_cell_group(self, event): """ Remove the cell from the group it's in if any """ label = ut.getCellValue( self.epicure.seglayer, event ) self.epicure.cell_removegroup( label ) if self.grouplayer_name in self.viewer.layers: - self.redraw_label_group( label, 0 ) + self.redraw_label_group( [label], 0 ) - def redraw_label_group(self, label, group): + def redraw_label_group(self, labels, group): """ Update the Group layer for label """ lay = self.viewer.layers[self.grouplayer_name] if group == 0: - lay.data[self.epicure.seg==label] = 0 + lay.data[ np.isin( self.epicure.seg, labels ) ] = 0 else: igroup = self.epicure.get_group_index(group) + 1 - lay.data[self.epicure.seg==label] = igroup + lay.data[ np.isin( self.epicure.seg, labels) ] = igroup lay.refresh() ######### overlay message @@ -1424,27 +1603,74 @@ class Editing(QWidget): ut.setOverlayText(self.viewer, text, size=10) ################## Track editing functions + def add_division( self, labela, labelb, frame ): + """ Add a division event, given the labels of the two daughter cells """ + if frame == 0: + if self.epicure.verbose > 0: + print("Cannot define a division before the first frame") + return + if (frame != self.epicure.tracking.get_first_frame( labela )) or (frame != self.epicure.tracking.get_first_frame(labelb) ): + if self.epicure.verbose > 0: + print("One daughter track is not starting at current frame, don't add division") + return + + ## merge the two labels to find their parent + bbox, merge = ut.getBBox2DMerge( self.epicure.seglayer.data[frame], labela, labelb ) + twoframes = ut.crop_twoframes( self.epicure.seglayer.data, bbox, frame ) + crop_merge = ut.cropBBox2D( merge, bbox ) + twoframes[1] = crop_merge # merge of the labels and 0 outside + + ## keep only parent labels that stop at the previous frame + orig_frame = ut.cropBBox2D(self.epicure.seglayer.data[frame], bbox) + ut.keep_orphans(twoframes, orig_frame, []) + ## do mini-tracking to assign most likely parent + parent = self.get_parents( twoframes, [1] ) + if self.epicure.verbose > 0: + print( "Found parent "+str(parent[0])+" to clicked cells "+str(labela)+" and "+str(labelb) ) + ## add division to graph + self.epicure.tracking.add_division( labela, labelb, parent[0] ) + ## add division to event list (if active) + self.epicure.inspecting.add_division( labela, labelb, parent[0], frame ) + def key_tracking_binding(self): """ active key bindings for tracking options """ self.epicure.overtext["trackedit"] = "---- Track editing ---- \n" - self.epicure.overtext["trackedit"] += " <r> to show/hide the tracks \n" - self.epicure.overtext["trackedit"] += " <t> for tracks editing mode \n" - self.epicure.overtext["trackedit"] += " <t>, <t> end tracks editing mode \n" - self.epicure.overtext["trackedit"] += " <t>, (Left-Right) clicks to merge two tracks (temporally or spatially) \n" - self.epicure.overtext["trackedit"] += " <t>, <Control>+Left clicks manually do a new track \n(<Control>+Right click to end it) \n" - self.epicure.overtext["trackedit"] += " <t>, <Shift>+Right clicks split the track temporally \n" - self.epicure.overtext["trackedit"] += " <t>, <Shift>+Left drag-click swap 2 tracks from current frame \n" - self.epicure.overtext["trackedit"] += " <t>, <Alt>+(Left-Right) clicks to interpolate labels temporally \n" - self.epicure.overtext["trackedit"] += " <t>, Double-Right click to delete all the track from current frame \n" + strack = self.epicure.shortcuts["Tracks"] + self.epicure.overtext["trackedit"] += ut.print_shortcuts( strack ) - @self.epicure.seglayer.bind_key('r', overwrite=True) + @self.epicure.seglayer.mouse_drag_callbacks.append + def manual_add_division(layer, event): + ### add an event of a division, selecting the two daughter cells + if ut.shortcut_click_match( strack["add division"], event ): + # get the start and last labels + labela = self.epicure.seglayer.get_value(position=event.position, view_direction = event.view_direction, dims_displayed=event.dims_displayed, world=True) + start_pos = event.position + yield + while event.type == 'mouse_move': + yield + labelb = self.epicure.seglayer.get_value(position=event.position, view_direction = event.view_direction, dims_displayed=event.dims_displayed, world=True) + end_pos = event.position + tframe = int(event.position[0]) + + if labela == 0 or labelb == 0: + if self.epicure.verbose > 0: + print("One position is not a cell, do nothing") + return + self.add_division( labela, labelb, tframe ) + + @self.epicure.seglayer.bind_key( strack["lineage color"]["key"], overwrite=True ) + def color_tracks_lineage(seglayer): + if self.tracklayer_name in self.viewer.layers: + self.epicure.tracking.color_tracks_by_lineage() + + @self.epicure.seglayer.bind_key( strack["show"]["key"], overwrite=True ) def see_tracks(seglayer): if self.tracklayer_name in self.viewer.layers: tlayer = self.viewer.layers[self.tracklayer_name] tlayer.visible = not tlayer.visible - @self.epicure.seglayer.bind_key('t', overwrite=True) + @self.epicure.seglayer.bind_key( strack["mode"]["key"], overwrite=True) def edit_track(layer): self.label_tr = None self.start_label = None @@ -1458,15 +1684,15 @@ class Editing(QWidget): """ Edit tracking """ if event.type == "mouse_press": - if len(event.modifiers)== 0 and event.button == 1: - """ Merge two tracks, spatially or temporally: left click, select the first label """ + """ Merge two tracks, spatially or temporally: left click, select the first label """ + if ut.shortcut_click_match( strack["merge first"], event ): self.start_label = self.epicure.seglayer.get_value(position=event.position, view_direction = event.view_direction, dims_displayed=event.dims_displayed, world=True) self.start_pos = event.position # move one frame after for next cell to link #ut.set_frame( self.epicure.viewer, event.position[0]+1 ) return - if len(event.modifiers)== 0 and event.button == 2: - """ Merge two tracks, spatially or temporally: right click, select the second label """ + """ Merge two tracks, spatially or temporally: right click, select the second label """ + if ut.shortcut_click_match( strack["merge second"], event ): if self.start_label is None: if self.epicure.verbose > 0: print("No left click done before right click, don't merge anything") @@ -1485,95 +1711,90 @@ class Editing(QWidget): self.end_track_edit() return - if (len(event.modifiers) == 1) and ("Shift" in event.modifiers): - if event.button == 2: - ### Split the track in 2: new label for the next frames - start_frame = int(event.position[0]) - label = ut.getCellValue(self.epicure.seglayer, event) - new_label = self.epicure.get_free_label() - self.epicure.replace_label( label, new_label, start_frame ) - if self.epicure.verbose > 0: - ut.show_info("Split track "+str(label)+" from frame "+str(start_frame)) - self.end_track_edit() - return + ### Split the track in 2: new label for the next frames + if ut.shortcut_click_match( strack["split track"], event ): + start_frame = int(event.position[0]) + label = ut.getCellValue(self.epicure.seglayer, event) + new_label = self.epicure.get_free_label() + self.epicure.replace_label( label, new_label, start_frame ) + if self.epicure.verbose > 0: + ut.show_info("Split track "+str(label)+" from frame "+str(start_frame)) + self.end_track_edit() + return - if event.button == 1: - ### Swap the two track from the current frame - start_frame = int(event.position[0]) - label = ut.getCellValue(self.epicure.seglayer, event) + ### Swap the two track from the current frame + if ut.shortcut_click_match( strack["swap"], event ): + start_frame = int(event.position[0]) + label = ut.getCellValue(self.epicure.seglayer, event) + yield + while event.type == 'mouse_move': yield - while event.type == 'mouse_move': - yield - end_label = self.epicure.seglayer.get_value(position=event.position, view_direction = event.view_direction, dims_displayed=event.dims_displayed, world=True) - - if label == 0 or end_label == 0: - if self.epicure.verbose > 0: - print("One position is not a cell, do nothing") - return - - self.epicure.swap_tracks( label, end_label, start_frame ) + end_label = self.epicure.seglayer.get_value(position=event.position, view_direction = event.view_direction, dims_displayed=event.dims_displayed, world=True) + if label == 0 or end_label == 0: if self.epicure.verbose > 0: - ut.show_info("Swapped track "+str(label)+" with track "+str(end_label)+" from frame "+str(start_frame)) - self.end_track_edit() + print("One position is not a cell, do nothing") return - if (len(event.modifiers) == 1) and ("Control" in event.modifiers): - if event.button == 1: - ### Manual tracking: get a new label and spread it to clicked cells on next frames - zpos = int(event.position[0]) - if self.label_tr is None: - ## first click: get the track label - self.label_tr = ut.getCellValue(self.epicure.seglayer, event) - else: - old_label = ut.setCellValue(self.epicure.seglayer, self.epicure.seglayer, event, self.label_tr, layer_frame=zpos, label_frame=zpos) - self.epicure.tracking.remove_one_frame( old_label, zpos ) - self.epicure.add_label( [self.label_tr], zpos ) - ## advance to next frame, ready for a click - self.viewer.dims.set_point(0, zpos+1) - ## if reach the end, stops here for this track - if (zpos+1) >= self.epicure.seglayer.data.shape[0]: - self.end_track_edit() - return - if event.button == 2: + self.epicure.swap_tracks( label, end_label, start_frame ) + + if self.epicure.verbose > 0: + ut.show_info("Swapped track "+str(label)+" with track "+str(end_label)+" from frame "+str(start_frame)) + self.end_track_edit() + return + + # Manual tracking: get a new label and spread it to clicked cells on next frames + if ut.shortcut_click_match( strack["start manual"], event ): + zpos = int(event.position[0]) + if self.label_tr is None: + ## first click: get the track label + self.label_tr = ut.getCellValue(self.epicure.seglayer, event) + else: + old_label = ut.setCellValue(self.epicure.seglayer, self.epicure.seglayer, event, self.label_tr, layer_frame=zpos, label_frame=zpos) + self.epicure.tracking.remove_one_frame( old_label, zpos, handle_gaps=self.epicure.forbid_gaps ) + self.epicure.add_label( [self.label_tr], zpos ) + ## advance to next frame, ready for a click + self.viewer.dims.set_point(0, zpos+1) + ## if reach the end, stops here for this track + if (zpos+1) >= self.epicure.seglayer.data.shape[0]: self.end_track_edit() - return + return - if (len(event.modifiers) == 1) and ("Alt" in event.modifiers): - if event.button == 1: - ## left click, first cell - self.interp_labela = ut.getCellValue(self.epicure.seglayer, event) - self.interp_framea = int(event.position[0]) - return - if event.button == 2: - ## right click, second cell - labelb = ut.getCellValue(self.epicure.seglayer, event) - interp_frameb = int(event.position[0]) - if self.interp_labela is not None: - if abs(self.interp_framea - interp_frameb) <= 1: - print("No frames to interpolate, exit") - self.end_track_edit() - return - if self.interp_framea < interp_frameb: - self.interpolate_labels(self.interp_labela, self.interp_framea, labelb, interp_frameb) - else: - self.interpolate_labels(labelb, interp_frameb, self.interp_labela, self.interp_framea ) + ## Finish manual tracking + if ut.shortcut_click_match( strack["end manual"], event ): + self.end_track_edit() + return + + ## Interpolate between two labels: get first label + if ut.shortcut_click_match( strack["interpolate first"], event ): + ## left click, first cell + self.interp_labela = ut.getCellValue(self.epicure.seglayer, event) + self.interp_framea = int(event.position[0]) + return + + ## Interpolate between two labels: get second label and interpolate + if ut.shortcut_click_match( strack["interpolate second"], event ): + ## right click, second cell + labelb = ut.getCellValue(self.epicure.seglayer, event) + interp_frameb = int(event.position[0]) + if self.interp_labela is not None: + if abs(self.interp_framea - interp_frameb) <= 1: + print("No frames to interpolate, exit") self.end_track_edit() return + if self.interp_framea < interp_frameb: + self.interpolate_labels(self.interp_labela, self.interp_framea, labelb, interp_frameb) else: - print("No cell selected with left click before. Exit mode") - self.end_track_edit() - return - - ## A right click or other click stops it - self.end_track_edit() - - @self.epicure.seglayer.mouse_double_click_callbacks.append - def double_click(layer, event): - """ Edit tracking : double click options """ - if event.type == "mouse_double_click": - if len(event.modifiers)== 0 and event.button == 2: - """ Double right click: delete all the track from the current frame """ + self.interpolate_labels(labelb, interp_frameb, self.interp_labela, self.interp_framea ) + self.end_track_edit() + return + else: + print("No cell selected with left click before. Exit mode") + self.end_track_edit() + return + + ## Delete all the labels of the track until its end + if ut.shortcut_click_match( strack["delete"], event ): tframe = int(event.position[0]) label = ut.getCellValue(self.epicure.seglayer, event) if label > 0: @@ -1582,11 +1803,17 @@ class Editing(QWidget): print("Track "+str(label)+" deleted from frame "+str(tframe)) self.end_track_edit() return - - ## A double click with nothing else stop the mode + + ## A right click or other click stops it self.end_track_edit() + + #@self.epicure.seglayer.mouse_double_click_callbacks.append + #def double_click(layer, event): + # """ Edit tracking : double click options """ + # if event.type == "mouse_double_click": + - @self.epicure.seglayer.bind_key('t', overwrite=True) + @self.epicure.seglayer.bind_key( strack["mode"]["key"], overwrite=True ) def end_edit_track(layer): self.end_track_edit() @@ -1798,7 +2025,6 @@ class Editing(QWidget): ## go, do the update self.epicure.change_labels(indmodif, new_labels) - ############# Test def interpolate_labels( self, labela, framea, labelb, frameb ): """ diff --git a/src/epicure/epicuring.py b/src/epicure/epicuring.py index 106788025840bf314f8fb56c3357e45967b7bf1e..8514c669d5f9823ca8791c4d47aa2e816de48935 100644 --- a/src/epicure/epicuring.py +++ b/src/epicure/epicuring.py @@ -15,9 +15,10 @@ import pandas as pand import epicure.Utils as ut from epicure.editing import Editing from epicure.tracking import Tracking -from epicure.suspecting import Suspecting +from epicure.inspecting import Inspecting from epicure.outputing import Outputing from epicure.displaying import Displaying +from epicure.preferences import Preferences """ EpiCure main @@ -33,14 +34,14 @@ class EpiCure(): self.viewer = napari.Viewer(show=False) self.viewer.title = "Napari - EpiCure" self.img = None - self.suspecting = None + self.inspecting = None self.others = None self.imgshape2D = None ## width, height of the image self.nframes = None ## Number of time frames self.thickness = 4 ## thickness of junctions, wider self.minsize = 4 ## smallest number of pixels in a cell - self.verbose = 1 ## level of printing messages (None/few, normal, debug mode) - + self.verbose = 1 ## level of printing messages (None/few, normal, debug mode) + self.overtext = dict() self.help_index = 1 ## current display index of help overlay self.blabla = None ## help window @@ -50,7 +51,37 @@ class EpiCure(): self.nparallel = 4 ## number of parallel threads self.dtype = np.uint32 ## label type, default 32 but if less labels, reduce it self.outputing = None ## non initialized yet - + + self.forbid_gaps = False ## allow gaps in track or not + + self.pref = Preferences() + self.shortcuts = self.pref.get_shortcuts() ## user specific shortcuts + self.settings = self.pref.get_settings() ## user specific preferences + ## display settings + self.display_colors = None ## settings for changing some display colors + if "Display" in self.settings: + if "Colors" in self.settings["Display"]: + self.display_colors = self.settings["Display"]["Colors"] + + self.init_epicure_metadata() ## initialize metadata variables (scalings, channels) + + def init_epicure_metadata( self ): + """ Returns metadata to save """ + ## scalings and unit names + self.epi_metadata = {} + self.epi_metadata["ScaleXY"] = 1 + self.epi_metadata["UnitXY"] = "um" + self.epi_metadata["ScaleT"] = 1 + self.epi_metadata["UnitT"] = "min" + self.epi_metadata["MainChannel"] = 0 + + def get_resetbtn_color( self ): + """ Returns the color of Reset buttons if defined """ + if "Display" in self.settings: + if "Colors" in self.settings["Display"]: + if "Reset button" in self.settings["Display"]["Colors"]: + return self.settings["Display"]["Colors"]["Reset button"] + return None def set_thickness( self, thick ): """ Thickness of junctions (half thickness) """ @@ -59,7 +90,7 @@ class EpiCure(): def load_movie(self, imgpath): """ Load the intensity movie, and get metadata """ self.imgpath = imgpath - self.img, self.scale, nchan = ut.opentif( self.imgpath, verbose=self.verbose>1 ) + self.img, nchan, self.epi_metadata["ScaleXY"], self.epi_metadata["UnitXY"], self.epi_metadata["ScaleT"], self.epi_metadata["UnitT"] = ut.opentif( self.imgpath, verbose=self.verbose>1 ) ## transform static image to movie (add temporal dimension) if len(self.img.shape) == 2: self.img = np.expand_dims(self.img, axis=0) @@ -91,15 +122,19 @@ class EpiCure(): def quantiles(self): return tuple(np.quantile(self.img, [0.01, 0.9999])) - def set_scales( self, scalexy, scalet ): + def set_scales( self, scalexy, scalet, unitxy, unitt ): """ Set the scaling units for outputs """ - self.scale = scalexy - self.scale_time = scalet - ut.show_info("Movie scales set to "+str(self.scale)+" (in x,y) and "+str(self.scale_time)+" (in time)") + self.epi_metadata["ScaleXY"] = scalexy + self.epi_metadata["ScaleT"] = scalet + self.epi_metadata["UnitXY"] = unitxy + self.epi_metadata["UnitT"] = unitt + if self.verbose > 0: + ut.show_info( "Movie scales set to "+str(self.epi_metadata["ScaleXY"])+" "+self.epi_metadata["UnitXY"]+" and "+str(self.epi_metadata["ScaleT"])+" "+self.epi_metadata["UnitT"] ) def set_chanel( self, chan, chanaxis ): """ Update the movie to the correct chanel """ self.img = np.rollaxis(np.copy(self.mov), chanaxis, 0)[chan] + self.main_channel = chan if self.viewer is not None: mview = self.viewer.layers["Movie"] mview.data = self.img @@ -118,7 +153,7 @@ class EpiCure(): if purechan >= chan: purechan = purechan + 1 self.others_chanlist.append(purechan) - mview = self.viewer.add_image( self.others[ochan], name="MovieOtherChanel_"+str(purechan), blending="additive", colormap="gray" ) + mview = self.viewer.add_image( self.others[ochan], name="MovieChannel_"+str(purechan), blending="additive", colormap="gray" ) mview.contrast_limits=tuple(np.quantile(self.others[ochan],[0.01, 0.9999])) mview.gamma=0.95 mview.visible = False @@ -127,7 +162,7 @@ class EpiCure(): """ Load the segmentation file """ start_time = ut.start_time() self.segpath = segpath - self.seg,_, _ = ut.opentif( self.segpath, verbose=self.verbose>1 ) + self.seg,_, _,_,_,_ = ut.opentif( self.segpath, verbose=self.verbose>1 ) self.seg = np.uint32(self.seg) ## transform static image to movie (add temporal dimension) if len(self.seg.shape) == 2: @@ -163,8 +198,9 @@ class EpiCure(): if self.tracked == 0: tracked = "untracked" else: - progress_bar.set_description( "check and fix track gaps" ) - self.handle_gaps( track_list=None, verbose=1 ) + if self.forbid_gaps: + progress_bar.set_description( "check and fix track gaps" ) + self.handle_gaps( track_list=None, verbose=1 ) ut.show_info(""+str(len(self.tracking.get_track_list()))+" "+tracked+" cells loaded") @@ -192,10 +228,11 @@ class EpiCure(): """ Extract default names from imgpath """ self.imgname, self.imgdir, self.outdir = ut.extract_names( self.imgpath, outdir, mkdir=True ) - def go_epicure(self, outdir, segmentation_file): + def go_epicure( self, outdir="epics", segmentation_file=None ): """ Initialize everything and start the main widget """ self.set_names( outdir ) - + if segmentation_file is None: + segmentation_file = self.suggest_segfile( outdir ) self.viewer.window._status_bar._toggle_activity_dock(True) progress_bar = progress(total=5) progress_bar.set_description( "Reading segmented image" ) @@ -224,54 +261,106 @@ class EpiCure(): if self.verbose > 0: ut.show_duration(start_time, header="Tracks and graph loaded in ") progress_bar.update(5) + self.apply_settings() progress_bar.close() self.viewer.window._status_bar._toggle_activity_dock(False) + +###### Settings (preferences) save and load + def apply_settings( self ): + """ Apply all default or prefered settings """ + for sety, val in self.settings.items(): + if sety=="Display": + self.display.apply_settings( val ) + if "Show help" in val: + index = int( val["Show help"] ) + self.switchOverlayText( index ) + if "Contour" in val: + contour = int( val["Contour"] ) + self.seglayer.contour = contour + self.seglayer.refresh() + if "Colors" in val: + color = val["Colors"]["button"] + check_color = val["Colors"]["checkbox"] + line_edit_color = val["Colors"]["line edit"] + group_color = val["Colors"]["group"] + self.main_gui.setStyleSheet( 'QPushButton {background-color: '+color+'} QCheckBox::indicator {background-color: '+check_color+'} QLineEdit {background-color: '+line_edit_color+'} QGroupBox {color: grey; background-color: '+group_color+'} ') + self.display_colors = val["Colors"] + if sety == "events": + self.inspecting.apply_settings( val ) + if sety == "Output": + self.outputing.apply_settings( val ) + if sety == "Track": + self.tracking.apply_settings( val ) + if sety == "Edit": + self.editing.apply_settings( val ) + #case _: + # continue + ## match is not compatible with python 3.9 + + def update_settings( self ): + """ Returns all the prefered settings """ + disp = self.settings + ## load display current settings (layers visibility) + disp["Display"] = self.display.get_current_settings() + disp["Display"]["Show help"] = self.help_index + disp["Display"]["Contour"] = self.seglayer.contour + ## load suspect current settings + disp["events"] = self.inspecting.get_current_settings() + ## get outputs current settings + disp["Output"] = self.outputing.get_current_settings() + disp["Track"] = self.tracking.get_current_settings() + disp["Edit"] = self.editing.get_current_settings() + +#### Main widget that contains the tabs of the sub widgets def main_widget(self): """ Open the main widget interface """ - main_widget = QWidget() + self.main_gui = QWidget() layout = QVBoxLayout() tabs = QTabWidget() tabs.setObjectName("main") layout.addWidget(tabs) - main_widget.setLayout(layout) + self.main_gui.setLayout(layout) self.editing = Editing(self.viewer, self) tabs.addTab( self.editing, "Edit" ) - self.suspecting = Suspecting(self.viewer, self) - tabs.addTab( self.suspecting, "Suspect" ) + self.inspecting = Inspecting(self.viewer, self) + tabs.addTab( self.inspecting, "Inspect" ) self.tracking = Tracking(self.viewer, self) tabs.addTab( self.tracking, "Track" ) self.outputing = Outputing(self.viewer, self) tabs.addTab( self.outputing, "Output" ) self.display = Displaying(self.viewer, self) tabs.addTab( self.display, "Display" ) - main_widget.setStyleSheet('QPushButton {background-color: rgb(40, 60, 75)} QCheckBox::indicator {background-color: rgb(40,52,65)}') + self.main_gui.setStyleSheet('QPushButton {background-color: rgb(40, 60, 75)} QCheckBox::indicator {background-color: rgb(40,52,65)}') - self.viewer.window.add_dock_widget( main_widget, name="Main" ) + self.viewer.window.add_dock_widget( self.main_gui, name="Main" ) def key_bindings(self): - """ Activate shortcurs """ + """ Activate shortcuts """ self.text = "-------------- ShortCuts -------------- \n " - self.text = self.text + "If Segmentation layer is active: \n" - self.text = self.text + " <h> show/next/hide this help message \n" - self.text = self.text + " <a> show ALL shortcuts in separate window \n" - self.text = self.text + " <s> save the updated segmentation \n" - self.text = self.text + " <Shift-s> save the movie with current display \n" + self.text += "!! Shortcuts work if Segmentation layer is active !! \n" + #for sctype, scvals in self.shortcuts.items(): + self.text += "\n---"+"General"+" options---\n" + sg = self.shortcuts["General"] + self.text += ut.print_shortcuts( sg ) self.text = self.text + "\n" if self.verbose > 0: print("Activating key shortcuts on segmentation layer") - print("Press several times <h> to show all the shortcuts list or hide it") + print("Press <" + str(sg["show help"]["key"]) + "> to show/hide the main shortcuts") + print("Press <" + str(sg["show all"]["key"]) + "> to show ALL shortcuts") ut.setOverlayText(self.viewer, self.text, size=10) - @self.seglayer.bind_key('h', overwrite=True) + @self.seglayer.bind_key( sg["show help"]["key"], overwrite=True ) def switch_shortcuts(seglayer): - index = (self.help_index+1)%(len(self.overtext.keys())+1) - self.switchOverlayText(index) + #index = (self.help_index+1)%(len(self.overtext.keys())+1) + #self.switchOverlayText(index) + index = (self.help_index+1)%2 + self.switchOverlayText( index ) - @self.seglayer.bind_key('a', overwrite=True) + @self.seglayer.bind_key( sg["show all"]["key"], overwrite=True ) def list_all_shortcuts(seglayer): self.switchOverlayText(0) ## hide display message in main window text = "**************** EPICURE *********************** \n" @@ -284,11 +373,11 @@ class EpiCure(): text += val self.update_text_window(text) - @self.seglayer.bind_key('s', overwrite=True) + @self.seglayer.bind_key( sg["save segmentation"]["key"], overwrite=True ) def save_seglayer(seglayer): self.save_epicures() - @self.viewer.bind_key('Shift-s', overwrite=True) + @self.viewer.bind_key( sg["save movie"]["key"], overwrite=True ) def save_movie(seglayer): endname = "_frames.tif" outname = os.path.join( self.outdir, self.imgname+endname ) @@ -304,7 +393,8 @@ class EpiCure(): return else: ut.showOverlayText(self.viewer, vis=True) - self.setCurrentOverlayText() + #self.setCurrentOverlayText() + self.setGeneralOverlayText() def init_text_window(self): """ Create and display help text window """ @@ -314,10 +404,14 @@ class EpiCure(): def update_text_window(self, message): """ Update message in separate window """ - if self.blabla is None: - self.init_text_window() + self.init_text_window() self.blabla.value = message + def setGeneralOverlayText(self): + """ set overlay help message to general message """ + text = self.text + ut.setOverlayText(self.viewer, text, size=10) + def setCurrentOverlayText(self): """ Set overlay help text message to current selected options list """ text = self.text @@ -345,7 +439,8 @@ class EpiCure(): summ += "Nb cells: "+str( nb_labels )+"\n" summ += "Average track lengths: "+str(mean_duration)+" frames\n" summ += "Average cell area: "+str(mean_area)+" pixels^2\n" - summ += "Nb suspect tracks: "+str(self.suspecting.nb_suspects())+"\n" + summ += "Nb suspect events: "+str(self.inspecting.nb_events(only_suspect=True))+"\n" + summ += "Nb divisions: "+str(self.inspecting.nb_type("division"))+"\n" summ += "\n" summ += "--- Parameter infos \n" summ += "Junction thickness: "+str(self.thickness)+"\n" @@ -362,10 +457,10 @@ class EpiCure(): if self.verbose > 0: print("Reput shape layer") self.editing.create_shapelayer() - if self.suspecting.suspectlayer_name not in self.viewer.layers: + if self.inspecting.eventlayer_name not in self.viewer.layers: if self.verbose > 0: - print("Reput suspect layer") - self.suspecting.create_suspectlayer() + print("Reput event layer") + self.inspecting.create_eventlayer() if "Movie" not in self.viewer.layers: if self.verbose > 0: print("Reput movie layer") @@ -390,43 +485,51 @@ class EpiCure(): for dlay in duplayers: if dlay in self.viewer.layers: (self.viewer.layers[dlay]).refresh() - + + def read_epicure_metadata( self ): + """ Load saved infos from file """ + epiname = self.outname() + "_epidata.pkl" + if os.path.exists( epiname ): + infile = open(epiname, "rb") + try: + epidata = pickle.load( infile ) + if "EpiMetaData" in epidata.keys(): + self.epi_metadata = epidata["EpiMetaData"] + infile.close() + except: + ut.show_warning( "Could not read EpiCure data file "+epiname ) + + def save_epicures( self, imtype="float32" ): outname = os.path.join( self.outdir, self.imgname+"_labels.tif" ) - ut.writeTif(self.seg, outname, self.scale, imtype, what="Segmentation") + ut.writeTif(self.seg, outname, self.epi_metadata["ScaleXY"], imtype, what="Segmentation") epiname = os.path.join( self.outdir, self.imgname+"_epidata.pkl" ) outfile = open(epiname, "wb") + epidata = {} + epidata["EpiMetaData"] = self.epi_metadata if self.groups is not None: - pickle.dump(self.groups, outfile) - else: - pickle.dump({}, outfile) + epidata["Group"] = self.groups if self.tracking.graph is not None: - pickle.dump(self.tracking.graph, outfile) - else: - pickle.dump({}, outfile) - if self.suspecting is not None and self.suspecting.suspects is not None: - if self.suspecting.suspects.data is not None: - pickle.dump(self.suspecting.suspects.data, outfile) - else: - pickle.dump(None, outfile) - pickle.dump(self.suspecting.suspects.properties, outfile) - pickle.dump(self.suspecting.suspicions, outfile) - pickle.dump(self.suspecting.suspects.symbol, outfile) - pickle.dump(self.suspecting.suspects.face_color, outfile) + epidata["Graph"] = self.tracking.graph + if self.inspecting is not None and self.inspecting.events is not None: + epidata["Events"] = {} + if self.inspecting.events.data is not None: + epidata["Events"]["Points"] = self.inspecting.events.data + epidata["Events"]["Props"] = self.inspecting.events.properties + epidata["Events"]["Types"] = self.inspecting.event_types + epidata["Events"]["Symbols"] = self.inspecting.events.symbol + epidata["Events"]["Colors"] = self.inspecting.events.face_color + pickle.dump( epidata, outfile ) outfile.close() - def read_group_data( self, infile ): + def read_group_data( self, groups ): """ Read the group EpiCure data from opened file """ - try: - groups = pickle.load(infile) - if self.verbose > 0: - print("Loaded cell groups info: "+str(groups)) - return groups - except: - if self.verbose > 1: - print("No group infos found") - return None - + if self.verbose > 0: + print( "Loaded cell groups info: "+str(list(groups.keys())) ) + if self.verbose > 2: + print( "Cell groups: "+str(groups) ) + return groups + def read_graph_data( self, infile ): """ Read the graph EpiCure data from opened file """ try: @@ -439,47 +542,100 @@ class EpiCure(): print("No graph infos found") return None - def read_suspicions_data(self, infile): - """ Read info of EpiCure suspicions from opened file """ + def read_events_data(self, infile): + """ Read info of EpiCure events (suspects, divisions) from opened file """ try: - suspects_pts = pickle.load(infile) - if suspects_pts is not None: - suspects_props = pickle.load(infile) - suspicions = pickle.load(infile) + events_pts = pickle.load(infile) + if events_pts is not None: + events_props = pickle.load(infile) + events_type = pickle.load(infile) try: symbols = pickle.load(infile) colors = pickle.load(infile) except: if self.verbose > 1: - print("No suspects display info found") + print("No events display info found") symbols = None colors = None - return suspects_pts, suspects_props, suspicions, symbols, colors + return events_pts, events_props, events_type, symbols, colors else: return None, None, None, None, None except: if self.verbose > 1: - print("Suspects info not complete") + print("events info not complete") return None, None, None, None, None def load_epicure_data(self, epiname): """ Load saved infos from file """ infile = open(epiname, "rb") + try: + epidata = pickle.load( infile ) + if "EpiMetaData" in epidata.keys(): + # version of epicure file after Epicure 0.2.0 + self.read_epidata( epidata ) + infile.close() + else: + # version anterior of Epicure 0.2.0 + self.load_epicure_data_old( epidata, infile ) + except: + ut.show_warning( "Could not read EpiCure data file "+epiname ) + + def read_epidata( self, epidata ): + """ Read the dict of saved state and initialize all instances with it """ + for key, vals in epidata.items(): + if key == "EpiMetaData": + ## image data is read on the previous step + continue + if key == "Group": + ## Load groups information + self.groups = self.read_group_data( vals ) + for group in self.groups.keys(): + self.editing.update_group_list( group ) + self.outputing.update_selection_list() + if key == "Graph": + ## Load graph (lineage) informations + self.tracking.graph = vals + if self.tracking.graph is not None: + self.tracking.tracklayer.refresh() + if key == "Events": + ## Load events information + if "Points" in vals.keys(): + pts = vals["Points"] + if "Props" in vals.keys(): + props = vals["Props"] + if "Types" in vals.keys(): + event_types = vals["Types"] + if "Symbols" in vals.keys(): + symbols = vals["Symbols"] + if "Colors" in vals.keys(): + colors = vals["Colors"] + if pts is not None: + if len(pts) > 0: + self.inspecting.load_events(pts, props, event_types, symbols, colors) + if len(pts) > 0 and self.verbose > 0: + print("events loaded") + ut.show_info("Loaded "+str(len(pts))+" events") + + + def load_epicure_data_old( self, groups, infile ): + """ Load saved infos from file """ ## Load groups information - self.groups = self.read_group_data( infile ) + self.groups = self.read_group_data( groups ) + for group in self.groups.keys(): + self.editing.update_group_list( group ) self.outputing.update_selection_list() ## Load graph (lineage) informations self.tracking.graph = self.read_graph_data( infile ) if self.tracking.graph is not None: self.tracking.tracklayer.refresh() - ## Load suspects information - pts, props, suspicions, symbols, colors = self.read_suspicions_data( infile ) + ## Load events information + pts, props, event_types, symbols, colors = self.read_events_data( infile ) if pts is not None: if len(pts) > 0: - self.suspecting.load_suspects(pts, props, suspicions, symbols, colors) + self.inspecting.load_events(pts, props, event_types, symbols, colors) if len(pts) > 0 and self.verbose > 0: - print("Suspects loaded") - ut.show_info("Loaded "+str(len(pts))+" suspects") + print("events loaded") + ut.show_info("Loaded "+str(len(pts))+" events") infile.close() def save_movie(self, outname): @@ -510,7 +666,7 @@ class EpiCure(): def reset_data( self ): """ Reset EpiCure data (group, suspect, graph) """ - self.suspecting.reset_all_suspects() + self.inspecting.reset_all_events() self.reset_groups() self.outputing.update_selection_list() self.tracking.graph = None @@ -627,6 +783,10 @@ class EpiCure(): def has_label(self, label): """ Check if label is present in the tracks """ return self.tracking.has_track(label) + + def has_labels(self, labels): + """ Check if labels are present in the tracks """ + return self.tracking.has_tracks( labels ) def nlabels(self): """ Number of unique tracks """ @@ -642,11 +802,11 @@ class EpiCure(): self.tracking.remove_tracks( tracks ) def delete_track(self, label, frame=None): - """ Remove the track """ + """ Remove (part of) the track """ if frame is None: self.tracking.remove_track(label) else: - self.tracking.remove_one_frame(label, frame) + self.tracking.remove_one_frame(label, frame, handle_gaps=self.forbid_gaps ) def update_centroid(self, label, frame): """ Track label has been change at given frame """ @@ -736,7 +896,23 @@ class EpiCure(): movie[ i, xminshift:xmaxshift, yminshift:ymaxshift ] = self.img[ frame, xmin:xmax, ymin:ymax ] return movie + ### Check individual cell features + def cell_radius( self, label, frame ): + """ Approximate the cell radius at given frame """ + area = np.sum( self.seg[frame] == label ) + radius = math.sqrt(area/math.pi) + return radius + + def cell_area( self, label, frame ): + """ Approximate the cell radius at given frame """ + area = np.sum( self.seg[frame] == label ) + return area + def cell_on_border( self, label, frame ): + """ Check if a given cell is on border of the image """ + bbox = ut.getBBox2D( self.seg[frame], label ) + out = ut.outerBBox2D( bbox, self.imgshape2D, margin=3 ) + return out ###### Synchronize tracks whith labels changed def add_label( self, labels, frame=None ): @@ -765,29 +941,31 @@ class EpiCure(): def update_changed_labels( self, indmodif, new_labels, old_labels ): """ Check what had been modified, and update tracks from it """ ## check all the old_labels if still present or not - min_frame = np.min(indmodif[0]) - max_frame = np.max(indmodif[0]) - start_time = time.time() + if self.verbose > 1: + start_time = time.time() + frames = np.sort( np.unique( indmodif[0] ) ) all_deleted = [] - for frame in range(min_frame, max_frame+1): - if self.verbose > 1: - print("Updating labels at frame "+str(frame)) - keep = np.where(indmodif[0] == frame)[0] - nlabels = np.unique(new_labels[keep]) - olabels = np.unique(old_labels[keep]) + debug_verb = self.verbose > 2 + if debug_verb: + print( "Updating labels in frames "+str(frames) ) + for frame in frames: + keep = indmodif[0] == frame ## check old labels if totally removed or not - deleted = np.setdiff1d( olabels, self.seg[frame] ) + deleted = np.setdiff1d( old_labels[keep], self.seg[frame] ) if deleted.shape[0] > 0: self.tracking.remove_one_frame( deleted, frame, handle_gaps=False, refresh=False ) all_deleted = all_deleted + list(set(deleted) - set(all_deleted)) + ## now check new labels + nlabels = np.unique( new_labels[keep] ) if nlabels.shape[0] > 0: self.tracking.update_track_on_frame( nlabels, frame ) - if self.verbose > 1: - print("Labels deleted "+str(deleted)+" or added "+str(nlabels)) + if debug_verb: + print("Labels deleted at frame "+str(frame)+" "+str(deleted)+" or added "+str(nlabels)) ## Check if some gaps has been created in tracks (remove middle(s) frame(s)) - if len(all_deleted) > 0: - self.handle_gaps( all_deleted, verbose=0 ) + if self.forbid_gaps: + if len(all_deleted) > 0: + self.handle_gaps( all_deleted, verbose=0 ) if self.verbose > 1: ut.show_duration(start_time, "updated tracks in ") @@ -854,7 +1032,7 @@ class EpiCure(): if frame is None: self.tracking.remove_tracks( deleted_labels ) else: - self.tracking.remove_one_frame( track_id=deleted_labels.tolist(), frame=frame, handle_gaps=True) + self.tracking.remove_one_frame( track_id=deleted_labels.tolist(), frame=frame, handle_gaps=self.forbid_gaps ) def remove_label(self, label, force=False): """ Remove a given label if allowed """ @@ -884,9 +1062,13 @@ class EpiCure(): ut.setNewLabel(self.seglayer, inds, 0) self.tracking.remove_tracks(toremove) - def get_frame_features( self, frame, props ): + def get_frame_features( self, frame ): """ Measure the label properties of given frame """ - return regionprops_table( self.seg[frame], properties=props ) + return regionprops( self.seg[frame] ) + + def updates_after_tracking( self ): + """ When tracking has been done, update events, others """ + self.inspecting.get_divisions() ####################### ## Classified cells options @@ -919,19 +1101,19 @@ class EpiCure(): groups[ind] = gr return groups - def cell_ingroup(self, label, group): + def cells_ingroup(self, labels, group): """ Put the cell "label" in group group, add it if new group """ - if not self.has_label(label): - if self.verbose > 1: - print("Cell "+str(label)+" missing") - return + presents = self.has_labels( labels ) + labels = np.array(labels)[ presents ] if group not in self.groups.keys(): self.groups[group] = [] if self.outputing is not None: self.outputing.update_selection_list() - if label not in self.groups[group]: - self.groups[group].append(label) - + self.editing.update_group_list( group ) + ## add only non present label(s) + grlabels = self.groups[ group ] + self.groups[ group ] = list( set( grlabels + labels.tolist()) ) + def find_group(self, label): """ Find in which group the label is """ for gr, labs in self.groups.items(): @@ -939,7 +1121,6 @@ class EpiCure(): return gr return None - def cell_removegroup(self, label): """ Detach the cell from its group """ if not self.has_label(label): @@ -959,12 +1140,12 @@ class EpiCure(): def draw_groups(self): """ Draw all the epicells colored by their group """ - grouped = np.zeros(self.seg.shape, np.uint8) + grouped = np.zeros( self.seg.shape, np.uint8 ) if (self.groups is None) or len(self.groups.keys()) == 0: return grouped for group, labels in self.groups.items(): igroup = self.get_group_index(group) + 1 - np.place(grouped, np.isin(self.seg, labels), igroup) + np.place( grouped, np.isin( self.seg, labels ), igroup ) return grouped def get_group_index(self, group): @@ -972,9 +1153,7 @@ class EpiCure(): igroup = (list(self.groups.keys())).index(group) return igroup - ######### ROI - def only_current_roi(self, frame): """ Put 0 everywhere outside the current ROI """ roi_labels = self.editing.get_labels_inside() diff --git a/src/epicure/epiwidgets.py b/src/epicure/epiwidgets.py new file mode 100644 index 0000000000000000000000000000000000000000..e9cd9c23376024b8792ea78302cd8150adfb5499 --- /dev/null +++ b/src/epicure/epiwidgets.py @@ -0,0 +1,266 @@ +import epicure.Utils as ut +from qtpy.QtWidgets import QPushButton, QCheckBox, QHBoxLayout, QLabel, QLineEdit, QComboBox, QSpinBox, QSlider, QGroupBox +from qtpy.QtCore import Qt + +def help_button( link, description="", display_settings=None ): + """ Create a new Help button with given parameter """ + def show_doc(): + """ Open documentation page """ + ut.show_documentation_page( link ) + + help_btn = QPushButton( "help" ) + if description == "": + help_btn.setToolTip( "Open EpiCure documentation" ) + help_btn.setStatusTip( "Open EpiCure documentation" ) + else: + help_btn.setToolTip( description ) + help_btn.setStatusTip( description ) + help_btn.clicked.connect( show_doc ) + if display_settings is not None: + if "Help button" in display_settings: + color = display_settings["Help button"] + help_btn.setStyleSheet( 'QPushButton {background-color: '+color+'}' ) + return help_btn + +def checkgroup_help( name, checked, descr, help_link, display_settings=None, groupnb=None ): + """ Create a group that can be show/hide with checkbox and an help button """ + group = QGroupBox( name ) + chbox = QCheckBox( text=name ) + + ## set group and checkbox to the same specific color + if (groupnb is not None) and (display_settings is not None): + if groupnb in display_settings: + color = display_settings[groupnb] + group.setStyleSheet( 'QGroupBox {background-color: '+color+'}' ) + chbox.setStyleSheet( 'QCheckBox::indicator {background-color: '+color+'}' ) + + def show_hide(): + group.setVisible( chbox.isChecked() ) + + line = QHBoxLayout() + ## create checkbox + chbox.setToolTip( descr ) + line.addWidget( chbox ) + chbox.stateChanged.connect( show_hide ) + chbox.setChecked( checked ) + ## create button + if help_link is not None: + help_btn = help_button( help_link, "", display_settings ) + line.addWidget( help_btn ) + return line, chbox, group + +def checkhelp_line( checkbox_name, checked, checkfunc, check_descr, help_link, display_settings=None, help_descr="" ): + """ Create a layout line with a checkbox associated with help button """ + line = QHBoxLayout() + ## create checkbox + chbox = QCheckBox( text=checkbox_name ) + chbox.setToolTip( check_descr ) + line.addWidget( chbox ) + if checkfunc is not None: + chbox.stateChanged.connect( checkfunc ) + chbox.setChecked( checked ) + ## create button + help_btn = help_button( help_link, help_descr, display_settings ) + line.addWidget( help_btn ) + return line, chbox + +def add_check( check, checked, check_func=None, descr="" ): + """ Add a checkbox with set parameters """ + cbox = QCheckBox( text=check ) + cbox.setToolTip( descr ) + if check_func is not None: + cbox.stateChanged.connect( check_func ) + cbox.setChecked( checked ) + return cbox + +def double_check( checka, checkeda, funca, descra, checkb, checkedb, funcb, descrb ): + """ Line with two customized checkboxes """ + line = QHBoxLayout() + check_a = add_check( checka, checkeda, funca, descra ) + check_b = add_check( checkb, checkedb, funcb, descrb ) + line.addWidget( check_a ) + line.addWidget( check_b ) + return line, check_a, check_b + +def add_button( btn, btn_func, descr="", color=None ): + """ Add a button connected to an action when pushed """ + btn = QPushButton( btn ) + if btn_func is not None: + btn.clicked.connect( btn_func ) + if descr != "": + btn.setToolTip( descr ) + else: + btn.setToolTip( "Click to perform action" ) + if color is not None: + btn.setStyleSheet( 'QPushButton {background-color: '+color+'}' ) + return btn + +def double_button( btna, funca, descra, btnb, funcb, descrb ): + """ Line with two customized buttons """ + line = QHBoxLayout() + btn_a = add_button( btna, funca, descra ) + btn_b = add_button( btnb, funcb, descrb ) + line.addWidget( btn_a ) + line.addWidget( btn_b ) + return line + +def button_parameter_line( btn, btn_func, value, descr_btn="", descr_value="" ): + """ Create a layout with a button and an editable value associated """ + line = QHBoxLayout() + ## Action button + btn = QPushButton( btn ) + btn.clicked.connect( btn_func ) + if descr_btn != "": + btn.setToolTip( descr_btn ) + line.addWidget( btn ) + ## Value editable + val = QLineEdit() + val.setText( value ) + line.addWidget( val ) + if descr_value != "": + val.setToolTip( descr_value ) + return line, val + +def min_button_max( btn, btn_func, min_val, max_val, descr="" ): + """ Button inside two values (min and max) interfaces """ + line = QHBoxLayout() + ## left value + minv = QLineEdit() + minv.setText( min_val ) + line.addWidget( minv ) + ## button + btn = QPushButton( btn ) + btn.clicked.connect( btn_func ) + if descr != "": + btn.setToolTip( descr ) + line.addWidget( btn ) + ## right value + maxv = QLineEdit() + maxv.setText( max_val ) + line.addWidget( maxv ) + return line, minv, maxv + + +def button_check_line( btn, btn_func, check, checked=False, checkfunc=None, descr_btn="", descr_check="", leftbtn=True ): + """ Create a layout with a button and an assiociated checkbox """ + line = QHBoxLayout() + ## Action button + btn = QPushButton( btn ) + btn.clicked.connect( btn_func ) + if descr_btn != "": + btn.setToolTip( descr_btn ) + ## Value editable + cbox = QCheckBox( check ) + if descr_check != "": + cbox.setToolTip( descr_check ) + if checkfunc is not None: + cbox.stateChanged.connect( checkfunc ) + cbox.setChecked( checked ) + ## button first (left), then checkbox + if leftbtn: + line.addWidget( btn ) + line.addWidget( cbox ) + else: + ## or checkbox first (left), then button + line.addWidget( cbox ) + line.addWidget( btn ) + return line, cbox + +def value_line( label, default_value, descr="" ): + """ Create a layout line with a value to edit (non editable name + value part ) """ + line = QHBoxLayout() + ## Value name + lab = QLabel() + lab.setText( label ) + line.addWidget( lab ) + if descr != "": + lab.setToolTip( descr ) + ## Value editable part + value = QLineEdit() + value.setText( default_value ) + line.addWidget( value ) + return line, value + +def check_value( check, checkfunc=None, checked=False, value="0", descr="", label=None ): + """ Line with a checkbox and an associated editable parameter """ + line = QHBoxLayout() + ## add checkbox + cbox = add_check( check, checked=checked, check_func=checkfunc, descr=descr ) + line.addWidget( cbox ) + ## add eventually a text + if label is not None: + lab = QLabel() + lab.setText( label ) + line.addWidget( lab ) + ## add the editable value + val = QLineEdit() + val.setText( value ) + line.addWidget( val ) + return line, cbox, val + +def ranged_value_line( label, minval, maxval, step, val, descr="" ): + """ Create a line with a label and a ranged value (limited between min and max) """ + line = QHBoxLayout() + ## Add the name of the value + lab = QLabel() + lab.setText( label ) + if descr != "": + lab.setToolTip( descr ) + line.addWidget( lab ) + ## Ranged-value widget + ranged_val = QSpinBox() + ranged_val.setMinimum( minval ) + ranged_val.setMaximum( maxval ) + ranged_val.setSingleStep( step ) + ranged_val.setValue( val ) + line.addWidget( ranged_val ) + return line, ranged_val + +def button_list( btn, func, descr ): + """ Button associated with a list """ + line = QHBoxLayout() + ## Button part + button = add_button( btn, func, descr ) + line.addWidget( button ) + ## list part + li = QComboBox() + line.addWidget( li ) + return line, li + +def list_line( label, descr="", func=None ): + """ Create a layout line with a choice list to edit (non editable name + list part ) """ + line = QHBoxLayout() + ## Value name + lab = QLabel() + lab.setText( label ) + line.addWidget( lab ) + if descr != "": + lab.setToolTip( descr ) + lab.setStatusTip( descr ) + ## Value editable part + value = QComboBox() + line.addWidget( value ) + if func is not None: + value.currentIndexChanged.connect( func ) + return line, value + +def slider_line( name, minval, maxval, step, value, slidefunc=None, descr="" ): + """ Line with a text and a slider """ + line = QHBoxLayout() + ## add name if any + if name is not None: + lab = QLabel() + lab.setText( name ) + line.addWidget( lab ) + ## add slider + slider = QSlider( Qt.Horizontal ) + slider.setMinimum( minval ) + slider.setMaximum( maxval ) + slider.setSingleStep( step ) + slider.setValue( value ) + if slidefunc is not None: + slider.valueChanged.connect( slidefunc ) + if descr != "": + slider.setToolTip( descr ) + line.addWidget( slider ) + return line, slider diff --git a/src/epicure/inspecting.py b/src/epicure/inspecting.py new file mode 100644 index 0000000000000000000000000000000000000000..7696473249ad92bca2c6ff322bd79b046a22b611 --- /dev/null +++ b/src/epicure/inspecting.py @@ -0,0 +1,1203 @@ +import numpy as np +from skimage import filters +from skimage.measure import regionprops, label +from skimage.morphology import binary_erosion, binary_dilation, disk +from qtpy.QtWidgets import QVBoxLayout, QHBoxLayout, QWidget, QLabel, QComboBox +from napari.utils import progress +import epicure.Utils as ut +import epicure.epiwidgets as wid +import time + +""" + EpiCure - Inspection interface + Handle supects, events detection layer +""" + +class Inspecting(QWidget): + + def __init__(self, napari_viewer, epic): + super().__init__() + self.viewer = napari_viewer + self.epicure = epic + self.seglayer = self.viewer.layers["Segmentation"] + self.border_cells = None ## list of cells that are on the border (touch the background) + self.eventlayer_name = "Events" + self.events = None + self.win_size = 10 + + ## Print the current number of events + self.nevents_print = QLabel("") + self.update_nevents_display() + + self.create_eventlayer() + layout = QVBoxLayout() + layout.addWidget( self.nevents_print ) + + show_line, self.show_divisions, self.show_suspects = wid.double_check( "Show divisions", True, None, "Show/hide the division events", "Show suspects", True, None, "Show/hide suspect events" ) + layout.addLayout( show_line ) + self.show_divisions.stateChanged.connect( self.show_hide_divisions ) + self.show_suspects.stateChanged.connect( self.show_hide_suspects ) + + ## Handle division events + #div_line, self.show_divisions = wid.button_check_line( btn="Update divisions", btn_func=self.get_divisions, check="Show divisions", checked=True, #checkfunc=None, descr_btn="Update the list of division events from the track graph", descr_check="Show/hide the division events", leftbtn=False ) + update_div_btn = wid.add_button( btn="Update divisions from graph", btn_func=self.get_divisions, descr="Update the list of division events from the track graph" ) + layout.addWidget(update_div_btn) + #self.show_divisions.stateChanged.connect( self.show_hide_divisions ) + + ### Reset: delete all events + reset_color = self.epicure.get_resetbtn_color() + reset_event_btn = wid.add_button( btn="Reset all events", btn_func=self.reset_all_events, descr="Delete all current events", color=reset_color ) + layout.addWidget(reset_event_btn) + + ## Error suggestions based on cell features + outlier_line, self.outlier_vis, self.featOutliers = wid.checkgroup_help( "Outlier options", False, "Show/Hide outlier options panel", "event#frame-based-events", self.epicure.display_colors, "group" ) + layout.addLayout( outlier_line ) + self.create_outliersBlock() + layout.addWidget(self.featOutliers) + + ## Error suggestions based on tracks + track_line, self.track_vis, self.eventTrack = wid.checkgroup_help( "Track options", True, "Show/hide track options", "event#track-based-events", self.epicure.display_colors, "group2" ) + self.create_tracksBlock() + layout.addLayout( track_line ) + layout.addWidget(self.eventTrack) + + ## Visualisation options + disp_line, self.event_disp, self.displayevent = wid.checkgroup_help( "Display options", False, "Show/hide event display options panel", "event#visualisation", self.epicure.display_colors, "group3" ) + self.create_displayeventBlock() + layout.addLayout( disp_line ) + layout.addWidget(self.displayevent) + + self.setLayout(layout) + self.key_binding() + + def key_binding(self): + """ active key bindings for events options """ + sevents = self.epicure.shortcuts["Events"] + self.epicure.overtext["events"] = "---- Events editing ---- \n" + self.epicure.overtext["events"] += ut.print_shortcuts( sevents ) + + @self.epicure.seglayer.mouse_drag_callbacks.append + def handle_event(seglayer, event): + if event.type == "mouse_press": + ## remove a event + if ut.shortcut_click_match( sevents["delete"], event ): + ind = ut.getCellValue( self.events, event ) + if self.epicure.verbose > 1: + print("Removing clicked event, at index "+str(ind)) + if ind is None: + ## click was not on a event + return + sid = self.events.properties["id"][ind] + if sid is not None: + self.exonerate_one(ind, remove_division=True) + self.update_nevents_display() + else: + if self.epicure.verbose > 1: + print("event with id "+str(sid)+" not found") + self.events.refresh() + return + + ## zoom on a event + if ut.shortcut_click_match( sevents["zoom"], event ): + ind = ut.getCellValue( self.events, event ) + if "id" not in self.events.properties.keys(): + print("No event under click") + return + sid = self.events.properties["id"][ind] + if self.epicure.verbose > 1: + print("Zoom on event with id "+str(sid)+"") + self.zoom_on_event( event.position, sid ) + return + + @self.epicure.seglayer.bind_key( sevents["next"]["key"], overwrite=True ) + def go_next(seglayer): + """ Select next suspect event and zoom on it """ + num_event = int(self.event_num.value()) + nevents = self.nb_events() + if num_event < 0: + if self.event_sizenb_events( only_suspect=True ) == 0: + if self.epicure.verbose > 0: + print("No more suspect event") + return + else: + self.event_num.setValue(0) + else: + self.event_num.setValue( (num_event+1)%nevents ) + self.skip_nonsuspect_event( nevents, nevents ) + self.go_to_event() + + def skip_nonsuspect_event( self, nevents, left ): + """ Skip next event if not a suspect one (eg division) """ + index = int(self.event_num.value()) + if self.is_division( index ): + index = (index + 1)%nevents + self.event_num.setValue( index ) + self.skip_nonsuspect_event( nevents, left-1 ) + if left < 0: + return + + def create_eventlayer(self): + """ Create a point layer that contains the events """ + features = {} + pts = [] + self.events = self.viewer.add_points( np.array(pts), properties=features, face_color="red", size = 10, symbol='x', name=self.eventlayer_name, ) + self.event_types = {} + self.update_nevents_display() + self.epicure.finish_update() + + def load_events(self, pts, features, event_types, symbols=None, colors=None): + """ Load events data from file and reinitialize layer with it""" + ut.remove_layer(self.viewer, self.eventlayer_name) + if symbols is None: + symbols = "x" + if colors is None: + colors = "red" + symb = symbols + self.events = self.viewer.add_points( np.array(pts), properties=features, face_color=colors, size = 10, symbol=symbols, name=self.eventlayer_name, ) + self.event_types = event_types + self.update_nevents_display() + self.epicure.finish_update() + + + ############### Display event options + def get_event_types( self ): + """ Returns the list of possible event types """ + return list( self.event_types.keys() ) + + def update_nevents_display( self ): + """ Update the display of number of event""" + text = str(self.nb_events(only_suspect=True))+" suspects \n" + text += str(self.nb_type("division"))+" divisions" + self.nevents_print.setText( text ) + + def nb_events( self, only_suspect=False ): + """ Returns current number of events """ + if self.events is None: + return 0 + if self.events.properties is None: + return 0 + if "score" not in self.events.properties: + return 0 + if not only_suspect: + return len(self.events.properties["score"]) + return ( len(self.events.properties["score"]) - self.nb_type("division") ) + + def get_events_from_type( self, feature ): + """ Return the list of events of a given type """ + if feature == "suspect": + sub_features = self.suspect_subtypes() + evts_id = [] + for feat in sub_features: + evts_id.extend( eid for eid in self.event_types[ feat ] if eid not in evts_id ) + return list( evts_id ) + if feature in self.event_types: + return self.event_types[ feature ] + return [] + + def nb_type( self, feature ): + """ Return nb of event of given type """ + if self.events is None: + return 0 + if (self.event_types is None) or (feature not in self.event_types): + return 0 + return len(self.event_types[feature]) + + def create_displayeventBlock(self): + ''' Block interface of displaying event layer options ''' + disp_layout = QVBoxLayout() + + ## Color mode + colorlay = QHBoxLayout() + color_label = QLabel() + color_label.setText("Color by:") + colorlay.addWidget(color_label) + self.color_choice = QComboBox() + colorlay.addWidget(self.color_choice) + self.color_choice.addItem("None") + self.color_choice.addItem("score") + self.color_choice.addItem("tracking-2->1") + self.color_choice.addItem("tracking-1-2-*") + self.color_choice.addItem("track-length") + self.color_choice.addItem("division") + self.color_choice.addItem("area") + self.color_choice.addItem("solidity") + self.color_choice.addItem("intensity") + self.color_choice.addItem("tubeness") + self.color_choice.currentIndexChanged.connect(self.color_events) + disp_layout.addLayout(colorlay) + + sizelay, self.event_size = wid.slider_line( "Point size:", minval=0, maxval=50, step=1, value=10, slidefunc=self.display_event_size, descr="Choose the current point size display" ) + disp_layout.addLayout(sizelay) + + ### Interface to select a event and zoom on it + chooselay, self.event_num = wid.ranged_value_line( label="event n°", minval=0, maxval=1000000, step=1, val=0, descr="Choose current event to display/remove" ) + disp_layout.addLayout(chooselay) + go_event_btn = wid.add_button( "Go to event", self.go_to_event, "Zoom and display current event" ) + disp_layout.addWidget(go_event_btn) + clear_event_btn = wid.add_button( "Remove current event", self.clear_event, "Delete current event from the list of events" ) + disp_layout.addWidget(clear_event_btn) + + ## all features + self.displayevent.setLayout(disp_layout) + self.displayevent.setVisible( self.event_disp.isChecked() ) + + ##### + def reset_event_range(self): + """ Reset the max num of event """ + nsus = len(self.events.data)-1 + if self.event_num.value() > nsus: + self.event_num.setValue(0) + self.event_num.setMaximum(nsus) + + def go_to_event(self): + """ Zoom on the currently selected event """ + num_event = int(self.event_num.value()) + ## if reached the end of possible events + if num_event >= self.nb_events(): + num_event = 0 + self.event_num.setValue(0) + if num_event < 0: + if self.nb_events() == 0: + if self.epicure.verbose > 0: + print("No more event") + return + else: + self.event_num.setValue(0) + num_event = 0 + pos = self.events.data[num_event] + event_id = self.events.properties["id"][num_event] + self.zoom_on_event( pos, event_id ) + + def get_event_infos( self, sid ): + """ Get the properties of the event of given id """ + index = self.index_from_id( sid ) + pos = self.events.data[ index ] + label = self.events.properties[ "label" ][index] + return pos, label + + def zoom_on_event( self, event_pos, event_id ): + """ Zoom on chose event at given position """ + self.viewer.camera.center = event_pos + self.viewer.camera.zoom = 5 + self.viewer.dims.set_point( 0, int(event_pos[0]) ) + crimes = self.get_crimes(event_id) + if self.epicure.verbose > 0: + print("Suspected because of: "+str(crimes)) + + def color_events(self): + """ Color points by the selected mode """ + color_mode = self.color_choice.currentText() + self.events.refresh_colors() + if color_mode == "None": + self.events.face_color = "white" + elif color_mode == "score": + self.set_colors_from_properties("score") + else: + self.set_colors_from_event_type(color_mode) + self.events.refresh_colors() + + def suspect_subtypes( self ): + """ Return the list of suspect-related event types """ + features = list( self.event_types.keys() ) + if "division" in features: + features.remove( "division" ) + return features + + def show_subset_event( self, feature, show=True ): + """ Show/hide a subset (type) of event """ + tmp_size = int(self.event_size.value()) + size = 0.1 + if show: + size = tmp_size + ## select the events of corresponding type + self.events.selected_data = {} + if feature == "suspect": + ## take all possible features except non-suspect ones (division, extrusion..) + features = self.suspect_subtypes() + else: + features = [feature] + for feat in features: + self.select_feature_event( feat ) + self.events.current_size = size + ## reset selection and default size + self.events.selected_data = {} + self.events.current_size = tmp_size + self.events.refresh() + + def select_feature_event( self, feature ): + """ Add all event of given feature to currently selected data """ + if feature not in self.event_types: + return + posid = self.event_types[feature] + for sid in posid: + ind = self.index_from_id(sid) + self.events.selected_data.add(ind) + + def set_colors_from_event_type(self, feature): + """ Set colors from given event_type feature (eg area, tracking..) """ + if self.event_types.get(feature) is None: + self.events.face_color="white" + return + posid = self.event_types[feature] + colors = ["white"]*len(self.events.data) + ## change the color of all the positive events for the chosen feature + for sid in posid: + ind = self.index_from_id(sid) + if ind is not None: + colors[ind] = (0.8,0.1,0.1) + self.events.face_color = colors + + def set_colors_from_properties(self, feature): + """ Set colors from given propertie (eg score, label) """ + ncols = (np.max(self.events.properties[feature])) + color_cycle = [] + for i in range(ncols): + color_cycle.append( (0.25+float(i/ncols*0.75), float(i/ncols*0.85), float(i/ncols*0.75)) ) + self.events.face_color_cycle = color_cycle + self.events.face_color = feature + + def update_display(self): + self.events.refresh() + self.color_events() + + def get_current_settings(self): + """ Returns current event widget parameters """ + disp = {} + disp["Point size"] = int(self.event_size.value()) + disp["Outliers ON"] = self.outlier_vis.isChecked() + disp["Track ON"] = self.track_vis.isChecked() + disp["EventDisp ON"] = self.event_disp.isChecked() + disp["Show divisions"] = self.show_divisions.isChecked() + disp["Show suspects"] = self.show_suspects.isChecked() + disp["Ignore border"] = self.ignore_borders.isChecked() + disp["Flag length"] = self.check_length.isChecked() + disp["length"] = self.min_length.text() + disp["Check size"] = self.check_size.isChecked() + disp["Check shape"] = self.check_shape.isChecked() + disp["Get apparitions"] = self.get_apparition.isChecked() + disp["Get disparitions"] = self.get_disparition.isChecked() + disp["threshold disparition"] = self.threshold_disparition.text() + disp["Min area"] = self.min_area.text() + disp["Max area"] = self.max_area.text() + disp["Current frame"] = self.feat_onframe.isChecked() + return disp + + def apply_settings( self, settings ): + """ Set the current state (display, widget) from preferences if any """ + for setting, val in settings.items(): + if setting == "Outliers ON": + self.outlier_vis.setChecked( val ) + if setting == "Track ON": + self.track_vis.setChecked( val ) + if setting =="EventDisp ON": + self.event_disp.setChecked( val ) + if setting == "Point size": + self.event_size.setValue( int(val) ) + #self.display_event_size() + if setting == "Show divisions": + self.show_divisions.setChecked( val ) + self.show_hide_divisions() + if setting == "Show suspects": + self.show_suspects.setChecked( val ) + self.show_hide_suspects() + if setting == "Ignore border": + self.ignore_borders.setChecked( val ) + if setting == "Flag length": + self.check_length.setChecked( val ) + if setting == "length": + self.min_length.setText( val ) + if setting == "Check size": + self.check_size.setChecked( val ) + if setting == "Check shape": + self.check_shape.setChecked( val ) + if setting == "Get apparitions": + self.get_apparition.setChecked( val ) + if setting == "Get disparitions": + self.get_disparition.setChecked( val ) + if setting == "Threshold disparition": + self.threshold_disparition.setText( val ) + if setting == "Min area": + self.min_area.setText( val ) + if setting == "Max area": + self.max_area.setText( val ) + if setting == "Current frame": + self.feat_onframe.setChecked( val ) + + + def display_event_size(self): + """ Change the size of the point display """ + size = int(self.event_size.value()) + self.events.size = size + self.events.refresh() + #### Depend on event type, to update + + ############### eventing functions + def get_crimes(self, sid): + """ For a given event, get its event_type(s) """ + crimes = [] + for feat in self.event_types.keys(): + if sid in self.event_types.get(feat): + crimes.append(feat) + return crimes + + def add_event_type(self, ind, sid, feature): + """ Add 1 to the event_type score for given feature """ + #print(self.event_types) + if self.event_types.get(feature) is None: + self.event_types[feature] = [] + self.event_types[feature].append(sid) + self.events.properties["score"][ind] = self.events.properties["score"][ind] + 1 + + def first_event(self, pos, label, featurename): + """ Addition of the first event (initialize all) """ + ut.remove_layer(self.viewer, "Events") + features = {} + sid = self.new_event_id() + features["id"] = np.array([sid], dtype="uint16") + features["label"] = np.array([label], dtype=self.epicure.dtype) + features["score"] = np.array([0], dtype="uint8") + pts = [pos] + self.events = self.viewer.add_points( np.array(pts), properties=features, face_color="score", size = 10, symbol="x", name="Events", ) + self.add_event_type(0, sid, featurename) + self.events.refresh() + self.update_nevents_display() + + def add_event(self, pos, label, reason, symb="x", color="white", force=False, refresh=True): + """ Add a event to the list, evented by a feature """ + if (not force) and (self.ignore_borders.isChecked()) and (self.border_cells is not None): + tframe = int(pos[0]) + if label in self.border_cells[tframe]: + return + + ## initialise if necessary + if len(self.events.data) <= 0: + self.first_event(pos, label, reason) + return + + self.events.selected_data = [] + + ## look if already evented, then add the charge + num, sid = self.find_event(pos[0], label) + if num is not None: + ## event already in the list. For same crime ? + if self.event_types.get(reason) is not None: + if sid not in self.event_types[reason]: + self.add_event_type(num, sid, reason) + else: + self.add_event_type(num, sid, reason) + else: + ## new event, add to the Point layer + ind = len(self.events.data) + sid = self.new_event_id() + self.events.add(pos) + self.events.properties["label"][ind] = label + self.events.properties["id"][ind] = sid + self.events.properties["score"][ind] = 0 + self.add_event_type(ind, sid, reason) + + self.events.symbol.flags.writeable = True + self.events.current_symbol = symb + self.events.current_face_color = color + if refresh: + self.refresh_events() + + def refresh_events( self ): + """ Refresh event view and text """ + self.events.refresh() + self.update_nevents_display() + self.reset_event_range() + + def new_event_id(self): + """ Find the first unused id """ + sid = 0 + if self.events.properties.get("id") is None: + return 0 + while sid in self.events.properties["id"]: + sid = sid + 1 + return sid + + def reset_all_events(self): + """ Remove all event_types """ + features = {} + pts = [] + ut.remove_layer(self.viewer, "Events") + self.events = self.viewer.add_points( np.array(pts), properties=features, face_color="red", size = 10, symbol='x', name="Events", ) + self.event_types = {} + self.update_nevents_display() + #self.update_nevents_display() + + def reset_event_type(self, feature, frame): + """ Remove all event_types of given feature, for current frame or all if frame is None """ + if self.event_types.get(feature) is None: + return + idlist = self.event_types[feature].copy() + for sid in idlist: + ind = self.index_from_id(sid) + if ind is not None: + if frame is not None: + if int(self.events.data[ind][0]) == frame: + self.event_types[feature].remove(sid) + self.decrease_score(ind) + else: + self.event_types[feature].remove(sid) + self.decrease_score(ind) + self.events.refresh() + self.update_nevents_display() + + def remove_event_types(self, sid): + """ Remove all event_types of given event id """ + for listval in self.event_types.values(): + if sid in listval: + listval.remove(sid) + + def decrease_score(self, ind): + """ Decrease by one score of event at index ind. Delete it if reach 0""" + self.events.properties["score"][ind] = self.events.properties["score"][ind] - 1 + if self.events.properties["score"][ind] == 0: + self.exonerate_one( ind, remove_division=False ) + self.update_nevents_display() + + def index_from_id(self, sid): + """ From event id, find the corresponding index in the properties array """ + for ind, cid in enumerate(self.events.properties["id"]): + if cid == sid: + return ind + return None + + def id_from_index( self, ind ): + """ From event index, returns it id """ + return self.events.properties["id"][ind] + + def find_event(self, frame, label): + """ Find if there is already a event at given frame and label """ + events = self.events.data + events_lab = self.events.properties["label"] + for i, lab in enumerate(events_lab): + if lab == label: + if events[i][0] == frame: + return i, self.events.properties["id"][i] + return None, None + + def init_suggestion(self): + """ Initialize the layer that will contains propostion of tracks/segmentations """ + suggestion = np.zeros(self.seglayer.data.shape, dtype="uint16") + self.suggestion = self.viewer.add_labels(suggestion, blending="additive", name="Suggestion") + + @self.seglayer.mouse_drag_callbacks.append + def click(layer, event): + if event.type == "mouse_press": + if 'Alt' in event.modifiers: + if event.button == 1: + pos = event.position + # alt+left click accept suggestion under the mouse pointer (in all frames) + self.accept_suggestion(pos) + + def accept_suggestion(self, pos): + """ Accept the modifications of the label at position pos (all the label) """ + seglayer = self.viewer.layers["Segmentation"] + label = self.suggestion.data[tuple(map(int, pos))] + found = self.suggestion.data==label + self.exonerate( found, seglayer ) + indices = np.argwhere( found ) + ut.setNewLabel( seglayer, indices, label, add_frame=None ) + self.suggestion.data[self.suggestion.data==label] = 0 + self.suggestion.refresh() + self.update_nevents_display() + + def exonerate_one(self, ind, remove_division=True): + """ Remove one event at index ind """ + self.events.selected_data = [ind] + sid = self.events.properties["id"][ind] + if (remove_division) and (ind in self.event_types["division"]): + self.epicure.tracking.remove_division( self.events.properties["label"][ind] ) + self.events.remove_selected() + self.remove_event_types(sid) + + def clear_event(self): + """ Remove the current event """ + num_event = int(self.event_num.value()) + self.exonerate_one( num_event, remove_division=True ) + self.update_nevents_display() + + def exonerate_from_event(self, event): + """ Remove all events in the corresponding cell of position """ + label = ut.getCellValue( self.seglayer, event ) + if len(self.events.data) > 0: + for ind, lab in enumerate(self.events.properties["label"]): + if lab == label: + if self.events.data[ind][0] == event.position[0]: + self.exonerate_one(ind, remove_division=True) + self.update_nevents_display() + + def exonerate(self, indices, seglayer): + """ Remove events that have been corrected/cleared """ + seglabels = np.unique(seglayer.data[indices]) + selected = [] + if self.events.properties.get("label") is None: + return + for ind, lab in enumerate(self.events.properties["label"]): + if lab in seglabels: + ## label to remove from event list + selected.append(ind) + if len(selected) > 0: + self.events.selected_data = selected + self.events.remove_selected() + self.update_nevents_display() + + + #######################################" + ## Outliers suggestion functions + def show_outlierBlock(self): + self.featOutliers.setVisible( self.outlier_vis.isChecked() ) + + def create_outliersBlock(self): + ''' Block interface of functions for error suggestions based on cell features ''' + feat_layout = QVBoxLayout() + + self.feat_onframe = wid.add_check( check="Only current frame", checked=True, check_func=None, descr="Search for outliers only in current frame" ) + feat_layout.addWidget(self.feat_onframe) + + ## area widget + tarea_layout, self.min_area, self.max_area = wid.min_button_max( btn="< Area (pix^2) <", btn_func=self.event_area_threshold, min_val="0", max_val="2000", descr="Look for cell which size is outside the given area range" ) + feat_layout.addLayout( tarea_layout ) + + ## solid widget + feat_solid_line, self.fsolid_out = wid.button_parameter_line( btn="Solidity outliers", btn_func=self.event_solidity, value="3.0", descr_btn="Search for outliers in solidity value", descr_value="Inter-quartiles range factor to consider outlier" ) + feat_layout.addLayout( feat_solid_line ) + + ## intensity widget + feat_inten_line, self.fintensity_out = wid.button_parameter_line( btn="Intensity cytoplasm/junction", btn_func=self.event_intensity, value="1.0", descr_btn="Search for outliers in intensity ratio", descr_value="Ratio of intensity above which the cell looks suspect" ) + feat_layout.addLayout( feat_inten_line ) + + ## tubeness widget + feat_tub_line, self.ftub_out = wid.button_parameter_line( btn="Tubeness cytoplasm/junction", btn_func=self.event_tubeness, value="1.0", descr_btn="Search for outliers in tubeness ratio", descr_value="Ratio of tubeness above which the cell looks suspect" ) + feat_layout.addLayout( feat_tub_line ) + + ## all features + self.featOutliers.setLayout(feat_layout) + self.featOutliers.setVisible( self.outlier_vis.isChecked() ) + + def event_feature(self, featname, funcname ): + """ event in one frame or all frames the given feature """ + onframe = self.feat_onframe.isChecked() + if onframe: + tframe = ut.current_frame(self.viewer) + self.reset_event_type(featname, tframe) + funcname(tframe) + else: + self.reset_event_type(featname, None) + for frame in range(self.seglayer.data.shape[0]): + funcname(frame) + self.update_display() + ut.set_active_layer( self.viewer, "Segmentation" ) + + def inspect_outliers(self, tab, props, tuk, frame, feature): + q1 = np.quantile(tab, 0.25) + q3 = np.quantile(tab, 0.75) + qtuk = tuk * (q3-q1) + for sign in [1, -1]: + #thresh = np.mean(tab) + sign * np.std(tab)*tuk + if sign > 0: + thresh = q3 + qtuk + else: + thresh = q1 - qtuk + for i in np.where((tab-thresh)*sign>0)[0]: + position = ut.prop_to_pos( props[i], frame ) + self.add_event( position, props[i].label, feature ) + + def event_area_threshold(self): + """ Look for cell's area below/above a threshold """ + self.event_feature( "area", self.event_area_threshold_oneframe ) + + def event_area_threshold_oneframe( self, tframe ): + """ Check if area is above/below given threshold """ + minarea = int(self.min_area.text()) + maxarea = int(self.max_area.text()) + frame_props = self.epicure.get_frame_features( tframe ) + for prop in frame_props: + if (prop.area < minarea) or (prop.area > maxarea): + position = ut.prop_to_pos( prop, tframe ) + self.add_event( position, prop.label, "area" ) + + + def event_area(self, state): + """ Look for outliers in term of cell area """ + self.event_feature( "area", self.event_area_oneframe ) + + def event_area_oneframe(self, frame): + seglayer = self.seglayer.data[frame] + props = regionprops(seglayer) + ncell = len(props) + areas = np.zeros((ncell,1), dtype="float") + for i, prop in enumerate(props): + if prop.label > 0: + areas[i] = prop.area + tuk = self.farea_out.value() + self.inspect_outliers(areas, props, tuk, frame, "area") + + def event_solidity(self, state): + """ Look for outliers in term ofz cell solidity """ + self.event_feature( "solidity", self.event_solidity_oneframe ) + + def event_solidity_oneframe(self, frame): + seglayer = self.seglayer.data[frame] + props = regionprops(seglayer) + ncell = len(props) + sols = np.zeros((ncell,1), dtype="float") + for i, prop in enumerate(props): + if prop.label > 0: + sols[i] = prop.solidity + tuk = float(self.fsolid_out.text()) + self.inspect_outliers(sols, props, tuk, frame, "solidity") + + def event_intensity(self, state): + """ Look for abnormal intensity inside/periph ratio """ + self.event_feature( "intensity", self.event_intensity_oneframe ) + + def event_intensity_oneframe(self, frame): + seglayer = self.seglayer.data[frame] + intlayer = self.viewer.layers["Movie"].data[frame] + props = regionprops(seglayer) + for i, prop in enumerate(props): + if prop.label > 0: + self.test_intensity( intlayer, prop, frame ) + + def test_intensity(self, inten, prop, frame): + """ Test if intensity inside is much smaller than at periphery """ + bbox = prop.bbox + intbb = inten[bbox[0]:bbox[2], bbox[1]:bbox[3]] + footprint = disk(radius=self.epicure.thickness) + inside = binary_erosion(prop.image, footprint) + ininten = np.mean(intbb*inside) + dil_img = binary_dilation(prop.image, footprint) + periph = dil_img^inside + periphint = np.mean(intbb*periph) + if (periphint<=0) or (ininten/periphint > float(self.fintensity_out.text())): + position = ( frame, int(prop.centroid[0]), int(prop.centroid[1]) ) + self.add_event( position, prop.label, "intensity" ) + + def event_tubeness(self, state): + """ Look for abnormal tubeness inside vs periph """ + self.event_feature( "tubeness", self.event_tubeness_oneframe ) + + def event_tubeness_oneframe(self, frame): + seglayer = self.seglayer.data[frame] + mov = self.viewer.layers["Movie"].data[frame] + sated = np.copy(mov) + sated = filters.sato(sated, black_ridges=False) + props = regionprops(seglayer) + for i, prop in enumerate(props): + if prop.label > 0: + self.test_tubeness( sated, prop, frame ) + + def test_tubeness(self, sated, prop, frame): + """ Test if tubeness inside is much smaller than tubeness on periph """ + bbox = prop.bbox + satbb = sated[bbox[0]:bbox[2], bbox[1]:bbox[3]] + footprint = disk(radius=self.epicure.thickness) + inside = binary_erosion(prop.image, footprint) + intub = np.mean(satbb*inside) + periph = prop.image^inside + periphtub = np.mean(satbb*periph) + if periphtub <= 0: + position = ( frame, int(prop.centroid[0]), int(prop.centroid[1]) ) + self.add_event( position, prop.label, "tubeness" ) + else: + if intub/periphtub > float(self.ftub_out.text()): + position = ( frame, int(prop.centroid[0]), int(prop.centroid[1]) ) + self.add_event( position, prop.label, "tubeness" ) + + +############# event based on track + + def show_tracksBlock(self): + self.eventTrack.setVisible( self.track_vis.isChecked() ) + + def create_tracksBlock(self): + ''' Block interface of functions for error suggestions based on tracks ''' + track_layout = QVBoxLayout() + + self.ignore_borders = wid.add_check( "Ignore cells on border", False, None, "When adding suspect, don't add it if the cell is touching the border" ) + track_layout.addWidget(self.ignore_borders) + + ## Look for sudden appearance of tracks + self.get_apparition = wid.add_check( "Flag track apparition", True, None, "Add a suspect if a track appears in the middle of the movie (not on border)" ) + track_layout.addWidget(self.get_apparition) + + ## Look for sudden disappearance of tracks + disp_line, self.get_disparition, self.threshold_disparition = wid.check_value( check="Flag track disparition", checkfunc=None, checked=True, value="50", descr="Add a suspect if a track disappears (not last frame, not border) and the cell area is above threshold", label="if cell area above" ) + track_layout.addLayout( disp_line ) + + ## track length event_types + ilengthlay, self.check_length, self.min_length = wid.check_value( check="Flag tracks smaller than", checkfunc=None, checked=True, value="1", descr="Add a suspect event for each track smaller than chosen value (in number of frames)" ) + track_layout.addLayout(ilengthlay) + + ## Variability in feature event_type + sizevar_line, self.check_size, self.size_variability = wid.check_value( check="Size variation", checkfunc=None, checked=False, value="1", descr="Add a suspect if the size of the cell varies suddenly in the track" ) + track_layout.addLayout( sizevar_line ) + shapevar_line, self.check_shape, self.shape_variability = wid.check_value( check="Shape variation", checkfunc=None, checked=False, value="2.0", descr="Add a suspect if the shape of the cell varies suddenly in the track" ) + track_layout.addLayout( shapevar_line ) + + ## merge/split combinaisons + track_btn = wid.add_button( btn="Inspect track", btn_func=self.inspect_tracks, descr="Start track analysis to look for suspects based on selected features" ) + track_layout.addWidget(track_btn) + + ## all features + self.eventTrack.setLayout(track_layout) + self.eventTrack.setVisible( self.track_vis.isChecked() ) + + def reset_tracking_event(self): + """ Remove events from tracking """ + self.reset_event_type("tracking-1-2-*", None) + self.reset_event_type("tracking-2->1", None) + self.reset_event_type("track-length", None) + self.reset_event_type("track-size", None) + self.reset_event_type("track-shape", None) + self.reset_event_type("track-apparition", None) + self.reset_event_type("track-disparition", None) + self.reset_event_range() + + def track_length(self): + """ Find all cells that are only in one frame """ + max_len = int(self.min_length.text()) + labels, lengths, positions = self.epicure.tracking.get_small_tracks( max_len ) + for label, nframe, pos in zip(labels, lengths, positions): + if self.epicure.verbose > 2: + print("event track length "+str(nframe)+": "+str(label)+" frame "+str(pos[0]) ) + self.add_event(pos, label, "track-length", refresh=False) + self.refresh_events() + + def inspect_tracks(self): + """ Look for suspicious tracks """ + self.viewer.window._status_bar._toggle_activity_dock(True) + ut.set_visibility( self.viewer, "Events", True ) + progress_bar = progress( total=8 ) + progress_bar.update(0) + self.reset_tracking_event() + progress_bar.update(1) + if self.ignore_borders.isChecked(): + progress_bar.set_description("Identifying border cells") + self.get_border_cells() + progress_bar.update(2) + tracks = self.epicure.tracking.get_track_list() + if self.check_length.isChecked(): + progress_bar.set_description("Identifying too small tracks") + self.track_length() + progress_bar.update(3) + progress_bar.set_description("Inspect tracks 2->1") + self.track_21() + progress_bar.update(4) + if (self.check_size.isChecked()) or self.check_shape.isChecked(): + progress_bar.set_description("Inspect track features") + self.track_features() + progress_bar.update(5) + if self.get_apparition.isChecked(): + progress_bar.set_description("Check new track apparition") + self.track_apparition( tracks ) + progress_bar.update(6) + if self.get_disparition.isChecked(): + progress_bar.set_description("Check track disparition") + self.track_disparition( tracks, progress_bar ) + progress_bar.update(7) + progress_bar.close() + self.viewer.window._status_bar._toggle_activity_dock(False) + ut.set_active_layer( self.viewer, "Segmentation" ) + + def track_apparition( self, tracks ): + """ Check if some track appears suddenly (in the middle of the movie and not by division) """ + start_time = time.time() + ## remove track on first frame + ctracks = list( set(tracks) - set( self.epicure.tracking.get_tracks_on_frame( 0 ) ) ) + graph = self.epicure.tracking.graph + for i, track_id in enumerate( ctracks) : + fframe = self.epicure.tracking.get_first_frame( track_id ) + ## If on the border, ignore + outside = self.epicure.cell_on_border( track_id, fframe ) + if outside: + continue + ## Not on border, check if potential division + if (graph is not None) and (track_id in graph.keys()): + continue + ## event apparition + posxy = self.epicure.tracking.get_position( track_id, fframe ) + if posxy is not None: + pos = [ fframe, posxy[0], posxy[1] ] + if self.epicure.verbose > 2: + print("Appearing track: "+str(track_id)+" at frame "+str(fframe) ) + self.add_event(pos, track_id, "tracking-apparition", refresh=False) + self.refresh_events() + if self.epicure.verbose > 1: + ut.show_duration( start_time, "Tracks apparition took " ) + + def track_disparition( self, tracks, progress_bar ): + """ Check if some track disappears suddenly (in the middle of the movie and not by division) """ + start_time = time.time() + ## Track disappears in the movie, not last frame + ctracks = list( set(tracks) - set( self.epicure.tracking.get_tracks_on_frame( self.epicure.nframes-1 ) ) ) + graph = self.epicure.tracking.graph + threshold_area = float(self.threshold_disparition.text()) + sub_bar = progress( total = len( ctracks ), desc="Check non last frame tracks", nest_under = progress_bar ) + for i, track_id in enumerate( ctracks ): + sub_bar.update( i ) + lframe = self.epicure.tracking.get_last_frame( track_id ) + ## If on the border, ignore + outside = self.epicure.cell_on_border( track_id, lframe ) + if outside: + continue + ## Not on border, check if potential division + if self.epicure.tracking.is_parent( track_id ): + continue + ## check if the cell area is below the threshold, then considered as ok (likely extrusion) + if (threshold_area > 0): + cell_area = self.epicure.cell_area( track_id, lframe ) + if cell_area < threshold_area: + continue + ## event disparition + posxy = self.epicure.tracking.get_position( track_id, lframe ) + if posxy is not None: + pos = [ lframe, posxy[0], posxy[1] ] + if self.epicure.verbose > 2: + print("Disappearing track: "+str(track_id)+" at frame "+str(lframe) ) + self.add_event(pos, track_id, "tracking-disparition", refresh=False) + sub_bar.close() + self.refresh_events() + if self.epicure.verbose > 1: + ut.show_duration( start_time, "Tracks disparition took " ) + + def track_21(self): + """ Look for event track: 2->1 """ + if self.epicure.tracking.tracklayer is None: + ut.show_error("No tracking done yet!") + return + + graph = self.epicure.tracking.graph + if graph is not None: + for child, parent in graph.items(): + ## 2->1, merge, event + if len(parent) == 2: + onetwoone = False + ## was it only one before ? + if (parent[0] in graph.keys()) and (parent[1] in graph.keys()): + if graph[parent[0]][0] == graph[parent[1]][0]: + pos = self.epicure.tracking.get_mean_position([parent[0], parent[1]]) + if pos is not None: + if self.epicure.verbose > 1: + print("event 1->2->1 track: "+str(graph[parent[0]][0])+"-"+str(parent)+"-"+str(child)+" frame "+str(pos[0]) ) + self.add_event(pos, parent[0], "tracking-1-2-*") + onetwoone = True + + if not onetwoone: + pos = self.epicure.tracking.get_mean_position(child, only_first=True) + if pos is not None: + if self.epicure.verbose > 2: + print("event 2->1 track: "+str(parent)+"-"+str(child)+" frame "+str(int(pos[0])) ) + self.add_event(pos, parent[0], "tracking-2->1", refresh=False) + else: + if self.epicure.verbose > 1: + print("Something weird, "+str(child)+" mean position") + + self.epicure.finish_update() + self.refresh_events() + + def get_border_cells(self): + """ Return list of cells that are at the border (touching background) """ + self.border_cells = dict() + for tframe in range(self.epicure.nframes): + img = self.epicure.seg[tframe] + self.border_cells[tframe] = self.get_border_cells_frame(img) + + def get_border_cells_frame(self, imframe): + """ Return cells on border in current image """ + height = self.epicure.imgshape2D[1] + width = self.epicure.imgshape2D[0] + labels = list( np.unique( imframe[ :, 0:2 ] ) ) ## top border + labels += list( np.unique( imframe[ :, (height-2): ] ) ) ## bottom border + labels += list( np.unique( imframe[ 0:2,] ) ) ## left border + labels += list( np.unique( imframe[ (width-2):,] ) ) ## right border + #graph = ut.connectivity_graph( imframe, distance=3 ) + #adj_bg = [] + #if 0 in graph.nodes: + # adj_bg = list( graph.adj[0 ]) + #return adj_bg + return labels + + def get_divisions( self ): + """ Get and add divisions from the tracking graph """ + self.reset_event_type( "division", frame=None ) + graph = self.epicure.tracking.graph + divisions = {} + ## Go through the graph and fill all division by parents + if graph is not None: + for child, parent in graph.items(): + ## 1 parent, potential division + if (isinstance(parent, int)) or (len(parent) == 1): + if isinstance( parent, list ): + par = parent[0] + else: + par = parent + if par not in divisions: + divisions[par] = [child] + else: + divisions[par].append(child) + + ## Add all the divisions in the event list + for parent, childs in divisions.items(): + indexes = self.epicure.tracking.get_track_indexes(childs) + if len(indexes) <= 0: + ## something wrong in the graph or in the tracks, ignore for now + continue + ## get the average first position of the childs just after division + pos = self.epicure.tracking.mean_position(indexes, only_first=True) + self.add_event(pos, parent, "division", symb="o", color="#0055ffff", force=True) + ## Update display to show/hide the divisions + self.show_hide_divisions() + self.epicure.finish_update() + + def show_hide_divisions( self ): + """ Show or hide division events """ + self.show_subset_event( "division", self.show_divisions.isChecked() ) + + def show_hide_suspects( self ): + """ Show or hide suspect events """ + self.show_subset_event( "suspect", self.show_suspects.isChecked() ) + + def add_division( self, labela, labelb, parent, frame ): + """ Add a division event given the two daughter labels, the parent one and frame of division """ + indexes = self.epicure.tracking.get_index( [labela, labelb], frame ) + indexes = indexes.flatten() + pos = self.epicure.tracking.mean_position( indexes ) + self.events.selected_data = {} + if self.show_divisions.isChecked(): + self.events.current_size = int(self.event_size.value()) + else: + self.events.current_size = 0.1 + self.add_event( pos, parent, "division", symb="o", color="#0055ffff", force=True ) + self.events.selected_data = {} + self.events.current_size = int(self.event_size.value()) + ## check if there are suspect events to remove, cleared by the division + if parent is not None: + ## check eventual parent event + num, sid = self.find_event( pos[0]-1, parent ) + if num is not None: + if self.is_end_event( sid ): + ## the parent event correspond to a potential end of track, remove it + ind = self.index_from_id( sid ) + self.exonerate_one( ind, remove_division=False ) + if self.epicure.verbose > 0: + print( "Removed suspect event of parent cell "+str(parent)+" cleared by the division flag" ) + ## check each child suspect if cleared by the new division + for child in [labela, labelb]: + num, sid = self.find_event( pos[0], child ) + if num is not None: + if self.is_begin_event( sid ): + ## the child event correspond to a potential begin of track, remove it + ind = self.index_from_id( sid ) + self.exonerate_one( ind, remove_division=False ) + if self.epicure.verbose > 0: + print( "Removed suspect event of daughter cell "+str(child)+" cleared by the division flag" ) + self.update_nevents_display() + + + def is_division( self, ind ): + """ Return if the event of current index is a division """ + return ("division" in self.event_types) and (self.id_from_index(ind) in self.event_types["division"]) + + def is_begin_event( self, sid ): + """ Return True if the event has a type corresponding to begin of a track (too small or appearing) """ + beg_events = ["tracking-apparition", "track-length"] + for event in beg_events: + if event in self.event_types: + if sid in self.event_types[event]: + return True + return False + + def is_end_event( self, sid ): + """ Return True if the event has a type corresponding to end of a track (too small or disappearing) """ + end_events = ["tracking-disparition", "track-length"] + for event in end_events: + if event in self.event_types: + if sid in self.event_types[event]: + return True + return False + + def track_features(self): + """ Look at outliers in track features """ + track_ids = self.epicure.tracking.get_track_list() + features = [] + featType = {} + if self.check_size.isChecked(): + features = features + ["Area", "Perimeter"] + featType["Area"] = "size" + featType["Perimeter"] = "size" + size_factor = float(self.size_variability.text()) + if self.check_shape.isChecked(): + features = features + ["Eccentricity", "Solidity"] + featType["Eccentricity"] = "shape" + featType["Solidity"] = "shape" + shape_factor = float(self.shape_variability.text()) + for tid in track_ids: + track_indexes = self.epicure.tracking.get_track_indexes( tid ) + ## track should be long enough to make sense to look for outlier + if len(track_indexes) > 3: + track_feats = self.epicure.tracking.measure_features( tid, features ) + for feature, values in track_feats.items(): + if featType[feature] == "size": + factor = size_factor + if featType[feature] == "shape": + factor = shape_factor + outliers = self.find_jump( values, factor=factor ) + for out in outliers: + tdata = self.epicure.tracking.get_frame_data( tid, out ) + if self.epicure.verbose > 1: + print("event track "+feature+": "+str(tdata[0])+" "+" frame "+str(tdata[1]) ) + self.add_event(tdata[1:4], tid, "track_"+featType[feature]) + + def find_jump( self, tab, factor=1 ): + """ Detect brutal jump in the values """ + jumps = [] + tab = np.array(tab) + diff = tab[:(len(tab)-2)] - 2*tab[1:(len(tab)-1)] + tab[2:] + diff = [(tab[1]-tab[0])] + diff.tolist() + [tab[len(tab)-1]-tab[len(tab)-2]] + avg = (tab[:(len(tab)-2)] + tab[2:])/2 + avg = [(tab[1]+tab[0])/2] + avg.tolist() + [(tab[len(tab)-1]+tab[len(tab)-2])/2] + eps = 0.000000001 + diff = np.array(diff, dtype=np.float32) + avg = np.array(avg, dtype=np.float32) + diff = abs(diff+eps)/(avg+eps) + ## keep only local max above threshold + for i, diffy in enumerate(diff): + if (i>0) and (i<len(diff)-1): + if diffy > factor: + if (diffy > diff[i-1]) and (diffy > diff[i+1]): + jumps.append(i) + else: + if diffy > factor: + jumps.append(i) + #jumps = (np.where( diff > factor )[0]).tolist() + return jumps + + def find_outliers_tuk( self, tab, factor=3, below=True, above=True ): + """ Returns index of outliers from Tukey's like test """ + q1 = np.quantile(tab, 0.2) + q3 = np.quantile(tab, 0.8) + qtuk = factor * (q3-q1) + outliers = [] + if below: + outliers = outliers + (np.where((tab-q1+qtuk)<0)[0]).tolist() + if above: + outliers = outliers + (np.where((tab-q3-qtuk)>0)[0]).tolist() + return outliers + + def weirdo_area(self): + """ look at area trajectory for outliers """ + track_df = self.epicure.tracking.track_df + for tid in np.unique(track_df["track_id"]): + rows = track_df[track_df["track_id"]==tid].copy() + if len(rows) >= 3: + rows["smooth"] = rows.area.rolling(self.win_size, min_periods=1).mean() + rows["diff"] = (rows["area"] - rows["smooth"]).abs() + rows["diff"] = rows["diff"].div(rows["smooth"]) + if self.epicure.verbose > 2: + print(rows) + + diff --git a/src/epicure/laptrack_centroids.py b/src/epicure/laptrack_centroids.py index b019852c8de7acdc6c0791b7486b715c31eb6b04..dcd0e0817ff7b1cb06977fd10c576967bf17069b 100644 --- a/src/epicure/laptrack_centroids.py +++ b/src/epicure/laptrack_centroids.py @@ -37,7 +37,7 @@ class LaptrackCentroids(): self.penal_solidity = 0 self.track = track self.epicure = epic - self.suspecting = False + self.inspecting = False self.suggesting = False self.region_properties = ["label", "frame", "centroid-0", "centroid-1", "area", "solidity"] @@ -129,14 +129,14 @@ class LaptrackCentroids(): ut.napari_info("Starting tracking with LapTrack centroids metrics...") return self.perform_track( regionprops_df ) - def suspect_oneframe(self, graph, trackdf): + def inspect_oneframe(self, graph, trackdf): for track in np.unique(trackdf["track_id"]): tr = trackdf[trackdf["track_id"] == track] ## track is only on one frame, suspect if len(np.unique(tr["frame"])) == 1: # trackid + 1 as trackid starts as 0 pos = (tr.iloc[0]["frame"], int(tr.iloc[0]["centroid-0"]), int(tr.iloc[0]["centroid-1"])) - self.epicure.suspecting.add_suspect( pos, track+1, "tracking" ) + self.epicure.inspecting.add_event( pos, track+1, "tracking" ) if self.track.suggesting: if track in graph.keys(): sisters = [] diff --git a/src/epicure/laptrack_overlaps.py b/src/epicure/laptrack_overlaps.py index 192ec0d08c2003965e44c7933f771107963409e0..8d372e1dba7e7c89e1ead4a50d07e3d9cb743aa1 100644 --- a/src/epicure/laptrack_overlaps.py +++ b/src/epicure/laptrack_overlaps.py @@ -28,7 +28,7 @@ class LaptrackOverlaps(): self.merging_cost = 1 self.track = track self.epicure = epic - self.suspecting = False + self.inspecting = False self.suggesting = False @@ -85,14 +85,14 @@ class LaptrackOverlaps(): return self.perform_track( labels ) - def suspect_oneframe(self, graph, trackdf): + def inspect_oneframe(self, graph, trackdf): for track in np.unique(trackdf["track_id"]): tr = trackdf[trackdf["track_id"] == track] ## track is only on one frame, suspect if len(np.unique(tr["frame"])) == 1: # trackid + 1 as trackid starts as 0 pos = (tr.iloc[0]["frame"], int(tr.iloc[0]["centroid-0"]), int(tr.iloc[0]["centroid-1"])) - self.epicure.suspecting.add_suspect( pos, track+1, "tracking" ) + self.epicure.inspecting.add_event( pos, track+1, "tracking" ) if self.track.suggesting: if track in graph.keys(): sisters = [] diff --git a/src/epicure/napari.yaml b/src/epicure/napari.yaml index 5d1cba8c18f0e8062c737bf2a902734ac9234c79..ef1ca2e3754420399a19165f7de2ef371cb3f0ef 100644 --- a/src/epicure/napari.yaml +++ b/src/epicure/napari.yaml @@ -12,6 +12,9 @@ contributions: - id: epicure.doc title: Documentation python_name: epicure.Utils:show_documentation + - id: epicure.preferences + title: Preferences + python_name: epicure.preferences:edit_preferences widgets: - command: epicure.start display_name: Start EpiCure @@ -20,3 +23,5 @@ contributions: - command: epicure.doc display_name: Open EpiCure documentation autogenerate: false + - command: epicure.preferences + display_name: Edit Preferences diff --git a/src/epicure/outputing.py b/src/epicure/outputing.py index 162b2ba713d495dac7f67be8e30ed670552435d8..39c8bd1ae095d4c8b18f2b9381d67b3f1817aad2 100644 --- a/src/epicure/outputing.py +++ b/src/epicure/outputing.py @@ -1,19 +1,16 @@ -from qtpy.QtWidgets import QApplication, QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QGroupBox, QLineEdit, QComboBox, QLabel, QSpinBox, QCheckBox, QTableWidget, QTableWidgetItem, QGridLayout +from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget, QGroupBox, QComboBox, QLabel, QCheckBox, QTableWidget, QTableWidgetItem, QGridLayout, QListWidget +from qtpy.QtWidgets import QAbstractItemView as aiv from qtpy.QtCore import Qt -from napari import Viewer import pandas as pand import numpy as np -import epicure.Utils as ut import roifile -from napari.utils.notifications import show_info -from skimage.measure import find_contours, regionprops_table from skimage.morphology import binary_erosion, binary_dilation, disk from skimage.segmentation import expand_labels import os, time import napari from napari.utils import progress -from magicgui import magicgui - +import epicure.Utils as ut +import epicure.epiwidgets as wid import matplotlib as mpl from matplotlib.backends.backend_qt5agg import FigureCanvas from matplotlib.figure import Figure @@ -23,6 +20,7 @@ try: from skimage.graph import RAG except: from skimage.future.graph import RAG ## older version of scikit-image + class Outputing(QWidget): @@ -35,9 +33,16 @@ class Outputing(QWidget): self.seglayer = self.viewer.layers["Segmentation"] self.movlayer = self.viewer.layers["Movie"] self.selection_choices = ["All cells", "Only selected cell"] - self.output_options = ["Export to extern plugins", "Export segmentations", "Measure cell features", "Measure track features"] + self.output_options = ["", "Export to extern plugins", "Export segmentations", "Measure cell features", "Measure track features", "Export events"] self.tplots = None + chanlist = ["Movie"] + if self.epicure.others is not None: + for chan in self.epicure.others_chanlist: + chanlist.append( "MovieChannel_"+str(chan) ) + self.cell_features = CellFeatures( chanlist ) + self.event_types = EventTypes() + all_layout = QVBoxLayout() self.choose_output = QComboBox() @@ -48,12 +53,7 @@ class Outputing(QWidget): ## Choice of active selection layout = QVBoxLayout() - selection_layout = QHBoxLayout() - selection_lab = QLabel() - selection_lab.setText("Apply on") - selection_layout.addWidget(selection_lab) - self.output_mode = QComboBox() - selection_layout.addWidget(self.output_mode) + selection_layout, self.output_mode = wid.list_line( "Apply on", descr="Choose on which cell(s) to do the action", func=None ) for sel in self.selection_choices: self.output_mode.addItem(sel) all_layout.addLayout(selection_layout) @@ -61,140 +61,135 @@ class Outputing(QWidget): ## Choice of interface self.export_group = QGroupBox("Export to extern plugins") export_layout = QVBoxLayout() - griot_btn = QPushButton("Current frame to Griottes", parent=self) + griot_btn = wid.add_button( "Current frame to Griottes", self.to_griot, "Launch(in new window) Griottes plugin on current frame" ) export_layout.addWidget(griot_btn) - griot_btn.clicked.connect(self.to_griot) - ncp_btn = QPushButton("Current frame to Cluster-Plotter", parent=self) + ncp_btn = wid.add_button( "Current frame to Cluster-Plotter", self.to_ncp, "Launch (in new window) cluster-plotter plugin on current frame" ) export_layout.addWidget(ncp_btn) - ncp_btn.clicked.connect(self.to_ncp) self.export_group.setLayout(export_layout) - self.export_group.setCheckable(True) - self.export_group.clicked.connect(self.show_export_group) all_layout.addWidget(self.export_group) ## Option to export segmentation results - self.export_seg_group = QGroupBox(self.output_options[1]) - self.save_rois = QPushButton("Save ROI(s)", parent=self) - layout.addWidget(self.save_rois) - self.save_rois.clicked.connect(self.roi_out) - - self.save_seg = QPushButton("Save segmentation(s)", parent=self) - layout.addWidget(self.save_seg) - self.save_seg.clicked.connect(self.save_segmentation) - - self.save_skel = QPushButton("Save Skeleton(s)", parent=self) - layout.addWidget(self.save_skel) - self.save_skel.clicked.connect(self.save_skeleton) + self.export_seg_group = QGroupBox(self.output_options[2]) + layout = QVBoxLayout() + save_line, self.save_choice = wid.button_list( "Save segmentation as", self.save_segmentation, "Save the current segmentation either as ROI, label image or skeleton" ) + self.save_choice.addItem( "labels" ) + self.save_choice.addItem( "ROI" ) + self.save_choice.addItem( "skeleton" ) + layout.addLayout( save_line ) - self.export_seg_group.setCheckable(True) - self.export_seg_group.clicked.connect(self.show_export_seg_group) self.export_seg_group.setLayout(layout) - self.export_seg_group.hide() all_layout.addWidget(self.export_seg_group) #### Features group - self.feature_group = QGroupBox(self.output_options[2]) - self.feature_group.setCheckable(True) + self.feature_group = QGroupBox(self.output_options[3]) featlayout = QVBoxLayout() - self.feature_shape_cbox = QCheckBox(text="Shape features") - self.feature_intensity_cbox = QCheckBox(text="Intensity features") - self.measure_other_chanels_cbox = QCheckBox(text="Intensity in other chanels") - self.feature_graph_cbox = QCheckBox(text="Neighboring features") - featlayout.addWidget(self.feature_shape_cbox) - featlayout.addWidget(self.feature_intensity_cbox) - featlayout.addWidget(self.measure_other_chanels_cbox) - featlayout.addWidget(self.feature_graph_cbox) - self.feature_table = QPushButton("Create features table", parent=self) + + self.choose_features_btn = wid.add_button( "Choose features", self.choose_features, "Open a window to select the features to measure" ) + featlayout.addWidget(self.choose_features_btn) + + self.feature_table = wid.add_button( "Create features table", self.show_table, "Measure the selected features and display it as a clickable table" ) featlayout.addWidget(self.feature_table) - self.feature_table.clicked.connect(self.show_table) self.featTable = FeaturesTable(self.viewer, self.epicure) featlayout.addWidget(self.featTable) - self.temp_graph = QPushButton("Table to temporal graphs", parent=self) + ######## Temporal option + self.temp_graph = wid.add_button( "Table to temporal graphs", self.temporal_graphs, "Open a plot interface of measured features temporal evolution" ) featlayout.addWidget(self.temp_graph) - self.temp_graph.clicked.connect(self.temporal_graphs) self.temp_graph.setEnabled(False) - - featmap = QHBoxLayout() - featmap_lab = QLabel() - featmap_lab.setText("Draw feature map:") - featmap.addWidget(featmap_lab) - self.show_feature_map = QComboBox() - featmap.addWidget(self.show_feature_map) - self.show_feature_map.currentIndexChanged.connect(self.show_feature) + + ######## Drawing option + featmap, self.show_feature_map = wid.list_line( "Draw feature map:", descr="Add a layer with the cells colored by the selected feature value", func=self.show_feature ) featlayout.addLayout(featmap) - - self.save_table = QPushButton("Save features table", parent=self) + orienbtn = wid.add_button( "Draw cell orientation", self.draw_orientation, "Add a layer with each cell main axis orientation and length " ) + featlayout.addWidget( orienbtn ) + + self.save_table = wid.add_button( "Save features table", self.save_measure_features, "Save the current table in a .csv file" ) featlayout.addWidget(self.save_table) - self.save_table.clicked.connect(self.save_measure_features) - if (self.epicure.others is None): - self.measure_other_chanels_cbox.hide() self.feature_group.setLayout(featlayout) - self.feature_group.clicked.connect(self.show_feature_group) self.feature_group.hide() all_layout.addWidget(self.feature_group) ## Track features - self.trackfeat_group = QGroupBox(self.output_options[3]) - self.trackfeat_group.setCheckable(True) + self.trackfeat_group = QGroupBox(self.output_options[4]) trackfeatlayout = QVBoxLayout() - self.trackfeat_table = QPushButton("Track features table", parent=self) + self.trackfeat_table = wid.add_button( "Track features table", self.show_trackfeature_table, "Measure track-related feature and show a table by track" ) trackfeatlayout.addWidget(self.trackfeat_table) - self.trackfeat_table.clicked.connect(self.show_trackfeature_table) self.trackTable = FeaturesTable(self.viewer, self.epicure) trackfeatlayout.addWidget(self.trackTable) self.trackfeat_group.setLayout(trackfeatlayout) - self.trackfeat_group.clicked.connect(self.show_trackfeature_group) self.trackfeat_group.hide() all_layout.addWidget(self.trackfeat_group) + + ## Option to export events (Fiji ROI or table), + graphs ? + self.export_event_group = QGroupBox(self.output_options[5]) + elayout = QVBoxLayout() + self.choose_events_btn = wid.add_button( "Choose events", self.choose_events, "Open a window to select the events to export/measure" ) + elayout.addWidget( self.choose_events_btn ) + save_evt_line, self.save_evt_choice = wid.button_list( "Export events as", self.export_events, "Save the checked events as Fiji ROIs or .csv table" ) + self.save_evt_choice.addItem( "Fiji ROI" ) + elayout.addLayout( save_evt_line ) + + self.export_event_group.setLayout( elayout ) + self.export_event_group.hide() + all_layout.addWidget( self.export_event_group ) + ## Finished self.setLayout(all_layout) - self.setStyleSheet('QGroupBox {color: grey; background-color: rgb(35,45,50)} ') + self.show_output_option() + #self.setStyleSheet('QGroupBox {color: grey; background-color: rgb(35,45,50)} ') + + def get_current_settings( self ): + """ Returns current settings of the widget """ + disp = {} + disp["Apply on"] = self.output_mode.currentText() + disp["Current option"] = self.choose_output.currentText() + disp = self.cell_features.get_current_settings( disp ) + disp = self.event_types.get_current_settings( disp ) + return disp + + def apply_settings( self, settings ): + """ Set the current state of the widget from preferences if any """ + for setting, val in settings.items(): + if setting == "Apply on": + self.output_mode.setCurrentText( val ) + if setting == "Current option": + self.choose_output.setCurrentText( val ) + + self.cell_features.apply_settings( settings ) + self.event_types.apply_settings( settings ) + + def events_select( self, event, check ): + """ Check/Uncheck the event in event types list """ + if event in self.event_types.types: + self.event_types.types[ event ][0].setChecked( check ) + else: + print(event+" not found in possible event types to export") def show_output_option(self): """ Show selected output panel """ cur_option = self.choose_output.currentText() - if cur_option == "Export to extern plugins": - self.export_group.setChecked(True) - self.export_group.show() - if cur_option == "Export segmentations": - self.export_seg_group.setChecked(True) - self.export_seg_group.show() - if cur_option == "Measure cell features": - self.feature_group.setChecked(True) - self.feature_group.show() - if cur_option == "Measure track features": - self.trackfeat_group.setChecked(True) - self.trackfeat_group.show() - - def show_export_group(self): - """ Show/Hide export group """ - if not self.export_group.isChecked(): - self.export_group.setChecked(True) - self.export_group.hide() - - def show_export_seg_group(self): - """ Show/Hide export segmentaion group """ - if not self.export_seg_group.isChecked(): - self.export_seg_group.setChecked(True) - self.export_seg_group.hide() - - def show_feature_group(self): - """ Show/Hide feature cell group """ - if not self.feature_group.isChecked(): - self.feature_group.setChecked(True) - self.feature_group.hide() - - def show_trackfeature_group(self): - """ Show/Hide feature cell group """ - if not self.trackfeat_group.isChecked(): - self.trackfeat_group.setChecked(True) - self.trackfeat_group.hide() + self.export_group.setVisible( cur_option == "Export to extern plugins" ) + self.export_seg_group.setVisible( cur_option == "Export segmentations" ) + self.feature_group.setVisible( cur_option == "Measure cell features" ) + self.trackfeat_group.setVisible( cur_option == "Measure track features" ) + self.export_event_group.setVisible( cur_option == "Export events" ) + def get_current_labels( self ): + """ Get the cell labels to process according to current selection of apply on""" + if self.output_mode.currentText() == "Only selected cell": + lab = self.epicure.seglayer.selected_label + return [lab] + if self.output_mode.currentText() == "All cells": + return self.epicure.get_labels() + else: + group = self.output_mode.currentText() + label_group = self.epicure.groups[group] + return label_group + def get_selection_name(self): if self.output_mode.currentText() == "Only selected cell": lab = self.epicure.seglayer.selected_label @@ -208,34 +203,12 @@ class Outputing(QWidget): def save_measure_features(self): """ Save measures table to file whether it was created or not """ if self.table is None or self.table_selection is None or self.selection_changed() : - show_info("Create/update the table before") + ut.show_warning("Create/update the table before") return outfile = self.epicure.outname()+"_features"+self.get_selection_name()+".xlsx" self.table.to_excel(outfile, sheet_name='EpiCureMeasures') - show_info("Measures saved in "+outfile) - - def roi_out(self): - """ Save ROI of cell contours in zip file by cell """ - if self.output_mode.currentText() == "Only selected cell": - lab = self.seglayer.selected_label - self.save_one_roi(lab) - show_info("Cell "+str(lab)+" saved to Fiji ROI") - return - else: - if self.output_mode.currentText() == "All cells": - ncells = 0 - for lab in np.unique(self.epicure.seglayer.data): - self.save_one_roi(lab) - ncells += 1 - show_info(str(ncells)+" cells saved to Fiji ROIs") - else: - ncells = 0 - group = self.output_mode.currentText() - label_group = self.epicure.groups[group] - for lab in label_group: - self.save_one_roi(lab) - ncells += 1 - show_info(str(ncells)+" cells saved to Fiji ROIs") + if self.epicure.verbose > 0: + ut.show_info("Measures saved in "+outfile) def save_one_roi(self, lab): """ Save the Rois of cell with label lab """ @@ -245,7 +218,7 @@ class Outputing(QWidget): ## add 2D case for iframe, frame in enumerate(keep): if np.sum(frame) > 0: - contour = find_contours(frame) + contour = ut.get_contours(frame) roi = self.create_roi(contour[0], iframe, lab) rois.append(roi) @@ -272,59 +245,69 @@ class Outputing(QWidget): return croi def save_segmentation( self ): - """ Save label movies of current output selection """ + """ Save current segmentation in selected format """ if self.output_mode.currentText() == "Only selected cell": + ## output only the selected cell lab = self.seglayer.selected_label - tosave = np.zeros(self.seglayer.data.shape, dtype=self.epicure.dtype) - if np.sum(self.seglayer.data==lab) > 0: - tosave[self.seglayer.data==lab] = lab - endname = "_cell_"+str(lab)+".tif" - else: - endname = "_checked_cells.tif" - if self.output_mode.currentText() == "All cells": - tosave = self.seglayer.data - endname = "_labels.tif" + if self.save_choice.currentText() == "ROI": + self.save_one_roi(lab) + if self.epicure.verbose > 0: + ut.show_info("Cell "+str(lab)+" saved to Fiji ROI") + return else: tosave = np.zeros(self.seglayer.data.shape, dtype=self.epicure.dtype) - endname = "_"+self.output_mode.currentText()+".tif" - ncells = 0 - group = self.output_mode.currentText() - label_group = self.epicure.groups[group] - for lab in label_group: + if np.sum(self.seglayer.data==lab) > 0: tosave[self.seglayer.data==lab] = lab - - outname = os.path.join( self.epicure.outdir, self.epicure.imgname+endname ) - ut.writeTif(tosave, outname, self.epicure.scale, 'float32', what="Segmentation") - - def save_skeleton( self ): - """ Save skeleton movies of current output selection """ - if self.output_mode.currentText() == "Only selected cell": - lab = self.seglayer.selected_label - tosave = np.zeros(self.seglayer.data.shape, dtype=self.epicure.dtype) - if np.sum(self.seglayer.data==lab) > 0: - tosave[self.seglayer.data==lab] = lab - endname = "_skeleton_cell_"+str(lab)+".tif" - else: - endname = "_checked_cells.tif" - if self.output_mode.currentText() == "All cells": + endname = "_"+self.save_choice.currentText()+"_"+str(lab)+".tif" + else: + ## output all cells + if self.output_mode.currentText() == "All cells": + if self.save_choice.currentText() == "ROI": + self.save_all_rois() + return tosave = self.seglayer.data - endname = "_skeleton.tif" + endname = "_"+self.save_choice.currentText()+".tif" else: - tosave = np.zeros(self.seglayer.data.shape, dtype=self.epicure.dtype) - endname = "_skeleton_"+self.output_mode.currentText()+".tif" - ncells = 0 + ## or output only selected group group = self.output_mode.currentText() label_group = self.epicure.groups[group] + if self.save_choice.currentText() == "ROI": + ncells = 0 + for lab in label_group: + self.save_one_roi(lab) + ncells += 1 + if self.epicure.verbose > 0: + ut.show_info(str(ncells)+" cells saved to Fiji ROIs") + return + tosave = np.zeros(self.seglayer.data.shape, dtype=self.epicure.dtype) + endname = "_"+self.save_choice.currentText()+"_"+self.output_mode.currentText()+".tif" for lab in label_group: tosave[self.seglayer.data==lab] = lab - - tosave = ut.get_skeleton( tosave, verbose=self.epicure.verbose ) + + ## save filled image (for label or skeleton) to file outname = os.path.join( self.epicure.outdir, self.epicure.imgname+endname ) - ut.writeTif( tosave, outname, self.epicure.scale, 'uint8', what="Skeleton" ) + if self.save_choice.currentText() == "skeleton": + tosave = ut.get_skeleton( tosave, verbose=self.epicure.verbose ) + ut.writeTif( tosave, outname, self.epicure.scale_xy, 'uint8', what="Skeleton" ) + else: + ut.writeTif(tosave, outname, self.epicure.scale_xy, 'float32', what="Segmentation") + + def save_all_rois( self ): + """ Save all cells to ROI format """ + ncells = 0 + for lab in np.unique(self.epicure.seglayer.data): + self.save_one_roi(lab) + ncells += 1 + if self.epicure.verbose > 0: + ut.show_info(str(ncells)+" cells saved to Fiji ROIs") + + def choose_features( self ): + """ Pop-up widget to choose the features to measure """ + self.cell_features.choose() def measure_features(self): """ Measure features and put them to table """ - def intensities_inside_outside(regionmask, intensity): + def intensity_junction_cytoplasm(regionmask, intensity): """ Measure the intensity only on the contour of regionmask """ footprint = disk(radius=self.epicure.thickness) inside = binary_erosion(regionmask, footprint) @@ -352,60 +335,74 @@ class Outputing(QWidget): for lab in label_group: meas[self.epicure.seglayer.data==lab] = lab - properties = ["label", "area", "centroid"] + properties, other_features, int_feat, int_extrafeat = self.cell_features.get_features() + do_channels = self.cell_features.get_channels() + ## prepare intensity extra properties if necessary + extra_prop = [] + if "intensity_junction_cytoplasm" in int_extrafeat: + extra_prop = extra_prop + [intensity_junction_cytoplasm] + extra_properties = [] - if self.feature_shape_cbox.isChecked(): - properties = properties + ["area_convex", "axis_major_length", "axis_minor_length", "feret_diameter_max", "equivalent_diameter_area", "eccentricity", "orientation", "perimeter", "solidity"] - if self.feature_intensity_cbox.isChecked(): - properties = properties + ["intensity_mean", "intensity_min", "intensity_max"] - extra_properties = extra_properties + [intensities_inside_outside] + if (do_channels is not None) and ("Movie" in do_channels): + properties = properties + int_feat + for extra in int_extrafeat: + if extra == "intensity_junction_cytoplasm": + extra_properties = extra_properties + [intensity_junction_cytoplasm] self.table = None for iframe, frame in progress(enumerate(meas)): - frame_table = self.measure_one_frame( frame, properties, extra_properties, iframe ) + frame_table = self.measure_one_frame( frame, properties, extra_properties, other_features, do_channels, int_feat, extra_prop, iframe ) if self.table is None: self.table = pand.DataFrame(frame_table) else: self.table = pand.concat([self.table, pand.DataFrame(frame_table)]) - if "intensities_inside_outside-0" in self.table.keys(): - self.table = self.table.rename(columns={"intensities_inside_outside-0": "intensity_cytoplasm", "intensities_inside_outside-1":"intensity_junction"}) + if "intensity_junction_cytoplasm" in self.table.keys(): + self.table = self.table.rename(columns={"intensity_junction_cytoplasm-0": "intensity_cytoplasm", "intensity_junction_cytoplasm-1":"intensity_junction"}) self.table_selection = self.selection_choices.index(self.output_mode.currentText()) self.viewer.window._status_bar._toggle_activity_dock(False) - show_info("Features measured in "+"{:.3f}".format((time.time()-start_time)/60)+" min") + if self.epicure.verbose > 0: + ut.show_info("Features measured in "+"{:.3f}".format((time.time()-start_time)/60)+" min") - def measure_one_frame(self, img, properties, extra_properties, frame): + def measure_one_frame(self, img, properties, extra_properties, other_features, channels, int_feat, int_extrafeat, frame): """ Measure on one frame """ if frame is not None: intimg = self.movlayer.data[frame] else: intimg = self.movlayer.data - frame_table = regionprops_table(img, intensity_image=intimg, properties=properties, extra_properties=extra_properties) + frame_table = ut.labels_table( img, intensity_image=intimg, properties=properties, extra_properties=extra_properties ) ndata = len(frame_table["label"]) if frame is not None: frame_table["frame"] = np.repeat(frame, ndata) + ## add info of the cell group - frame_group = self.epicure.get_groups(list(frame_table["label"]), numeric=False) - frame_table["group"] = frame_group + if "group" in other_features: + frame_group = self.epicure.get_groups(list(frame_table["label"]), numeric=False) + frame_table["group"] = frame_group ### Measure intensity features in other chanels if option is on - if self.measure_other_chanels_cbox.isChecked(): - prop = ["intensity_mean", "intensity_min", "intensity_max"] - extra_prop = extra_properties - for ochan, oimg in zip(self.epicure.others_chanlist, self.epicure.others): + if (channels is not None): + for chan in channels: + ## if it's movie, already measured in the general measure + if chan == "Movie": + continue + ## otherwise, do a new measure on the selected channels + oimg = self.viewer.layers[chan].data if frame is not None: intimg = oimg[frame] else: intimg = oimg - frame_tab = regionprops_table(img, intensity_image=intimg, properties=prop, extra_properties=extra_prop) - for add_prop in prop: - frame_table[add_prop+"_Chanel"+str(ochan)] = frame_tab[add_prop] - if "intensities_inside_outside-0" in frame_tab.keys(): - frame_table["intensity_cytoplasm_Chanel"+str(ochan)] = frame_tab["intensities_inside_outside-0"] - frame_table["intensity_junction_Chanel"+str(ochan)] = frame_tab["intensities_inside_outside-1"] + frame_tab = ut.labels_table( img, intensity_image=intimg, properties=int_feat, extra_properties=int_extrafeat ) + for add_prop in int_feat: + frame_table[add_prop+"_"+str(chan)] = frame_tab[add_prop] + if "intensity_junction_cytoplasm-0" in frame_tab.keys(): + frame_table["intensity_cytoplasm_"+chan] = frame_tab["intensity_junction_cytoplasm-0"] + frame_table["intensity_junction_"+str(chan)] = frame_tab["intensity_junction_cytoplasm-1"] ## add features of neighbors relationship with graph - if self.feature_graph_cbox.isChecked(): + do_neighbor = "NbNeighbors" in other_features + do_border = "Border" in other_features + if do_neighbor or do_border: touchlab = expand_labels(img, distance=3) ## be sure that labels touch graph = RAG( touchlab, connectivity=2) adj_bg = [] @@ -413,15 +410,19 @@ class Outputing(QWidget): adj_bg = list(graph.adj[0]) graph.remove_node(0) - frame_table["NbNeighbors"] = np.repeat(-1, ndata) - frame_table["External"] = np.repeat(-1, ndata) + if do_neighbor: + frame_table["NbNeighbors"] = np.repeat(-1, ndata) + if do_border: + frame_table["Border"] = np.repeat(-1, ndata) nodes = list(graph.nodes) for label in nodes: nneighbor = len(graph.adj[label]) outer = int( label in adj_bg ) rlabel = (frame_table["label"] == label) - frame_table["NbNeighbors"][rlabel] = nneighbor - frame_table["External"][rlabel] = outer + if do_neighbor: + frame_table["NbNeighbors"][rlabel] = nneighbor + if do_border: + frame_table["Border"][rlabel] = outer return frame_table @@ -486,21 +487,70 @@ class Outputing(QWidget): def draw_map(self, labels, values, frames, featname): """ Add image layer of values by label """ + ## special feature: orientation, draw the axis instead self.viewer.window._status_bar._toggle_activity_dock(True) mapfeat = np.empty(self.epicure.seg.shape, dtype="float16") mapfeat[:] = np.nan - for ind, lab in progress(enumerate(labels)): - if frames is not None: - frame = frames[ind] + if frames is not None: + for frame, lab, val in progress(zip(frames, labels, values)): cell = self.seglayer.data[frame]==lab - (mapfeat[frame])[cell] = values[ind] - else: + (mapfeat[frame])[cell] = val + else: + for lab, val in progress(zip(labels, values)): cell = self.seglayer.data==lab - mapfeat[cell] = values[ind] + mapfeat[cell] = val ut.remove_layer(self.viewer, "Map_"+featname) self.viewer.add_image(mapfeat, name="Map_"+featname) self.viewer.window._status_bar._toggle_activity_dock(False) + def draw_orientation( self ): + """ Display the cells orientation axis in a new layer """ + ## check that necessary features are measured + ut.remove_layer( self.viewer, "CellOrientation" ) + feats = ["centroid-0", "centroid-1", "orientation"] + if self.table is None: + print("Features centroid and orientation necessary to draw orientation, but are not measured yet") + return + for feat in feats: + if feat not in self.table.keys(): + print("Feature "+feat+" necessary to draw orientation, but was not measured") + return + ## ok, can work now + self.viewer.window._status_bar._toggle_activity_dock(True) + + ## get the coordinates of the axis lines by getting the cell centroid, main orientation + xs = np.array( self.table["centroid-0"] ) + ys = np.array( self.table["centroid-1"] ) + angles = np.array( self.table["orientation"] ) + lens = np.array( [10]*len(angles) ) + oriens = np.zeros( (self.epicure.seg.shape), dtype="uint8" ) + + ## draw axis length depending on the eccentricity + if "eccentricity" in self.table.keys(): + lens = np.array(self.table["eccentricity"]*16) + + if "frame" in self.table: + frames = np.array( self.table["frame"] ).astype(int) + else: + frames = np.array( [0]*len(angles) ) + + ## draw the lines in between the two extreme points (using Shape layer is too slow on display for big movies) + npts = 30 + xmax = oriens.shape[1]-1 + ymax = oriens.shape[2]-1 + for i in range(npts): + xas = np.clip(xs - lens/2 * np.cos( angles ) * i/float(npts), 0, xmax).astype(int) + xbs = np.clip(xs + lens/2 * np.cos( angles ) * i/float(npts), 0, xmax).astype(int) + yas = np.clip(ys - lens/2 * np.sin( angles ) * i/float(npts), 0, ymax).astype(int) + ybs = np.clip(ys + lens/2 * np.sin( angles ) * i/float(npts), 0, ymax).astype(int) + oriens[ (frames, xas, yas) ] = 255 + oriens[ (frames, xbs, ybs) ] = 255 + + self.viewer.add_image( oriens, name="CellOrientation", blending="additive", opacity=1 ) + self.viewer.window._status_bar._toggle_activity_dock(False) + + ################### Export to other plugins + def to_griot(self): """ Export current frame to new viewer and makes it ready for Griotte plugin """ try: @@ -557,12 +607,15 @@ class Outputing(QWidget): gview.window.add_dock_widget(ncp.ClusteringWidget(gview)) gview.window.add_dock_widget(ncp.PlotterWidget(gview)) + ################### Temporal graphs + def temporal_graphs(self): """ New window with temporal graph of the current table selection """ #self.temporal_viewer = napari.Viewer() - self.tplots = TemporalPlots(self.viewer) + self.tplots = TemporalPlots( self.viewer, self.epicure ) self.tplots.setTable(self.table) - self.plot_wid = self.viewer.window.add_dock_widget( self.tplots, name="Plots" ) + self.tplots.show() + #self.plot_wid = self.viewer.window.add_dock_widget( self.tplots, name="Plots" ) self.viewer.dims.events.current_step.connect(self.position_verticalline) def on_close_viewer(self): @@ -575,14 +628,15 @@ class Outputing(QWidget): def position_verticalline(self): """ Place the vertical line in the temporal graph to the current frame """ - try: - wid = self.plot_wid - except: - self.on_close_viewer() + #try: + # wid = self.tplots + #except: + # self.on_close_viewer() if self.tplots is not None: self.tplots.move_framepos(self.viewer.dims.current_step[0]) - ### track features + ############### track features + def show_trackfeature_table(self): """ Show the measurement of tracks table """ self.measure_track_features() @@ -621,14 +675,248 @@ class Outputing(QWidget): self.table_selection = self.selection_choices.index(self.output_mode.currentText()) self.viewer.window._status_bar._toggle_activity_dock(False) - show_info("Features measured in "+"{:.3f}".format((time.time()-start_time)/60)+" min") + if self.epicure.verbose > 0: + ut.show_info("Features measured in "+"{:.3f}".format((time.time()-start_time)/60)+" min") def measure_one_track( self, track_id ): """ Measure features of one track """ track_features = self.epicure.tracking.measure_track_features( track_id ) return track_features + ############## Events functions + + def choose_events( self ): + """ Pop-up widget to choose the event types to measure/export """ + self.event_types.choose() + + def export_events( self ): + """ Export events of selected types """ + evt_types = self.event_types.get_types() + export_type = self.save_evt_choice.currentText() + if export_type == "Fiji ROI": + ## keep only events related to selected cells + labels = self.get_current_labels() + if self.epicure.verbose > 2: + print("Exporting events of type "+str(evt_types)+" to Fiji ROIs" ) + ## export each type of event in separate files + for itype, evt_type in enumerate( evt_types ): + evts = self.epicure.inspecting.get_events_from_type( evt_type ) + if len( evts ) > 0: + rois = [] + for evt_sid in evts: + pos, label = self.epicure.inspecting.get_event_infos( evt_sid ) + if label in labels: + roi = self.create_point_roi( pos, itype ) + rois.append( roi ) + outfile = self.epicure.outname()+"_rois_"+evt_type +""+self.get_selection_name()+".zip" + roifile.roiwrite(outfile, rois, mode='w') + if self.epicure.verbose > 0: + print( "Events "+str( evt_type )+" saved in ROI file: "+outfile ) + else: + ## dont save anything if empty, just print info to user + if self.epicure.verbose > 0: + print( "No events of type "+str(evt_type)+"" ) + + def create_point_roi( self, pos, cat=0 ): + """ Create a point Fiji ROI """ + croi = roifile.ImagejRoi() + croi.version = 227 + croi.roitype = roifile.ROI_TYPE(10) + croi.name = str(pos[0]+1).zfill(4)+'-'+str(pos[1]).zfill(4)+"-"+str(pos[2]).zfill(4) + croi.n_coordinates = 1 + croi.left = int(pos[2]) + croi.top = int(pos[1]) + croi.z_position = 1 + croi.t_position = pos[0]+1 + croi.c_position = 1 + croi.integer_coordinates = np.array( [[0,0]] ) + croi.stroke_width=3 + ncolors = 3 + if cat%ncolors == 0: ## color type 0 + croi.stroke_color = b'\xff\x00\x00\xff' + if cat%ncolors == 1: ## color type 1 + croi.stroke_color = b'\xff\x00\xff\x00' + if cat%ncolors == 2: ## color type 2 + croi.stroke_color = b'\xff\xff\x00\x00' + return croi + + +class CellFeatures(QWidget): + """ Choice of features to measure """ + def __init__(self, chanlist): + super().__init__() + layout = QVBoxLayout() + + self.required = ["label"] + self.features = {} + self.chan_list = None + + other_list = ["group", "NbNeighbors", "Border"] + feat_layout = self.add_feature_group( other_list, "other" ) + layout.addLayout( feat_layout ) + + ## Add shape features + shape_list = ["centroid", "area", "area_convex", "axis_major_length", "axis_minor_length", "feret_diameter_max", "equivalent_diameter_area", "eccentricity", "orientation", "perimeter", "solidity"] + feat_layout = self.add_feature_group( shape_list, "prop" ) + layout.addLayout( feat_layout ) + + int_lab = QLabel() + int_lab.setText("Intensity features:") + layout.addWidget( int_lab ) + intensity_list = ["intensity_mean", "intensity_min", "intensity_max"] + extra_list = ["intensity_junction_cytoplasm"] + feat_layout = self.add_feature_group( intensity_list, "intensity_prop" ) + layout.addLayout( feat_layout ) + feat_layout = self.add_feature_group( extra_list, "intensity_extra" ) + layout.addLayout( feat_layout ) + if len(chanlist) > 1: + chan_lab = QLabel() + chan_lab.setText("Measure intensity in channels:") + layout.addWidget( chan_lab ) + self.chan_list = QListWidget() + self.chan_list.addItems( chanlist ) + self.chan_list.setSelectionMode(aiv.MultiSelection) + self.chan_list.item(0).setSelected(True) + layout.addWidget( self.chan_list ) + + bye = wid.add_button( "Ok", self.close, "Close the window" ) + layout.addWidget( bye ) + self.setLayout( layout ) + + def add_feature_group( self, feat_list, feat_type ): + """ Add features to the GUI """ + layout = QVBoxLayout() + ncols = 3 + for i, feat in enumerate(feat_list): + if i%ncols == 0: + line = QHBoxLayout() + feature_check = QCheckBox(text=""+feat) + line.addWidget(feature_check) + self.features[ feat ] = [feature_check, feat_type] + feature_check.setChecked( True ) + if i%ncols == (ncols-1): + layout.addLayout( line ) + line = None + if line is not None: + layout.addLayout( line ) + return layout + + + def close( self ): + """ Close the pop-up window """ + self.hide() + + def choose( self ): + """ Show the interface to select the choices """ + self.show() + + def get_current_settings( self, setting ): + """ Get current settings of check or not of features """ + for feat, feat_cbox in self.features.items(): + setting[feat] = feat_cbox[0].isChecked() + return setting + + def apply_settings( self, settings ): + """ Set the checkboxes from preferenced settings """ + for feat, checked in settings.items(): + if feat in self.features.keys(): + self.features[feat][0].setChecked( checked ) + + def get_features( self ): + """ Returns the list of features to measure """ + feats = self.required + int_extra_feats = [] + int_feats = [] + other_feats = [] + self.do_intensity = False + for feat, feat_cbox in self.features.items(): + if feat_cbox[0].isChecked(): + if feat_cbox[1] == "prop": + feats.append( feat ) + if feat_cbox[1] == "other": + other_feats.append( feat ) + if feat_cbox[1] == "intensity_prop": + int_feats.append( feat ) + self.do_intensity = True + if feat_cbox[1] == "intensity_extra": + int_extra_feats.append( feat ) + self.do_intensity = True + return feats, other_feats, int_feats, int_extra_feats + + def get_channels( self ): + """ Returns the list of channels to measure """ + if self.do_intensity: + if self.chan_list is not None: + wid_channels = self.chan_list.selectedItems() + channels = [] + for chan in wid_channels: + channels.append( chan.text() ) + else: + channels = ["Movie"] + return channels + return None + +class EventTypes(QWidget): + """ Choice of event types to export/measure """ + def __init__(self): + super().__init__() + layout = QVBoxLayout() + + self.types = {} + possible_types = [ "division", "suspect" ] + event_layout = self.add_events( possible_types ) + layout.addLayout( event_layout ) + + bye = wid.add_button( "Ok", self.close, "Close the window" ) + layout.addWidget( bye ) + self.setLayout( layout ) + + def add_events( self, event_list ): + """ Add events to the GUI """ + layout = QVBoxLayout() + ncols = 3 + for i, event in enumerate( event_list ): + if i%ncols == 0: + line = QHBoxLayout() + event_check = QCheckBox(text=""+event) + line.addWidget( event_check ) + self.types[ event ] = [ event_check ] + event_check.setChecked( True ) + if i%ncols == (ncols-1): + layout.addLayout( line ) + line = None + if line is not None: + layout.addLayout( line ) + return layout + + + def close( self ): + """ Close the pop-up window """ + self.hide() + + def choose( self ): + """ Show the interface to select the choices """ + self.show() + + def get_current_settings( self, setting ): + """ Get current settings of check or not of features """ + for event, event_cbox in self.types.items(): + setting[event] = event_cbox[0].isChecked() + return setting + + def apply_settings( self, settings ): + """ Set the checkboxes from preferenced settings """ + for evt, checked in settings.items(): + if evt in self.types.keys(): + self.types[evt][0].setChecked( checked ) + def get_types( self ): + """ Returns the list of events to measure """ + events = [] + for evt, evt_cbox in self.types.items(): + if evt_cbox[0].isChecked(): + events.append( evt ) + return events class FeaturesTable(QWidget): """ Widget to visualize and interact with the measurement table """ @@ -693,12 +981,14 @@ class FeaturesTable(QWidget): class TemporalPlots(QWidget): """ Widget to visualize and interact with temporal plots """ - def __init__(self, napari_viewer): + def __init__(self, napari_viewer, epicure): super().__init__() self.viewer = napari_viewer + self.epicure = epicure self.features_list = ["frame"] self.parameter_gui() self.vline = None + self.ymin = None #self.viewer.window.add_dock_widget( self.plot_wid, name="Temporal plot" ) def parameter_gui(self): @@ -706,23 +996,19 @@ class TemporalPlots(QWidget): layout = QVBoxLayout() - feat_choice = QHBoxLayout() - feat_choice_lab = QLabel() - feat_choice_lab.setText("Plot feature") - feat_choice.addWidget(feat_choice_lab) - self.feature_choice = QComboBox() - feat_choice.addWidget(self.feature_choice) + ## choice of feature to plot + feat_choice, self.feature_choice = wid.list_line( label="Plot feature", descr="Choose the feature to plot", func=self.plot_feature ) layout.addLayout(feat_choice) - - self.avg_group = QCheckBox(text="Average by groups") + ## option to average by group + self.avg_group = wid.add_check( "Average by groups", False, self.plot_feature, descr="Show a line by cell or a line by group" ) layout.addWidget(self.avg_group) - self.avg_group.setChecked(False) - + ## show the plot self.plot_wid = self.create_plotwidget() layout.addWidget(self.plot_wid) + ## save plot or save data of the plot + line = wid.double_button( "Save plot image", self.save_plot_image, "Save the grapic in a PNG file", "Save plot data", self.save_plot_data, "Save the value used for the plot in .csv file" ) + layout.addLayout( line ) self.setLayout(layout) - self.feature_choice.currentIndexChanged.connect(self.plot_feature) - self.avg_group.stateChanged.connect(self.plot_feature) def setTable(self, table): """ Data table to plot """ @@ -755,27 +1041,51 @@ class TemporalPlots(QWidget): if feat == "": return self.ax.cla() - tab = list(zip(self.table["frame"], self.table[feat], self.table["label"], self.table["group"])) - df = pand.DataFrame( tab, columns=["frame", feat, "label", "group"] ) - #df["group"] = df["group"].replace("None", "Ungrouped") - df.set_index('frame', inplace=True) - #self.ax.plot(self.table["frame"], self.table[feat]) - if self.avg_group.isChecked(): - dfmean = df.groupby(['group', 'frame'])[feat].mean().reset_index() - dfmean.set_index('frame', inplace=True) - df.columns.name = 'group' - dfmean.groupby('group')[feat].plot(legend=False, ax=self.ax) - self.ax.legend(np.unique(dfmean['group'])) + if "group" in self.table: + tab = list(zip(self.table["frame"], self.table[feat], self.table["label"], self.table["group"])) + self.df = pand.DataFrame( tab, columns=["frame", feat, "label", "group"] ) else: - df.groupby('label')[feat].plot(legend=False, ax=self.ax) + tab = list(zip(self.table["frame"], self.table[feat], self.table["label"])) + self.df = pand.DataFrame( tab, columns=["frame", feat, "label"] ) + self.df.set_index('frame', inplace=True) + if "group" in self.table and self.avg_group.isChecked(): + self.dfmean = self.df.groupby(['group', 'frame'])[feat].mean().reset_index() + self.dfmean.set_index('frame', inplace=True) + self.df.columns.name = 'group' + self.dfmean.groupby('group')[feat].plot(legend=False, ax=self.ax) + self.ax.legend(np.unique(self.dfmean['group'])) + else: + self.df.groupby('label')[feat].plot(legend=False, ax=self.ax) self.ax.set_ylabel(''+feat) self.ax.set_xlabel('Time (frame)') self.fig.canvas.draw_idle() self.ymin, self.ymax = self.ax.get_ylim() + def save_plot_image( self ): + """ Save current plot graphic to PNG image """ + feat = self.feature_choice.currentText() + outfile = self.epicure.outname()+"_plot_"+feat+".png" + if self.fig is not None: + self.fig.savefig( outfile ) + if self.epicure.verbose > 0: + ut.show_info("Measures saved in "+outfile) + + def save_plot_data( self ): + """ Save the raw data to redraw the current plot to csv file """ + feat = self.feature_choice.currentText() + outfile = self.epicure.outname()+"_time_"+feat+".csv" + if self.avg_group.isChecked(): + data = self.dfmean.reset_index()[["frame", "group", feat]] + data[["frame", "group", feat]].to_csv( outfile, sep='\t', header=True, index=False ) + else: + data = self.df.reset_index()[["frame", "label", feat]] + data[["frame", "label", feat]].to_csv( outfile, sep='\t', header=True, index=False ) + def move_framepos(self, frame): """ Move the vertical line showing the current frame position in the main window """ if self.ax is not None: + if self.ymin is None: + self.ymin, self.ymax = self.ax.get_ylim() if self.vline is not None: self.vline.remove() ymin = float(self.ymin*1.01) diff --git a/src/epicure/preferences.py b/src/epicure/preferences.py new file mode 100644 index 0000000000000000000000000000000000000000..f5751bca21d71d5484843199840a352a26beb7c5 --- /dev/null +++ b/src/epicure/preferences.py @@ -0,0 +1,392 @@ +from qtpy.QtWidgets import QPushButton, QVBoxLayout, QTabWidget, QWidget, QComboBox, QLabel, QLineEdit, QGroupBox, QHBoxLayout, QColorDialog +#from qtpy.QtGui import QColor +import napari +import epicure.Utils as ut +from pathlib import Path +import os, pickle + +def edit_preferences(): + """ Launch preferences edition interface""" + viewer = napari.current_viewer() + prefgui = PreferencesGUI( viewer ) + return prefgui + +class PreferencesGUI( QWidget ): + """ Handles user preferences for shortcuts, default widget state """ + + def __init__(self, napari_viewer): + """ Initialize the tab with the different widgets """ + super().__init__() + + ## preferences (shortcuts, plugin state) object + self.pref = Preferences() + + layout = QVBoxLayout() + tabs = QTabWidget() + tabs.setObjectName("Preferences") + + ## shortcut and plugin state preferences tabs + self.shortcuts = ShortCut( napari_viewer, self.pref ) + tabs.addTab( self.shortcuts, "Shortcuts Config." ) + self.displays = DisplaySettings( napari_viewer, self.pref ) + tabs.addTab( self.displays, "Display Config." ) + layout.addWidget(tabs) + + ## save option + self.save_pref = QPushButton("Save preferences", parent=self) + layout.addWidget( self.save_pref ) + self.save_pref.clicked.connect( self.save ) + + ## add to main interface + self.setLayout( layout ) + #napari_viewer.window.add_dock_widget( main_widget, name="Preferences" ) + + def save( self ): + """ Save current preferences: update them and save to default file """ + self.shortcuts.update_pref() + self.pref.save() + + +class Preferences(): + """ Handles user-specific preferences (shortcuts, widgets states) """ + + def __init__( self ): + """ Initialise file path, load current preferences""" + self.build_preferences_path() + + self.load_default_shortcuts() + self.load_default_settings() + if os.path.exists( self.preference_path ): + self.load() + + def build_preferences_path( self ): + """ Build (create directories if necessary) preference path """ + home_dir = Path.home() + self.preference_path = os.path.join( home_dir, ".napari" ) + if not os.path.exists( self.preference_path ): + os.mkdir( self.preference_path ) + self.preference_path = os.path.join( self.preference_path, "epicure_preferences.pkl" ) + + def save( self ): + """ Save the current preferences to the preference files in user home """ + outfile = open( self.preference_path, "wb" ) + pickle.dump( self.shortcuts, outfile ) + pickle.dump( self.settings, outfile ) + outfile.close() + print( "Preferences saved in file "+self.preference_path ) + + def set_preferences( self, default_prefs, prefs ): + """ Merge (recursively) the preferences with the default ones """ + for key, vals in prefs.items(): + if key in default_prefs.keys(): + if isinstance( vals, dict ): + self.set_preferences( default_prefs[key], vals ) + else: + default_prefs[key] = vals + else: + default_prefs[key] = vals + + def load( self ): + """ Load the current preferences to the preference files in user home """ + infile = open( self.preference_path, "rb" ) + shortcuts = pickle.load( infile ) + self.set_preferences( self.shortcuts, shortcuts ) + try: + settings = pickle.load( infile ) + #print(settings) + self.set_preferences( self.settings, settings ) + #print(self.settings) + except: + self.load_default_settings() + #print(self.shortcuts) + infile.close() + #print( "Preferences loaded from file "+self.preference_path ) + + def get_settings( self ): + """ Return the dict of prefered settings (widget state) """ + return self.settings + + def get_shortcuts( self ): + """ Return the dict of shortcuts """ + return self.shortcuts + + def add_key_shortcut( self, main_type, shortname, fulltext, key ): + """ Add a keyboard shortcut """ + if main_type not in self.shortcuts.keys(): + self.shortcuts[ main_type ] = {} + ## initialize the new shortcut object + self.shortcuts[ main_type ][ shortname ] = {} + sc = self.shortcuts[ main_type ][ shortname ] + sc["type"] = "key" + sc["text"] = fulltext + sc["key"] = key + + def add_click_shortcut( self, main_type, shortname, fulltext, button, modifiers=None ): + """ Add a keyboard shortcut """ + if main_type not in self.shortcuts.keys(): + self.shortcuts[ main_type ] = {} + ## initialize the new shortcut object + self.shortcuts[ main_type ][ shortname ] = {} + sc = self.shortcuts[ main_type ][ shortname ] + sc["type"] = "click" + sc["text"] = fulltext + sc["button"] = button + if modifiers is not None: + sc["modifiers"] = modifiers + + def load_default_shortcuts( self ): + """ Load all default shortcuts """ + + self.shortcuts = {} + + ## General shortcuts + self.add_key_shortcut( "General", shortname="show help", fulltext="show/hide overlay help message", key="h" ) + self.add_key_shortcut( "General", shortname="show all", fulltext="show all shortcuts in a separate window", key="a" ) + self.add_key_shortcut( "General", shortname="save segmentation", fulltext="save the segmentation and epicure files", key="s" ) + self.add_key_shortcut( "General", shortname="save movie", fulltext="save the movie with current display", key="Shift-s" ) + + ## Labels edition (static) shortcuts + self.add_key_shortcut( "Labels", shortname="unused paint", fulltext="set the current label to unused value and go to paint mode", key="n" ) + self.add_key_shortcut( "Labels", shortname="unused fill", fulltext="set the current label to unused value and go to fill mode", key="Shift-n" ) + self.add_key_shortcut( "Labels", shortname="swap mode", fulltext="<key shortcut> then <Control>+Left click on one cell to another to swap their values", key="w" ) + + self.add_click_shortcut( "Labels", shortname="erase", fulltext="erase the cell under the click", button="Right", modifiers=None ) + self.add_click_shortcut( "Labels", shortname="merge", fulltext="drag-click from one cell to another to merge them", button="Left", modifiers=["Control"] ) + self.add_click_shortcut( "Labels", shortname="split accross", fulltext="drag-click in the cell to split into 2 cells ", button="Right", modifiers=["Control"] ) + self.add_click_shortcut( "Labels", shortname="split draw", fulltext="drag-click draw a junction to split in 2 cells", button="Right", modifiers=["Alt"] ) + self.add_click_shortcut( "Labels", shortname="redraw junction", fulltext="drag-click draw a junction to correct it", button="Left", modifiers=["Alt"] ) + self.add_key_shortcut( "Labels", shortname="draw junction mode", fulltext="<key shortcut> then Right click drawing connected junction(s) to create new cell", key="j" ) + self.add_click_shortcut( "Labels", shortname="drawing junction", fulltext="Draw junction mode ON. Drag-click draw a junction to create new cell(s)", button="Left", modifiers=["Control"] ) + + ## Seeds (manual segmentation) shortcuts + self.add_key_shortcut( "Seeds", shortname="new seed", fulltext="<key shortcut> then left-click to place a seed", key="e" ) + + ## Groups shortcuts + self.add_click_shortcut( "Groups", shortname="add group", fulltext="add the clicked cell to the current group", button="Left", modifiers=["Shift"] ) + self.add_click_shortcut( "Groups", shortname="remove group", fulltext="remove the clicked cell from the group", button="Right", modifiers=["Shift"] ) + + ## events edition shortcuts + self.add_key_shortcut( "Events", shortname="next", fulltext="zoom on next event", key="Space" ) + self.add_click_shortcut( "Events", shortname="zoom", fulltext="Zoom on the clicked event", button="Left", modifiers=["Control", "Alt"] ) + self.add_click_shortcut( "Events", shortname="delete", fulltext="Remove the clicked event", button="Right", modifiers=["Control", "Alt"] ) + + ## Tracks edition shortcuts + self.add_key_shortcut( "Tracks", shortname="show", fulltext="show/hide the tracks", key="r" ) + self.add_key_shortcut( "Tracks", shortname="lineage color", fulltext="color the tracks by lineage", key="l" ) + self.add_click_shortcut( "Tracks", shortname="add division", fulltext="add a division: drag-click from first to second daugther", button="Left", modifiers=["Control", "Shift"] ) + self.add_key_shortcut( "Tracks", shortname="mode", fulltext="on/off track editing mode", key="t" ) + self.add_click_shortcut( "Tracks", shortname="merge first", fulltext="+track mode ON. Merge tracks: select the first", button="Left" ) + self.add_click_shortcut( "Tracks", shortname="merge second", fulltext="+trackmode ON. Merge tracks: selec the second", button="Right" ) + self.add_click_shortcut( "Tracks", shortname="split track", fulltext="+trackmode ON. Split the track temporally in 2", button="Right", modifiers=["Shift"] ) + self.add_click_shortcut( "Tracks", shortname="start manual", fulltext="+trackmode ON. Start manual tracking, clicking on cells", button="Left", modifiers=["Control"] ) + self.add_click_shortcut( "Tracks", shortname="end manual", fulltext="+trackmode ON. Finish manual tracking", button="Right", modifiers=["Control"] ) + self.add_click_shortcut( "Tracks", shortname="interpolate first", fulltext="+trackmode ON. Interpolate temporally labels: select first", button="Left", modifiers=["Alt"] ) + self.add_click_shortcut( "Tracks", shortname="interpolate second", fulltext="+trackmode ON. Interpolate temporally labels: select second", button="Right", modifiers=["Alt"] ) + self.add_click_shortcut( "Tracks", shortname="swap", fulltext="+trackmode ON. Drag click to swap 2 tracks from current frame", button="Left", modifiers=["Shift"] ) + self.add_click_shortcut( "Tracks", shortname="delete", fulltext="+trackmode ON. Delete all the track from current frame", button="Right", modifiers=["Control", "Alt"] ) + + ## Visualisation option shortcuts + self.add_key_shortcut( "Display", shortname="vis. segmentation", fulltext="show/hide segmentation layer", key="b" ) + self.add_key_shortcut( "Display", shortname="vis. movie", fulltext="show/hide movie layer", key="v" ) + self.add_key_shortcut( "Display", shortname="vis. event", fulltext="show.hide events layer", key="x" ) + self.add_key_shortcut( "Display", shortname="only movie", fulltext="show ONLY movie layer on/off", key="c" ) + self.add_key_shortcut( "Display", shortname="light view", fulltext="on/off light segmentation view", key="d" ) + self.add_key_shortcut( "Display", shortname="skeleton", fulltext="show/hide/update segmentation skeleton", key="k" ) + self.add_key_shortcut( "Display", shortname="show side", fulltext="view layers side by side on/off", key="z" ) + self.add_key_shortcut( "Display", shortname="grid", fulltext="show/hide grid", key="g" ) + self.add_key_shortcut( "Display", shortname="increase", fulltext="increase label contour size", key="Control-c" ) + self.add_key_shortcut( "Display", shortname="decrease", fulltext="decrease label contour size", key="Control-d" ) + + def load_default_settings( self ): + """ Load all default widget settings """ + self.settings = {} + + ## Default visualisation set-up + self.settings["Display"] = {} + self.settings["Display"]["Layers"] = { 'Tracks': True, 'events': True, 'ROIs': False, 'Segmentation': True, 'Movie': True, 'EpicGrid': False, 'Groups': False } + + ## widgets colors + self.load_default_colors() + + ## default visualisation of events widget + self.settings["events"] = {} + + def load_default_colors( self ): + """ Load the defualt GUI colors """ + self.settings["Display"]["Colors"] = {} + col_set = self.settings["Display"]["Colors"] + col_set["button"] = "rgb(40, 60, 75)" + col_set["Help button"] = "rgb(62, 60, 75)" + col_set["Reset button"] = "rgb(70, 68, 85)" + col_set["checkbox"] = "rgb(40, 52, 65)" + col_set["line edit"] = "rgb(30, 30, 40)" + col_set["group"] = "rgb(33,42,55)" + col_set["group4"] = "rgb(37,37,57)" + col_set["group3"] = "rgb(30,35,40)" + col_set["group2"] = "rgb(30,40,50)" + + +class ShortCut( QWidget ): + """ Class to handle edit EpiCure shortcuts """ + + def __init__( self, napari_viewer, pref ): + super().__init__() + + layout = QVBoxLayout() + + self.sc = pref.get_shortcuts() + ## choice list to choose which shortcuts to edit + self.shortcut_types = self.sc.keys() + self.sc_types = QComboBox() + self.sc_groups = {} + self.sc_guis = {} + layout.addWidget( self.sc_types ) + for sc_type in self.shortcut_types: + self.sc_types.addItem( sc_type ) + self.sc_guis[sc_type] = {} + self.sc_groups[sc_type] = self.create_sc_type( sc_type ) + layout.addWidget( self.sc_groups[sc_type] ) + self.show_sc_type() + + self.setLayout(layout) + self.sc_types.currentIndexChanged.connect( self.show_sc_type ) + + def show_sc_type( self ): + """ Show only selected shortcut subset """ + for sc_type in self.shortcut_types: + self.sc_groups[ sc_type ].setVisible( self.sc_types.currentText() == sc_type ) + + def create_sc_type( self, sc_type ): + """ Interface to edit shortcut subset of a given type """ + sc_curgroup = QGroupBox( "" ) + sc_layout = QVBoxLayout() + + ## add each shortcut from the current selected group + cur_shortcuts = self.sc[ sc_type ] + for shortname, val in cur_shortcuts.items(): + new_line = QHBoxLayout() + ## current keyboard shortcut + if val["type"] == "click": + ## shortcut is a mouse shortcut + if "modifiers" in val.keys(): + ind = 0 + for modif in val["modifiers"]: + cur_modif = QComboBox() + cur_modif.addItem("") + cur_modif.addItem("Control") + cur_modif.addItem("Shift") + cur_modif.addItem("Alt") + new_line.addWidget( cur_modif ) + cur_modif.setCurrentText( modif ) + self.sc_guis[sc_type][ shortname+"modifiers"+str(ind) ] = cur_modif + ind = ind + 1 + cur_click = QComboBox() + cur_click.addItem("Left-click") + cur_click.addItem("Right-click") + new_line.addWidget( cur_click ) + if val["button"] == "Right": + cur_click.setCurrentText( "Right-click" ) + self.sc_guis[sc_type][ shortname ] = cur_click + if val["type"] == "key": + new_line_val = QLineEdit() + new_line_val.setText( val["key"] ) + self.sc_guis[sc_type][ shortname ] = new_line_val + new_line.addWidget( new_line_val ) + ## full description of the shortcut + long_description = QLabel() + long_description.setText( val["text"] ) + new_line.addWidget( long_description ) + sc_layout.addLayout( new_line ) + #empty = QLabel() + #sc_layout.addWidget( empty ) + + sc_curgroup.setLayout( sc_layout ) + return sc_curgroup + + def update_pref( self ): + """ Update the shortcuts in the Preference based on current values """ + for sc_type in self.shortcut_types: + gui = self.sc_guis[ sc_type ] + sc_group = self.sc[ sc_type ] + for shortname, vals in sc_group.items(): + if vals["type"] == "click": + ## update the modifiers if there are some + ind = 0 + if "modifiers" in vals.keys(): + del vals["modifiers"] + while shortname+"modifiers"+str(ind) in gui.keys(): + modif = gui[ shortname+"modifiers"+str(ind) ].currentText() + if "modifiers" not in vals.keys(): + vals["modifiers"] = [] + if modif != "": + vals["modifiers"].append(modif) + ind = ind + 1 + if len( vals["modifiers"] ) == 0: + del vals["modifiers"] + ## update the button information + click = gui[shortname].currentText() + if click == "Left-click": + vals["button"] = "Left" + else: + vals["button"] = "Right" + if vals["type"] == "key": + vals["key"] = gui[shortname].text() + + +class DisplaySettings( QWidget ): + """ Class to handle edit EpiCure display button colors...)""" + + def __init__( self, napari_viewer, pref ): + super().__init__() + self.settings = pref.get_settings() + if "Colors" not in self.settings["Display"]: + pref.load_default_colors() + colors = self.settings["Display"]["Colors"] + + ## interface of display choices + layout = QVBoxLayout() + self.grid_color = QPushButton("EpicGrid color", self) + self.grid_color.clicked.connect( self.get_grid_color ) + layout.addWidget( self.grid_color ) + + self.add_color( layout, "Buttons color", "button", "Choose default color of buttons" ) + self.add_color( layout, "Help buttons color", "Help button", "Choose color of buttons for Help actions" ) + self.add_color( layout, "Reset buttons color", "Reset button", "Choose color of buttons for Reset actions" ) + self.add_color( layout, "CheckBox color", "checkbox", "Choose color of checkboxes" ) + self.add_color( layout, "Input color", "line edit", "Choose color of editable parameters boxes" ) + self.add_color( layout, "Subpanels color", "group", "Choose color of option subpanels that appears when clicked/selected" ) + self.add_color( layout, "Subpanels color 2", "group2", "Choose second color of option subpanels that appears when clicked/selected" ) + self.add_color( layout, "Subpanels color 3", "group3", "Choose third color of option subpanels that appears when clicked/selected" ) + self.add_color( layout, "Subpanels color 4", "group4", "Choose fourth color of option subpanels that appears when clicked/selected" ) + + self.setLayout(layout) + + def add_color( self, layout, label, setname, descr="" ): + """ Add a choice of color (push button that opens a color dialog) """ + btn = QPushButton( label ) + if descr != "": + btn.setToolTip( descr ) + def get_color(): + """ opens color dialog and set button color to it """ + color = QColorDialog.getColor() + if color.isValid(): + self.settings["Display"]["Colors"][setname] = color.name() + btn.setStyleSheet( 'QPushButton {background-color: '+color.name()+'}' ) + btn.clicked.connect( get_color ) + if setname in self.settings["Display"]["Colors"]: + color = self.settings["Display"]["Colors"][setname] + btn.setStyleSheet( 'QPushButton {background-color: '+color+'}' ) + layout.addWidget( btn ) + + def get_grid_color( self ): + """ Get the EpiCGrid color """ + color = QColorDialog.getColor() + if color.isValid(): + if "Display" not in self.settings: + self.settings["Display"] = {} + self.settings["Display"]["Grid color"] = color.name() + self.grid_color.setStyleSheet( 'QPushButton {background-color: '+color.name()+'}' ) + + + + diff --git a/src/epicure/start_epicuring.py b/src/epicure/start_epicuring.py index 3ffc3c9bbae28ded377ec7f24f9c818563eb2da5..d7164bab6b884ee6eff0f0b244adada36b82a459 100644 --- a/src/epicure/start_epicuring.py +++ b/src/epicure/start_epicuring.py @@ -33,46 +33,53 @@ def start_epicure(): nonlocal caxis, cval image_file = get_files.image_file.value caxis, cval = Epic.load_movie(image_file) - if caxis is not None: - get_files.junction_chanel.max = cval-1 - get_files.junction_chanel.visible = True - set_chanel() - #get_files.scale_xy.value = Epic.scale imgdir = ut.get_directory(image_file) get_files.segmentation_file.value = pathlib.Path(imgdir) labname = Epic.suggest_segfile( get_files.output_dirname.value ) + Epic.set_names( get_files.output_dirname.value ) if labname is not None: get_files.segmentation_file.value = pathlib.Path(labname) + Epic.read_epicure_metadata() + if caxis is not None: + get_files.junction_chanel.max = cval-1 + get_files.junction_chanel.visible = True + set_chanel() + get_files.scale_xy.value = Epic.epi_metadata["ScaleXY"] + get_files.timeframe.value = Epic.epi_metadata["ScaleT"] + get_files.unit_xy.value = Epic.epi_metadata["UnitXY"] + get_files.unit_t.value = Epic.epi_metadata["UnitT"] ut.show_duration(start_time, header="Movie loaded in ") def show_others(): """ Display other chanels from the initial movie """ for ochan in range(cval): - ut.remove_layer(viewer, "MovieOtherChanel_"+str(ochan)) + ut.remove_layer(viewer, "MovieChannel_"+str(ochan)) if get_files.show_other_chanels.value == True: Epic.add_other_chanels(int(get_files.junction_chanel.value), caxis) def set_chanel(): """ Set the correct chanel that contains the junction signal """ start_time = ut.start_time() - Epic.set_chanel(int(get_files.junction_chanel.value), caxis) + Epic.set_chanel( int(get_files.junction_chanel.value), caxis ) show_others() ut.show_duration(start_time, header="Movie chanel loaded in ") @magicgui(call_button="Start cure", junction_chanel={"widget_type": "Slider", "min":0, "max": 0}, - #scale_xy = {"widget_type": "LiteralEvalLineEdit"}, - #scale_t = {"widget_type": "LiteralEvalLineEdit"}, + scale_xy = {"widget_type": "LiteralEvalLineEdit"}, + timeframe = {"widget_type": "LiteralEvalLineEdit"}, junction_half_thickness={"widget_type": "LiteralEvalLineEdit"}, nbparallel_threads = {"widget_type": "LiteralEvalLineEdit"}, - verbose_level={"widget_type": "Slider", "min":0, "max": 2}, + verbose_level={"widget_type": "Slider", "min":0, "max": 3}, ) def get_files( image_file = pathlib.Path(cdir), junction_chanel = 0, segmentation_file = pathlib.Path(cdir), - #scale_xy = 1, - #scale_t = 1, + scale_xy = 1, + unit_xy = "um", + timeframe = 1, + unit_t = "min", advanced_parameters = False, show_other_chanels = True, process_frames_parallel = False, @@ -91,7 +98,7 @@ def start_epicure(): Epic.nparallel = nbparallel_threads #Epic.load_segmentation(segmentation_file) Epic.set_thickness(junction_half_thickness) - #Epic.set_scales(scale_xy, scale_t) + Epic.set_scales(scale_xy, timeframe, unit_xy, unit_t) Epic.go_epicure(outdir, segmentation_file) set_visibility() diff --git a/src/epicure/suspecting.py b/src/epicure/suspecting.py deleted file mode 100644 index 6ec750c1e732173e52ed293188223be73d1f6924..0000000000000000000000000000000000000000 --- a/src/epicure/suspecting.py +++ /dev/null @@ -1,960 +0,0 @@ -import numpy as np -import os -import napari -from skimage.measure import regionprops, label -from skimage import filters -from skimage.morphology import binary_erosion, binary_dilation, disk -from qtpy.QtWidgets import QPushButton, QVBoxLayout, QHBoxLayout, QGroupBox, QWidget, QCheckBox, QSlider, QLabel, QDoubleSpinBox, QComboBox, QSpinBox, QLineEdit -from qtpy.QtCore import Qt -import epicure.Utils as ut -from skimage.segmentation import expand_labels -from napari.utils import progress -try: - from skimage.graph import RAG -except: - from skimage.future.graph import RAG ## older version of scikit-image - -""" - EpiCure - Suspects interface - Handle suspects and suggestion layer -""" - -class Suspecting(QWidget): - - def __init__(self, napari_viewer, epic): - super().__init__() - self.viewer = napari_viewer - self.epicure = epic - self.seglayer = self.viewer.layers["Segmentation"] - self.border_cells = None ## list of cells that are on the border (touch the background) - self.suspectlayer_name = "Suspects" - self.suspects = None - self.win_size = 10 - - ## Print the current number of suspects - self.nsuspect_print = QLabel("") - self.update_nsuspects_display() - - self.create_suspectlayer() - layout = QVBoxLayout() - layout.addWidget( self.nsuspect_print ) - - ### Reset: delete all suspects - reset_suspect_btn = QPushButton("Reset suspects", parent=self) - layout.addWidget(reset_suspect_btn) - reset_suspect_btn.clicked.connect(self.reset_all_suspects) - - ## Error suggestions based on cell features - outlier_vis = QCheckBox(text="Outliers options") - outlier_vis.setChecked(False) - layout.addWidget(outlier_vis) - self.create_outliersBlock() - outlier_vis.stateChanged.connect(self.show_outlierBlock) - layout.addWidget(self.featOutliers) - - ## Error suggestions based on tracks - track_vis = QCheckBox(text="Track options") - track_vis.setChecked(True) - layout.addWidget(track_vis) - self.create_tracksBlock() - track_vis.stateChanged.connect(self.show_tracksBlock) - layout.addWidget(self.suspectTrack) - - ## Visualisation options - suspect_disp = QCheckBox(text="Display options") - suspect_disp.setChecked(True) - layout.addWidget(suspect_disp) - self.create_displaySuspectBlock() - suspect_disp.stateChanged.connect(self.show_displaySuspectBlock) - layout.addWidget(self.displaySuspect) - self.displaySuspect.setVisible(True) - - self.setLayout(layout) - self.key_binding() - - def key_binding(self): - """ active key bindings for suspects options """ - self.epicure.overtext["suspects"] = "---- Suspects editing ---- \n" - self.epicure.overtext["suspects"] += "<Ctrl>+<Alt>+Left click to zoom on a suspect \n" - self.epicure.overtext["suspects"] += "<Ctrl>+<Alt>+Right click to remove a suspect \n" - self.epicure.overtext["suspects"] += "<Space bar> zoom on next suspect \n" - - @self.epicure.seglayer.mouse_drag_callbacks.append - def handle_suspect(seglayer, event): - if event.type == "mouse_press": - if len(event.modifiers)==2: - if ("Control" in event.modifiers) and ('Alt' in event.modifiers): - if event.button == 2: - ind = ut.getCellValue( self.suspects, event ) - if self.epicure.verbose > 1: - print("Removing clicked suspect, at index "+str(ind)) - if ind is None: - ## click was not on a suspect - return - sid = self.suspects.properties["id"][ind] - if sid is not None: - self.exonerate_one(ind) - else: - if self.epicure.verbose > 1: - print("Suspect with id "+str(sid)+" not found") - self.remove_suspicions( sid ) - self.suspects.refresh() - if event.button == 1: - ind = ut.getCellValue( self.suspects, event ) - sid = self.suspects.properties["id"][ind] - if self.epicure.verbose > 1: - print("Zoom on suspect with id "+str(sid)+"") - self.zoom_on_suspect( event.position, sid ) - - @self.epicure.seglayer.bind_key('Space', overwrite=True) - def go_next(seglayer): - """ Select next suspect and zoom on it """ - num_suspect = int(self.suspect_num.value()) - if num_suspect < 0: - if self.nb_suspects() == "_": - if self.epicure.verbose > 0: - print("No more suspect") - return - else: - self.suspect_num.setValue(0) - else: - self.suspect_num.setValue( (num_suspect+1)%(self.nb_suspects()) ) - self.go_to_suspect() - - def create_suspectlayer(self): - """ Create a point layer that contains the suspects """ - features = {} - pts = [] - self.suspects = self.viewer.add_points( np.array(pts), properties=features, face_color="red", size = 10, symbol='x', name=self.suspectlayer_name, ) - self.suspicions = {} - self.update_nsuspects_display() - self.epicure.finish_update() - - def load_suspects(self, pts, features, suspicions, symbols=None, colors=None): - """ Load suspects data from file and reinitialize layer with it""" - ut.remove_layer(self.viewer, self.suspectlayer_name) - if symbols is None: - symbols = "x" - if colors is None: - colors = "red" - symb = symbols - self.suspects = self.viewer.add_points( np.array(pts), properties=features, face_color=colors, size = 10, symbol=symbols, name=self.suspectlayer_name, ) - self.suspicions = suspicions - self.update_nsuspects_display() - self.epicure.finish_update() - - - ############### Display suspect options - - def update_nsuspects_display( self ): - """ Update the display of number of suspect""" - self.nsuspect_print.setText( str(self.nb_suspects())+" suspects" ) - - def nb_suspects(self): - """ Returns current number of suspects """ - if self.suspects is None: - return "_" - if self.suspects.properties is None: - return "_" - if "score" not in self.suspects.properties: - return "_" - return len(self.suspects.properties["score"]) - - def show_displaySuspectBlock(self): - self.displaySuspect.setVisible(not self.displaySuspect.isVisible()) - - def create_displaySuspectBlock(self): - ''' Block interface of displaying suspect layer options ''' - self.displaySuspect = QGroupBox("Display options") - disp_layout = QVBoxLayout() - - ## Color mode - colorlay = QHBoxLayout() - color_label = QLabel() - color_label.setText("Color by:") - colorlay.addWidget(color_label) - self.color_choice = QComboBox() - colorlay.addWidget(self.color_choice) - self.color_choice.addItem("None") - self.color_choice.addItem("score") - self.color_choice.addItem("tracking-2->1") - self.color_choice.addItem("tracking-1-2-*") - self.color_choice.addItem("track-length") - self.color_choice.addItem("division") - self.color_choice.addItem("area") - self.color_choice.addItem("solidity") - self.color_choice.addItem("intensity") - self.color_choice.addItem("tubeness") - self.color_choice.currentIndexChanged.connect(self.color_suspects) - disp_layout.addLayout(colorlay) - - sizelay = QHBoxLayout() - size_label = QLabel() - size_label.setText("Point size:") - sizelay.addWidget(size_label) - self.suspect_size = QSlider(Qt.Horizontal) - self.suspect_size.setMinimum(0) - self.suspect_size.setMaximum(50) - self.suspect_size.setSingleStep(1) - self.suspect_size.setValue(10) - self.suspect_size.valueChanged.connect(self.display_suspect_size) - sizelay.addWidget(self.suspect_size) - disp_layout.addLayout(sizelay) - - ### Interface to select a suspect and zoom on it - chooselay = QHBoxLayout() - choose_lab = QLabel() - choose_lab.setText("Suspect n°") - chooselay.addWidget(choose_lab) - self.suspect_num = QSpinBox() - self.suspect_num.setMinimum(0) - self.suspect_num.setMaximum(len(self.suspects.data)-1) - self.suspect_num.setSingleStep(1) - self.suspect_num.setValue(0) - chooselay.addWidget(self.suspect_num) - disp_layout.addLayout(chooselay) - go_suspect_btn = QPushButton("Go to suspect", parent=self) - disp_layout.addWidget(go_suspect_btn) - go_suspect_btn.clicked.connect(self.go_to_suspect) - clear_suspect_btn = QPushButton("Exonerate current suspect", parent=self) - disp_layout.addWidget(clear_suspect_btn) - clear_suspect_btn.clicked.connect(self.clear_suspect) - - ## all features - self.displaySuspect.setLayout(disp_layout) - - ##### - def reset_suspect_range(self): - """ Reset the max num of suspect """ - nsus = len(self.suspects.data)-1 - if self.suspect_num.value() > nsus: - self.suspect_num.setValue(0) - self.suspect_num.setMaximum(nsus) - - def go_to_suspect(self): - """ Zoom on the currently selected suspect """ - num_suspect = int(self.suspect_num.value()) - if num_suspect < 0: - if self.nb_suspects() == "_": - if self.epicure.verbose > 0: - print("No more suspect") - return - else: - self.suspect_num.setValue(0) - num_suspect = 0 - pos = self.suspects.data[num_suspect] - suspect_id = self.suspects.properties["id"][num_suspect] - self.zoom_on_suspect( pos, suspect_id ) - - def zoom_on_suspect( self, suspect_pos, suspect_id ): - """ Zoom on chose suspect at given position """ - self.viewer.camera.center = suspect_pos - self.viewer.camera.zoom = 5 - self.viewer.dims.set_point( 0, int(suspect_pos[0]) ) - crimes = self.get_crimes(suspect_id) - if self.epicure.verbose > 0: - print("Suspected because of: "+str(crimes)) - - def color_suspects(self): - """ Color points by the selected mode """ - color_mode = self.color_choice.currentText() - self.suspects.refresh_colors() - if color_mode == "None": - self.suspects.face_color = "white" - elif color_mode == "score": - self.set_colors_from_properties("score") - else: - self.set_colors_from_suspicion(color_mode) - self.suspects.refresh_colors() - - def set_colors_from_suspicion(self, feature): - """ Set colors from given suspicion feature (eg area, tracking..) """ - if self.suspicions.get(feature) is None: - self.suspects.face_color="white" - return - posid = self.suspicions[feature] - colors = ["white"]*len(self.suspects.data) - ## change the color of all the positive suspects for the chosen feature - for sid in posid: - ind = self.index_from_id(sid) - if ind is not None: - colors[ind] = (0.8,0.1,0.1) - self.suspects.face_color = colors - - def set_colors_from_properties(self, feature): - """ Set colors from given propertie (eg score, label) """ - ncols = (np.max(self.suspects.properties[feature])) - color_cycle = [] - for i in range(ncols): - color_cycle.append( (0.25+float(i/ncols*0.75), float(i/ncols*0.85), float(i/ncols*0.75)) ) - self.suspects.face_color_cycle = color_cycle - self.suspects.face_color = feature - - def update_display(self): - self.suspects.refresh() - self.color_suspects() - - def display_suspect_size(self): - """ Change the size of the point display """ - size = int(self.suspect_size.value()) - self.suspects.size = size - self.suspects.refresh() - - ############### Suspecting functions - def get_crimes(self, sid): - """ For a given suspect, get its suspicion(s) """ - crimes = [] - for feat in self.suspicions.keys(): - if sid in self.suspicions.get(feat): - crimes.append(feat) - return crimes - - def add_suspicion(self, ind, sid, feature): - """ Add 1 to the suspicion score for given feature """ - #print(self.suspicions) - if self.suspicions.get(feature) is None: - self.suspicions[feature] = [] - self.suspicions[feature].append(sid) - self.suspects.properties["score"][ind] = self.suspects.properties["score"][ind] + 1 - - def first_suspect(self, pos, label, featurename): - """ Addition of the first suspect (initialize all) """ - ut.remove_layer(self.viewer, "Suspects") - features = {} - sid = self.new_suspect_id() - features["id"] = np.array([sid], dtype="uint16") - features["label"] = np.array([label], dtype=self.epicure.dtype) - features["score"] = np.array([0], dtype="uint8") - pts = [pos] - self.suspects = self.viewer.add_points( np.array(pts), properties=features, face_color="score", size = 10, symbol="x", name="Suspects", ) - self.add_suspicion(0, sid, featurename) - self.suspects.refresh() - self.update_nsuspects_display() - - def add_suspect(self, pos, label, reason, symb="x", color="white"): - """ Add a suspect to the list, suspected by a feature """ - if (self.ignore_borders.isChecked()) and (self.border_cells is not None): - tframe = int(pos[0]) - if label in self.border_cells[tframe]: - return - - ## initialise if necessary - if len(self.suspects.data) <= 0: - self.first_suspect(pos, label, reason) - return - - self.suspects.selected_data = [] - - ## look if already suspected, then add the charge - num, sid = self.find_suspect(pos[0], label) - if num is not None: - ## suspect already in the list. For same crime ? - if self.suspicions.get(reason) is not None: - if sid not in self.suspicions[reason]: - self.add_suspicion(num, sid, reason) - else: - self.add_suspicion(num, sid, reason) - else: - ## new suspect, add to the Point layer - ind = len(self.suspects.data) - sid = self.new_suspect_id() - self.suspects.add(pos) - self.suspects.properties["label"][ind] = label - self.suspects.properties["id"][ind] = sid - self.suspects.properties["score"][ind] = 0 - self.add_suspicion(ind, sid, reason) - - self.suspects.symbol.flags.writeable = True - self.suspects.current_symbol = symb - self.suspects.current_face_color = color - self.suspects.refresh() - self.reset_suspect_range() - self.update_nsuspects_display() - - def new_suspect_id(self): - """ Find the first unused id """ - sid = 0 - if self.suspects.properties.get("id") is None: - return 0 - while sid in self.suspects.properties["id"]: - sid = sid + 1 - return sid - - def reset_all_suspects(self): - """ Remove all suspicions """ - features = {} - pts = [] - ut.remove_layer(self.viewer, "Suspects") - self.suspects = self.viewer.add_points( np.array(pts), properties=features, face_color="red", size = 10, symbol='x', name="Suspects", ) - self.suspicions = {} - self.update_nsuspects_display() - self.update_nsuspects_display() - - def reset_suspicion(self, feature, frame): - """ Remove all suspicions of given feature, for current frame or all if frame is None """ - if self.suspicions.get(feature) is None: - return - idlist = self.suspicions[feature].copy() - for sid in idlist: - ind = self.index_from_id(sid) - if ind is not None: - if frame is not None: - if int(self.suspects.data[ind][0]) == frame: - self.suspicions[feature].remove(sid) - self.decrease_score(ind) - else: - self.suspicions[feature].remove(sid) - self.decrease_score(ind) - self.suspects.refresh() - self.update_nsuspects_display() - - def remove_suspicions(self, sid): - """ Remove all suspicions of given suspect id """ - for listval in self.suspicions.values(): - if sid in listval: - listval.remove(sid) - - def decrease_score(self, ind): - """ Decrease by one score of suspect at index ind. Delete it if reach 0""" - self.suspects.properties["score"][ind] = self.suspects.properties["score"][ind] - 1 - if self.suspects.properties["score"][ind] == 0: - self.exonerate_one(ind) - - def index_from_id(self, sid): - """ From suspect id, find the corresponding index in the properties array """ - for ind, cid in enumerate(self.suspects.properties["id"]): - if cid == sid: - return ind - return None - - def find_suspect(self, frame, label): - """ Find if there is already a suspect at given frame and label """ - suspects = self.suspects.data - suspects_lab = self.suspects.properties["label"] - for i, lab in enumerate(suspects_lab): - if lab == label: - if suspects[i][0] == frame: - return i, self.suspects.properties["id"][i] - return None, None - - def init_suggestion(self): - """ Initialize the layer that will contains propostion of tracks/segmentations """ - suggestion = np.zeros(self.seglayer.data.shape, dtype="uint16") - self.suggestion = self.viewer.add_labels(suggestion, blending="additive", name="Suggestion") - - @self.seglayer.mouse_drag_callbacks.append - def click(layer, event): - if event.type == "mouse_press": - if 'Alt' in event.modifiers: - if event.button == 1: - pos = event.position - # alt+left click accept suggestion under the mouse pointer (in all frames) - self.accept_suggestion(pos) - - def accept_suggestion(self, pos): - """ Accept the modifications of the label at position pos (all the label) """ - seglayer = self.viewer.layers["Segmentation"] - label = self.suggestion.data[tuple(map(int, pos))] - found = self.suggestion.data==label - self.exonerate( found, seglayer ) - indices = np.argwhere( found ) - ut.setNewLabel( seglayer, indices, label, add_frame=None ) - self.suggestion.data[self.suggestion.data==label] = 0 - self.suggestion.refresh() - self.update_nsuspects_display() - - def exonerate_one(self, ind): - """ Remove one suspect at index ind """ - self.suspects.selected_data = [ind] - self.suspects.remove_selected() - self.update_nsuspects_display() - - def clear_suspect(self): - """ Remove the current suspect """ - num_suspect = int(self.suspect_num.value()) - self.exonerate_one( num_suspect ) - - def exonerate_from_event(self, event): - """ Remove all suspects in the corresponding cell of position """ - label = ut.getCellValue( self.seglayer, event ) - if len(self.suspects.data) > 0: - for ind, lab in enumerate(self.suspects.properties["label"]): - if lab == label: - if self.suspects.data[ind][0] == event.position[0]: - sid = self.suspects.properties["id"][ind] - self.exonerate_one(ind) - self.remove_suspicions(sid) - - def exonerate(self, indices, seglayer): - """ Remove suspects that have been corrected/cleared """ - seglabels = np.unique(seglayer.data[indices]) - selected = [] - if self.suspects.properties.get("label") is None: - return - for ind, lab in enumerate(self.suspects.properties["label"]): - if lab in seglabels: - ## label to remove from suspect list - selected.append(ind) - if len(selected) > 0: - self.suspects.selected_data = selected - self.suspects.remove_selected() - self.update_nsuspects_display() - - - #######################################" - ## Outliers suggestion functions - def show_outlierBlock(self): - self.featOutliers.setVisible(not self.featOutliers.isVisible()) - - def create_outliersBlock(self): - ''' Block interface of functions for error suggestions based on cell features ''' - self.featOutliers = QGroupBox("Outliers highlight") - feat_layout = QVBoxLayout() - # option to avoid checked cell - #self.feat_checked = QCheckBox(text="Ignore checked cells") - #self.feat_checked.setChecked(True) - #feat_layout.addWidget(self.feat_checked) - - self.feat_onframe = QCheckBox(text="Only current frame") - self.feat_onframe.setChecked(True) - feat_layout.addWidget(self.feat_onframe) - - ## area widget - feat_area_btn = QPushButton("Area outliers", parent=self) - feat_area_btn.clicked.connect(self.suspect_area) - farea_layout = QHBoxLayout() - farea_layout.addWidget(feat_area_btn) - self.farea_out = QDoubleSpinBox() - self.farea_out.setRange(0,20) - self.farea_out.decimals = 2 - self.farea_out.setSingleStep(0.25) - self.farea_out.setValue(3) - farea_layout.addWidget(self.farea_out) - feat_layout.addLayout(farea_layout) - #self.feat_area.stateChanged.connect(self.show_areaOutliers) - - ## solid widget - feat_solid_btn = QPushButton(text="Solidity outliers", parent=self) - feat_solid_btn.clicked.connect(self.suspect_solidity) - fsolid_layout = QHBoxLayout() - fsolid_layout.addWidget(feat_solid_btn) - self.fsolid_out = QDoubleSpinBox() - self.fsolid_out.setRange(0,20) - self.fsolid_out.decimals = 2 - self.fsolid_out.setSingleStep(0.25) - self.fsolid_out.setValue(3) - fsolid_layout.addWidget(self.fsolid_out) - feat_layout.addLayout(fsolid_layout) - - ## intensity widget - feat_intensity_btn = QPushButton(text="Intensity (inside/periphery)") - feat_intensity_btn.clicked.connect(self.suspect_intensity) - fintensity_layout = QHBoxLayout() - fintensity_layout.addWidget(feat_intensity_btn) - self.fintensity_out = QDoubleSpinBox() - self.fintensity_out.setRange(0,10) - self.fintensity_out.decimals = 2 - self.fintensity_out.setSingleStep(0.05) - self.fintensity_out.setValue(1.0) - fintensity_layout.addWidget(self.fintensity_out) - feat_layout.addLayout(fintensity_layout) - - ## tubeness widget - feat_tub_btn = QPushButton(text="Tubeness (inside/periph)", parent=self) - feat_tub_btn.clicked.connect(self.suspect_tubeness) - ftub_layout = QHBoxLayout() - ftub_layout.addWidget(feat_tub_btn) - self.ftub_out = QDoubleSpinBox() - self.ftub_out.setRange(0,10) - self.ftub_out.decimals = 2 - self.ftub_out.setSingleStep(0.05) - self.ftub_out.setValue(1) - ftub_layout.addWidget(self.ftub_out) - feat_layout.addLayout(ftub_layout) - - ## all features - self.featOutliers.setLayout(feat_layout) - self.featOutliers.setVisible(False) - - def suspect_feature(self, featname, funcname ): - """ Suspect in one frame or all frames the given feature """ - onframe = self.feat_onframe.isChecked() - if onframe: - tframe = ut.current_frame(self.viewer) - self.reset_suspicion(featname, tframe) - funcname(tframe) - else: - self.reset_suspicion(featname, None) - for frame in range(self.seglayer.data.shape[0]): - funcname(frame) - self.update_display() - ut.set_active_layer( self.viewer, "Segmentation" ) - - def inspect_outliers(self, tab, props, tuk, frame, feature): - q1 = np.quantile(tab, 0.25) - q3 = np.quantile(tab, 0.75) - qtuk = tuk * (q3-q1) - for sign in [1, -1]: - #thresh = np.mean(tab) + sign * np.std(tab)*tuk - if sign > 0: - thresh = q3 + qtuk - else: - thresh = q1 - qtuk - for i in np.where((tab-thresh)*sign>0)[0]: - position = ut.prop_to_pos( props[i], frame ) - self.add_suspect( position, props[i].label, feature ) - - def suspect_area(self, state): - """ Look for outliers in term of cell area """ - self.suspect_feature( "area", self.suspect_area_oneframe ) - - def suspect_area_oneframe(self, frame): - seglayer = self.seglayer.data[frame] - props = regionprops(seglayer) - ncell = len(props) - areas = np.zeros((ncell,1), dtype="float") - for i, prop in enumerate(props): - if prop.label > 0: - areas[i] = prop.area - tuk = self.farea_out.value() - self.inspect_outliers(areas, props, tuk, frame, "area") - - def suspect_solidity(self, state): - """ Look for outliers in term ofz cell solidity """ - self.suspect_feature( "solidity", self.suspect_solidity_oneframe ) - - def suspect_solidity_oneframe(self, frame): - seglayer = self.seglayer.data[frame] - props = regionprops(seglayer) - ncell = len(props) - sols = np.zeros((ncell,1), dtype="float") - for i, prop in enumerate(props): - if prop.label > 0: - sols[i] = prop.solidity - tuk = self.fsolid_out.value() - self.inspect_outliers(sols, props, tuk, frame, "solidity") - - def suspect_intensity(self, state): - """ Look for abnormal intensity inside/periph ratio """ - self.suspect_feature( "intensity", self.suspect_intensity_oneframe ) - - def suspect_intensity_oneframe(self, frame): - seglayer = self.seglayer.data[frame] - intlayer = self.viewer.layers["Movie"].data[frame] - props = regionprops(seglayer) - for i, prop in enumerate(props): - if prop.label > 0: - self.test_intensity( intlayer, prop, frame ) - - def test_intensity(self, inten, prop, frame): - """ Test if intensity inside is much smaller than at periphery """ - bbox = prop.bbox - intbb = inten[bbox[0]:bbox[2], bbox[1]:bbox[3]] - footprint = disk(radius=self.epicure.thickness) - inside = binary_erosion(prop.image, footprint) - ininten = np.mean(intbb*inside) - dil_img = binary_dilation(prop.image, footprint) - periph = dil_img^inside - periphint = np.mean(intbb*periph) - if (periphint<=0) or (ininten/periphint > self.fintensity_out.value()): - position = ( frame, int(prop.centroid[0]), int(prop.centroid[1]) ) - self.add_suspect( position, prop.label, "intensity" ) - - def suspect_tubeness(self, state): - """ Look for abnormal tubeness inside vs periph """ - self.suspect_feature( "tubeness", self.suspect_tubeness_oneframe ) - - def suspect_tubeness_oneframe(self, frame): - seglayer = self.seglayer.data[frame] - mov = self.viewer.layers["Movie"].data[frame] - sated = np.copy(mov) - sated = filters.sato(sated, black_ridges=False) - props = regionprops(seglayer) - for i, prop in enumerate(props): - if prop.label > 0: - self.test_tubeness( sated, prop, frame ) - - def test_tubeness(self, sated, prop, frame): - """ Test if tubeness inside is much smaller than tubeness on periph """ - bbox = prop.bbox - satbb = sated[bbox[0]:bbox[2], bbox[1]:bbox[3]] - footprint = disk(radius=self.epicure.thickness) - inside = binary_erosion(prop.image, footprint) - intub = np.mean(satbb*inside) - periph = prop.image^inside - periphtub = np.mean(satbb*periph) - if periphtub <= 0: - position = ( frame, int(prop.centroid[0]), int(prop.centroid[1]) ) - self.add_suspect( position, prop.label, "tubeness" ) - else: - if intub/periphtub > self.ftub_out.value(): - position = ( frame, int(prop.centroid[0]), int(prop.centroid[1]) ) - self.add_suspect( position, prop.label, "tubeness" ) - - -############# Suspect based on track - - def show_tracksBlock(self): - self.suspectTrack.setVisible(not self.suspectTrack.isVisible()) - - def create_tracksBlock(self): - ''' Block interface of functions for error suggestions based on tracks ''' - self.suspectTrack = QGroupBox("Tracks") - track_layout = QVBoxLayout() - - self.get_div = QCheckBox(text="Get potential divisions") - self.get_div.setChecked(True) - track_layout.addWidget(self.get_div) - - self.ignore_borders = QCheckBox(text="Ignore cells on border") - self.ignore_borders.setChecked(False) - track_layout.addWidget(self.ignore_borders) - - ## track length suspicions - ilengthlay = QHBoxLayout() - self.check_length = QCheckBox(text="Flag tracks smaller than") - self.check_length.setChecked(True) - ilengthlay.addWidget(self.check_length) - self.min_length = QLineEdit() - self.min_length.setText("1") - ilengthlay.addWidget(self.min_length) - track_layout.addLayout(ilengthlay) - - ## Variability in feature suspicion - self.check_size, self.size_variability = self.add_feature_gui( track_layout, "Size variation" ) - self.check_shape, self.shape_variability = self.add_feature_gui( track_layout, "Shape variation" ) - self.shape_variability.setText("2.0") - - ## merge/split combinaisons - track_btn = QPushButton("Inspect track", parent=self) - track_btn.clicked.connect(self.inspect_tracks) - track_layout.addWidget(track_btn) - - ## tmp test - test_btn = QPushButton("Test", parent=self) - test_btn.clicked.connect(self.track_features) - track_layout.addWidget(test_btn) - - ## all features - self.suspectTrack.setLayout(track_layout) - - def add_feature_gui( self, layout, feature_name ): - """ Interface for a track feature check option """ - featlay = QHBoxLayout() - check_item = QCheckBox(text=feature_name) - check_item.setChecked(False) - featlay.addWidget( check_item ) - var_item = QLineEdit() - var_item.setText("1") - featlay.addWidget( var_item ) - layout.addLayout( featlay ) - return check_item, var_item - - - def reset_tracking_suspect(self): - """ Remove suspects from tracking """ - self.reset_suspicion("tracking-1-2-*", None) - self.reset_suspicion("tracking-2->1", None) - self.reset_suspicion("division", None) - self.reset_suspicion("track_length", None) - self.reset_suspicion("track_size", None) - self.reset_suspicion("track_shape", None) - self.reset_suspect_range() - - def track_length(self): - """ Find all cells that are only in one frame """ - max_len = int(self.min_length.text()) - labels, lengths, positions = self.epicure.tracking.get_small_tracks( max_len ) - for label, nframe, pos in zip(labels, lengths, positions): - if self.epicure.verbose > 1: - print("Suspect track length "+str(nframe)+": "+str(label)+" frame "+str(pos[0]) ) - self.add_suspect(pos, label, "track-length") - - def inspect_tracks(self): - """ Look for suspicious tracks """ - self.viewer.window._status_bar._toggle_activity_dock(True) - progress_bar = progress(total=6) - progress_bar.update(0) - self.reset_tracking_suspect() - progress_bar.update(1) - if self.ignore_borders.isChecked(): - progress_bar.set_description("Identifying border cells") - self.get_border_cells() - progress_bar.update(2) - if self.check_length.isChecked(): - progress_bar.set_description("Identifying too small tracks") - self.track_length() - progress_bar.update(3) - progress_bar.set_description("Inspect tracks 2->1") - self.track_21() - progress_bar.update(4) - if (self.check_size.isChecked()) or self.check_shape.isChecked(): - progress_bar.set_description("Inspect track features") - self.track_features() - progress_bar.update(5) - progress_bar.close() - self.viewer.window._status_bar._toggle_activity_dock(False) - - def track_21(self): - """ Look for suspect track: 2->1 """ - if self.epicure.tracking.tracklayer is None: - ut.show_error("No tracking done yet!") - return - - graph = self.epicure.tracking.graph - divisions = dict() - undiv = [] - if graph is not None: - for child, parent in graph.items(): - ## 2->1, merge, suspect - if len(parent) == 2: - onetwoone = False - ## was it only one before ? - if (parent[0] in graph.keys()) and (parent[1] in graph.keys()): - if graph[parent[0]][0] == graph[parent[1]][0]: - pos = self.epicure.tracking.get_mean_position([parent[0], parent[1]]) - if pos is not None: - if self.epicure.verbose > 1: - print("Suspect 1->2->1 track: "+str(graph[parent[0]][0])+"-"+str(parent)+"-"+str(child)+" frame "+str(pos[0]) ) - self.add_suspect(pos, parent[0], "tracking-1-2-*") - undiv.append(graph[parent[0]][0]) - onetwoone = True - - if not onetwoone: - pos = self.epicure.tracking.get_mean_position(child, only_first=True) - if pos is not None: - if self.epicure.verbose > 1: - print("Suspect 2->1 track: "+str(parent)+"-"+str(child)+" frame "+str(int(pos[0])) ) - self.add_suspect(pos, parent[0], "tracking-2->1") - else: - if self.epicure.verbose > 1: - print("Something weird, "+str(child)+" mean position") - ## 1->2, potential division - else: - if self.get_div.isChecked(): - if parent[0] not in divisions: - divisions[parent[0]] = [child] - else: - divisions[parent[0]].append(child) - - if self.get_div.isChecked(): - self.potential_divisions(divisions, undiv) - self.epicure.finish_update() - - def get_border_cells(self): - """ Return list of cells that are at the border (touching background) """ - self.border_cells = dict() - for tframe in range(self.epicure.nframes): - img = self.epicure.seg[tframe] - self.border_cells[tframe] = self.get_border_cells_frame(img) - - def get_border_cells_frame(self, imframe): - """ Return cells on border in current image """ - touchlab = expand_labels(imframe, distance=3) ## be sure that labels touch - graph = RAG( touchlab, connectivity=2) - adj_bg = [] - if 0 in graph.nodes: - adj_bg = list(graph.adj[0]) - return adj_bg - - def potential_divisions(self, divisions, undivisions): - """ Look at splitting events """ - for parent, childs in divisions.items(): - if parent not in undivisions: - indexes = self.epicure.tracking.get_track_indexes(childs) - if len(indexes) <= 0: - ## something wrong in the graph or in the tracks, ignore for now - continue - ## One of the child exist only in one frame, suspicious - if len(indexes) <= 3: - pos = self.epicure.tracking.mean_position(indexes, only_first=True) - if self.epicure.verbose > 1: - print("Suspect 1-2-0 track: "+str(parent)+"-"+str(childs)+" frame "+str(pos[0]) ) - self.add_suspect(pos, parent, "tracking-1-2-*") - else: - ## get the average first position of the childs just after division - pos = self.epicure.tracking.mean_position(indexes, only_first=True) - self.add_suspect(pos, parent, "division", symb="o", color="#0055ffff") - - def track_features(self): - """ Look at outliers in track features """ - track_ids = self.epicure.tracking.get_track_list() - features = [] - featType = {} - if self.check_size.isChecked(): - features = features + ["Area", "Perimeter"] - featType["Area"] = "size" - featType["Perimeter"] = "size" - size_factor = float(self.size_variability.text()) - if self.check_shape.isChecked(): - features = features + ["Eccentricity", "Solidity"] - featType["Eccentricity"] = "shape" - featType["Solidity"] = "shape" - shape_factor = float(self.shape_variability.text()) - for tid in track_ids: - track_indexes = self.epicure.tracking.get_track_indexes( tid ) - ## track should be long enough to make sense to look for outlier - if len(track_indexes) > 3: - track_feats = self.epicure.tracking.measure_features( tid, features ) - for feature, values in track_feats.items(): - if featType[feature] == "size": - factor = size_factor - if featType[feature] == "shape": - factor = shape_factor - outliers = self.find_jump( values, factor=factor ) - for out in outliers: - tdata = self.epicure.tracking.get_frame_data( tid, out ) - if self.epicure.verbose > 1: - print("Suspect track "+feature+": "+str(tdata[0])+" "+" frame "+str(tdata[1]) ) - self.add_suspect(tdata[1:4], tid, "track_"+featType[feature]) - - - def find_jump( self, tab, factor=1 ): - """ Detect brutal jump in the values """ - jumps = [] - tab = np.array(tab) - diff = tab[:(len(tab)-2)] - 2*tab[1:(len(tab)-1)] + tab[2:] - diff = [(tab[1]-tab[0])] + diff.tolist() + [tab[len(tab)-1]-tab[len(tab)-2]] - avg = (tab[:(len(tab)-2)] + tab[2:])/2 - avg = [(tab[1]+tab[0])/2] + avg.tolist() + [(tab[len(tab)-1]+tab[len(tab)-2])/2] - eps = 0.000000001 - diff = np.array(diff, dtype=np.float32) - avg = np.array(avg, dtype=np.float32) - diff = abs(diff+eps)/(avg+eps) - ## keep only local max above threshold - for i, diffy in enumerate(diff): - if (i>0) and (i<len(diff)-1): - if diffy > factor: - if (diffy > diff[i-1]) and (diffy > diff[i+1]): - jumps.append(i) - else: - if diffy > factor: - jumps.append(i) - #jumps = (np.where( diff > factor )[0]).tolist() - return jumps - - def find_outliers_tuk( self, tab, factor=3, below=True, above=True ): - """ Returns index of outliers from Tukey's like test """ - q1 = np.quantile(tab, 0.2) - q3 = np.quantile(tab, 0.8) - qtuk = factor * (q3-q1) - outliers = [] - if below: - outliers = outliers + (np.where((tab-q1+qtuk)<0)[0]).tolist() - if above: - outliers = outliers + (np.where((tab-q3-qtuk)>0)[0]).tolist() - return outliers - - def weirdo_area(self): - """ look at area trajectory for outliers """ - track_df = self.epicure.tracking.track_df - for tid in np.unique(track_df["track_id"]): - rows = track_df[track_df["track_id"]==tid].copy() - if len(rows) >= 3: - rows["smooth"] = rows.area.rolling(self.win_size, min_periods=1).mean() - rows["diff"] = (rows["area"] - rows["smooth"]).abs() - rows["diff"] = rows["diff"].div(rows["smooth"]) - if self.epicure.verbose > 2: - print(rows) - - diff --git a/src/epicure/track_optical.py b/src/epicure/track_optical.py index 45d9b29786bab2122ab845005eb410fc336b03e8..ad4e06ea21eff233a28ee565ce8ab74c53a74be8 100644 --- a/src/epicure/track_optical.py +++ b/src/epicure/track_optical.py @@ -134,16 +134,16 @@ class trackOptical(): def suspect_oneframe(self): """ Inspect the list of possible divisions if something suspicious """ - for (parent, frame), suspects in self.divisions.items(): - haslab = np.sum( self.seglayer.data==suspects[0], axis=(1,2) ) - # label suspects[0] is present in only one frame + for (parent, frame), events in self.divisions.items(): + haslab = np.sum( self.seglayer.data==events[0], axis=(1,2) ) + # label events[0] is present in only one frame if self.track.suggesting and np.sum(haslab>0) <= 1: - self.suggest_merge(frame, suspects[0], suspects[1], parent) + self.suggest_merge(frame, events[0], events[1], parent) else: - # label suspects[1] is present in only one frame - haslab = np.sum( self.seglayer.data==suspects[1], axis=(1,2) ) + # label events[1] is present in only one frame + haslab = np.sum( self.seglayer.data==events[1], axis=(1,2) ) if self.track.suggesting and np.sum(haslab>0) <= 1: - self.suggest_merge(frame, suspects[1], suspects[0], parent) + self.suggest_merge(frame, events[1], events[0], parent) def suggest_merge(self, frame, suspect, sister, parent): """ Suggest a merge of labels suspect and sister to label parent """ diff --git a/src/epicure/tracking.py b/src/epicure/tracking.py index a53608ef5323ccd9d8c0ec9c9eb2179c1c7852d1..5777462ea405efc6fef2b5a372cb3ec4656596d6 100644 --- a/src/epicure/tracking.py +++ b/src/epicure/tracking.py @@ -1,21 +1,15 @@ -from qtpy.QtWidgets import QPushButton, QHBoxLayout, QVBoxLayout, QWidget, QGroupBox, QLineEdit, QComboBox, QLabel, QSpinBox, QCheckBox -from napari import Viewer -import time -from napari.utils.notifications import show_info +from qtpy.QtWidgets import QVBoxLayout, QWidget, QGroupBox from epicure.laptrack_centroids import LaptrackCentroids from epicure.laptrack_overlaps import LaptrackOverlaps from laptrack.data_conversion import convert_split_merge_df_to_napari_graph -from epicure.track_optical import trackOptical -import epicure.Utils as ut from napari.utils import progress - -import vispy.color +import time import pandas as pd import numpy as np from skimage.measure import regionprops, regionprops_table from multiprocessing.pool import ThreadPool as Pool -from functools import partial -import networkx as nx +import epicure.Utils as ut +import epicure.epiwidgets as wid class Tracking(QWidget): """ @@ -34,17 +28,13 @@ class Tracking(QWidget): layout = QVBoxLayout() - self.track_update = QPushButton("Update tracks", parent=self) + ## Add update track button + self.track_update = wid.add_button( "Update tracks display", self.update_track_layer, "Update the Track layer with the changements made since the last update" ) layout.addWidget(self.track_update) - self.track_update.clicked.connect(self.update_track_layer) - ## Track only in one ROI - #self.track_only_in_roi = QCheckBox(text="Only in ROI") - #layout.addWidget(self.track_only_in_roi) - #self.track_only_in_roi.setChecked(False) ## Method specific - self.track_choice = QComboBox() + track_method, self.track_choice = wid.list_line( "Tracking method", "Choose the tracking method to use and display its parameter", func=None ) layout.addWidget(self.track_choice) self.track_choice.addItem("Laptrack-Centroids") @@ -55,39 +45,18 @@ class Tracking(QWidget): self.create_laptrack_overlap() layout.addWidget(self.gLapOverlap) - self.track_go = QPushButton("Track", parent=self) + self.track_go = wid.add_button( "Track", self.do_tracking, "Launch the tracking with the current parameter. Can take time" ) layout.addWidget(self.track_go) self.setLayout(layout) - self.track_go.clicked.connect(self.do_tracking) ## General tracking options - self.frame_range = QCheckBox( text="Track only some frames" ) - self.frame_range.setChecked( False ) + frame_line, self.frame_range, self.range_group = wid.checkgroup_help( "Track only some frames", False, "Option to track only a given range of frames", None ) self.frame_range.clicked.connect( self.show_frame_range ) - self.range_group = QGroupBox( "Frame range" ) range_layout = QVBoxLayout() - ntrack = QHBoxLayout() - ntrack_lab = QLabel() - ntrack_lab.setText("Track from frame:") - ntrack.addWidget(ntrack_lab) - self.start_frame = QSpinBox() - self.start_frame.setMinimum(0) - self.start_frame.setMaximum(self.nframes-1) - self.start_frame.setSingleStep(1) - self.start_frame.setValue(0) - ntrack.addWidget(self.start_frame) + ntrack, self.start_frame = wid.ranged_value_line( "Track from frame:", 0, self.nframes-1, 1, 0, "Set first frame to begin tracking" ) range_layout.addLayout(ntrack) - entrack = QHBoxLayout() - entrack_lab = QLabel() - entrack_lab.setText("Until frame:") - entrack.addWidget(entrack_lab) - self.end_frame = QSpinBox() - self.end_frame.setMinimum(1) - self.end_frame.setMaximum(self.nframes-1) - self.end_frame.setSingleStep(1) - self.end_frame.setValue(self.nframes-1) - entrack.addWidget(self.end_frame) + entrack, self.end_frame = wid.ranged_value_line( "Until frame:", 1, self.nframes-1, 1, self.nframes-1, "Set the last frame unitl which to track" ) range_layout.addLayout(entrack) self.start_frame.valueChanged.connect( self.changed_start ) self.end_frame.valueChanged.connect( self.changed_end ) @@ -99,11 +68,47 @@ class Tracking(QWidget): self.show_frame_range() self.show_trackoptions() self.track_choice.currentIndexChanged.connect(self.show_trackoptions) + def show_frame_range( self ): """ Show/Hide frame range options """ self.range_group.setVisible( self.frame_range.isChecked() ) + #### settings + + def get_current_settings( self ): + """ Get current settings to save as preferences """ + settings = {} + settings["Track method"] = self.track_choice.currentText() + settings["Add feat"] = self.check_penalties.isChecked() + settings["Max distance"] = self.max_dist.text() + settings["Splitting cost"] = self.splitting_cost.text() + settings["Merging cutoff"] = self.merging_cost.text() + settings["Min IOU"] = self.min_iou.text() + settings["Over split"] = self.split_cost.text() + settings["Over merge"] = self.merg_cost.text() + return settings + + def apply_settings( self, settings ): + """ Set the parameters/current display from the prefered settings """ + for setty, val in settings.items(): + if setty == "Track method": + self.track_choice.setCurrentText( val ) + if setty == "Add feat": + self.check_penalties.setChecked( val ) + if setty == "Max distance": + self.max_dist.setText( val ) + if setty == "Splitting cost": + self.splitting_cost.setText( val ) + if setty == "Merging cutoff": + self.merging_cost.setText( val ) + if setty == "Min IOU": + self.min_iou.setText( val ) + if setty == "Over split": + self.split_cost.setText( val ) + if setty == "Over merge": + self.merg_cost.setText( val ) + ########################################## #### Tracks layer and function @@ -140,6 +145,18 @@ class Tracking(QWidget): cols[i] = (self.epicure.seglayer.get_color(tr)) self.tracklayer._track_colors = cols self.tracklayer.events.color_by() + + def color_tracks_by_lineage(self): + """ Color the tracks by their lineage (daughters same colors as parents) """ + ## must color it manually by getting the Label layer colors for each track_id + cols = np.zeros((len(self.tracklayer.data[:,0]),4)) + for i, tr in enumerate(self.tracklayer.data[:,0]): + ## find the parent cell,n going up the tree until no more parent + while tr in self.graph.keys(): + tr = self.graph[tr][0] + cols[i] = (self.epicure.seglayer.get_color(tr)) + self.tracklayer._track_colors = cols + self.tracklayer.events.color_by() def replace_tracks(self, track_df): """ Replace all tracks based on the dataframe """ @@ -164,27 +181,6 @@ class Tracking(QWidget): progress_bar.close() self.color_tracks_as_labels() self.viewer.window._status_bar._toggle_activity_dock(False) - - - def update_tracks(self, labels, refresh=True): - """ Update the track infos of a few labels """ - print("DEPRECATED") - if self.track_df is not None: - ## remove them - self.track_df = self.track_df.drop( self.track_df[self.track_df['label'].isin(labels)].index ) - ## and remeasure them - seglabels = self.epicure.seg*np.isin(self.epicure.seg, labels) - dflabels = self.measure_labels( seglabels ) - self.track_df = pd.concat( [self.track_df, dflabels] ) - ## update tracks - if refresh: - #self.graph = {} - print("Graph of division/merges not updated, removed") - if "Tracks" not in self.viewer.layers: - self.init_tracks() - else: - #self.show_tracks() - self.viewer.layers["Tracks"].refresh() def measure_track_features(self, track_id): """ Measure features (length, total displacement...) of given track """ @@ -262,13 +258,20 @@ class Tracking(QWidget): def get_track_list(self): """ Get list of unique track_ids """ - #return self.track._manager.unique_track_ids() - return np.unique(self.track_data[:,0]) + return np.unique( self.track_data[:,0] ) + + def get_tracks_on_frame( self, tframe ): + """ Return list of tracks present on given frame """ + return np.unique( self.track_data[ self.track_data[:,1]==tframe, 0] ) def has_track(self, label): """ Test if track label is present """ return label in self.track_data[:,0] + def has_tracks(self, labels): + """ Test if track labels are present """ + return np.isin( labels, self.track_data[:,0] ) + def nb_points(self): """ Number of points in the tracks """ return self.track_data.shape[0] @@ -288,13 +291,14 @@ class Tracking(QWidget): def gap_frames(self, track_id): """ Returns the frame(s) at which the gap(s) are """ - min_frame, max_frame = self.get_extreme_frames( track_id ) - frame = min_frame - indexes = self.get_track_indexes(track_id) - track_frames = self.track_data[indexes,1] - gaps = list(set(range(min_frame, max_frame+1, 1)) - set(track_frames)) - if len(gaps) > 0: - gaps.sort() + track_frames = self.get_track_column( track_id, "frame" ) + gaps = [] + if len( track_frames ) > 0: + min_frame = int( np.min(track_frames) ) + max_frame = int( np.max(track_frames) ) + gaps = np.setdiff1d( np.arange(min_frame+1, max_frame), track_frames ).tolist() + if len(gaps) > 0: + gaps.sort() return gaps def check_gap(self, tracks=None, verbose=None): @@ -314,9 +318,9 @@ class Tracking(QWidget): def get_track_indexes(self, track_id): """ Get indexes of track_id tracks position in the arrays """ - if type(track_id) == int: - return (np.argwhere( self.track_data[:,0] == track_id )).flatten() - return (np.argwhere( np.isin( self.track_data[:,0], track_id ) )).flatten() + if isinstance( track_id, int ): + return (np.flatnonzero( self.track_data[:,0] == track_id ) ) + return (np.flatnonzero( np.isin( self.track_data[:,0], track_id ) ) ) def get_track_indexes_from_frame(self, track_id, frame): """ Get indexes of track_id tracks position in the arrays from the given frame """ @@ -350,12 +354,29 @@ class Tracking(QWidget): indexes = self.get_track_indexes( track_id ) track = self.track_data[indexes,] return track + + def get_track_column( self, track_id, column ): + """ Return the chosen column (frame, x, y, label) of track track_id """ + indexes = self.get_track_indexes( track_id ) + if column == "frame": + i = 1 + if column == "label": + i = 0 + if column == "pos": + i = [2,3] + track = self.track_data[indexes, i] + return track def get_frame_data( self, track_id, ind ): """ Get ind-th data of track track_id """ track = self.get_track_data( track_id ) return track[ind] + def get_position( self, track_id, frame ): + """ Get position of the track at given frame """ + ind = self.get_index( track_id, frame ) + return [int(self.track_data[ind, 2]), int(self.track_data[ind,3])] + def mean_position(self, indexes, only_first=False): """ Mean positions of tracks at indexes """ if len(indexes) <= 0: @@ -372,7 +393,8 @@ class Tracking(QWidget): track = self.get_track_data( track_id ) if len(track) <= 0: return None - return int(np.min(track[:,1])) + return int( np.min(track[:,1]) ) + def get_last_frame(self, track_id): """ Returns last frame where track_id is present """ @@ -394,11 +416,7 @@ class Tracking(QWidget): def update_centroid(self, track_id, frame, ind=None, cx=None, cy=None): """ Update track at given frame """ if ind is None: - ind = self.get_track_indexes( track_id ) - if len(ind) > 1: - if self.epicure.verbose > 1: - print(ind) - print("Weird") + ind = self.get_index( track_id, frame ) if cx is None: prop = ut.getPropLabel( self.epicure.seg[frame], track_id ) self.track_data[ind, 2:4] = prop.centroid[1] @@ -417,16 +435,15 @@ class Tracking(QWidget): self.update_graph( track_index, frame ) self.track_data[[ind, oind],0] = [otid, tid] - def update_track_on_frame(self, track_ids, frame): """ Update (add or modify) tracks at given frame """ - frame_table = regionprops_table(np.where(np.isin(self.epicure.seg[frame], track_ids), self.epicure.seg[frame], 0), properties=self.properties) - for i, tid in enumerate(frame_table["label"]): + frame_table = regionprops_table( np.where(np.isin(self.epicure.seg[frame], track_ids), self.epicure.seg[frame], 0), properties=self.properties ) + for x, y, tid in zip(frame_table["centroid-0"], frame_table["centroid-1"], frame_table["label"]): index = self.get_index(tid, frame) if len(index) > 0: - self.update_centroid( tid, frame, index, int(frame_table["centroid-0"][i]), int(frame_table["centroid-1"][i]) ) + self.update_centroid( tid, frame, index, int(x), int(y) ) else: - cur_cell = np.array( [tid, frame, int(frame_table["centroid-0"][i]), int(frame_table["centroid-1"][i])] ) + cur_cell = np.array( [tid, frame, int(x), int(y)] ) cur_cell = np.expand_dims(cur_cell, axis=0) self.track_data = np.append(self.track_data, cur_cell, axis=0) @@ -438,7 +455,7 @@ class Tracking(QWidget): cur_cell = np.expand_dims(cur_cell, axis=0) self.track_data = np.append(self.track_data, cur_cell, axis=0) - def remove_one_frame(self, track_id, frame, handle_gaps=True, refresh=True): + def remove_one_frame( self, track_id, frame, handle_gaps=False, refresh=True ): """ Remove one frame from track(s) """ @@ -455,12 +472,12 @@ class Tracking(QWidget): self.update_graph( tid, frame ) else: check_for_gaps = True - self.track_data = np.delete(self.track_data, inds, axis=0) + self.track_data = np.delete( self.track_data, inds, axis=0 ) ## gaps might have been created in the tracks, for now doesn't allow it so split the tracks if handle_gaps and check_for_gaps: gaped = self.check_gap( track_id, verbose=0 ) if len(gaped) > 0: - self.epicure.fix_gaps(gaped) + self.epicure.fix_gaps( gaped ) def get_current_value(self, track_id, frame): ind = self.get_index( track_id, frame ) @@ -506,10 +523,37 @@ class Tracking(QWidget): """ Change the value of a key in the graph """ self.graph[new_key] = self.graph.pop(prev_key) + def is_parent( self, cur_id ): + """ Return if the current id is in the graph (as a parent, so in values) """ + if self.graph is None: + return False + return any( cur_id in vals for vals in self.graph.values() ) + + def add_division( self, childa, childb, parent ): + """ Add info of a division to the graph of divisions/merges """ + if self.graph is None: + self.graph = {} + self.graph[childa] = parent + self.graph[childb] = parent + + def remove_division( self, parent ): + """ Remove a division event from the graph """ + keys = list(self.graph.keys()) + for key in keys: + vals = self.graph[key] + if isinstance(vals, list): + if vals[0] == parent: + del self.graph[key] + else: + if vals == parent: + del self.graph[key] + def last_in_graph(self, track_id, frame): """ Check if given label and frame is the last of a branch, in the graph """ parents = [] for key, vals in self.graph.items(): + if not isinstance(vals, list): + vals = [vals] for val in vals: if val == track_id: last_frame = self.get_last_frame( val ) @@ -529,6 +573,29 @@ class Tracking(QWidget): """ Remove track with given id """ inds = self.get_track_indexes(track_ids) self.track_data = np.delete(self.track_data, inds, axis=0) + self.remove_ids_from_graph( track_ids ) + + def remove_ids_from_graph( self, track_ids ): + """ Remove all ids from the graph """ + track_ids_set = set( track_ids ) + self.graph = { + key: vals for key, vals in self.graph.items() + if (key not in track_ids_set) and ( not any( val in track_ids_set for val in (vals if isinstance(vals, list) else [vals])) ) + } + """ + for tid in track_ids: + self.graph.pop( tid, None ) + + keys = self.graph.keys() + for key in keys: + kgr = self.graph[ key ] + if not isinstance( kgr, list ): + kgr = [kgr] + for val in kgr: + if val in track_ids: + del self.graph[key] + continue + """ def build_tracks(self, track_df): """ Create tracks from dataframe (after tracking) """ @@ -550,7 +617,7 @@ class Tracking(QWidget): def add_track_features(self, labels): """ Add features specific to tracks (eg nframes) """ nframes = np.zeros(len(labels), int) - if self.epicure.verbose > 1: + if self.epicure.verbose > 2: print("REPLACE BY COUNT METHOD") for track_id in np.unique(labels): cur_track = np.argwhere(labels == track_id) @@ -594,10 +661,6 @@ class Tracking(QWidget): if self.track_choice.currentText() == "Laptrack-Overlaps": return self.laptrack_overlaps_twoframes(labels, twoframes) - if self.track_choice.currentText() == "Optictrack": - if self.epicure.verbose > 1: - print("Merge propagation with Optitrack not implemented yet") - return [None]*len(labels) def do_tracking(self): """ Start the tracking with the selected options """ @@ -609,7 +672,7 @@ class Tracking(QWidget): end = self.nframes-1 start_time = time.time() self.viewer.window._status_bar._toggle_activity_dock(True) - self.epicure.suspecting.reset_all_suspects() + self.epicure.inspecting.reset_all_events() if self.track_choice.currentText() == "Laptrack-Centroids": if self.epicure.verbose > 1: @@ -621,9 +684,6 @@ class Tracking(QWidget): print("Starting track with Laptrack-Centroids") self.laptrack_overlaps( start, end ) self.epicure.tracked = 1 - if self.track_choice.currentText() == "Optictrack": - self.optic_track(start, end ) - self.epicure.tracked = 2 self.epicure.finish_update(contour=2) #self.epicure.reset_free_label() @@ -634,7 +694,6 @@ class Tracking(QWidget): def show_trackoptions(self): self.gLapCentroids.setVisible(self.track_choice.currentText() == "Laptrack-Centroids") self.gLapOverlap.setVisible(self.track_choice.currentText() == "Laptrack-Overlaps") - #self.gOptic.setVisible(self.track_choice.currentText() == "Optictrack") def relabel_nonunique_labels(self, track_df): """ After tracking, some track can be splitted and get same label, fix that """ @@ -683,18 +742,16 @@ class Tracking(QWidget): new_mergedf = mergedf.copy() for tid in np.unique(track_df['track_id']): ctrack_df = track_df[track_df['track_id']==tid] - newval = ctrack_df["label"][ctrack_df["frame"]==np.min(ctrack_df["frame"])] + newval = int( ctrack_df.loc[ ctrack_df["frame"]==np.min(ctrack_df["frame"]), "label" ].iloc[0] ) ## have to replace if different - if tid != int(newval): - newval = int(newval) - toreplace = track_df['track_id']==tid - new_trackids[toreplace] = newval + if tid != newval: + new_trackids.loc[ track_df['track_id']==tid ] = newval if not new_splitdf.empty: - new_splitdf["parent_track_id"][splitdf["parent_track_id"]==tid] = newval - new_splitdf["child_track_id"][splitdf["child_track_id"]==tid] = newval + new_splitdf.loc[ splitdf["parent_track_id"]==tid, "parent_track_id" ] = newval + new_splitdf.loc[ splitdf["child_track_id"]==tid, "child_track_id" ] = newval if not new_mergedf.empty: - new_mergedf["parent_track_id"][ mergedf["parent_track_id"]==tid ] = newval - new_mergedf["child_track_id"][ mergedf["child_track_id"]==tid ] = newval + new_mergedf.loc[ mergedf["parent_track_id"]==tid, "parent_track_id" ] = newval + new_mergedf.loc[ mergedf["child_track_id"]==tid, "child_track_id" ] = newval return new_trackids, new_splitdf, new_mergedf def change_labels(self, track_df): @@ -792,8 +849,9 @@ class Tracking(QWidget): self.replace_tracks(track_df) progress_bar.update(indprogress+2) - ## update the list of suspects ? - #self.epicure.update_epicells() + ## update the list of events, or others + self.epicure.updates_after_tracking() + progress_bar.update(indprogress+3) return graph @@ -804,62 +862,29 @@ class Tracking(QWidget): """ GUI of the laptrack option """ self.gLapCentroids = QGroupBox("Laptrack-Centroids") glap_layout = QVBoxLayout() - mdist = QHBoxLayout() - mdist_lab = QLabel() - mdist_lab.setText("Max distance") - mdist.addWidget(mdist_lab) - self.max_dist = QLineEdit() - self.max_dist.setText("15.0") - mdist.addWidget(self.max_dist) + mdist, self.max_dist = wid.value_line( "Max distance", "15.0", "Maximal distance between two labels in consecutive frames to link them (in pixels)" ) glap_layout.addLayout(mdist) ## splitting ~ cell division - scost = QHBoxLayout() - scost_lab = QLabel() - scost_lab.setText("Splitting cutoff") - scost.addWidget(scost_lab) - self.splitting_cost = QLineEdit() - self.splitting_cost.setText("1") - scost.addWidget(self.splitting_cost) + scost, self.splitting_cost = wid.value_line( "Splitting cutoff", "1", "Weight to split a track in two (increasing it favors division)" ) glap_layout.addLayout(scost) ## merging ~ error ? - mcost = QHBoxLayout() - mcost_lab = QLabel() - mcost_lab.setText("Merging cutoff") - mcost.addWidget(mcost_lab) - self.merging_cost = QLineEdit() - self.merging_cost.setText("1") - mcost.addWidget(self.merging_cost) + mcost, self.merging_cost = wid.value_line( "Merging cutoff", "1", "Weight to merge to labels together" ) glap_layout.addLayout(mcost) - self.check_penalties = QCheckBox(text="Add features cost") - glap_layout.addWidget(self.check_penalties) + add_feat, self.check_penalties, self.bpenalties = wid.checkgroup_help( "Add features cost", True, "Add cell features in the tracking calculation", None ) self.create_penalties() + glap_layout.addWidget(self.check_penalties) glap_layout.addWidget(self.bpenalties) - self.check_penalties.setChecked(True) - self.check_penalties.stateChanged.connect(self.show_penalties) self.gLapCentroids.setLayout(glap_layout) def show_penalties(self): self.bpenalties.setVisible(not self.bpenalties.isVisible()) def create_penalties(self): - self.bpenalties = QGroupBox("Features cost") pen_layout = QVBoxLayout() - areaCost = QHBoxLayout() - penarea_lab = QLabel() - penarea_lab.setText("Area difference:") - self.area_cost = QLineEdit() - self.area_cost.setText("2") - areaCost.addWidget(penarea_lab) - areaCost.addWidget(self.area_cost) + areaCost, self.area_cost = wid.value_line( "Area difference", "2", "Weight of the difference of area between two labels to link them (0 to ignore)" ) pen_layout.addLayout(areaCost) - solidCost = QHBoxLayout() - pensol_lab = QLabel() - pensol_lab.setText("Solidity difference:") - self.solidity_cost = QLineEdit() - self.solidity_cost.setText("0") - solidCost.addWidget(pensol_lab) - solidCost.addWidget(self.solidity_cost) + solidCost, self.solidity_cost = wid.value_line( "Solidity difference", "0", "Weight of the difference of solidity between two labels to link them (0 to ignore)" ) pen_layout.addLayout(solidCost) self.bpenalties.setLayout(pen_layout) @@ -905,7 +930,7 @@ class Tracking(QWidget): laptrack.penal_solidity = float(self.solidity_cost.text()) laptrack.set_region_properties(with_extra=self.check_penalties.isChecked()) - progress_bar = progress(total=5) + progress_bar = progress(total=7) progress_bar.set_description( "Prepare tracking" ) if self.epicure.verbose > 1: print("Convert labels to centroids: use track info ?") @@ -919,7 +944,7 @@ class Tracking(QWidget): if self.epicure.verbose > 1: print("After tracking, update everything") self.after_tracking(track_df, split_df, merge_df, progress_bar, 2) - progress_bar.update(5) + progress_bar.update(6) progress_bar.close() ############ Laptrack overlap option @@ -928,31 +953,13 @@ class Tracking(QWidget): """ GUI of the laptrack overlap option """ self.gLapOverlap = QGroupBox("Laptrack-Overlaps") glap_layout = QVBoxLayout() - miou = QHBoxLayout() - miou_lab = QLabel() - miou_lab.setText("Min IOU") - miou.addWidget(miou_lab) - self.min_iou = QLineEdit() - self.min_iou.setText("0.1") - miou.addWidget(self.min_iou) + miou, self.min_iou = wid.value_line( "Min IOU", "0.1", "Minimum Intersection Over Union score to link to labels together" ) glap_layout.addLayout(miou) - scost = QHBoxLayout() - scost_lab = QLabel() - scost_lab.setText("Splitting cost") - scost.addWidget(scost_lab) - self.split_cost = QLineEdit() - self.split_cost.setText("0.2") - scost.addWidget(self.split_cost) + scost, self.split_cost = wid.value_line( "Splitting cost", "0.2", "Weight of linking a parent label with two labels (increasing it for more divisions)" ) glap_layout.addLayout(scost) - mcost = QHBoxLayout() - mcost_lab = QLabel() - mcost_lab.setText("Merging cost") - mcost.addWidget(mcost_lab) - self.merg_cost = QLineEdit() - self.merg_cost.setText("0.2") - mcost.addWidget(self.merg_cost) + mcost, self.merg_cost = wid.value_line( "Merging cost", "0.2", "Weight of merging two parent labels into one" ) glap_layout.addLayout(mcost) self.gLapOverlap.setLayout(glap_layout) @@ -1001,37 +1008,4 @@ class Tracking(QWidget): parent_labels = laptrack.twoframes_track(twoframes, labels) return parent_labels - def create_optictrack(self): - """ GUI of the Optical track option """ - self.gOptic = QGroupBox("Optictrack") - gOptic_layout = QVBoxLayout() - miou = QHBoxLayout() - miou_lab = QLabel() - miou_lab.setText("Min IOU") - miou.addWidget(miou_lab) - self.min_iou = QLineEdit() - self.min_iou.setText("0.25") - miou.addWidget(self.min_iou) - gOptic_layout.addLayout(miou) - rad = QHBoxLayout() - rad_lab = QLabel() - rad_lab.setText("Flow radius") - rad.addWidget(rad_lab) - self.rad = QLineEdit() - self.rad.setText("10") - rad.addWidget(self.rad) - gOptic_layout.addLayout(rad) - self.show_opticed = QCheckBox("Show flowed segmentation") - self.show_opticed.setChecked(False) - gOptic_layout.addWidget(self.show_opticed) - self.gOptic.setLayout(gOptic_layout) - - def optic_track(self, start, end): - """ Perform track with optical flow registration + best match """ - optic = trackOptical(self, self.epicure) - miniou = float(self.min_iou.text()) - radius = float(self.rad.text()) - opticed = self.show_opticed.isChecked() - optic.set_parameters(miniou, radius, opticed) - optic.track_by_optical_flow( self.viewer, start, end ) diff --git a/src/epicure/tracking_editing.py b/src/epicure/tracking_editing.py index 04dfbb64dad148830e1e5da5b18452bd0cfe22af..b2f7d405c84d9c5525e57d6396761cc30eb34cc7 100644 --- a/src/epicure/tracking_editing.py +++ b/src/epicure/tracking_editing.py @@ -10,7 +10,7 @@ class trackEditingWidget(QWidget): self.viewer = napari_viewer self.epicure = epic self.suggestion = self.epicure.suggestion - self.suspects = self.epicure.suspects + self.events = self.epicure.events layout = QVBoxLayout() self.btn = QPushButton("Nothing yet", parent=self) @@ -18,33 +18,32 @@ class trackEditingWidget(QWidget): layout.addWidget(self.btn) self.setLayout(layout) - self.btn.clicked.connect(self.suspect_oneframe) + self.btn.clicked.connect(self.inspect_oneframe) - - def add_suspect(self, pos, label): + def add_inspect(self, pos, label): """ Add a suspicious point (position and label) """ - self.suspects = self.epicure.suspects - self.epicure.add_suspect(pos, label, "tracking") + self.events = self.epicure.events + self.epicure.add_inspect(pos, label, "tracking") - def suspect_oneframe(self): + def inspect_oneframe(self): """ Find suspicious cell that exists in only one frame """ seg = self.viewer.layers["Segmentation"] props = regionprops(seg.data) imshape = seg.data.shape - self.suspects.data = np.zeros(imshape, dtype="uint8") + self.events.data = np.zeros(imshape, dtype="uint8") for prop in props: if (prop.bbox[3]-prop.bbox[0]) == 1: - ## label present only on one frame, suspect + ## label present only on one frame, inspect if (prop.bbox[3] != (imshape[0]-1)) and (prop.bbox[0]!=0): ## not first or last frame if (prop.bbox[1]>0) and (prop.bbox[4]<(imshape[1]-1)): if (prop.bbox[2]>0) and (prop.bbox[5]<(imshape[2]-1)): ## not touching border - self.suspect[ seg.data==prop.label ] = 1 - self.show_suspects() + self.inspect[ seg.data==prop.label ] = 1 + self.show_events() - def show_suspects(self): - self.suspects.refresh() + def show_events(self): + self.events.refresh() self.show_names( self.suggestion, "SuggestedId" ) self.epicure.finish_update() @@ -118,7 +117,7 @@ class trackEditingWidget(QWidget): bbox_rect = np.moveaxis(bbox_rect, 2, 0) return bbox_rect - def suspect_labels(self): + def inspect_labels(self): rprops = regionprops(self.viewer.layers["Segmentation"].data, intensity_image=self.viewer.layers["Movie"].data) supported = [] diff --git a/src/tests/test_concatenate.py b/src/tests/test_concatenate.py index fe811323b1e29a3d68ca8242fb76436d31005bc0..c9a2935b3fb78abeef863d0fcd7cbf3fb8706837 100644 --- a/src/tests/test_concatenate.py +++ b/src/tests/test_concatenate.py @@ -29,18 +29,18 @@ def test_process_first_movie(): print("Assign some cells to groups") epic.reset_groups() - epic.cell_ingroup(17, "Bing") - epic.cell_ingroup(42, "Bing") - epic.cell_ingroup(142, "Bang") - epic.cell_ingroup(143, "Bang") + epic.cells_ingroup(17, "Bing") + epic.cells_ingroup(42, "Bing") + epic.cells_ingroup(142, "Bang") + epic.cells_ingroup(143, "Bang") print("Do tracking with default parameters") epic.tracking.do_tracking() assert epic.nlabels() == 73 print("Generate suspect list") - epic.suspecting.inspect_tracks() - assert len(epic.suspecting.suspects.data) == 6 + epic.inspecting.inspect_tracks() + assert len(epic.inspecting.events.data) == 4 epic.save_epicures() assert os.path.exists(os.path.join( main_dir, "epics", "013-t1-t4_labels.tif") ) @@ -64,8 +64,8 @@ def test_process_second_movie(): epic.tracking.do_tracking() epic.reset_groups() - epic.cell_ingroup(21, "Bing") - epic.cell_ingroup(22, "BAM") + epic.cells_ingroup(21, "Bing") + epic.cells_ingroup(22, "BAM") epic.save_epicures() assert os.path.exists(os.path.join( main_dir, "epics", "013-t4-t6_labels.tif") ) assert os.path.exists(os.path.join( main_dir, "epics", "013-t4-t6_epidata.pkl") ) diff --git a/src/tests/test_editings.py b/src/tests/test_editings.py index 05daae111acf68e76c1b42a0d49e9f05f7c06160..3e99e18b780bb3f85fc34c8ac632d9b098491c57 100644 --- a/src/tests/test_editings.py +++ b/src/tests/test_editings.py @@ -65,7 +65,7 @@ def test_group(): assert epic.viewer is not None layer = epic.viewer.layers["Segmentation"] - epic.cell_ingroup( 111, "Test" ) + epic.cells_ingroup( 111, "Test" ) assert "Test" in epic.groups segedit = epic.editing @@ -74,7 +74,7 @@ def test_group(): event.view_direction = None event.dims_displayed = [0,1, 1] assert "GroupTest" not in epic.groups - segedit.group_group.setText("GroupTest") + segedit.group_choice.setCurrentText("GroupTest") segedit.add_cell_to_group(event) assert "GroupTest" in epic.groups diff --git a/src/tests/test_outputs.py b/src/tests/test_outputs.py index ff0c7f24c4e5e6af82d752bdad7359cbed93066e..8fcb03157caf591abdc5193a30a69d7d5531a36a 100644 --- a/src/tests/test_outputs.py +++ b/src/tests/test_outputs.py @@ -20,12 +20,15 @@ def test_output_selected(): output = epic.outputing assert output is not None sel = output.get_selection_name() - assert sel == "" - output.output_mode.setCurrentText("Only selected cell") - sel = output.get_selection_name() assert sel == "_cell_1" + output.output_mode.setCurrentText("All cells") + sel = output.get_selection_name() + assert sel == "" roi_file = os.path.join(".", "data_test", "test_epics", "area3_t100-101_rois_cell_1.zip") if os.path.exists(roi_file): os.remove(roi_file) - output.roi_out() - assert os.path.exists(roi_file) + ## TO UPDATE WITH NEW VERSION + #output.roi_out() + #assert os.path.exists(roi_file) + +#test_output_selected() diff --git a/src/tests/test_suspects.py b/src/tests/test_suspects.py index a044091e77f27092380e7b91027a252df25a965c..a17fa80eb4c42698255d931fe4be4ab080e3dc36 100644 --- a/src/tests/test_suspects.py +++ b/src/tests/test_suspects.py @@ -17,17 +17,18 @@ def test_suspect_frame(): assert epic.viewer is not None epic.go_epicure("test_epics", test_seg) - segedit = epic.suspecting + segedit = epic.inspecting assert segedit is not None - segedit.suspect_area(True) - assert "Suspects" in epic.viewer.layers - outlier = epic.viewer.layers["Suspects"] - assert len(outlier.data)>=5 + segedit.min_area.setText("50") + segedit.event_area_threshold() + assert "Events" in epic.viewer.layers + outlier = epic.viewer.layers["Events"] + assert len(outlier.data)>=10 assert outlier.data[1][0] == 0 nsus = len(outlier.data) - segedit.fintensity_out.setValue(0.5) - segedit.suspect_intensity(True) + segedit.fintensity_out.setText("0.5") + segedit.event_intensity(True) assert len(outlier.data) > (nsus+5) def test_suspect_track(): @@ -42,27 +43,30 @@ def test_suspect_track(): track = epic.tracking # default tracking + susp = epic.inspecting + assert susp.nb_events() == 0 track.do_tracking() - susp = epic.suspecting ## test basics - assert susp.nb_suspects() == "_" - susp.add_suspect( (5,50,50), 10, "test" ) - assert susp.nb_suspects() == 1 + assert susp.nb_events() == susp.nb_type("division") + nev = susp.nb_events() + susp.add_event( (5,50,50), 10, "test" ) + assert susp.nb_events() == (nev+1) ## test default parameter inspection susp.inspect_tracks() - assert susp.nb_suspects() > 50 - assert susp.nb_suspects() < 100 + assert susp.nb_events() > 50 + assert susp.nb_events() < 100 ## test minimum track length inspection + susp.check_size.setChecked( False ) susp.min_length.setText("5") susp.inspect_tracks() - nmin = susp.nb_suspects() - assert nmin > 100 + nmin = susp.nb_events() + assert nmin > 50 ## test reset all - susp.reset_all_suspects() - assert susp.nb_suspects() == "_" + susp.reset_all_events() + assert susp.nb_events() == 0 ## Track feature change test - susp.check_size.setChecked( True ) - susp.inspect_tracks() - assert susp.nb_suspects() > nmin - + ## A CHECKER + #susp.check_size.setChecked( True ) + #susp.inspect_tracks() + #assert susp.nb_events() > nmin diff --git a/src/tests/test_tracking.py b/src/tests/test_tracking.py index 62a748d0ed7070d183acb9380f206ac5fd9751b1..e4804d42313644fd6b16aca92eae12f846a7a74a 100644 --- a/src/tests/test_tracking.py +++ b/src/tests/test_tracking.py @@ -52,8 +52,10 @@ def test_track_methods(): midle = track.get_first_frame(track_id) + 2 track.remove_one_frame( track_id, midle ) gaped = track.check_gap() - assert len(gaped) <= 0 + ## gaps are allowed now + assert len(gaped) > 0 epic.handle_gaps( None ) assert track.nb_tracks() == 310 #track_methods() +#test_track_methods() \ No newline at end of file