diff --git a/.bumpversion.cfg b/.bumpversion.cfg index afa46dee..10bb33ae 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.6.8 +current_version = 0.6.9 commit = True tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z]+)(?P\d+))? diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1f400bf3..c7ba5f0a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.10.13 - name: Install dependencies diff --git a/changelog.rst b/changelog.rst index eb283927..b603bd1d 100644 --- a/changelog.rst +++ b/changelog.rst @@ -1,7 +1,12 @@ Changelog ========= -Last change: 23-JAN-2024 MTS +Last change: 22-FEB-2024 MTS + +0.6.9 +----- +- Added the option to draw polygon picks in Picasso: Render +- Save pick properties in Picasso: Render saves areas of picked regions in nm^2 0.6.6 - 0.6.8 ------------- diff --git a/distribution/picasso.iss b/distribution/picasso.iss index 4986471a..40d2b61a 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.6.8 +AppVersion=0.6.9 DefaultDirName={commonpf}\Picasso DefaultGroupName=Picasso -OutputBaseFilename="Picasso-Windows-64bit-0.6.8" +OutputBaseFilename="Picasso-Windows-64bit-0.6.9" ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 diff --git a/docs/conf.py b/docs/conf.py index 76da1c40..e7465712 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.6.8" +release = "0.6.9" # -- General configuration --------------------------------------------------- diff --git a/docs/render.rst b/docs/render.rst index d2fb07a7..b5e182e3 100644 --- a/docs/render.rst +++ b/docs/render.rst @@ -35,7 +35,7 @@ Picking of regions of interest ------------------------------ 1. Manual selection. Open ``Picasso: Render`` and load the localization HDF5 file to be processed. -2. Switch the active tool by selecting ``Tools > Pick``. The mouse cursor will now change to a circle. Alternatively, open ``Tools > Tools Settings`` to change the shape into a rectangle. +2. Switch the active tool by selecting ``Tools > Pick``. The mouse cursor will now change to a circle. Alternatively, open ``Tools > Tools Settings`` to change the shape into a rectangle. Lastly, choosing ``Polygon`` allows for drawing polygons of any shape. 3. Set the size of the pick circle by adjusting the ``Diameter`` field in the tool settings dialog (``Tools > Tools Settings``). Alternatively, choose ``Width`` for a rectangular shape. 4. Pick regions of interest using the circular mouse cursor by clicking the left mouse button. All localizations within the circle will be selected for further processing. 5. (Optional) Automated region of interest selection. Select ``Tools > Pick similar`` to automatically detect and pick structures that have similar numbers of localizations and RMS deviation (RMSD) from their center of mass than already-picked structures. The upper and lower thresholds for these similarity measures are the respective standard deviations of already-picked regions, scaled by a tunable factor. This factor can be adjusted using the field ``Tools > Tools Settings > Pick similar ± range``. To display the mean and standard deviation of localization number and RMSD for currently picked regions, select ``View > Show info`` and click ``Calculate info below``. @@ -157,7 +157,7 @@ Save the localizations that are currently loaded in render to an hdf5 file. Save picked localizations [Ctrl+Shift+S] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Save the localizations that are within a picked region (yellow circle or rectangle). Each pick will get a different group number. To display the group number in Render, select ``Annotate picks`` in Tools/Tools Settings. +Save the localizations that are within a picked region (yellow circle, rectangle or polygon). Each pick will get a different group number. To display the group number in Render, select ``Annotate picks`` in Tools/Tools Settings. In case of rectangular picks, the saved localizations file will contain new columns `x_pick_rot` and `y_pick_rot`, which are localization coordinates into the coordinate system of the pick rectangle (coordinate (0,0) is where the rectangle was started to be drawn, and `y_pick_rot` is in the direction of the drawn line.) These columns can be used to plot density profiles of localizations along the rectangle dimensions easily (e.g., with "Filter"). @@ -267,7 +267,7 @@ Selects the zoom tool. The mouse can now be used for zoom and pan. Pick (CTRL + P) ^^^^^^^^^^^^^^^ -Selects the pick tool. The mouse can now be used for picking localizations. The user can set the pick shape in the `Tools settings` (CTRL + T) dialog. The default shape is Circle with the diameter to be set. For rectangles, the user draws the length, while the width is controlled via a parameter for all drawn rectangles, similar to the diameter for circular picks. +Selects the pick tool. The mouse can now be used for picking localizations. The user can set the pick shape in the `Tools settings` (CTRL + T) dialog. The default shape is Circle with the diameter to be set. For rectangles, the user draws the length, while the width is controlled via a parameter for all drawn rectangles, similar to the diameter for circular picks. For a polygonal pick, the user clicks with the left button to draw the desired polygon. The right button deletes the last selected vertex. The polygon can be close by clicking with the left button on the starting vertex. Measure (CTRL + M) ^^^^^^^^^^^^^^^^^^ diff --git a/picasso/__init__.py b/picasso/__init__.py index c3905fb2..a0285f55 100644 --- a/picasso/__init__.py +++ b/picasso/__init__.py @@ -8,7 +8,7 @@ import os.path as _ospath import yaml as _yaml -__version__ = "0.6.8" +__version__ = "0.6.9" _this_file = _ospath.abspath(__file__) _this_dir = _ospath.dirname(_this_file) diff --git a/picasso/__version__.py b/picasso/__version__.py index d50f6166..00075140 100644 --- a/picasso/__version__.py +++ b/picasso/__version__.py @@ -1 +1 @@ -VERSION_NO = "0.6.8" +VERSION_NO = "0.6.9" diff --git a/picasso/gui/render.py b/picasso/gui/render.py index b5835d10..4d23362b 100644 --- a/picasso/gui/render.py +++ b/picasso/gui/render.py @@ -57,6 +57,7 @@ ZOOM = 9 / 7 N_GROUP_COLORS = 8 N_Z_COLORS = 32 +POLYGON_POINTER_SIZE = 16 # must be even def get_colors(n_channels): @@ -4308,7 +4309,7 @@ def __init__(self, window): pick_grid.addWidget(QtWidgets.QLabel("Shape:"), 1, 0) self.pick_shape = QtWidgets.QComboBox() - self.pick_shape.addItems(["Circle", "Rectangle"]) + self.pick_shape.addItems(["Circle", "Rectangle", "Polygon"]) pick_grid.addWidget(self.pick_shape, 1, 1) pick_stack = QtWidgets.QStackedWidget() pick_grid.addWidget(pick_stack, 2, 0, 1, 2) @@ -4325,6 +4326,10 @@ def __init__(self, window): pick_stack.addWidget(self.pick_rectangle_settings) self.pick_width = self.pick_rectangle_settings.pick_width + # Polygon + self.pick_polygon_settings = QtWidgets.QWidget() + pick_stack.addWidget(self.pick_polygon_settings) + self.pick_annotation = QtWidgets.QCheckBox("Annotate picks") self.pick_annotation.stateChanged.connect(self.update_scene_with_cache) pick_grid.addWidget(self.pick_annotation, 3, 0) @@ -5293,6 +5298,9 @@ class View(QtWidgets.QLabel): Adds a pick at a given position add_point(position) Adds a point at a given position for measuring distances + add_polygon_point(point_movie, point_screen) + Adds a new point to the polygon or closes the current + polygon. add_picks(positions) Adds several picks adjust_viewport_to_view(viewport) @@ -5356,6 +5364,8 @@ class View(QtWidgets.QLabel): Finds group color index for each localization get_index_blocks(channel) Calls self.index_locs if not calculated earlier + get_pick_polygon_corners(pick) + Returns X and Y coordinates of a pick polygon get_pick_rectangle_corners(start_x, start_y, end_x, end_y, width) Finds the positions of a rectangular pick's corners get_pick_rectangle_polygon(start_x, start_y, end_x, end_y, width) @@ -5399,6 +5409,8 @@ class View(QtWidgets.QLabel): Assigns attributes and updates scene if new pick shape chosen pan_relative(dy, dx) Moves viewport by a given relative distance + pick_areas() + Finds the areas of all current picks in nm^2. pick_message_box(params) Returns a message box for selecting picks pick_similar() @@ -5414,6 +5426,8 @@ class View(QtWidgets.QLabel): center remove_points() Removes all distance measurement points + remove_polygon_point() + Removes the last point from the last polygon remove_picks(position) Deletes picks at a given position remove_picked_locs() @@ -5759,6 +5773,34 @@ 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): + """Adds a new point to the polygon or closes the current + polygon.""" + + if len(self._picks) == 0: + self._picks.append([point_movie]) + else: + # check if the polygon is to be closed or if a new point is + # to be added + if len(self._picks[-1]) < 3: # cannot close polygon yet + self._picks[-1].append(point_movie) + else: + # check the distance between the current point and the + # starting point of the currently drawn polygon + start_point = self.map_to_view(*self._picks[-1][0]) + distance2 = ( + (point_screen.x() - start_point[0]) ** 2 + + (point_screen.y() - start_point[1]) ** 2 + ) + # 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 + self._picks[-1].append(point_movie) + self.update_pick_info_short() + self.update_scene(picks_only=True) + def adjust_viewport_to_view(self, viewport): """ Adds space to a desired viewport, such that it matches the @@ -6396,6 +6438,18 @@ def dragEnterEvent(self, event): else: event.ignore() + def get_pick_polygon_corners(self, pick): + """Returns X and Y coordinates of a pick polygon. + + Returns None, None if the pick is not a closed polygon.""" + + if len(pick) < 3 or pick[0] != pick[-1]: + return None, None + else: + X = [_[0] for _ in pick] + Y = [_[1] for _ in pick] + return X, Y + def get_pick_rectangle_corners( self, start_x, start_y, end_x, end_y, width ): @@ -6453,18 +6507,17 @@ def get_pick_rectangle_polygon( return p def draw_picks(self, image): - """ - Draws all current picks onto rendered locs. + """Draws all current picks onto rendered locs. Parameters ---------- image : QImage - Image containing rendered localizations + Image containing rendered localizations. Returns ------- QImage - Image with the drawn picks + Image with the drawn picks. """ image = image.copy() @@ -6552,6 +6605,40 @@ def draw_picks(self, image): if t_dialog.pick_annotation.isChecked(): painter.drawText(*most_right, str(i)) painter.end() + + # polygon - circles at the corners connected by lines + elif self._pick_shape == "Polygon": + painter = QtGui.QPainter(image) + painter.setPen(QtGui.QColor("yellow")) + + # yellow is barely visible on white background + if self.window.dataset_dialog.wbackground.isChecked(): + painter.setPen(QtGui.QColor("red")) + + # draw corners and lines + for i, pick in enumerate(self._picks): + oldpoint = [] + for point in pick: + cx, cy = self.map_to_view(*point) + painter.drawEllipse( + QtCore.QPoint(cx, cy), + int(POLYGON_POINTER_SIZE / 2), + int(POLYGON_POINTER_SIZE / 2), + ) + if oldpoint != []: # draw the line + ox, oy = self.map_to_view(*oldpoint) + painter.drawLine(cx, cy, ox, oy) + oldpoint = point + + # annotate picks + if len(pick): + if t_dialog.pick_annotation.isChecked(): + painter.drawText( + cx + int(POLYGON_POINTER_SIZE / 2) + 10, + cy + int(POLYGON_POINTER_SIZE / 2) + 10, + str(i), + ) + painter.end() return image def draw_rectangle_pick_ongoing(self, image): @@ -6956,7 +7043,7 @@ def move_to_pick(self): x_max = x + 1.4 * r y_min = y - 1.4 * r y_max = y + 1.4 * r - else: + elif self._pick_shape == "Rectangle": (xs, ys), (xe, ye) = self._picks[pick_no] xc = np.mean([xs, xe]) yc = np.mean([ys, ye]) @@ -6966,6 +7053,12 @@ def move_to_pick(self): x_max = max(X) + (0.2 * (max(X) - xc)) y_min = min(Y) - (0.2 * (yc - min(Y))) y_max = max(Y) + (0.2 * (max(Y) - yc)) + elif self._pick_shape == "Polygon": + X, Y = self.get_pick_polygon_corners(self._picks[pick_no]) + x_min = min(X) - 0.2 * (max(X) - min(X)) + x_max = max(X) + 0.2 * (max(X) - min(X)) + y_min = min(Y) - 0.2 * (max(Y) - min(Y)) + y_max = max(Y) + 0.2 * (max(Y) - min(Y)) viewport = [(y_min, x_min), (y_max, x_max)] self.update_scene(viewport=viewport) @@ -7224,6 +7317,8 @@ def load_picks(self, path): self.window.tools_settings_dialog.pick_width.setValue( regions["Width"] ) + elif loaded_shape == "Polygon": + self._picks = regions["Vertices"] else: raise ValueError("Unrecognized pick shape") @@ -7248,9 +7343,9 @@ def subtract_picks(self, path): Rectangular picks have not been implemented yet """ - if self._pick_shape == "Rectangle": + if self._pick_shape != "Circle": raise NotImplementedError( - "Subtracting picks not implemented for rectangle picks" + "Subtracting picks implemented for circular picks only." ) oldpicks = self._picks.copy() @@ -7280,7 +7375,7 @@ def subtract_picks(self, path): self.update_scene(picks_only=True) def map_to_movie(self, position): - """ Converts coordinates from display units to camera units. """ + """Converts coordinates from display units to camera units. """ x_rel = position.x() / self.width() x_movie = x_rel * self.viewport_width() + self.viewport[0][1] @@ -7289,7 +7384,7 @@ def map_to_movie(self, position): return x_movie, y_movie def map_to_view(self, x, y): - """ Converts coordinates from camera units to display units. """ + """Converts coordinates from camera units to display units. """ cx = self.width() * (x - self.viewport[0][1]) / self.viewport_width() cy = self.height() * (y - self.viewport[0][0]) / self.viewport_height() @@ -7447,6 +7542,14 @@ def mouseReleaseEvent(self, event): 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() elif self._mode == "Measure": if event.button() == QtCore.Qt.LeftButton: # add measure point @@ -7826,9 +7929,9 @@ def show_pick(self): Opens self.pick_message_box to display information. """ - if self._pick_shape == "Rectangle": + if self._pick_shape != "Circle": raise NotImplementedError( - "Not implemented for rectangular picks" + "Implemented for circular picks only." ) channel = self.get_channel3d("Select Channel") @@ -8445,6 +8548,40 @@ def get_index_blocks(self, channel, fast_render=False): if self.index_blocks[channel] is None or fast_render: self.index_locs(channel, fast_render=fast_render) return self.index_blocks[channel] + + @check_pick + def pick_areas(self): + """Finds the areas of all current picks in nm^2. + + Returns + ------- + areas : np.1darray + Areas of all picks. + """ + + if self._pick_shape == "Circle": + d = self.window.tools_settings_dialog.pick_diameter.value() + r = d / 2 + areas = np.ones(len(self._picks)) * np.pi * r ** 2 + elif self._pick_shape == "Rectangle": + w = self.window.tools_settings_dialog.pick_width.value() + areas = np.zeros(len(self._picks)) + for i, pick in enumerate(self._picks): + (xs, ys), (xe, ye) = pick + areas[i] = w * np.sqrt((xe - xs) ** 2 + (ye - ys) ** 2) + elif self._pick_shape == "Polygon": + areas = np.zeros(len(self._picks)) + for i, pick in enumerate(self._picks): + if len(pick) < 3 or pick[0] != pick[-1]: # not a closed polygon + areas[i] = 0 + continue + X, Y = self.get_pick_polygon_corners(pick) + areas[i] = lib.polygon_area(X, Y) + areas = areas[areas > 0] # remove open polygons + + pixelsize = self.window.display_settings_dlg.pixelsize.value() + areas *= pixelsize ** 2 + return areas @check_picks def pick_similar(self): @@ -8461,9 +8598,9 @@ def pick_similar(self): If pick shape is rectangle """ - if self._pick_shape == "Rectangle": + if self._pick_shape != "Circle": raise NotImplementedError( - "Pick similar not implemented for rectangle picks" + "Pick similar implemented for circular picks only." ) channel = self.get_channel("Pick similar") if channel is not None: @@ -8621,6 +8758,29 @@ def picked_locs( group_locs.sort(kind="mergesort", order="frame") picked_locs.append(group_locs) progress.set_value(i + 1) + elif self._pick_shape == "Polygon": + if fast_render: + channel_locs = self.locs[channel] + else: + channel_locs = self.all_locs[channel] + for i, pick in enumerate(self._picks): + X, Y = self.get_pick_polygon_corners(pick) + if X is None: + progress.set_value(i + 1) + continue + group_locs = channel_locs[channel_locs.x > min(X)] + group_locs = group_locs[group_locs.x < max(X)] + group_locs = group_locs[group_locs.y > min(Y)] + group_locs = group_locs[group_locs.y < max(Y)] + group_locs = lib.locs_in_polygon(group_locs, X, Y) + if add_group: + group = i * np.ones(len(group_locs), dtype=np.int32) + group_locs = lib.append_to_rec( + group_locs, group, "group" + ) + group_locs.sort(kind="mergesort", order="frame") + picked_locs.append(group_locs) + progress.set_value(i + 1) return picked_locs @@ -8714,6 +8874,28 @@ def _remove_picked_locs(self, channel): self.window.fast_render_dialog.sample_locs() self.update_scene() + def remove_polygon_point(self): + """Removes the last point from the last polygon, if there is + only one point, the whole polygon is removed.""" + + if len(self._picks) == 0: + return + else: # if a polygon is present + # if no point are present in the last polygon, remove the + # point from the last polygon + if len(self._picks[-1]) == 0: + self._picks.pop() # remove the last polygon + # remove the last point of the previous polygon: + if len(self._picks): + self._picks[-1].pop() + # if there is only one point, remove the polygon + elif len(self._picks[-1]) == 1: + self._picks.pop() + else: # remove the last point only + self._picks[-1].pop() + self.update_pick_info_short() + self.update_scene(picks_only=True) + def remove_points(self): """ Removes all distance measurement points. """ @@ -9157,6 +9339,9 @@ def save_pick_properties(self, path, channel): pick_props = postprocess.groupprops( out_locs, callback=progress.set_value ) + # add the area of the picks to the properties + areas = self.pick_areas() + pick_props = lib.append_to_rec(pick_props, areas, "pick_area_nm2") progress.close() # QPAINT estimate of number of binding sites n_units = self.window.info_dialog.calculate_n_units(dark) @@ -9180,23 +9365,29 @@ def save_picks(self, path): Path for saving pick regions """ + picks = {} if self._pick_shape == "Circle": d = self.window.tools_settings_dialog.pick_diameter.value() - picks = { - "Diameter": float(d), - "Centers": [[float(_[0]), float(_[1])] for _ in self._picks], - } + picks["Diameter"] = float(d) + picks["Centers"] = [[float(_[0]), float(_[1])] for _ in self._picks] elif self._pick_shape == "Rectangle": w = self.window.tools_settings_dialog.pick_width.value() - picks = { - "Width": float(w), - "Center-Axis-Points": [ - [ - [float(s[0]), float(s[1])], - [float(e[0]), float(e[1])], - ] for s, e in self._picks - ], - } + picks["Width"] = float(w) + picks["Center-Axis-Points"] = [ + [ + [float(s[0]), float(s[1])], + [float(e[0]), float(e[1])], + ] for s, e in self._picks + ] + elif self._pick_shape == "Polygon": + vertices = [] + for pick in self._picks: + # vertices.append([]) + if len(pick): + vertices.append([]) + for vertex in pick: + vertices[-1].append([float(vertex[0]), float(vertex[1])]) + picks["Vertices"] = vertices picks["Shape"] = self._pick_shape with open(path, "w") as f: yaml.dump(picks, f) @@ -9422,7 +9613,7 @@ def set_mode(self, action): def on_pick_shape_changed(self, pick_shape_index): """ - If new shape is chosen, asks user to delete current picks, + If a new shape is chosen, asks user to delete current picks, assigns attributes and updates scene. Parameters @@ -9926,9 +10117,9 @@ def unfold_groups(self): groups = np.unique(self.all_locs[0].group) if self._picks: - if self._pick_shape == "Rectangle": + if self._pick_shape != "Circle": raise NotImplementedError( - "Unfolding not implemented for rectangle picks" + "Unfolding implemented for circular picks only." ) for j in range(len(self._picks)): for i in range(len(groups) - 1): @@ -10004,9 +10195,9 @@ def unfold_groups_square(self): self.all_locs[0].y += offset_y if self._picks: - if self._pick_shape == "Rectangle": + if self._pick_shape != "Circle": raise NotImplementedError( - "Not implemented for rectangle picks" + "Implemented for circular picks only." ) # Also unfold picks groups = np.unique(self.all_locs[0].group) @@ -10084,6 +10275,20 @@ def update_cursor(self): self.unsetCursor() elif self._pick_shape == "Rectangle": self.unsetCursor() + elif self._pick_shape == "Polygon": + diameter = POLYGON_POINTER_SIZE + pixmap_size = ceil(diameter) + 1 + pixmap = QtGui.QPixmap(pixmap_size, pixmap_size) + pixmap.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(pixmap) + painter.setPen(QtGui.QColor("white")) + if self.window.dataset_dialog.wbackground.isChecked(): + painter.setPen(QtGui.QColor("black")) + offset = int((pixmap_size - diameter) / 2) + painter.drawEllipse(offset, offset, diameter, diameter) + painter.end() + cursor = QtGui.QCursor(pixmap) + self.setCursor(cursor) def update_pick_info_long(self): """ Called when evaluating picks statistics in Info Dialog. """ @@ -10093,8 +10298,8 @@ def update_pick_info_long(self): QtWidgets.QMessageBox.information(self, "Warning", warning) return - if self._pick_shape == "Rectangle": - warning = "Not supported for rectangular picks." + if self._pick_shape != "Circle": + warning = "Supported for circular picks only." QtWidgets.QMessageBox.information(self, "Warning", warning) return @@ -12059,10 +12264,14 @@ def remove_locs(self): def rot_win(self): """ Opens/updates RotationWindow. """ - if len(self.view._picks) == 0: - raise ValueError("Pick a region to rotate.") - elif len(self.view._picks) > 1: - raise ValueError("Pick only one region.") + if self.view._pick_shape == "Polygon": + if len(self.view._picks) != 2 or len(self.view._picks[1]): + raise ValueError("Pick only one region.") + else: + if len(self.view._picks) == 0: + raise ValueError("Pick a region to rotate.") + elif len(self.view._picks) > 1: + raise ValueError("Pick only one region.") self.window_rot.view_rot.load_locs(update_window=True) self.window_rot.show() self.window_rot.view_rot.update_scene(autoscale=True) diff --git a/picasso/gui/rotation.py b/picasso/gui/rotation.py index 96858449..b4a8a96a 100644 --- a/picasso/gui/rotation.py +++ b/picasso/gui/rotation.py @@ -862,9 +862,13 @@ def load_locs(self, update_window=False): if self.pick_shape == "Circle": self.pick_size = self.window.window. \ tools_settings_dialog.pick_diameter.value() - else: + elif self.pick_shape == "Rectangle": self.pick_size = self.window.window. \ tools_settings_dialog.pick_width.value() + elif self.pick_shape == "Polygon": + self.pick_size = None + else: + print("This should never happen.") # update view, dataset_dialog for multichannel data and # paths @@ -1521,7 +1525,7 @@ def fit_in_view_rotated(self, get_viewport=False): x_max = x + r y_min = y - r y_max = y + r - else: + elif self.pick_shape == "Rectangle": w = self.pick_size (xs, ys), (xe, ye) = self.pick X, Y = self.window.window.view.get_pick_rectangle_corners( @@ -1531,6 +1535,12 @@ def fit_in_view_rotated(self, get_viewport=False): x_max = max(X) y_min = min(Y) y_max = max(Y) + elif self.pick_shape == "Polygon": + X, Y = self.window.window.view.get_pick_polygon_corners(self.pick) + x_min = min(X) + x_max = max(X) + y_min = min(Y) + y_max = max(Y) viewport = [(y_min, x_min), (y_max, x_max)] if get_viewport: @@ -2273,7 +2283,7 @@ def move_pick(self, dx, dy): y = self.window.view._picks[0][1] self.window.view._picks = [(x + dx, y + dy)] # main window self.view_rot.pick = (x + dx, y + dy) # view rotation - else: # rectangle + elif self.view_rot.pick_shape == "Rectangle": (xs, ys), (xe, ye) = self.window.view._picks[0] self.window.view._picks = [( (xs + dx, ys + dy), @@ -2283,6 +2293,13 @@ def move_pick(self, dx, dy): (xs + dx, ys + dy), (xe + dx, ye + dy), ) # view rotation + elif self.view_rot.pick_shape == "Polygon": + new_pick = [] + for point in self.window.view._picks[0]: + new_pick.append((point[0] + dx, point[1] + dy)) + self.window.view._picks = [new_pick] + [] # main window + self.view_rot.pick = new_pick # view rotation + self.window.view.update_scene() # update scene in main window def save_channel_multi(self, title="Choose a channel"): diff --git a/picasso/lib.py b/picasso/lib.py index 449803cc..d981c5e4 100644 --- a/picasso/lib.py +++ b/picasso/lib.py @@ -157,6 +157,97 @@ def locs_at(x, y, locs, r): return locs[is_picked] +def polygon_area(X, Y): + """Finds the area of a polygon defined by corners X and Y. + + Parameters + ---------- + X : numpy.1darray + x-coordinates of the polygon corners. + Y : numpy.1darray + y-coordinates of the polygon corners. + + Returns + ------- + area : float + Area of the polygon. + """ + + n_corners = len(X) + area = 0 + for i in range(n_corners): + j = (i + 1) % n_corners # next corner + area += X[i] * Y[j] - X[j] * Y[i] + area = abs(area) / 2 + return area + + +@_numba.jit(nopython=True) +def check_if_in_polygon(x, y, X, Y): + """Checks if points (x, y) are in polygon defined by corners (X, Y). + Uses the ray casting algorithm, see check_if_in_rectangle for + details. + + Parameters + ---------- + x : numpy.1darray + x-coordinates of points. + y : numpy.1darray + y-coordinates of points. + X : numpy.1darray + x-coordinates of polygon corners. + Y : numpy.1darray + y-coordinates of polygon corners. + + Returns + ------- + is_in_polygon : numpy.ndarray + Boolean array indicating if point is in polygon. + """ + + n_locs = len(x) + n_polygon = len(X) + is_in_polygon = _np.zeros(n_locs, dtype=_np.bool_) + + for i in range(n_locs): + count = 0 + for j in range(n_polygon): + j_next = (j + 1) % n_polygon + if ( + ((Y[j] > y[i]) != (Y[j_next] > y[i])) and + (x[i] < X[j] + (X[j_next] - X[j]) * (y[i] - Y[j]) / (Y[j_next] - Y[j])) + ): + count += 1 + if count % 2 == 1: + is_in_polygon[i] = True + + return is_in_polygon + + +def locs_in_polygon(locs, X, Y): + """Returns localizations in polygon defined by corners (X, Y). + + Parameters + ---------- + locs : numpy.recarray + Localizations. + X : list + x-coordinates of polygon corners. + Y : list + y-coordinates of polygon corners. + + Returns + ------- + picked_locs : numpy.recarray + Localizations in polygon. + """ + + is_in_polygon = check_if_in_polygon( + locs.x, locs.y, _np.array(X), _np.array(Y) + ) + return locs[is_in_polygon] + + @_numba.jit(nopython=True) def check_if_in_rectangle(x, y, X, Y): """ @@ -164,7 +255,24 @@ def check_if_in_rectangle(x, y, X, Y): by counting the number of rectangle sides which are hit by a ray originating from each loc to the right. If the number of hit rectangle sides is odd, then the loc is in the rectangle + + Parameters + ---------- + x : numpy.1darray + x-coordinates of points. + y : numpy.1darray + y-coordinates of points. + X : numpy.1darray + x-coordinates of polygon corners. + Y : numpy.1darray + y-coordinates of polygon corners. + + Returns + ------- + is_in_polygon : numpy.ndarray + Boolean array indicating if point is in polygon. """ + n_locs = len(x) ray_hits_rectangle_side = _np.zeros((n_locs, 4)) for i in range(4): @@ -194,7 +302,9 @@ def check_if_in_rectangle(x, y, X, Y): def locs_in_rectangle(locs, X, Y): - is_in_rectangle = check_if_in_rectangle(locs.x, locs.y, _np.array(X), _np.array(Y)) + is_in_rectangle = check_if_in_rectangle( + locs.x, locs.y, _np.array(X), _np.array(Y) + ) return locs[is_in_rectangle] diff --git a/picasso/localize.py b/picasso/localize.py index 58e72de9..89de078c 100755 --- a/picasso/localize.py +++ b/picasso/localize.py @@ -203,7 +203,7 @@ def identify_async(movie, minimum_ng, box, roi=None): _io.save_user_settings(settings) n_workers = min( - 60, max(1, int(0.75 * _multiprocessing.cpu_count())) + 60, max(1, int(cpu_utilization * _multiprocessing.cpu_count())) ) # Python crashes when using >64 cores lock = _threading.Lock() diff --git a/picasso/zfit.py b/picasso/zfit.py index cbe75652..e61f3bd3 100644 --- a/picasso/zfit.py +++ b/picasso/zfit.py @@ -59,6 +59,8 @@ def calibrate_z(locs, info, d, magnification_factor, path=None): 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), } if path is not None: with open(path, "w") as f: diff --git a/release/one_click_windows_gui/create_installer_windows.bat b/release/one_click_windows_gui/create_installer_windows.bat index 8643aa27..dcdb36f7 100644 --- a/release/one_click_windows_gui/create_installer_windows.bat +++ b/release/one_click_windows_gui/create_installer_windows.bat @@ -11,7 +11,7 @@ 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.6.8-py3-none-any.whl" +call pip install "../../dist/picassosr-0.6.9-py3-none-any.whl" call pip install pyinstaller==5.7 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 8a526800..23c55198 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.6.8 +AppVersion=0.6.9 DefaultDirName={commonpf}\Picasso DefaultGroupName=Picasso -OutputBaseFilename="Picasso-Windows-64bit-0.6.8" +OutputBaseFilename="Picasso-Windows-64bit-0.6.9" ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 diff --git a/setup.py b/setup.py index bf8fab2e..36ac9688 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name="picassosr", - version="0.6.8", + version="0.6.9", 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",