diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 4a32aa0c..359f980d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.7.3 +current_version = 0.7.5 commit = True tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))? diff --git a/changelog.rst b/changelog.rst index 9626d883..c0a052e3 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,13 +1,32 @@ Changelog ========= -Last change: 02-OCT-2024 MTS +Last change: 06-FEB-2025 MTS -0.7.1 - 0.7.3 +0.7.5 +----- +- Automatic picking of fiducials added in Render: ``Tools/Pick fiducials`` +- Undrifting from picked moved from ``picasso/gui/render`` to ``picasso/postprocess`` +- Plugin docs update +- Filter histogram display fixed for datasets with low variance (bug fix) +- AIM undrifting works now if the first frames of localizations are filtered out (bug fix) +- 2D drift plot in Render inverts y axis to match the rendered localizations +- 3D animation fixed +- Other minor bug fixes + +0.7.1 - 0.7.4 ------------- - SMLM clusterer in picked regions deleted - Show legend in Render property displayed rounded tick label values - Pick circular area does not save the area for each pick in localization's metadata +- Picasso: Render - adjust the scale bar's size automatically based on the current FOV's width +- Picasso: Render - RESI dialog fixed, units in nm +- Picasso: Render - show drift in nm, not camera pixels +- Picasso: Render - masking localizations saves the mask area in its metadata +- Picasso: Render - export current view across channels in grayscale +- Picasso: Render - title bar displays the file only the names of the currently opened files +- CMD implementation of AIM undrifting, see ``picasso aim -h`` in terminal +- CMD localize saves camera information in the metadata file - Other minor bug fixes 0.7.0 diff --git a/distribution/picasso.iss b/distribution/picasso.iss index b348b3e8..c202dba0 100644 --- a/distribution/picasso.iss +++ b/distribution/picasso.iss @@ -2,10 +2,10 @@ AppName=Picasso AppPublisher=Jungmann Lab, Max Planck Institute of Biochemistry -AppVersion=0.7.3 +AppVersion=0.7.5 DefaultDirName={commonpf}\Picasso DefaultGroupName=Picasso -OutputBaseFilename="Picasso-Windows-64bit-0.7.3" +OutputBaseFilename="Picasso-Windows-64bit-0.7.5" ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 diff --git a/docs/cmd.rst b/docs/cmd.rst index 4303efe5..ff5d7008 100644 --- a/docs/cmd.rst +++ b/docs/cmd.rst @@ -78,6 +78,10 @@ undrift ------- Correct localization coordinates for drift with RCC. +aim +------- +Correct localization coordinates for drift with AIM. + density ------- Compute the local density of localizations diff --git a/docs/conf.py b/docs/conf.py index 3e5b94db..1b483f98 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # The short X.Y version version = "" # The full version, including alpha/beta/rc tags -release = "0.7.3" +release = "0.7.5" # -- General configuration --------------------------------------------------- diff --git a/docs/plugins.rst b/docs/plugins.rst index f9c8a5d3..4f5ee29b 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -4,34 +4,33 @@ Plugins Usage ----- -Starting in version 0.5.0, Picasso allows for creating plugins. They can be added by the user for each of the available distributions of picasso (GitHub, picassosr from PyPI and the one click installer). However, using plugins in the latter may cause issues, see below. +Starting in version 0.5.0, Picasso supports plugins. Please find the instructions on how to add them to your Picasso below. -Please keep in mind that the ``__init__.py`` file in the ``plugins`` folder must not be modified or deleted. +Please keep in mind that the ``__init__.py`` file in the ``picasso/picasso/gui/plugins`` folder must not be modified or deleted. GitHub ~~~~~~ +If you cloned the GitHub repository, you can add plugins by following these steps: - Find the directory where you cloned the GitHub repository with Picasso. - Go to ``picasso/picasso/gui/plugins``. - Copy the plugin(s) to this folder. -- The plugin(s) should automatically work after running picasso in the new command window. PyPI ~~~~ -- Find the location of the environment where picassosr is installed (type ``conda env list`` to see the directory). -- Find this directory and go to ``YOUR_ENVIRONMENT/Lib/site-packages/picasso/gui/plugins``. +If you installed Picasso using ``pip install picassosr``, you can add plugins by following these steps: +- To find the location of the environment where ``picassosr`` is installed, type ``conda env list``. +- If your environment can be found under ``YOUR_ENVIRONMENT``, go to ``YOUR_ENVIRONMENT/Lib/site-packages/picasso/gui/plugins``. - Copy the plugin(s) to this folder. -- The plugin(s) should automatically work after running picasso in the new command window. One click installer ~~~~~~~~~~~~~~~~~~~ -**NOTE**: This may lead to issues if Picasso is installed, as the plugin scripts will remain in the ``plugins`` folder upon deinstallation. After deinstalltion, the ``Picasso`` folder needs to be deleted manually. +**NOTE**: After deinstalling Picasso, ``Program Files/Picasso`` folder needs to be deleted manually, as the uninstaller currently does not remove the plugins automatically. - Find the location where you installed Picasso. By default, it is ``C:/Program Files/Picasso``. - Go to the following subfolder in the `Picasso` directory: ``picasso/gui/plugins``. - Copy the plugin(s) to this folder. -- The plugin(s) should automatically be loaded after double-clicking the respective desktop shortcuts. -**NOTE**: Plugins added in this distribution will not be able to use packages that are not installed automatically (from the file ``requirements.txt``). +.. **NOTE**: Plugins added in this distribution will not be able to use packages that are not installed automatically (from the file ``requirements.txt``). For developers -------------- @@ -41,4 +40,4 @@ To create a plugin, you can use the template provided in ``picasso/plugin_templa :scale: 70 % :alt: Plugins -As an example, please see any of the plugins on the GitHub `repo `_. \ No newline at end of file +As an example, please see any of the plugins in the `GitHub repo `_. \ No newline at end of file diff --git a/docs/render.rst b/docs/render.rst index 2b2d70c4..31b3003d 100644 --- a/docs/render.rst +++ b/docs/render.rst @@ -61,6 +61,8 @@ The 3D rotation window allows the user to render 3D localization data. To use it The user may perform multiple actions in the rotation window, including: saving rotated localizations, building animations (.mp4 format), rotating by a specified angle, etc. +Note that to build animations, the user must have ``ffmpeg`` installed on their system. + Rotation around z-axis is available by pressing Ctrl/Command. Rotation axis can be frozen by pressing x/y/z to freeze around the corresponding axes (to freeze around the z-axis, Ctrl/Command must be pressed as well). There are several things to keep in mind when using the rotation window. Firstly, using individual localization precision is very slow and is not recommended as a default blur method. Also, the size of the rotation window can be altered, however, if it becomes too large, rendering may start to lag. diff --git a/picasso/__init__.py b/picasso/__init__.py index 13e48c82..daee83f6 100644 --- a/picasso/__init__.py +++ b/picasso/__init__.py @@ -8,7 +8,7 @@ import os.path as _ospath import yaml as _yaml -__version__ = "0.7.3" +__version__ = "0.7.5" _this_file = _ospath.abspath(__file__) _this_dir = _ospath.dirname(_this_file) diff --git a/picasso/__main__.py b/picasso/__main__.py index 6e8327ad..15e75443 100644 --- a/picasso/__main__.py +++ b/picasso/__main__.py @@ -423,6 +423,26 @@ def _undrift(files, segmentation, display=True, fromfile=None): savetxt(base + "_drift.txt", drift, header="dx\tdy", newline="\r\n") +def _undrift_aim( + files, segmentation, intersectdist=20/130, roiradius=60/130 +): + import glob + from . import io, aim + from numpy import savetxt + + paths = glob.glob(files) + for path in paths: + try: + locs, info = io.load_locs(path) + except io.NoMetadataFileError: + continue + print("Undrifting file {}".format(path)) + locs, new_info, drift = aim.aim(locs, info, segmentation, intersectdist, roiradius) + base, ext = os.path.splitext(path) + io.save_locs(base + "_aim.hdf5", locs, new_info) + savetxt(base + "_aimdrift.txt", drift, header="dx\tdy", newline="\r\n") + + def _density(files, radius): import glob @@ -772,7 +792,7 @@ def _localize(args): print("Localize - Parameters:") print("{:<8} {:<15} {:<10}".format("No", "Label", "Value")) - if args.fit_method == "lq-gpu": + if args.fit_method == "lq-gpu" or args.fit_method == "lq-gpu-3d": if gausslq.gpufit_installed: print("GPUfit installed") else: @@ -985,6 +1005,7 @@ def prompt_info(): print("------------------------------------------") info.append(localize_info) + info.append(camera_info) base, ext = splitext(path) @@ -1004,7 +1025,7 @@ def prompt_info(): if CHECK_DB: print("\n") - print("Assesing quality and adding to DB") + print("Assessing quality and adding to DB") add_file_to_db(path, out_path) print("Done.") print("\n") @@ -1210,12 +1231,12 @@ def main(): " specified by a unix style path pattern" ), ) - undrift_parser.add_argument( - "-m", - "--mode", - default="render", - help='"std", "render" or "framepair")', - ) + # undrift_parser.add_argument( + # "-m", + # "--mode", + # default="render", + # help='"std", "render" or "framepair")', + # ) undrift_parser.add_argument( "-s", "--segmentation", @@ -1239,6 +1260,48 @@ def main(): help="do not display estimated drift", ) + # undrift by AIM parser + undrift_aim_parser = subparsers.add_parser( + "aim", help="correct localization coordinates for drift with AIM" + ) + undrift_aim_parser.add_argument( + "files", + help=( + "one or multiple hdf5 localization files" + " specified by a unix style path pattern" + ), + ) + undrift_aim_parser.add_argument( + "-s", + "--segmentation", + type=float, + default=100, + help=( + "the number of frames to be combined" + " for one temporal segment (default=100)" + ), + ) + undrift_aim_parser.add_argument( + "-i", + "--intersectdist", + type=float, + default=20/130, + help=( + "max. distance (cam. pixels) between localizations in" + " consecutive segments to be considered as intersecting" + ), + ) + undrift_aim_parser.add_argument( + "-r", + "--roiradius", + type=float, + default=60/130, + help=( + "max. drift (cam. pixels) between two consecutive" + " segments" + ), + ) + # local densitydd density_parser = subparsers.add_parser( "density", help="compute the local density of localizations" @@ -1708,6 +1771,8 @@ def main(): ) elif args.command == "undrift": _undrift(args.files, args.segmentation, args.nodisplay, args.fromfile) + elif args.command == "aim": + _undrift_aim(args.files, args.segmentation, args.intersectdist, args.roiradius) elif args.command == "density": _density(args.files, args.radius) elif args.command == "dbscan": diff --git a/picasso/__version__.py b/picasso/__version__.py index 996c86f6..0fab7c7f 100644 --- a/picasso/__version__.py +++ b/picasso/__version__.py @@ -1 +1 @@ -VERSION_NO = "0.7.3" +VERSION_NO = "0.7.5" diff --git a/picasso/aim.py b/picasso/aim.py index 4336cb2c..95c04832 100644 --- a/picasso/aim.py +++ b/picasso/aim.py @@ -628,12 +628,15 @@ def aim( Radius of the local search region in camera pixels. Should be larger than the maximum expected drift within segmentation. progress : picasso.lib.ProgressDialog (default=None) - Progress dialog. If None, progress is displayed with tqdm. + Progress dialog. If None, progress is displayed with into the + console. Returns ------- locs : _np.rec.array Undrifted localizations. + new_info : list of 1 dict + Updated metadata. drift : _np.rec.array Drift in x and y directions (and z if applicable). """ @@ -649,14 +652,18 @@ def aim( if val := inf.get("Height"): height = val if val := inf.get('Frames'): - n_frames = val + n_frames = val - locs["frame"].min() if val := inf.get("Pixelsize"): pixelsize = val if _np.isnan(width * height * pixelsize * n_frames): - raise KeyError("Insufficient metadata available.") + raise KeyError( + "Insufficient metadata available. Please specify 'Width', 'Height'," + " 'Frames' and 'Pixelsize' in the metadata .yaml." + ) # frames should start at 1 - frame = locs["frame"] + 1 + frame = locs["frame"] + 1 - locs["frame"].min() + # find the segmentation bounds (temporal intervals) seg_bounds = _np.concatenate(( _np.arange(0, n_frames, segmentation), [n_frames] @@ -686,9 +693,13 @@ def aim( drift_x = drift_x1 + drift_x2 drift_y = drift_y1 + drift_y2 - # # shift the drifts by the mean value - drift_x -= _np.mean(drift_x) - drift_y -= _np.mean(drift_y) + # shift the drifts by the mean value + shift_x = _np.mean(drift_x) + shift_y = _np.mean(drift_y) + drift_x -= shift_x + drift_y -= shift_y + x_pdc += shift_x + y_pdc += shift_y # combine to Picasso format drift = _np.rec.array((drift_x, drift_y), dtype=[("x", "f"), ("y", "f")]) @@ -713,7 +724,9 @@ def aim( aim_round=2, progress=progress, ) drift_z = drift_z1 + drift_z2 - drift_z -= _np.mean(drift_z) + shift_z = _np.mean(drift_z) + drift_z -= shift_z + z_pdc += shift_z drift = _np.rec.array( (drift_x, drift_y, drift_z), dtype=[("x", "f"), ("y", "f"), ("z", "f")] @@ -726,7 +739,7 @@ def aim( locs["z"] = z_pdc new_info = { - "Undrifted by": "AIM", + "Generated by": "AIM undrift", "Intersect distance (nm)": intersect_d * pixelsize, "Segmentation": segmentation, "Search regions radius (nm)": roi_r * pixelsize, diff --git a/picasso/gausslq.py b/picasso/gausslq.py index 6a45d163..d2165216 100644 --- a/picasso/gausslq.py +++ b/picasso/gausslq.py @@ -14,7 +14,7 @@ from tqdm import tqdm as _tqdm import numba as _numba import multiprocessing as _multiprocessing -from concurrent import futures as _futures +from concurrent import futures as _futures from . import postprocess as _postprocess try: @@ -81,7 +81,6 @@ def _initial_parameters(spot, size, size_half): def initial_parameters_gpufit(spots, size): - center = (size / 2.0) - 0.5 initial_width = _np.amax([size / 5.0, 1.0]) @@ -155,7 +154,7 @@ def fit_spot(spot): def fit_spots(spots): theta = _np.empty((len(spots), 6), dtype=_np.float32) - theta.fill(_np.nan) + # theta.fill(_np.nan) for i, spot in enumerate(spots): theta[i] = fit_spot(spot) return theta diff --git a/picasso/gui/localize.py b/picasso/gui/localize.py index 8d315845..c4bdb732 100644 --- a/picasso/gui/localize.py +++ b/picasso/gui/localize.py @@ -12,7 +12,9 @@ import os.path import sys import yaml -from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtWidgets import QShortcut +from PyQt5.QtGui import QKeySequence import time import numpy as np import traceback @@ -44,7 +46,6 @@ def paintEvent(self, event): rect.setWidth(int(rect.width() - 1)) painter.drawRect(rect) - class View(QtWidgets.QGraphicsView): """The central widget which shows `Scene` objects of individual frames""" @@ -52,11 +53,15 @@ def __init__(self, window): super().__init__(window) self.window = window self.setAcceptDrops(True) + self.setMouseTracking(True) + self.clickInsideRubberBand = False self.pan = False self.hscrollbar = self.horizontalScrollBar() self.vscrollbar = self.verticalScrollBar() self.rubberband = RubberBand(self) self.roi = None + self.roi_point1 = QtCore.QPoint() + self.roi_point2 = QtCore.QPoint() self.numeric_roi = False def mousePressEvent(self, event): @@ -65,26 +70,72 @@ def mousePressEvent(self, event): self.rubberband.setGeometry(QtCore.QRect(self.roi_origin, QtCore.QSize())) self.rubberband.show() elif event.button() == QtCore.Qt.RightButton: + click_pos = event.pos() + rubberband_rect = self.rubberband.geometry() + if self.roi != None and rubberband_rect.contains(click_pos): + self.clickInsideRubberBand = True + self.setCursor(QtCore.Qt.ClosedHandCursor) + else: + self.clickInsideRubberBand = False + self.setCursor(QtCore.Qt.OpenHandCursor) self.pan = True self.pan_start_x = event.x() self.pan_start_y = event.y() - self.setCursor(QtCore.Qt.ClosedHandCursor) event.accept() else: event.ignore() + def mouseDoubleClickEvent(self, event): + self.window.fit_in_view() + def mouseMoveEvent(self, event): + x_on_scene = self.mapToScene(event.x(), event.y()).x() + y_on_scene = self.mapToScene(event.x(), event.y()).y() + if(self.window.movie is not None and x_on_scene > 0 and x_on_scene < np.shape(self.window.movie[self.window.curr_frame_number])[0] - 1): + x_on_screen = str(round(x_on_scene)) + # x_on_screen = str(round(x_on_scene, 2)) + else: + x_on_screen = "-" + if(self.window.movie is not None and y_on_scene > 0 and y_on_scene < np.shape(self.window.movie[self.window.curr_frame_number])[1] - 1): + y_on_screen = str(round(y_on_scene)) + # y_on_screen = str(round(y_on_scene, 2)) + else: + y_on_screen = "-" + if self.window.movie is not None and x_on_screen != "-" and y_on_screen != "-": + value = str(self.window.movie[self.window.curr_frame_number][round(x_on_scene), round(y_on_scene)]) + else: + value = "-" + if value != "-" and x_on_screen != "-" and y_on_screen != "-": + self.window.status_bar.showMessage("x=" + x_on_screen + ", y=" + y_on_screen + ", value=" + value) + else: + self.window.status_bar.showMessage("") + if event.buttons() == QtCore.Qt.LeftButton and not self.numeric_roi: - self.rubberband.setGeometry(QtCore.QRect(self.roi_origin, event.pos())) + if event.x() > self.roi_origin.x(): + self.roi_point1.setX(self.roi_origin.x()) + self.roi_point2.setX(event.x()) + else: + self.roi_point1.setX(event.x()) + self.roi_point2.setX(self.roi_origin.x()) + if event.y() > self.roi_origin.y(): + self.roi_point1.setY(self.roi_origin.y()) + self.roi_point2.setY(event.y()) + else: + self.roi_point1.setY(event.y()) + self.roi_point2.setY(self.roi_origin.y()) + self.rubberband.setGeometry(QtCore.QRect(self.roi_point1, self.roi_point2)) if self.pan: - self.hscrollbar.setValue( - self.hscrollbar.value() - event.x() + self.pan_start_x - ) - self.vscrollbar.setValue( - self.vscrollbar.value() - event.y() + self.pan_start_y - ) - self.pan_start_x = event.x() - self.pan_start_y = event.y() + if self.clickInsideRubberBand: + self.rubberband.move(self.roi_point1.x() + event.x() - self.pan_start_x, self.roi_point1.y() + event.y() - self.pan_start_y) + else: + self.hscrollbar.setValue( + self.hscrollbar.value() - event.x() + self.pan_start_x + ) + self.vscrollbar.setValue( + self.vscrollbar.value() - event.y() + self.pan_start_y + ) + self.pan_start_x = event.x() + self.pan_start_y = event.y() event.accept() else: event.ignore() @@ -99,18 +150,56 @@ def mouseReleaseEvent(self, event): self.rubberband.hide() self.window.parameters_dialog.roi_edit.setText("") else: + if self.roi_end.x() > self.roi_origin.x(): + self.roi_point1.setX(self.roi_origin.x()) + self.roi_point2.setX(self.roi_end.x()) + else: + self.roi_point1.setX(self.roi_end.x()) + self.roi_point2.setX(self.roi_origin.x()) + if self.roi_end.y() > self.roi_origin.y(): + self.roi_point1.setY(self.roi_origin.y()) + self.roi_point2.setY(self.roi_end.y()) + else: + self.roi_point1.setY(self.roi_end.y()) + self.roi_point2.setY(self.roi_origin.y()) + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ControlModifier: + roi_points = ( + self.mapToScene(self.roi_point1), + self.mapToScene(self.roi_point2) + ) + self.roi = [[int(_.y()), int(_.x())] for _ in roi_points] + (y_min, x_min), (y_max, x_max) = self.roi + self.window.parameters_dialog.roi_edit.setText( + f"{y_min},{x_min},{y_max},{x_max}" + ) + self.numeric_roi = False + else: + self.rubberband.hide() + self.fitInView( + self.mapToScene(self.roi_point1).x(), + self.mapToScene(self.roi_point1).y(), + self.mapToScene(self.roi_point2).x() - self.mapToScene(self.roi_point1).x(), + self.mapToScene(self.roi_point2).y() - self.mapToScene(self.roi_point1).y(), + QtCore.Qt.KeepAspectRatio) + self.window.draw_frame() + elif event.button() == QtCore.Qt.RightButton: + # if self.clickInsideRubberBand: + if self.roi != None: + rubberband_rect = self.rubberband.geometry() + self.roi_point1.setX(rubberband_rect.x()) + self.roi_point2.setX(rubberband_rect.x() + rubberband_rect.width()) + self.roi_point1.setY(rubberband_rect.y()) + self.roi_point2.setY(rubberband_rect.y() + rubberband_rect.height()) roi_points = ( - self.mapToScene(self.roi_origin), - self.mapToScene(self.roi_end), + self.mapToScene(self.roi_point1), + self.mapToScene(self.roi_point2) ) self.roi = [[int(_.y()), int(_.x())] for _ in roi_points] (y_min, x_min), (y_max, x_max) = self.roi self.window.parameters_dialog.roi_edit.setText( f"{y_min},{x_min},{y_max},{x_max}" ) - self.numeric_roi = False - self.window.draw_frame() - elif event.button() == QtCore.Qt.RightButton: self.pan = False self.setCursor(QtCore.Qt.ArrowCursor) event.accept() @@ -118,10 +207,24 @@ def mouseReleaseEvent(self, event): event.ignore() def wheelEvent(self, event): - """Implements zoooming with the mouse wheel""" - scale = 1.008 ** (-event.angleDelta().y()) - self.scale(scale, scale) - + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ControlModifier: + if self.window.movie is not None: + step = int(event.angleDelta().y() / abs(event.angleDelta().y())) + if self.window.curr_frame_number + step >= 0 and self.window.curr_frame_number + step < self.window.info[0]["Frames"]: + self.window.set_frame(self.window.curr_frame_number + step) + elif modifiers == (QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier): + if self.window.movie is not None: + step = 10 * int(event.angleDelta().y() / abs(event.angleDelta().y())) + if self.window.curr_frame_number + step >= 0 and self.window.curr_frame_number + step < self.window.info[0]["Frames"]: + self.window.set_frame(self.window.curr_frame_number + step) + else: + """Implements zoooming with the mouse wheel""" + pos_on_scene = self.mapToScene(event.pos()) + self.centerOn(pos_on_scene) + # scale = 1.008 ** (event.angleDelta().y()) + scale = 1.00153 ** event.angleDelta().y() + self.scale(scale, scale) class Scene(QtWidgets.QGraphicsScene): """ @@ -579,11 +682,10 @@ def __init__(self, parent=None): # Baseline photon_grid.addWidget(QtWidgets.QLabel("Baseline:"), 1, 0) - self.baseline = QtWidgets.QDoubleSpinBox() - self.baseline.setRange(0, 1e6) - self.baseline.setValue(100.0) - self.baseline.setDecimals(1) - self.baseline.setSingleStep(0.1) + self.baseline = QtWidgets.QSpinBox() + self.baseline.setRange(0, int(1e6)) + self.baseline.setValue(100) + self.baseline.setSingleStep(1) photon_grid.addWidget(self.baseline, 1, 1) # Sensitivity @@ -777,7 +879,7 @@ def reset_quality_check(self): def on_roi_edit_changed(self): if self.roi_edit.text() == "": - self.window.view.numeric_roi = False + self.window.view.numeric_roi = False self.window.view.roi = None self.window.view.rubberband.hide() self.window.draw_frame() @@ -797,7 +899,8 @@ def on_roi_edit_finished(self): ) self.window.view.rubberband.show() self.window.draw_frame() - self.window.view.numeric_roi = True + # self.window.view.numeric_roi = True + self.window.view.numeric_roi = False def on_fit_method_changed(self, state): if self.fit_method.currentText() == "LQ, Gaussian": @@ -1156,10 +1259,27 @@ def init_menu_bar(self): previous_frame_action.setShortcut("Left") previous_frame_action.triggered.connect(self.previous_frame) view_menu.addAction(previous_frame_action) - next_frame_action = view_menu.addAction("Next frame") + next_frame_action = view_menu.addAction("Next frame") + # next_frame_action.setShortcuts(["Up", "Right"]) next_frame_action.setShortcut("Right") next_frame_action.triggered.connect(self.next_frame) view_menu.addAction(next_frame_action) + shortcut_previous_frame_10 = QShortcut(QKeySequence("Shift+Left"), self) + shortcut_previous_frame_10.activated.connect(self.previous_frame_10) + shortcut_next_frame_10 = QShortcut(QKeySequence("Shift+Right"), self) + shortcut_next_frame_10.activated.connect(self.next_frame_10) + previous_frame_25_action = view_menu.addAction("Previous frame 25") + previous_frame_25_action.setShortcut("Down") + previous_frame_25_action.triggered.connect(self.previous_frame_25) + view_menu.addAction(previous_frame_25_action) + next_frame_action_25 = view_menu.addAction("Next frame 25") + next_frame_action_25.setShortcut("Up") + next_frame_action_25.triggered.connect(self.next_frame_25) + view_menu.addAction(next_frame_action_25) + shortcut_previous_frame_50 = QShortcut(QKeySequence("Shift+Down"), self) + shortcut_previous_frame_50.activated.connect(self.previous_frame_50) + shortcut_next_frame_50 = QShortcut(QKeySequence("Shift+Up"), self) + shortcut_next_frame_50.activated.connect(self.next_frame_50) view_menu.addSeparator() first_frame_action = view_menu.addAction("First frame") first_frame_action.setShortcut("Home") @@ -1221,10 +1341,10 @@ def init_menu_bar(self): @property def camera_info(self): camera_info = {} - camera_info["Baseline"] = self.parameters_dialog.baseline.value() - camera_info["Gain"] = self.parameters_dialog.gain.value() - camera_info["Sensitivity"] = self.parameters_dialog.sensitivity.value() - camera_info["Qe"] = self.parameters_dialog.qe.value() + camera_info["baseline"] = self.parameters_dialog.baseline.value() + camera_info["gain"] = self.parameters_dialog.gain.value() + camera_info["sensitivity"] = self.parameters_dialog.sensitivity.value() + camera_info["qe"] = self.parameters_dialog.qe.value() return camera_info def calibrate_z(self): @@ -1281,7 +1401,8 @@ def open(self, path): ) self.setWindowTitle( - "Picasso: Localize. File: {}".format(os.path.basename(path)) + # "Picasso: Localize. File: {}".format(os.path.basename(path)) + "Picasso: Localize. File: {}".format(path) ) self.parameters_dialog.reset_quality_check() @@ -1474,14 +1595,38 @@ def prompt_channel(self, channels): return channel def previous_frame(self): - if self.movie is not None: - if self.curr_frame_number > 0: - self.set_frame(self.curr_frame_number - 1) + self.previous_frame_step(1) def next_frame(self): + self.next_frame_step(1) + + def previous_frame_10(self): + self.previous_frame_step(10) + + def next_frame_10(self): + self.next_frame_step(10) + + def previous_frame_25(self): + self.previous_frame_step(25) + + def next_frame_25(self): + self.next_frame_step(25) + + def previous_frame_50(self): + self.previous_frame_step(50) + + def next_frame_50(self): + self.next_frame_step(50) + + def previous_frame_step(self, step): + if self.movie is not None: + if self.curr_frame_number - step >= 0: + self.set_frame(self.curr_frame_number - step) + + def next_frame_step(self, step): if self.movie is not None: - if self.curr_frame_number + 1 < self.info[0]["Frames"]: - self.set_frame(self.curr_frame_number + 1) + if self.curr_frame_number + step < self.info[0]["Frames"]: + self.set_frame(self.curr_frame_number + step) def first_frame(self): if self.movie is not None: @@ -1732,6 +1877,7 @@ def on_fit_finished(self, locs, elapsed_time, fit_z, calibrate_z): zfit.calibrate_z( locs, self.info, + self.last_identification_info["Min. Net Gradient"], step, self.parameters_dialog.magnification_factor.value(), path=path, @@ -1924,7 +2070,7 @@ def run(self): if self.use_gpufit: self.progressMade.emit(1, 1) theta = gausslq.fit_spots_gpufit(spots) - em = self.camera_info["Gain"] > 1 + em = self.camera_info["gain"] > 1 locs = gausslq.locs_from_fits_gpufit( self.identifications, theta, self.box, em ) @@ -1937,7 +2083,7 @@ def run(self): ) time.sleep(0.2) theta = gausslq.fits_from_futures(fs) - em = self.camera_info["Gain"] > 1 + em = self.camera_info["gain"] > 1 locs = gausslq.locs_from_fits(self.identifications, theta, self.box, em) elif self.method == "mle": curr, thetas, CRLBs, llhoods, iterations = gaussmle.gaussmle_async( @@ -1963,7 +2109,7 @@ def run(self): self.progressMade.emit(round(N * lib.n_futures_done(fs) / n_tasks), N) time.sleep(0.2) theta = avgroi.fits_from_futures(fs) - em = self.camera_info["Gain"] > 1 + em = self.camera_info["gain"] > 1 locs = avgroi.locs_from_fits(self.identifications, theta, self.box, em) else: print("This should never happen...") diff --git a/picasso/gui/render.py b/picasso/gui/render.py index 6ebd49ea..6ab97740 100644 --- a/picasso/gui/render.py +++ b/picasso/gui/render.py @@ -21,6 +21,7 @@ import matplotlib.pyplot as plt import matplotlib.patches as patches import numpy as np +import pandas as pd import yaml from matplotlib.backends.backend_qt5agg import FigureCanvas @@ -826,6 +827,12 @@ def close_file(self, i, render=True): self.update_viewport() self.adjustSize() + # update the window title + self.window.setWindowTitle( + f"Picasso v{__version__}: Render. File: " + f"{os.path.basename(self.window.view.locs_paths[-1])}" + ) + def update_viewport(self): """ Updates the scene in the main window. """ @@ -1704,7 +1711,7 @@ class AIMDialog(QtWidgets.QDialog): def __init__(self, window): super().__init__(window) self.window = window - self.setWindowTitle("Enter parameters") + self.setWindowTitle("AIM undrifting") vbox = QtWidgets.QVBoxLayout(self) grid = QtWidgets.QGridLayout() grid.addWidget(QtWidgets.QLabel("Segmentation:"), 0, 0) @@ -1824,12 +1831,11 @@ def getParams(parent=None): dialog = DbscanDialog(parent) result = dialog.exec_() - return ( - dialog.radius.value(), - dialog.density.value(), - dialog.save_centers.isChecked(), - result == QtWidgets.QDialog.Accepted, - ) + return { + "radius": dialog.radius.value(), + "min_density": dialog.density.value(), + "save_centers": dialog.save_centers.isChecked(), + }, result == QtWidgets.QDialog.Accepted class HdbscanDialog(QtWidgets.QDialog): @@ -1908,13 +1914,12 @@ def getParams(parent=None): dialog = HdbscanDialog(parent) result = dialog.exec_() - return ( - dialog.min_cluster.value(), - dialog.min_samples.value(), - dialog.cluster_eps.value(), - dialog.save_centers.isChecked(), - result == QtWidgets.QDialog.Accepted, - ) + return { + "min_cluster": dialog.min_cluster.value(), + "min_samples": dialog.min_samples.value(), + "cluster_eps": dialog.cluster_eps.value(), + "save_centers": dialog.save_centers.isChecked(), + }, result == QtWidgets.QDialog.Accepted, class LinkDialog(QtWidgets.QDialog): @@ -2420,7 +2425,6 @@ def __init__(self, dialog): grid = QtWidgets.QGridLayout(self) grid.addWidget(QtWidgets.QLabel("Radius (nm):"), 0, 0) self.radius = QtWidgets.QDoubleSpinBox() - self.radius.setKeyboardTracking(False) self.radius.setRange(0.01, 1e6) self.radius.setValue(10) self.radius.setDecimals(2) @@ -2429,7 +2433,6 @@ def __init__(self, dialog): grid.addWidget(QtWidgets.QLabel("Min. samples:"), 1, 0) self.min_samples = QtWidgets.QSpinBox() - self.min_samples.setKeyboardTracking(False) self.min_samples.setValue(4) self.min_samples.setRange(1, int(1e6)) self.min_samples.setSingleStep(1) @@ -2448,7 +2451,6 @@ def __init__(self, dialog): grid = QtWidgets.QGridLayout(self) grid.addWidget(QtWidgets.QLabel("Min. cluster size:"), 0, 0) self.min_cluster_size = QtWidgets.QSpinBox() - self.min_cluster_size.setKeyboardTracking(False) self.min_cluster_size.setValue(10) self.min_cluster_size.setRange(1, int(1e6)) self.min_cluster_size.setSingleStep(1) @@ -2456,7 +2458,6 @@ def __init__(self, dialog): grid.addWidget(QtWidgets.QLabel("Min. samples"), 1, 0) self.min_samples = QtWidgets.QSpinBox() - self.min_samples.setKeyboardTracking(False) self.min_samples.setValue(10) self.min_samples.setRange(1, int(1e6)) self.min_samples.setSingleStep(1) @@ -2466,7 +2467,6 @@ def __init__(self, dialog): QtWidgets.QLabel("Intercluster max.\ndistance (pixels):"), 2, 0 ) self.cluster_eps = QtWidgets.QDoubleSpinBox() - self.cluster_eps.setKeyboardTracking(False) self.cluster_eps.setRange(0, 1e6) self.cluster_eps.setValue(0.0) self.cluster_eps.setDecimals(3) @@ -2486,7 +2486,6 @@ def __init__(self, dialog): grid = QtWidgets.QGridLayout(self) grid.addWidget(QtWidgets.QLabel("Radius xy (nm):"), 0, 0) self.radius_xy = QtWidgets.QDoubleSpinBox() - self.radius_xy.setKeyboardTracking(False) self.radius_xy.setValue(10) self.radius_xy.setRange(0.01, 1e6) self.radius_xy.setSingleStep(0.1) @@ -2495,7 +2494,6 @@ def __init__(self, dialog): grid.addWidget(QtWidgets.QLabel("Radius z (3D only):"), 1, 0) self.radius_z = QtWidgets.QDoubleSpinBox() - self.radius_z.setKeyboardTracking(False) self.radius_z.setValue(25) self.radius_z.setRange(0.01, 1e6) self.radius_z.setSingleStep(0.1) @@ -2504,7 +2502,6 @@ def __init__(self, dialog): grid.addWidget(QtWidgets.QLabel("Min. no. locs"), 2, 0) self.min_locs = QtWidgets.QSpinBox() - self.min_locs.setKeyboardTracking(False) self.min_locs.setValue(10) self.min_locs.setRange(1, int(1e6)) self.min_locs.setSingleStep(1) @@ -2851,8 +2848,9 @@ class DriftPlotWindow(QtWidgets.QTabWidget): Creates 3 plots with drift """ - def __init__(self, info_dialog): + def __init__(self, parent): super().__init__() + self.parent = parent self.setWindowTitle("Drift Plot") this_directory = os.path.dirname(os.path.realpath(__file__)) icon_path = os.path.join(this_directory, "icons", "render.ico") @@ -2880,23 +2878,27 @@ def plot_3d(self, drift): self.figure.clear() + # get camera pixel size in nm + pixelsize = self.parent.window.display_settings_dlg.pixelsize.value() + ax1 = self.figure.add_subplot(131) - ax1.plot(drift.x, label="x") - ax1.plot(drift.y, label="y") + ax1.plot(drift.x * pixelsize, label="x") + ax1.plot(drift.y * pixelsize, label="y") ax1.legend(loc="best") ax1.set_xlabel("Frame") - ax1.set_ylabel("Drift (pixel)") + ax1.set_ylabel("Drift (nm)") ax2 = self.figure.add_subplot(132) ax2.plot( - drift.x, - drift.y, + drift.x * pixelsize, + drift.y * pixelsize, color=list(plt.rcParams["axes.prop_cycle"])[2][ "color" ], ) - ax2.set_xlabel("x") - ax2.set_ylabel("y") + ax2.set_xlabel("x (nm)") + ax2.set_ylabel("y (nm)") + ax2.inverse_yaxis() ax3 = self.figure.add_subplot(133) ax3.plot(drift.z, label="z") ax3.legend(loc="best") @@ -2917,23 +2919,27 @@ def plot_2d(self, drift): self.figure.clear() + # get camera pixel size in nm + pixelsize = self.parent.window.display_settings_dlg.pixelsize.value() + ax1 = self.figure.add_subplot(121) - ax1.plot(drift.x, label="x") - ax1.plot(drift.y, label="y") + ax1.plot(drift.x * pixelsize, label="x") + ax1.plot(drift.y * pixelsize, label="y") ax1.legend(loc="best") ax1.set_xlabel("Frame") - ax1.set_ylabel("Drift (pixel)") + ax1.set_ylabel("Drift (nm)") ax2 = self.figure.add_subplot(122) ax2.plot( - drift.x, - drift.y, + drift.x * pixelsize, + drift.y * pixelsize, color=list(plt.rcParams["axes.prop_cycle"])[2][ "color" ], ) - ax2.set_xlabel("x") - ax2.set_ylabel("y") + ax2.set_xlabel("x (nm)") + ax2.set_ylabel("y (nm)") + ax2.invert_yaxis() self.canvas.draw() @@ -3467,6 +3473,9 @@ class MaskSettingsDialog(QtWidgets.QDialog): Blurs localizations using a Gaussian filter generate_image() Histograms loaded localizations from a given channel + get_info(channel) + Returns metadata for saving masked localizaitons in a given + channel init_dialog() Initializes dialog when called from the main window load_mask() @@ -3477,12 +3486,14 @@ class MaskSettingsDialog(QtWidgets.QDialog): Masks localizations from a single or all channels _mask_locs(locs) Masks locs given a mask + save_blur() + Saves blurred image of localizations in .png format save_mask() Saves binary mask into .npy format save_locs() Saves masked localizations - save_locs_multi() - Saves masked localizations for all loaded channels + _save_locs(channel, path_in, path_out) + Saves masked localizations from a single channel update_plots() Plots in all 4 axes """ @@ -3531,10 +3542,7 @@ def __init__(self, window): mask_grid.addWidget(self.mask_thresh, 2, 1, 1, 2) gridspec_dict = { - 'bottom': 0.05, - 'top': 0.95, - 'left': 0.05, - 'right': 0.95, + 'bottom': 0.05, 'top': 0.95, 'left': 0.05, 'right': 0.95, } ( self.figure, @@ -3794,93 +3802,105 @@ def save_locs(self): """ Saves masked localizations. """ if self.save_all.isChecked(): # save all channels - self.save_locs_multi() - else: - out_path = self.paths[self.channel].replace( + suffix_in, ok1 = QtWidgets.QInputDialog.getText( + self, + "", + "Enter suffix for localizations inside the mask", + QtWidgets.QLineEdit.Normal, + "_mask_in", + ) + if ok1: + suffix_out, ok2 = QtWidgets.QInputDialog.getText( + self, + "", + "Enter suffix for localizations outside the mask", + QtWidgets.QLineEdit.Normal, + "_mask_out", + ) + if ok2: + for channel in range(len(self.index_locs)): + path_in = self.paths[channel].replace( + ".hdf5", f"{suffix_in}.hdf5" + ) + path_out = self.paths[channel].replace( + ".hdf5", f"{suffix_out}.hdf5" + ) + self._save_locs(channel, path_in, path_out) + + else: # save only the current channel + path_in = self.paths[self.channel].replace( ".hdf5", "_mask_in.hdf5" ) - path, ext = QtWidgets.QFileDialog.getSaveFileName( + path_in, ext = QtWidgets.QFileDialog.getSaveFileName( self, "Save localizations within mask", - out_path, + path_in, filter="*.hdf5", ) - if path: - info = self.infos[self.channel] + [ - { - "Generated by": "Picasso Render : Mask in ", - "Display pixel size [nm]": self.disp_px_size.value(), - "Blur": self.mask_blur.value(), - "Threshold": self.mask_thresh.value(), - } - ] - io.save_locs(path, self.index_locs[0], info) + if path_in: + path_out = self.paths[self.channel].replace( + ".hdf5", "_mask_out.hdf5" + ) + path_out, ext = QtWidgets.QFileDialog.getSaveFileName( + self, + "Save localizations outside of mask", + path_out, + filter="*.hdf5", + ) + if path_out: + self._save_locs(self.channel, path_in, path_out) + + def _save_locs(self, channel, path_in, path_out): + """ + Saves masked localizations for a single channel. - out_path = self.paths[self.channel].replace( - ".hdf5", "_mask_out.hdf5" - ) - path, ext = QtWidgets.QFileDialog.getSaveFileName( - self, - "Save localizations outside of mask", - out_path, - filter="*.hdf5", - ) - if path: - info = self.infos[self.channel] + [ - { - "Generated by": "Picasso Render : Mask out", - "Display pixel size [nm]": self.disp_px_size.value(), - "Blur": self.mask_blur.value(), - "Threshold": self.mask_thresh.value(), - } - ] - io.save_locs(path, self.index_locs_out[0], info) + Parameters + ---------- + channel : int + Channel of localizations to be saved + path_in : str + Path to save localizations inside the mask + path_out : str + Path to save localizations outside the mask + """ - def save_locs_multi(self): - """ Saves masked localizations for all loaded channels. """ + info = self.get_info(channel, locs_in=True) + io.save_locs(path_in, self.index_locs[channel], info) + info = self.get_info(channel, locs_in=False) + io.save_locs(path_out, self.index_locs_out[channel], info) - suffix_in, ok1 = QtWidgets.QInputDialog.getText( - self, - "", - "Enter suffix for localizations inside the mask", - QtWidgets.QLineEdit.Normal, - "_mask_in", - ) - if ok1: - suffix_out, ok2 = QtWidgets.QInputDialog.getText( - self, - "", - "Enter suffix for localizations outside the mask", - QtWidgets.QLineEdit.Normal, - "_mask_out", - ) - if ok2: - for channel in range(len(self.index_locs)): - out_path = self.paths[channel].replace( - ".hdf5", f"{suffix_in}.hdf5" - ) - info = self.infos[channel] + [ - { - "Generated by": "Picasso Render : Mask in", - "Display pixel size [nm]": self.disp_px_size.value(), - "Blur": self.mask_blur.value(), - "Threshold": self.mask_thresh.value(), - } - ] - io.save_locs(out_path, self.index_locs[channel], info) + def get_info(self, channel, locs_in=True): + """ + Returns metadata for masked localizations. + + Parameters + ---------- + channel : int + Channel of localizations to be saved + locs_in : bool (default=True) + True if localizations inside the mask are to be saved + + Returns + ------- + info : list of dicts + Metadata for masked localizations + """ + + mask_in = "in" if locs_in else "out" + mask_pixelsize = self.disp_px_size.value() + area_in = float(np.sum(self.mask)) * (mask_pixelsize * 1e-3) ** 2 + area_total = float(self.mask.size * (mask_pixelsize * 1e-3) ** 2) + area = area_in if locs_in else area_total - area_in + info = self.infos[channel] + [{ + "Generated by": f"Picasso Render : Mask {mask_in}", + "Display pixel size (nm)": mask_pixelsize, + "Blur": self.mask_blur.value(), + "Threshold": self.mask_thresh.value(), + "Area (um^2)": area, + # "Area (um^2)": np.sum(self.mask) * self.x_max * self.y_max, #TODO: get the right formula + }] + return info - out_path = self.paths[channel].replace( - ".hdf5", f"{suffix_out}.hdf5" - ) - info = self.infos[channel] + [ - { - "Generated by": "Picasso Render : Mask out", - "Display pixel size [nm]": self.disp_px_size.value(), - "Blur": self.mask_blur.value(), - "Threshold": self.mask_thresh.value(), - } - ] - io.save_locs(out_path, self.index_locs_out[channel], info) class PickToolCircleSettings(QtWidgets.QWidget): """ A class contating information about circular pick. """ @@ -3892,7 +3912,8 @@ def __init__(self, window, tools_settings_dialog): self.grid.addWidget(QtWidgets.QLabel("Diameter (cam. pixel):"), 0, 0) self.pick_diameter = QtWidgets.QDoubleSpinBox() self.pick_diameter.setRange(0.001, 999999) - self.pick_diameter.setValue(1) + # self.pick_diameter.setValue(1) + self.pick_diameter.setValue(10) self.pick_diameter.setSingleStep(0.1) self.pick_diameter.setDecimals(3) self.pick_diameter.setKeyboardTracking(False) @@ -3933,7 +3954,7 @@ def __init__(self, window, tools_settings_dialog): class RESIDialog(QtWidgets.QDialog): - """ RESI dialog. + """RESI dialog. Allows for clustering multiple channels with user-defined clustering parameters using the SMLM clusterer; saves cluster @@ -4023,17 +4044,17 @@ def __init__(self, window): params_grid.addWidget(QtWidgets.QLabel("RESI channel"), 2, 0) if self.ndim == 2: params_grid.addWidget( - QtWidgets.QLabel("Radius\n[cam. pixel]"), 2, 1 + QtWidgets.QLabel("Radius\n[nm]"), 2, 1 ) params_grid.addWidget( QtWidgets.QLabel("Min # localizations"), 2, 2, 1, 2 ) else: params_grid.addWidget( - QtWidgets.QLabel("Radius xy\n[cam. pixel]"), 2, 1 + QtWidgets.QLabel("Radius xy\n[nm]"), 2, 1 ) params_grid.addWidget( - QtWidgets.QLabel("Radius z\n[cam. pixel]"), 2, 2 + QtWidgets.QLabel("Radius z\n[nm]"), 2, 2 ) params_grid.addWidget( QtWidgets.QLabel("Min # localizations"), 2, 3 @@ -4045,17 +4066,17 @@ def __init__(self, window): count = params_grid.rowCount() r_xy = QtWidgets.QDoubleSpinBox() - r_xy.setRange(0.0001, 1e3) - r_xy.setDecimals(4) - r_xy.setValue(0.1) - r_xy.setSingleStep(0.01) + r_xy.setRange(0.01, 1e6) + r_xy.setDecimals(2) + r_xy.setValue(10) + r_xy.setSingleStep(0.1) self.radius_xy.append(r_xy) r_z = QtWidgets.QDoubleSpinBox() - r_z.setRange(0.0001, 1e3) - r_z.setDecimals(4) - r_z.setValue(0.25) - r_z.setSingleStep(0.01) + r_z.setRange(0.01, 1e6) + r_z.setDecimals(2) + r_z.setValue(25) + r_z.setSingleStep(0.1) self.radius_z.append(r_z) min_locs = QtWidgets.QSpinBox() @@ -4129,13 +4150,14 @@ def perform_resi(self): return ### Prepare data + # get camera pixel size + pixelsize = self.window.display_settings_dlg.pixelsize.value() + # extract clustering parameters - r_xy = [_.value() for _ in self.radius_xy] - r_z = [_.value() for _ in self.radius_z] + r_xy = [_.value() / pixelsize for _ in self.radius_xy] + r_z = [_.value() / pixelsize for _ in self.radius_z] min_locs = [_.value() for _ in self.min_locs] - # get camera pixel size - pixelsize = self.window.display_settings_dlg.pixelsize.value() # saving: path and info for the resi file, suffices for saving # clustered localizations and cluster centers if requested @@ -4191,13 +4213,14 @@ def perform_resi(self): resi_channels = [] # holds each channel's cluster centers for i, locs in enumerate(self.locs): - # cluster each channel using SMLM clusterer - if self.ndim == 3: - params = [r_xy[i], r_z[i], min_locs[i], 0, apply_fa, 0] - else: - params = [r_xy[i], min_locs[i], 0, apply_fa, 0] - - clustered_locs = clusterer.cluster(locs, params, pixelsize) + clustered_locs = clusterer.cluster( + locs, + radius_xy=r_xy[i], + min_locs=min_locs[i], + frame_analysis=apply_fa, + radius_z=r_z[i] if self.ndim == 3 else None, + pixelsize=pixelsize, + ) # save clustered localizations if requested if ok1: @@ -4481,7 +4504,8 @@ def __init__(self, window): self.minimum.setRange(0, 999999) self.minimum.setSingleStep(5) self.minimum.setValue(0) - self.minimum.setDecimals(6) + # self.minimum.setDecimals(6) + self.minimum.setDecimals(0) self.minimum.setKeyboardTracking(False) self.minimum.valueChanged.connect(self.update_scene) contrast_grid.addWidget(self.minimum, 0, 1) @@ -4490,8 +4514,10 @@ def __init__(self, window): self.maximum = QtWidgets.QDoubleSpinBox() self.maximum.setRange(0, 999999) self.maximum.setSingleStep(5) - self.maximum.setValue(100) - self.maximum.setDecimals(6) + self.maximum.setValue(20) + # self.maximum.setValue(100) + # self.maximum.setDecimals(6) + self.maximum.setDecimals(0) self.maximum.setKeyboardTracking(False) self.maximum.valueChanged.connect(self.update_scene) contrast_grid.addWidget(self.maximum, 1, 1) @@ -4981,7 +5007,7 @@ def __init__(self, window): self.sl.valueChanged.connect(self.on_slice_position_changed) slicer_grid.addWidget(self.sl, 1, 0, 1, 2) - self.figure, self.ax = plt.subplots(1, figsize=(3, 3)) + self.figure, self.ax = plt.subplots(1, figsize=(7, 7)) self.canvas = FigureCanvas(self.figure) slicer_grid.addWidget(self.canvas, 2, 0, 1, 2) @@ -5007,6 +5033,7 @@ def initialize(self): """ self.calculate_histogram() + self.window.status_bar_frame_indicator.setText("") self.show() def calculate_histogram(self): @@ -5053,8 +5080,8 @@ def calculate_histogram(self): self.ax.set_ylabel("Rel. frequency") self.ax.set_title(r"$\mathrm{Histogram\ of\ Z:}$") self.canvas.draw() - self.sl.setMaximum(int(len(self.bins)) - 2) - self.sl.setValue(int(len(self.bins) / 2)) + self.sl.setMaximum(int(len(self.bins) - 2)) + self.sl.setValue (int(len(self.bins) / 2)) # reset cache self.slicer_cache = {} @@ -5071,11 +5098,25 @@ def on_pick_slice_changed(self): self.sl.setValue(int(len(self.bins) / 2)) # self.on_slice_position_changed(self.sl.value()) + def toggle_slicer_and_radio_button_check(self): + if not hasattr(SlicerDialog, "slicermin"): + self.calculate_histogram() + self.window.slicer_dialog.slicer_radio_button.setChecked(not self.window.slicer_dialog.slicer_radio_button.checkState()) + self.toggle_slicer + + def toggle_slicer(self): """ Updates scene in the main window slicing is called. """ self.window.view.update_scene() + if not self.window.slicer_dialog.slicer_radio_button.isChecked(): + self.window.status_bar_frame_indicator.setText("") + else: + self.window.status_bar_frame_indicator.setText( + "{:,}/{:,}".format(self.window.slicer_dialog.sl.value(), self.window.slicer_dialog.sl.maximum()) + ) + def on_slice_position_changed(self, position): """ Changes some properties and updates scene in the main window. @@ -5092,6 +5133,10 @@ def on_slice_position_changed(self, position): self.slicermax = self.bins[position + 1] self.window.view.update_scene_slicer() + self.window.status_bar_frame_indicator.setText( + "{:,}/{:,}".format(self.window.slicer_dialog.sl.value(), self.window.slicer_dialog.sl.maximum()) + ) + def export_stack(self): """ Saves all slices as .tif files. """ @@ -5345,6 +5390,8 @@ class View(QtWidgets.QLabel): Renders sliced locs in the given viewport and draws picks etc dropEvent(event) Defines what happens when a file is dropped onto the window + export_grayscale(suffix) + Renders each channel in grayscale and saves the images. export_trace() Saves trace as a .csv filter_picks() @@ -5405,6 +5452,8 @@ class View(QtWidgets.QLabel): Moves viewport by a given relative distance pick_areas() Finds the areas of all current picks in um^2. + pick_fiducials() + Finds the circular picks centered around the fiducials pick_message_box(params) Returns a message box for selecting picks pick_similar() @@ -5458,6 +5507,8 @@ class View(QtWidgets.QLabel): Lets user to select picks based on their traces set_mode() Sets self._mode for QMouseEvents + set_optimal_scalebar() + Sets the optimal scalebar length based on the current viewport set_property() Activates rendering by property set_zoom(zoom) @@ -5505,8 +5556,6 @@ class View(QtWidgets.QLabel): Undrifts with AIM. undrift_from_picked() Undrifts from picked localizations. - _undrift_from_picked_coordinate() - Calculates drift in a given coordinate undrift_from_picked2d() Undrifts x and y coordinates from picked localizations. undrift_rcc() @@ -5554,6 +5603,7 @@ def __init__(self, window): icon = QtGui.QIcon(icon_path) self.icon = icon self.setAcceptDrops(True) + self.setMouseTracking(True) self.setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding ) @@ -5561,6 +5611,8 @@ def __init__(self, window): QtWidgets.QRubberBand.Rectangle, self ) self.rubberband.setStyleSheet("selection-background-color: white") + self.roi_point1 = QtCore.QPoint() + self.roi_point2 = QtCore.QPoint() self.window = window self._pixmap = None self.all_locs = [] # for fast render @@ -5601,8 +5653,7 @@ def get_group_color(self, locs): return locs.group.astype(int) % N_GROUP_COLORS def add(self, path, render=True): - """ - Loads a .hdf5 localizations and the associated .yaml metadata + """Loads a .hdf5 localizations and the associated .yaml metadata files. Parameters @@ -5704,9 +5755,7 @@ def add(self, path, render=True): self.window.dataset_dialog.add_entry(path) self.window.setWindowTitle( - "Picasso v{}: Render. File: {}".format( - __version__, os.path.basename(path) - ) + f"Picasso v{__version__}: Render. File: {os.path.basename(path)}" ) # fast rendering add channel @@ -5737,7 +5786,8 @@ def add_multiple(self, paths): pd.set_value(i+1) if len(self.locs): # if loading was successful if fit_in_view: - self.fit_in_view(autoscale=True) + # self.fit_in_view(autoscale=True) + self.fit_in_view(autoscale=False) else: self.update_scene() @@ -5765,7 +5815,7 @@ def add_point(self, position, update_scene=True): if update_scene: self.update_scene() - def add_polygon_point(self, point_movie, point_screen, update_scene=True): + def add_polygon_point(self, point_movie, point_screen): """Adds a new point to the polygon or closes the current polygon.""" @@ -5776,6 +5826,9 @@ def add_polygon_point(self, point_movie, point_screen, update_scene=True): # to be added if len(self._picks[-1]) < 3: # cannot close polygon yet self._picks[-1].append(point_movie) + # if the last polygon has been closed, start a new pick + elif self._picks[-1][0] == self._picks[-1][-1]: + self._picks.append([point_movie]) else: # check the distance between the current point and the # starting point of the currently drawn polygon @@ -5787,8 +5840,7 @@ def add_polygon_point(self, point_movie, point_screen, update_scene=True): # close the polygon if distance2 < POLYGON_POINTER_SIZE ** 2: self._picks[-1].append(self._picks[-1][0]) - self._picks.append([]) - else: # add a new point + else: # add a new point to the existing pick self._picks[-1].append(point_movie) self.update_pick_info_short() self.update_scene(picks_only=True) @@ -6009,9 +6061,7 @@ def dbscan(self): channel = self.get_channel_all_seq("DBSCAN") # get DBSCAN parameters - params = DbscanDialog.getParams() - ok = params[-1] # true if parameters were given - + params, ok = DbscanDialog.getParams() if ok: if channel == len(self.locs_paths): # apply to all channels # get saving name suffix @@ -6027,7 +6077,7 @@ def dbscan(self): path = self.locs_paths[channel].replace( ".hdf5", f"{suffix}.hdf5" ) - self._dbscan(channel, path, params) + self._dbscan(channel, path, **params) else: # get the path to save path, ext = QtWidgets.QFileDialog.getSaveFileName( @@ -6037,9 +6087,9 @@ def dbscan(self): filter="*.hdf5", ) if path: - self._dbscan(channel, path, params) + self._dbscan(channel, path, **params) - def _dbscan(self, channel, path, params): + def _dbscan(self, channel, path, radius, min_density, save_centers): """ Performs DBSCAN in a given channel with user-defined parameters and saves the result. @@ -6050,11 +6100,14 @@ def _dbscan(self, channel, path, params): Index of the channel were clustering is performed path : str Path to save clustered localizations - params : list - DBSCAN parameters + radius : float + Radius for DBSCAN clustering in nm + min_density : int + Minimum local density for DBSCAN clustering + save_centers : bool + Specifies if cluster centers should be saved """ - radius, min_density, save_centers, _ = params status = lib.StatusDialog( "Applying DBSCAN. This may take a while.", self ) @@ -6083,7 +6136,6 @@ def _dbscan(self, channel, path, params): "Radius (nm)": radius, "Minimum local density": min_density, } - io.save_locs(path, locs, self.infos[channel] + [dbscan_info]) status.close() if save_centers: @@ -6101,9 +6153,7 @@ def hdbscan(self): channel = self.get_channel_all_seq("HDBSCAN") # get HDBSCAN parameters - params = HdbscanDialog.getParams() - ok = params[-1] # true if parameters were given - + params, ok = HdbscanDialog.getParams() if ok: if channel == len(self.locs_paths): # apply to all channels # get saving name suffix @@ -6119,7 +6169,7 @@ def hdbscan(self): path = self.locs_paths[channel].replace( ".hdf5", f"{suffix}.hdf5" ) - self._hdbscan(channel, path, params) + self._hdbscan(channel, path, **params) else: # get the path to save path, ext = QtWidgets.QFileDialog.getSaveFileName( @@ -6132,9 +6182,17 @@ def hdbscan(self): filter="*.hdf5", ) if path: - self._hdbscan(channel, path, params) + self._hdbscan(channel, path, **params) - def _hdbscan(self, channel, path, params): + def _hdbscan( + self, + channel, + path, + min_cluster, + min_samples, + cluster_eps, + save_centers, + ): """ Performs HDBSCAN in a given channel with user-defined parameters and saves the result. @@ -6145,11 +6203,17 @@ def _hdbscan(self, channel, path, params): Index of the channel were clustering is performed path : str Path to save clustered localizations - params : list - HDBSCAN parameters + min_cluster : int + Minimum number of localizations in a cluster + min_samples : int + Number of localizations within radius to consider a given + point a core sample + cluster_eps : float + Distance threshold. Clusters below this value will be merged + save_centers : bool + Specifies if cluster centers should be saved """ - min_cluster, min_samples, cluster_eps, save_centers, _ = params status = lib.StatusDialog( "Applying HDBSCAN. This may take a while.", self ) @@ -6996,6 +7060,59 @@ def move_to_pick(self): viewport = [(y_min, x_min), (y_max, x_max)] self.update_scene(viewport=viewport) + def export_grayscale(self, suffix): + """Exports grayscale rendering of the current viewport for each + channel separately.""" + + kwargs = self.get_render_kwargs() + for i, locs in enumerate(self.all_locs): + path = self.locs_paths[i].replace(".hdf5", f"{suffix}.png") + # render like in self.render_single_channel and + # self.render_scene + _, image = render.render(locs, **kwargs, info=self.infos[i]) + image = self.scale_contrast(image) + image = self.to_8bit(image) + cmap = np.uint8( + np.round(255 * plt.get_cmap("gray")(np.arange(256))) + ) + Y, X = image.shape + bgra = np.zeros((Y, X, 4), dtype=np.uint8, order="C") + bgra[:, :, 0] = cmap[:, 2][image] + bgra[:, :, 1] = cmap[:, 1][image] + bgra[:, :, 2] = cmap[:, 0][image] + bgra[:, :, 3] = 255 + qimage = QtGui.QImage(bgra.data, X, Y, QtGui.QImage.Format_RGB32) + # modify qimage like in self.draw_scene + qimage = qimage.scaled( + self.width(), + self.height(), + QtCore.Qt.KeepAspectRatioByExpanding, + ) + qimage = self.draw_scalebar(qimage) + qimage = self.draw_minimap(qimage) + qimage = self.draw_legend(qimage) + qimage = self.draw_picks(qimage) + qimage = self.draw_points(qimage) + # save image + qimage.save(path) + + # save metadata + info = self.window.export_current_info(path=None) + info["Colormap"] = "gray" + io.save_info(path.replace(".png", ".yaml"), [info]) + + # save a copy with scale bar if not present + scalebar = self.window.display_settings_dlg.scalebar_groupbox.isChecked() + if not scalebar: + spath = path.replace(".png", "_scalebar.png") + self.window.display_settings_dlg.scalebar_groupbox.setChecked(True) + qimage_scale = self.draw_scalebar(qimage.copy()) + qimage_scale.save(spath) + self.window.display_settings_dlg.scalebar_groupbox.setChecked(False) + + + + def get_channel(self, title="Choose a channel"): """ Opens an input dialog to ask for a channel. @@ -7334,6 +7451,17 @@ def max_movie_width(self): return max([info[0]["Width"] for info in self.infos]) + # Function to find the nearest (x, y) and return the corresponding photons value + def find_nearest_photons(self, df, input_x, input_y): + # Calculate the Euclidean distance from the input (x, y) to all points + df['distance'] = np.sqrt((df['x'] - input_x)**2 + (df['y'] - input_y)**2) + + # Find the index of the row with the minimum distance + nearest_index = df['distance'].idxmin() + + # Return the corresponding photons value + return df.loc[nearest_index, 'photons'] + def mouseMoveEvent(self, event): """ Defines actions taken when moving mouse. @@ -7346,12 +7474,32 @@ def mouseMoveEvent(self, event): event : QMouseEvent """ + if hasattr(self, 'viewport'): + x_on_scene, y_on_scene = self.map_to_movie(event.pos()) + + df = pd.DataFrame(self.all_locs[0]) + + # nearest_photons = self.find_nearest_photons(df, x_on_scene, y_on_scene) + # self.window.status_bar.showMessage("x=" + str(round(x_on_scene, 2)) + ", y=" + str(round(y_on_scene, 2)) + ", value=" + str(nearest_photons)) + + self.window.status_bar.showMessage("x=" + str(round(x_on_scene, 2)) + ", y=" + str(round(y_on_scene, 2)) + ", value=" + str(self.find_nearest_photons(df, x_on_scene, y_on_scene))) + if self._mode == "Zoom": # if zooming in if self.rubberband.isVisible(): - self.rubberband.setGeometry( - QtCore.QRect(self.origin, event.pos()) - ) + if event.x() > self.origin.x(): + self.roi_point1.setX(self.origin.x()) + self.roi_point2.setX(event.x()) + else: + self.roi_point1.setX(event.x()) + self.roi_point2.setX(self.origin.x()) + if event.y() > self.origin.y(): + self.roi_point1.setY(self.origin.y()) + self.roi_point2.setY(event.y()) + else: + self.roi_point1.setY(event.y()) + self.roi_point2.setY(self.origin.y()) + self.rubberband.setGeometry(QtCore.QRect(self.roi_point1, self.roi_point2)) # if panning if self._pan: rel_x_move = (event.x() - self.pan_start_x) / self.width() @@ -7359,6 +7507,7 @@ def mouseMoveEvent(self, event): self.pan_relative(rel_y_move, rel_x_move) self.pan_start_x = event.x() self.pan_start_y = event.y() + self.setCursor(QtCore.Qt.OpenHandCursor) # if drawing a rectangular pick elif self._mode == "Pick": if self._pick_shape == "Rectangle": @@ -7394,7 +7543,7 @@ def mousePressEvent(self, event): self._pan = True self.pan_start_x = event.x() self.pan_start_y = event.y() - self.setCursor(QtCore.Qt.ClosedHandCursor) + self.setCursor(QtCore.Qt.OpenHandCursor) event.accept() else: event.ignore() @@ -7407,6 +7556,50 @@ def mousePressEvent(self, event): self.rectangle_pick_start_y = event.y() self.rectangle_pick_start = self.map_to_movie(event.pos()) + def mouseDoubleClickEvent(self, event): + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ControlModifier: + if self._pick_shape == "Circle": + # add pick + if event.button() == QtCore.Qt.LeftButton: + x, y = self.map_to_movie(event.pos()) + self.add_pick((x, y)) + event.accept() + # remove pick + elif event.button() == QtCore.Qt.RightButton: + x, y = self.map_to_movie(event.pos()) + self.remove_picks((x, y)) + event.accept() + else: + event.ignore() + elif self._pick_shape == "Rectangle": + if event.button() == QtCore.Qt.LeftButton: + # finish drawing rectangular pick and add it + rectangle_pick_end = self.map_to_movie(event.pos()) + self._rectangle_pick_ongoing = False + self.add_pick( + (self.rectangle_pick_start, rectangle_pick_end) + ) + event.accept() + elif event.button() == QtCore.Qt.RightButton: + # remove pick + x, y = self.map_to_movie(event.pos()) + self.remove_picks((x, y)) + event.accept() + else: + event.ignore() + elif self._pick_shape == "Polygon": + # add a point to the polygon + if event.button() == QtCore.Qt.LeftButton: + point_movie = self.map_to_movie(event.pos()) + self.add_polygon_point(point_movie, event.pos()) + # remove the last point from the polygon + elif event.button() == QtCore.Qt.RightButton: + self.remove_polygon_point() + else: + # elif self._mode == "Zoom": + self.fit_in_view() + def mouseReleaseEvent(self, event): """ Defines actions taken when releasing mouse button. @@ -7425,11 +7618,20 @@ def mouseReleaseEvent(self, event): and self.rubberband.isVisible() ): # zoom in if the zoom-in rectangle is visible end = QtCore.QPoint(event.pos()) - if end.x() > self.origin.x() and end.y() > self.origin.y(): + x_min_rel = x_max_rel = y_min_rel = y_max_rel = 0 + if end.x() > self.origin.x(): x_min_rel = self.origin.x() / self.width() - x_max_rel = end.x() / self.width() + x_max_rel = end.x() / self.width() + if self.origin.x() > end.x(): + x_min_rel = end.x() / self.width() + x_max_rel = self.origin.x() / self.width() + if end.y() > self.origin.y(): y_min_rel = self.origin.y() / self.height() - y_max_rel = end.y() / self.height() + y_max_rel = end.y() / self.height() + if self.origin.y() > end.y(): + y_min_rel = end.y() / self.height() + y_max_rel = self.origin.y() / self.height() + if x_min_rel != 0 and x_max_rel != 0 and y_min_rel != 0 and y_max_rel != 0: viewport_height, viewport_width = self.viewport_size() x_min = self.viewport[0][1] + x_min_rel * viewport_width x_max = self.viewport[0][1] + x_max_rel * viewport_width @@ -8508,6 +8710,34 @@ def pick_areas(self): areas *= (pixelsize * 1e-3) ** 2 # convert to um^2 return areas + def pick_fiducials(self): + """Finds the circular picks centered around the fiducials.""" + + channel = self.get_channel("Pick fiducials") + if channel is None: + return + + if self._pick_shape != "Circle": + message = "Please select circular pick before picking fiducials." + QtWidgets.QMessageBox.warning(self, "Warning", message) + return + if len(self._picks): + message = "Please remove all picks before picking fiducials." + QtWidgets.QMessageBox.warning(self, "Warning", message) + return + + locs = self.all_locs[channel] + info = self.infos[channel] + picks, box = imageprocess.find_fiducials(locs, info) + + if len(picks) == 0: + message = "No fiducials found, manual picking is required." + QtWidgets.QMessageBox.warning(self, "Warning", message) + return + + self.window.tools_settings_dialog.pick_diameter.setValue(box) + self.add_picks(picks) + @check_picks def pick_similar(self): """ @@ -8998,7 +9228,7 @@ def render_single_channel( """ Renders single channel localizations. - Calls render_multi_channel in case of clustered or picked locs, + Calls render_multi_channel in case of clustered, picked locs or rendering by property) Parameters @@ -9113,9 +9343,19 @@ def save_picked_locs(self, path, channel): if self._pick_shape == "Circle": d = self.window.tools_settings_dialog.pick_diameter.value() pick_info["Pick Diameter"] = d + # correct for the total area + pick_info["Total Picked Area (um^2)"] = ( + pick_info["Total Picked Area (um^2)"] * len(self._picks) + ) elif self._pick_shape == "Rectangle": w = self.window.tools_settings_dialog.pick_width.value() pick_info["Pick Width"] = w + # if polygon pick and the last not closed, ignore the last pick + elif ( + self._pick_shape == "Polygon" + and self._picks[-1][0] != self._picks[-1][-1] + ): + pick_info["Number of picks"] -= 1 io.save_locs(path, locs, self.infos[channel] + [pick_info]) def save_picked_locs_multi(self, path): @@ -9540,6 +9780,27 @@ def set_zoom(self, zoom): current_zoom = self.display_pixels_per_viewport_pixels() self.zoom(current_zoom / zoom) + def set_optimal_scalebar(self): + """Sets scalebar to approx. 1/8 of the current viewport's + width""" + + pixelsize = self.window.display_settings_dlg.pixelsize.value() + width = self.viewport_width() + width_nm = width * pixelsize + optimal_scalebar = width_nm / 8 + # approximate to the nearest thousands, hundreds, tens or ones + if optimal_scalebar > 10_000: + scalebar = 10_000 + elif optimal_scalebar > 1_000: + scalebar = int(1_000 * round(optimal_scalebar / 1_000)) + elif optimal_scalebar > 100: + scalebar = int(100 * round(optimal_scalebar / 100)) + elif optimal_scalebar > 10: + scalebar = int(10 * round(optimal_scalebar / 10)) + else: + scalebar = int(round(optimal_scalebar)) + self.window.display_settings_dlg.scalebar.setValue(scalebar) + def sizeHint(self): """ Returns recommended window size. """ @@ -9717,31 +9978,19 @@ def undrift_from_picked(self): picked_locs = self.picked_locs(channel) status = lib.StatusDialog("Calculating drift...", self) - drift_x = self._undrift_from_picked_coordinate( - channel, picked_locs, "x" - ) # find drift in x - drift_y = self._undrift_from_picked_coordinate( - channel, picked_locs, "y" - ) # find drift in y + drift = postprocess.undrift_from_picked( + picked_locs, self.infos[channel] + ) # Apply drift - self.all_locs[channel].x -= drift_x[self.all_locs[channel].frame] - self.all_locs[channel].y -= drift_y[self.all_locs[channel].frame] - self.locs[channel].x -= drift_x[self.locs[channel].frame] - self.locs[channel].y -= drift_y[self.locs[channel].frame] - - # A rec array to store the applied drift - drift = (drift_x, drift_y) - drift = np.rec.array(drift, dtype=[("x", "f"), ("y", "f")]) - + self.all_locs[channel].x -= drift["x"][self.all_locs[channel].frame] + self.all_locs[channel].y -= drift["y"][self.all_locs[channel].frame] + self.locs[channel].x -= drift["x"][self.locs[channel].frame] + self.locs[channel].y -= drift["y"][self.locs[channel].frame] # If z coordinate exists, also apply drift there if all([hasattr(_, "z") for _ in picked_locs]): - drift_z = self._undrift_from_picked_coordinate( - channel, picked_locs, "z" - ) - self.all_locs[channel].z -= drift_z[self.all_locs[channel].frame] - self.locs[channel].z -= drift_z[self.locs[channel].frame] - drift = lib.append_to_rec(drift, drift_z, "z") + self.all_locs[channel].z -= drift["z"][self.all_locs[channel].frame] + self.locs[channel].z -= drift["z"][self.locs[channel].frame] # Cleanup self.index_blocks[channel] = None @@ -9759,85 +10008,21 @@ def undrift_from_picked2d(self): picked_locs = self.picked_locs(channel) status = lib.StatusDialog("Calculating drift...", self) - drift_x = self._undrift_from_picked_coordinate( - channel, picked_locs, "x" - ) - drift_y = self._undrift_from_picked_coordinate( - channel, picked_locs, "y" + drift = postprocess.undrift_from_picked( + picked_locs, self.infos[channel] ) - # Apply drift - self.all_locs[channel].x -= drift_x[self.all_locs[channel].frame] - self.all_locs[channel].y -= drift_y[self.all_locs[channel].frame] - self.locs[channel].x -= drift_x[self.locs[channel].frame] - self.locs[channel].y -= drift_y[self.locs[channel].frame] - - # A rec array to store the applied drift - drift = (drift_x, drift_y) - drift = np.rec.array(drift, dtype=[("x", "f"), ("y", "f")]) + # Apply drift, ignore z coordinates + self.all_locs[channel].x -= drift["x"][self.all_locs[channel].frame] + self.all_locs[channel].y -= drift["y"][self.all_locs[channel].frame] + self.locs[channel].x -= drift["x"][self.locs[channel].frame] + self.locs[channel].y -= drift["y"][self.locs[channel].frame] # Cleanup self.index_blocks[channel] = None self.add_drift(channel, drift) status.close() self.update_scene() - - def _undrift_from_picked_coordinate( - self, channel, picked_locs, coordinate - ): - """ - Calculates drift in a given coordinate. - - Parameters - ---------- - channel : int - Channel where locs are being undrifted - picked_locs : list - List of np.recarrays with locs for each pick - coordinate : str - Spatial coordinate where drift is to be found - - Returns - ------- - np.array - Contains average drift across picks for all frames - """ - - n_picks = len(picked_locs) - n_frames = self.infos[channel][0]["Frames"] - - # Drift per pick per frame - drift = np.empty((n_picks, n_frames)) - drift.fill(np.nan) - - # Remove center of mass offset - for i, locs in enumerate(picked_locs): - coordinates = getattr(locs, coordinate) - drift[i, locs.frame] = coordinates - np.mean(coordinates) - - # Mean drift over picks - drift_mean = np.nanmean(drift, 0) - # Square deviation of each pick's drift to mean drift along frames - sd = (drift - drift_mean) ** 2 - # Mean of square deviation for each pick - msd = np.nanmean(sd, 1) - # New mean drift over picks - # where each pick is weighted according to its msd - nan_mask = np.isnan(drift) - drift = np.ma.MaskedArray(drift, mask=nan_mask) - drift_mean = np.ma.average(drift, axis=0, weights=1/msd) - drift_mean = drift_mean.filled(np.nan) - - # Linear interpolation for frames without localizations - def nan_helper(y): - return np.isnan(y), lambda z: z.nonzero()[0] - - nans, nonzero = nan_helper(drift_mean) - drift_mean[nans] = np.interp( - nonzero(nans), nonzero(~nans), drift_mean[~nans] - ) - - return drift_mean def undo_drift(self): """ Gets channel for undoing drift. """ @@ -9926,7 +10111,7 @@ def apply_drift(self): ) if path: drift = np.loadtxt(path, delimiter=' ') - if hasattr(self.locs[channel], "z"): + if drift.shape[1] == 3: # 3D drift drift = (drift[:,0], drift[:,1], drift[:,2]) drift = np.rec.array( drift, @@ -9950,7 +10135,7 @@ def apply_drift(self): self.locs[channel].z -= drift.z[ self.locs[channel].frame ] - else: + else: # 2D drift drift = (drift[:,0], drift[:,1]) drift = np.rec.array( drift, @@ -10309,6 +10494,8 @@ def update_scene( picks_only=picks_only, ) self.update_cursor() + if not use_cache: + self.set_optimal_scalebar() def update_scene_slicer( self, @@ -10328,7 +10515,7 @@ def update_scene_slicer( True if optimally adjust contrast use_cache : boolean (default=False) True if use stored image - cache : boolena (default=True) + cache : boolean (default=True) True if save image """ @@ -10505,21 +10692,30 @@ def zoom_out(self): self.zoom(ZOOM) - def wheelEvent(self, QWheelEvent): + def wheelEvent(self, event): """ Defines what happens when mouse wheel is used. Press Ctrl/Command to zoom in/out. """ - - modifiers = QtWidgets.QApplication.keyboardModifiers() - if modifiers == QtCore.Qt.ControlModifier: - direction = QWheelEvent.angleDelta().y() - position = self.map_to_movie(QWheelEvent.pos()) - if direction > 0: - self.zoom(1 / ZOOM, cursor_position=position) + + if hasattr(self, 'viewport'): + direction = event.angleDelta().y() + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ControlModifier: + if hasattr(self.window.slicer_dialog, 'patches'): + if direction < 0 and self.window.slicer_dialog.sl.value() > 0: + self.window.slicer_dialog.sl.setValue(self.window.slicer_dialog.sl.value() - 1) + self.window.slicer_dialog.on_slice_position_changed(self.window.slicer_dialog.sl.value()) + elif direction > 0 and self.window.slicer_dialog.sl.value() < self.window.slicer_dialog.sl.maximum(): + self.window.slicer_dialog.sl.setValue(self.window.slicer_dialog.sl.value() + 1) + self.window.slicer_dialog.on_slice_position_changed(self.window.slicer_dialog.sl.value()) else: - self.zoom(ZOOM, cursor_position=position) + position = self.map_to_movie(event.pos()) + if direction > 0: + self.zoom(1 / ZOOM, cursor_position=position) + else: + self.zoom(ZOOM, cursor_position=position) class Window(QtWidgets.QMainWindow): @@ -10574,6 +10770,8 @@ class Window(QtWidgets.QMainWindow): Exports current view as .png or .tif export_current_info() Exports info about the current view in .yaml file + export_grayscale() + Exports each channel in grayscale. export_multi() Asks the user to choose a type of export export_fov_ims() @@ -10648,6 +10846,9 @@ def initUI(self, plugins_loaded): self.view = View(self) # displays rendered locs self.view.setMinimumSize(1, 1) self.setCentralWidget(self.view) + self.status_bar = self.statusBar() + self.status_bar_frame_indicator = QtWidgets.QLabel() + self.status_bar.addPermanentWidget(self.status_bar_frame_indicator) # set up dialogs self.display_settings_dlg = DisplaySettingsDialog(self) @@ -10714,6 +10915,10 @@ def initUI(self, plugins_loaded): export_complete_action = file_menu.addAction("Export complete image") export_complete_action.setShortcut("Ctrl+Shift+E") export_complete_action.triggered.connect(self.export_complete) + export_grayscale_action = file_menu.addAction( + "Export channels in grayscale" + ) + export_grayscale_action.triggered.connect(self.export_grayscale) file_menu.addSeparator() export_multi_action = file_menu.addAction("Export localizations") @@ -10771,8 +10976,13 @@ def initUI(self, plugins_loaded): info_action.setShortcut("Ctrl+I") info_action.triggered.connect(self.info_dialog.show) view_menu.addAction(info_action) - slicer_action = view_menu.addAction("Slice") + slicer_action = view_menu.addAction("3D slicer menu") slicer_action.triggered.connect(self.slicer_dialog.initialize) + slicing_action = view_menu.addAction("3D slicing") + # slicing_action.triggered.connect(self.slicer_dialog.initialize) + # slicing_action.triggered.connect(self.view.update_scene) + slicing_action.triggered.connect(self.slicer_dialog.toggle_slicer_and_radio_button_check) + slicing_action.setShortcut("Ctrl+Q") rot_win_action = view_menu.addAction("Update rotation window") rot_win_action.setShortcut("Ctrl+Shift+R") rot_win_action.triggered.connect(self.rot_win) @@ -10823,6 +11033,9 @@ def initUI(self, plugins_loaded): move_to_pick_action = tools_menu.addAction("Move to pick") move_to_pick_action.triggered.connect(self.view.move_to_pick) + pick_fiducials_action = tools_menu.addAction("Pick fiducials") + pick_fiducials_action.triggered.connect(self.view.pick_fiducials) + tools_menu.addSeparator() show_trace_action = tools_menu.addAction("Show trace") show_trace_action.setShortcut("Ctrl+R") @@ -10938,7 +11151,7 @@ def initUI(self, plugins_loaded): resi_action = postprocess_menu.addAction("RESI") resi_action.triggered.connect(self.open_resi_dialog) - self.load_user_settings() + self.load_user_settings() # Define 3D entries self.actions_3d = [ @@ -11025,12 +11238,9 @@ def export_current_info(self, path): ---------- path : str Path for saving the original image with .png or .tif - extension + extension. If None, info is returned and is not saved. """ - path, ext = os.path.splitext(path) - path = path + ".yaml" - fov_info = [ self.info_dialog.change_fov.x_box.value(), self.info_dialog.change_fov.y_box.value(), @@ -11052,10 +11262,15 @@ def export_current_info(self, path): "Colors": colors, "Min. blur (cam. px)": d.min_blur_width.value(), } - io.save_info(path, [info]) + if path is not None: + path, ext = os.path.splitext(path) + path = path + ".yaml" + io.save_info(path, [info]) + else: + return info def export_complete(self): - """ Exports the whole field of view as .png or .tif. """ + """Exports the whole field of view as .png or .tif. """ try: base, ext = os.path.splitext(self.view.locs_paths[0]) @@ -11072,6 +11287,21 @@ def export_complete(self): qimage.save(path) self.export_current_info(path) + def export_grayscale(self): + """Exports each channel in grayscale.""" + + # get the suffix to save the screenshots + + suffix, ok = QtWidgets.QInputDialog.getText( + self, + "Save each channel in grayscale", + "Enter suffix for the screenshots", + QtWidgets.QLineEdit.Normal, + "_grayscale", + ) + if ok: + self.view.export_grayscale(suffix) + def export_txt(self): """ Exports locs as .txt for ImageJ. @@ -12248,4 +12478,4 @@ def excepthook(type, value, tback): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/picasso/gui/rotation.py b/picasso/gui/rotation.py index 4f61c818..ae34aaa3 100644 --- a/picasso/gui/rotation.py +++ b/picasso/gui/rotation.py @@ -16,8 +16,30 @@ import numpy as np import matplotlib.pyplot as plt -# from moviepy.video.io.ImageSequenceClip import ImageSequenceClip -import imageio +import imageio.v2 as imageio +from PyQt5 import QtCore, QtGui, QtWidgets + +from numpy.lib.recfunctions import stack_arrays + +from .. import io, render, lib""" + picasso/rotation + ~~~~~~~~~~~~~~~~~~~~ + + Rotation window classes and functions. + Extension of Picasso: Render to visualize 3D data. + Many functions are copied from gui.render.View to avoid circular import + + :author: Rafal Kowalewski, 2021-2022 + :copyright: Copyright (c) 2021 Jungmann Lab, MPI of Biochemistry +""" + +import os +import colorsys +from functools import partial + +import numpy as np +import matplotlib.pyplot as plt +import imageio.v2 as imageio from PyQt5 import QtCore, QtGui, QtWidgets from numpy.lib.recfunctions import stack_arrays @@ -314,7 +336,7 @@ def silent_maximum_update(self, value): def render_scene(self, *args, **kwargs): """ Updates scene in the rotation window. """ - self.window.view_rot.update_scene() + self.window.view_rot.update_scene(use_cache=True) def set_dynamic_disp_px(self, state): """ Updates scene if dynamic display pixel size is checked. """ @@ -612,6 +634,7 @@ def build_animation(self): height += 1 # render all frames and save in RAM + # video_writer = imageio.get_writer(name, fps=self.fps.value(),codec='libx264', format='FFMPEG') video_writer = imageio.get_writer(name, fps=self.fps.value()) progress = lib.ProgressDialog( "Rendering frames", 0, len(angx), self.window @@ -762,6 +785,8 @@ class ViewRotation(QtWidgets.QLabel): Dialog set_mode(action) Sets self._mode for QMouseEvents + set_optimal_scalebar() + Sets the scalebar to approx. 1/8 of the current viewport's width shift_viewport(dx, dy) Moves viewport by dx and dy to_down_rot() @@ -900,6 +925,7 @@ def render_scene( ang=None, animation=False, autoscale=False, + use_cache=False, ): """ Returns QImage with rendered localizations. @@ -915,6 +941,8 @@ def render_scene( If True, scenes are rendered for building an animation autoscale : boolean If True, optimally adjust contrast + use_cache : boolean + If True, use cached image Returns ------- @@ -929,9 +957,13 @@ def render_scene( # render single or multi channel data n_channels = len(self.locs) if n_channels == 1: - self.render_single_channel(kwargs, ang=ang, autoscale=autoscale) + self.render_single_channel( + kwargs, ang=ang, autoscale=autoscale, use_cache=use_cache + ) else: - self.render_multi_channel(kwargs, ang=ang, autoscale=autoscale) + self.render_multi_channel( + kwargs, ang=ang, autoscale=autoscale, use_cache=use_cache + ) # add alpha channel (no transparency) self._bgra[:, :, 3].fill(255) # build QImage @@ -947,6 +979,7 @@ def render_multi_channel( locs=None, ang=None, autoscale=False, + use_cache=False, ): """ Renders and paints multichannel localizations. @@ -965,6 +998,8 @@ def render_multi_channel( angles autoscale : boolean If True, optimally adjust contrast + use_cache : boolean + If True, use cached image Returns ------- @@ -980,24 +1015,28 @@ def render_multi_channel( n_channels = len(locs) colors = get_colors(n_channels) # automatic colors - if ang is None: # no build animation - renderings = [ - render.render( - _, **kwargs, - ang=(self.angx, self.angy, self.angz), - ) for _ in locs - ] - else: # build animation - renderings = [ - render.render( - _, **kwargs, - ang=ang, - ) for _ in locs - ] - n_locs = sum([_[0] for _ in renderings]) - image = np.array([_[1] for _ in renderings]) - self.n_locs = n_locs - self.image = image + if use_cache: + n_locs = self.n_locs + image = self.image + else: + if ang is None: # no build animation + renderings = [ + render.render( + _, **kwargs, + ang=(self.angx, self.angy, self.angz), + ) for _ in locs + ] + else: # build animation + renderings = [ + render.render( + _, **kwargs, + ang=ang, + ) for _ in locs + ] + n_locs = sum([_[0] for _ in renderings]) + image = np.array([_[1] for _ in renderings]) + self.n_locs = n_locs + self.image = image # adjust contrast image = self.scale_contrast(image, autoscale=autoscale) @@ -1063,7 +1102,9 @@ def render_multi_channel( self._bgra = self.to_8bit(bgra) # convert to 8 bit return self._bgra - def render_single_channel(self, kwargs, ang=None, autoscale=False): + def render_single_channel( + self, kwargs, ang=None, autoscale=False, use_cache=False + ): """ Renders single channel localizations. @@ -1078,7 +1119,9 @@ def render_single_channel(self, kwargs, ang=None, autoscale=False): Rotation angles to be rendered. If None, takes the current angles autoscale : boolean (default=False) - True if optimally adjust contrast + True if optimally adjust contrast + use_cache : boolean (default=False) + True if the rendered scene should be taken from cache Returns ------- @@ -1098,22 +1141,26 @@ def render_single_channel(self, kwargs, ang=None, autoscale=False): kwargs, locs=locs, ang=ang, autoscale=autoscale ) - if ang is None: # if not build animation - n_locs, image = render.render( - locs, - **kwargs, - info=self.infos[0], - ang=(self.angx, self.angy, self.angz), - ) - else: # if build animation - n_locs, image = render.render( - locs, - **kwargs, - info=self.infos[0], - ang=ang, - ) - self.n_locs = n_locs - self.image = image + if use_cache: + n_locs = self.n_locs + image = self.image + else: + if ang is None: # if not build animation + n_locs, image = render.render( + locs, + **kwargs, + info=self.infos[0], + ang=(self.angx, self.angy, self.angz), + ) + else: # if build animation + n_locs, image = render.render( + locs, + **kwargs, + info=self.infos[0], + ang=ang, + ) + self.n_locs = n_locs + self.image = image # adjust contrast and convert to 8 bits image = self.scale_contrast(image, autoscale=autoscale) @@ -1132,7 +1179,7 @@ def render_single_channel(self, kwargs, ang=None, autoscale=False): self._bgra[..., 2] = cmap[:, 0][image] return self._bgra - def update_scene(self, viewport=None, autoscale=False): + def update_scene(self, viewport=None, autoscale=False, use_cache=False): """ Updates the view of rendered locs. @@ -1142,12 +1189,16 @@ def update_scene(self, viewport=None, autoscale=False): Viewport to be rendered. If None self.viewport is taken autoscale : boolean (default=False) True if optimally adjust contrast + use_cache : boolean (default=False) + True if the rendered scene should be taken from cache """ n_channels = len(self.locs) if n_channels: viewport = viewport or self.viewport - self.draw_scene(viewport, autoscale=autoscale) + self.draw_scene(viewport, autoscale=autoscale, use_cache=use_cache) + if not use_cache: + self.set_optimal_scalebar() # update current position in the animation dialog angx = np.round(self.angx * 180 / np.pi, 1) @@ -1157,7 +1208,7 @@ def update_scene(self, viewport=None, autoscale=False): "{}, {}, {}".format(angx, angy, angz) ) - def draw_scene(self, viewport, autoscale=False): + def draw_scene(self, viewport, autoscale=False, use_cache=False): """ Renders localizations in the given viewport and draws legend, rotation, etc. @@ -1168,12 +1219,14 @@ def draw_scene(self, viewport, autoscale=False): Viewport defining the rendered FOV autoscale : boolean (default=False) True if contrast should be optimally adjusted + use_cache : boolean (default=False) + True if the rendered scene should be taken from cache """ # make sure viewport has the same shape as the main window self.viewport = self.adjust_viewport_to_view(viewport) # render locs - qimage = self.render_scene(autoscale=autoscale) + qimage = self.render_scene(autoscale=autoscale, use_cache=use_cache) # scale image's size to the window self.qimage = qimage.scaled( self.width(), @@ -1592,6 +1645,26 @@ def to_down_rot(self): self.window.move_pick(0, dy) self.shift_viewport(0, dy) + def set_optimal_scalebar(self): + """Sets scalebar to approx. 1/8 of the current viewport's + width""" + + width = self.viewport_width() + width_nm = width * self.pixelsize + optimal_scalebar = width_nm / 8 + # approximate to the nearest thousands, hundreds, tens or ones + if optimal_scalebar > 10_000: + scalebar = 10_000 + elif optimal_scalebar > 1_000: + scalebar = int(1_000 * round(optimal_scalebar / 1_000)) + elif optimal_scalebar > 100: + scalebar = int(100 * round(optimal_scalebar / 100)) + elif optimal_scalebar > 10: + scalebar = int(10 * round(optimal_scalebar / 10)) + else: + scalebar = int(round(optimal_scalebar)) + self.window.display_settings_dlg.scalebar.setValue(scalebar) + def shift_viewport(self, dx, dy): """ Moves viewport by a given amount. @@ -1819,6 +1892,46 @@ def export_current_view(self): ) if path: self.qimage.save(path) + self.export_current_view_info(path) + scalebar = self.window.display_settings_dlg.scalebar_groupbox.isChecked() + if not scalebar: + self.window.display_settings_dlg.scalebar_groupbox.setChecked(True) + self.update_scene() + self.qimage.save(path.replace(".png", "_scalebar.png")) + self.window.display_settings_dlg.scalebar_groupbox.setChecked(False) + self.update_scene() + + def export_current_view_info(self, path): + """ Exports current view's information. """ + + (y_min, x_min), (y_max, x_max) = self.viewport + fov = [x_min, y_min, x_max - x_min, y_max - y_min] + d = self.window.display_settings_dlg + colors = [ + _.currentText() for _ in self.window.dataset_dialog.colorselection + ] + rot_angles = [ + int(self.angx * 180 / np.pi), + int(self.angy * 180 / np.pi), + int(self.angz * 180 / np.pi), + ] + info = { + "Rotation angles (deg)": rot_angles, + "FOV (X, Y, Width, Height)": fov, + "Display pixel size (nm)": d.disp_px_size.value(), + "Min. density": d.minimum.value(), + "Max. density": d.maximum.value(), + "Colormap": d.colormap.currentText(), + "Blur method": d.blur_methods[d.blur_buttongroup.checkedButton()], + "Scalebar length (nm)": d.scalebar.value(), + "Min. blur (cam. px)": d.min_blur_width.value(), + "Localizations loaded": self.paths, + "Colors": colors, + } + path, ext = os.path.splitext(path) + path = path + ".yaml" + io.save_info(path, [info]) + def zoom_in(self): """ Zooms in by a constant factor. """ @@ -2421,4 +2534,4 @@ def closeEvent(self, event): self.display_settings_dlg.close() self.animation_dialog.close() - QtWidgets.QMainWindow.closeEvent(self, event) \ No newline at end of file + QtWidgets.QMainWindow.closeEvent(self, event) diff --git a/picasso/imageprocess.py b/picasso/imageprocess.py index 6b63aefd..1259c3a1 100644 --- a/picasso/imageprocess.py +++ b/picasso/imageprocess.py @@ -13,7 +13,8 @@ import lmfit as _lmfit from tqdm import tqdm as _tqdm from . import lib as _lib - +from . import render as _render +from . import localize as _localize _plt.style.use("ggplot") @@ -132,3 +133,54 @@ def rcc(segments, max_shift=None, callback=None): callback(flag) return _lib.minimize_shifts(shifts_x, shifts_y) + + +def find_fiducials(locs, info): + """Finds the xy coordinates of regions with high density of + localizations, likely originating from fiducial markers. + + Uses picasso.localize.identify_in_image with threshold set to 99th + percentile of the image histogram. The image is rendered using + one-pixel-blur, see picasso.render.render. + + + Parameters + ---------- + locs : np.recarray + Localizations. + info : list of dicts + Localizations' metadata (from the corresponding .yaml file). + + Returns + ------- + picks : list of (2,) tuples + Coordinates of fiducial markers. Each list element corresponds + to (x, y) coordinates of one fiducial marker. + box : int + Size of the box used for the fiducial marker identification. + Can be set as the pick diameter in pixels for undrifting. + """ + + image = _render.render( + locs=locs, + info=info, + oversampling=1, + viewport=None, + blur_method="smooth", + )[1] + hist = _np.histogram(image.flatten(), bins=256) + threshold = _np.percentile(hist[0], 99) + # box size should be an odd number, corresponding to approximately + # 900 nm + pixelsize = 130 + for inf in info: + if val := inf.get("Pixelsize"): + pixelsize = val + break + box = int(_np.round(900 / pixelsize)) + box = box + 1 if box % 2 == 0 else box + + # find the local maxima and translate to pick coordinates + y, x, _ = _localize.identify_in_image(image, threshold, box=box) + picks = [(xi, yi) for xi, yi in zip(x, y)] + return picks, box \ No newline at end of file diff --git a/picasso/lib.py b/picasso/lib.py index 6d15f16c..ea0aee48 100644 --- a/picasso/lib.py +++ b/picasso/lib.py @@ -133,11 +133,11 @@ def calculate_optimal_bins(data, max_n_bins=None): if data.dtype.kind in ("u", "i") and bin_size < 1: bin_size = 1 bin_min = data.min() - bin_size / 2 - n_bins = _np.ceil((data.max() - bin_min) / bin_size) try: + n_bins = (data.max() - bin_min) / bin_size n_bins = int(n_bins) - except ValueError: - return None + except: + n_bins = 10 if max_n_bins and n_bins > max_n_bins: n_bins = max_n_bins bins = _np.linspace(bin_min, data.max(), n_bins) @@ -629,14 +629,14 @@ def pick_areas_polygon(picks): Pick areas. """ - areas = _np.zeros(len(picks)) + areas = [] for i, pick in enumerate(picks): if len(pick) < 3 or pick[0] != pick[-1]: # not a closed polygon - areas[i] = 0 continue X, Y = get_pick_polygon_corners(pick) - areas[i] = polygon_area(X, Y) - areas = areas[areas > 0] # remove open polygons + areas.append(polygon_area(X, Y)) + areas = _np.array(areas) + areas = areas[areas > 0] # remove open polygons #TODO: delete this line? return areas diff --git a/picasso/localize.py b/picasso/localize.py old mode 100755 new mode 100644 index 48065967..f2e3633a --- a/picasso/localize.py +++ b/picasso/localize.py @@ -344,7 +344,6 @@ def _cut_spots(movie, ids, box): return spots else: """Assumes that identifications are in order of frames!""" - r = int(box / 2) N = len(ids.frame) spots = _np.zeros((N, box, box), dtype=movie.dtype) @@ -355,11 +354,11 @@ def _cut_spots(movie, ids, box): def _to_photons(spots, camera_info): spots = _np.float32(spots) - baseline = camera_info["Baseline"] - sensitivity = camera_info["Sensitivity"] - gain = camera_info["Gain"] + baseline = camera_info["baseline"] + sensitivity = camera_info["sensitivity"] + gain = camera_info["gain"] # since v0.6.0: remove quantum efficiency to better reflect precision - # qe = camera_info["Qe"] + # qe = camera_info["qe"] return (spots - baseline) * sensitivity / (gain) @@ -562,4 +561,4 @@ def save_file_summary(summary): def add_file_to_db(file, file_hdf, drift=None, len_mean=None, nena=None): summary = get_file_summary(file, file_hdf, drift, len_mean, nena) - save_file_summary(summary) \ No newline at end of file + save_file_summary(summary) diff --git a/picasso/postprocess.py b/picasso/postprocess.py index af74d7d9..41f1d891 100644 --- a/picasso/postprocess.py +++ b/picasso/postprocess.py @@ -1227,6 +1227,8 @@ def undrift( segmentation_callback=None, rcc_callback=None, ): + """Undrift by RCC. """ + bounds, segments = segment( locs, info, @@ -1286,6 +1288,79 @@ def undrift( return drift, locs +def undrift_from_picked(picked_locs, info): + """Finds drift from picked localizations. Note that unlike other + undrifting functions, this function does not return undrifted + localizations but only drift.""" + + drift_x = _undrift_from_picked_coordinate(picked_locs, info, "x") + drift_y = _undrift_from_picked_coordinate(picked_locs, info, "y") + + # A rec array to store the applied drift + drift = (drift_x, drift_y) + drift = _np.rec.array(drift, dtype=[("x", "f"), ("y", "f")]) + + # If z coordinate exists, also apply drift there + if all([hasattr(_, "z") for _ in picked_locs]): + drift_z = _undrift_from_picked_coordinate(picked_locs, info, "z") + drift = _lib.append_to_rec(drift, drift_z, "z") + return drift + + +def _undrift_from_picked_coordinate(picked_locs, info, coordinate): + """Calculates drift in a given coordinate. + + Parameters + ---------- + picked_locs : list + List of np.recarrays with locs for each pick. + info : list of dicts + Localizations' metadeta. + coordinate : {"x", "y", "z"} + Spatial coordinate where drift is to be found. + + Returns + ------- + drift_mean : np.array + Average drift across picks for all frames + """ + + n_picks = len(picked_locs) + n_frames = info[0]["Frames"] + + # Drift per pick per frame + drift = _np.empty((n_picks, n_frames)) + drift.fill(_np.nan) + + # Remove center of mass offset + for i, locs in enumerate(picked_locs): + coordinates = getattr(locs, coordinate) + drift[i, locs.frame] = coordinates - _np.mean(coordinates) + + # Mean drift over picks + drift_mean = _np.nanmean(drift, 0) + # Square deviation of each pick's drift to mean drift along frames + sd = (drift - drift_mean) ** 2 + # Mean of square deviation for each pick + msd = _np.nanmean(sd, 1) + # New mean drift over picks + # where each pick is weighted according to its msd + nan_mask = _np.isnan(drift) + drift = _np.ma.MaskedArray(drift, mask=nan_mask) + drift_mean = _np.ma.average(drift, axis=0, weights=1/msd) + drift_mean = drift_mean.filled(_np.nan) + + # Linear interpolation for frames without localizations + def nan_helper(y): + return _np.isnan(y), lambda z: z.nonzero()[0] + + nans, nonzero = nan_helper(drift_mean) + drift_mean[nans] = _np.interp( + nonzero(nans), nonzero(~nans), drift_mean[~nans] + ) + return drift_mean + + def align(locs, infos, display=False): images = [] for i, (locs_, info_) in enumerate(zip(locs, infos)): diff --git a/picasso/zfit.py b/picasso/zfit.py index 9bc39da5..b79b58a6 100644 --- a/picasso/zfit.py +++ b/picasso/zfit.py @@ -23,11 +23,11 @@ def interpolate_nan(data): return data -def calibrate_z(locs, info, d, magnification_factor, path=None): +def calibrate_z(locs, info, min_net_gradient, step, magnification_factor, path=None): n_frames = info[0]["Frames"] - range = (n_frames - 1) * d + range = (n_frames - 1) * step frame_range = _np.arange(n_frames) - z_range = -(frame_range * d - range / 2) # negative so that the first frames of + z_range = -(frame_range * step - range / 2) # negative so that the first frames of # a bottom-to-up scan are positive z coordinates. mean_sx = _np.array([_np.mean(locs.sx[locs.frame == _]) for _ in frame_range]) @@ -52,20 +52,25 @@ def calibrate_z(locs, info, d, magnification_factor, path=None): cy = _np.polyfit(z_range, mean_sy, 6, full=False) # Fits calibration curve to each localization - # true_z = locs.frame * d - range / 2 + # true_z = locs.frame * step - range / 2 # cx = _np.polyfit(true_z, locs.sx, 6, full=False) # cy = _np.polyfit(true_z, locs.sy, 6, full=False) calibration = { - "X Coefficients": [float(_) for _ in cx], - "Y Coefficients": [float(_) for _ in cy], - "Number of frames": int(n_frames), - "Step size in nm": float(d), + "Calibration min. net gradient": int(min_net_gradient), "Magnification factor": float(magnification_factor), +<<<<<<< HEAD + +======= + "Number of frames": int(n_frames), + "Step size in nm": float(step), + "X Coefficients": [float(_) for _ in cx], + "Y Coefficients": [float(_) for _ in cy] +>>>>>>> 2c0d57b (proposal for GUI improvements) } if path is not None: with open(path, "w") as f: - _yaml.dump(calibration, f, default_flow_style=False) + _yaml.dump(calibration, f, sort_keys=False, default_flow_style=False) locs = fit_z(locs, info, calibration, magnification_factor) locs.z /= magnification_factor @@ -251,6 +256,7 @@ def fit_z_parallel( int(n_locs / n_tasks + 1) if _ < n_locs % n_tasks else int(n_locs / n_tasks) for _ in range(n_tasks) ] + start_indices = _np.cumsum([0] + spots_per_task[:-1]) fs = [] executor = _ProcessPoolExecutor(n_workers) @@ -267,7 +273,11 @@ def fit_z_parallel( ) if asynch: return fs - with _tqdm(total=n_tasks, unit="task") as progress_bar: +<<<<<<< HEAD +======= +# with _tqdm( total=n_tasks, unit="task") as progress_bar: +>>>>>>> 2c0d57b (proposal for GUI improvements) + with _tqdm(desc="3D Fitting", total=n_tasks, unit="task") as progress_bar: for f in _futures.as_completed(fs): progress_bar.update() return locs_from_futures(fs, filter=filter) diff --git a/readme.rst b/readme.rst index ea96bea2..ce15c029 100644 --- a/readme.rst +++ b/readme.rst @@ -135,10 +135,12 @@ If you use picasso in your research, please cite our Nature Protocols publicatio | | If you use some of the functionalities provided by Picasso, please also cite the respective publications: -- Nearest Neighbor based Analysis (NeNA) for experimental localization precision. DOI: `https://doi.org/10.1007/s00418-014-1192-3 `__ -- Theoretical localization precision (Gauss LQ and MLE). DOI: `https://doi.org/10.1038/nmeth.1447 `__ -- MLE fitting. DOI: `https://doi.org/10.1038/nmeth.1449 `__ +- Nearest Neighbor based Analysis (NeNA) for experimental localization precision. DOI: `10.1007/s00418-014-1192-3 `__ +- Theoretical localization precision (Gauss LQ and MLE). DOI: `10.1038/nmeth.1447 `__ +- MLE fitting. DOI: `10.1038/nmeth.1449 `__ - AIM undrifting. DOI: `10.1126/sciadv.adm776 `__ +- DBSCAN: Ester, et al. Inkdd, 1996. (Vol. 96, No. 34, pp. 226-231). +- HDBSCAN. DOI: `10.1007/978-3-642-37456-2_14 `__ Credits ------- diff --git a/release/one_click_windows_gui/create_installer_windows.bat b/release/one_click_windows_gui/create_installer_windows.bat index f935ae94..8eba48cc 100644 --- a/release/one_click_windows_gui/create_installer_windows.bat +++ b/release/one_click_windows_gui/create_installer_windows.bat @@ -5,13 +5,13 @@ call RMDIR /Q/S dist call cd %~dp0\..\.. -call conda create -n picasso_installer python=3.10 -y +call conda create -n picasso_installer python=3.10.15 -y call conda activate picasso_installer call python setup.py sdist bdist_wheel call cd release/one_click_windows_gui -call pip install "../../dist/picassosr-0.7.3-py3-none-any.whl" +call pip install "../../dist/picassosr-0.7.5-py3-none-any.whl" call pip install pyinstaller==5.12 call pyinstaller ../pyinstaller/picasso.spec -y --clean diff --git a/release/one_click_windows_gui/picasso_innoinstaller.iss b/release/one_click_windows_gui/picasso_innoinstaller.iss index 23ba5673..cfffc8f2 100644 --- a/release/one_click_windows_gui/picasso_innoinstaller.iss +++ b/release/one_click_windows_gui/picasso_innoinstaller.iss @@ -1,10 +1,10 @@ [Setup] AppName=Picasso AppPublisher=Jungmann Lab, Max Planck Institute of Biochemistry -AppVersion=0.7.3 +AppVersion=0.7.5 DefaultDirName={commonpf}\Picasso DefaultGroupName=Picasso -OutputBaseFilename="Picasso-Windows-64bit-0.7.3" +OutputBaseFilename="Picasso-Windows-64bit-0.7.5" ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 diff --git a/requirements.txt b/requirements.txt index 686cc129..9d1fa4c6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,6 @@ scikit-learn==1.3.1 tqdm==4.66.1 lmfit==1.2.2 streamlit==1.27.0 -moviepy==1.0.3 nd2==0.7.2 sqlalchemy==2.0.21 plotly-express==0.4.1 diff --git a/setup.py b/setup.py index 9e36657c..b2f40857 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="picassosr", - version="0.7.3", + version="0.7.5", author="Joerg Schnitzbauer, Maximilian T. Strauss, Rafal Kowalewski", author_email=("joschnitzbauer@gmail.com, straussmaximilian@gmail.com, rafalkowalewski998@gmail.com"), url="https://github.com/jungmannlab/picasso",